mirror of
https://github.com/zotero/zotero.git
synced 2026-08-28 05:25:31 +00:00
Compare commits
757 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d1d32a211 | ||
|
|
c2052cfff1 | ||
|
|
61174e2158 | ||
|
|
e29d8f151e | ||
|
|
8e4ec83abd | ||
|
|
3b93d33b35 | ||
|
|
0c9ba2d05c | ||
|
|
b6efbe880e | ||
|
|
ff93139cce | ||
|
|
4a250cd20f | ||
|
|
d153397151 | ||
|
|
5d20c692e8 | ||
|
|
a1ea7037c2 | ||
|
|
6d8198bbfa | ||
|
|
56eb77b704 | ||
|
|
e69ee69218 | ||
|
|
baa47e3bb9 | ||
|
|
753dbf557a | ||
|
|
786cdea884 | ||
|
|
f2a42bec15 | ||
|
|
070ae8b615 | ||
|
|
e8055dfdf2 | ||
|
|
9e45191b22 | ||
|
|
7243648ed7 | ||
|
|
85e1cbc75c | ||
|
|
ad98e84d24 | ||
|
|
77a3a8815e | ||
|
|
766cb079a0 | ||
|
|
0ee3e4052e | ||
|
|
0746c665a9 | ||
|
|
73f4273e2d | ||
|
|
04796dff22 | ||
|
|
5f978c71ca | ||
|
|
c220866d9a | ||
|
|
6ce33bcd81 | ||
|
|
f5a2985a34 | ||
|
|
cdb13af13b | ||
|
|
32640e2019 | ||
|
|
22b3daf845 | ||
|
|
aa49b48915 | ||
|
|
c7cffdd26c | ||
|
|
da705c36ec | ||
|
|
b930ddb863 | ||
|
|
847dffbf18 | ||
|
|
96972db8c6 | ||
|
|
dac1ad489f | ||
|
|
ed8049dffc | ||
|
|
e1759daa00 | ||
|
|
4e532a17a2 | ||
|
|
ad1bec62f2 | ||
|
|
18f6fa50d5 | ||
|
|
db1216d3e2 | ||
|
|
b6837a3a57 | ||
|
|
bba85a3939 | ||
|
|
5757396197 | ||
|
|
5a6c9245f0 | ||
|
|
844bd87824 | ||
|
|
e0f9710c46 | ||
|
|
b8fdee865a | ||
|
|
29a4d9d140 | ||
|
|
472a0bf856 | ||
|
|
68bec97114 | ||
|
|
4a6a8220ff | ||
|
|
f53d4b5eca | ||
|
|
1d6158dfd8 | ||
|
|
5df473cf96 | ||
|
|
e7767c5bfa | ||
|
|
8d01216e5c | ||
|
|
8ac1273acb | ||
|
|
399a63ade2 | ||
|
|
d9d52de516 | ||
|
|
a9e765e37f | ||
|
|
fdec96911c | ||
|
|
ad7159834c | ||
|
|
e5508e870c | ||
|
|
896582ab32 | ||
|
|
c1cdd038a2 | ||
|
|
fe35127f50 | ||
|
|
b8cbdc8c95 | ||
|
|
04321fc627 | ||
|
|
3630b904d7 | ||
|
|
646fbfae65 | ||
|
|
46603ca4eb | ||
|
|
1d279fba5b | ||
|
|
45867cea54 | ||
|
|
c87328ada9 | ||
|
|
8f6dc583b8 | ||
|
|
3af8cea1af | ||
|
|
09249bfb73 | ||
|
|
8fb2870f93 | ||
|
|
9da57a9fe3 | ||
|
|
b83342d8e0 | ||
|
|
2f90893e59 | ||
|
|
8052b8f9e2 | ||
|
|
aaeb9c6c9a | ||
|
|
adc5648d29 | ||
|
|
300305a9fb | ||
|
|
97de8858fe | ||
|
|
770f44568c | ||
|
|
a2a419c6da | ||
|
|
0ae2e42ca2 | ||
|
|
1dffe21de0 | ||
|
|
d5aacafe3f | ||
|
|
ea30bbd2af | ||
|
|
72115c3cb4 | ||
|
|
6f3e4b5499 | ||
|
|
e4ee85296c | ||
|
|
b5ceb6fa65 | ||
|
|
35a70117b0 | ||
|
|
030cd3dc5e | ||
|
|
503bb03aec | ||
|
|
3430ad241b | ||
|
|
00cad2ced0 | ||
|
|
2bd9342188 | ||
|
|
d43dc2fa37 | ||
|
|
78d21617af | ||
|
|
7793b469c9 | ||
|
|
8110ab4eea | ||
|
|
45e38322e1 | ||
|
|
2eba3398e9 | ||
|
|
01b7d0667c | ||
|
|
a32dd2b959 | ||
|
|
6fbe5e4135 | ||
|
|
567cc2d43a | ||
|
|
f245f5c2b2 | ||
|
|
bcd8ad20ec | ||
|
|
eac3838f00 | ||
|
|
bca6537e6d | ||
|
|
5ea6243384 | ||
|
|
3bb3b4f2c9 | ||
|
|
1c005c7e21 | ||
|
|
14b43c9559 | ||
|
|
b7232f130f | ||
|
|
c192127ab1 | ||
|
|
d701c43862 | ||
|
|
06848ce17f | ||
|
|
408179ad09 | ||
|
|
242bbccda5 | ||
|
|
fdd3434349 | ||
|
|
c9d5aa3c8e | ||
|
|
d54327a045 | ||
|
|
b5ea455c1f | ||
|
|
9c03a93e00 | ||
|
|
a7cae87be8 | ||
|
|
4ec5ba9c27 | ||
|
|
7abcf23ef7 | ||
|
|
8bebaf0ead | ||
|
|
80658e928e | ||
|
|
4388f6da20 | ||
|
|
9ed00c843d | ||
|
|
08880f7a0e | ||
|
|
c39f16891f | ||
|
|
efcfaec5d4 | ||
|
|
dc6d55a137 | ||
|
|
783da0e66e | ||
|
|
1d97f6448e | ||
|
|
5d1a2cba32 | ||
|
|
e45871fa9d | ||
|
|
c92c75eb36 | ||
|
|
3da869b2e6 | ||
|
|
85a33e1586 | ||
|
|
3bf74a3e46 | ||
|
|
ae98136ec8 | ||
|
|
2fb8d69a93 | ||
|
|
681c48f1c2 | ||
|
|
caeffa9f41 | ||
|
|
b66519cb4c | ||
|
|
db5e7edb49 | ||
|
|
7925b4e9ac | ||
|
|
93d66e3999 | ||
|
|
938c414e0c | ||
|
|
8526a022ec | ||
|
|
75b78b410b | ||
|
|
a37a9e782a | ||
|
|
77f2432103 | ||
|
|
9dd17a212e | ||
|
|
12114f6a86 | ||
|
|
ce20a5f228 | ||
|
|
91d7e73d26 | ||
|
|
566115dc7c | ||
|
|
512a2444d8 | ||
|
|
9eea7d9a0c | ||
|
|
3a54a5ecb1 | ||
|
|
0b32e93cb1 | ||
|
|
a81af0c268 | ||
|
|
ea4b301f73 | ||
|
|
4f825ad50c | ||
|
|
6fb30346e6 | ||
|
|
a4b39b91f3 | ||
|
|
146520aed5 | ||
|
|
cddb19bf36 | ||
|
|
e81e72dccf | ||
|
|
40974e493d | ||
|
|
621c0fbb91 | ||
|
|
ee68452b05 | ||
|
|
5877952954 | ||
|
|
ccb592ba68 | ||
|
|
ddcdea3795 | ||
|
|
14c6167a4c | ||
|
|
5a785c4e0f | ||
|
|
ccbeab61e2 | ||
|
|
59c1801a10 | ||
|
|
0dc256166a | ||
|
|
8437de1a4f | ||
|
|
4a7c359ae2 | ||
|
|
f171e7b5d8 | ||
|
|
c233fcf5f0 | ||
|
|
30b5bf4bb9 | ||
|
|
d739fed03e | ||
|
|
bcfa43b7e9 | ||
|
|
0ce289a7fe | ||
|
|
228799774f | ||
|
|
f8f83cceac | ||
|
|
10b1dbb785 | ||
|
|
994d557e92 | ||
|
|
fb20a84ab7 | ||
|
|
a2c7e0c00e | ||
|
|
6c5d160895 | ||
|
|
d3b04e7748 | ||
|
|
404fb4c3ae | ||
|
|
d78cceac71 | ||
|
|
cae3ab6160 | ||
|
|
73c45f8ec9 | ||
|
|
ea2ee7391d | ||
|
|
02fb0e92ed | ||
|
|
3ec407d861 | ||
|
|
5ade25f5f5 | ||
|
|
7c2a1d127d | ||
|
|
e2536ba299 | ||
|
|
ae46954167 | ||
|
|
da88c63231 | ||
|
|
984c0a7f8c | ||
|
|
3bd8d641b8 | ||
|
|
d0f8d438b5 | ||
|
|
cf09a0547c | ||
|
|
c6595d6456 | ||
|
|
4f45db5009 | ||
|
|
933e207013 | ||
|
|
0ba89211ce | ||
|
|
bdea584a17 | ||
|
|
1a5e32a7b6 | ||
|
|
142f77b094 | ||
|
|
6e35bd1497 | ||
|
|
2c6185d8f1 | ||
|
|
89880e3284 | ||
|
|
5a8badae7b | ||
|
|
28867883a1 | ||
|
|
cdb9134537 | ||
|
|
7cdd74bd2b | ||
|
|
24fe50cfd5 | ||
|
|
c181aac7f6 | ||
|
|
582333397d | ||
|
|
5fe4c901ed | ||
|
|
8eb71a588d | ||
|
|
dea2fb8491 | ||
|
|
6225931f93 | ||
|
|
540735785d | ||
|
|
4188ef0ebc | ||
|
|
ccc7d0a5ad | ||
|
|
3a34c6a18f | ||
|
|
a6c3ad51ef | ||
|
|
1e0cf49a16 | ||
|
|
cc28fad6da | ||
|
|
d95d8f294d | ||
|
|
c43f028c96 | ||
|
|
8daed576ba | ||
|
|
2c62010c4c | ||
|
|
0a67f4967c | ||
|
|
64165eabf6 | ||
|
|
243ef0e28f | ||
|
|
606d8f19ba | ||
|
|
03a610e696 | ||
|
|
c3787495cc | ||
|
|
e1f19f34f5 | ||
|
|
1c9bf88888 | ||
|
|
39a1c56f6c | ||
|
|
d07dd66c21 | ||
|
|
3ae3bd41dc | ||
|
|
4314494455 | ||
|
|
f9e711a9b2 | ||
|
|
77a3cd82a9 | ||
|
|
49e284bab0 | ||
|
|
7d33215a37 | ||
|
|
dfd745c5d5 | ||
|
|
1fe9cad5f8 | ||
|
|
62a431f42b | ||
|
|
4c392c4764 | ||
|
|
bd25413213 | ||
|
|
094d5cc2b6 | ||
|
|
225e91c5e7 | ||
|
|
2b7f46227f | ||
|
|
55eceab3b3 | ||
|
|
e2db3c7966 | ||
|
|
cddf0ea237 | ||
|
|
057966000c | ||
|
|
e416ea2dfa | ||
|
|
2f364f2fab | ||
|
|
a48336350a | ||
|
|
af704dc119 | ||
|
|
26c30d8ecd | ||
|
|
95ee1c616c | ||
|
|
40b272c46b | ||
|
|
1d8cdb13c1 | ||
|
|
163c27ca79 | ||
|
|
d28bdd8d6e | ||
|
|
279e25c57e | ||
|
|
13bd8c9d8d | ||
|
|
7f30b0e47d | ||
|
|
f3328b02ee | ||
|
|
9cffc1c7c2 | ||
|
|
36d025f6a9 | ||
|
|
2d56936bfe | ||
|
|
2006cdac6d | ||
|
|
02b19d0cde | ||
|
|
340ba92513 | ||
|
|
6c7c661fb1 | ||
|
|
a1f36f750e | ||
|
|
fe340283c1 | ||
|
|
0e587176e8 | ||
|
|
d4507544af | ||
|
|
98308c65b9 | ||
|
|
f7e67f6643 | ||
|
|
cf7ee984ea | ||
|
|
3875f411e5 | ||
|
|
680452cc59 | ||
|
|
3544848e5f | ||
|
|
b56c153125 | ||
|
|
2717aafedd | ||
|
|
cb2110e1c1 | ||
|
|
2239dd20a4 | ||
|
|
60807d552c | ||
|
|
ccf6f18643 | ||
|
|
786b85bf2d | ||
|
|
0529c9a579 | ||
|
|
092295da22 | ||
|
|
8b5a77a75e | ||
|
|
1341b6d5cc | ||
|
|
cdc70d1280 | ||
|
|
5904f0e4bf | ||
|
|
d808988540 | ||
|
|
c01def2a85 | ||
|
|
9a4317daa7 | ||
|
|
8f96a6d191 | ||
|
|
aab912e4ee | ||
|
|
d7660f2f27 | ||
|
|
962e1334c8 | ||
|
|
40cb1cfa84 | ||
|
|
35317982f5 | ||
|
|
bdf79336f9 | ||
|
|
e71bd89e0d | ||
|
|
c92148c3ec | ||
|
|
8d7f5a39f9 | ||
|
|
ff094330f5 | ||
|
|
4a318617d6 | ||
|
|
6092b0335c | ||
|
|
35c8448582 | ||
|
|
e37a7d8f06 | ||
|
|
2e79bbe402 | ||
|
|
e38f8f78b0 | ||
|
|
90aa5d33a0 | ||
|
|
f4adb452ee | ||
|
|
08d875c7f7 | ||
|
|
6d1cd85211 | ||
|
|
39954dc437 | ||
|
|
20fd5fe3e2 | ||
|
|
618fcfa49d | ||
|
|
1c76195a7c | ||
|
|
94c78759ab | ||
|
|
b80fa996f5 | ||
|
|
15c2c95470 | ||
|
|
2ddb4e991b | ||
|
|
e23a453e5c | ||
|
|
4d38970ef0 | ||
|
|
e6f82dabfd | ||
|
|
cf876033f6 | ||
|
|
f03004bb9e | ||
|
|
80ca898a74 | ||
|
|
7eef2fb3f0 | ||
|
|
22055d92b7 | ||
|
|
39d7835472 | ||
|
|
e41c788c4c | ||
|
|
580e423d4c | ||
|
|
27560d9476 | ||
|
|
81b3c13b13 | ||
|
|
a3180d87c5 | ||
|
|
8ed617ae53 | ||
|
|
97aa4f93ea | ||
|
|
3d334dc244 | ||
|
|
18d332ec5e | ||
|
|
702094f703 | ||
|
|
40851e1f58 | ||
|
|
2689997c50 | ||
|
|
00527332c4 | ||
|
|
d2f1c56250 | ||
|
|
cd29a818b0 | ||
|
|
0f2d3e103d | ||
|
|
40f949ba94 | ||
|
|
c72d80f22b | ||
|
|
cd39445b94 | ||
|
|
632f11db74 | ||
|
|
55434d14a1 | ||
|
|
98d82d2909 | ||
|
|
982c00aaf6 | ||
|
|
06e16c297c | ||
|
|
de1bf1cec1 | ||
|
|
4e3baf09a0 | ||
|
|
fde4086e08 | ||
|
|
3e7030a642 | ||
|
|
fa3e0f683f | ||
|
|
dcf5010415 | ||
|
|
31d9e89165 | ||
|
|
d0507e02c8 | ||
|
|
31b3ead557 | ||
|
|
615ba3c178 | ||
|
|
e029354a35 | ||
|
|
87901c1c21 | ||
|
|
8cf5c9b3d1 | ||
|
|
c838e9b29e | ||
|
|
8277277948 | ||
|
|
786960079b | ||
|
|
70c4cadab1 | ||
|
|
bf8c6f2027 | ||
|
|
cfd5596814 | ||
|
|
a692ca3493 | ||
|
|
9ca79a2cb1 | ||
|
|
d2631cc614 | ||
|
|
ad4caa8688 | ||
|
|
37bf9d0965 | ||
|
|
f81e7cb5da | ||
|
|
8f44172342 | ||
|
|
b7437faf55 | ||
|
|
c2a9ea2687 | ||
|
|
5c232d65c0 | ||
|
|
596882980d | ||
|
|
714d4416f8 | ||
|
|
ba7e036582 | ||
|
|
431e1dabde | ||
|
|
789c83249c | ||
|
|
504447de41 | ||
|
|
ddf2419e78 | ||
|
|
d66682b04e | ||
|
|
fda72a3434 | ||
|
|
575dc5351c | ||
|
|
4b2c0518f1 | ||
|
|
99228a6c76 | ||
|
|
ede90ac04a | ||
|
|
fc37e524fe | ||
|
|
8d59331d43 | ||
|
|
3c53118f2c | ||
|
|
ef7896ab5f | ||
|
|
178f162449 | ||
|
|
a7d001fc1d | ||
|
|
36e25d171a | ||
|
|
a57cb6276f | ||
|
|
373accce3b | ||
|
|
66dc97de33 | ||
|
|
03d94060b4 | ||
|
|
13cb8d99b6 | ||
|
|
82b31574a8 | ||
|
|
bc2ede0459 | ||
|
|
2e19017d0e | ||
|
|
2f38926da5 | ||
|
|
5dc817db44 | ||
|
|
fd812070b6 | ||
|
|
e13e85b2e5 | ||
|
|
7ca8242a3e | ||
|
|
a43d6a5d37 | ||
|
|
1ff2f640eb | ||
|
|
462357e49e | ||
|
|
777d01b5c2 | ||
|
|
5ca1fbb167 | ||
|
|
6b42f9f9c0 | ||
|
|
d7920e320b | ||
|
|
f0474fa250 | ||
|
|
df6037ce0a | ||
|
|
24e16c0aad | ||
|
|
2d14f33de2 | ||
|
|
de69835419 | ||
|
|
0b15b794e6 | ||
|
|
e7ed0b2881 | ||
|
|
a487dd9eef | ||
|
|
9abd13fd22 | ||
|
|
e1cd83214f | ||
|
|
fa9a54b773 | ||
|
|
a835136d3b | ||
|
|
89ee4c21c0 | ||
|
|
26816b03e2 | ||
|
|
07906931c1 | ||
|
|
c52a6151dc | ||
|
|
ce424c6b67 | ||
|
|
cc76207a23 | ||
|
|
32e43cb8f1 | ||
|
|
00ece3db68 | ||
|
|
0fe31b0f04 | ||
|
|
45596e70e9 | ||
|
|
11c75ae6e5 | ||
|
|
c2c66795d2 | ||
|
|
55672ba933 | ||
|
|
488fd29978 | ||
|
|
d2d4451165 | ||
|
|
a9fe8e3b62 | ||
|
|
acfb7c7915 | ||
|
|
7334080693 | ||
|
|
c89abbc5be | ||
|
|
676d609d0d | ||
|
|
ad4bb17db9 | ||
|
|
683ecf1620 | ||
|
|
56ef61e45b | ||
|
|
f20eb5b566 | ||
|
|
62815bdd00 | ||
|
|
1ed1fab55b | ||
|
|
16b63a97dd | ||
|
|
b27c4cb023 | ||
|
|
e22e2cd9dd | ||
|
|
3e04f5e3e7 | ||
|
|
a0198e2571 | ||
|
|
e96d65f0ed | ||
|
|
1a89239a13 | ||
|
|
a1baa68006 | ||
|
|
f82efc766d | ||
|
|
ea230faa26 | ||
|
|
c3d8058794 | ||
|
|
117fa1dddb | ||
|
|
4b14ca9af4 | ||
|
|
91e82566ff | ||
|
|
70047b6fd6 | ||
|
|
5671a724e8 | ||
|
|
a3aa2bb5c7 | ||
|
|
3f0de027a2 | ||
|
|
4398a2bcee | ||
|
|
67288047f3 | ||
|
|
b11d776b36 | ||
|
|
92c876e47d | ||
|
|
d5ee350081 | ||
|
|
5a347f4be5 | ||
|
|
a602d21258 | ||
|
|
7f9fdc0786 | ||
|
|
2d88a3f181 | ||
|
|
0ff3ec6a1b | ||
|
|
c2ce0af87a | ||
|
|
92db62df0a | ||
|
|
862573eabc | ||
|
|
41b5442952 | ||
|
|
c6a95fd5b7 | ||
|
|
842115ff7e | ||
|
|
8effdacf65 | ||
|
|
d2a01d02f8 | ||
|
|
6f5f221db0 | ||
|
|
df18ac4e1f | ||
|
|
0e7ed13a43 | ||
|
|
f3ecf285b3 | ||
|
|
293cd25fb6 | ||
|
|
5f942684b6 | ||
|
|
2ad4618efb | ||
|
|
fb8389d654 | ||
|
|
391e8497a6 | ||
|
|
1267440187 | ||
|
|
16380e4a19 | ||
|
|
6140088b0c | ||
|
|
1d437d88f0 | ||
|
|
8a0a798672 | ||
|
|
0e8b195c5a | ||
|
|
8167c903f3 | ||
|
|
322b764091 | ||
|
|
5ac56ae717 | ||
|
|
2522be0c17 | ||
|
|
a3909af5ab | ||
|
|
6998235a31 | ||
|
|
81591eb890 | ||
|
|
6bec3c817e | ||
|
|
587d5cda7e | ||
|
|
351860273f | ||
|
|
5823247df9 | ||
|
|
d21a5d9cad | ||
|
|
763330d1f4 | ||
|
|
6fa5c4b3e3 | ||
|
|
fd7cc84f6b | ||
|
|
5bceea7b7b | ||
|
|
46ba21fe75 | ||
|
|
8af91b0147 | ||
|
|
e629a3c456 | ||
|
|
6e5738de0f | ||
|
|
518056f9e7 | ||
|
|
ba3c3a506f | ||
|
|
e28c97dc56 | ||
|
|
7b17187a1a | ||
|
|
eb3a136ccd | ||
|
|
0ade1b9ed5 | ||
|
|
98d911f6b1 | ||
|
|
883747e372 | ||
|
|
35713fefd0 | ||
|
|
3ebd26acc0 | ||
|
|
5dc9705294 | ||
|
|
cf7fcd751f | ||
|
|
1d31ed2508 | ||
|
|
1502214b5d | ||
|
|
1f702fc5dc | ||
|
|
c9f893a5a2 | ||
|
|
376fba55b6 | ||
|
|
8401b83cb6 | ||
|
|
1fad46f983 | ||
|
|
5b5b70b499 | ||
|
|
489a46564f | ||
|
|
f0af0bec9d | ||
|
|
07d99d891f | ||
|
|
aaed7ddfc0 | ||
|
|
288fa8e401 | ||
|
|
085a6fa5e0 | ||
|
|
d20cac39c9 | ||
|
|
34039991f8 | ||
|
|
0ce5cd5bac | ||
|
|
08c75006f3 | ||
|
|
290d44480e | ||
|
|
43f422a989 | ||
|
|
a9342cc242 | ||
|
|
426d681b65 | ||
|
|
fb4ac78291 | ||
|
|
fa476245fe | ||
|
|
518e7d22a0 | ||
|
|
3328557d00 | ||
|
|
259abb15f5 | ||
|
|
d3509ae259 | ||
|
|
be1373dd3a | ||
|
|
fcce3bb21d | ||
|
|
f130e3b3e5 | ||
|
|
25a96c35ca | ||
|
|
d2d66d322c | ||
|
|
db70d92b4c | ||
|
|
2c234c4c0a | ||
|
|
2f6825027a | ||
|
|
f9a10e1874 | ||
|
|
f6811bd1a8 | ||
|
|
e60bf91e31 | ||
|
|
81f80a62e3 | ||
|
|
12942ec717 | ||
|
|
ce11d5295c | ||
|
|
b80bcea184 | ||
|
|
0abf2b455f | ||
|
|
20735c425f | ||
|
|
76c7a613a9 | ||
|
|
c0c9437da8 | ||
|
|
3a11ac146d | ||
|
|
c9d108ba5e | ||
|
|
810626e458 | ||
|
|
c4df6b2151 | ||
|
|
3d09df1419 | ||
|
|
9d5f46587e | ||
|
|
7c61d282ee | ||
|
|
a6a919234a | ||
|
|
f07d7077b5 | ||
|
|
4e60a9fbca | ||
|
|
db124860ed | ||
|
|
f5dd72dfb5 | ||
|
|
1cd12791f6 | ||
|
|
520a12ebe0 | ||
|
|
b6ffa16893 | ||
|
|
a7c76e3202 | ||
|
|
041fa48664 | ||
|
|
13f1606dc7 | ||
|
|
3f90e60cd0 | ||
|
|
e63ed25b3f | ||
|
|
8adee749e0 | ||
|
|
b0b2378866 | ||
|
|
5905638907 | ||
|
|
578361913a | ||
|
|
da4978354b | ||
|
|
003bdab3ff | ||
|
|
f0d3347b26 | ||
|
|
f21e1b2d32 | ||
|
|
2739b29709 | ||
|
|
3386490a03 | ||
|
|
2aaf0c213f | ||
|
|
0d16d81a3c | ||
|
|
fe74fce120 | ||
|
|
7fe4e50c4c | ||
|
|
c9ab1ec62c | ||
|
|
ae397ca37c | ||
|
|
c377c4c7ec | ||
|
|
427fb1d4d9 | ||
|
|
94dbdd5981 | ||
|
|
fcd887a89d | ||
|
|
9640875fcf | ||
|
|
c35d7f21e9 | ||
|
|
8a1bdacf7d | ||
|
|
5cb83c69c6 | ||
|
|
fe3021701e | ||
|
|
06794ce278 | ||
|
|
fa30baf523 | ||
|
|
c7014b97c7 | ||
|
|
752b0f7008 | ||
|
|
d966eee6a7 | ||
|
|
2f7055e15d | ||
|
|
bd8d56e384 | ||
|
|
bbb39719f0 | ||
|
|
eecdd66098 | ||
|
|
5e82c888b6 | ||
|
|
0f565ecfef | ||
|
|
7b0f5d8f32 | ||
|
|
2f064a5604 | ||
|
|
818ce564bb | ||
|
|
331a83d2e8 | ||
|
|
56cd4713f1 | ||
|
|
089701eca8 | ||
|
|
e7d1772470 | ||
|
|
1347023b39 | ||
|
|
62894d56e5 | ||
|
|
a0758e016a | ||
|
|
0c97b6f74c | ||
|
|
f3c0e4fdc2 | ||
|
|
2c2b6fe171 | ||
|
|
22f62138a3 | ||
|
|
1969bde82c | ||
|
|
db9598f01e | ||
|
|
c31386dc3e | ||
|
|
aef4beee89 | ||
|
|
4880674668 | ||
|
|
85bff00a3c | ||
|
|
0e78eb6709 | ||
|
|
2b262c5442 | ||
|
|
aabac2576c | ||
|
|
56de1abb79 | ||
|
|
7d79ec01a5 | ||
|
|
8b446b7fc5 | ||
|
|
cc53bc1699 | ||
|
|
98b0a3a741 | ||
|
|
1f9d9d2967 | ||
|
|
83e1a6030a | ||
|
|
c9f34754a9 | ||
|
|
ea1ef4492d | ||
|
|
3c6ad491b5 | ||
|
|
c42f3b8174 | ||
|
|
e0ff103afd | ||
|
|
f58ca4b399 | ||
|
|
6697db1dba | ||
|
|
69002c122d | ||
|
|
6c4fde54be | ||
|
|
a34b845154 | ||
|
|
e3e122b69d | ||
|
|
67c10a9ff7 | ||
|
|
9891b6dc51 | ||
|
|
2ffd6a893b | ||
|
|
b1b0d45353 | ||
|
|
3424ab511b | ||
|
|
3c6c90a2a1 | ||
|
|
c21d8983de | ||
|
|
fef2178450 | ||
|
|
d53d760cb6 | ||
|
|
aab4a08267 | ||
|
|
dfca2f5862 | ||
|
|
404667f6a9 | ||
|
|
dcad02c1d4 | ||
|
|
bd89c00d37 | ||
|
|
5dcb1a4bba | ||
|
|
3e248c703e | ||
|
|
14a26038d5 | ||
|
|
0d864ae83e | ||
|
|
620d1b96b9 |
936 changed files with 99253 additions and 26415 deletions
407
.github/workflows/ci.yml
vendored
407
.github/workflows/ci.yml
vendored
|
|
@ -1,93 +1,352 @@
|
|||
name: CI
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Monthly, to catch runner image and OS changes
|
||||
- cron: '0 6 1 * *'
|
||||
concurrency:
|
||||
group: ${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
build:
|
||||
name: Build, Upload, Test
|
||||
# Decide whether to run the network-filesystem tests: always for manual and scheduled
|
||||
# runs, and otherwise only when the changes touch the Gecko version or the files
|
||||
# responsible for network-filesystem database handling
|
||||
changes:
|
||||
name: Detect changes
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.event_name != 'pull_request'
|
||||
|| github.event.pull_request.head.repo.full_name != github.repository
|
||||
outputs:
|
||||
network: ${{ steps.check.outputs.network }}
|
||||
steps:
|
||||
- name: Check for network-filesystem-related changes
|
||||
id: check
|
||||
env:
|
||||
EVENT: ${{ github.event_name }}
|
||||
REPO: ${{ github.repository }}
|
||||
SHA: ${{ github.sha }}
|
||||
BEFORE: ${{ github.event.before }}
|
||||
BASE: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
network=false
|
||||
if [[ "$EVENT" == "workflow_dispatch" || "$EVENT" == "schedule" ]]; then
|
||||
network=true
|
||||
else
|
||||
base="$BASE"
|
||||
[[ -z "$base" ]] && base="$BEFORE"
|
||||
git init -q repo && cd repo
|
||||
git remote add origin "https://github.com/$REPO"
|
||||
git fetch -q --depth 1 origin "$SHA"
|
||||
# Run the tests if the base of the change can't be determined (e.g., a new
|
||||
# branch or an unfetchable pre-force-push commit)
|
||||
if [[ -z "$base" || "$base" == 0000000000000000000000000000000000000000 ]] \
|
||||
|| ! git fetch -q --depth 1 origin "$base" 2>/dev/null; then
|
||||
network=true
|
||||
elif git diff --name-only "$base" "$SHA" \
|
||||
| grep -qE '^(app/config\.sh|chrome/content/zotero/xpcom/(db|file)\.js|\.github/workflows/ci\.yml)$'; then
|
||||
network=true
|
||||
fi
|
||||
fi
|
||||
echo "network=$network" >> "$GITHUB_OUTPUT"
|
||||
|
||||
test:
|
||||
name: Test (${{ matrix.shard }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
needs: changes
|
||||
# Same-repo PRs are covered by the push run, so run pull_request jobs only for forks
|
||||
if: >
|
||||
github.event_name != 'pull_request'
|
||||
|| github.event.pull_request.head.repo.full_name != github.repository
|
||||
strategy:
|
||||
matrix:
|
||||
# Numbered shards split the full suite. 'smb' and 'nfs' run the db and file tests
|
||||
# with the test data directory on a loopback CIFS or NFS mount, so that connection
|
||||
# initialization itself runs against a network filesystem, which is where
|
||||
# network-filesystem failures (startup crashes and hangs) occur. The network shards
|
||||
# run only when the 'changes' job detects relevant changes.
|
||||
shard: ${{ fromJSON(needs.changes.outputs.network == 'true'
|
||||
&& '["1", "2", "3", "4", "smb", "nfs"]'
|
||||
|| '["1", "2", "3", "4"]') }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
lfs: true
|
||||
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 24
|
||||
#cache: npm
|
||||
|
||||
# On GitHub
|
||||
- name: Install xvfb
|
||||
if: env.ACT != 'true'
|
||||
run: sudo apt update && sudo apt install -y xvfb
|
||||
|
||||
|
||||
# Local via act
|
||||
- name: Install packages for act
|
||||
if: env.ACT == 'true'
|
||||
run: apt update && apt install -y zstd xvfb dbus-x11 libgtk-3-0 libx11-xcb1 libdbus-glib-1-2 libxt6
|
||||
|
||||
|
||||
- name: Cache xulrunner
|
||||
id: xulrunner-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: app/xulrunner/firefox-x86_64
|
||||
key: xulrunner-${{ hashFiles('app/config.sh', 'app/scripts/fetch_xulrunner') }}
|
||||
|
||||
|
||||
- name: Fetch xulrunner
|
||||
if: steps.xulrunner-cache.outputs.cache-hit != 'true'
|
||||
run: app/scripts/fetch_xulrunner -p l
|
||||
|
||||
|
||||
- name: Cache Node modules
|
||||
id: node-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: node-modules-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
key: node-modules-24-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Install Node modules
|
||||
if: steps.node-cache.outputs.cache-hit != 'true'
|
||||
run: npm install
|
||||
|
||||
|
||||
- name: Build Zotero
|
||||
run: npm run build
|
||||
# Currently necessary for pdf-worker Webpack: https://stackoverflow.com/a/69746937
|
||||
# Currently necessary for document-worker Webpack: https://stackoverflow.com/a/69746937
|
||||
env:
|
||||
NODE_OPTIONS: --openssl-legacy-provider
|
||||
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.3'
|
||||
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||
|
||||
- name: Upload deployment ZIP
|
||||
if: |
|
||||
env.ACT != 'true'
|
||||
&& github.repository == 'zotero/zotero'
|
||||
&& github.event_name == 'push'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/fx128' || endsWith(github.ref, '-hotfix'))
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
# Create deployment ZIP from the build output, then replace build/ with the
|
||||
# unzipped contents so that tests run against the same artifact that gets
|
||||
# deployed. This catches problems that only manifest after a zip round-trip
|
||||
# (e.g., resolved symlinks, missing files).
|
||||
- name: Create deployment ZIP
|
||||
run: |
|
||||
mkdir build-zip
|
||||
cd build
|
||||
zip -r ../build-zip/$GITHUB_SHA.zip *
|
||||
zip -r ../build.zip *
|
||||
cd ..
|
||||
gem install --no-document dpl -v '>= 2.0'
|
||||
dpl s3 --bucket zotero-download --local_dir build-zip --upload_dir ci/client --acl public_read
|
||||
|
||||
|
||||
- name: Replace build with ZIP contents
|
||||
run: |
|
||||
rm -rf build
|
||||
mkdir build
|
||||
cd build
|
||||
unzip ../build.zip
|
||||
|
||||
- name: Set up SMB share
|
||||
if: matrix.shard == 'smb'
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo apt install -y --no-install-recommends samba cifs-utils
|
||||
sudo mkdir -p /srv/zotero-smb /mnt/zotero-smb
|
||||
sudo chown $(whoami) /srv/zotero-smb
|
||||
sudo tee -a /etc/samba/smb.conf > /dev/null <<EOF
|
||||
[ztest]
|
||||
path = /srv/zotero-smb
|
||||
read only = no
|
||||
EOF
|
||||
(echo smbtestpass; echo smbtestpass) | sudo smbpasswd -s -a $(whoami)
|
||||
sudo systemctl restart smbd
|
||||
sudo mount -t cifs //127.0.0.1/ztest /mnt/zotero-smb \
|
||||
-o user=$(whoami),pass=smbtestpass,vers=3.0,uid=$(id -u),gid=$(id -g)
|
||||
|
||||
- name: Set up NFS share
|
||||
if: matrix.shard == 'nfs'
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo apt install -y --no-install-recommends nfs-kernel-server
|
||||
sudo mkdir -p /srv/zotero-nfs /mnt/zotero-nfs
|
||||
sudo chown $(whoami) /srv/zotero-nfs
|
||||
echo "/srv/zotero-nfs localhost(rw,no_subtree_check,insecure)" | sudo tee -a /etc/exports
|
||||
sudo exportfs -ra
|
||||
sudo mount -t nfs4 localhost:/srv/zotero-nfs /mnt/zotero-nfs
|
||||
|
||||
- name: Run tests
|
||||
run: xvfb-run test/runtests.sh -f
|
||||
if: matrix.shard != 'smb' && matrix.shard != 'nfs'
|
||||
run: xvfb-run test/runtests.sh -f -r 3 -p ${{ matrix.shard }}/4
|
||||
|
||||
# The step timeout turns a startup hang into a failure
|
||||
- name: Run tests on network share
|
||||
if: matrix.shard == 'smb' || matrix.shard == 'nfs'
|
||||
timeout-minutes: 10
|
||||
run: TMPDIR=/mnt/zotero-${{ matrix.shard }} xvfb-run test/runtests.sh -f -r 3 -b db file
|
||||
|
||||
# Run the db and file tests on macOS with the test data directory on a loopback NFS
|
||||
# mount. On macOS, SQLite selects locking methods without shared-memory support on
|
||||
# network filesystems, which is what the network-filesystem database handling has to
|
||||
# detect and handle, so this runs the real code path that crashed on SMB/NFS/sshfs
|
||||
# data directories.
|
||||
test-mac:
|
||||
name: Test (macOS NFS)
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 30
|
||||
needs: changes
|
||||
if: >
|
||||
needs.changes.outputs.network == 'true'
|
||||
&& (github.event_name != 'pull_request'
|
||||
|| github.event.pull_request.head.repo.full_name != github.repository)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
lfs: true
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Cache xulrunner
|
||||
id: xulrunner-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: app/xulrunner/Firefox.app
|
||||
key: xulrunner-mac-${{ hashFiles('app/config.sh', 'app/scripts/fetch_xulrunner') }}
|
||||
|
||||
- name: Fetch xulrunner
|
||||
if: steps.xulrunner-cache.outputs.cache-hit != 'true'
|
||||
run: app/scripts/fetch_xulrunner -p m
|
||||
|
||||
- name: Cache Node modules
|
||||
id: node-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: node-modules-mac-24-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Install Node modules
|
||||
if: steps.node-cache.outputs.cache-hit != 'true'
|
||||
run: npm install
|
||||
|
||||
- name: Build Zotero
|
||||
run: npm run build
|
||||
# Currently necessary for document-worker Webpack: https://stackoverflow.com/a/69746937
|
||||
env:
|
||||
NODE_OPTIONS: --openssl-legacy-provider
|
||||
|
||||
- name: Set up NFS share
|
||||
run: |
|
||||
sudo mkdir -p /private/var/zotero-nfs
|
||||
sudo chown $(whoami) /private/var/zotero-nfs
|
||||
echo "/private/var/zotero-nfs -mapall=$(whoami) localhost" | sudo tee /etc/exports
|
||||
sudo nfsd enable || sudo nfsd start
|
||||
sleep 2
|
||||
showmount -e localhost
|
||||
mkdir -p "$HOME/zotero-nfs"
|
||||
sudo mount_nfs -o vers=3 localhost:/private/var/zotero-nfs "$HOME/zotero-nfs"
|
||||
|
||||
# The step timeout turns a startup hang into a failure
|
||||
- name: Run tests on network share
|
||||
timeout-minutes: 10
|
||||
run: TMPDIR="$HOME/zotero-nfs" test/runtests.sh -f -r 3 -b db file
|
||||
|
||||
# Boot the Windows build -- which uses custom-built Firefox components (xul.dll) rather
|
||||
# than stock Mozilla binaries -- and run the db and file tests. A Gecko bump commit
|
||||
# updates the custom-component hashes in config.sh along with the version, so this
|
||||
# tests each new set of custom components.
|
||||
test-win:
|
||||
name: Test (Windows ${{ matrix.arch }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 40
|
||||
needs: changes
|
||||
if: >
|
||||
needs.changes.outputs.network == 'true'
|
||||
&& (github.event_name != 'pull_request'
|
||||
|| github.event.pull_request.head.repo.full_name != github.repository)
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
os: windows-latest
|
||||
- arch: arm64
|
||||
os: windows-11-arm
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
# The repository contains symlinks, which Git checks out as plain files on Windows
|
||||
# by default, breaking the build
|
||||
- name: Enable symlinks
|
||||
run: git config --global core.symlinks true
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
lfs: true
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
# Git Bash has unzip but not zip, which fetch_xulrunner needs to repack omni.ja
|
||||
- name: Install zip
|
||||
run: choco install zip --no-progress -y
|
||||
|
||||
- name: Cache xulrunner
|
||||
id: xulrunner-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: app/xulrunner/firefox-win-${{ matrix.arch }}
|
||||
key: xulrunner-win-${{ matrix.arch }}-${{ hashFiles('app/config.sh', 'app/scripts/fetch_xulrunner') }}
|
||||
|
||||
- name: Fetch xulrunner
|
||||
if: steps.xulrunner-cache.outputs.cache-hit != 'true'
|
||||
run: app/scripts/fetch_xulrunner -p w -a ${{ matrix.arch }}
|
||||
|
||||
- name: Cache Node modules
|
||||
id: node-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: node-modules-win-24-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Install Node modules
|
||||
if: steps.node-cache.outputs.cache-hit != 'true'
|
||||
run: npm install
|
||||
|
||||
# Committed symlinks point into node_modules, which doesn't exist at checkout time,
|
||||
# so Git creates the directory ones with the wrong symlink type on Windows. Recreate
|
||||
# them now that the targets exist.
|
||||
- name: Recreate symlinks
|
||||
run: |
|
||||
git ls-files -s | awk '$1 == 120000 { print $4 }' | xargs rm
|
||||
git checkout -- .
|
||||
|
||||
- name: Build Zotero
|
||||
run: npm run build
|
||||
# Currently necessary for document-worker Webpack: https://stackoverflow.com/a/69746937
|
||||
env:
|
||||
NODE_OPTIONS: --openssl-legacy-provider
|
||||
|
||||
# The step timeout turns a startup hang into a failure
|
||||
- name: Run tests
|
||||
timeout-minutes: 15
|
||||
run: test/runtests.sh -f -r 3 -b -x app/staging/Zotero_win-${{ matrix.arch }}/zotero.exe db file
|
||||
|
||||
utilities:
|
||||
name: Utilities Tests
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.event_name != 'pull_request'
|
||||
|| github.event.pull_request.head.repo.full_name != github.repository
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Cache utilities Node modules
|
||||
id: utilities-node-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: chrome/content/zotero/xpcom/utilities/node_modules
|
||||
key: utilities-node-modules-${{ hashFiles('chrome/content/zotero/xpcom/utilities/package-lock.json') }}
|
||||
key: utilities-node-modules-24-${{ hashFiles('chrome/content/zotero/xpcom/utilities/package-lock.json') }}
|
||||
|
||||
- name: Install utilities Node modules
|
||||
if: steps.utilities-node-cache.outputs.cache-hit != 'true'
|
||||
|
|
@ -96,3 +355,73 @@ jobs:
|
|||
- name: Run utilities tests
|
||||
run: |
|
||||
npm test --prefix chrome/content/zotero/xpcom/utilities -- -j resource/schema/global/schema.json
|
||||
|
||||
# Build deployment ZIPs from main, version branches (e.g., 9.0, 10.0), and *-hotfix branches
|
||||
deploy:
|
||||
name: Build, Upload
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.repository == 'zotero/zotero'
|
||||
steps:
|
||||
- name: Check if deployment branch
|
||||
id: check-deploy
|
||||
if: env.ACT != 'true'
|
||||
run: |
|
||||
branch="${GITHUB_REF#refs/heads/}"
|
||||
if [[ "$branch" == "main" || "$branch" =~ ^[0-9]+\.[0-9]+$ || "$branch" == *-hotfix ]]; then
|
||||
echo "deploy=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: steps.check-deploy.outputs.deploy == 'true'
|
||||
with:
|
||||
submodules: recursive
|
||||
lfs: true
|
||||
|
||||
- name: Install Node
|
||||
if: steps.check-deploy.outputs.deploy == 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Cache Node modules
|
||||
id: node-cache
|
||||
if: steps.check-deploy.outputs.deploy == 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: node-modules-24-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Install Node modules
|
||||
if: steps.check-deploy.outputs.deploy == 'true' && steps.node-cache.outputs.cache-hit != 'true'
|
||||
run: npm install
|
||||
|
||||
- name: Build Zotero
|
||||
if: steps.check-deploy.outputs.deploy == 'true'
|
||||
run: npm run build
|
||||
# Currently necessary for document-worker Webpack: https://stackoverflow.com/a/69746937
|
||||
env:
|
||||
NODE_OPTIONS: --openssl-legacy-provider
|
||||
|
||||
- name: Create deployment ZIP
|
||||
if: steps.check-deploy.outputs.deploy == 'true'
|
||||
run: |
|
||||
cd build
|
||||
zip -r ../build.zip *
|
||||
cd ..
|
||||
|
||||
- uses: ruby/setup-ruby@v1
|
||||
if: steps.check-deploy.outputs.deploy == 'true'
|
||||
with:
|
||||
ruby-version: '3.3'
|
||||
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||
|
||||
- name: Upload deployment ZIP
|
||||
if: steps.check-deploy.outputs.deploy == 'true'
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
mkdir build-zip
|
||||
cp build.zip build-zip/$GITHUB_SHA.zip
|
||||
gem install --no-document dpl -v '>= 2.0'
|
||||
dpl s3 --bucket zotero-download --local_dir build-zip --upload_dir ci/client --acl public_read
|
||||
|
|
|
|||
4
.gitmodules
vendored
4
.gitmodules
vendored
|
|
@ -30,8 +30,8 @@
|
|||
url = https://github.com/zotero/reader.git
|
||||
branch = master
|
||||
[submodule "pdf-worker"]
|
||||
path = pdf-worker
|
||||
url = https://github.com/zotero/pdf-worker.git
|
||||
path = document-worker
|
||||
url = https://github.com/zotero/document-worker.git
|
||||
branch = master
|
||||
[submodule "note-editor"]
|
||||
path = note-editor
|
||||
|
|
|
|||
110
CLAUDE.md
Normal file
110
CLAUDE.md
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Zotero is a Firefox-based desktop research management application. It runs as a XUL/XHTML application on a Mozilla platform (not Electron, not a web app). The UI uses a mix of XUL/XHTML, custom elements (Web Components), and React components.
|
||||
|
||||
## Build & Development Commands
|
||||
|
||||
```bash
|
||||
npm install # Install dependencies
|
||||
npm run build # Full build (Babel transpile JS/JSX, compile SCSS, browserify, symlink)
|
||||
npm run clean-build # Clean build output and rebuild
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
Tests run inside a built Zotero application instance via `test/runtests.sh`:
|
||||
|
||||
```bash
|
||||
test/runtests.sh # Run all tests
|
||||
test/runtests.sh item # Run a single test file (omit "Test" and .js extension)
|
||||
test/runtests.sh item collections # Run multiple test files
|
||||
test/runtests.sh -g "pattern" # Run only tests matching a grep pattern
|
||||
test/runtests.sh -f # Stop after first failure
|
||||
test/runtests.sh -s item # Start at a specific test file
|
||||
test/runtests.sh -e item # End at a specific test file
|
||||
test/runtests.sh -c # Open JS console, don't quit on completion
|
||||
test/runtests.sh -b # Skip bundled translator/style installation (for faster startup when not necessary)
|
||||
test/runtests.sh -d 5 # Enable debug logging (level 1-5; default is 3)
|
||||
```
|
||||
|
||||
Tests use Mocha + Chai (assert style) + Sinon. Test files live in `test/tests/` and follow the naming convention `<module>Test.js`. Test helpers are in `test/content/support.js`.
|
||||
|
||||
The test runner automatically triggers `npm run build` if the watch process isn't running, so it's not necessary to manually run a build before running tests.
|
||||
|
||||
Tips:
|
||||
|
||||
- Pass flags before test names (e.g., `test/runtests.sh -f item`).
|
||||
- `-f` should almost always be used, since test failures can cause later spurious test failures.
|
||||
- To view debug logging for a specific test when running multiple tests, add `Zotero.Debug.init(1)` at the beginning of the test and run with `-d 5`.
|
||||
|
||||
|
||||
### Linting
|
||||
|
||||
```bash
|
||||
npx eslint <file> # Lint a specific file
|
||||
```
|
||||
|
||||
ESLint v9 flat config is in `eslint.config.mjs`. Uses `@zotero/eslint-config`, Babel parser, and React plugin. Currently, the code base is not fully linted, so only worry about issues with new or modified code lines.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Layers
|
||||
|
||||
- **XPCOM modules** (`chrome/content/zotero/xpcom/`) -- Core business logic. Loaded sequentially by `chrome/content/zotero/zotero.mjs`. All modules attach to the global `Zotero` namespace (e.g., `Zotero.Items`, `Zotero.Sync.Runner`).
|
||||
|
||||
- **Data model** (`chrome/content/zotero/xpcom/data/`) -- ORM-like classes for database entities. `Zotero.DataObject` is the base class; `Zotero.Item`, `Zotero.Collection`, `Zotero.Search`, `Zotero.Library`, etc. extend it. Plural classes (`Zotero.Items`, `Zotero.Collections`) manage object caches and queries. Data objects use an async `saveTx()` pattern for persistence.
|
||||
|
||||
- **Database** (`chrome/content/zotero/xpcom/db.js`) -- SQLite via Mozilla's mozStorage API. Accessed through `Zotero.DB.queryAsync()`, `Zotero.DB.executeTransaction()`.
|
||||
|
||||
- **Sync system** (`chrome/content/zotero/xpcom/sync/`) -- `syncRunner.js` orchestrates sync. `syncEngine.js` handles object-level sync logic. `syncAPIClient.js` communicates with the Zotero API server. `syncLocal.js` manages local sync state. Similar `storage*` files for file syncing, along with zfs.js (Zotero Storage) and webdav.js (WebDAV).
|
||||
|
||||
- **HTTP server** (`chrome/content/zotero/xpcom/server/`) -- Local HTTP server for browser connector integration and local API.
|
||||
|
||||
### UI Layers
|
||||
|
||||
- **Main window**: `chrome/content/zotero/zoteroPane.xhtml` + `zoteroPane.js`
|
||||
- **Custom elements** (`chrome/content/zotero/elements/`) -- XUL custom elements inheriting from `XULElementBase` (defined in `elements/base.js`), which provides lifecycle helpers (`init()`, `destroy()`, `content` getter for templates). Registered via `customElements.define()`.
|
||||
- **React components** (`chrome/content/zotero/components/`) -- Used for complex interactive UI (tag selector, virtualized table, item tree, collection tree). Major tree views are `itemTree.jsx` and `collectionTree.jsx`.
|
||||
- **SCSS styles** (`scss/`) -- Compiled to CSS. Platform-specific overrides in `scss/mac/`, `scss/win/`, `scss/linux/`.
|
||||
- **Localization** -- Fluent (`.ftl` files) in `chrome/locale/en-US/zotero/`. Accessed via `data-l10n-id` attributes or `Zotero.getString()` for legacy `.properties` strings. All new strings must be added to `.ftl` files -- do not add new strings to `.properties` or `.dtd` files.
|
||||
|
||||
### Submodules
|
||||
|
||||
Several features are developed in separate repos and included as Git submodules:
|
||||
|
||||
- `reader/` -- PDF/EPUB/snapshot reader with annotations
|
||||
- `note-editor/` -- Rich text note editor
|
||||
- `document-worker/` -- PDF processing (extraction, manipulation)
|
||||
- `translators/` -- 760+ web translators for importing metadata from websites
|
||||
- `styles/` -- CSL citation styles
|
||||
- `chrome/content/zotero/xpcom/utilities/` -- Shared utility library
|
||||
- `chrome/content/zotero/xpcom/translate/` -- Translation framework
|
||||
- `resource/SingleFile/` -- Web page archiving
|
||||
|
||||
### Build System (`js-build/`)
|
||||
|
||||
Custom Node.js build system (not Webpack). `js-build/config.js` defines what gets built:
|
||||
- **JS/JSX files** in `chrome/`, `components/`, `defaults/`, `test/` are transpiled via Babel (React JSX, CommonJS modules)
|
||||
- **SCSS files** in `scss/` and `chrome/skin/` are compiled with Dart Sass
|
||||
- **Browserify** bundles select npm packages (react-select, sinon, url) for use in the XUL environment
|
||||
- **Symlinks** connect source files and submodules into the `build/` directory
|
||||
- The built app reads from `build/`, not directly from source
|
||||
|
||||
## Code Style
|
||||
|
||||
- Tabs for indentation, not spaces
|
||||
- Use `let` instead of `const` except for true scalar constants (e.g., `const MAX_SECONDS = 5 * 60;`)
|
||||
- No cuddled braces (opening brace on same line, but `else`/`catch`/etc. on their own line)
|
||||
- Use two hyphens `--` in comments, not an em dash
|
||||
- Indent blank lines to match surrounding indentation level
|
||||
- Objects attach to the `Zotero` global namespace rather than using ES module exports
|
||||
- Async code uses `async`/`await` throughout
|
||||
- Mozilla/XPCOM APIs are available globally: `Cc`, `Ci`, `Cu`, `Cr`, `Services`, `ChromeUtils`, `IOUtils`, `PathUtils`
|
||||
|
||||
## Translators
|
||||
|
||||
See `translators/CLAUDE.md` for guidelines. Key rules: **never** generate translators from scratch, **never** generate UUIDs, **never** generate test cases -- all of these must be done through Zotero's Scaffold tool.
|
||||
|
|
@ -7,8 +7,8 @@ Copyright=Copyright (c) 2006-2025 Contributors
|
|||
ID=zotero@zotero.org
|
||||
|
||||
[Gecko]
|
||||
MinVersion=140.0
|
||||
MaxVersion=140.99.*
|
||||
MinVersion=153.0
|
||||
MaxVersion=153.99.*
|
||||
|
||||
[XRE]
|
||||
EnableExtensionManager=1
|
||||
|
|
|
|||
|
|
@ -4,4 +4,5 @@
|
|||
-brand-product-name = Zotero
|
||||
-vendor-short-name = Zotero
|
||||
-app-name = Zotero
|
||||
-subscription-name = Zotero Storage
|
||||
trademarkInfo = Zotero is a trademark of the Corporation for Digital Scholarship.
|
||||
|
|
|
|||
|
|
@ -50,8 +50,10 @@ if (processTestOptions) {
|
|||
TestOptions.bail = cmdLine.handleFlag("bail", false);
|
||||
TestOptions.startAt = cmdLine.handleFlagWithParam("startAtTestFile", false);
|
||||
TestOptions.stopAt = cmdLine.handleFlagWithParam("stopAtTestFile", false);
|
||||
TestOptions.shard = cmdLine.handleFlagWithParam("shard", false);
|
||||
TestOptions.grep = cmdLine.handleFlagWithParam("grep", false);
|
||||
TestOptions.timeout = cmdLine.handleFlagWithParam("ZoteroTestTimeout", false);
|
||||
TestOptions.retries = cmdLine.handleFlagWithParam("retries", false) || 0;
|
||||
|
||||
Services.ww.openWindow(
|
||||
null,
|
||||
|
|
|
|||
225
app/build.sh
225
app/build.sh
|
|
@ -42,7 +42,7 @@ Options
|
|||
-f FILE ZIP file to build from (cannot be used with -d)
|
||||
-t add devtools
|
||||
-p PLATFORMS build for platforms PLATFORMS (m=Mac, w=Windows, l=Linux)
|
||||
-a ARCH architecture to build (arm64, x64, i686, win32)
|
||||
-a ARCH architecture to build (arm64, x64, win32)
|
||||
* Ignored for Mac (always universal)
|
||||
* If omitted on Windows/Linux, all standard archs are built
|
||||
-c CHANNEL use update channel CHANNEL
|
||||
|
|
@ -59,6 +59,17 @@ function cleanup {
|
|||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Copy the contents of a directory into another directory, which may already exist,
|
||||
# using rsync if it's available (not on Windows)
|
||||
function copy_dir {
|
||||
if command -v rsync > /dev/null; then
|
||||
rsync -a "$1/" "$2/"
|
||||
else
|
||||
mkdir -p "$2"
|
||||
cp -a "$1/." "$2/"
|
||||
fi
|
||||
}
|
||||
|
||||
function abspath {
|
||||
echo $(cd $(dirname $1); pwd)/$(basename $1);
|
||||
}
|
||||
|
|
@ -210,7 +221,7 @@ if [ $BUILD_LINUX == 1 ]; then
|
|||
if [[ -n $arch ]]; then
|
||||
check_xulrunner_hash l $(get_canonical_arch l $arch)
|
||||
else
|
||||
for _a in x64 arm64 i686; do
|
||||
for _a in x64 arm64; do
|
||||
check_xulrunner_hash l "$_a"
|
||||
done
|
||||
fi
|
||||
|
|
@ -296,7 +307,7 @@ elif [[ $BUILD_LINUX == 1 ]]; then
|
|||
if [[ -n $arch ]]; then
|
||||
omni_arch=$(get_canonical_arch l $arch)
|
||||
else
|
||||
for cand in x86_64 arm64 i686; do
|
||||
for cand in x86_64 arm64; do
|
||||
[[ -d "${LINUX_RUNTIME_PATH_PREFIX}${cand}" ]] && { omni_arch="$cand"; break; }
|
||||
done
|
||||
fi
|
||||
|
|
@ -311,7 +322,6 @@ cd $omni_dir
|
|||
rm actors/AboutLogins{Parent,Child}.sys.mjs
|
||||
rm actors/AboutMessagePreview{Parent,Child}.sys.mjs
|
||||
rm actors/AboutNewTab{Parent,Child}.sys.mjs
|
||||
rm actors/AboutPocket{Parent,Child}.sys.mjs
|
||||
rm actors/AboutPrivateBrowsing{Parent,Child}.sys.mjs
|
||||
rm actors/AboutProtections{Parent,Child}.sys.mjs
|
||||
rm actors/AboutReader{Parent,Child}.sys.mjs
|
||||
|
|
@ -350,11 +360,10 @@ browser_keep=(
|
|||
content/browser/parent/ext-browser.js
|
||||
# For spellchecking
|
||||
content/browser/built_in_addons.json
|
||||
# Statically imported by BackupService, which SelectableProfileService pulls in when it's
|
||||
# instantiated as a command-line handler at startup
|
||||
content/browser/backup/backup-constants.mjs
|
||||
)
|
||||
if [ $BUILD_WIN == 1 ]; then
|
||||
# Windows window controls
|
||||
browser_keep+=(skin/classic/browser/window-controls)
|
||||
fi
|
||||
for file in "${browser_keep[@]}"; do
|
||||
mkdir -p "$(dirname "chrome/browser-fx/$file")"
|
||||
mv "chrome/browser/$file" "chrome/browser-fx/$file"
|
||||
|
|
@ -376,11 +385,18 @@ if [ -n "$ZIP_FILE" ]; then
|
|||
echo "Building from $ZIP_FILE"
|
||||
unzip -q $ZIP_FILE -d "$omni_dir"
|
||||
else
|
||||
rsync_params=""
|
||||
if [ $include_tests -eq 0 ]; then
|
||||
rsync_params="--exclude /test"
|
||||
if command -v rsync > /dev/null; then
|
||||
rsync_params=""
|
||||
if [ $include_tests -eq 0 ]; then
|
||||
rsync_params="--exclude /test"
|
||||
fi
|
||||
rsync -a $rsync_params "$SOURCE_DIR/" ./
|
||||
else
|
||||
copy_dir "$SOURCE_DIR" .
|
||||
if [ $include_tests -eq 0 ]; then
|
||||
rm -rf ./test
|
||||
fi
|
||||
fi
|
||||
rsync -a $rsync_params "$SOURCE_DIR/" ./
|
||||
fi
|
||||
|
||||
mv defaults defaults-z
|
||||
|
|
@ -392,7 +408,7 @@ prefs_file=defaults/preferences/zotero.js
|
|||
# - network.captive-portal-service.enabled
|
||||
# Disable the captive portal check against Mozilla servers
|
||||
# - extensions.systemAddon.update.url
|
||||
egrep -v '(network.captive-portal-service.enabled|extensions.systemAddon.update.url)' defaults/preferences/firefox.js > $prefs_file
|
||||
grep -E -v '(network.captive-portal-service.enabled|extensions.systemAddon.update.url)' defaults/preferences/firefox.js > $prefs_file
|
||||
rm defaults/preferences/firefox.js
|
||||
|
||||
# Combine app and "extension" Zotero prefs
|
||||
|
|
@ -406,6 +422,8 @@ echo "" >> $prefs_file
|
|||
echo "# Zotero extension prefs" >> $prefs_file
|
||||
echo "" >> $prefs_file
|
||||
cat defaults-z/preferences/zotero.js >> $prefs_file
|
||||
# Babel strips the trailing newline, so anything appended below would share a line
|
||||
echo "" >> $prefs_file
|
||||
|
||||
rm -rf defaults-z
|
||||
|
||||
|
|
@ -458,6 +476,58 @@ if [ $DEVTOOLS -eq 1 ]; then
|
|||
replace_line 'let command = Services.dirsvc.get\("XREExeF", Ci.nsIFile\).path;' \
|
||||
'let command = Services.dirsvc.get("XREExeF", Ci.nsIFile).path; command = command.replace("zotero-bin", "zotero");' \
|
||||
chrome/devtools/modules/devtools/client/framework/browser-toolbox/Launcher.sys.mjs
|
||||
|
||||
# Fix source display breaking when switching windows in the Browser Toolbox.
|
||||
# When the user switches which chrome window is being inspected (via the frame
|
||||
# picker), the thread actor's _onWindowReady handler clears all debuggees and
|
||||
# source actors, then re-adds debuggees. But it never calls addAllSources() to
|
||||
# re-emit the existing scripts — it only does that for BFCache navigations.
|
||||
# Since the scripts already exist (they're not new), onNewScript won't fire,
|
||||
# so the debugger's source list stays empty. Fix: also call addAllSources()
|
||||
# after frame switching, just like BFCache.
|
||||
replace_line '_onWindowReady\(\{ isTopLevel, isBFCache \}\)' \
|
||||
'_onWindowReady({ isTopLevel, isBFCache, isFrameSwitching })' \
|
||||
chrome/devtools/modules/devtools/server/actors/thread.js
|
||||
replace_line 'if \(isBFCache\) {' \
|
||||
'if (isBFCache || isFrameSwitching) {' \
|
||||
chrome/devtools/modules/devtools/server/actors/thread.js
|
||||
|
||||
# Suppress "Failed to get the active browserId" error when profile recording starts
|
||||
replace_line 'console\.error\(' \
|
||||
'if (false) console.error(' \
|
||||
chrome/devtools/modules/devtools/shared/performance-new/recording-utils.sys.mjs
|
||||
|
||||
# Stub out profile favicon lookup since we don't have PlacesUtils
|
||||
replace_line 'return getPageFavicons\(pageUrls\);' \
|
||||
'return pageUrls ? pageUrls.map(() => null) : [];' \
|
||||
chrome/devtools/modules/devtools/client/performance-new/shared/background.sys.mjs
|
||||
|
||||
# Use our profilerViewer.xhtml to display profiles.
|
||||
# We can't use basicViewer because that loads Zotero scripts, and profiling
|
||||
# runs in the Browser Toolbox child process, where a second Zotero instance
|
||||
# would collide with the locked database.
|
||||
replace_line 'const contentBrowser = await new Promise\(resolveOnContentBrowserCreated =>' \
|
||||
'if (typeof win.openWebLinkIn !== "function") {
|
||||
const ww = Cc["\@mozilla.org\/embedcomp\/window-watcher;1"].getService(
|
||||
Ci.nsIWindowWatcher
|
||||
);
|
||||
const arg = { uri: urlToLoad };
|
||||
arg.wrappedJSObject = arg;
|
||||
const viewerWin = ww.openWindow(
|
||||
null,
|
||||
"chrome:\/\/zotero\/content\/standalone\/profilerViewer.xhtml",
|
||||
null,
|
||||
"chrome,dialog=no,resizable,centerscreen,scrollbars",
|
||||
arg
|
||||
);
|
||||
return await new Promise(resolve => {
|
||||
viewerWin.addEventListener("load", () => {
|
||||
resolve(viewerWin.document.getElementById("content"));
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
const contentBrowser = await new Promise(resolveOnContentBrowserCreated =>' \
|
||||
chrome/devtools/modules/devtools/client/performance-new/shared/browser.js
|
||||
fi
|
||||
|
||||
# 5.0.96.3 / 5.0.97-beta.37+ddc7be75c
|
||||
|
|
@ -522,11 +592,11 @@ fi
|
|||
|
||||
# Copy platform-specific assets
|
||||
if [ $BUILD_MAC == 1 ]; then
|
||||
rsync -a "$CALLDIR/assets/mac/" ./
|
||||
copy_dir "$CALLDIR/assets/mac" .
|
||||
elif [ $BUILD_WIN == 1 ]; then
|
||||
rsync -a "$CALLDIR/assets/win/" ./
|
||||
copy_dir "$CALLDIR/assets/win" .
|
||||
elif [ $BUILD_LINUX == 1 ]; then
|
||||
rsync -a "$CALLDIR/assets/unix/" ./
|
||||
copy_dir "$CALLDIR/assets/unix" .
|
||||
fi
|
||||
|
||||
# Add word processor plug-ins
|
||||
|
|
@ -624,8 +694,8 @@ if [ $BUILD_MAC == 1 ]; then
|
|||
|
||||
# Merge relevant assets from Firefox
|
||||
mkdir "$CONTENTSDIR/MacOS"
|
||||
cp -r "$MAC_RUNTIME_PATH/Contents/MacOS/"!(firefox|firefox-bin|crashreporter.app|minidump-analyzer|nmhproxy|pingsender|updater.app) "$CONTENTSDIR/MacOS"
|
||||
cp -r "$MAC_RUNTIME_PATH/Contents/Resources/"!(application.ini|browser|defaults|precomplete|removed-files|updater.ini|update-settings.ini|webapprt*|*.icns|*.lproj) "$CONTENTSDIR/Resources"
|
||||
cp -r "$MAC_RUNTIME_PATH/Contents/MacOS/"!(firefox|firefox-bin|crashhelper|crashreporter.app|minidump-analyzer|nmhproxy|pingsender|updater.app) "$CONTENTSDIR/MacOS"
|
||||
cp -r "$MAC_RUNTIME_PATH/Contents/Resources/"!(application.ini|Assets.car|browser|defaults|precomplete|removed-files|updater.ini|update-settings.ini|webapprt*|*.icns|*.lproj) "$CONTENTSDIR/Resources"
|
||||
|
||||
# Add our custom ChannelPrefs.framework and change channel if not a source build
|
||||
mkdir "$CONTENTSDIR/Frameworks"
|
||||
|
|
@ -669,7 +739,7 @@ if [ $BUILD_MAC == 1 ]; then
|
|||
echo
|
||||
|
||||
# Copy app files
|
||||
rsync -a "$base_dir/" "$CONTENTSDIR/Resources/"
|
||||
copy_dir "$base_dir" "$CONTENTSDIR/Resources"
|
||||
|
||||
# Add word processor plug-ins
|
||||
mkdir "$CONTENTSDIR/Resources/integration"
|
||||
|
|
@ -680,12 +750,66 @@ if [ $BUILD_MAC == 1 ]; then
|
|||
find "$CONTENTSDIR" -depth -type d -name .git -exec rm -rf {} \;
|
||||
find "$CONTENTSDIR" \( -name .DS_Store -or -name update.rdf \) -exec rm -f {} \;
|
||||
|
||||
# Add Safari App Extension -- this depends on signing but needs to be done before generating
|
||||
# Add Safari extensions -- this depends on signing but needs to be done before generating
|
||||
# the precomplete file
|
||||
#
|
||||
# $SAFARI_APPEX is a stub appex built from the safari-web-extension wrapper project. The web
|
||||
# extension itself comes from $SAFARI_EXT_RESOURCES (a zotero-connectors build/safari
|
||||
# directory), which replaces the stub's placeholder resources here before signing.
|
||||
#
|
||||
# $SAFARI_APP_EXTENSION is an optional prebuilt legacy Safari App Extension, embedded
|
||||
# alongside the web extension for Safari versions that can't load Developer ID web
|
||||
# extensions (supported in Safari 18.4 and later). On Safari versions that can load the
|
||||
# web extension, the SFSafariAppExtensionBundleIdentifiersToReplace key causes it to
|
||||
# replace the App Extension.
|
||||
if [[ $SIGN == 1 ]] && [[ -n "$SAFARI_APPEX" ]] && [[ -d "$SAFARI_APPEX" ]]; then
|
||||
if [[ -z "${SAFARI_EXT_RESOURCES:-}" ]] || [[ ! -f "$SAFARI_EXT_RESOURCES/manifest.json" ]]; then
|
||||
echo "SAFARI_EXT_RESOURCES doesn't contain a web extension -- aborting" 2>&1
|
||||
exit 1
|
||||
fi
|
||||
bundle_identifier=$(/usr/libexec/PlistBuddy -c "Print CFBundleIdentifier" "$APPDIR/Contents/Info.plist")
|
||||
mkdir "$APPDIR/Contents/PlugIns"
|
||||
cp -R $SAFARI_APPEX "$APPDIR/Contents/PlugIns/ZoteroSafariExtension.appex"
|
||||
rm -rf "$APPDIR/Contents/PlugIns/ZoteroSafariExtension.appex/Contents/Resources/safari/test/"
|
||||
|
||||
webext_appex="$APPDIR/Contents/PlugIns/ZoteroSafariWebExtension.appex"
|
||||
cp -R "$SAFARI_APPEX" "$webext_appex"
|
||||
appex_resources="$webext_appex/Contents/Resources"
|
||||
rm -rf "$appex_resources"
|
||||
mkdir "$appex_resources"
|
||||
cp -R "$SAFARI_EXT_RESOURCES/." "$appex_resources/"
|
||||
|
||||
# Show the connector version in Safari
|
||||
connector_version=$(python3 -c "import json, sys; print(json.load(open(sys.argv[1]))['version'])" "$appex_resources/manifest.json")
|
||||
if [[ $connector_version == *999* ]] && [ "$UPDATE_CHANNEL" != "test" ]; then
|
||||
echo "Placeholder connector version $connector_version not allowed for '$UPDATE_CHANNEL' channel -- aborting" 2>&1
|
||||
exit 1
|
||||
fi
|
||||
/usr/libexec/PlistBuddy -c "Set CFBundleShortVersionString $connector_version" \
|
||||
"$webext_appex/Contents/Info.plist"
|
||||
/usr/libexec/PlistBuddy -c "Set CFBundleVersion $connector_version" \
|
||||
"$webext_appex/Contents/Info.plist"
|
||||
|
||||
# Give the appex the same bundle identifier prefix as the parent app
|
||||
/usr/libexec/PlistBuddy -c "Set CFBundleIdentifier $bundle_identifier.SafariWebExtension" \
|
||||
"$webext_appex/Contents/Info.plist"
|
||||
|
||||
# Replace the legacy App Extension on Safari versions that can load the web extension
|
||||
/usr/libexec/PlistBuddy -c "Add :NSExtension:SFSafariAppExtensionBundleIdentifiersToReplace array" \
|
||||
"$webext_appex/Contents/Info.plist"
|
||||
/usr/libexec/PlistBuddy -c "Add :NSExtension:SFSafariAppExtensionBundleIdentifiersToReplace:0 string $bundle_identifier.SafariExtension" \
|
||||
"$webext_appex/Contents/Info.plist"
|
||||
|
||||
# Add legacy Safari App Extension
|
||||
if [[ -n "${SAFARI_APP_EXTENSION:-}" ]]; then
|
||||
if [[ ! -d "$SAFARI_APP_EXTENSION" ]]; then
|
||||
echo "SAFARI_APP_EXTENSION not found at $SAFARI_APP_EXTENSION -- aborting" 2>&1
|
||||
exit 1
|
||||
fi
|
||||
appext_appex="$APPDIR/Contents/PlugIns/ZoteroSafariExtension.appex"
|
||||
cp -R "$SAFARI_APP_EXTENSION" "$appext_appex"
|
||||
rm -rf "$appext_appex/Contents/Resources/safari/test"
|
||||
/usr/libexec/PlistBuddy -c "Set CFBundleIdentifier $bundle_identifier.SafariExtension" \
|
||||
"$appext_appex/Contents/Info.plist"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Copy over removed-files and make a precomplete file
|
||||
|
|
@ -721,34 +845,36 @@ if [ $BUILD_MAC == 1 ]; then
|
|||
|
||||
# Sign .jnilib (Java native shared library) within LibreOffice extension, since notarization
|
||||
# started failing without this. The .jnilib is within a .jar within the .oxt, so we have to
|
||||
# extract both, sign the library, and then update each ZIP.
|
||||
# extract both, sign the libraries, and then update each ZIP.
|
||||
#
|
||||
# TODO: Remove this block once the plugin ships a jna.jar without the macOS native
|
||||
# libraries, which are never loaded (JNA is used only on Windows)
|
||||
pushd "$BUILD_DIR"
|
||||
mkdir libreoffice-repack
|
||||
cd libreoffice-repack
|
||||
unzip -q "$APPDIR/Contents/Resources/integration/libreoffice/Zotero_LibreOffice_Integration.oxt" external_jars/jna.jar
|
||||
unzip -q external_jars/jna.jar com/sun/jna/darwin/libjnidispatch.jnilib
|
||||
/usr/bin/codesign --force --options runtime --sign "$DEVELOPER_ID" com/sun/jna/darwin/libjnidispatch.jnilib
|
||||
zip -u external_jars/jna.jar com/sun/jna/darwin/libjnidispatch.jnilib
|
||||
unzip -q external_jars/jna.jar 'com/sun/jna/darwin*/libjnidispatch.jnilib'
|
||||
/usr/bin/codesign --force --options runtime --sign "$DEVELOPER_ID" com/sun/jna/darwin*/libjnidispatch.jnilib
|
||||
zip -u external_jars/jna.jar com/sun/jna/darwin*/libjnidispatch.jnilib
|
||||
zip -u "$APPDIR/Contents/Resources/integration/libreoffice/Zotero_LibreOffice_Integration.oxt" external_jars/jna.jar
|
||||
cd ..
|
||||
rm -rf libreoffice-repack
|
||||
popd
|
||||
|
||||
# Sign Safari App Extension
|
||||
# Sign Safari extensions
|
||||
#
|
||||
# Even though it's signed by Xcode, we sign it again to make sure it matches the parent app signature
|
||||
if [ -d "$APPDIR/Contents/PlugIns/ZoteroSafariExtension.appex" ]; then
|
||||
# Even though they're signed by Xcode, we sign them again to make sure they match the parent app signature
|
||||
for appex in "$APPDIR"/Contents/PlugIns/*.appex; do
|
||||
if [ ! -d "$appex" ]; then
|
||||
continue
|
||||
fi
|
||||
echo
|
||||
# Extract entitlements, which differ from parent app
|
||||
/usr/bin/codesign -d --entitlements "$BUILD_DIR/safari-entitlements.plist" --xml "$SAFARI_APPEX"
|
||||
/usr/bin/codesign -d --entitlements "$BUILD_DIR/safari-entitlements.plist" --xml "$appex"
|
||||
|
||||
# Change appex bundle identifier to have same prefix as parent app
|
||||
bundle_identifier=$(/usr/libexec/PlistBuddy -c "Print CFBundleIdentifier" "$APPDIR/Contents/Info.plist")
|
||||
perl -pi -e "s/org\.zotero\.SafariExtensionApp\.SafariExtension/$bundle_identifier.SafariExtension/" "$APPDIR/Contents/PlugIns/ZoteroSafariExtension.appex/Contents/Info.plist"
|
||||
|
||||
find "$APPDIR/Contents/PlugIns/ZoteroSafariExtension.appex/Contents" -name '*.dylib' -exec /usr/bin/codesign --force --options runtime --entitlements "$entitlements_file" --sign "$DEVELOPER_ID" {} \;
|
||||
/usr/bin/codesign --force --options runtime --entitlements "$BUILD_DIR/safari-entitlements.plist" --sign "$DEVELOPER_ID" "$APPDIR/Contents/PlugIns/ZoteroSafariExtension.appex"
|
||||
fi
|
||||
find "$appex/Contents" -name '*.dylib' -exec /usr/bin/codesign --force --options runtime --entitlements "$entitlements_file" --sign "$DEVELOPER_ID" {} \;
|
||||
/usr/bin/codesign --force --options runtime --entitlements "$BUILD_DIR/safari-entitlements.plist" --sign "$DEVELOPER_ID" "$appex"
|
||||
done
|
||||
|
||||
# Sign final app package
|
||||
echo
|
||||
|
|
@ -756,11 +882,14 @@ if [ $BUILD_MAC == 1 ]; then
|
|||
|
||||
# Verify app
|
||||
/usr/bin/codesign --verify -vvvv "$APPDIR"
|
||||
# Verify Safari App Extension
|
||||
if [[ -n "$SAFARI_APPEX" ]] && [[ -d "$SAFARI_APPEX" ]]; then
|
||||
# Verify Safari extensions
|
||||
for appex in "$APPDIR"/Contents/PlugIns/*.appex; do
|
||||
if [ ! -d "$appex" ]; then
|
||||
continue
|
||||
fi
|
||||
echo
|
||||
/usr/bin/codesign --verify -vvvv "$APPDIR/Contents/PlugIns/ZoteroSafariExtension.appex"
|
||||
fi
|
||||
/usr/bin/codesign --verify -vvvv "$appex"
|
||||
done
|
||||
fi
|
||||
|
||||
# Build and notarize disk image
|
||||
|
|
@ -833,7 +962,7 @@ if [ $BUILD_WIN == 1 ]; then
|
|||
#
|
||||
# 'i686' is a huge directory containing x86 versions of xul.dll and other files in
|
||||
# Firefox ARM64 builds for use with the EME DRM plugins
|
||||
cp -R "$runtime_path"/!(application.ini|browser|crashreporter*|default-browser-agent.exe|defaultagent*|defaults|devtools-files|firefox*|i686|maintenanceservice*|minidump-analyzer.exe|pingsender.exe|private_browsing*|precomplete|removed-files|uninstall|update*) "$APPDIR"
|
||||
cp -R "$runtime_path"/!(application.ini|browser|crashhelper.exe|crashreporter*|default-browser-agent.exe|defaultagent*|defaults|desktop-launcher|devtools-files|firefox*|i686|maintenanceservice*|minidump-analyzer.exe|nmhproxy.exe|pingsender.exe|private_browsing*|precomplete|removed-files|uninstall|update*) "$APPDIR"
|
||||
|
||||
# Copy zotero.exe, which is built directly from Firefox source and then modified by
|
||||
# ResourceHacker to add icons
|
||||
|
|
@ -858,11 +987,11 @@ if [ $BUILD_WIN == 1 ]; then
|
|||
fi
|
||||
|
||||
# Copy app files
|
||||
rsync -a "$base_dir/" "$APPDIR/"
|
||||
copy_dir "$base_dir" "$APPDIR"
|
||||
#mv "$APPDIR/app/application.ini" "$APPDIR/"
|
||||
|
||||
# Copy in common files
|
||||
rsync -a "$COMMON_APPDIR/" "$APPDIR/"
|
||||
copy_dir "$COMMON_APPDIR" "$APPDIR"
|
||||
|
||||
cat "$CALLDIR/win/installer/updater_append.ini" >> "$APPDIR/updater.ini"
|
||||
|
||||
|
|
@ -1004,7 +1133,7 @@ if [ $BUILD_LINUX == 1 ]; then
|
|||
if [[ -n $arch ]]; then
|
||||
archs=("$(get_canonical_arch l $arch)")
|
||||
else
|
||||
archs=(x64 arm64 i686)
|
||||
archs=(x64 arm64)
|
||||
fi
|
||||
for arch in "${archs[@]}"; do
|
||||
[[ $arch == x64 ]] && arch="x86_64"
|
||||
|
|
@ -1018,7 +1147,7 @@ if [ $BUILD_LINUX == 1 ]; then
|
|||
mkdir "$APPDIR"
|
||||
|
||||
# Merge relevant assets from Firefox
|
||||
cp -r "$runtime_path/"!(application.ini|browser|defaults|devtools-files|crashreporter|crashreporter.ini|firefox|pingsender|precomplete|removed-files|run-mozilla.sh|update-settings.ini|updater|updater.ini) "$APPDIR"
|
||||
cp -r "$runtime_path/"!(application.ini|browser|crashhelper|crashreporter|crashreporter.ini|defaults|devtools-files|firefox|pingsender|precomplete|removed-files|run-mozilla.sh|update-settings.ini|updater|updater.ini) "$APPDIR"
|
||||
|
||||
# Use our own launcher that calls the original Firefox executable with -app
|
||||
mv "$APPDIR"/firefox-bin "$APPDIR"/zotero-bin
|
||||
|
|
@ -1034,12 +1163,14 @@ if [ $BUILD_LINUX == 1 ]; then
|
|||
chmod 755 "$APPDIR/updater"
|
||||
|
||||
# Copy app files
|
||||
rsync -a "$base_dir/" "$APPDIR/"
|
||||
copy_dir "$base_dir" "$APPDIR"
|
||||
|
||||
# Add word processor plug-ins
|
||||
mkdir "$APPDIR/integration"
|
||||
cp -RH "$CALLDIR/modules/zotero-libreoffice-integration/install" "$APPDIR/integration/libreoffice"
|
||||
|
||||
# Firefox includes an 'icons' folder with updater.png, but some Linux distro builds omit it
|
||||
mkdir -p "$APPDIR/icons"
|
||||
# Copy icons
|
||||
cp "$CALLDIR/linux/icons/icon32.png" "$APPDIR/icons/"
|
||||
cp "$CALLDIR/linux/icons/icon64.png" "$APPDIR/icons/"
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
|
||||
# Version of Gecko to build with
|
||||
GECKO_VERSION_MAC="140.6.0esr"
|
||||
GECKO_VERSION_LINUX="140.6.0esr"
|
||||
GECKO_VERSION_WIN="140.6.0esr"
|
||||
RUST_VERSION=1.86.0
|
||||
GECKO_VERSION_MAC="153.1.0esr"
|
||||
GECKO_VERSION_LINUX="153.1.0esr"
|
||||
GECKO_VERSION_WIN="153.1.0esr"
|
||||
RUST_VERSION=1.94.0
|
||||
|
||||
# URL prefix for custom builds of Firefox components
|
||||
custom_components_url="https://download.zotero.org/dev/firefox-components/"
|
||||
custom_components_hash_mac=""
|
||||
custom_components_hash_win_x64="ff5092d6b4c4cbcc459bef833e00782f3dae8ce327e63b326e4fa1e21ea76a00"
|
||||
custom_components_hash_win_arm64="e61d36a2c26887e6cbd2e6961e07bc8aa0a75e7d9412f9df3e38bf9d998c04f3"
|
||||
custom_components_hash_win32="a9bbd2e414964745900b8af499b7f74b79243d96313d11969ada0930966003ab"
|
||||
custom_components_hash_win_x64="ac2cd5e2d4190c07af1649ca0372ff07dae70afd880d309441dafb575ccdc61a"
|
||||
custom_components_hash_win_arm64="2369eb75912fb91633fb16d0f756c78d41f533522a3415bb4746c2a911547083"
|
||||
custom_components_hash_win32="8eea54a3754b5f5331fc9147a6e7741976d70ddb48c8759f3b1a77e8729d8019"
|
||||
|
||||
APP_NAME="Zotero"
|
||||
APP_ID="zotero\@zotero.org"
|
||||
|
|
@ -45,8 +45,7 @@ S3_CI_ZIP_PATH="ci/client"
|
|||
S3_DIST_PATH="client"
|
||||
|
||||
DEPLOY_HOST="deploy.zotero"
|
||||
DEPLOY_PATH="www/www-production/public/download/client/manifests"
|
||||
DEPLOY_CMD="ssh $DEPLOY_HOST update-site-files"
|
||||
DEPLOY_PATH="www/www-production/public/download/client"
|
||||
|
||||
BUILD_PLATFORMS=""
|
||||
NUM_INCREMENTALS=6
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ APP_ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
|||
. "$APP_ROOT_DIR/config.sh"
|
||||
|
||||
if [ -z "${1:-}" ]; then
|
||||
echo "Usage: $0 x64|arm64|i686" >&2
|
||||
echo "Usage: $0 x64|arm64" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
@ -24,8 +24,6 @@ if [ $arch = "x64" ]; then
|
|||
rust_target=x86_64
|
||||
elif [ $arch = "arm64" ]; then
|
||||
rust_target=aarch64
|
||||
elif [ $arch = "i686" ]; then
|
||||
rust_target=i686
|
||||
else
|
||||
echo "Unknown architecture $arch" >&2
|
||||
exit 1
|
||||
|
|
@ -37,8 +35,6 @@ touch "$gecko_path/mozconfig"
|
|||
|
||||
if [ $arch == "arm64" ]; then
|
||||
echo "ac_add_options --target=aarch64-linux-gnu" >> "$gecko_path/mozconfig"
|
||||
elif [ $arch == "i686" ]; then
|
||||
echo "ac_add_options --target=i686" >> "$gecko_path/mozconfig"
|
||||
fi
|
||||
|
||||
cat "$SCRIPT_DIR/mozconfig" >> "$gecko_path/mozconfig"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ fi
|
|||
|
||||
version="$1"
|
||||
|
||||
for arch in x86_64 aarch64 i686; do
|
||||
for arch in x86_64 aarch64; do
|
||||
package="firefox-${version}.en-US.linux-${arch}.tar.xz"
|
||||
if [ $arch = 'aarch64' ]; then
|
||||
arch="arm64"
|
||||
|
|
@ -36,8 +36,8 @@ if [ `uname` = "Darwin" ]; then
|
|||
else
|
||||
tar=tar
|
||||
fi
|
||||
$tar --owner=0 --group=0 --numeric-owner --no-xattrs --mode=0755 -cJvf updater.tar.xz updater-x86_64 updater-arm64 updater-i686
|
||||
$tar --owner=0 --group=0 --numeric-owner --no-xattrs --mode=0755 -cJvf updater.tar.xz updater-x86_64 updater-arm64
|
||||
|
||||
rm updater-x86_64 updater-arm64 updater-i686
|
||||
rm updater-x86_64 updater-arm64
|
||||
|
||||
echo "Done: updater.tar.xz created"
|
||||
|
|
|
|||
|
|
@ -303,6 +303,7 @@ my(@gCleanup, %gConfig, $gDarwinMajor, $gDryRun, $gVerbosity);
|
|||
'cmd_chmod' => 'chmod',
|
||||
'cmd_diskutil' => 'diskutil',
|
||||
'cmd_du' => 'du',
|
||||
'cmd_find' => 'find',
|
||||
'cmd_hdid' => 'hdid',
|
||||
'cmd_hdiutil' => 'hdiutil',
|
||||
'cmd_mkdir' => 'mkdir',
|
||||
|
|
@ -311,6 +312,7 @@ my(@gCleanup, %gConfig, $gDarwinMajor, $gDryRun, $gVerbosity);
|
|||
'cmd_rm' => 'rm',
|
||||
'cmd_rsync' => 'rsync',
|
||||
'cmd_SetFile' => '/Developer/Tools/SetFile',
|
||||
'cmd_xattr' => 'xattr',
|
||||
|
||||
# create_directly indicates whether hdiutil create supports
|
||||
# -srcfolder and -srcdevice. It does on >= 10.3 (Panther).
|
||||
|
|
@ -996,6 +998,65 @@ sub diskImageMaker($$$$$$$$) {
|
|||
cleanupDie('unlink hybridImage failed: '.$!);
|
||||
}
|
||||
}
|
||||
|
||||
# hdiutil makehybrid gives every file on the image non-empty
|
||||
# Finder info (it sets an icon location), which appears as a
|
||||
# com.apple.FinderInfo extended attribute that "codesign
|
||||
# --verify --strict" rejects as detritus and that can cause
|
||||
# Safari to silently ignore a bundled web extension (observed
|
||||
# with Safari 17.6 on macOS 12; the exact trigger conditions
|
||||
# are unknown). Mount a read-write copy of the image and strip
|
||||
# the attributes before compressing. Note that Finder flags set
|
||||
# with --attribute are stored in the same Finder info, so if
|
||||
# --attribute is ever used, the affected files would need to be
|
||||
# excluded from the strip.
|
||||
if($uncompressedImage eq $hybridImage) {
|
||||
my($udrwImage);
|
||||
$udrwImage = giveExtension($tempDir.'/udrw', '.dmg');
|
||||
|
||||
if(command($gConfig{'cmd_hdiutil'}, 'convert', '-format', 'UDRW',
|
||||
'-ov', $hybridImage, '-o', $udrwImage) != 0) {
|
||||
cleanupDie('hdiutil convert to UDRW failed');
|
||||
}
|
||||
|
||||
push(@gCleanup,
|
||||
sub {commandInternalVerbosity(0, 'unlink', $udrwImage);});
|
||||
|
||||
$uncompressedImage = $udrwImage;
|
||||
|
||||
# $hybridImage is no longer needed. Remove it and its cleanup
|
||||
# entry, which is below the entry for $udrwImage.
|
||||
my(@tempCleanup) = splice(@gCleanup, -2);
|
||||
push(@gCleanup, $tempCleanup[1]);
|
||||
|
||||
if(commandInternal('unlink', $hybridImage) != 1) {
|
||||
cleanupDie('unlink hybridImage failed: '.$!);
|
||||
}
|
||||
}
|
||||
|
||||
my($rootDevice, $partitionDevice, $partitionMountPoint);
|
||||
if(!(($rootDevice, $partitionDevice, $partitionMountPoint) =
|
||||
hdidMountImage($tempMount, $uncompressedImage))) {
|
||||
cleanupDie('hdid mount failed');
|
||||
}
|
||||
|
||||
push(@gCleanup, sub {commandVerbosity(0,
|
||||
$gConfig{'cmd_diskutil'}, 'eject', $rootDevice);});
|
||||
|
||||
if(command($gConfig{'cmd_find'}, $partitionMountPoint,
|
||||
'-mindepth', '1', '-not', '-type', 'l',
|
||||
'-xattrname', 'com.apple.FinderInfo',
|
||||
'-exec', $gConfig{'cmd_xattr'}, '-d', 'com.apple.FinderInfo',
|
||||
'{}', '+') != 0) {
|
||||
cleanupDie('stripping com.apple.FinderInfo failed');
|
||||
}
|
||||
|
||||
# Pop diskutil eject
|
||||
pop(@gCleanup);
|
||||
|
||||
if(command($gConfig{'cmd_diskutil'}, 'eject', $rootDevice) != 0) {
|
||||
cleanupDie('diskutil eject failed');
|
||||
}
|
||||
}
|
||||
else {
|
||||
# makehybrid is not available, fall back to making a UDRW and
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ binary=$1
|
|||
from_channel=$2
|
||||
to_channel=$3
|
||||
# `strings` has a 4-character minimum by default, but we need 3 for 'dev'
|
||||
strings_cmd="strings -n 3"
|
||||
# `-arch all` scans all slices of the universal binary rather than just the host architecture
|
||||
strings_cmd="strings -n 3 -arch all"
|
||||
|
||||
if [ ${#to_channel} -gt 7 ]; then
|
||||
echo "Channel length cannot exceed 7 characters -- aborting" >&2
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit 692759ba43d77a2448b37e6d1dd7992a2ba59007
|
||||
Subproject commit 227551c1644c6cb1156226d4db5d1b293fdb271d
|
||||
|
|
@ -1 +1 @@
|
|||
Subproject commit 74eed0152378d776ced32000100a8a90d3c0a705
|
||||
Subproject commit 51faa4b21e1a433a6c0f69a4bdfc5a7882341f23
|
||||
|
|
@ -1 +1 @@
|
|||
Subproject commit e960ae3a3cc676f6279d61c8b222bb9a8f149da6
|
||||
Subproject commit c0aa6e4bef039d94e17e81cb28b1fe9170c45b96
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
APP_ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
ROOT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
||||
. "$APP_ROOT_DIR/config.sh"
|
||||
|
||||
CHANNEL="beta"
|
||||
BRANCH="main"
|
||||
export SAFARI_APPEX="$ROOT_DIR/../safari-app-extension-builds/beta/ZoteroSafariExtension.appex"
|
||||
|
||||
# Set Safari extension LSMinimumSystemVersion to Mojave for betas
|
||||
perl -pi -e 's/<string>11\.0<\/string>/<string>10.14<\/string>/' "$SAFARI_APPEX"/Contents/Info.plist
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
./check_requirements
|
||||
|
||||
hash=`./get_repo_branch_hash $BRANCH`
|
||||
source_dir=`./get_commit_files $hash`
|
||||
build_dir=`mktemp -d`
|
||||
|
||||
function cleanup {
|
||||
rm -rf "$source_dir"
|
||||
rm -rf "$build_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
./prepare_build -s "$source_dir" -o "$build_dir" -c $CHANNEL -m $hash
|
||||
./build_and_deploy -d "$build_dir" -p $BUILD_PLATFORMS -c $CHANNEL
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
APP_ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
ROOT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
||||
. "$APP_ROOT_DIR/config.sh"
|
||||
|
||||
CHANNEL="dev"
|
||||
BRANCH="main"
|
||||
export SAFARI_APPEX="$ROOT_DIR/../safari-app-extension-builds/dev/ZoteroSafariExtension.appex"
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
./check_requirements
|
||||
|
||||
hash=`./get_repo_branch_hash $BRANCH`
|
||||
source_dir=`./get_commit_files $hash`
|
||||
build_dir=`mktemp -d`
|
||||
|
||||
function cleanup {
|
||||
rm -rf "$source_dir"
|
||||
rm -rf "$build_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
./prepare_build -s "$source_dir" -o "$build_dir" -c $CHANNEL -m $hash
|
||||
./build_and_deploy -d "$build_dir" -p $BUILD_PLATFORMS -c $CHANNEL -i 1
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
APP_ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
ROOT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
||||
. "$APP_ROOT_DIR/config.sh"
|
||||
|
||||
CHANNEL="release"
|
||||
BRANCH="main"
|
||||
if [ "`uname`" = "Darwin" ]; then
|
||||
export SAFARI_APPEX="$ROOT_DIR/../safari-app-extension-builds/release/ZoteroSafariExtension.appex"
|
||||
fi
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
./check_requirements
|
||||
|
||||
hash=`./get_repo_branch_hash $BRANCH`
|
||||
source_dir=`./get_commit_files $hash`
|
||||
build_dir=`mktemp -d`
|
||||
|
||||
function cleanup {
|
||||
rm -rf "$source_dir"
|
||||
rm -rf "$build_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
./prepare_build -s "$source_dir" -o "$build_dir" -c $CHANNEL -m $hash
|
||||
./build_and_deploy -d "$build_dir" -p $BUILD_PLATFORMS -c $CHANNEL
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
APP_ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
ROOT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
||||
. "$APP_ROOT_DIR/config.sh"
|
||||
|
||||
CHANNEL="test"
|
||||
BRANCH="main"
|
||||
export SAFARI_APPEX="$ROOT_DIR/../safari-app-extension-builds/test/ZoteroSafariExtension.appex"
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
./check_requirements
|
||||
|
||||
hash=`./get_repo_branch_hash $BRANCH`
|
||||
source_dir=`./get_commit_files $hash`
|
||||
build_dir=`mktemp -d`
|
||||
|
||||
function cleanup {
|
||||
rm -rf "$source_dir"
|
||||
rm -rf "$build_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
./prepare_build -s "$source_dir" -o "$build_dir" -c $CHANNEL -m $hash
|
||||
./build_and_deploy -d "$build_dir" -p $BUILD_PLATFORMS -c $CHANNEL -i 1
|
||||
59
app/scripts/9.0_release_build_and_deploy
Executable file
59
app/scripts/9.0_release_build_and_deploy
Executable file
|
|
@ -0,0 +1,59 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
APP_ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
ROOT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
||||
. "$APP_ROOT_DIR/config.sh"
|
||||
|
||||
CHANNEL="release"
|
||||
BRANCH="9.0"
|
||||
BUILD_ONLY=0
|
||||
if [ "`uname`" = "Darwin" ]; then
|
||||
export SAFARI_APPEX="$ROOT_DIR/../safari-web-extension-builds/ZoteroSafariExtension.appex"
|
||||
export SAFARI_EXT_RESOURCES="$ROOT_DIR/../safari-web-extension-builds/release/safari"
|
||||
if [ ! -d "$SAFARI_APPEX" ]; then
|
||||
echo "Safari extension stub not found at $SAFARI_APPEX -- aborting" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$SAFARI_EXT_RESOURCES/manifest.json" ]; then
|
||||
echo "Safari web extension not found in $SAFARI_EXT_RESOURCES -- aborting" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
while getopts "b" opt; do
|
||||
case $opt in
|
||||
b)
|
||||
BUILD_ONLY=1
|
||||
;;
|
||||
esac
|
||||
shift $((OPTIND-1)); OPTIND=1
|
||||
done
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
./check_requirements
|
||||
|
||||
hash=`./get_repo_branch_hash $BRANCH`
|
||||
./check_app_matches_commit $hash
|
||||
source_dir=`./get_commit_files $hash`
|
||||
build_dir=`mktemp -d`
|
||||
|
||||
function cleanup {
|
||||
rm -rf "$source_dir"
|
||||
rm -rf "$build_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
./prepare_build -s "$source_dir" -o "$build_dir" -c $CHANNEL -m $hash
|
||||
VERSION="`cat \"$build_dir/version\"`"
|
||||
./build_for_deploy -d "$build_dir" -p $BUILD_PLATFORMS -c $CHANNEL
|
||||
|
||||
if [ $BUILD_ONLY -eq 1 ]; then
|
||||
echo
|
||||
echo "Build only -- skipping deploy."
|
||||
echo "To deploy, run on the deploy server:"
|
||||
echo " $DEPLOY_PATH/deploy $CHANNEL $VERSION $BUILD_PLATFORMS"
|
||||
else
|
||||
ssh $DEPLOY_HOST "$DEPLOY_PATH/deploy" $CHANNEL "$VERSION" $BUILD_PLATFORMS
|
||||
fi
|
||||
|
|
@ -37,11 +37,14 @@ win_path="$STAGE_DIR/Zotero_win-x64"
|
|||
linux_path="$STAGE_DIR/Zotero_linux-x86_64"
|
||||
|
||||
added=0
|
||||
build_id=$(date +%Y%m%d%H%M%S)
|
||||
for path in "$mac_path" "$win_path" "$linux_path"; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path/app/omni.ja"
|
||||
echo "Updating $(basename $(dirname $(dirname $path)))"
|
||||
zip "$path/app/omni.ja" $files
|
||||
# Bump BuildID so that startup caches are invalidated
|
||||
perl -pi -e "s/^BuildID=.*/BuildID=$build_id/" "$path/app/application.ini"
|
||||
added=1
|
||||
fi
|
||||
done
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ Update a builds manifest with info on a given build
|
|||
"""
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import shutil
|
||||
import json
|
||||
|
|
@ -25,7 +26,7 @@ def main():
|
|||
try:
|
||||
file = args.file
|
||||
version = args.version
|
||||
short_version = version[0:3]
|
||||
short_version = re.match(r'\d+\.\d+', version).group(0)
|
||||
|
||||
# Back up JSON file
|
||||
shutil.copy2(file, file + '.bak')
|
||||
|
|
|
|||
64
app/scripts/beta_build_and_deploy
Executable file
64
app/scripts/beta_build_and_deploy
Executable file
|
|
@ -0,0 +1,64 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
APP_ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
ROOT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
||||
. "$APP_ROOT_DIR/config.sh"
|
||||
|
||||
CHANNEL="beta"
|
||||
BRANCH="main"
|
||||
BUILD_ONLY=0
|
||||
if [ "`uname`" = "Darwin" ]; then
|
||||
export SAFARI_APPEX="$ROOT_DIR/../safari-web-extension-builds/ZoteroSafariExtension.appex"
|
||||
export SAFARI_EXT_RESOURCES="$ROOT_DIR/../safari-web-extension-builds/beta/safari"
|
||||
export SAFARI_APP_EXTENSION="$ROOT_DIR/../safari-app-extension-builds/beta/ZoteroSafariExtension.appex"
|
||||
if [ ! -d "$SAFARI_APPEX" ]; then
|
||||
echo "Safari extension stub not found at $SAFARI_APPEX -- aborting" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$SAFARI_EXT_RESOURCES/manifest.json" ]; then
|
||||
echo "Safari web extension not found in $SAFARI_EXT_RESOURCES -- aborting" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -d "$SAFARI_APP_EXTENSION" ]; then
|
||||
echo "Safari App Extension not found at $SAFARI_APP_EXTENSION -- aborting" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
while getopts "b" opt; do
|
||||
case $opt in
|
||||
b)
|
||||
BUILD_ONLY=1
|
||||
;;
|
||||
esac
|
||||
shift $((OPTIND-1)); OPTIND=1
|
||||
done
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
./check_requirements
|
||||
|
||||
hash=`./get_repo_branch_hash $BRANCH`
|
||||
./check_app_matches_commit $hash
|
||||
source_dir=`./get_commit_files $hash`
|
||||
build_dir=`mktemp -d`
|
||||
|
||||
function cleanup {
|
||||
rm -rf "$source_dir"
|
||||
rm -rf "$build_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
./prepare_build -s "$source_dir" -o "$build_dir" -c $CHANNEL -m $hash
|
||||
VERSION="`cat \"$build_dir/version\"`"
|
||||
./build_for_deploy -d "$build_dir" -p $BUILD_PLATFORMS -c $CHANNEL
|
||||
|
||||
if [ $BUILD_ONLY -eq 1 ]; then
|
||||
echo
|
||||
echo "Build only -- skipping deploy."
|
||||
echo "To deploy, run on the deploy server:"
|
||||
echo " $DEPLOY_PATH/deploy $CHANNEL $VERSION $BUILD_PLATFORMS"
|
||||
else
|
||||
ssh $DEPLOY_HOST "$DEPLOY_PATH/deploy" $CHANNEL "$VERSION" $BUILD_PLATFORMS
|
||||
fi
|
||||
|
|
@ -10,13 +10,23 @@ if [ -n "${ZOTERO_PROFILE:-}" ]; then
|
|||
profile_args=(-p "$ZOTERO_PROFILE")
|
||||
fi
|
||||
|
||||
REBUILD=0
|
||||
FORCE_FULL=0
|
||||
NO_REBUILD=0
|
||||
SKIP_BUNDLED_FILES=0
|
||||
DEBUGGER=0
|
||||
while getopts "rbd" opt; do
|
||||
while getopts "rfnbd" opt; do
|
||||
case $opt in
|
||||
r)
|
||||
REBUILD=1
|
||||
# Deprecated -- rebuilding is now the default
|
||||
echo "-r is deprecated -- rebuilding is now the default (use -n to skip)" >&2
|
||||
;;
|
||||
|
||||
f)
|
||||
FORCE_FULL=1
|
||||
;;
|
||||
|
||||
n)
|
||||
NO_REBUILD=1
|
||||
;;
|
||||
|
||||
b)
|
||||
|
|
@ -28,7 +38,6 @@ while getopts "rbd" opt; do
|
|||
;;
|
||||
|
||||
\?)
|
||||
echo "Invalid option: -$OPTARG" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
|
@ -37,12 +46,15 @@ done
|
|||
# Remove options from $@
|
||||
shift $((OPTIND-1))
|
||||
|
||||
if [ $REBUILD -eq 1 ]; then
|
||||
if [ $NO_REBUILD -eq 0 ]; then
|
||||
PARAMS=""
|
||||
if [ $DEBUGGER -eq 1 ]; then
|
||||
PARAMS="-t"
|
||||
fi
|
||||
|
||||
if [ $FORCE_FULL -eq 1 ]; then
|
||||
PARAMS="$PARAMS -f"
|
||||
fi
|
||||
|
||||
# Check if build watch is running
|
||||
# If not, run now
|
||||
if ! ps u | grep js-build/build.js | grep -v grep > /dev/null; then
|
||||
|
|
@ -50,16 +62,11 @@ if [ $REBUILD -eq 1 ]; then
|
|||
echo
|
||||
cd $ROOT_DIR
|
||||
# TEMP: --openssl-legacy-provider avoids a build error in pdf.js
|
||||
NODE_OPTIONS=--openssl-legacy-provider npm run build
|
||||
NODE_OPTIONS=--openssl-legacy-provider node js-build/build.js
|
||||
echo
|
||||
fi
|
||||
|
||||
"$SCRIPT_DIR/dir_build" -q $PARAMS
|
||||
|
||||
if [ "`uname`" = "Darwin" ]; then
|
||||
# Sign the Word dylib so it works on Apple Silicon
|
||||
"$SCRIPT_DIR/codesign_local" "$APP_ROOT_DIR/staging/Zotero.app"
|
||||
fi
|
||||
|
||||
"$SCRIPT_DIR/dir_build" $PARAMS
|
||||
fi
|
||||
|
||||
PARAMS=""
|
||||
|
|
@ -87,4 +94,5 @@ else
|
|||
exit 1
|
||||
fi
|
||||
|
||||
"$APP_ROOT_DIR/staging/$command" "${profile_args[@]}" -ZoteroDebugText -purgecaches $PARAMS "$@"
|
||||
echo
|
||||
"$APP_ROOT_DIR/staging/$command" "${profile_args[@]}" -ZoteroDebugText $PARAMS "$@"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
#!/bin/bash
|
||||
#
|
||||
# Builds and deploys Zotero with full and incremental updates
|
||||
# Builds Zotero and uploads to S3 and deploy server, but does not make the build live.
|
||||
# To make the build live, run the deploy script on the deploy server (or via SSH).
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
|
|
@ -107,13 +108,23 @@ do
|
|||
usage
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
echo
|
||||
echo "Getting $platform_name incrementals"
|
||||
INCREMENTALS="`\"$SCRIPT_DIR/manage_incrementals\" -c $CHANNEL -p ${PLATFORMS:i:1} -n $NUM_INCREMENTALS`"
|
||||
|
||||
# Fetch pinned incrementals from deploy server and merge
|
||||
PINNED_FILE="$DEPLOY_PATH/manifests/$CHANNEL/pinned-incrementals-$platform"
|
||||
PINNED=""
|
||||
if ssh $DEPLOY_HOST "test -f $PINNED_FILE"; then
|
||||
PINNED="`ssh $DEPLOY_HOST cat $PINNED_FILE`"
|
||||
fi
|
||||
|
||||
# Combine and deduplicate
|
||||
INCREMENTALS="`echo -e "$INCREMENTALS\n$PINNED" | sort -u | grep -v '^$' || true`"
|
||||
echo "$INCREMENTALS"
|
||||
echo
|
||||
|
||||
|
||||
if [ -n "$INCREMENTALS" ]; then
|
||||
for from in $INCREMENTALS; do
|
||||
echo "Building incremental update for $platform_name from $from to $VERSION"
|
||||
|
|
@ -126,46 +137,31 @@ done
|
|||
# Upload builds to S3
|
||||
"$SCRIPT_DIR/upload_builds" $CHANNEL $VERSION
|
||||
|
||||
# Upload file lists for each platform
|
||||
channel_deploy_path="$DEPLOY_PATH/$CHANNEL"
|
||||
# Upload file lists and build info for each platform
|
||||
channel_deploy_path="$DEPLOY_PATH/manifests/$CHANNEL"
|
||||
mkdir "$TEMP_DIR/version_info"
|
||||
chmod g+ws "$TEMP_DIR/version_info"
|
||||
cp "$DIST_DIR"/files-* "$TEMP_DIR/version_info"
|
||||
chmod g+w "$TEMP_DIR"/version_info/files-*
|
||||
rsync -rv "$TEMP_DIR/version_info/" $DEPLOY_HOST:"$channel_deploy_path/$VERSION/"
|
||||
|
||||
# Download updates JSON for each platform, update it, and reupload it
|
||||
# Generate build-{os}.json for each platform
|
||||
SHORT_VERSION=$(echo "$VERSION" | grep -oE '^[0-9]+\.[0-9]+')
|
||||
DETAILS_URL="https://www.zotero.org/support/${SHORT_VERSION}_changelog"
|
||||
for i in `seq 0 1 $((${#PLATFORMS}-1))`
|
||||
do
|
||||
case ${PLATFORMS:i:1} in
|
||||
m)
|
||||
architectures="mac"
|
||||
;;
|
||||
w)
|
||||
architectures="win32 win-x64 win-arm64"
|
||||
;;
|
||||
l)
|
||||
architectures="linux-i686 linux-x86_64 linux-arm64"
|
||||
;;
|
||||
m) os_name="mac" ;;
|
||||
w) os_name="win" ;;
|
||||
l) os_name="linux" ;;
|
||||
esac
|
||||
|
||||
for arch in $architectures;
|
||||
do
|
||||
jsonfile="updates-$arch.json"
|
||||
|
||||
scp $DEPLOY_HOST:"$channel_deploy_path/$jsonfile" "$TEMP_DIR/$jsonfile"
|
||||
"$SCRIPT_DIR/add_version_info" -f "$TEMP_DIR/$jsonfile" -v $VERSION -b $BUILD_ID
|
||||
scp "$TEMP_DIR/$jsonfile" $DEPLOY_HOST:"$channel_deploy_path/$jsonfile"
|
||||
done
|
||||
printf '{\n "buildID": "%s",\n "detailsURL": "%s"\n}\n' "$BUILD_ID" "$DETAILS_URL" \
|
||||
> "$TEMP_DIR/version_info/build-${os_name}.json"
|
||||
chmod g+w "$TEMP_DIR/version_info/build-${os_name}.json"
|
||||
done
|
||||
|
||||
# Add version to incremental lists
|
||||
echo
|
||||
for i in `seq 0 1 $((${#PLATFORMS}-1))`
|
||||
do
|
||||
"$SCRIPT_DIR/manage_incrementals" -c $CHANNEL -p ${PLATFORMS:i:1} -a $VERSION
|
||||
done
|
||||
|
||||
$DEPLOY_CMD
|
||||
rsync -rv "$TEMP_DIR/version_info/" $DEPLOY_HOST:"$channel_deploy_path/$VERSION/"
|
||||
|
||||
rm -rf "$STAGE_DIR"/*
|
||||
|
||||
echo
|
||||
echo "Build $VERSION uploaded successfully."
|
||||
echo "To deploy, run on the deploy server:"
|
||||
echo " ./deploy $CHANNEL $VERSION $PLATFORMS"
|
||||
37
app/scripts/check_app_matches_commit
Executable file
37
app/scripts/check_app_matches_commit
Executable file
|
|
@ -0,0 +1,37 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Check that app/ in this checkout matches app/ in the commit being built.
|
||||
#
|
||||
# The build-and-deploy scripts run the build scripts and config from the local
|
||||
# checkout but build source files from the tip of the remote branch, so a stale
|
||||
# checkout can silently build with the wrong Gecko version or omni patches.
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
ROOT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
||||
|
||||
if [ -z "${1:-}" ]; then
|
||||
echo "Usage: $0 commit-hash" >&2
|
||||
exit 1
|
||||
fi
|
||||
hash=$1
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
if ! git cat-file -e "$hash^{commit}" 2>/dev/null; then
|
||||
git fetch -q origin
|
||||
fi
|
||||
if ! git cat-file -e "$hash^{commit}" 2>/dev/null; then
|
||||
echo "Commit $hash not found locally after fetching -- aborting" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git diff --quiet HEAD "$hash" -- app/; then
|
||||
echo >&2
|
||||
echo "app/ files in this checkout differ from app/ in $hash:" >&2
|
||||
echo >&2
|
||||
git --no-pager diff --stat HEAD "$hash" -- app/ >&2
|
||||
echo >&2
|
||||
echo "Update this checkout (e.g., 'git pull') and try again" >&2
|
||||
exit 1
|
||||
fi
|
||||
41
app/scripts/dev_build_and_deploy
Executable file
41
app/scripts/dev_build_and_deploy
Executable file
|
|
@ -0,0 +1,41 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
APP_ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
ROOT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
||||
. "$APP_ROOT_DIR/config.sh"
|
||||
|
||||
CHANNEL="dev"
|
||||
BRANCH="main"
|
||||
if [ "`uname`" = "Darwin" ]; then
|
||||
export SAFARI_APPEX="$ROOT_DIR/../safari-web-extension-builds/ZoteroSafariExtension.appex"
|
||||
export SAFARI_EXT_RESOURCES="$ROOT_DIR/../safari-web-extension-builds/dev/safari"
|
||||
if [ ! -d "$SAFARI_APPEX" ]; then
|
||||
echo "Safari extension stub not found at $SAFARI_APPEX -- aborting" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$SAFARI_EXT_RESOURCES/manifest.json" ]; then
|
||||
echo "Safari web extension not found in $SAFARI_EXT_RESOURCES -- aborting" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
./check_requirements
|
||||
|
||||
hash=`./get_repo_branch_hash $BRANCH`
|
||||
./check_app_matches_commit $hash
|
||||
source_dir=`./get_commit_files $hash`
|
||||
build_dir=`mktemp -d`
|
||||
|
||||
function cleanup {
|
||||
rm -rf "$source_dir"
|
||||
rm -rf "$build_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
./prepare_build -s "$source_dir" -o "$build_dir" -c $CHANNEL -m $hash
|
||||
VERSION="`cat \"$build_dir/version\"`"
|
||||
./build_for_deploy -d "$build_dir" -p $BUILD_PLATFORMS -c $CHANNEL -i 1
|
||||
ssh $DEPLOY_HOST "$DEPLOY_PATH/deploy" $CHANNEL "$VERSION" $BUILD_PLATFORMS
|
||||
|
|
@ -5,6 +5,7 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
|||
APP_ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
ROOT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
||||
. "$APP_ROOT_DIR/config.sh"
|
||||
. "$SCRIPT_DIR/utils.sh"
|
||||
|
||||
function usage {
|
||||
cat >&2 <<DONE
|
||||
|
|
@ -15,9 +16,9 @@ Architecture is ignored for Mac builds, which are always shipped as universal bi
|
|||
|
||||
Options
|
||||
-p PLATFORM Platform to build (m=Mac, w=Windows, l=Linux)
|
||||
-a ARCH Target architecture (arm64, x64, i686, win32) — **Windows/Linux only**
|
||||
-a ARCH Target architecture (arm64, x64, win32) — **Windows/Linux only**
|
||||
-t add devtools
|
||||
-q quick build (skip compression and other optional steps for faster restarts during development)
|
||||
-f force a full rebuild instead of updating the staged build in place
|
||||
DONE
|
||||
exit 1
|
||||
}
|
||||
|
|
@ -25,8 +26,8 @@ DONE
|
|||
platform=""
|
||||
arch=""
|
||||
devtools=0
|
||||
quick_build=0
|
||||
while getopts "tp:a:q" opt; do
|
||||
force_full=0
|
||||
while getopts "tp:a:f" opt; do
|
||||
case $opt in
|
||||
t)
|
||||
devtools=1
|
||||
|
|
@ -47,12 +48,11 @@ while getopts "tp:a:q" opt; do
|
|||
a)
|
||||
arch="$OPTARG"
|
||||
;;
|
||||
q)
|
||||
quick_build=1
|
||||
f)
|
||||
force_full=1
|
||||
;;
|
||||
\?)
|
||||
echo "Invalid option: -$OPTARG" >&2
|
||||
exit 1
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
|
@ -76,29 +76,100 @@ if [[ $platform = "m" ]]; then
|
|||
else
|
||||
# Windows / Linux: derive arch if not supplied
|
||||
if [[ -z $arch ]]; then
|
||||
case "$(uname -m)" in
|
||||
arm64|aarch64) arch="arm64" ;;
|
||||
x86_64) arch="x64" ;;
|
||||
i?86) arch="i686" ;;
|
||||
esac
|
||||
# An x64 build of Git for Windows runs emulated on Windows on ARM, where it
|
||||
# reports x86_64 from `uname -m`, while `uname -s` reports the native
|
||||
# architecture
|
||||
if [[ $platform = "w" && "$(uname -s)" == *ARM64* ]]; then
|
||||
arch="arm64"
|
||||
else
|
||||
case "$(uname -m)" in
|
||||
arm64|aarch64) arch="arm64" ;;
|
||||
x86_64) arch="x64" ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
CHANNEL="source"
|
||||
|
||||
# Remove stale symlinks left behind in build/ when source files are deleted,
|
||||
# which would otherwise break the build
|
||||
if [ -d "$ROOT_DIR/build" ]; then
|
||||
# With -L, -type l matches only broken symlinks
|
||||
stale_links=$(find -L "$ROOT_DIR/build" -type l -print)
|
||||
if [ -n "$stale_links" ]; then
|
||||
echo "Removing stale symlinks from build/:" >&2
|
||||
echo "$stale_links" >&2
|
||||
while IFS= read -r link; do
|
||||
rm "$link"
|
||||
done <<< "$stale_links"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Try to update the staged build in place instead of doing a full rebuild
|
||||
if [[ $force_full -eq 0 ]]; then
|
||||
incr_cmd=("$SCRIPT_DIR/incremental_update" -p "$platform")
|
||||
[[ -n $arch ]] && incr_cmd+=( -a "$arch" )
|
||||
[[ $devtools -eq 1 ]] && incr_cmd+=( -t )
|
||||
set +e
|
||||
"${incr_cmd[@]}"
|
||||
incr_status=$?
|
||||
set -e
|
||||
if [ $incr_status -ne 2 ]; then
|
||||
exit $incr_status
|
||||
fi
|
||||
fi
|
||||
|
||||
hash=$(git -C "$ROOT_DIR" rev-parse --short HEAD)
|
||||
|
||||
build_dir=$(mktemp -d)
|
||||
cleanup() { rm -rf "$build_dir"; }
|
||||
manifest_tmp=""
|
||||
cleanup() {
|
||||
rm -rf "$build_dir"
|
||||
if [ -n "$manifest_tmp" ]; then
|
||||
rm -f "$manifest_tmp"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Snapshot the state of build/ and app/ before the build for later incremental
|
||||
# updates
|
||||
app_hash=""
|
||||
if [ -d "$ROOT_DIR/build" ]; then
|
||||
manifest_tmp=$(mktemp)
|
||||
generate_build_manifest "$ROOT_DIR/build" > "$manifest_tmp"
|
||||
app_hash=$(generate_app_hash "$APP_ROOT_DIR")
|
||||
fi
|
||||
|
||||
"$SCRIPT_DIR/prepare_build" -s "$ROOT_DIR/build" -o "$build_dir" -c "$CHANNEL" -m "$hash"
|
||||
|
||||
build_cmd=("$APP_ROOT_DIR/build.sh" -d "$build_dir" -p "$platform" -c "$CHANNEL" -s)
|
||||
build_cmd=("$APP_ROOT_DIR/build.sh" -d "$build_dir" -p "$platform" -c "$CHANNEL" -s -q)
|
||||
[[ -n $arch ]] && build_cmd+=( -a "$arch" )
|
||||
[[ $devtools -eq 1 ]] && build_cmd+=( -t )
|
||||
[[ $quick_build -eq 1 ]] && build_cmd+=( -q )
|
||||
|
||||
"${build_cmd[@]}"
|
||||
|
||||
# Save manifest for future incremental updates
|
||||
if [ -n "$manifest_tmp" ]; then
|
||||
if [[ -z "${ZOTERO_TEST:-}" ]] || [[ "${ZOTERO_TEST:-}" == "0" ]]; then
|
||||
include_tests=0
|
||||
else
|
||||
include_tests=1
|
||||
fi
|
||||
{
|
||||
echo "#format=2"
|
||||
echo "#include_tests=$include_tests"
|
||||
echo "#devtools=$devtools"
|
||||
echo "#app_hash=$app_hash"
|
||||
cat "$manifest_tmp"
|
||||
} > "$STAGE_DIR/.build-manifest"
|
||||
fi
|
||||
|
||||
# Ad-hoc-sign the Word dylib so it works on Apple Silicon. This only needs to
|
||||
# happen when the staged build is fully rebuilt -- incremental updates don't
|
||||
# touch the dylib.
|
||||
if [[ $platform = "m" ]] && [[ "$(uname -s)" = "Darwin" ]]; then
|
||||
"$SCRIPT_DIR/codesign_local" "$STAGE_DIR/Zotero.app"
|
||||
fi
|
||||
|
||||
echo Done
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ Required
|
|||
Optional / Conditional
|
||||
-a ARCH - Mac: disallowed (builds are universal)
|
||||
- Windows: x64 | arm64 | win32
|
||||
- Linux: x64 (or x86_64) | arm64 | i686
|
||||
- Linux: x64 (or x86_64) | arm64
|
||||
If omitted on Windows/Linux, all standard archs are fetched.
|
||||
DONE
|
||||
exit 1
|
||||
|
|
@ -87,7 +87,7 @@ if [[ $BUILD_WIN == 1 || $BUILD_LINUX == 1 ]]; then
|
|||
fi
|
||||
if [[ $BUILD_LINUX == 1 ]]; then
|
||||
[[ $arch == x64 ]] && arch="x86_64"
|
||||
case $arch in x86_64|i686|arm64) ;; *) echo "Invalid Linux arch: $arch" >&2; echo; usage;; esac
|
||||
case $arch in x86_64|arm64) ;; *) echo "Invalid Linux arch: $arch" >&2; echo; usage;; esac
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
|
@ -130,8 +130,15 @@ function modify_omni {
|
|||
rm actors/PictureInPictureChild.sys.mjs
|
||||
rm actors/ThumbnailsChild.sys.mjs
|
||||
rm actors/Translations{Engine,}{Parent,Child}.sys.mjs
|
||||
rm actors/AudioPlayback{Parent,Child}.sys.mjs
|
||||
|
||||
replace_line 'BROWSER_CHROME_URL:.+' 'BROWSER_CHROME_URL: "chrome:\/\/zotero\/content\/zoteroPane.xhtml",' modules/AppConstants.sys.mjs
|
||||
# Used by OSKeyStore as the master-key label, visible in macOS Keychain Access.
|
||||
# Verify that OSKeyStore still derives the label from MOZ_APP_BASENAME, so a
|
||||
# future Mozilla change to a hardcoded string doesn't silently rebrand the
|
||||
# keychain entry back to "Firefox Encrypted Storage".
|
||||
replace_line 'MOZ_APP_BASENAME: "Firefox"' 'MOZ_APP_BASENAME: "Zotero"' modules/AppConstants.sys.mjs
|
||||
check_line 'STORE_LABEL: AppConstants\.MOZ_APP_BASENAME \+ " Encrypted Storage"' modules/OSKeyStore.sys.mjs
|
||||
|
||||
# https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/internals/preferences.html
|
||||
#
|
||||
|
|
@ -162,6 +169,20 @@ function modify_omni {
|
|||
|
||||
# Prevent error during network requests
|
||||
replace_line 'async lazyInit\(\) \{' 'async lazyInit() { if (this.features) return false;' modules/UrlClassifierExceptionListService.sys.mjs
|
||||
|
||||
# Silence CrashManager "Could not get children of [...]/Crash Reports/events" spam
|
||||
replace_line '\/\/ Missing events folders are allowed' '\/\/ Missing events folders are allowed (silence warning)
|
||||
if (ex) return entries;' modules/CrashManager.sys.mjs
|
||||
|
||||
# Several services pull in services-settings (which we strip) and throw
|
||||
# at profile-after-change. Remove their #init() methods to prevent errors.
|
||||
replace_line 'async #init\(\) \{' 'async #init() {} async _unused_init() {' modules/EssentialDomainsRemoteSettings.sys.mjs
|
||||
replace_line 'async #init\(\) \{' 'async #init() {} async _unused_init() {' modules/SimpleURIUnknownSchemesRemoteObserver.sys.mjs
|
||||
# RemotePermissionService uses a top-level import, so even loading the
|
||||
# module throws
|
||||
replace_line 'import \{ RemoteSettings \} from "resource:\/\/services-settings\/remote-settings.sys.mjs";' \
|
||||
'const RemoteSettings = () => ({ on() {}, get: async () => [] });' \
|
||||
modules/RemotePermissionService.sys.mjs
|
||||
|
||||
replace_line 'pref\("network.captive-portal-service.enabled".+' 'pref("network.captive-portal-service.enabled", false);' greprefs.js
|
||||
replace_line 'pref\("network.connectivity-service.enabled".+' 'pref("network.connectivity-service.enabled", false);' greprefs.js
|
||||
|
|
@ -180,14 +201,17 @@ function modify_omni {
|
|||
|
||||
file="modules/ActorManagerParent.sys.mjs"
|
||||
# Remove deleted actors
|
||||
remove_between 'AboutTranslations: \{' '^ },' $file
|
||||
remove_between 'CookieBanner: \{' '^ },' $file
|
||||
remove_between 'PictureInPictureLauncher: \{' '^ },' $file
|
||||
remove_between 'PictureInPictureToggle: \{' '^ },' $file
|
||||
remove_between 'PictureInPicture: \{' '^ },' $file
|
||||
remove_between 'Thumbnails: \{' '^ },' $file
|
||||
remove_between 'Translations: \{' '^ },' $file
|
||||
remove_between 'TranslationsEngine: \{' '^ },' $file
|
||||
# Entries in the JSWINDOWACTORS/JSPROCESSACTORS literals:
|
||||
remove_between '^ AudioPlayback: \{' '^ },' $file
|
||||
remove_between '^ CookieBanner: \{' '^ },' $file
|
||||
remove_between '^ Thumbnails: \{' '^ },' $file
|
||||
remove_between '^ Translations: \{' '^ },' $file
|
||||
remove_between '^ TranslationsEngine: \{' '^ },' $file
|
||||
# Entries added conditionally after the literals:
|
||||
remove_between '^ JSWINDOWACTORS\.AboutTranslations = \{' '^ \};' $file
|
||||
remove_between '^ JSWINDOWACTORS\.PictureInPictureLauncher = \{' '^ \};' $file
|
||||
remove_between '^ JSWINDOWACTORS\.PictureInPictureToggle = \{' '^ \};' $file
|
||||
remove_between '^ JSWINDOWACTORS\.PictureInPicture = \{' '^ \};' $file
|
||||
# Do not trigger LoginManager event that logs an error on autocomplete submission
|
||||
remove_line 'DOMInputPasswordAdded: \{\},' $file
|
||||
|
||||
|
|
@ -245,6 +269,20 @@ function modify_omni {
|
|||
# When installing addon, use app version instead of toolkit version for targetApplication
|
||||
replace_line "id: TOOLKIT_ID," "id: '$APP_ID'," modules/addons/XPIInstall.sys.mjs
|
||||
|
||||
# Set strictCompatibility to false in beta/dev/source builds to skip strict_max_version check
|
||||
replace_line 'addon.strictCompatibility = true;' \
|
||||
'let version = Services.appinfo.version;
|
||||
addon.strictCompatibility = !version.includes("-beta") && !version.includes("-dev") && !version.includes("SOURCE");' modules/addons/XPIInstall.sys.mjs
|
||||
|
||||
# In beta/dev/source builds, ignore the persisted strictCompatibility flag for already-installed
|
||||
# plugins, so plugins installed on a stable build (where the flag was set to true) aren't marked
|
||||
# incompatible after upgrading. The manifest isn't re-parsed on app upgrade, so the flag set at
|
||||
# install time sticks around.
|
||||
replace_line '!this\.addon\.strictCompatibility' \
|
||||
'(!this.addon.strictCompatibility || Services.appinfo.version.includes("-beta") || Services.appinfo.version.includes("-dev") || Services.appinfo.version.includes("SOURCE"))' modules/addons/XPIInstall.sys.mjs
|
||||
replace_line '!this\.strictCompatibility &&' \
|
||||
'(!this.strictCompatibility || Services.appinfo.version.includes("-beta") || Services.appinfo.version.includes("-dev") || Services.appinfo.version.includes("SOURCE")) &&' modules/addons/XPIDatabase.sys.mjs
|
||||
|
||||
# Accept zotero@chnm.gmu.edu for target application to allow Zotero 6 plugins to remain
|
||||
# installed in Zotero 7
|
||||
replace_line "if \(targetApp.id == Services.appinfo.ID\) \{" "if (targetApp.id == 'zotero\@chnm.gmu.edu') targetApp.id = '$APP_ID'; if (targetApp.id == Services.appinfo.ID) {" modules/addons/XPIDatabase.sys.mjs
|
||||
|
|
@ -262,6 +300,11 @@ function modify_omni {
|
|||
replace_line "id: TOOLKIT_ID," "id: '$APP_ID'," modules/addons/AddonUpdateChecker.sys.mjs
|
||||
replace_line 'lazy.AddonManagerPrivate.webExtensionsMinPlatformVersion' '"7.0"' modules/addons/AddonUpdateChecker.sys.mjs
|
||||
replace_line 'result.targetApplications.push' 'false && result.targetApplications.push' modules/addons/AddonUpdateChecker.sys.mjs
|
||||
# In beta/dev/source builds, don't set strictCompatibility on parsed update entries so that
|
||||
# AddonUpdateChecker doesn't reject an available update whose maxVersion is lower than the
|
||||
# current (non-stable) app version.
|
||||
replace_line 'result\.strictCompatibility = appEntry\.maxVersion != "\*";' \
|
||||
'let appVer = Services.appinfo.version; result.strictCompatibility = appEntry.maxVersion != "*" && !appVer.includes("-beta") && !appVer.includes("-dev") && !appVer.includes("SOURCE");' modules/addons/AddonUpdateChecker.sys.mjs
|
||||
# Set uninstall flag to true if the update config has it set
|
||||
replace_line 'targetApplications: \[appEntry\],' \
|
||||
'targetApplications: \[appEntry\],
|
||||
|
|
@ -352,8 +395,12 @@ function modify_omni {
|
|||
|
||||
file="chrome/toolkit/content/mozapps/extensions/aboutaddons.css"
|
||||
echo >> $file
|
||||
# Hide search bar, Themes and Plugins tabs, and sidebar footer
|
||||
echo '.main-search, button[name="theme"], button[name="plugin"], sidebar-footer { display: none; }' >> $file
|
||||
# Hide the search bar and the whole sidebar, since we only ever show plugins and the main pane
|
||||
# is already headed "Manage Your Plugins"
|
||||
echo '.main-search, #sidebar { display: none; }' >> $file
|
||||
# Center the content in the window now that it isn't offset by the sidebar
|
||||
echo '#full { grid-template-columns: 1fr; }' >> $file
|
||||
echo '#content { max-width: calc(var(--page-main-content-width) + var(--main-margin-start)); margin-inline: auto; }' >> $file
|
||||
echo '.main-heading { margin-top: 2em; }' >> $file
|
||||
# Hide Details/Permissions tabs in addon details so we only show details
|
||||
echo 'addon-details > button-group { display: none !important; }' >> $file
|
||||
|
|
@ -369,38 +416,46 @@ function modify_omni {
|
|||
echo '.addon-detail-row-homepage .text-link { cursor: pointer; color: LinkText; }' >> $file
|
||||
echo '.addon-detail-row-homepage .text-link:hover { text-decoration: underline; }' >> $file
|
||||
|
||||
file="chrome/toolkit/content/mozapps/extensions/aboutaddons.js"
|
||||
file="chrome/toolkit/content/mozapps/extensions/aboutaddons-utils.mjs"
|
||||
# Hide unsigned-addon warning
|
||||
replace_line 'if \(!isCorrectlySigned\(addon\)\) \{' 'if (!isCorrectlySigned(addon)) {return {};' $file
|
||||
replace_line 'export function isUnsignedWarningMessageDisabled\(\) \{' \
|
||||
'export function isUnsignedWarningMessageDisabled() {if (true) return true;' $file
|
||||
# Use our own localized string for blocked plugin notification
|
||||
replace_line 'details-notification-hard-blocked-\$\{typeSuffix\}' 'plugins-blocked-plugin' $file
|
||||
# Hide Recommendations tab in sidebar and recommendations in main pane
|
||||
replace_line 'function isDiscoverEnabled\(\) \{' 'function isDiscoverEnabled() {return false;' $file
|
||||
|
||||
file="chrome/toolkit/content/mozapps/extensions/components/addon-details.mjs"
|
||||
# Hide Private Browsing setting in addon details
|
||||
replace_line 'pbRow\.' '\/\/pbRow.' $file
|
||||
replace_line 'let isAllowed = await isAllowedInPrivateBrowsing' '\/\/let isAllowed = await isAllowedInPrivateBrowsing' $file
|
||||
# Use our own strings for the removal prompt
|
||||
replace_line 'let \{ BrowserAddonUI \} = windowRoot.ownerGlobal;' '' $file
|
||||
replace_line 'await BrowserAddonUI.promptRemoveExtension' 'promptRemoveExtension' $file
|
||||
|
||||
# Customize empty-list message
|
||||
replace_line 'createEmptyListMessage\(\) {' 'createEmptyListMessage() {
|
||||
var p = document.createElement("p");
|
||||
p.id = "empty-list-message";
|
||||
return p;' $file
|
||||
# Swap in include.js, which we need for Zotero.getString(), for abuse-reports.js, which we don't need
|
||||
|
||||
# Open plugin links in external browser
|
||||
replace_line 'let homepageURL = homepageRow.querySelector\(\"a\"\);' 'let homepageURL = homepageRow.querySelector(\"\.text-link\");' $file
|
||||
replace_line 'homepageURL.href = addon.homepageURL;' 'homepageURL.setAttribute("href", addon.homepageURL);' $file
|
||||
replace_line '<a target="_blank" dir="ltr"><\/a>' \
|
||||
'<label target="_blank" class="text-link" dir="ltr"><\/label>' \
|
||||
'<label target="_blank" class="text-link" dir="ltr"><\/label>' $file
|
||||
|
||||
file="chrome/toolkit/content/mozapps/extensions/components/addon-card.mjs"
|
||||
# Use our own strings for the removal prompt
|
||||
replace_line 'let \{ BrowserAddonUI \} = windowRoot.window;' '' $file
|
||||
replace_line 'await BrowserAddonUI.promptRemoveExtension' 'promptRemoveExtension' $file
|
||||
|
||||
# Customize empty-list message
|
||||
replace_line 'createEmptyListMessage\(\) {' 'createEmptyListMessage() {
|
||||
var p = document.createElement("p");
|
||||
p.id = "empty-list-message";
|
||||
return p;' chrome/toolkit/content/mozapps/extensions/components/addon-list.mjs
|
||||
|
||||
# Add zotero.ftl to the addons page for our own localized strings
|
||||
replace_line 'href="toolkit\/about\/aboutAddons.ftl"' 'href="toolkit\/about\/aboutAddons.ftl" \/>\n <link rel="localization" href="zotero.ftl"' \
|
||||
chrome/toolkit/content/mozapps/extensions/aboutaddons.html
|
||||
|
||||
|
||||
# TODO: Use our own strings, but for now fix the bundled English ones
|
||||
replace_line 'Manage Your Extensions' 'Manage Your Plugins' localization/en-US/toolkit/about/aboutAddons.ftl
|
||||
replace_line '= Extensions' '= Plugins' localization/en-US/toolkit/about/aboutAddons.ftl
|
||||
replace_line 'Add-on' 'Plugin' localization/en-US/toolkit/about/aboutAddons.ftl
|
||||
replace_line 'add-ons' 'plugins' localization/en-US/toolkit/about/aboutAddons.ftl
|
||||
replace_line 'Select add-on to install' 'Select plugin to install' localization/en-US/toolkit/about/aboutAddons.ftl
|
||||
|
||||
# Hide Recommendations tab in sidebar and recommendations in main pane
|
||||
replace_line 'function isDiscoverEnabled\(\) \{' 'function isDiscoverEnabled() {return false;' chrome/toolkit/content/mozapps/extensions/aboutaddonsCommon.js
|
||||
replace_line 'pref\("extensions.htmlaboutaddons.recommendations.enabled".+' 'pref("extensions.htmlaboutaddons.recommendations.enabled", false);' greprefs.js
|
||||
|
||||
# Hide Report option
|
||||
|
|
@ -417,7 +472,7 @@ function modify_omni {
|
|||
chrome/toolkit/content/global/commonDialog.xhtml
|
||||
# commonDialog.css link is split across multiple lines, so we have to do a weird substitution,
|
||||
# so check the one-line global.css to make sure the format hasn't changed
|
||||
check_line '<html:link rel="stylesheet" href="chrome:\/\/global\/skin\/global.css" \/>'
|
||||
check_line '<html:link rel="stylesheet" href="chrome:\/\/global\/skin\/global.css" \/>' chrome/toolkit/content/global/commonDialog.xhtml
|
||||
replace_line 'chrome:\/\/global\/skin\/commonDialog.css"' \
|
||||
'chrome:\/\/global\/skin\/commonDialog.css"\/>
|
||||
<html:link rel="stylesheet" href="chrome:\/\/zotero-platform\/content\/zotero.css"' \
|
||||
|
|
@ -447,8 +502,19 @@ function modify_omni {
|
|||
}
|
||||
popupset.appendChild(this._autoScrollPopup);' chrome/toolkit/content/global/elements/browser-custom-element.mjs
|
||||
|
||||
# Remove aria-autocomplete unnecessarily added to search-textbox to not confuse screen readers
|
||||
remove_line 'this.inputField.setAttribute\("aria-autocomplete' chrome/toolkit/content/global/elements/search-textbox.js
|
||||
# Add the dropmarker for toolbarbuttons with wantdropmarker, which Firefox no longer creates
|
||||
replace_line 'this.appendChild\(this.constructor.fragment.cloneNode\(true\)\);' \
|
||||
'this.appendChild(this.constructor.fragment.cloneNode(true));
|
||||
|
||||
if (this.hasAttribute("wantdropmarker")) {
|
||||
let dropmarker = document.createXULElement("dropmarker");
|
||||
dropmarker.setAttribute("type", "menu");
|
||||
dropmarker.className = "toolbarbutton-menu-dropmarker";
|
||||
this.appendChild(dropmarker);
|
||||
}' chrome/toolkit/content/global/elements/toolbarbutton.js
|
||||
# Let clicks on the dropmarker pass through to the toolbarbutton, which only opens its menu
|
||||
# when it's the original event target, as xul.css did when Firefox created the dropmarker
|
||||
echo '.toolbarbutton-menu-dropmarker { pointer-events: none; }' >> chrome/toolkit/content/global/xul.css
|
||||
|
||||
# Remove non-native text input styles
|
||||
remove_between 'html\|input\:where\(' '^}' chrome/toolkit/skin/classic/global/global-shared.css
|
||||
|
|
@ -462,10 +528,12 @@ function modify_omni {
|
|||
overflow: var(--moz-text-control-overflow);
|
||||
}' >> chrome/toolkit/res/forms.css
|
||||
|
||||
# By default, an autocomplete popup's width is calculated based on the input that opened it.
|
||||
# Allow an ancestor to designate itself as the width container instead. (For creator inputs.)
|
||||
replace_line 'aElement.getBoundingClientRect\(\).width' \
|
||||
'(aElement.closest(".autocomplete-popup-width-container") || aElement).getBoundingClientRect().width' \
|
||||
# Size an autocomplete popup to the input that opened it, or to an ancestor that designates
|
||||
# itself as the width container. Firefox sizes the popup to its content. (For creator inputs.)
|
||||
replace_line '\/\/ invalidate\(\) depends on the width attribute' \
|
||||
'let widthElement = aElement.closest(".autocomplete-popup-width-container") || aElement;
|
||||
this.style.setProperty("--panel-width", Math.max(widthElement.getBoundingClientRect().width, 100) + "px");
|
||||
\/\/ invalidate() depends on the width attribute' \
|
||||
chrome/toolkit/content/global/elements/autocomplete-popup.js
|
||||
|
||||
zip -qr9XD omni.ja *
|
||||
|
|
@ -513,7 +581,7 @@ if [ $BUILD_MAC == 1 ]; then
|
|||
echo "Using Firefox $GECKO_VERSION.app.zip"
|
||||
unzip "Firefox $GECKO_VERSION.app.zip"
|
||||
else
|
||||
curl -o Firefox.dmg "$DOWNLOAD_URL/mac/en-US/Firefox%20$GECKO_VERSION.dmg"
|
||||
curl -fo Firefox.dmg "$DOWNLOAD_URL/mac/en-US/Firefox%20$GECKO_VERSION.dmg"
|
||||
set +e
|
||||
hdiutil detach -quiet /Volumes/Firefox 2>/dev/null
|
||||
set -e
|
||||
|
|
@ -599,7 +667,7 @@ if [ $BUILD_WIN == 1 ]; then
|
|||
else
|
||||
moz_arch=$arch
|
||||
fi
|
||||
curl -O "$DOWNLOAD_URL/$moz_arch/en-US/Firefox%20Setup%20$GECKO_VERSION.exe"
|
||||
curl -fO "$DOWNLOAD_URL/$moz_arch/en-US/Firefox%20Setup%20$GECKO_VERSION.exe"
|
||||
# Store local copy of unmodified app
|
||||
cp "Firefox%20Setup%20$GECKO_VERSION.exe" "$archived_file"
|
||||
fi
|
||||
|
|
@ -715,7 +783,7 @@ if [ $BUILD_LINUX == 1 ]; then
|
|||
if [[ -n $arch ]]; then
|
||||
arch_list=("$arch")
|
||||
else
|
||||
arch_list=(x86_64 arm64 i686)
|
||||
arch_list=(x86_64 arm64)
|
||||
fi
|
||||
for arch in "${arch_list[@]}"; do
|
||||
xdir="firefox-linux-$arch"
|
||||
|
|
@ -731,7 +799,7 @@ if [ $BUILD_LINUX == 1 ]; then
|
|||
else
|
||||
moz_arch=$arch
|
||||
fi
|
||||
curl -O "$DOWNLOAD_URL/linux-$moz_arch/en-US/firefox-$GECKO_VERSION.tar.xz"
|
||||
curl -fO "$DOWNLOAD_URL/linux-$moz_arch/en-US/firefox-$GECKO_VERSION.tar.xz"
|
||||
# Store local copy of unmodified app
|
||||
cp "firefox-$GECKO_VERSION.tar.xz" "$archived_file"
|
||||
fi
|
||||
|
|
|
|||
322
app/scripts/incremental_update
Executable file
322
app/scripts/incremental_update
Executable file
|
|
@ -0,0 +1,322 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
#
|
||||
# Incrementally update a staged build in staging/ by zipping changed files from
|
||||
# the client build/ directory directly into app/omni.ja, based on a manifest
|
||||
# saved by dir_build after the last full build.
|
||||
#
|
||||
# Only files that are copied into omni.ja unmodified by build.sh can be updated
|
||||
# this way. If any other file changed (or a file was removed), a full rebuild
|
||||
# is required.
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 -- staged build was updated (or nothing changed)
|
||||
# 2 -- a full rebuild is required
|
||||
#
|
||||
# This is normally run automatically by dir_build, not directly.
|
||||
#
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
APP_ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
ROOT_DIR="$(dirname "$APP_ROOT_DIR")"
|
||||
. "$APP_ROOT_DIR/config.sh"
|
||||
. "$SCRIPT_DIR/utils.sh"
|
||||
|
||||
function usage {
|
||||
cat >&2 <<DONE
|
||||
Usage: $0 -p PLATFORM [-a ARCH] [-t]
|
||||
|
||||
Options
|
||||
-p PLATFORM platform of the staged build to update (m=Mac, w=Windows, l=Linux)
|
||||
-a ARCH target architecture (Windows/Linux only)
|
||||
-t devtools requested
|
||||
DONE
|
||||
exit 1
|
||||
}
|
||||
|
||||
platform=""
|
||||
arch=""
|
||||
devtools=0
|
||||
while getopts "p:a:t" opt; do
|
||||
case $opt in
|
||||
p)
|
||||
platform="$OPTARG"
|
||||
;;
|
||||
a)
|
||||
arch="$OPTARG"
|
||||
;;
|
||||
t)
|
||||
devtools=1
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$platform" ]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
BUILD_SRC_DIR="$ROOT_DIR/build"
|
||||
MANIFEST_FILE="$STAGE_DIR/.build-manifest"
|
||||
|
||||
function fall_back {
|
||||
echo "$1 -- performing full build" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
if [ ! -d "$BUILD_SRC_DIR" ]; then
|
||||
fall_back "No build/ directory"
|
||||
fi
|
||||
if [ ! -f "$MANIFEST_FILE" ]; then
|
||||
fall_back "No manifest from previous build"
|
||||
fi
|
||||
if [[ "$(sed -n 's/^#format=//p' "$MANIFEST_FILE")" != "2" ]]; then
|
||||
fall_back "Unsupported manifest format"
|
||||
fi
|
||||
|
||||
# Make sure a staged build exists for the requested platform
|
||||
case $platform in
|
||||
m)
|
||||
app_dir="$STAGE_DIR/Zotero.app/Contents/Resources"
|
||||
;;
|
||||
w)
|
||||
app_dir="$STAGE_DIR/Zotero_$(get_canonical_arch w "$arch")"
|
||||
;;
|
||||
l)
|
||||
app_dir="$STAGE_DIR/Zotero_linux-$(get_canonical_arch l "$arch")"
|
||||
;;
|
||||
*)
|
||||
fall_back "Unknown platform '$platform'"
|
||||
;;
|
||||
esac
|
||||
if [ ! -f "$app_dir/app/omni.ja" ]; then
|
||||
fall_back "No staged build for requested platform"
|
||||
fi
|
||||
|
||||
# If the previous build didn't include tests or devtools and they're requested
|
||||
# now, files are missing from the staged build
|
||||
manifest_tests=$(sed -n 's/^#include_tests=//p' "$MANIFEST_FILE")
|
||||
manifest_tests=${manifest_tests:-0}
|
||||
manifest_devtools=$(sed -n 's/^#devtools=//p' "$MANIFEST_FILE")
|
||||
manifest_devtools=${manifest_devtools:-0}
|
||||
if [[ -z "${ZOTERO_TEST:-}" ]] || [[ "${ZOTERO_TEST:-}" == "0" ]]; then
|
||||
want_tests=0
|
||||
else
|
||||
want_tests=1
|
||||
fi
|
||||
if [[ $want_tests -eq 1 ]] && [[ $manifest_tests != "1" ]]; then
|
||||
fall_back "Tests requested but not included in staged build"
|
||||
fi
|
||||
if [[ $devtools -eq 1 ]] && [[ $manifest_devtools != "1" ]]; then
|
||||
fall_back "Devtools requested but not included in staged build"
|
||||
fi
|
||||
|
||||
# If anything changed in app/ (build scripts, assets, bundled modules, Firefox
|
||||
# runtime), a full rebuild is required
|
||||
manifest_app_hash=$(sed -n 's/^#app_hash=//p' "$MANIFEST_FILE")
|
||||
app_hash=$(generate_app_hash "$APP_ROOT_DIR")
|
||||
if [[ -z "$manifest_app_hash" ]] || [[ "$app_hash" != "$manifest_app_hash" ]]; then
|
||||
fall_back "Files in app/ changed"
|
||||
fi
|
||||
|
||||
file_list=$(mktemp)
|
||||
new_stats=$(mktemp)
|
||||
carried=$(mktemp)
|
||||
candidates=$(mktemp)
|
||||
removed=$(mktemp)
|
||||
cand_entries=$(mktemp)
|
||||
trap 'rm -f "$file_list" "$new_stats" "$carried" "$candidates" "$removed" "$cand_entries"' EXIT
|
||||
|
||||
cd "$BUILD_SRC_DIR"
|
||||
|
||||
# Quickly find files that may have changed based on size/mtime, so that only
|
||||
# those have to be hashed. Unmodified manifest entries are carried over,
|
||||
# new/modified files become candidates, and files no longer present are
|
||||
# considered removed.
|
||||
build_manifest_file_list > "$file_list"
|
||||
stat_file_list < "$file_list" > "$new_stats"
|
||||
awk -F'\t' -v carried="$carried" -v candidates="$candidates" -v removed="$removed" '
|
||||
NR==FNR {
|
||||
if (/^#/) next
|
||||
hash[$3] = $1
|
||||
stat[$3] = $2
|
||||
next
|
||||
}
|
||||
{
|
||||
if (!($2 in stat)) {
|
||||
print > candidates
|
||||
}
|
||||
else {
|
||||
if ($1 == stat[$2]) {
|
||||
print hash[$2] "\t" $0 > carried
|
||||
}
|
||||
else {
|
||||
print > candidates
|
||||
}
|
||||
delete stat[$2]
|
||||
}
|
||||
}
|
||||
END {
|
||||
for (path in stat) print path > removed
|
||||
}
|
||||
' "$MANIFEST_FILE" "$new_stats"
|
||||
|
||||
# Hash the candidates and compare to the previous hashes to find real changes
|
||||
# -- A = added, C = changed, R = removed
|
||||
if [ -s "$candidates" ]; then
|
||||
paste \
|
||||
<(cut -f2 "$candidates" | sed 's,^,./,' | tr '\n' '\0' | hash_file_list | cut -f1) \
|
||||
"$candidates" \
|
||||
> "$cand_entries"
|
||||
fi
|
||||
changes=$(
|
||||
if [ -s "$cand_entries" ]; then
|
||||
awk -F'\t' '
|
||||
NR==FNR {
|
||||
if (/^#/) next
|
||||
old[$3] = $1
|
||||
next
|
||||
}
|
||||
{
|
||||
if (!($3 in old)) print "A\t" $3
|
||||
else if (old[$3] != $1) print "C\t" $3
|
||||
}
|
||||
' "$MANIFEST_FILE" "$cand_entries"
|
||||
fi
|
||||
if [ -s "$removed" ]; then
|
||||
awk '{print "R\t" $0}' "$removed"
|
||||
fi
|
||||
)
|
||||
|
||||
# Make sure all changed files can be zipped directly into omni.ja, and skip
|
||||
# ones that aren't included in the staged build
|
||||
zip_files=()
|
||||
ftl_files=()
|
||||
test_copy_files=()
|
||||
while IFS=$'\t' read -r status path; do
|
||||
if [ -z "$status" ]; then
|
||||
continue
|
||||
fi
|
||||
if [ "$status" == "R" ]; then
|
||||
case "$path" in
|
||||
test/*)
|
||||
# Ignore removed test files if tests weren't included
|
||||
if [[ $manifest_tests != "1" ]]; then
|
||||
continue
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fall_back "$path was removed"
|
||||
fi
|
||||
case "$path" in
|
||||
# Copied into localization/ subdirectories by build.sh
|
||||
chrome/locale/*/zotero/mozilla/*)
|
||||
fall_back "Can't update $path in place"
|
||||
;;
|
||||
# Pruned by prepare_build
|
||||
chrome/content/zotero/locale/csl/*)
|
||||
fall_back "Can't update $path in place"
|
||||
;;
|
||||
# Copied to localization/<locale>/ in addition to the chrome path
|
||||
chrome/locale/*/zotero/*.ftl)
|
||||
zip_files+=("$path")
|
||||
ftl_files+=("$path")
|
||||
;;
|
||||
chrome/*|components/*|resource/*)
|
||||
zip_files+=("$path")
|
||||
;;
|
||||
# Appended to the main chrome.manifest by build.sh
|
||||
test/chrome.manifest)
|
||||
fall_back "Can't update $path in place"
|
||||
;;
|
||||
test/*)
|
||||
# If tests weren't included in the staged build, ignore changes
|
||||
# to test files
|
||||
if [[ $manifest_tests == "1" ]]; then
|
||||
zip_files+=("$path")
|
||||
case "$path" in
|
||||
# Also copied to tests/ at the top level of the app
|
||||
test/tests/*)
|
||||
test_copy_files+=("$path")
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
fall_back "Can't update $path in place"
|
||||
;;
|
||||
esac
|
||||
done <<< "$changes"
|
||||
|
||||
# Find all staged omni.ja files to update (multiple can exist -- e.g., all
|
||||
# Windows architectures)
|
||||
omni_files=()
|
||||
while IFS= read -r file; do
|
||||
omni_files+=("$file")
|
||||
done < <(find "$STAGE_DIR" -path '*/app/omni.ja')
|
||||
|
||||
if [ ${#zip_files[@]} -gt 0 ]; then
|
||||
echo "Changed files:"
|
||||
for path in "${zip_files[@]}"; do
|
||||
echo " $path"
|
||||
done
|
||||
|
||||
cd "$BUILD_SRC_DIR"
|
||||
for omni_file in "${omni_files[@]}"; do
|
||||
echo "Updating ${omni_file#"$STAGE_DIR"/}"
|
||||
zip -qX "$omni_file" "${zip_files[@]}"
|
||||
done
|
||||
|
||||
# Copy .ftl files to their localization/ paths
|
||||
if [ ${#ftl_files[@]} -gt 0 ]; then
|
||||
l10n_dir=$(mktemp -d)
|
||||
for path in "${ftl_files[@]}"; do
|
||||
locale=${path#chrome/locale/}
|
||||
locale=${locale%%/*}
|
||||
mkdir -p "$l10n_dir/localization/$locale"
|
||||
cp -L "$path" "$l10n_dir/localization/$locale/$(basename "$path")"
|
||||
done
|
||||
pushd "$l10n_dir" > /dev/null
|
||||
for omni_file in "${omni_files[@]}"; do
|
||||
zip -qrX "$omni_file" localization
|
||||
done
|
||||
popd > /dev/null
|
||||
rm -rf "$l10n_dir"
|
||||
fi
|
||||
|
||||
# Copy test files to tests/ directories
|
||||
if [ ${#test_copy_files[@]} -gt 0 ]; then
|
||||
for path in "${test_copy_files[@]}"; do
|
||||
for omni_file in "${omni_files[@]}"; do
|
||||
dest="${omni_file%/app/omni.ja}/tests/${path#test/tests/}"
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
cp -L "$path" "$dest"
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
# Bump BuildID so that startup caches are invalidated even without
|
||||
# -purgecaches
|
||||
build_id=$(date +%Y%m%d%H%M%S)
|
||||
for omni_file in "${omni_files[@]}"; do
|
||||
perl -pi -e "s/^BuildID=.*/BuildID=$build_id/" "$(dirname "$omni_file")/application.ini"
|
||||
done
|
||||
|
||||
echo "Updated staged build with ${#zip_files[@]} changed file(s)"
|
||||
elif [ -n "$changes" ]; then
|
||||
echo "No staged files changed"
|
||||
else
|
||||
echo "No changes since last build"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "#format=2"
|
||||
echo "#include_tests=$manifest_tests"
|
||||
echo "#devtools=$manifest_devtools"
|
||||
echo "#app_hash=$app_hash"
|
||||
LC_ALL=C sort -t$'\t' -k3 "$carried" "$cand_entries"
|
||||
} > "$MANIFEST_FILE.tmp"
|
||||
mv "$MANIFEST_FILE.tmp" "$MANIFEST_FILE"
|
||||
|
|
@ -64,22 +64,19 @@ if [[ "$VERSION" ]] && [[ "$NUM_VERSIONS" ]]; then
|
|||
usage
|
||||
fi
|
||||
|
||||
INCR_FILENAME="incrementals-$CHANNEL-$PLATFORM"
|
||||
S3_URL="s3://$S3_BUCKET/$S3_DIST_PATH/$CHANNEL/incrementals-$PLATFORM"
|
||||
INCR_FILENAME="incrementals-$PLATFORM"
|
||||
REMOTE_PATH="$DEPLOY_PATH/manifests/$CHANNEL/$INCR_FILENAME"
|
||||
INCR_PATH="$DIST_DIR/$INCR_FILENAME"
|
||||
|
||||
if [ "`uname -o 2> /dev/null`" = "Cygwin" ]; then
|
||||
INCR_PATH=$(cygpath -w "$INCR_PATH")
|
||||
fi
|
||||
|
||||
mkdir -p "$DIST_DIR"
|
||||
aws s3 cp $S3_URL "$INCR_PATH" >&2
|
||||
scp $DEPLOY_HOST:"$REMOTE_PATH" "$INCR_PATH" >&2
|
||||
|
||||
# Add version to file and reupload
|
||||
if [ "$VERSION" ]; then
|
||||
echo "Adding $VERSION to incrementals-$PLATFORM"
|
||||
echo $VERSION >> "$INCR_PATH"
|
||||
aws s3 cp "$INCR_PATH" $S3_URL
|
||||
scp "$INCR_PATH" $DEPLOY_HOST:"$REMOTE_PATH"
|
||||
# Show last n versions
|
||||
elif [ "$NUM_VERSIONS" -gt 0 ]; then
|
||||
# TEMP: Don't include 6.0 versions
|
||||
|
|
|
|||
|
|
@ -71,34 +71,42 @@ def main():
|
|||
|
||||
tmp_src_dir = os.path.join(tmp_dir, 'zotero')
|
||||
|
||||
# Export a clean copy of the source tree
|
||||
subprocess.check_call([
|
||||
'rsync',
|
||||
'-aL',
|
||||
# Exclude hidden files
|
||||
'--exclude', '.*',
|
||||
'--exclude', '#*',
|
||||
'--exclude', 'package.json',
|
||||
'--exclude', 'package-lock.json',
|
||||
'.' + os.sep,
|
||||
tmp_src_dir + os.sep
|
||||
])
|
||||
# Export a clean copy of the source tree -- with Python on Windows, where rsync,
|
||||
# if present at all, doesn't handle Windows-style paths
|
||||
if os.name == 'nt' or not shutil.which('rsync'):
|
||||
shutil.copytree(
|
||||
'.',
|
||||
tmp_src_dir,
|
||||
ignore=shutil.ignore_patterns('.*', '#*', 'package.json', 'package-lock.json')
|
||||
)
|
||||
else:
|
||||
subprocess.check_call([
|
||||
'rsync',
|
||||
'-aL',
|
||||
# Exclude hidden files
|
||||
'--exclude', '.*',
|
||||
'--exclude', '#*',
|
||||
'--exclude', 'package.json',
|
||||
'--exclude', 'package-lock.json',
|
||||
'.' + os.sep,
|
||||
tmp_src_dir + os.sep
|
||||
])
|
||||
|
||||
# Make sure rsync worked
|
||||
# Make sure the copy worked
|
||||
d = os.path.join(tmp_src_dir, 'chrome')
|
||||
if not os.path.isdir(d):
|
||||
raise FileNotFoundError(d + " not found")
|
||||
|
||||
# Delete CSL locale support files
|
||||
subprocess.check_call([
|
||||
'find',
|
||||
os.path.normpath(tmp_src_dir + '/chrome/content/zotero/locale/csl/'),
|
||||
'-mindepth', '1',
|
||||
'!', '-name', '*.xml',
|
||||
'!', '-name', 'locales.json',
|
||||
#'-print',
|
||||
'-delete'
|
||||
])
|
||||
csl_dir = os.path.normpath(tmp_src_dir + '/chrome/content/zotero/locale/csl/')
|
||||
for root, dirs, files in os.walk(csl_dir, topdown=False):
|
||||
for name in files:
|
||||
if not name.endswith('.xml') and name != 'locales.json':
|
||||
os.remove(os.path.join(root, name))
|
||||
for name in dirs:
|
||||
d = os.path.join(root, name)
|
||||
if not os.listdir(d):
|
||||
os.rmdir(d)
|
||||
|
||||
# Delete styles build script
|
||||
os.remove(os.path.join(tmp_src_dir, 'styles', 'update'))
|
||||
|
|
|
|||
46
app/scripts/test_build_and_deploy
Executable file
46
app/scripts/test_build_and_deploy
Executable file
|
|
@ -0,0 +1,46 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
APP_ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
ROOT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
||||
. "$APP_ROOT_DIR/config.sh"
|
||||
|
||||
CHANNEL="test"
|
||||
BRANCH="main"
|
||||
if [ "`uname`" = "Darwin" ]; then
|
||||
export SAFARI_APPEX="$ROOT_DIR/../safari-web-extension-builds/ZoteroSafariExtension.appex"
|
||||
export SAFARI_EXT_RESOURCES="$ROOT_DIR/../safari-web-extension-builds/test/safari"
|
||||
export SAFARI_APP_EXTENSION="$ROOT_DIR/../safari-app-extension-builds/test/ZoteroSafariExtension.appex"
|
||||
if [ ! -d "$SAFARI_APPEX" ]; then
|
||||
echo "Safari extension stub not found at $SAFARI_APPEX -- aborting" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$SAFARI_EXT_RESOURCES/manifest.json" ]; then
|
||||
echo "Safari web extension not found in $SAFARI_EXT_RESOURCES -- aborting" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -d "$SAFARI_APP_EXTENSION" ]; then
|
||||
echo "Safari App Extension not found at $SAFARI_APP_EXTENSION -- aborting" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
./check_requirements
|
||||
|
||||
hash=`./get_repo_branch_hash $BRANCH`
|
||||
./check_app_matches_commit $hash
|
||||
source_dir=`./get_commit_files $hash`
|
||||
build_dir=`mktemp -d`
|
||||
|
||||
function cleanup {
|
||||
rm -rf "$source_dir"
|
||||
rm -rf "$build_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
./prepare_build -s "$source_dir" -o "$build_dir" -c $CHANNEL -m $hash
|
||||
VERSION="`cat \"$build_dir/version\"`"
|
||||
./build_for_deploy -d "$build_dir" -p $BUILD_PLATFORMS -c $CHANNEL -i 1
|
||||
ssh $DEPLOY_HOST "$DEPLOY_PATH/deploy" $CHANNEL "$VERSION" $BUILD_PLATFORMS
|
||||
|
|
@ -39,9 +39,88 @@ get_canonical_arch() {
|
|||
esac
|
||||
}
|
||||
|
||||
# Print a sorted NUL-separated list of the files in the current directory to
|
||||
# include in the build manifest. Follows symlinks, like the rsync calls in
|
||||
# prepare_build and build.sh.
|
||||
function build_manifest_file_list {
|
||||
# Exclusions match the rsync call in prepare_build
|
||||
find -L . -mindepth 1 \( -name '.*' -o -name '#*' \) -prune \
|
||||
-o -type f ! -name 'package.json' ! -name 'package-lock.json' -print0 \
|
||||
| LC_ALL=C sort -z
|
||||
}
|
||||
|
||||
# Read a NUL-separated file list on stdin and print "size|mtime<TAB>path" for
|
||||
# each file
|
||||
function stat_file_list {
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
xargs -0 stat -L -f '%z|%Fm|%N'
|
||||
else
|
||||
xargs -0 stat -L -c '%s|%y|%n'
|
||||
fi | sed -E 's,^([0-9]+[|][^|]+)[|](\./)?,\1'$'\t'','
|
||||
}
|
||||
|
||||
# Read a NUL-separated file list on stdin and print "hash<TAB>path" for each
|
||||
# file
|
||||
function hash_file_list {
|
||||
local md5_cmd
|
||||
if command -v md5sum > /dev/null 2>&1; then
|
||||
md5_cmd="md5sum"
|
||||
else
|
||||
md5_cmd="md5 -r"
|
||||
fi
|
||||
xargs -0 $md5_cmd | sed -E 's,^([0-9a-f]{32}) ?(\./)?,\1'$'\t'','
|
||||
}
|
||||
|
||||
# Generate a manifest of a build directory for incremental updates, with one
|
||||
# "hash<TAB>size|mtime<TAB>path" line per file, sorted by path
|
||||
function generate_build_manifest {
|
||||
local dir=$1
|
||||
(
|
||||
cd "$dir"
|
||||
local file_list
|
||||
file_list=$(mktemp)
|
||||
build_manifest_file_list > "$file_list"
|
||||
paste \
|
||||
<(hash_file_list < "$file_list" | cut -f1) \
|
||||
<(stat_file_list < "$file_list")
|
||||
rm -f "$file_list"
|
||||
)
|
||||
}
|
||||
|
||||
# Generate a hash of the files in app/ that affect build output, for detecting
|
||||
# when a staged build can't be updated incrementally. Since some of the files
|
||||
# are large binaries, use file sizes and modification times rather than
|
||||
# contents. The xulrunner runtimes are covered by the hash-* files written by
|
||||
# fetch_xulrunner.
|
||||
function generate_app_hash {
|
||||
local app_dir=$1
|
||||
local md5_cmd
|
||||
if command -v md5sum > /dev/null 2>&1; then
|
||||
md5_cmd="md5sum"
|
||||
else
|
||||
md5_cmd="md5 -r"
|
||||
fi
|
||||
(
|
||||
cd "$app_dir"
|
||||
local paths=(assets build.sh config.sh scripts mac win linux modules update-packaging)
|
||||
if [ -f config-custom.sh ]; then
|
||||
paths+=(config-custom.sh)
|
||||
fi
|
||||
{
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
find -L "${paths[@]}" -name '.*' -prune -o -type f -print0 | xargs -0 stat -L -f '%N|%z|%m'
|
||||
else
|
||||
find -L "${paths[@]}" -name '.*' -prune -o -type f -print0 | xargs -0 stat -L -c '%n|%s|%Y'
|
||||
fi
|
||||
cat xulrunner/hash-* 2> /dev/null || true
|
||||
} | LC_ALL=C sort | $md5_cmd | awk '{print $1}'
|
||||
)
|
||||
}
|
||||
|
||||
function check_line {
|
||||
pattern=$1
|
||||
if ! egrep -q "$pattern" "$file"; then
|
||||
file=$2
|
||||
if ! grep -E -q "$pattern" "$file"; then
|
||||
echo "$pattern" not found in "$file" -- aborting 2>&1
|
||||
exit 1
|
||||
fi
|
||||
|
|
@ -52,7 +131,7 @@ function replace_line {
|
|||
replacement=$2
|
||||
file=$3
|
||||
|
||||
if egrep -q "$pattern" "$file"; then
|
||||
if grep -E -q "$pattern" "$file"; then
|
||||
perl -pi -e "s/$pattern/$replacement/" "$file"
|
||||
else
|
||||
echo "$pattern" not found in "$file" -- aborting 2>&1
|
||||
|
|
@ -64,8 +143,8 @@ function remove_line {
|
|||
pattern=$1
|
||||
file=$2
|
||||
|
||||
if egrep -q "$pattern" "$file"; then
|
||||
egrep -v "$pattern" "$file" > "$file.tmp"
|
||||
if grep -E -q "$pattern" "$file"; then
|
||||
grep -E -v "$pattern" "$file" > "$file.tmp"
|
||||
mv "$file.tmp" "$file"
|
||||
else
|
||||
echo "$pattern" not found in "$file" -- aborting 2>&1
|
||||
|
|
@ -78,7 +157,7 @@ function remove_between {
|
|||
end_pattern=$2
|
||||
file=$3
|
||||
|
||||
if egrep -q "$start_pattern" "$file" && egrep -q "$end_pattern" "$file"; then
|
||||
if grep -E -q "$start_pattern" "$file" && grep -E -q "$end_pattern" "$file"; then
|
||||
perl -ni -e '
|
||||
if (/'"$start_pattern"'/) { $skip = 1; next; }
|
||||
elsif ($skip && /'"$end_pattern"'/) { $skip = 0; next; }
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
# * **Windows (w)** ‑a is **required**. Accepted values:
|
||||
# win32 | x64 | arm64
|
||||
# * **Linux (l)** ‑a is **required**. Accepted values:
|
||||
# x86_64 | i686 | arm64 (alias x64 → x86_64)
|
||||
# x86_64 | arm64 (alias x64 → x86_64)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ Usage: xulrunner_hash.sh -p <m|w|l> [-a ARCH]
|
|||
-p PLATFORM m (mac), w (windows), l (linux) [required]
|
||||
-a ARCH *required* for Windows & Linux builds
|
||||
Windows: x64 | arm64 | win32
|
||||
Linux: x64 (or x86_64) | arm64 | i686
|
||||
Linux: x64 (or x86_64) | arm64
|
||||
Mac: disallowed – always universal
|
||||
DONE
|
||||
exit 1
|
||||
|
|
@ -65,7 +65,7 @@ case $platform in
|
|||
[[ -z $arch ]] && { echo "-a is required for Linux builds" >&2; echo; usage; }
|
||||
[[ $arch == x64 ]] && arch="x86_64"
|
||||
case $arch in
|
||||
x86_64|i686|arm64) ;;
|
||||
x86_64|arm64) ;;
|
||||
*) echo "Invalid Linux arch: $arch" >&2; echo; usage;;
|
||||
esac
|
||||
GECKO_VERSION="$GECKO_VERSION_LINUX"
|
||||
|
|
@ -91,4 +91,5 @@ fi
|
|||
|
||||
input_string="${platform}-${arch}-${components_hash}|${GECKO_VERSION}|${xulrunner_content}"
|
||||
|
||||
printf "%s" "$input_string" | openssl dgst -sha256 | awk '{print $2}'
|
||||
|
||||
printf "%s" "$input_string" | openssl dgst -sha256 | awk '{print $NF}'
|
||||
|
|
|
|||
|
|
@ -97,6 +97,8 @@ if [[ $BUILD_MAC == 0 ]] && [[ $BUILD_WIN == 0 ]] && [[ $BUILD_LINUX == 0 ]]; th
|
|||
usage
|
||||
fi
|
||||
|
||||
TO_MAJOR_VERSION="${TO%%.*}"
|
||||
|
||||
rm -rf "$UPDATE_STAGE_DIR"
|
||||
mkdir "$UPDATE_STAGE_DIR"
|
||||
|
||||
|
|
@ -136,20 +138,43 @@ for version in "$FROM" "$TO"; do
|
|||
mkdir -p "$versiondir"
|
||||
cd "$versiondir"
|
||||
|
||||
# Determine Linux archive extension by major Zotero version
|
||||
MAJOR_VERSION="${version%%.*}"
|
||||
if [ "$MAJOR_VERSION" -lt 8 ]; then
|
||||
LINUX_EXT="tar.bz2"
|
||||
else
|
||||
LINUX_EXT="tar.xz"
|
||||
fi
|
||||
|
||||
MAC_ARCHIVE="Zotero-${version}.dmg"
|
||||
WIN32_ARCHIVE="Zotero-${version}_win32.zip"
|
||||
WIN_X64_ARCHIVE="Zotero-${version}_win-x64.zip"
|
||||
WIN_ARM64_ARCHIVE="Zotero-${version}_win-arm64.zip"
|
||||
LINUX_X86_ARCHIVE="Zotero-${version}_linux-i686.tar.xz"
|
||||
LINUX_X86_64_ARCHIVE="Zotero-${version}_linux-x86_64.tar.xz"
|
||||
LINUX_ARM64_ARCHIVE="Zotero-${version}_linux-arm64.tar.xz"
|
||||
LINUX_X86_ARCHIVE="Zotero-${version}_linux-i686.${LINUX_EXT}"
|
||||
LINUX_X86_64_ARCHIVE="Zotero-${version}_linux-x86_64.${LINUX_EXT}"
|
||||
LINUX_ARM64_ARCHIVE="Zotero-${version}_linux-arm64.${LINUX_EXT}"
|
||||
|
||||
CACHE_DIR="$ROOT_DIR/cache"
|
||||
if [ ! -e "$CACHE_DIR" ]; then
|
||||
mkdir "$CACHE_DIR"
|
||||
fi
|
||||
|
||||
for archive in "$MAC_ARCHIVE" "$WIN32_ARCHIVE" "$WIN_X64_ARCHIVE" "$WIN_ARM64_ARCHIVE" "$LINUX_X86_ARCHIVE" "$LINUX_X86_64_ARCHIVE" "$LINUX_ARM64_ARCHIVE"; do
|
||||
# Build archive list (no Linux arm64 before Zotero 8, no Linux i686 from Zotero 11)
|
||||
ARCHIVES=(
|
||||
"$MAC_ARCHIVE"
|
||||
"$WIN32_ARCHIVE"
|
||||
"$WIN_X64_ARCHIVE"
|
||||
"$WIN_ARM64_ARCHIVE"
|
||||
"$LINUX_X86_64_ARCHIVE"
|
||||
)
|
||||
if [ "$TO_MAJOR_VERSION" -lt 11 ]; then
|
||||
ARCHIVES+=("$LINUX_X86_ARCHIVE")
|
||||
fi
|
||||
if [ "$MAJOR_VERSION" -ge 8 ]; then
|
||||
ARCHIVES+=("$LINUX_ARM64_ARCHIVE")
|
||||
fi
|
||||
|
||||
for archive in "${ARCHIVES[@]}"; do
|
||||
if [[ $archive = "$MAC_ARCHIVE" ]] && [[ $BUILD_MAC != 1 ]]; then
|
||||
continue
|
||||
fi
|
||||
|
|
@ -214,8 +239,13 @@ for version in "$FROM" "$TO"; do
|
|||
set -e
|
||||
done
|
||||
|
||||
# Delete cached files older than 14 days
|
||||
find "$CACHE_DIR" -ctime +14 -delete
|
||||
# Delete cached archives older than 14 days
|
||||
find "$CACHE_DIR" -maxdepth 1 -type f -ctime +14 -delete
|
||||
# Delete cached diffs and compressed files not created or used in 60 days
|
||||
# (cache hits touch the entries)
|
||||
if [ -d "$CACHE_DIR/update-packaging" ]; then
|
||||
find "$CACHE_DIR/update-packaging" -type f -ctime +60 -delete
|
||||
fi
|
||||
|
||||
# Unpack Zotero.app
|
||||
if [ $BUILD_MAC == 1 ]; then
|
||||
|
|
@ -236,9 +266,14 @@ for version in "$FROM" "$TO"; do
|
|||
# Unpack Windows zips
|
||||
if [ $BUILD_WIN == 1 ]; then
|
||||
if [[ -f "$WIN32_ARCHIVE" ]] && [[ -f "$WIN_X64_ARCHIVE" ]] && [[ -f "$WIN_ARM64_ARCHIVE" ]]; then
|
||||
# Unpack architectures in parallel
|
||||
UNPACK_PIDS=""
|
||||
for build in "$WIN32_ARCHIVE" "$WIN_X64_ARCHIVE" "$WIN_ARM64_ARCHIVE"; do
|
||||
unzip -q "$build"
|
||||
rm "$build"
|
||||
(unzip -q "$build" && rm "$build") &
|
||||
UNPACK_PIDS="$UNPACK_PIDS $!"
|
||||
done
|
||||
for pid in $UNPACK_PIDS; do
|
||||
wait $pid
|
||||
done
|
||||
INCREMENTALS_FOUND=1
|
||||
else
|
||||
|
|
@ -248,14 +283,36 @@ for version in "$FROM" "$TO"; do
|
|||
|
||||
# Unpack Linux tarballs
|
||||
if [ $BUILD_LINUX == 1 ]; then
|
||||
if [[ -f "$LINUX_X86_ARCHIVE" ]] && [[ -f "$LINUX_X86_64_ARCHIVE" ]] && [[ -f "$LINUX_ARM64_ARCHIVE" ]]; then
|
||||
for build in "$LINUX_X86_ARCHIVE" "$LINUX_X86_64_ARCHIVE" "$LINUX_ARM64_ARCHIVE"; do
|
||||
tar -xf "$build"
|
||||
rm "$build"
|
||||
# Zotero 7 has no Linux arm64 build; Zotero 11 has no Linux i686 build
|
||||
LINUX_BUILDS_TO_UNPACK=("$LINUX_X86_64_ARCHIVE")
|
||||
if [ "$TO_MAJOR_VERSION" -lt 11 ]; then
|
||||
LINUX_BUILDS_TO_UNPACK+=("$LINUX_X86_ARCHIVE")
|
||||
fi
|
||||
if [ "$MAJOR_VERSION" -ge 8 ]; then
|
||||
LINUX_BUILDS_TO_UNPACK+=("$LINUX_ARM64_ARCHIVE")
|
||||
fi
|
||||
|
||||
MISSING=0
|
||||
for build in "${LINUX_BUILDS_TO_UNPACK[@]}"; do
|
||||
if [ ! -f "$build" ]; then
|
||||
MISSING=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$MISSING" -eq 0 ]; then
|
||||
# Unpack architectures in parallel
|
||||
UNPACK_PIDS=""
|
||||
for build in "${LINUX_BUILDS_TO_UNPACK[@]}"; do
|
||||
(tar -xf "$build" && rm "$build") &
|
||||
UNPACK_PIDS="$UNPACK_PIDS $!"
|
||||
done
|
||||
for pid in $UNPACK_PIDS; do
|
||||
wait $pid
|
||||
done
|
||||
INCREMENTALS_FOUND=1
|
||||
else
|
||||
echo "$LINUX_X86_ARCHIVE/$LINUX_X86_64_ARCHIVE/$LINUX_ARM64_ARCHIVE not found"
|
||||
echo "Linux tarballs not found: ${LINUX_BUILDS_TO_UNPACK[*]}"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
|
@ -266,8 +323,21 @@ done
|
|||
export MOZ_PRODUCT_VERSION="$TO"
|
||||
export MAR_CHANNEL_ID="$CHANNEL"
|
||||
|
||||
# Reuse diffs and compressed files across incremental builds, since FROM
|
||||
# versions often share files with each other and with the TO version. Entries
|
||||
# that haven't been used recently are cleaned up above.
|
||||
export UPDATE_CACHE_DIR="$ROOT_DIR/cache/update-packaging"
|
||||
|
||||
CHANGES_MADE=0
|
||||
for build in "mac" "win32" "win-x64" "win-arm64" "linux-i686" "linux-x86_64" "linux-arm64"; do
|
||||
# Zotero 7 has no Linux arm64 builds
|
||||
if [[ $build == "linux-arm64" ]] && [[ $FROM == 7.* ]]; then
|
||||
continue
|
||||
fi
|
||||
# Zotero 11 has no Linux i686 builds, so there's nothing to update to
|
||||
if [[ $build == "linux-i686" ]] && [[ $TO_MAJOR_VERSION -ge 11 ]]; then
|
||||
continue
|
||||
fi
|
||||
if [[ $build == "mac" ]]; then
|
||||
if [[ $BUILD_MAC == 0 ]]; then
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@
|
|||
#
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
QUIET=0
|
||||
# Preserve a value set by the parent so it survives in the child processes
|
||||
# spawned by run_parallel_tasks()
|
||||
QUIET=${QUIET:-0}
|
||||
|
||||
# By default just assume that these tools exist on our path
|
||||
MAR=${MAR:-mar}
|
||||
|
|
@ -33,7 +35,11 @@ if [ $? -ne 0 ]; then
|
|||
fi
|
||||
fi
|
||||
fi
|
||||
# Ensure that we're always using the right compression settings
|
||||
# Ensure that we're always using the right compression settings.
|
||||
# -T1 (single-threaded) is required: multi-threaded xz produces non-deterministic
|
||||
# output, which would break both the reproducibility of MARs and the content-hash
|
||||
# cache in process_update_task(). See get_cache_tag() for how changes here are
|
||||
# accounted for in the cache.
|
||||
export XZ_OPT="-T1 -7e"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
|
@ -156,10 +162,10 @@ append_remove_instructions() {
|
|||
if [ -n "$f" ]; then
|
||||
# Exclude comments
|
||||
if [ ! $(echo "$f" | grep -c '^#') = 1 ]; then
|
||||
if [ $(echo "$f" | grep -c '\/$') = 1 ]; then
|
||||
if [ $(echo "$f" | grep -c '/$') = 1 ]; then
|
||||
verbose_notice " rmdir \"$f\""
|
||||
echo "rmdir \"$f\"" >> "$filev3"
|
||||
elif [ $(echo "$f" | grep -c '\/\*$') = 1 ]; then
|
||||
elif [ $(echo "$f" | grep -c '/\*$') = 1 ]; then
|
||||
# Remove the *
|
||||
f=$(echo "$f" | sed -e 's:\*$::')
|
||||
verbose_notice " rmrfdir \"$f\""
|
||||
|
|
@ -175,6 +181,236 @@ append_remove_instructions() {
|
|||
fi
|
||||
}
|
||||
|
||||
# Return the number of parallel jobs to use for diffing/compression, from
|
||||
# UPDATE_PACKAGING_JOBS or the number of cores
|
||||
get_parallel_jobs() {
|
||||
if [ -n "${UPDATE_PACKAGING_JOBS:-}" ]; then
|
||||
echo "$UPDATE_PACKAGING_JOBS"
|
||||
return 0
|
||||
fi
|
||||
nproc 2>/dev/null && return 0
|
||||
sysctl -n hw.ncpu 2>/dev/null && return 0
|
||||
echo 1
|
||||
}
|
||||
|
||||
# Run per-file tasks from a task file in parallel by re-invoking the calling
|
||||
# script with --run-task for each line. Tasks are ordered largest-file-first so
|
||||
# that long-running jobs (e.g., diffing xul) start as early as possible.
|
||||
#
|
||||
# $1 - task file, with one "<type><TAB><relative path>" task per line
|
||||
# $2 - directory to measure file sizes against for job ordering
|
||||
# $3 - script to re-invoke (callers pass "$0" and handle --run-task before
|
||||
# option parsing by calling process_update_task)
|
||||
#
|
||||
# Callers must export the variables needed by process_update_task().
|
||||
run_parallel_tasks() {
|
||||
local taskfile="$1"
|
||||
local basedir="$2"
|
||||
local script="$3"
|
||||
local jobs
|
||||
|
||||
if [ ! -s "$taskfile" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# xargs requires a resolvable command path
|
||||
script="$(cd "$(dirname "$script")" && pwd)/$(basename "$script")"
|
||||
|
||||
# Compute the cache tag once here and export it so the per-file child
|
||||
# processes inherit it rather than recomputing it for every file
|
||||
if [ -n "${UPDATE_CACHE_DIR:-}" ] && [ -z "${UPDATE_CACHE_TAG:-}" ]; then
|
||||
UPDATE_CACHE_TAG="$(get_cache_tag)"
|
||||
export UPDATE_CACHE_TAG
|
||||
fi
|
||||
|
||||
jobs=$(get_parallel_jobs)
|
||||
notice "Processing $(wc -l < "$taskfile" | tr -d ' ') files with $jobs parallel jobs"
|
||||
|
||||
(cd "$basedir" && du -ak .) > "$taskfile.sizes"
|
||||
awk -F'\t' 'NR==FNR { sizes[substr($2, 3)] = $1; next } { print sizes[$2] "\t" $0 }' \
|
||||
"$taskfile.sizes" "$taskfile" \
|
||||
| sort -rn \
|
||||
| cut -f 2- \
|
||||
| tr '\n' '\000' \
|
||||
| xargs -0 -n 1 -P "$jobs" "$script" --run-task
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Diff and compression cache
|
||||
#
|
||||
# Building updates repeats the same expensive work: the same file contents get
|
||||
# xz-compressed once per architecture, per FROM version, and per release, and
|
||||
# the same before/after file pair gets binary-diffed again and again. When
|
||||
# UPDATE_CACHE_DIR is set, we cache the results by content hash and reuse them
|
||||
# across builds. Two kinds of entry are stored:
|
||||
#
|
||||
# full-<tag>-<hash>.xz A single file compressed with xz, keyed
|
||||
# on the file's contents. Reused whenever
|
||||
# the same content shows up again, in any
|
||||
# architecture, version, or release.
|
||||
#
|
||||
# patch-<tag>-<oldhash>-<newhash>.xz The compressed binary diff between a
|
||||
# file's old and new contents, keyed on
|
||||
# both. Reused whenever the same
|
||||
# before/after pair recurs (e.g., a file
|
||||
# unchanged across several FROM versions
|
||||
# being patched to the same TO).
|
||||
#
|
||||
# Both entries are written even when only one is ultimately packaged, so a file
|
||||
# that loses the patch-vs-full size comparison in one build still seeds the
|
||||
# cache for the next. <tag> is get_cache_tag(), so a toolchain/settings change
|
||||
# starts a fresh namespace rather than reusing stale output. Entries are touched
|
||||
# on use and expire via the time-based cleanup in build_autoupdate.sh.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Print a SHA-256 content hash to use as a cache key
|
||||
hash_file() {
|
||||
if command -v sha256sum > /dev/null 2>&1; then
|
||||
sha256sum "$1" | awk '{print $1}'
|
||||
else
|
||||
shasum -a 256 "$1" | awk '{print $1}'
|
||||
fi
|
||||
}
|
||||
|
||||
# Print a SHA-256 hash of stdin
|
||||
hash_stdin() {
|
||||
if command -v sha256sum > /dev/null 2>&1; then
|
||||
sha256sum | awk '{print $1}'
|
||||
else
|
||||
shasum -a 256 | awk '{print $1}'
|
||||
fi
|
||||
}
|
||||
|
||||
# Print a short tag identifying the toolchain and options that produce cached
|
||||
# files, so that entries are invalidated when any of them change. The cache
|
||||
# keys files by input content only, on the assumption that the same inputs
|
||||
# always compress/diff to the same output; this tag makes that assumption hold
|
||||
# across xz/mbsdiff upgrades and XZ_OPT/BCJ_OPTIONS changes. (A change to any
|
||||
# input invalidates both the full and patch caches, which is more than strictly
|
||||
# necessary but keeps this simple; a stale build is just a cold rebuild.)
|
||||
# Stale-tag entries are removed by the time-based cleanup in build_autoupdate.sh.
|
||||
get_cache_tag() {
|
||||
local mbsdiff_bin
|
||||
mbsdiff_bin="$(command -v "$MBSDIFF" 2>/dev/null)"
|
||||
{
|
||||
$XZ --version 2>/dev/null | head -1
|
||||
echo "XZ_OPT=$XZ_OPT"
|
||||
echo "BCJ_OPTIONS=${BCJ_OPTIONS:-}"
|
||||
# mbsdiff has no --version, so identify it by its binary contents
|
||||
if [ -n "$mbsdiff_bin" ]; then
|
||||
hash_file "$mbsdiff_bin"
|
||||
else
|
||||
echo "MBSDIFF=$MBSDIFF"
|
||||
fi
|
||||
} | hash_stdin | cut -c 1-16
|
||||
}
|
||||
|
||||
# Atomically store file $2 in the cache as $1
|
||||
cache_store() {
|
||||
mkdir -p "$(dirname "$1")"
|
||||
cp "$2" "$1.tmp.$$" && mv -f "$1.tmp.$$" "$1"
|
||||
}
|
||||
|
||||
# xz-compress $1 to $2, reusing a cached result when one exists (see the cache
|
||||
# overview above)
|
||||
compress_full_file() {
|
||||
local src="$1"
|
||||
local dest="$2"
|
||||
local key=""
|
||||
|
||||
if [ -n "${UPDATE_CACHE_DIR:-}" ]; then
|
||||
key="$UPDATE_CACHE_DIR/full-${UPDATE_CACHE_TAG:-}-$(hash_file "$src").xz"
|
||||
if [ -f "$key" ]; then
|
||||
# Keep entries that are still in use from expiring
|
||||
touch -c "$key" 2>/dev/null || true
|
||||
cp "$key" "$dest"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
$XZ $XZ_OPT --compress $BCJ_OPTIONS --lzma2 --format=xz --check=crc64 --force --stdout "$src" > "$dest"
|
||||
|
||||
if [ -n "$key" ]; then
|
||||
cache_store "$key" "$dest"
|
||||
fi
|
||||
}
|
||||
|
||||
# Process a single task line from run_parallel_tasks():
|
||||
#
|
||||
# full - compress the new file into the work directory
|
||||
# diff - if the file changed, generate a binary diff and keep the smaller of
|
||||
# the compressed patch ($f.patch) and the compressed file ($f)
|
||||
#
|
||||
# Expects newdir and workdir in the environment, plus olddir for diff tasks.
|
||||
# The results are assembled into the manifest serially by the caller based on
|
||||
# which files exist in the work directory. Diffs and compressed files are
|
||||
# cached when enabled -- see the cache overview above.
|
||||
process_update_task() {
|
||||
local task="$1"
|
||||
local tab=$(printf '\t')
|
||||
local type="${task%%"$tab"*}"
|
||||
local f="${task#*"$tab"}"
|
||||
local oldfile_path newfile_path patch_path patchsize fullsize full_pid patchkey
|
||||
|
||||
if [ "$type" = "diff" ]; then
|
||||
if diff "$olddir/$f" "$newdir/$f" > /dev/null; then
|
||||
# Unchanged
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$workdir/$f")"
|
||||
verbose_notice "diffing \"$f\""
|
||||
|
||||
# Compress the full new file in the background while diffing, since both
|
||||
# are needed to decide which to package
|
||||
compress_full_file "$newdir/$f" "$workdir/$f" &
|
||||
full_pid=$!
|
||||
|
||||
patchkey=""
|
||||
if [ -n "${UPDATE_CACHE_DIR:-}" ]; then
|
||||
patchkey="$UPDATE_CACHE_DIR/patch-${UPDATE_CACHE_TAG:-}-$(hash_file "$olddir/$f")-$(hash_file "$newdir/$f").xz"
|
||||
fi
|
||||
if [ -n "$patchkey" ] && [ -f "$patchkey" ]; then
|
||||
# Keep entries that are still in use from expiring
|
||||
touch -c "$patchkey" 2>/dev/null || true
|
||||
cp "$patchkey" "$workdir/$f.patch.xz"
|
||||
else
|
||||
# mbsdiff doesn't like POSIX paths on Windows
|
||||
if [ ${WIN_NATIVE:-0} -eq 1 ]; then
|
||||
oldfile_path=$(cygpath -m "$olddir/$f")
|
||||
newfile_path=$(cygpath -m "$newdir/$f")
|
||||
patch_path=$(cygpath -m "$workdir/$f.patch")
|
||||
else
|
||||
oldfile_path="$olddir/$f"
|
||||
newfile_path="$newdir/$f"
|
||||
patch_path="$workdir/$f.patch"
|
||||
fi
|
||||
$MBSDIFF "$oldfile_path" "$newfile_path" "$patch_path"
|
||||
$XZ $XZ_OPT --compress --lzma2 --format=xz --check=crc64 --force "$workdir/$f.patch"
|
||||
if [ -n "$patchkey" ]; then
|
||||
cache_store "$patchkey" "$workdir/$f.patch.xz"
|
||||
fi
|
||||
fi
|
||||
|
||||
wait $full_pid
|
||||
copy_perm "$newdir/$f" "$workdir/$f"
|
||||
|
||||
patchsize=$(get_file_size "$workdir/$f.patch.xz")
|
||||
fullsize=$(get_file_size "$workdir/$f")
|
||||
|
||||
if [ $patchsize -lt $fullsize ]; then
|
||||
mv -f "$workdir/$f.patch.xz" "$workdir/$f.patch"
|
||||
rm -f "$workdir/$f"
|
||||
else
|
||||
rm -f "$workdir/$f.patch.xz"
|
||||
fi
|
||||
else
|
||||
mkdir -p "$(dirname "$workdir/$f")"
|
||||
compress_full_file "$newdir/$f" "$workdir/$f"
|
||||
copy_perm "$newdir/$f" "$workdir/$f"
|
||||
fi
|
||||
}
|
||||
|
||||
# List all files in the current directory, stripping leading "./"
|
||||
# Pass a variable name and it will be filled as an array.
|
||||
list_files() {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@ set -eo pipefail
|
|||
|
||||
. $(dirname "$0")/common.sh
|
||||
|
||||
# Process a single file task when invoked from run_parallel_tasks()
|
||||
if [ "${1:-}" = "--run-task" ]; then
|
||||
process_update_task "$2"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
print_usage() {
|
||||
|
|
@ -50,7 +56,7 @@ archive="$1"
|
|||
targetdir="$2"
|
||||
# Prevent the workdir from being inside the targetdir so it isn't included in
|
||||
# the update mar.
|
||||
if [ $(echo "$targetdir" | grep -c '\/$') = 1 ]; then
|
||||
if [ $(echo "$targetdir" | grep -c '/$') = 1 ]; then
|
||||
# Remove the /
|
||||
targetdir=$(echo "$targetdir" | sed -e 's:\/$::')
|
||||
fi
|
||||
|
|
@ -88,6 +94,23 @@ notice ""
|
|||
notice "Adding file add instructions to update manifests"
|
||||
num_files=${#files[*]}
|
||||
|
||||
# Compress the files in parallel via run_parallel_tasks() in
|
||||
# common.sh, and then assemble the manifest and archive list serially in the
|
||||
# original order
|
||||
tmpdir="$(mktemp -d)"
|
||||
taskfile="$tmpdir/tasks"
|
||||
TAB="$(printf '\t')"
|
||||
> "$taskfile"
|
||||
|
||||
for ((i=0; $i<$num_files; i=$i+1)); do
|
||||
echo "full$TAB${files[$i]}" >> "$taskfile"
|
||||
done
|
||||
|
||||
newdir="$targetdir"
|
||||
export newdir workdir BCJ_OPTIONS QUIET
|
||||
run_parallel_tasks "$taskfile" "$targetdir" "$0"
|
||||
rm -rf "$tmpdir"
|
||||
|
||||
for ((i=0; $i<$num_files; i=$i+1)); do
|
||||
f="${files[$i]}"
|
||||
|
||||
|
|
@ -97,11 +120,6 @@ for ((i=0; $i<$num_files; i=$i+1)); do
|
|||
make_add_instruction "$f" "$updatemanifestv3"
|
||||
fi
|
||||
|
||||
dir=$(dirname "$f")
|
||||
mkdir -p "$workdir/$dir"
|
||||
$XZ $XZ_OPT --compress $BCJ_OPTIONS --lzma2 --format=xz --check=crc64 --force --stdout "$targetdir/$f" > "$workdir/$f"
|
||||
copy_perm "$targetdir/$f" "$workdir/$f"
|
||||
|
||||
targetfiles="$targetfiles \"$f\""
|
||||
done
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@ set -eo pipefail
|
|||
|
||||
. $(dirname "$0")/common.sh
|
||||
|
||||
# Process a single file task when invoked from run_parallel_tasks()
|
||||
if [ "${1:-}" = "--run-task" ]; then
|
||||
process_update_task "$2"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
print_usage() {
|
||||
|
|
@ -112,7 +118,7 @@ olddir="$2"
|
|||
newdir="$3"
|
||||
# Prevent the workdir from being inside the targetdir so it isn't included in
|
||||
# the update mar.
|
||||
if [ $(echo "$newdir" | grep -c '\/$') = 1 ]; then
|
||||
if [ $(echo "$newdir" | grep -c '/$') = 1 ]; then
|
||||
# Remove the /
|
||||
newdir=$(echo "$newdir" | sed -e 's:\/$::')
|
||||
fi
|
||||
|
|
@ -164,93 +170,67 @@ num_oldfiles=${#oldfiles[*]}
|
|||
remove_array=
|
||||
num_removes=0
|
||||
|
||||
# The main file loop is split into three passes so that the
|
||||
# CPU-heavy work can run in parallel: classify each file into a task list, run
|
||||
# the diffing/compression tasks in parallel via run_parallel_tasks() in
|
||||
# common.sh, and then assemble the manifest and archive list serially in the
|
||||
# original order based on the files the tasks left in the work directory.
|
||||
#
|
||||
# (This also drops the unused MBSDIFF_HOOK/funsize path from the upstream
|
||||
# script -- the diff behavior itself is unchanged and now lives in
|
||||
# process_update_task() in common.sh.)
|
||||
tmpdir="$(mktemp -d)"
|
||||
taskfile="$tmpdir/tasks"
|
||||
TAB="$(printf '\t')"
|
||||
> "$taskfile"
|
||||
|
||||
for ((i=0; $i<$num_oldfiles; i=$i+1)); do
|
||||
f="${oldfiles[$i]}"
|
||||
|
||||
# If this file exists in the new directory as well, then check if it differs.
|
||||
if [ -f "$newdir/$f" ]; then
|
||||
|
||||
if check_for_add_if_not_update "$f"; then
|
||||
# The full workdir may not exist yet, so create it if necessary.
|
||||
mkdir -p "$(dirname "$workdir/$f")"
|
||||
$XZ $XZ_OPT --compress $BCJ_OPTIONS --lzma2 --format=xz --check=crc64 --force --stdout "$newdir/$f" > "$workdir/$f"
|
||||
copy_perm "$newdir/$f" "$workdir/$f"
|
||||
echo "full$TAB$f" >> "$taskfile"
|
||||
elif check_for_forced_update "$requested_forced_updates" "$f"; then
|
||||
echo "full$TAB$f" >> "$taskfile"
|
||||
else
|
||||
echo "diff$TAB$f" >> "$taskfile"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Newly added files -- in the new directory but not the old one. (This replaces
|
||||
# the O(n^2) nested skip loop from the upstream script.)
|
||||
printf '%s\n' "${oldfiles[@]}" | sort > "$tmpdir/oldlist"
|
||||
printf '%s\n' "${newfiles[@]}" | sort > "$tmpdir/newlist"
|
||||
comm -13 "$tmpdir/oldlist" "$tmpdir/newlist" | sed '/^$/d' | sort -r > "$tmpdir/newonly"
|
||||
|
||||
while IFS= read -r f; do
|
||||
echo "full$TAB$f" >> "$taskfile"
|
||||
done < "$tmpdir/newonly"
|
||||
|
||||
export olddir newdir workdir MBSDIFF BCJ_OPTIONS QUIET
|
||||
run_parallel_tasks "$taskfile" "$newdir" "$0"
|
||||
|
||||
# Assemble the manifest and archive list in the original order. A diffed file
|
||||
# left $f.patch in the work directory if the patch was smaller, $f if the
|
||||
# compressed file was smaller, and neither if the file was unchanged.
|
||||
for ((i=0; $i<$num_oldfiles; i=$i+1)); do
|
||||
f="${oldfiles[$i]}"
|
||||
|
||||
if [ -f "$newdir/$f" ]; then
|
||||
if check_for_add_if_not_update "$f"; then
|
||||
make_add_if_not_instruction "$f" "$updatemanifestv3"
|
||||
archivefiles="$archivefiles \"$f\""
|
||||
continue 1
|
||||
fi
|
||||
|
||||
if check_for_forced_update "$requested_forced_updates" "$f"; then
|
||||
# The full workdir may not exist yet, so create it if necessary.
|
||||
mkdir -p "$(dirname "$workdir/$f")"
|
||||
$XZ $XZ_OPT --compress $BCJ_OPTIONS --lzma2 --format=xz --check=crc64 --force --stdout "$newdir/$f" > "$workdir/$f"
|
||||
copy_perm "$newdir/$f" "$workdir/$f"
|
||||
elif check_for_forced_update "$requested_forced_updates" "$f"; then
|
||||
make_add_instruction "$f" "$updatemanifestv3" 1
|
||||
archivefiles="$archivefiles \"$f\""
|
||||
continue 1
|
||||
fi
|
||||
|
||||
if ! diff "$olddir/$f" "$newdir/$f" > /dev/null; then
|
||||
# Compute both the compressed binary diff and the compressed file, and
|
||||
# compare the sizes. Then choose the smaller of the two to package.
|
||||
dir=$(dirname "$workdir/$f")
|
||||
mkdir -p "$dir"
|
||||
verbose_notice "diffing \"$f\""
|
||||
# MBSDIFF_HOOK represents the communication interface with funsize and,
|
||||
# if enabled, caches the intermediate patches for future use and
|
||||
# compute avoidance
|
||||
#
|
||||
# An example of MBSDIFF_HOOK env variable could look like this:
|
||||
# export MBSDIFF_HOOK="myscript.sh -A https://funsize/api -c /home/user"
|
||||
# where myscript.sh has the following usage:
|
||||
# myscript.sh -A SERVER-URL [-c LOCAL-CACHE-DIR-PATH] [-g] [-u] \
|
||||
# PATH-FROM-URL PATH-TO-URL PATH-PATCH SERVER-URL
|
||||
#
|
||||
# Note: patches are bzipped or xz stashed in funsize to gain more speed
|
||||
|
||||
# if service is not enabled then default to old behavior
|
||||
# Disabled for Zotero
|
||||
#if [ -z "$MBSDIFF_HOOK" ]; then
|
||||
if true; then
|
||||
# mbsdiff doesn't like POSIX paths on Windows
|
||||
if [ $WIN_NATIVE -eq 1 ]; then
|
||||
oldfile_path=$(cygpath -m "$olddir/$f")
|
||||
newfile_path=$(cygpath -m "$newdir/$f")
|
||||
patch_path=$(cygpath -m "$workdir/$f.patch")
|
||||
else
|
||||
oldfile_path="$olddir/$f"
|
||||
newfile_path="$newdir/$f"
|
||||
patch_path="$workdir/$f.patch"
|
||||
fi
|
||||
$MBSDIFF "$oldfile_path" "$newfile_path" "$patch_path"
|
||||
$XZ $XZ_OPT --compress --lzma2 --format=xz --check=crc64 --force "$workdir/$f.patch"
|
||||
else
|
||||
# if service enabled then check patch existence for retrieval
|
||||
if $MBSDIFF_HOOK -g "$olddir/$f" "$newdir/$f" "$workdir/$f.patch.xz"; then
|
||||
verbose_notice "file \"$f\" found in funsize, diffing skipped"
|
||||
else
|
||||
# if not found already - compute it and cache it for future use
|
||||
$MBSDIFF "$olddir/$f" "$newdir/$f" "$workdir/$f.patch"
|
||||
$XZ $XZ_OPT --compress --lzma2 --format=xz --check=crc64 --force "$workdir/$f.patch"
|
||||
$MBSDIFF_HOOK -u "$olddir/$f" "$newdir/$f" "$workdir/$f.patch.xz"
|
||||
fi
|
||||
fi
|
||||
$XZ $XZ_OPT --compress $BCJ_OPTIONS --lzma2 --format=xz --check=crc64 --force --stdout "$newdir/$f" > "$workdir/$f"
|
||||
copy_perm "$newdir/$f" "$workdir/$f"
|
||||
patchfile="$workdir/$f.patch.xz"
|
||||
patchsize=$(get_file_size "$patchfile")
|
||||
fullsize=$(get_file_size "$workdir/$f")
|
||||
|
||||
if [ $patchsize -lt $fullsize ]; then
|
||||
make_patch_instruction "$f" "$updatemanifestv3"
|
||||
mv -f "$patchfile" "$workdir/$f.patch"
|
||||
rm -f "$workdir/$f"
|
||||
archivefiles="$archivefiles \"$f.patch\""
|
||||
else
|
||||
make_add_instruction "$f" "$updatemanifestv3"
|
||||
rm -f "$patchfile"
|
||||
archivefiles="$archivefiles \"$f\""
|
||||
fi
|
||||
elif [ -f "$workdir/$f.patch" ]; then
|
||||
make_patch_instruction "$f" "$updatemanifestv3"
|
||||
archivefiles="$archivefiles \"$f.patch\""
|
||||
elif [ -f "$workdir/$f" ]; then
|
||||
make_add_instruction "$f" "$updatemanifestv3"
|
||||
archivefiles="$archivefiles \"$f\""
|
||||
fi
|
||||
else
|
||||
# remove instructions are added after add / patch instructions for
|
||||
|
|
@ -265,33 +245,18 @@ done
|
|||
# Newly added files
|
||||
notice ""
|
||||
notice "Adding file add instructions to update manifests"
|
||||
num_newfiles=${#newfiles[*]}
|
||||
|
||||
for ((i=0; $i<$num_newfiles; i=$i+1)); do
|
||||
f="${newfiles[$i]}"
|
||||
|
||||
# If we've already tested this file, then skip it
|
||||
for ((j=0; $j<$num_oldfiles; j=$j+1)); do
|
||||
if [ "$f" = "${oldfiles[j]}" ]; then
|
||||
continue 2
|
||||
fi
|
||||
done
|
||||
|
||||
dir=$(dirname "$workdir/$f")
|
||||
mkdir -p "$dir"
|
||||
|
||||
$XZ $XZ_OPT --compress $BCJ_OPTIONS --lzma2 --format=xz --check=crc64 --force --stdout "$newdir/$f" > "$workdir/$f"
|
||||
copy_perm "$newdir/$f" "$workdir/$f"
|
||||
|
||||
while IFS= read -r f; do
|
||||
if check_for_add_if_not_update "$f"; then
|
||||
make_add_if_not_instruction "$f" "$updatemanifestv3"
|
||||
else
|
||||
make_add_instruction "$f" "$updatemanifestv3"
|
||||
fi
|
||||
|
||||
|
||||
archivefiles="$archivefiles \"$f\""
|
||||
done
|
||||
done < "$tmpdir/newonly"
|
||||
|
||||
rm -rf "$tmpdir"
|
||||
|
||||
notice ""
|
||||
notice "Adding file remove instructions to update manifests"
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
application.ini
|
||||
chrome.manifest
|
||||
chrome/*
|
||||
components/*
|
||||
defaults/*
|
||||
deleted.txt
|
||||
dictionaries/*
|
||||
extensions/*
|
||||
fonts/EmojiOneMozilla.ttf
|
||||
gtk2/*
|
||||
icudt58l.dat
|
||||
install.rdf
|
||||
libnssdbm3.chk
|
||||
libnssdbm3.so
|
||||
libsoftokn3.chk
|
||||
pingsender
|
||||
resource/*
|
||||
run-zotero.sh
|
||||
styles.zip
|
||||
test/*
|
||||
translators.index
|
||||
translators.zip
|
||||
xulrunner/*
|
||||
zotero.jar
|
||||
|
|
@ -38,12 +38,6 @@ Contents/MacOS/updater.app/Contents/MacOS/updater-bin
|
|||
Contents/MacOS/updates.xml
|
||||
Contents/MacOS/updates/*
|
||||
Contents/MacOS/zotero-bin
|
||||
Contents/PlugIns/ZoteroSafariExtension.appex/Contents/Resources/safari/inject/translate_inject.js
|
||||
Contents/PlugIns/ZoteroSafariExtension.appex/Contents/Resources/safari/node_modules.js
|
||||
Contents/PlugIns/ZoteroSafariExtension.appex/Contents/Resources/safari/test/*
|
||||
Contents/PlugIns/ZoteroSafariExtension.appex/Contents/Resources/safari/translate/cachedTypes.js
|
||||
Contents/PlugIns/ZoteroSafariExtension.appex/Contents/Resources/safari/translate/resource/README.md
|
||||
Contents/PlugIns/ZoteroSafariExtension.appex/Contents/Resources/safari/translate/resource/zoteroTypeSchemaData.js
|
||||
Contents/Resources/CONTRIBUTING.md
|
||||
Contents/Resources/COPYING
|
||||
Contents/Resources/README.md
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ else
|
|||
echo "Unknown architecture $arch" >&2
|
||||
exit 1
|
||||
fi
|
||||
rustup target add $rust_target-pc-windows-msvc
|
||||
rustup default $RUST_VERSION
|
||||
rustup target add $rust_target-pc-windows-msvc
|
||||
|
||||
cp "$SCRIPT_DIR/mozconfig" "$gecko_path"
|
||||
|
||||
|
|
@ -43,3 +43,17 @@ fi
|
|||
cd "$gecko_path"
|
||||
./mach build
|
||||
./mach package
|
||||
|
||||
# Copy the packaged ZIP back to app/win/ for launcher/xul.dll extraction
|
||||
case $arch in
|
||||
x64) zip_suffix=win64 ;;
|
||||
arm64) zip_suffix=win64-aarch64 ;;
|
||||
win32) zip_suffix=win32 ;;
|
||||
esac
|
||||
zip_file=$(ls "$gecko_path"/obj-*/dist/firefox-*.en-US.${zip_suffix}.zip 2>/dev/null | head -1) || true
|
||||
if [ -z "$zip_file" ] || [ ! -f "$zip_file" ]; then
|
||||
echo "Could not find packaged zip for $arch in $gecko_path/obj-*/dist/" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp "$zip_file" "$SCRIPT_DIR/"
|
||||
echo "Copied $(basename "$zip_file") to $SCRIPT_DIR"
|
||||
|
|
|
|||
|
|
@ -41,15 +41,15 @@ fi
|
|||
# Clean up any old artifacts
|
||||
rm -f "$version"-*.zip
|
||||
|
||||
# Process each architecture
|
||||
# Process whichever arch ZIPs are present
|
||||
processed=0
|
||||
for i in "${!files[@]}"; do
|
||||
file="${files[$i]}"
|
||||
arch="${arches[$i]}"
|
||||
temp_zip="xul-temp-$arch.zip"
|
||||
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "Missing file: $file"
|
||||
exit 1
|
||||
continue
|
||||
fi
|
||||
|
||||
dlls=(xul.dll)
|
||||
|
|
@ -75,4 +75,10 @@ for i in "${!files[@]}"; do
|
|||
rm -f xul.dll lgpllibs.dll
|
||||
|
||||
echo "Created $final_zip"
|
||||
processed=$((processed + 1))
|
||||
done
|
||||
|
||||
if [ $processed -eq 0 ]; then
|
||||
echo "No firefox-$version.en-US.*.zip files found in $SCRIPT_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -29,6 +29,11 @@ ac_add_options --disable-update-agent
|
|||
|
||||
export MOZILLA_OFFICIAL=1
|
||||
export RELEASE_OR_BETA=1
|
||||
|
||||
# We don't need source-repo info embedded, and generating it would require a
|
||||
# source-repo.h that Firefox only auto-populates for Mercurial, not our git
|
||||
# checkout, which breaks packaging. Turn it off.
|
||||
ac_add_options MOZ_INCLUDE_SOURCE_INFO=
|
||||
MOZ_REQUIRE_SIGNING=
|
||||
|
||||
# Build updater without MAR signature verification
|
||||
|
|
|
|||
|
|
@ -1,144 +0,0 @@
|
|||
diff --git a/browser/app/nsBrowserApp.cpp b/browser/app/nsBrowserApp.cpp
|
||||
index a85fdbfebe0f..819475717ffb 100644
|
||||
--- a/browser/app/nsBrowserApp.cpp
|
||||
+++ b/browser/app/nsBrowserApp.cpp
|
||||
@@ -156,19 +156,30 @@ static bool IsArg(const char* arg, const char* s) {
|
||||
#endif
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
MOZ_RUNINIT Bootstrap::UniquePtr gBootstrap;
|
||||
|
||||
static int do_main(int argc, char* argv[], char* envp[]) {
|
||||
+ // Allow profile downgrade for Zotero
|
||||
+ _putenv_s("MOZ_ALLOW_DOWNGRADE", "1");
|
||||
+ // Don't create dedicated profile (default-esr)
|
||||
+ _putenv_s("MOZ_LEGACY_PROFILES", "1");
|
||||
+
|
||||
// Allow firefox.exe to launch XULRunner apps via -app <application.ini>
|
||||
// Note that -app must be the *first* argument.
|
||||
- const char* appDataFile = getenv("XUL_APP_FILE");
|
||||
+ UniqueFreePtr<char> iniPath = BinaryPath::GetApplicationIni();
|
||||
+ if (!iniPath) {
|
||||
+ Output("Couldn't find application.ini.\n");
|
||||
+ return 255;
|
||||
+
|
||||
+ }
|
||||
+ char *appDataFile = iniPath.get();
|
||||
if ((!appDataFile || !*appDataFile) && (argc > 1 && IsArg(argv[1], "app"))) {
|
||||
if (argc == 2) {
|
||||
Output("Incorrect number of arguments passed to -app");
|
||||
return 255;
|
||||
}
|
||||
appDataFile = argv[2];
|
||||
|
||||
char appEnv[MAXPATHLEN];
|
||||
diff --git a/browser/app/winlauncher/LauncherProcessWin.cpp b/browser/app/winlauncher/LauncherProcessWin.cpp
|
||||
index 8167d2b81c91..a1ef657232bf 100644
|
||||
--- a/browser/app/winlauncher/LauncherProcessWin.cpp
|
||||
+++ b/browser/app/winlauncher/LauncherProcessWin.cpp
|
||||
@@ -156,17 +156,31 @@ static mozilla::LauncherFlags ProcessCmdLine(int& aArgc, wchar_t* aArgv[]) {
|
||||
mozilla::CheckArgFlag::None) == mozilla::ARG_FOUND ||
|
||||
mozilla::CheckArg(aArgc, aArgv, "remote-debugging-port", nullptr,
|
||||
mozilla::CheckArgFlag::None) == mozilla::ARG_FOUND ||
|
||||
mozilla::EnvHasValue("MOZ_AUTOMATION") ||
|
||||
mozilla::EnvHasValue("MOZ_HEADLESS")) {
|
||||
result |= mozilla::LauncherFlags::eWaitForBrowser;
|
||||
}
|
||||
|
||||
- if (mozilla::CheckArg(aArgc, aArgv, "no-deelevate") == mozilla::ARG_FOUND) {
|
||||
+ // Disable deelevation for Zotero
|
||||
+ //
|
||||
+ // If people are running as Administrator, or in some cases running with
|
||||
+ // UAC disabled, Word runs at integrity level High and deelevation drops
|
||||
+ // Zotero down to Medium, which causes 'Could not find a running Word
|
||||
+ // instance' errors.
|
||||
+ //
|
||||
+ // Even when not running as administrator, the process switch involved in
|
||||
+ // deelevation also seems to be causing various security software, such as
|
||||
+ // Cisco Secure Endpoint, to block Zotero from running:
|
||||
+ // https://forums.zotero.org/discussion/116762
|
||||
+ //
|
||||
+ // Disabling deelevation returns us to the behavior of Zotero 6, Word, and
|
||||
+ // most other programs
|
||||
+ if (true || mozilla::CheckArg(aArgc, aArgv, "no-deelevate") == mozilla::ARG_FOUND) {
|
||||
result |= mozilla::LauncherFlags::eNoDeelevate;
|
||||
}
|
||||
|
||||
if (mozilla::CheckArg(aArgc, aArgv, ATTEMPTING_DEELEVATION_FLAG) ==
|
||||
mozilla::ARG_FOUND) {
|
||||
result |= mozilla::LauncherFlags::eDeelevating;
|
||||
}
|
||||
|
||||
diff --git a/toolkit/mozapps/update/common/commonupdatedir.cpp b/toolkit/mozapps/update/common/commonupdatedir.cpp
|
||||
index 0ba9fcef9417..ed286476fec3 100644
|
||||
--- a/toolkit/mozapps/update/common/commonupdatedir.cpp
|
||||
+++ b/toolkit/mozapps/update/common/commonupdatedir.cpp
|
||||
@@ -38,17 +38,17 @@
|
||||
// (i.e. C:\ProgramData\<ROOT_UPDATE_DIR_NAME>)
|
||||
// It is really important that we properly set the permissions on this
|
||||
// directory at creation time. Thus, it is really important that this code be
|
||||
// the creator of this directory. We had many problems with the old update
|
||||
// directory having been previously created by old versions of Firefox. To avoid
|
||||
// this problem in the future, we are including a UUID in the root update
|
||||
// directory name to attempt to ensure that it will be created by this code and
|
||||
// won't already exist with the wrong permissions.
|
||||
-# define ROOT_UPDATE_DIR_NAME "Mozilla-1de4eec8-1241-4177-a864-e594e8d1fb38"
|
||||
+# define ROOT_UPDATE_DIR_NAME "Zotero"
|
||||
// This describes the directory between the "Mozilla" directory and the install
|
||||
// path hash (i.e. C:\ProgramData\Mozilla\<UPDATE_PATH_MID_DIR_NAME>\<hash>)
|
||||
# define UPDATE_PATH_MID_DIR_NAME "updates"
|
||||
|
||||
enum class WhichUpdateDir {
|
||||
CurrentUpdateDir,
|
||||
UnmigratedUpdateDir,
|
||||
};
|
||||
diff --git a/xpcom/build/BinaryPath.h b/xpcom/build/BinaryPath.h
|
||||
index 1718caa3c66d..10fa59c0cd80 100644
|
||||
--- a/xpcom/build/BinaryPath.h
|
||||
+++ b/xpcom/build/BinaryPath.h
|
||||
@@ -283,16 +283,43 @@ class BinaryPath {
|
||||
if (NS_FAILED(Get(path))) {
|
||||
return nullptr;
|
||||
}
|
||||
UniqueFreePtr<char> result;
|
||||
result.reset(strdup(path));
|
||||
return result;
|
||||
}
|
||||
|
||||
+ static UniqueFreePtr<char> GetApplicationIni() {
|
||||
+ char path[MAXPATHLEN];
|
||||
+ if (NS_FAILED(Get(path))) {
|
||||
+ return nullptr;
|
||||
+ }
|
||||
+
|
||||
+ char *c = path + strlen(path);
|
||||
+ while (c >= path && *c != '\\' && *c != '/') {
|
||||
+ *c = NULL;
|
||||
+ c--;
|
||||
+ }
|
||||
+
|
||||
+ if (c < path) {
|
||||
+ return nullptr;
|
||||
+ }
|
||||
+
|
||||
+ char iniPath[MAXPATHLEN];
|
||||
+ int n = snprintf(iniPath, MAXPATHLEN, "%s\\app\\application.ini", path);
|
||||
+ if (n < 0 || n >= MAXPATHLEN) {
|
||||
+ return nullptr;
|
||||
+ }
|
||||
+
|
||||
+ UniqueFreePtr<char> result;
|
||||
+ result.reset(strdup(iniPath));
|
||||
+ return result;
|
||||
+ }
|
||||
+
|
||||
#ifdef MOZILLA_INTERNAL_API
|
||||
static nsresult GetFile(nsIFile** aResult) {
|
||||
nsCOMPtr<nsIFile> lf;
|
||||
# ifdef XP_WIN
|
||||
wchar_t exePath[MAXPATHLEN];
|
||||
nsresult rv = GetW(exePath);
|
||||
# else
|
||||
char exePath[MAXPATHLEN];
|
||||
179
app/win/mozilla-153.patch
Normal file
179
app/win/mozilla-153.patch
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
diff --git a/browser/app/nsBrowserApp.cpp b/browser/app/nsBrowserApp.cpp
|
||||
index 4c5982c056fd..1ad4c9dfd597 100644
|
||||
--- a/browser/app/nsBrowserApp.cpp
|
||||
+++ b/browser/app/nsBrowserApp.cpp
|
||||
@@ -198,9 +198,20 @@ static bool HasFlag(int argc, char* argv[], const char* s) {
|
||||
constinit Bootstrap::UniquePtr gBootstrap;
|
||||
|
||||
static int do_main(int argc, char* argv[], char* envp[]) {
|
||||
+ // Allow profile downgrade for Zotero
|
||||
+ _putenv_s("MOZ_ALLOW_DOWNGRADE", "1");
|
||||
+ // Don't create dedicated profile (default-esr)
|
||||
+ _putenv_s("MOZ_LEGACY_PROFILES", "1");
|
||||
+
|
||||
// Allow firefox.exe to launch XULRunner apps via -app <application.ini>
|
||||
// Note that -app must be the *first* argument.
|
||||
- const char* appDataFile = getenv("XUL_APP_FILE");
|
||||
+ UniqueFreePtr<char> iniPath = BinaryPath::GetApplicationIni();
|
||||
+ if (!iniPath) {
|
||||
+ Output("Couldn't find application.ini.\n");
|
||||
+ return 255;
|
||||
+
|
||||
+ }
|
||||
+ char *appDataFile = iniPath.get();
|
||||
if ((!appDataFile || !*appDataFile) && (argc > 1 && IsFlag(argv[1], "app"))) {
|
||||
if (argc == 2) {
|
||||
Output("Incorrect number of arguments passed to -app");
|
||||
diff --git a/browser/app/winlauncher/LauncherProcessWin.cpp b/browser/app/winlauncher/LauncherProcessWin.cpp
|
||||
index 8337336dc894..c55677631c88 100644
|
||||
--- a/browser/app/winlauncher/LauncherProcessWin.cpp
|
||||
+++ b/browser/app/winlauncher/LauncherProcessWin.cpp
|
||||
@@ -250,7 +250,21 @@ static mozilla::LauncherFlags ProcessCmdLine(int& aArgc, wchar_t* aArgv[]) {
|
||||
result |= mozilla::LauncherFlags::eWaitForBrowser;
|
||||
}
|
||||
|
||||
- if (mozilla::CheckArg(aArgc, aArgv, "no-deelevate") == mozilla::ARG_FOUND) {
|
||||
+ // Disable deelevation for Zotero
|
||||
+ //
|
||||
+ // If people are running as Administrator, or in some cases running with
|
||||
+ // UAC disabled, Word runs at integrity level High and deelevation drops
|
||||
+ // Zotero down to Medium, which causes 'Could not find a running Word
|
||||
+ // instance' errors.
|
||||
+ //
|
||||
+ // Even when not running as administrator, the process switch involved in
|
||||
+ // deelevation also seems to be causing various security software, such as
|
||||
+ // Cisco Secure Endpoint, to block Zotero from running:
|
||||
+ // https://forums.zotero.org/discussion/116762
|
||||
+ //
|
||||
+ // Disabling deelevation returns us to the behavior of Zotero 6, Word, and
|
||||
+ // most other programs
|
||||
+ if (true || mozilla::CheckArg(aArgc, aArgv, "no-deelevate") == mozilla::ARG_FOUND) {
|
||||
result |= mozilla::LauncherFlags::eNoDeelevate;
|
||||
}
|
||||
|
||||
diff --git a/toolkit/components/remote/WinRemoteMessage.cpp b/toolkit/components/remote/WinRemoteMessage.cpp
|
||||
index 16a59fa7beda..4aa85c4a7632 100644
|
||||
--- a/toolkit/components/remote/WinRemoteMessage.cpp
|
||||
+++ b/toolkit/components/remote/WinRemoteMessage.cpp
|
||||
@@ -32,6 +32,31 @@ WinRemoteMessageSender::WinRemoteMessageSender(int32_t aArgc,
|
||||
|
||||
COPYDATASTRUCT* WinRemoteMessageSender::CopyData() { return &mData; }
|
||||
|
||||
+nsresult WinRemoteMessageReceiver::ParseV0(const nsACString& aBuffer) {
|
||||
+ CommandLineParserWin<char> parser;
|
||||
+ parser.HandleCommandLine(aBuffer);
|
||||
+
|
||||
+ mCommandLine = new nsCommandLine();
|
||||
+ return mCommandLine->Init(parser.Argc(), parser.Argv(), nullptr,
|
||||
+ nsICommandLine::STATE_REMOTE_AUTO);
|
||||
+}
|
||||
+
|
||||
+nsresult WinRemoteMessageReceiver::ParseV1(const nsACString& aBuffer) {
|
||||
+ CommandLineParserWin<char> parser;
|
||||
+ size_t cch = parser.HandleCommandLine(aBuffer);
|
||||
+ ++cch; // skip a null char
|
||||
+
|
||||
+ nsCOMPtr<nsIFile> workingDir;
|
||||
+ if (cch < aBuffer.Length()) {
|
||||
+ (void)NS_NewLocalFile(NS_ConvertUTF8toUTF16(Substring(aBuffer, cch)),
|
||||
+ getter_AddRefs(workingDir));
|
||||
+ }
|
||||
+
|
||||
+ mCommandLine = new nsCommandLine();
|
||||
+ return mCommandLine->Init(parser.Argc(), parser.Argv(), workingDir,
|
||||
+ nsICommandLine::STATE_REMOTE_AUTO);
|
||||
+}
|
||||
+
|
||||
nsresult WinRemoteMessageReceiver::ParseV2(const nsAString& aBuffer) {
|
||||
CommandLineParserWin<char16_t> parser;
|
||||
size_t cch = parser.HandleCommandLine(aBuffer);
|
||||
@@ -110,6 +135,12 @@ nsresult WinRemoteMessageReceiver::ParseV3(const nsACString& aBuffer) {
|
||||
|
||||
nsresult WinRemoteMessageReceiver::Parse(const COPYDATASTRUCT* aMessageData) {
|
||||
switch (static_cast<WinRemoteMessageVersion>(aMessageData->dwData)) {
|
||||
+ case WinRemoteMessageVersion::CommandLineOnly:
|
||||
+ return ParseV0(nsDependentCSubstring(
|
||||
+ reinterpret_cast<char*>(aMessageData->lpData), aMessageData->cbData));
|
||||
+ case WinRemoteMessageVersion::CommandLineAndWorkingDir:
|
||||
+ return ParseV1(nsDependentCSubstring(
|
||||
+ reinterpret_cast<char*>(aMessageData->lpData), aMessageData->cbData));
|
||||
case WinRemoteMessageVersion::CommandLineAndWorkingDirInUtf16:
|
||||
return ParseV2(
|
||||
nsDependentSubstring(reinterpret_cast<wchar_t*>(aMessageData->lpData),
|
||||
diff --git a/toolkit/components/remote/WinRemoteMessage.h b/toolkit/components/remote/WinRemoteMessage.h
|
||||
index b62fb73fa13d..ee6612f55f4b 100644
|
||||
--- a/toolkit/components/remote/WinRemoteMessage.h
|
||||
+++ b/toolkit/components/remote/WinRemoteMessage.h
|
||||
@@ -28,10 +28,10 @@
|
||||
// some sort, as v3 does, to reduce the chances of variants of bug 1847458.
|
||||
enum class WinRemoteMessageVersion : uint32_t {
|
||||
// "<CommandLine>\0" in utf8
|
||||
- /* CommandLineOnly = 0, */
|
||||
+ CommandLineOnly = 0,
|
||||
|
||||
// "<CommandLine>\0<WorkingDir>\0" in utf8
|
||||
- /* CommandLineAndWorkingDir = 1, */
|
||||
+ CommandLineAndWorkingDir = 1,
|
||||
|
||||
// L"<CommandLine>\0<WorkingDir>\0" in utf16, used by ESR 128
|
||||
CommandLineAndWorkingDirInUtf16 = 2,
|
||||
@@ -61,6 +61,8 @@ class WinRemoteMessageSender final {
|
||||
class WinRemoteMessageReceiver final {
|
||||
nsCOMPtr<nsICommandLineRunner> mCommandLine;
|
||||
|
||||
+ nsresult ParseV0(const nsACString& aBuffer);
|
||||
+ nsresult ParseV1(const nsACString& aBuffer);
|
||||
nsresult ParseV2(const nsAString& aBuffer);
|
||||
nsresult ParseV3(const nsACString& aBuffer);
|
||||
|
||||
diff --git a/toolkit/mozapps/update/common/commonupdatedir.cpp b/toolkit/mozapps/update/common/commonupdatedir.cpp
|
||||
index 0ba9fcef9417..ed286476fec3 100644
|
||||
--- a/toolkit/mozapps/update/common/commonupdatedir.cpp
|
||||
+++ b/toolkit/mozapps/update/common/commonupdatedir.cpp
|
||||
@@ -43,7 +43,7 @@
|
||||
// this problem in the future, we are including a UUID in the root update
|
||||
// directory name to attempt to ensure that it will be created by this code and
|
||||
// won't already exist with the wrong permissions.
|
||||
-# define ROOT_UPDATE_DIR_NAME "Mozilla-1de4eec8-1241-4177-a864-e594e8d1fb38"
|
||||
+# define ROOT_UPDATE_DIR_NAME "Zotero"
|
||||
// This describes the directory between the "Mozilla" directory and the install
|
||||
// path hash (i.e. C:\ProgramData\Mozilla\<UPDATE_PATH_MID_DIR_NAME>\<hash>)
|
||||
# define UPDATE_PATH_MID_DIR_NAME "updates"
|
||||
diff --git a/xpcom/build/BinaryPath.h b/xpcom/build/BinaryPath.h
|
||||
index 2c8d78f02440..f55d3db32469 100644
|
||||
--- a/xpcom/build/BinaryPath.h
|
||||
+++ b/xpcom/build/BinaryPath.h
|
||||
@@ -286,6 +286,33 @@ class BinaryPath {
|
||||
return result;
|
||||
}
|
||||
|
||||
+ static UniqueFreePtr<char> GetApplicationIni() {
|
||||
+ char path[MAXPATHLEN];
|
||||
+ if (NS_FAILED(Get(path))) {
|
||||
+ return nullptr;
|
||||
+ }
|
||||
+
|
||||
+ char *c = path + strlen(path);
|
||||
+ while (c >= path && *c != '\\' && *c != '/') {
|
||||
+ *c = NULL;
|
||||
+ c--;
|
||||
+ }
|
||||
+
|
||||
+ if (c < path) {
|
||||
+ return nullptr;
|
||||
+ }
|
||||
+
|
||||
+ char iniPath[MAXPATHLEN];
|
||||
+ int n = snprintf(iniPath, MAXPATHLEN, "%s\\app\\application.ini", path);
|
||||
+ if (n < 0 || n >= MAXPATHLEN) {
|
||||
+ return nullptr;
|
||||
+ }
|
||||
+
|
||||
+ UniqueFreePtr<char> result;
|
||||
+ result.reset(strdup(iniPath));
|
||||
+ return result;
|
||||
+ }
|
||||
+
|
||||
#ifdef MOZILLA_INTERNAL_API
|
||||
static nsresult GetFile(nsIFile** aResult) {
|
||||
nsCOMPtr<nsIFile> lf;
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e66cbd095e4b714ef5f2494240190f67da78af77053368b65f380c7c7ef95f41
|
||||
size 393284
|
||||
oid sha256:13a76afa9dac4bfbb5f1a9b2c69ecc82f300a6f52a673c562da8f30a11b62438
|
||||
size 876372
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:070a38196290224fcb0db22bbf0aa739d60188cf3df6137ced3b26819b7e98b6
|
||||
size 524100
|
||||
oid sha256:5ab890f9893d17d1b492f6762adc9d60d29e405eb089ca1311e522cb987cbca9
|
||||
size 559376
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ var Scaffold = new function () {
|
|||
var _editors = {};
|
||||
|
||||
var _browserProgressListener = null;
|
||||
var _persistentCookieContext = null;
|
||||
|
||||
var _propertyMap = {
|
||||
'textbox-translatorID': 'translatorID',
|
||||
|
|
@ -224,6 +225,10 @@ var Scaffold = new function () {
|
|||
|
||||
this.handleUnload = function () {
|
||||
Zotero.Prefs.unregisterObserver(_prefsObserverID);
|
||||
if (_persistentCookieContext) {
|
||||
_persistentCookieContext.dispose();
|
||||
_persistentCookieContext = null;
|
||||
}
|
||||
};
|
||||
|
||||
this.promptForTranslatorsDirectory = async function () {
|
||||
|
|
@ -290,6 +295,8 @@ var Scaffold = new function () {
|
|||
let translatorID = document.getElementById('textbox-translatorID').value;
|
||||
let modifiedTime = Scaffold_Translators.getModifiedTime(translatorID);
|
||||
if (modifiedTime && modifiedTime > _lastModifiedTime) {
|
||||
_lastModifiedTime = modifiedTime;
|
||||
|
||||
let ps = Services.prompt;
|
||||
let buttonFlags = ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING
|
||||
+ ps.BUTTON_POS_1 * ps.BUTTON_TITLE_IS_STRING;
|
||||
|
|
@ -304,9 +311,6 @@ var Scaffold = new function () {
|
|||
if (index == 1) {
|
||||
await this.load(translatorID);
|
||||
}
|
||||
else {
|
||||
_lastModifiedTime = modifiedTime;
|
||||
}
|
||||
}
|
||||
|
||||
_updateTitle();
|
||||
|
|
@ -2008,15 +2012,33 @@ var Scaffold = new function () {
|
|||
_clearOutput();
|
||||
|
||||
let rememberCookies = document.getElementById('checkbox-remember-cookies').checked;
|
||||
let cookieContext;
|
||||
if (rememberCookies) {
|
||||
if (!_persistentCookieContext) {
|
||||
_persistentCookieContext = Zotero.HTTP.newCookieContext();
|
||||
}
|
||||
cookieContext = _persistentCookieContext;
|
||||
}
|
||||
else {
|
||||
cookieContext = Zotero.HTTP.newCookieContext();
|
||||
}
|
||||
|
||||
let tester = new TranslatorTester(_getTranslatorFromPane(), {
|
||||
translatorProvider: _translatorProvider,
|
||||
cookieSandbox: rememberCookies ? null : new Zotero.CookieSandbox(),
|
||||
cookieSandbox: cookieContext.id,
|
||||
debug: _logOutput,
|
||||
webTranslationEnvironment: new ZoteroWebTranslationEnvironment(),
|
||||
});
|
||||
|
||||
for (let test of tests) {
|
||||
yield { test, ...await tester.run(test) };
|
||||
try {
|
||||
for (let test of tests) {
|
||||
yield { test, ...await tester.run(test) };
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if (!rememberCookies) {
|
||||
cookieContext.dispose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -293,11 +293,16 @@
|
|||
</keyset>
|
||||
|
||||
<!-- nsMenuBarX hides these and uses them to build the Application menu. -->
|
||||
<!-- macOS 13 changed "Preferences" to "Settings" so one of these gets picked -->
|
||||
<menupopup id="menu_FilePopup">
|
||||
<menuitem id="menu_preferences"
|
||||
label="&preferencesCmdMac.label;"
|
||||
key="key_preferencesCmdMac"
|
||||
oncommand="Zotero.Utilities.Internal.openPreferences()"/>
|
||||
<menuitem id="menu_settings"
|
||||
data-l10n-id="menu-application-settings"
|
||||
key="key_preferencesCmdMac"
|
||||
oncommand="Zotero.Utilities.Internal.openPreferences()"/>
|
||||
<menuitem id="menu_mac_services"
|
||||
label="&servicesMenuMac.label;"/>
|
||||
<menuitem id="menu_mac_hide_app"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export class ZoteroWebTranslationEnvironment extends AbstractWebTranslationEnvir
|
|||
const { HiddenBrowser } = ChromeUtils.importESModule('chrome://zotero/content/HiddenBrowser.mjs');
|
||||
let browser = new HiddenBrowser({
|
||||
docShell: { allowMetaRedirects: true },
|
||||
cookieSandbox: tester.cookieSandbox,
|
||||
userContextId: tester.cookieSandbox,
|
||||
});
|
||||
|
||||
await browser.load(url, { requireSuccessfulStatus: true });
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ ChromeUtils.defineESModuleGetters(globalThis, {
|
|||
|
||||
const progressListeners = new Set();
|
||||
|
||||
// From nsSandboxFlags.h
|
||||
const SANDBOXED_ORIGIN = 0x10;
|
||||
const SANDBOXED_SCRIPTS = 0x80;
|
||||
|
||||
/**
|
||||
* Functions for creating and destroying hidden browser objects
|
||||
**/
|
||||
|
|
@ -48,10 +52,13 @@ export class HiddenBrowser {
|
|||
* @param {Boolean} [options.blockRemoteResources] Block all remote (non-file:) resources
|
||||
* @param {Boolean} [options.useHiddenFrame=true] Use a hidden frame to create the browser.
|
||||
* Must be set to false if intending to call print().
|
||||
* @param {Zotero.CookieSandbox} [options.cookieSandbox]
|
||||
* @param {Number} [options.userContextId] - From Zotero.HTTP.newCookieContext() for cookie isolation
|
||||
* @param {String} [options.customUserAgent] - Override User-Agent for all requests
|
||||
* from this browser's browsing context
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this._destroyed = false;
|
||||
this._allowJavaScript = options.allowJavaScript !== false;
|
||||
this._createdPromise = (async () => {
|
||||
let doc;
|
||||
if (options.useHiddenFrame !== false) {
|
||||
|
|
@ -59,7 +66,6 @@ export class HiddenBrowser {
|
|||
this._frame = frame;
|
||||
|
||||
var windowlessBrowser = await frame.get();
|
||||
windowlessBrowser.browsingContext.allowJavascript = options.allowJavaScript !== false;
|
||||
windowlessBrowser.docShell.allowImages = false;
|
||||
if (options.docShell) {
|
||||
Object.assign(windowlessBrowser.docShell, options.docShell);
|
||||
|
|
@ -80,13 +86,12 @@ export class HiddenBrowser {
|
|||
browser.setAttribute("remote", "true");
|
||||
browser.setAttribute('maychangeremoteness', 'true');
|
||||
browser.setAttribute("disableglobalhistory", "true");
|
||||
if (options.userContextId) {
|
||||
browser.setAttribute("usercontextid", String(options.userContextId));
|
||||
}
|
||||
browser.style.display = "none";
|
||||
doc.documentElement.appendChild(browser);
|
||||
|
||||
if (options.cookieSandbox) {
|
||||
options.cookieSandbox.attachToBrowser(browser);
|
||||
}
|
||||
|
||||
|
||||
if (options.blockRemoteResources) {
|
||||
this._blockingObserver = new BlockingObserver({
|
||||
shouldBlock(uri) {
|
||||
|
|
@ -96,6 +101,17 @@ export class HiddenBrowser {
|
|||
this._blockingObserver.register(browser);
|
||||
}
|
||||
|
||||
if (options.customUserAgent) {
|
||||
browser.browsingContext.customUserAgent = options.customUserAgent;
|
||||
}
|
||||
|
||||
if (!this._allowJavaScript) {
|
||||
// A system-principal document (e.g., a blob: URL created by chrome code) can run
|
||||
// scripts even when scripting is otherwise disabled, so sandbox it with a null
|
||||
// principal and no scripts.
|
||||
browser.browsingContext.sandboxFlags |= SANDBOXED_ORIGIN | SANDBOXED_SCRIPTS;
|
||||
}
|
||||
|
||||
this._browser = browser;
|
||||
})();
|
||||
|
||||
|
|
@ -142,15 +158,11 @@ export class HiddenBrowser {
|
|||
// Figure out whether the browser should be remote. We actually
|
||||
// perform the load in PageDataChild, but remoteness changes
|
||||
// need to happen here.
|
||||
let oa = E10SUtils.predictOriginAttributes({ browser: this });
|
||||
let remoteType = E10SUtils.getRemoteTypeForURI(
|
||||
uri,
|
||||
true,
|
||||
false,
|
||||
E10SUtils.DEFAULT_REMOTE_TYPE,
|
||||
null,
|
||||
oa
|
||||
);
|
||||
let remoteType = ChromeUtils.predictRemoteTypeForURI(uri, {
|
||||
preferredRemoteType: E10SUtils.DEFAULT_REMOTE_TYPE,
|
||||
useRemoteTabs: true,
|
||||
useRemoteSubframes: false
|
||||
});
|
||||
if (this.remoteType !== remoteType) {
|
||||
// The following functions need to be called on the <browser> directly,
|
||||
// not through our proxy (aka 'this')
|
||||
|
|
@ -250,12 +262,22 @@ export class HiddenBrowser {
|
|||
|
||||
/**
|
||||
* @param {String[]} props - 'characterSet', 'title', 'bodyText', 'documentHTML', 'cookie', 'channelInfo'
|
||||
* @param {Object} [options]
|
||||
* @param {Number} [options.timeout=30000] - Time to wait for each property in milliseconds.
|
||||
* The queries wait for the document to be ready, so a page that never finishes loading
|
||||
* would otherwise hang the query forever.
|
||||
*/
|
||||
async getPageData(props) {
|
||||
async getPageData(props, { timeout = 30000 } = {}) {
|
||||
var actor = this.browsingContext.currentWindowGlobal.getActor("PageData");
|
||||
var data = {};
|
||||
for (let prop of props) {
|
||||
data[prop] = await actor.sendQuery(prop);
|
||||
let timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(
|
||||
() => reject(new Error(`Timed out getting '${prop}' from hidden browser`)),
|
||||
timeout
|
||||
);
|
||||
});
|
||||
data[prop] = await Promise.race([actor.sendQuery(prop), timeoutPromise]);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ export class PageDataChild extends JSWindowActorChild {
|
|||
case "documentHTML":
|
||||
return new XMLSerializer().serializeToString(document);
|
||||
|
||||
case "querySelectorMatches":
|
||||
return !!document.querySelector(message.data.selector);
|
||||
|
||||
case "channelInfo": {
|
||||
let docShell = this.contentWindow.docShell;
|
||||
try {
|
||||
|
|
|
|||
33
chrome/content/zotero/actors/ProfilerViewerChild.mjs
Normal file
33
chrome/content/zotero/actors/ProfilerViewerChild.mjs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
export class ProfilerViewerChild extends JSWindowActorChild {
|
||||
async handleEvent(event) {
|
||||
if (event.type !== "click" || event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
let target = event.composedTarget || event.originalTarget || event.target;
|
||||
let a = target?.closest?.("a[href]");
|
||||
if (!a) return;
|
||||
let href = a.href;
|
||||
|
||||
if (href.startsWith("blob:") && a.hasAttribute("download")) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
let contentBuffer = await (await fetch(href)).arrayBuffer();
|
||||
let bytes = new Uint8Array(contentBuffer.byteLength);
|
||||
bytes.set(new Uint8Array(contentBuffer));
|
||||
this.sendAsyncMessage("ProfilerViewer:Download", {
|
||||
filename: a.download || "profile",
|
||||
bytes,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (/^https?:/.test(href)) {
|
||||
let isOffOrigin = !href.startsWith("https://profiler.firefox.com/");
|
||||
if (a.target === "_blank" || isOffOrigin) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.sendAsyncMessage("ProfilerViewer:OpenExternal", { href });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
31
chrome/content/zotero/actors/ProfilerViewerParent.mjs
Normal file
31
chrome/content/zotero/actors/ProfilerViewerParent.mjs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
export class ProfilerViewerParent extends JSWindowActorParent {
|
||||
async receiveMessage(message) {
|
||||
switch (message.name) {
|
||||
case "ProfilerViewer:OpenExternal": {
|
||||
// launchWithURI via the protocol handler info skips the
|
||||
// prompt that loadURI() triggers
|
||||
const uri = Services.io.newURI(message.data.href);
|
||||
const handler = Cc[
|
||||
"@mozilla.org/uriloader/external-protocol-service;1"
|
||||
].getService(Ci.nsIExternalProtocolService).getProtocolHandlerInfo(uri.scheme);
|
||||
handler.preferredAction = Ci.nsIHandlerInfo.useSystemDefault;
|
||||
handler.launchWithURI(uri, null);
|
||||
return;
|
||||
}
|
||||
case "ProfilerViewer:Download": {
|
||||
const browser = this.browsingContext.top.embedderElement;
|
||||
const fp = Cc["@mozilla.org/filepicker;1"]
|
||||
.createInstance(Ci.nsIFilePicker);
|
||||
fp.init(
|
||||
browser.browsingContext,
|
||||
"Save Profile",
|
||||
Ci.nsIFilePicker.modeSave
|
||||
);
|
||||
fp.defaultString = message.data.filename;
|
||||
const rv = await new Promise(resolve => fp.open(resolve));
|
||||
if (rv === Ci.nsIFilePicker.returnCancel) return;
|
||||
await IOUtils.write(fp.file.path, message.data.bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -209,6 +209,9 @@ export class TranslationChild extends JSWindowActorChild {
|
|||
sameZoneAs: this.contentWindow,
|
||||
wantXrays: true,
|
||||
wantGlobalProperties: ["XMLHttpRequest", "fetch", "WebSocket"],
|
||||
// The translation framework adds Bluebird methods to Promise, which system-principal
|
||||
// sandboxes freeze by default
|
||||
freezeBuiltins: false,
|
||||
});
|
||||
|
||||
let scriptURIs = [
|
||||
|
|
|
|||
|
|
@ -109,15 +109,6 @@ Zotero.HTTP = new function() {
|
|||
|
||||
xmlhttp.open(method, url, true);
|
||||
|
||||
// Overwrite the system nsILoadInfo with one tied to our document
|
||||
// so CookieSandbox can identify the source of the XHR
|
||||
xmlhttp.channel.loadInfo = NetUtil.newChannel({
|
||||
uri: url,
|
||||
loadingNode: document,
|
||||
securityFlags: Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_INHERITS_SEC_CONTEXT,
|
||||
contentPolicyType: Ci.nsIContentPolicy.TYPE_XMLHTTPREQUEST,
|
||||
}).loadInfo;
|
||||
|
||||
for (let header in options.headers) {
|
||||
xmlhttp.setRequestHeader(header, options.headers[header]);
|
||||
}
|
||||
|
|
@ -158,7 +149,7 @@ Zotero.HTTP = new function() {
|
|||
* @param {String} url URL to request
|
||||
* @param {Function} onDone Callback to be executed upon request completion
|
||||
* @param {String} responseCharset
|
||||
* @param {N/A} cookieSandbox Not used in Connector
|
||||
* @param {N/A} cookieSandbox Unused, kept for compatibility with translate submodule
|
||||
* @param {Object} headers HTTP headers to include with the request
|
||||
* @return {Boolean} True if the request was sent, or false if the browser is offline
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,190 +0,0 @@
|
|||
/*
|
||||
***** BEGIN LICENSE BLOCK *****
|
||||
|
||||
Copyright © 2009 Center for History and New Media
|
||||
George Mason University, Fairfax, Virginia, USA
|
||||
http://zotero.org
|
||||
|
||||
This file is part of Zotero.
|
||||
|
||||
Zotero is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Zotero is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
***** END LICENSE BLOCK *****
|
||||
*/
|
||||
|
||||
|
||||
import ItemTree from 'zotero/itemTree';
|
||||
import { COLUMNS } from 'zotero/itemTreeColumns';
|
||||
|
||||
|
||||
var ZoteroAdvancedSearch = new function () {
|
||||
this.onLoad = onLoad;
|
||||
this.search = search;
|
||||
this.clear = clear;
|
||||
this.onItemActivate = onItemActivate;
|
||||
|
||||
this.itemsView = false;
|
||||
this._loadedDeferred = Zotero.Promise.defer();
|
||||
|
||||
var _searchBox;
|
||||
var _libraryID;
|
||||
|
||||
async function onLoad() {
|
||||
_searchBox = document.getElementById('zotero-search-box');
|
||||
|
||||
// Set font size from pref
|
||||
var sbc = document.getElementById('zotero-search-box-container');
|
||||
Zotero.UIProperties.registerRoot(sbc);
|
||||
|
||||
_searchBox.onLibraryChange = this.onLibraryChange;
|
||||
var io = window.arguments[0];
|
||||
|
||||
io.dataIn.search.loadPrimaryData()
|
||||
.then(function () {
|
||||
_searchBox.search = io.dataIn.search;
|
||||
});
|
||||
|
||||
var elem = document.getElementById('zotero-items-tree');
|
||||
const columns = COLUMNS.map((column) => {
|
||||
column = Object.assign({}, column);
|
||||
column.hidden = !['title', 'firstCreator', 'year', 'hasAttachment'].includes(column.dataKey);
|
||||
return column;
|
||||
});
|
||||
this.itemsView = await ItemTree.init(elem, {
|
||||
id: "advanced-search",
|
||||
dragAndDrop: true,
|
||||
persistColumns: true,
|
||||
columnPicker: true,
|
||||
onActivate: this.onItemActivate.bind(this),
|
||||
columns,
|
||||
});
|
||||
|
||||
await this.itemsView.changeCollectionTreeRow({
|
||||
ref: _searchBox.search,
|
||||
visibilityGroup: 'default',
|
||||
isSearchMode: () => true,
|
||||
getItems: async () => [],
|
||||
isLibrary: () => false,
|
||||
isCollection: () => false,
|
||||
isSearch: () => true,
|
||||
isPublications: () => false,
|
||||
isDuplicates: () => false,
|
||||
isFeed: () => false,
|
||||
isFeeds: () => false,
|
||||
isFeedsOrFeed: () => false,
|
||||
isShare: () => false,
|
||||
isTrash: () => false,
|
||||
isSearch: () => true
|
||||
});
|
||||
|
||||
// Focus the first field in the window
|
||||
Services.focus.moveFocus(window, null, Services.focus.MOVEFOCUS_FORWARD, 0);
|
||||
this._loadedDeferred.resolve();
|
||||
}
|
||||
|
||||
this.onUnload = function () {
|
||||
this.itemsView.unregister();
|
||||
}
|
||||
|
||||
function search() {
|
||||
_searchBox.updateSearch();
|
||||
_searchBox.active = true;
|
||||
|
||||
return this.itemsView.changeCollectionTreeRow({
|
||||
ref: _searchBox.search,
|
||||
visibilityGroup: 'default',
|
||||
isSearchMode: () => true,
|
||||
isSearch: () => true,
|
||||
getItems: async function () {
|
||||
await Zotero.Libraries.get(_libraryID).waitForDataLoad('item');
|
||||
|
||||
var search = _searchBox.search.clone();
|
||||
search.libraryID = _libraryID;
|
||||
var ids = await search.search();
|
||||
return Zotero.Items.get(ids);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function clear() {
|
||||
this.itemsView.changeCollectionTreeRow(null);
|
||||
|
||||
var s = new Zotero.Search();
|
||||
// Don't clear the selected library
|
||||
s.libraryID = _searchBox.search.libraryID;
|
||||
s.addCondition('title', 'contains', '');
|
||||
_searchBox.search = s;
|
||||
_searchBox.active = false;
|
||||
}
|
||||
|
||||
|
||||
this.save = async function () {
|
||||
_searchBox.updateSearch();
|
||||
|
||||
var promptService = Services.prompt;
|
||||
|
||||
var libraryID = _searchBox.search.libraryID;
|
||||
|
||||
var searches = await Zotero.Searches.getAll(libraryID);
|
||||
var prefix = Zotero.getString('pane.collections.untitled');
|
||||
var name = Zotero.Utilities.Internal.getNextName(
|
||||
prefix,
|
||||
searches.map(s => s.name).filter(n => n.startsWith(prefix))
|
||||
);
|
||||
|
||||
name = { value: name };
|
||||
var result = promptService.prompt(window,
|
||||
Zotero.getString('pane.collections.newSavedSeach'),
|
||||
Zotero.getString('pane.collections.savedSearchName'), name, "", {});
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!name.value) {
|
||||
name.value = 'untitled';
|
||||
}
|
||||
|
||||
var s = _searchBox.search.clone();
|
||||
s.name = name.value;
|
||||
await s.saveTx();
|
||||
|
||||
window.close();
|
||||
};
|
||||
|
||||
|
||||
this.onLibraryChange = function (libraryID) {
|
||||
_libraryID = libraryID;
|
||||
var library = Zotero.Libraries.get(libraryID);
|
||||
var isEditable = library.editable && library.libraryType != 'publications';
|
||||
document.getElementById('zotero-search-save').disabled = !isEditable;
|
||||
}
|
||||
|
||||
|
||||
function onItemActivate(event, items)
|
||||
{
|
||||
var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
|
||||
.getService(Components.interfaces.nsIWindowMediator);
|
||||
|
||||
var lastWin = wm.getMostRecentWindow("navigator:browser");
|
||||
|
||||
if (!lastWin) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastWin.ZoteroPane.selectItems(items.map(item => item.id));
|
||||
lastWin.focus();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
<?xml version="1.0"?>
|
||||
<?xml-stylesheet href="chrome://global/skin/"?>
|
||||
<?xml-stylesheet href="chrome://zotero/skin/zotero.css" type="text/css"?>
|
||||
<?xml-stylesheet href="chrome://zotero/skin/overlay.css" type="text/css"?>
|
||||
<?xml-stylesheet href="chrome://zotero-platform/content/overlay.css"?>
|
||||
<?xml-stylesheet href="chrome://zotero-platform/content/zotero.css"?>
|
||||
|
||||
<!DOCTYPE window [
|
||||
<!ENTITY % zoteroDTD SYSTEM "chrome://zotero/locale/zotero.dtd">
|
||||
%zoteroDTD;
|
||||
<!ENTITY % searchboxDTD SYSTEM "chrome://zotero/locale/searchbox.dtd">
|
||||
%searchboxDTD;
|
||||
]>
|
||||
|
||||
<window
|
||||
id="zotero-advanced-search-dialog"
|
||||
title="&zotero.toolbar.advancedSearch;"
|
||||
orient="vertical"
|
||||
persist="screenX screenY width height"
|
||||
onload="ZoteroAdvancedSearch.onLoad()"
|
||||
onunload="ZoteroAdvancedSearch.onUnload();"
|
||||
xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
|
||||
xmlns:html="http://www.w3.org/1999/xhtml"
|
||||
windowtype="zotero:search"
|
||||
style="display: flex;">
|
||||
|
||||
<script src="include.js"/>
|
||||
<script>
|
||||
</script>
|
||||
<script src="customElements.js"/>
|
||||
<script src="advancedSearch.js"/>
|
||||
|
||||
<popupset>
|
||||
<panel is="autocomplete-richlistbox-popup"
|
||||
id="search-autocomplete-popup"
|
||||
type="autocomplete-richlistbox"
|
||||
noautofocus="true"/>
|
||||
</popupset>
|
||||
|
||||
<vbox id="zotero-search-box-container" flex="1">
|
||||
<vbox id="zotero-search-box-controls">
|
||||
<zoterosearch id="zotero-search-box" oncommand="if (this.active) { ZoteroAdvancedSearch.search(); }" flex="1"/>
|
||||
|
||||
<hbox id="zotero-search-buttons">
|
||||
<button label="&zotero.search.search;" default="true" oncommand="ZoteroAdvancedSearch.search()"/>
|
||||
<button label="&zotero.search.clear;" oncommand="ZoteroAdvancedSearch.clear()"/>
|
||||
<button id="zotero-search-save" label="&zotero.search.saveSearch;" oncommand="ZoteroAdvancedSearch.save()"/>
|
||||
</hbox>
|
||||
</vbox>
|
||||
<hbox class="virtualized-table-container" flex="1">
|
||||
<html:div id="zotero-items-tree"/>
|
||||
</hbox>
|
||||
</vbox>
|
||||
|
||||
<keyset>
|
||||
<key id="key_close" key="W" modifiers="accel" oncommand="window.close()"/>
|
||||
</keyset>
|
||||
</window>
|
||||
|
|
@ -63,7 +63,7 @@ window.Zotero_File_Interface_Bibliography = new function () {
|
|||
styleConfigurator = document.querySelector("#style-configurator");
|
||||
|
||||
// Disable accept button until CE is initialized
|
||||
document.querySelector("dialog").getButton('accept').setAttribute('disabled', true);
|
||||
document.querySelector("dialog").getButton('accept').setAttribute('disabled', 'true');
|
||||
document.querySelector("dialog").getButton('cancel').focus();
|
||||
|
||||
// Set font size from pref
|
||||
|
|
@ -103,7 +103,7 @@ window.Zotero_File_Interface_Bibliography = new function () {
|
|||
|
||||
// Wait for CE initialization
|
||||
await styleConfigurator.ready;
|
||||
document.querySelector("dialog").getButton('accept').setAttribute('disabled', false);
|
||||
document.querySelector("dialog").getButton('accept').removeAttribute('disabled');
|
||||
// reset focus to the first focusable element (requestAnimationFrame to make sure richlistbox has already appeared)
|
||||
requestAnimationFrame(() => {
|
||||
Services.focus.moveFocus(
|
||||
|
|
@ -392,4 +392,4 @@ window.Zotero_File_Interface_Bibliography = new function () {
|
|||
|
||||
window.cancel = () => {
|
||||
window.close();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
const React = require('react');
|
||||
const ReactDOM = require('react-dom');
|
||||
const LibraryTree = require('./libraryTree');
|
||||
const VirtualizedTable = require('components/virtualized-table');
|
||||
const VirtualizedTree = require('components/virtualized-table').VirtualizedTree;
|
||||
const { getCSSIcon } = require('components/icons');
|
||||
const { getDragTargetOrient } = require('components/utils');
|
||||
const { noop } = require("./components/utils");
|
||||
|
|
@ -50,6 +50,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
dragAndDrop: false,
|
||||
filterLibraryIDs: false,
|
||||
hideSources: [],
|
||||
multiSelect: false,
|
||||
onContextMenu: noop,
|
||||
};
|
||||
|
||||
|
|
@ -59,17 +60,19 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
dragAndDrop: PropTypes.bool,
|
||||
filterLibraryIDs: PropTypes.array,
|
||||
hideSources: PropTypes.array,
|
||||
multiSelect: PropTypes.bool,
|
||||
onContextMenu: PropTypes.func,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.itemTreeView = null;
|
||||
this.itemToSelect = null;
|
||||
|
||||
this.type = 'collection';
|
||||
this.name = "CollectionTree";
|
||||
this.id = "collection-tree";
|
||||
this._rows = [];
|
||||
this._rowMap = {};
|
||||
this._highlightedRows = new Set();
|
||||
this._unregisterID = Zotero.Notifier.registerObserver(
|
||||
this,
|
||||
|
|
@ -138,6 +141,8 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
}
|
||||
|
||||
componentDidMount() {
|
||||
super.componentDidMount();
|
||||
|
||||
this.selection.select(0);
|
||||
this.makeVisible();
|
||||
if (this.props.dragAndDrop) {
|
||||
|
|
@ -161,10 +166,10 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
return false; // In-line editing active
|
||||
}
|
||||
|
||||
var libraryID = this.getSelectedLibraryID();
|
||||
if (!libraryID) return true;
|
||||
let treeRow = this.getRow(this.selection.focused);
|
||||
|
||||
var libraryID = treeRow?.ref?.libraryID;
|
||||
if (!libraryID) return true;
|
||||
|
||||
if (event.key == '+' && !(event.ctrlKey || event.altKey || event.metaKey)) {
|
||||
this.expandLibrary(libraryID, true);
|
||||
}
|
||||
|
|
@ -180,6 +185,16 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
else if (event.key == "F2" && !Zotero.isMac && treeRow.isCollection()) {
|
||||
this.handleActivate(event, [this.selection.focused]);
|
||||
}
|
||||
else if (event.key == 'a' && !event.shiftKey
|
||||
&& (Zotero.isMac ? (event.metaKey && !event.ctrlKey) : event.ctrlKey)) {
|
||||
if (this.props.multiSelect) {
|
||||
this._handleSelectAll();
|
||||
}
|
||||
// Own Cmd/Ctrl-A here so that neither the table's unscoped Select All nor
|
||||
// the cmd_selectAll key command runs
|
||||
event.preventDefault();
|
||||
return false;
|
||||
}
|
||||
else if (["ArrowDown", "ArrowUp"].includes(event.key)) {
|
||||
// Specific logic for keypress navigation during collection filtering
|
||||
// that skips context-rows
|
||||
|
|
@ -240,6 +255,11 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
}
|
||||
|
||||
handleActivate = (event, indices) => {
|
||||
// Activation (rename collection, edit saved search/feed, open library) acts on
|
||||
// a single row, so ignore it when multiple rows are selected
|
||||
if (this.selection.count > 1) {
|
||||
return;
|
||||
}
|
||||
let index = indices[0];
|
||||
let treeRow = this.getRow(index);
|
||||
if (treeRow.isCollection() && this.editable && this.selection.focused == index) {
|
||||
|
|
@ -273,7 +293,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
if (!treeRow.editingName) return;
|
||||
treeRow.ref.name = treeRow.editingName;
|
||||
delete treeRow.editingName;
|
||||
await treeRow.ref.saveTx();
|
||||
await treeRow.ref.saveTx({ undoAction: 'undo-action-rename-collection' });
|
||||
window.Zotero_Tabs.rename("zotero-pane", treeRow.ref.name);
|
||||
}
|
||||
|
||||
|
|
@ -300,16 +320,14 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
// Div creation and content
|
||||
let div = oldDiv || document.createElement('div');
|
||||
div.innerHTML = "";
|
||||
// When a hidden focused row is added last during filtering, it
|
||||
// is removed on focus change, which can happen at the same time as rendering.
|
||||
// In this case, just return empty div.
|
||||
if (index >= this._rows.length) {
|
||||
return div;
|
||||
}
|
||||
|
||||
// Classes
|
||||
div.className = "row";
|
||||
div.classList.toggle('selected', selection.isSelected(index));
|
||||
div.classList.toggle('first-selected', selection.isFirstRowOfSelectionBlock(index));
|
||||
div.classList.toggle('last-selected', selection.isLastRowOfSelectionBlock(index));
|
||||
// Focus ring on the focused-but-unselected row (e.g., macOS Cmd-arrow navigation)
|
||||
div.classList.toggle('focused', selection.focused == index);
|
||||
div.classList.toggle('highlighted', this._highlightedRows.has(treeRow.id));
|
||||
div.classList.toggle('drop', this._dropRow == index);
|
||||
div.classList.toggle('flashing', this._flashingRow == index);
|
||||
|
|
@ -338,7 +356,6 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
if (treeRow.isFeeds()) {
|
||||
depth = 0;
|
||||
}
|
||||
div.style.paddingInlineStart = (CHILD_INDENT * depth) + 'px';
|
||||
|
||||
// Create a single-cell for the row (for the single-column layout)
|
||||
let cell = document.createElement('span');
|
||||
|
|
@ -463,7 +480,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
}
|
||||
|
||||
render() {
|
||||
return React.createElement(VirtualizedTable,
|
||||
return React.createElement(VirtualizedTree,
|
||||
{
|
||||
getRowCount: () => this._rows.length,
|
||||
id: this.id,
|
||||
|
|
@ -478,15 +495,18 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
isContainer: this.isContainer,
|
||||
isContainerEmpty: this.isContainerEmpty,
|
||||
isContainerOpen: this.isContainerOpen,
|
||||
toggleOpenState: this.toggleOpenState,
|
||||
onToggleOpenState: this.toggleOpenState,
|
||||
getRowString: this.getRowString.bind(this),
|
||||
|
||||
multiSelect: this.props.multiSelect,
|
||||
// The collection tree must always have a selection
|
||||
requireSelection: true,
|
||||
|
||||
onItemContextMenu: (...args) => this.props.onContextMenu && this.props.onContextMenu(...args),
|
||||
|
||||
onKeyDown: this.handleKeyDown,
|
||||
onActivate: (...args) => (this.props.onActivate ? this.props.onActivate(...args) : this.handleActivate(...args)),
|
||||
|
||||
role: 'tree',
|
||||
label: Zotero.getString('pane.collections.title')
|
||||
}
|
||||
);
|
||||
|
|
@ -513,6 +533,8 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
}
|
||||
this._virtualCollectionLibraries.unfiled =
|
||||
Zotero.Prefs.getVirtualCollectionState('unfiled');
|
||||
this._virtualCollectionLibraries.recentlyRead =
|
||||
Zotero.Prefs.getVirtualCollectionState('recentlyRead');
|
||||
this._virtualCollectionLibraries.retracted =
|
||||
Zotero.Prefs.getVirtualCollectionState('retracted');
|
||||
this._virtualCollectionLibraries.publications = Zotero.Prefs.getVirtualCollectionState('publications');
|
||||
|
|
@ -730,10 +752,9 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
return 0;
|
||||
}
|
||||
|
||||
var currentLibraryID = this.getSelectedLibraryID();
|
||||
var libraryID = items[0].libraryID;
|
||||
// If in a different library
|
||||
if (libraryID != currentLibraryID) {
|
||||
// If not in one of the selected libraries
|
||||
if (!this.getSelectedLibraryIDs().includes(libraryID)) {
|
||||
Zotero.debug("Library ID differs; switching library");
|
||||
await this.selectLibrary(libraryID);
|
||||
}
|
||||
|
|
@ -1245,9 +1266,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
if (treeRow.isLibrary(true) || treeRow.isCollection() || treeRow.isFeeds()) {
|
||||
count = await this._expandRow(this._rows, index, true);
|
||||
}
|
||||
if (this.selection.focused > index) {
|
||||
this.selection.select(this.selection.focused + count);
|
||||
}
|
||||
this.selection.adjustForRowInsertion(index, count);
|
||||
this.selection.selectEventsSuppressed = false;
|
||||
|
||||
this._rows[index].isOpen = true;
|
||||
|
|
@ -1258,7 +1277,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
}
|
||||
|
||||
/**
|
||||
* Toggle virtual collection (duplicates/unfiled) visibility
|
||||
* Toggle virtual collection (duplicates/unfiled/recently read/retracted) visibility
|
||||
*
|
||||
* @param libraryID {Number}
|
||||
* @param type {String}
|
||||
|
|
@ -1269,6 +1288,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
const types = {
|
||||
duplicates: 'D',
|
||||
unfiled: 'U',
|
||||
recentlyRead: 'Y',
|
||||
retracted: 'R',
|
||||
publications: 'P'
|
||||
};
|
||||
|
|
@ -1308,17 +1328,40 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
* @returns {Promise<void>}
|
||||
*/
|
||||
async deleteSelection(deleteItems) {
|
||||
var treeRow = this.getRow(this.selection.focused);
|
||||
if (treeRow.isFeed()) {
|
||||
await treeRow.ref.eraseTx();
|
||||
return;
|
||||
let treeRows = this.getSelectedRows();
|
||||
// Feeds must be erased outside a transaction since Feed.erase()
|
||||
// starts its own transaction internally
|
||||
let feeds = treeRows.filter(r => r.isFeed());
|
||||
let others = treeRows.filter(r => !r.isFeed());
|
||||
for (let row of feeds) {
|
||||
await row.ref.eraseTx();
|
||||
}
|
||||
treeRow.ref.deleted = true;
|
||||
if (treeRow.isCollection()) {
|
||||
await treeRow.ref.saveTx({ deleteItems });
|
||||
return;
|
||||
if (others.length) {
|
||||
let collectionCount = others.filter(r => r.isCollection()).length;
|
||||
let searchCount = others.length - collectionCount;
|
||||
// Use the search label only when nothing but searches is selected;
|
||||
// otherwise describe the action in terms of collections
|
||||
let undoAction, undoActionArgs;
|
||||
if (searchCount && !collectionCount) {
|
||||
undoAction = 'undo-action-trash-search';
|
||||
undoActionArgs = { count: searchCount };
|
||||
}
|
||||
else {
|
||||
undoAction = 'undo-action-trash-collection';
|
||||
undoActionArgs = { count: collectionCount + searchCount };
|
||||
}
|
||||
await Zotero.DB.executeTransaction(async () => {
|
||||
for (let row of others) {
|
||||
row.ref.deleted = true;
|
||||
if (row.isCollection()) {
|
||||
await row.ref.save({ deleteItems, undoAction, undoActionArgs });
|
||||
}
|
||||
else {
|
||||
await row.ref.save({ undoAction, undoActionArgs });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
await treeRow.ref.saveTx();
|
||||
}
|
||||
|
||||
unregister() {
|
||||
|
|
@ -1364,40 +1407,63 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
return this.getRow(index).getName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return libraryID of selected row (which could be a collection, etc.)
|
||||
*/
|
||||
getSelectedLibraryID() {
|
||||
var treeRow = this.getRow(this.selection.focused);
|
||||
return treeRow && treeRow.ref && treeRow.ref.libraryID !== undefined
|
||||
&& treeRow.ref.libraryID;
|
||||
throw new Error("CollectionTree#getSelectedLibraryID() was removed "
|
||||
+ "-- use getSelectedLibraryIDs()");
|
||||
}
|
||||
|
||||
getSelectedCollection(asID) {
|
||||
var collection = this.getRow(this.selection.focused);
|
||||
if (collection && collection.isCollection()) {
|
||||
return asID ? collection.ref.id : collection.ref;
|
||||
}
|
||||
}
|
||||
|
||||
getSelectedSearch(asID) {
|
||||
if (this.getRow(this.selection.focused)) {
|
||||
var search = this.getRow(this.selection.focused);
|
||||
if (search && search.isSearch()) {
|
||||
return asID ? search.ref.id : search.ref;
|
||||
|
||||
/**
|
||||
* Return the libraryID of every selected row, in tree order
|
||||
*
|
||||
* @return {Integer[]}
|
||||
*/
|
||||
getSelectedLibraryIDs() {
|
||||
var libraryIDs = [];
|
||||
for (let index of [...this.selection.selected].sort((a, b) => a - b)) {
|
||||
let row = this.getRow(index);
|
||||
if (row && row.ref && row.ref.libraryID !== undefined
|
||||
&& !libraryIDs.includes(row.ref.libraryID)) {
|
||||
libraryIDs.push(row.ref.libraryID);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return libraryIDs;
|
||||
}
|
||||
|
||||
getSelectedGroup(asID) {
|
||||
if (this.getRow(this.selection.focused)) {
|
||||
var group = this.getRow(this.selection.focused);
|
||||
if (group && group.isGroup()) {
|
||||
return asID ? group.ref.id : group.ref;
|
||||
|
||||
getSelectedCollection() {
|
||||
throw new Error("CollectionTree#getSelectedCollection() was removed "
|
||||
+ "-- use getSelectedCollections()");
|
||||
}
|
||||
|
||||
getSelectedCollections(asID) {
|
||||
var collections = [];
|
||||
for (let index of this.selection.selected) {
|
||||
let row = this.getRow(index);
|
||||
if (row && row.isCollection()) {
|
||||
collections.push(asID ? row.ref.id : row.ref);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return collections;
|
||||
}
|
||||
|
||||
getSelectedSearch() {
|
||||
throw new Error("CollectionTree#getSelectedSearch() was removed "
|
||||
+ "-- use getSelectedSearches()");
|
||||
}
|
||||
|
||||
getSelectedSearches(asID) {
|
||||
var searches = [];
|
||||
for (let index of this.selection.selected) {
|
||||
let row = this.getRow(index);
|
||||
if (row && row.isSearch()) {
|
||||
searches.push(asID ? row.ref.id : row.ref);
|
||||
}
|
||||
}
|
||||
return searches;
|
||||
}
|
||||
|
||||
getSelectedGroup() {
|
||||
throw new Error("CollectionTree#getSelectedGroup() was removed "
|
||||
+ "-- filter getSelectedRows() by isGroup()");
|
||||
}
|
||||
|
||||
getIconName(index) {
|
||||
|
|
@ -1427,6 +1493,10 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
case 'feeds':
|
||||
icon = 'feed-library';
|
||||
break;
|
||||
|
||||
case 'recentlyRead':
|
||||
icon = 'recent';
|
||||
break;
|
||||
|
||||
case 'header':
|
||||
if (treeRow.ref.id == 'group-libraries-header') {
|
||||
|
|
@ -1447,18 +1517,27 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
onDragStart(event, index) {
|
||||
const treeRow = this.getRow(index);
|
||||
super.onDragStart(event, index);
|
||||
|
||||
// See note in #setDropEffect()
|
||||
if (Zotero.isWin || Zotero.isLinux) {
|
||||
event.dataTransfer.effectAllowed = 'copyMove';
|
||||
}
|
||||
|
||||
if (!treeRow.isCollection() && !treeRow.isSearch()) {
|
||||
let rows = this.getSelectedRows();
|
||||
// Ignore drag if all selected rows aren't the same type
|
||||
if (!rows.every(o => o.type == rows[0].type)) {
|
||||
return;
|
||||
}
|
||||
let type = treeRow.isCollection() ? "zotero/collection" : "zotero/search";
|
||||
event.dataTransfer.setData(type, treeRow.ref.id);
|
||||
Zotero.debug(`Dragging ${type} ` + treeRow.id);
|
||||
// Ignore if not all collections or searches
|
||||
if (!rows.every(o => o.isCollection() || o.isSearch())) {
|
||||
return;
|
||||
}
|
||||
let type = rows[0].isCollection() ? "zotero/collection" : "zotero/search";
|
||||
let ids = rows.map(o => o.ref.id);
|
||||
event.dataTransfer.setDragImage(this._dragImageContainer, 0, 0);
|
||||
event.dataTransfer.setData(type, ids);
|
||||
Zotero.debug(`Dragging ${type} ` + (ids.length > 1 ? '[' + ids.join(', ') + ']' : ids[0]));
|
||||
}
|
||||
|
||||
onDragOver(event, index) {
|
||||
|
|
@ -1532,7 +1611,19 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
}
|
||||
}
|
||||
|
||||
if ((Zotero.isMac && event.metaKey) || (!Zotero.isMac && event.shiftKey)) {
|
||||
let move = (Zotero.isMac && event.metaKey) || (!Zotero.isMac && event.shiftKey);
|
||||
|
||||
// A selection from a multiple-collection view can span libraries. Those items can
|
||||
// only be copied, never moved (a move can't coherently move some items and copy
|
||||
// others), so disallow a move rather than silently substituting a copy.
|
||||
let ids = Zotero.DragDrop.getDataFromDataTransfer(event.dataTransfer).data;
|
||||
let items = Zotero.Items.get(ids);
|
||||
if (new Set(items.map(item => item.libraryID)).size > 1) {
|
||||
this.setDropEffect(event, move ? "none" : "copy");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (move) {
|
||||
this.setDropEffect(event, "move");
|
||||
}
|
||||
else {
|
||||
|
|
@ -1602,7 +1693,9 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
}
|
||||
}
|
||||
|
||||
onDragEnd = () => {
|
||||
onDragEnd = (event, index) => {
|
||||
super.onDragEnd(event, index);
|
||||
|
||||
let dropRow = this._dropRow;
|
||||
this._dropRow = null;
|
||||
this.tree.invalidateRow(dropRow);
|
||||
|
|
@ -1731,11 +1824,14 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
}
|
||||
|
||||
// Intra-library drag
|
||||
|
||||
// Don't allow drag onto root of same library
|
||||
|
||||
// An item can't be added to the root of its own library, but skip it rather
|
||||
// than rejecting the whole drag, so a mixed-library selection can still copy
|
||||
// its out-of-library items here. (If every item is already in this library,
|
||||
// `skip` stays true and the drag is refused below.)
|
||||
if (treeRow.isLibrary(true)) {
|
||||
Zotero.debug("Can't drag into same library root");
|
||||
return false;
|
||||
Zotero.debug("Item " + item.id + " already in library " + treeRow.ref.libraryID);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Make sure there's at least one item that's not already in this destination
|
||||
|
|
@ -1774,19 +1870,20 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
return false;
|
||||
}
|
||||
|
||||
let draggedCollectionID = data[0];
|
||||
let draggedCollection = Zotero.Collections.get(draggedCollectionID);
|
||||
for (let id of data) {
|
||||
let draggedCollection = Zotero.Collections.get(id);
|
||||
|
||||
// Dragging within same library
|
||||
if (treeRow.ref.libraryID == draggedCollection.libraryID) {
|
||||
// Collections cannot be dropped on themselves
|
||||
if (draggedCollectionID == treeRow.ref.id) {
|
||||
return false;
|
||||
}
|
||||
// Dragging within same library
|
||||
if (treeRow.ref.libraryID == draggedCollection.libraryID) {
|
||||
// Collections cannot be dropped on themselves
|
||||
if (id == treeRow.ref.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Nor in their children
|
||||
if (draggedCollection.hasDescendent('collection', treeRow.ref.id)) {
|
||||
return false;
|
||||
// Nor in their children
|
||||
if (draggedCollection.hasDescendent('collection', treeRow.ref.id)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2191,16 +2288,21 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
// Dropping items, collections, or searches into trash
|
||||
if (targetTreeRow.isTrash()) {
|
||||
let objects = [];
|
||||
let undoAction;
|
||||
if (dataType == 'zotero/collection') {
|
||||
objects = await Zotero.Collections.getAsync(data);
|
||||
undoAction = 'undo-action-trash-collection';
|
||||
}
|
||||
else if (dataType == 'zotero/search') {
|
||||
objects = await Zotero.Searches.getAsync(data);
|
||||
undoAction = 'undo-action-trash-search';
|
||||
}
|
||||
else if (dataType == 'zotero/item') {
|
||||
objects = await Zotero.Items.getAsync(data);
|
||||
undoAction = 'undo-action-trash';
|
||||
}
|
||||
await Zotero.DB.executeTransaction(async function () {
|
||||
Zotero.UndoHistory.stageAction(undoAction, { count: objects.length });
|
||||
for (let obj of objects) {
|
||||
obj.deleted = true;
|
||||
await obj.save();
|
||||
|
|
@ -2213,21 +2315,31 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
var targetCollectionID = targetTreeRow.isCollection() ? targetTreeRow.ref.id : false;
|
||||
|
||||
if (dataType == 'zotero/collection') {
|
||||
var droppedCollection = await Zotero.Collections.getAsync(data[0]);
|
||||
let droppedCollections = await Zotero.Collections.getAsync(data);
|
||||
if (droppedCollections.some(c => c.id == targetCollectionID)) {
|
||||
throw new Error("Can't drop onto source row");
|
||||
}
|
||||
|
||||
// Collection drag between libraries
|
||||
if (targetLibraryID != droppedCollection.libraryID) {
|
||||
await this.executeCollectionCopy({
|
||||
collection: droppedCollection,
|
||||
targetCollectionID,
|
||||
targetLibraryID,
|
||||
targetTreeRow,
|
||||
copyOptions
|
||||
});
|
||||
if (targetLibraryID != droppedCollections[0].libraryID) {
|
||||
for (let droppedCollection of droppedCollections) {
|
||||
await this.executeCollectionCopy({
|
||||
collection: droppedCollection,
|
||||
targetCollectionID,
|
||||
targetLibraryID,
|
||||
targetTreeRow,
|
||||
copyOptions
|
||||
});
|
||||
}
|
||||
}
|
||||
// Collection drag within a library
|
||||
else {
|
||||
droppedCollection.parentID = targetCollectionID;
|
||||
await droppedCollection.saveTx();
|
||||
await Zotero.DB.executeTransaction(async () => {
|
||||
for (let droppedCollection of droppedCollections) {
|
||||
droppedCollection.parentID = targetCollectionID;
|
||||
await droppedCollection.save({ undoAction: 'undo-action-move-collection' });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
else if (dataType == 'zotero/item') {
|
||||
|
|
@ -2274,42 +2386,61 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
});
|
||||
}
|
||||
|
||||
let newItems = [];
|
||||
let newIDs = [];
|
||||
// Route each item by its own library: items already in the target library are added
|
||||
// directly, while items from other libraries are copied into the target library. A
|
||||
// selection can span multiple libraries when dragging from a multiple-collection view.
|
||||
let sameLibraryItems = [];
|
||||
let otherLibraryItems = [];
|
||||
let toMove = [];
|
||||
// TODO: support items coming from different sources?
|
||||
let sameLibrary = items[0].libraryID == targetLibraryID
|
||||
|
||||
for (let item of items) {
|
||||
if (!item.isTopLevelItem()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
newItems.push(item);
|
||||
|
||||
if (sameLibrary) {
|
||||
newIDs.push(item.id);
|
||||
if (item.libraryID == targetLibraryID) {
|
||||
sameLibraryItems.push(item);
|
||||
toMove.push(item.id);
|
||||
}
|
||||
else {
|
||||
otherLibraryItems.push(item);
|
||||
}
|
||||
}
|
||||
if (sameLibrary) {
|
||||
// Add items to target container in the same library.
|
||||
|
||||
// Add same-library items to the target container
|
||||
if (sameLibraryItems.length) {
|
||||
if (targetCollectionID) {
|
||||
let ids = newIDs.filter(itemID => Zotero.Items.get(itemID).isTopLevelItem());
|
||||
let ids = sameLibraryItems.map(item => item.id);
|
||||
await Zotero.DB.executeTransaction(async function () {
|
||||
let collection = await Zotero.Collections.getAsync(targetCollectionID);
|
||||
await collection.addItems(ids);
|
||||
// If moving, remove from source in the same
|
||||
// transaction so it's a single undo step
|
||||
if (dropEffect == 'move' && toMove.length
|
||||
&& sourceTreeRow && sourceTreeRow.isCollection()) {
|
||||
await sourceTreeRow.ref.removeItems(toMove);
|
||||
toMove = [];
|
||||
Zotero.UndoHistory.stageAction(
|
||||
'undo-action-move-to-collection', { count: ids.length }
|
||||
);
|
||||
}
|
||||
else {
|
||||
Zotero.UndoHistory.stageAction(
|
||||
'undo-action-add-to-collection', { count: ids.length }
|
||||
);
|
||||
}
|
||||
}.bind(this));
|
||||
}
|
||||
else if (targetTreeRow.isPublications()) {
|
||||
await Zotero.Items.addToPublications(newItems, copyOptions);
|
||||
await Zotero.Items.addToPublications(sameLibraryItems, copyOptions);
|
||||
}
|
||||
}
|
||||
else {
|
||||
|
||||
// Copy items from other libraries into the target library
|
||||
if (otherLibraryItems.length) {
|
||||
let toReconcile = [];
|
||||
|
||||
await Zotero.Utilities.Internal.forEachChunkAsync(
|
||||
newItems,
|
||||
otherLibraryItems,
|
||||
100,
|
||||
function (chunk) {
|
||||
return Zotero.DB.executeTransaction(async () => {
|
||||
|
|
@ -2378,11 +2509,10 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
}
|
||||
|
||||
|
||||
// If moving, remove items from source collection
|
||||
if (dropEffect == 'move' && toMove.length) {
|
||||
if (!sameLibrary) {
|
||||
throw new Error("Cannot move items between libraries");
|
||||
}
|
||||
// If moving, remove items from source collection. A move of a mixed-library selection
|
||||
// is disallowed in onDragOver(), so it shouldn't reach here; guard against a partial
|
||||
// move just in case, since only the same-library items would be moved.
|
||||
if (dropEffect == 'move' && toMove.length && !otherLibraryItems.length) {
|
||||
if (!sourceTreeRow || !sourceTreeRow.isCollection()) {
|
||||
throw new Error("Drag source must be a collection for move action");
|
||||
}
|
||||
|
|
@ -2518,9 +2648,72 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
return true;
|
||||
}
|
||||
|
||||
isSelectable = index => {
|
||||
// Cmd/Ctrl-A on the collection tree. Expand the current selection to all rows of
|
||||
// the same kind within their natural scope: all library roots in the same
|
||||
// visibility group (so feeds and regular libraries don't mix), all collections
|
||||
// sharing a parent with a selected collection (so a multi-level or multi-parent
|
||||
// selection expands each branch it touches), or Recently Read in every library.
|
||||
// Anything else (saved searches, Unfiled, Trash, etc.) has no useful "select
|
||||
// all", so the selection is left untouched rather than cleared.
|
||||
async _handleSelectAll() {
|
||||
let selectedIndexes = Array.from(this.selection.selected);
|
||||
if (!selectedIndexes.length) {
|
||||
return;
|
||||
}
|
||||
let selectedRows = selectedIndexes.map(index => this.getRow(index));
|
||||
let scope;
|
||||
if (selectedRows.every(row => row.isLibrary(true))) {
|
||||
// Stay within one visibility group so feeds aren't pulled into an
|
||||
// all-libraries selection, which couldn't be shown together
|
||||
let visibilityGroup = selectedRows[0].visibilityGroup;
|
||||
scope = row => row.isLibrary(true) && row.visibilityGroup === visibilityGroup;
|
||||
}
|
||||
else if (selectedRows.every(row => row.isCollection())) {
|
||||
let parentIndexes = new Set(
|
||||
selectedIndexes.map(index => this.getParentIndex(index))
|
||||
);
|
||||
scope = (row, index) => row.isCollection()
|
||||
&& parentIndexes.has(this.getParentIndex(index));
|
||||
}
|
||||
else if (selectedRows.every(row => row.isRecentlyRead())) {
|
||||
// Recently Read can appear under any library, so open any collapsed
|
||||
// libraries that show it -- otherwise its row isn't in the tree to select
|
||||
let libraryIDs = [];
|
||||
for (let i = 0; i < this._rows.length; i++) {
|
||||
let row = this.getRow(i);
|
||||
if (row.isLibrary(true)
|
||||
&& this._virtualCollectionLibraries.recentlyRead?.[row.ref.libraryID] !== false) {
|
||||
libraryIDs.push(row.ref.libraryID);
|
||||
}
|
||||
}
|
||||
for (let libraryID of libraryIDs) {
|
||||
await this.expandLibrary(libraryID);
|
||||
}
|
||||
scope = row => row.isRecentlyRead();
|
||||
}
|
||||
else {
|
||||
return;
|
||||
}
|
||||
this._selectAllScope = scope;
|
||||
try {
|
||||
this.selection.selectAll();
|
||||
}
|
||||
finally {
|
||||
this._selectAllScope = null;
|
||||
}
|
||||
}
|
||||
|
||||
isSelectable = (index) => {
|
||||
let treeRow = this.getRow(index);
|
||||
return treeRow && !(treeRow.isSeparator() || treeRow.isHeader());
|
||||
if (!treeRow || treeRow.isSeparator() || treeRow.isHeader()) {
|
||||
return false;
|
||||
}
|
||||
// While a scoped Select All (Cmd/Ctrl-A) is running, restrict the new
|
||||
// selection to the rows in the scope computed in _handleSelectAll()
|
||||
if (this._selectAllScope) {
|
||||
return this._selectAllScope(treeRow, index);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_closeContainer(row, skipMap) {
|
||||
|
|
@ -2531,9 +2724,10 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
var level = this.getLevel(row);
|
||||
var nextRow = row + 1;
|
||||
|
||||
// Remove child rows
|
||||
// Remove child rows, remapping any selected ones to the collapsed container
|
||||
while ((nextRow < this._rows.length) && (this.getLevel(nextRow) > level)) {
|
||||
this._removeRow(nextRow, true);
|
||||
this.selection.adjustForRowRemoval(nextRow, true);
|
||||
this._removeRow(nextRow, true, true);
|
||||
}
|
||||
this.selection.selectEventsSuppressed = false;
|
||||
|
||||
|
|
@ -2644,7 +2838,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
let collectionTable = document.getElementById("collection-tree").firstElementChild;
|
||||
let isEmpty = this._isFilterEmpty();
|
||||
let willBeEmpty = filterText.length == 0;
|
||||
this._filter = filterText.toLowerCase();
|
||||
this._filter = Zotero.Utilities.Internal.normalizeForSearch(filterText);
|
||||
let currentRow = this.getRow(this.selection.focused) || this._hiddenFocusedRow;
|
||||
let currentRowDisplayed = currentRow && this._includedInTree(currentRow.ref);
|
||||
let shouldRestoreScrollPosition = willBeEmpty && !isEmpty && !this._treeWasFocused;
|
||||
|
|
@ -2659,6 +2853,10 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
this.selection.clearSelection();
|
||||
}
|
||||
await this.reload();
|
||||
// reload() rebuilds the rows, so any selected indices from before now point at
|
||||
// different (or out-of-range) rows. Clear them and re-select just the current
|
||||
// row below, collapsing a multi-selection to the focused row while filtering.
|
||||
this.selection.clearSelection();
|
||||
if (currentRow) {
|
||||
// Special treatment for when there are no filter matches
|
||||
// Otherwise, selection.focused does not get updated by selectByID, which breaks ZoteroPane.
|
||||
|
|
@ -2891,14 +3089,15 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
if (this._filterResultsCache[objectID] && !resetCache) {
|
||||
return this._filterResultsCache[objectID];
|
||||
}
|
||||
// Filtering is case insensitive
|
||||
let objectName = (object.name || "").toLowerCase();
|
||||
// Filtering is case- and accent-insensitive
|
||||
let normalize = Zotero.Utilities.Internal.normalizeForSearch;
|
||||
let objectName = normalize(object.name || "");
|
||||
// Special treatment to fetch the name for My Library or Feeds
|
||||
if (objectID[0] == 'L' && object._ObjectType !== "Group") {
|
||||
objectName = Zotero.getString('pane.collections.library').toLowerCase();
|
||||
objectName = normalize(Zotero.getString('pane.collections.library'));
|
||||
}
|
||||
else if (objectID == 'feeds') {
|
||||
objectName = Zotero.getString('pane.collections.feedLibraries').toLowerCase();
|
||||
objectName = normalize(Zotero.getString('pane.collections.feedLibraries'));
|
||||
}
|
||||
let filterValue = this._filter;
|
||||
|
||||
|
|
@ -2971,6 +3170,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
&& this._virtualCollectionLibraries.duplicates[libraryID] !== false;
|
||||
var showUnfiled = this.props.hideSources.indexOf('unfiled') == -1
|
||||
&& this._virtualCollectionLibraries.unfiled?.[libraryID] !== false;
|
||||
var showRecentlyRead = this._virtualCollectionLibraries.recentlyRead?.[libraryID] !== false;
|
||||
var showRetracted = this.props.hideSources.indexOf('retracted') == -1
|
||||
&& this._virtualCollectionLibraries.retracted?.[libraryID] !== false
|
||||
&& Zotero.Retractions.libraryHasRetractedItems(libraryID);
|
||||
|
|
@ -2983,6 +3183,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
var savedSearches = [];
|
||||
var showDuplicates = false;
|
||||
var showUnfiled = false;
|
||||
var showRecentlyRead = false;
|
||||
var showRetracted = false;
|
||||
var showPublications = false;
|
||||
var showTrash = false;
|
||||
|
|
@ -2997,7 +3198,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
return 0;
|
||||
}
|
||||
|
||||
var startOpen = !!(collections.length || savedSearches.length || showDuplicates || showUnfiled || showRetracted || showTrash);
|
||||
var startOpen = !!(collections.length || savedSearches.length || showDuplicates || showUnfiled || showRecentlyRead || showRetracted || showTrash);
|
||||
|
||||
// If this isn't a manual open, set the initial state depending on whether
|
||||
// there are child nodes
|
||||
|
|
@ -3011,6 +3212,21 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
|
||||
var newRows = 0;
|
||||
|
||||
// Recently Read
|
||||
if (showRecentlyRead && this._isFilterEmpty()) {
|
||||
rows.splice(row + 1 + newRows, 0,
|
||||
new Zotero.CollectionTreeRow(this,
|
||||
'recentlyRead',
|
||||
{
|
||||
libraryID,
|
||||
name: Zotero.getString('recently-read'),
|
||||
},
|
||||
level + 1
|
||||
)
|
||||
);
|
||||
newRows++;
|
||||
}
|
||||
|
||||
// Add collections
|
||||
for (var i = 0, len = collections.length; i < len; i++) {
|
||||
// Skip collections in trash
|
||||
|
|
@ -3146,13 +3362,19 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
else {
|
||||
// Get all collections at the same level that don't have a different parent
|
||||
startRow++;
|
||||
// Skip past virtual collections (e.g., Recently Read) that come
|
||||
// before collections in the tree
|
||||
while (startRow < this._rows.length
|
||||
&& this.getLevel(startRow) == level
|
||||
&& this.getRow(startRow).isRecentlyRead()) {
|
||||
startRow++;
|
||||
}
|
||||
loop:
|
||||
for (let i = startRow; i < this._rows.length; i++) {
|
||||
let treeRow = this.getRow(i);
|
||||
beforeRow = i;
|
||||
|
||||
// Since collections come first, if we reach something that's not a collection,
|
||||
// stop
|
||||
|
||||
// If we reach something that's not a collection, stop
|
||||
if (!treeRow.isCollection()) {
|
||||
break;
|
||||
}
|
||||
|
|
@ -3215,7 +3437,11 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|
|||
for (let i = startRow; i < this._rows.length; i++) {
|
||||
let treeRow = this.getRow(i);
|
||||
beforeRow = i;
|
||||
|
||||
|
||||
// Skip forward to first collection
|
||||
if (treeRow.isRecentlyRead()) {
|
||||
continue;
|
||||
}
|
||||
// If we've reached something other than collections, stop
|
||||
if (treeRow.isSearch()) {
|
||||
// If current search sorts after, stop
|
||||
|
|
|
|||
2263
chrome/content/zotero/collectionViewItemTree.jsx
Normal file
2263
chrome/content/zotero/collectionViewItemTree.jsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -75,10 +75,10 @@ const ProgressQueueTable = ({ onActivate = noop, progressQueue }) => {
|
|||
for (let column of columns) {
|
||||
if (column.dataKey === 'success') {
|
||||
let span = document.createElement('span');
|
||||
if (!span.ownerGlobal) {
|
||||
if (!span.documentGlobal) {
|
||||
// If this script was imported from a non-window context, we'll have a global object that looks like
|
||||
// a Window and document.createElement() will succeed, but the returned Element object won't have
|
||||
// an ownerGlobal. Trying to append a child or set its innerHTML will segfault Zotero. For now,
|
||||
// an documentGlobal. Trying to append a child or set its innerHTML will segfault Zotero. For now,
|
||||
// let's just abort if we get an invalid Element.
|
||||
// TODO: Remove once we're using ES modules
|
||||
return div;
|
||||
|
|
|
|||
|
|
@ -33,13 +33,14 @@ const { CSSIcon, CSSItemTypeIcon } = require('./icons');
|
|||
const SCROLL_ARROW_SCROLL_BY = 222;
|
||||
|
||||
const Tab = memo((props) => {
|
||||
const { icon, id, index, isBeingDragged, isItemType, onContextMenu, onDragEnd, onDragStart, onTabClick, onTabClose, onTabMouseDown, selected, title, renderTitle, width } = props;
|
||||
const { icon, id, index, isBeingDragged, isItemType, onContextMenu, onDragEnd, onDragStart, onTabClick, onTabClose, onTabMouseDown, onAudioStatusClick, selected, title, renderTitle, width, audioStatus } = props;
|
||||
|
||||
const handleTabMouseDown = useCallback(event => onTabMouseDown(event, id), [onTabMouseDown, id]);
|
||||
const handleContextMenu = useCallback(event => onContextMenu(event, id), [onContextMenu, id]);
|
||||
const handleTabClick = useCallback(event => onTabClick(event, id), [onTabClick, id]);
|
||||
const handleDragStart = useCallback(event => onDragStart(event, id, index), [onDragStart, id, index]);
|
||||
const handleTabClose = useCallback(event => onTabClose(event, id), [onTabClose, id]);
|
||||
const handleAudioStatusClick = useCallback(event => onAudioStatusClick(event, id), [onAudioStatusClick, id]);
|
||||
|
||||
let titleText;
|
||||
let titleHTML;
|
||||
|
|
@ -72,6 +73,17 @@ const Tab = memo((props) => {
|
|||
? <CSSItemTypeIcon itemType={icon} className="tab-icon" />
|
||||
: <CSSIcon name={icon} className="tab-icon" />
|
||||
}
|
||||
{audioStatus?.active && (
|
||||
<div
|
||||
className="tab-audio-status"
|
||||
role="button"
|
||||
data-l10n-id={`reader-tab-audio-${audioStatus.paused ? 'play' : 'pause'}`}
|
||||
onClick={handleAudioStatusClick}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<CSSIcon name={audioStatus.paused ? 'play' : 'pause'} className="icon-16"/>
|
||||
</div>
|
||||
)}
|
||||
{titleHTML
|
||||
? <div className="tab-name" title={titleText} dangerouslySetInnerHTML={{ __html: titleHTML }}/>
|
||||
: <div className="tab-name" title={titleText}>{titleText}</div>}
|
||||
|
|
@ -98,10 +110,15 @@ Tab.propTypes = {
|
|||
onTabClick: PropTypes.func.isRequired,
|
||||
onTabClose: PropTypes.func.isRequired,
|
||||
onTabMouseDown: PropTypes.func.isRequired,
|
||||
onAudioStatusClick: PropTypes.func.isRequired,
|
||||
selected: PropTypes.bool.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
renderTitle: PropTypes.bool,
|
||||
width: PropTypes.number
|
||||
width: PropTypes.number,
|
||||
audioStatus: PropTypes.shape({
|
||||
active: PropTypes.bool.isRequired,
|
||||
paused: PropTypes.bool.isRequired,
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
|
|
@ -235,6 +252,11 @@ const TabBar = forwardRef(function (props, ref) {
|
|||
props.onTabSelect(id);
|
||||
event.stopPropagation();
|
||||
}, [props.onTabSelect]);
|
||||
|
||||
const handleAudioStatusClick = useCallback((event, id) => {
|
||||
props.onToggleAudio(id);
|
||||
event.stopPropagation();
|
||||
}, [props.onToggleAudio]);
|
||||
|
||||
const handleContextMenu = useCallback((event, id) => {
|
||||
let { screenX, screenY } = event;
|
||||
|
|
@ -419,6 +441,7 @@ const TabBar = forwardRef(function (props, ref) {
|
|||
onTabClick={ handleTabClick}
|
||||
onTabClose={ handleTabClose}
|
||||
onTabMouseDown = { handleTabMouseDown }
|
||||
onAudioStatusClick={handleAudioStatusClick}
|
||||
/>
|
||||
: null}
|
||||
</div>
|
||||
|
|
@ -456,6 +479,7 @@ const TabBar = forwardRef(function (props, ref) {
|
|||
onTabClick={handleTabClick}
|
||||
onTabClose={handleTabClose}
|
||||
onTabMouseDown={handleTabMouseDown}
|
||||
onAudioStatusClick={handleAudioStatusClick}
|
||||
/>)}
|
||||
<div ref={spacerRef} className="spacer"></div>
|
||||
</div>
|
||||
|
|
@ -487,6 +511,7 @@ TabBar.propTypes = {
|
|||
onTabMove: PropTypes.func.isRequired,
|
||||
onRefocus: PropTypes.func.isRequired,
|
||||
onContextMenu: PropTypes.func.isRequired,
|
||||
onToggleAudio: PropTypes.func.isRequired,
|
||||
tabs: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
icon: PropTypes.element.isRequired,
|
||||
|
|
@ -499,7 +524,12 @@ TabBar.propTypes = {
|
|||
onTabClick: PropTypes.func.isRequired,
|
||||
onTabMouseDown: PropTypes.func.isRequired,
|
||||
selected: PropTypes.bool.isRequired,
|
||||
title: PropTypes.string.isRequired
|
||||
title: PropTypes.string.isRequired,
|
||||
renderTitle: PropTypes.bool.isRequired,
|
||||
audioStatus: PropTypes.shape({
|
||||
active: PropTypes.bool.isRequired,
|
||||
paused: PropTypes.bool.isRequired,
|
||||
}),
|
||||
})
|
||||
).isRequired
|
||||
};
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ const Draggable = require('./draggable');
|
|||
const { CSSIcon, getCSSIcon } = require('components/icons');
|
||||
|
||||
const TYPING_TIMEOUT = 1000;
|
||||
// TODO: Move these to CSS variables
|
||||
const MINIMUM_ROW_HEIGHT = 20; // px
|
||||
const RESIZER_WIDTH = 5; // px
|
||||
const COLUMN_MIN_WIDTH = 20;
|
||||
|
|
@ -143,9 +144,10 @@ class TreeSelection {
|
|||
* @returns {boolean} False if nothing to select and select handlers won't be called
|
||||
*/
|
||||
select(index, shouldDebounce) {
|
||||
if (!this._tree.props.isSelectable(index)) return;
|
||||
index = Math.max(0, index);
|
||||
if (!this._tree.props.isSelectable(index)) return;
|
||||
if (this.selected.size == 1 && this.isSelected(index)) {
|
||||
this._updateTree(shouldDebounce);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -161,7 +163,12 @@ class TreeSelection {
|
|||
this._tree.scrollToRow(index);
|
||||
this._updateTree(shouldDebounce);
|
||||
if (this._tree.invalidate) {
|
||||
toInvalidate.forEach(this._tree.invalidateRow.bind(this._tree));
|
||||
const rowCount = this._tree.props.getRowCount();
|
||||
toInvalidate.forEach((idx) => {
|
||||
// this._updateTree() may change row count
|
||||
if (idx >= rowCount) return;
|
||||
this._tree.invalidateRow(idx);
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -251,6 +258,63 @@ class TreeSelection {
|
|||
this._updateTree(shouldDebounce);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts selection indexes after a row is removed. A removed selected row is
|
||||
* dropped from the selection, or remapped to the previous row when remapToPrevious
|
||||
* is true (e.g., when collapsing a container remaps removed descendants to the
|
||||
* container row, which precedes them). If the removal leaves nothing selected, the
|
||||
* previous row is selected. If the focused or pivot row is removed, it moves to
|
||||
* the previous row.
|
||||
*
|
||||
* @param {Number} index Removed row index
|
||||
* @param {Boolean} [remapToPrevious=false] Select the previous row in place of a
|
||||
* removed selected row instead of dropping it from the selection
|
||||
*/
|
||||
adjustForRowRemoval(index, remapToPrevious = false) {
|
||||
let previousIndex = Math.max(index - 1, 0);
|
||||
let selected = new Set();
|
||||
for (let selectedIndex of this.selected) {
|
||||
if (selectedIndex == index) {
|
||||
if (remapToPrevious) {
|
||||
selected.add(previousIndex);
|
||||
}
|
||||
}
|
||||
else {
|
||||
selected.add(selectedIndex > index ? selectedIndex - 1 : selectedIndex);
|
||||
}
|
||||
}
|
||||
if (this.selected.size && !selected.size) {
|
||||
selected.add(previousIndex);
|
||||
}
|
||||
this.selected = selected;
|
||||
this.focused = this.focused == index
|
||||
? previousIndex
|
||||
: this.focused > index ? this.focused - 1 : this.focused;
|
||||
this.pivot = this.pivot == index
|
||||
? previousIndex
|
||||
: this.pivot > index ? this.pivot - 1 : this.pivot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts selection indexes after rows are inserted after a given row.
|
||||
*
|
||||
* @param {Number} index Row after which rows were inserted
|
||||
* @param {Number} count Number of inserted rows
|
||||
*/
|
||||
adjustForRowInsertion(index, count) {
|
||||
let selected = new Set();
|
||||
for (let selectedIndex of this.selected) {
|
||||
selected.add(selectedIndex > index ? selectedIndex + count : selectedIndex);
|
||||
}
|
||||
this.selected = selected;
|
||||
if (this.focused > index) {
|
||||
this.focused += count;
|
||||
}
|
||||
if (this.pivot > index) {
|
||||
this.pivot += count;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the onSelectionChange prop on the tree
|
||||
* @param shouldDebounce {Boolean} Whether the update to the tree should be debounced
|
||||
|
|
@ -272,8 +336,9 @@ class TreeSelection {
|
|||
}
|
||||
|
||||
set selectEventsSuppressed(val) {
|
||||
let valChanged = val !== this._selectEventsSuppressed;
|
||||
this._selectEventsSuppressed = val;
|
||||
if (!val) {
|
||||
if (!val && valChanged) {
|
||||
this._updateTree();
|
||||
if (this._tree.invalidate) {
|
||||
this._tree.invalidate();
|
||||
|
|
@ -323,6 +388,8 @@ class VirtualizedTable extends React.Component {
|
|||
this._typingString = "";
|
||||
this._jsWindowID = `virtualized-table-list-${Zotero.Utilities.randomString(5)}`;
|
||||
this._containerWidth = props.containerWidth || window.innerWidth;
|
||||
this.className = props.className || "";
|
||||
this.firstColumnExtraWidth = (props.firstColumnExtraWidth) - (COLUMN_PADDING / 2); // missing left-padding of the content cell
|
||||
|
||||
this._columns = new Columns(this);
|
||||
|
||||
|
|
@ -362,11 +429,24 @@ class VirtualizedTable extends React.Component {
|
|||
staticColumns: false,
|
||||
alternatingRowColors: Zotero.isMac ? ['-moz-OddTreeRow', '-moz-EvenTreeRow'] : null,
|
||||
|
||||
firstColumnExtraWidth: 0,
|
||||
|
||||
// Render with display: none
|
||||
hide: false,
|
||||
|
||||
multiSelect: false,
|
||||
|
||||
// When true, the last selected row can't be toggled off, so the selection
|
||||
// never becomes empty through user action
|
||||
requireSelection: false,
|
||||
|
||||
// When true, the header row of the section currently at the top of the view is
|
||||
// pinned to the top while scrolling (see isSectionHeader)
|
||||
stickySectionHeaders: false,
|
||||
// Returns whether the row at the given index begins a section, i.e., should be
|
||||
// pinned to the top while its section is scrolled through
|
||||
isSectionHeader: () => false,
|
||||
|
||||
onSelectionChange: noop,
|
||||
|
||||
// The below are for arrow-key navigation
|
||||
|
|
@ -419,6 +499,8 @@ class VirtualizedTable extends React.Component {
|
|||
staticColumns: PropTypes.bool,
|
||||
// Used for initial column widths calculation
|
||||
containerWidth: PropTypes.number,
|
||||
// If first column is injected with extra stuff, like an item icon
|
||||
// and we need to reserve extra min-width for it, set this prop
|
||||
firstColumnExtraWidth: PropTypes.number,
|
||||
|
||||
// Internal windowed-list ref
|
||||
|
|
@ -429,6 +511,11 @@ class VirtualizedTable extends React.Component {
|
|||
|
||||
multiSelect: PropTypes.bool,
|
||||
|
||||
requireSelection: PropTypes.bool,
|
||||
|
||||
stickySectionHeaders: PropTypes.bool,
|
||||
isSectionHeader: PropTypes.func,
|
||||
|
||||
onSelectionChange: PropTypes.func,
|
||||
|
||||
// The below are for arrow-key navigation
|
||||
|
|
@ -539,7 +626,7 @@ class VirtualizedTable extends React.Component {
|
|||
*
|
||||
* @param {Event} e
|
||||
*/
|
||||
_onKeyDown = (e) => {
|
||||
_onKeyDown = async (e) => {
|
||||
if (this.props.onKeyDown && this.props.onKeyDown(e) === false) return;
|
||||
|
||||
this._preventKeyboardScrolling(e);
|
||||
|
|
@ -633,6 +720,25 @@ class VirtualizedTable extends React.Component {
|
|||
}
|
||||
|
||||
if (shiftSelect || moveFocused) return;
|
||||
|
||||
// If selection count is greater than 1 and the focused row wasn't
|
||||
// moved out of the selection - toggle open/closed all rows in that selection
|
||||
// Otherwise if the focused row has moved out of the selection, toggle state
|
||||
// of the focused row (handled below)
|
||||
if (this.selection.count > 1
|
||||
&& this.selection.isSelected(this.selection.focused)
|
||||
&& [Zotero.arrowPreviousKey, Zotero.arrowNextKey].includes(e.key)) {
|
||||
let open = e.key == Zotero.arrowNextKey;
|
||||
let rows = Array.from(this.selection.selected)
|
||||
.filter(index => this.props.isContainer(index)
|
||||
&& !this.props.isContainerEmpty(index)
|
||||
&& (open ? !this.props.isContainerOpen(index) : this.props.isContainerOpen(index)))
|
||||
.sort((a, b) => b - a);
|
||||
for (let index of rows) {
|
||||
await this.toggleOpenState(index);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case Zotero.arrowPreviousKey:
|
||||
|
|
@ -640,7 +746,7 @@ class VirtualizedTable extends React.Component {
|
|||
if (this.props.isContainer(this.selection.focused)
|
||||
&& !this.props.isContainerEmpty(this.selection.focused)
|
||||
&& this.props.isContainerOpen(this.selection.focused)) {
|
||||
this.props.toggleOpenState(this.selection.focused);
|
||||
this.toggleOpenState(this.selection.focused);
|
||||
}
|
||||
else if (parentIndex != -1) {
|
||||
this.onSelection(parentIndex);
|
||||
|
|
@ -651,7 +757,7 @@ class VirtualizedTable extends React.Component {
|
|||
if (this.props.isContainer(this.selection.focused)
|
||||
&& !this.props.isContainerEmpty(this.selection.focused)) {
|
||||
if (!this.props.isContainerOpen(this.selection.focused)) {
|
||||
this.props.toggleOpenState(this.selection.focused);
|
||||
this.toggleOpenState(this.selection.focused);
|
||||
}
|
||||
else {
|
||||
this.onSelection(this.selection.focused + 1);
|
||||
|
|
@ -783,7 +889,16 @@ class VirtualizedTable extends React.Component {
|
|||
* @param index {Number}
|
||||
*/
|
||||
scrollToRow(index) {
|
||||
this._jsWindow && this._jsWindow.scrollToRow(index);
|
||||
if (!this._jsWindow) return;
|
||||
// When a sticky section header is pinned at the top of the view, it overlays the rows
|
||||
// below it. Reserve a row's worth of space so a row scrolled up into view lands below the
|
||||
// pinned header rather than behind it.
|
||||
let topOffset = 0;
|
||||
if (this.props.stickySectionHeaders && !this.props.isSectionHeader(index)
|
||||
&& this._getSectionHeaderIndices().some(i => i < index)) {
|
||||
topOffset = this._rowHeight;
|
||||
}
|
||||
this._jsWindow.scrollToRow(index, false, topOffset);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -809,6 +924,11 @@ class VirtualizedTable extends React.Component {
|
|||
this.selection.shiftSelect(index, toggleSelection, shouldDebounce);
|
||||
}
|
||||
else if (toggleSelection) {
|
||||
// Don't allow toggling off the last selected row when a selection is required
|
||||
if (this.props.requireSelection
|
||||
&& this.selection.count === 1 && this.selection.isSelected(index)) {
|
||||
return;
|
||||
}
|
||||
this.selection.toggleSelect(index, shouldDebounce);
|
||||
}
|
||||
else if (moveFocused) {
|
||||
|
|
@ -821,7 +941,9 @@ class VirtualizedTable extends React.Component {
|
|||
}
|
||||
// Normal selection
|
||||
else if (!toggleSelection) {
|
||||
if (index > 0 && !this.props.isSelectable(index)) {
|
||||
// Non-selectable rows (e.g. a sticky library header) are a no-op, including no
|
||||
// scroll-to-row below -- otherwise clicking the header at index 0 jumps to the top
|
||||
if (!this.props.isSelectable(index)) {
|
||||
return;
|
||||
}
|
||||
this.selection.select(index, shouldDebounce);
|
||||
|
|
@ -864,10 +986,7 @@ class VirtualizedTable extends React.Component {
|
|||
event.stopPropagation();
|
||||
const result = this._getResizeColumns();
|
||||
if (!result) return;
|
||||
const columns = this._getVisibleColumns();
|
||||
const [aColumn, bColumn, resizingColumn] = result;
|
||||
const isFirstColumn = columns[0].dataKey === aColumn.dataKey;
|
||||
const firstColumnExtraWidth = isFirstColumn ? (this.props.firstColumnExtraWidth || 0) : 0;
|
||||
const a = document.querySelector(`#${this.props.id} .virtualized-table-header .cell.${window.CSS.escape(aColumn.dataKey)}`);
|
||||
const b = document.querySelector(`#${this.props.id} .virtualized-table-header .cell.${window.CSS.escape(bColumn.dataKey)}`);
|
||||
const resizing = document.querySelector(`#${this.props.id} .virtualized-table-header .cell.${window.CSS.escape(resizingColumn.dataKey)}`);
|
||||
|
|
@ -881,9 +1000,12 @@ class VirtualizedTable extends React.Component {
|
|||
const widthSum = aRect.width + bRect.width;
|
||||
const aColumnPadding = aColumn.iconLabel ? 0 : COLUMN_PADDING;
|
||||
const bColumnPadding = bColumn.iconLabel ? 0 : COLUMN_PADDING;
|
||||
const aSpacingOffset = (aColumn.minWidth ? aColumn.minWidth : COLUMN_MIN_WIDTH) + aColumnPadding + firstColumnExtraWidth;
|
||||
const bSpacingOffset = (bColumn.minWidth ? bColumn.minWidth : COLUMN_MIN_WIDTH) + bColumnPadding;
|
||||
const aColumnWidth = Math.min(widthSum - bSpacingOffset, Math.max(aSpacingOffset, event.clientX - (RESIZER_WIDTH / 2) - offset));
|
||||
const aMinWidth = (aColumn.minWidth ? aColumn.minWidth : COLUMN_MIN_WIDTH) + aColumnPadding;
|
||||
const bMinWidth = (bColumn.minWidth ? bColumn.minWidth : COLUMN_MIN_WIDTH) + bColumnPadding;
|
||||
const aMaxWidth = widthSum - bMinWidth;
|
||||
const aDragWidth = event.clientX - (RESIZER_WIDTH / 2) - offset;
|
||||
// Constrain the drag position to the min and max widths
|
||||
const aColumnWidth = Math.min(aMaxWidth, Math.max(aMinWidth, aDragWidth));
|
||||
const bColumnWidth = widthSum - aColumnWidth;
|
||||
let onResizeData = {};
|
||||
onResizeData[aColumn.dataKey] = aColumnWidth;
|
||||
|
|
@ -1055,18 +1177,31 @@ class VirtualizedTable extends React.Component {
|
|||
|
||||
this._setXulTooltip();
|
||||
|
||||
this._topDiv.style.setProperty("--firstColumnExtraWidth", `${this.props.firstColumnExtraWidth || 0}px`);
|
||||
window.addEventListener("resize", () => {
|
||||
this._debouncedRerender();
|
||||
});
|
||||
this._topDiv.style.setProperty("--first-column-extra-width", `${this.firstColumnExtraWidth}px`);
|
||||
this._resizeObserver = new ResizeObserver(() => this.rerender());
|
||||
this._resizeObserver.observe(this._jsWindow.targetElement);
|
||||
|
||||
if (this.props.stickySectionHeaders) {
|
||||
this._jsWindow.targetElement.addEventListener('scroll', this._updateStickySectionHeader, { passive: true });
|
||||
// The pinned header overlays the rows, so reject drops on it rather than letting
|
||||
// them reach the body's drop handler (which would treat them as a list drop)
|
||||
this._stickyHeader.addEventListener('dragover', this._rejectStickyHeaderDrop);
|
||||
this._stickyHeader.addEventListener('drop', this._rejectStickyHeaderDrop);
|
||||
this._updateStickySectionHeader();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
componentWillUnmount() {
|
||||
this._resizeObserver?.disconnect();
|
||||
if (this.props.stickySectionHeaders && this._jsWindow) {
|
||||
this._jsWindow.targetElement.removeEventListener('scroll', this._updateStickySectionHeader);
|
||||
}
|
||||
this._jsWindow.destroy();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.id !== prevProps.id) {
|
||||
if (this.props.id !== prevProps.id
|
||||
|| this.props.columns !== prevProps.columns) {
|
||||
this._columns = new Columns(this);
|
||||
this.forceUpdate();
|
||||
}
|
||||
|
|
@ -1112,13 +1247,13 @@ class VirtualizedTable extends React.Component {
|
|||
return {
|
||||
getItemCount: this.props.getRowCount,
|
||||
itemHeight: this._rowHeight,
|
||||
renderItem: this._renderItem,
|
||||
renderItem: this._renderItem.bind(this),
|
||||
targetElement: document.getElementById(this._jsWindowID),
|
||||
customRowHeights: this.props.customRowHeights ?? []
|
||||
};
|
||||
}
|
||||
|
||||
_renderItem = (index, oldElem = null) => {
|
||||
|
||||
_renderItem(index, oldElem = null) {
|
||||
let node = this.props.renderItem(index, this.selection, oldElem, this._getColumns());
|
||||
if (!node.dataset.eventHandlersAttached) {
|
||||
node.dataset.eventHandlersAttached = true;
|
||||
|
|
@ -1133,8 +1268,12 @@ class VirtualizedTable extends React.Component {
|
|||
}
|
||||
node.style.height = (index in this._customRowHeightMap ? this._customRowHeightMap[index] : this._rowHeight) + 'px';
|
||||
node.id = this.props.id + "-row-" + index;
|
||||
node.classList.toggle('odd', index % 2 == 1);
|
||||
node.classList.toggle('even', index % 2 == 0);
|
||||
// Row striping restarts at each section header, so every section's first row is
|
||||
// the same shade (see _sectionRelativeIndex); without section headers this is just
|
||||
// the row index
|
||||
let stripeIndex = this._sectionRelativeIndex(index);
|
||||
node.classList.toggle('odd', stripeIndex % 2 == 1);
|
||||
node.classList.toggle('even', stripeIndex % 2 == 0);
|
||||
if (!node.hasAttribute('role')) {
|
||||
node.setAttribute('role', 'row');
|
||||
}
|
||||
|
|
@ -1183,7 +1322,7 @@ class VirtualizedTable extends React.Component {
|
|||
if (!column.iconLabel && column.sortDirection) {
|
||||
sortIndicator = <CSSIcon name="sort-indicator" className={"icon-8 sort-indicator " + (column.sortDirection === 1 ? "ascending" : "descending")} />;
|
||||
}
|
||||
const className = cx("cell", column.className, { 'first-column': index === 0, dragging: this.state.draggingColumn == index },
|
||||
const className = cx("cell", column.className, { dragging: this.state.draggingColumn == index },
|
||||
{ "cell-icon": !!column.iconLabel });
|
||||
return (<Draggable
|
||||
onDragStart={this._handleColumnDragStart.bind(this, index)}
|
||||
|
|
@ -1237,7 +1376,9 @@ class VirtualizedTable extends React.Component {
|
|||
{
|
||||
resizing: this.state.resizing,
|
||||
'multi-select': this.props.multiSelect
|
||||
}]),
|
||||
},
|
||||
this.className
|
||||
]),
|
||||
id: this.props.id,
|
||||
ref: ref => this._topDiv = ref,
|
||||
tabIndex: 0,
|
||||
|
|
@ -1262,7 +1403,12 @@ class VirtualizedTable extends React.Component {
|
|||
}
|
||||
let jsWindowProps = {
|
||||
id: this._jsWindowID,
|
||||
className: "virtualized-table-body",
|
||||
// Reserve a scrollbar gutter when there are sticky section headers (see CSS), so the
|
||||
// scrollbar doesn't float over the content. Without it a macOS overlay scrollbar
|
||||
// overlaps the rows, and the opaque pinned header -- which must paint above the rows
|
||||
// to occlude them, and so above the scrollbar -- covers the scrollbar's edge.
|
||||
className: "virtualized-table-body"
|
||||
+ (this.props.stickySectionHeaders ? " has-sticky-section-headers" : ""),
|
||||
onFocus: (e) => {
|
||||
if (e.target.id == this._jsWindowID) {
|
||||
// Focus should always remain on the list itself.
|
||||
|
|
@ -1275,7 +1421,23 @@ class VirtualizedTable extends React.Component {
|
|||
<div {...props}>
|
||||
{columnDragMarker}
|
||||
{header}
|
||||
<div {...jsWindowProps} />
|
||||
<div {...jsWindowProps}>
|
||||
{/* Pinned copy of the current section's header. Lives inside the scrolling
|
||||
body (as its first child, before the windowed-list) and pins with
|
||||
position: sticky, so its width tracks the body's content box automatically
|
||||
and it lines up with the rows without any JS geometry. */}
|
||||
{this.props.stickySectionHeaders
|
||||
&& <div
|
||||
className="virtualized-table-sticky-section-header"
|
||||
ref={ref => this._stickyHeader = ref}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
className="virtualized-table-sticky-section-header-content"
|
||||
ref={ref => this._stickyHeaderContent = ref}
|
||||
/>
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1287,6 +1449,7 @@ class VirtualizedTable extends React.Component {
|
|||
if (!this._jsWindow) return;
|
||||
this._jsWindow.invalidate();
|
||||
this._updateWidth();
|
||||
this._refreshStickySectionHeader();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1297,6 +1460,149 @@ class VirtualizedTable extends React.Component {
|
|||
if (!this._jsWindow) return;
|
||||
this._jsWindow.render();
|
||||
this._updateWidth();
|
||||
this._refreshStickySectionHeader();
|
||||
}
|
||||
|
||||
// ------------------------ Sticky Section Headers ------------------------ //
|
||||
|
||||
/**
|
||||
* Make a drop on the pinned header a no-op. Not preventing the default on dragover leaves
|
||||
* it an invalid drop target (so no drop fires), and stopping propagation keeps the event
|
||||
* from the body's handlers, which would otherwise allow the drop and treat it as a drop on
|
||||
* the list. The header occludes a row, but dropping on it shouldn't act on that row.
|
||||
*/
|
||||
_rejectStickyHeaderDrop = (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.type == 'dragover' && e.dataTransfer) {
|
||||
e.dataTransfer.dropEffect = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of section-header rows can change whenever the row model changes, so drop
|
||||
* the cached indices and repin. Called after the list is invalidated/rerendered.
|
||||
*/
|
||||
_refreshStickySectionHeader() {
|
||||
if (!this.props.stickySectionHeaders) return;
|
||||
this._sectionHeaderIndices = null;
|
||||
this._stickyHeaderIndex = null;
|
||||
this._updateStickySectionHeader();
|
||||
}
|
||||
|
||||
/**
|
||||
* The stripe index of a row, so striping restarts at each section header. The header
|
||||
* counts as the section's first (unstriped) row, so the data row right below it is
|
||||
* striped; subsequent rows alternate. Without section headers this is just the row
|
||||
* index, preserving the normal whole-list striping (first row unstriped).
|
||||
*/
|
||||
_sectionRelativeIndex(index) {
|
||||
let base = -1;
|
||||
for (let headerIndex of this._getSectionHeaderIndices()) {
|
||||
if (headerIndex <= index) {
|
||||
base = headerIndex;
|
||||
}
|
||||
else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// No header above: stripe from the top (first row unstriped). With a header above,
|
||||
// the header is the unstriped row 0, so the row below it (index - base == 1) is striped.
|
||||
return base === -1 ? index : index - base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indices of all section-header rows, ascending. Cached until the row model changes.
|
||||
*/
|
||||
_getSectionHeaderIndices() {
|
||||
// Only trees that opt into section headers have them
|
||||
if (!this.props.stickySectionHeaders) {
|
||||
return [];
|
||||
}
|
||||
if (this._sectionHeaderIndices) {
|
||||
return this._sectionHeaderIndices;
|
||||
}
|
||||
let indices = [];
|
||||
let count = this.props.getRowCount();
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (this.props.isSectionHeader(i)) {
|
||||
indices.push(i);
|
||||
}
|
||||
}
|
||||
this._sectionHeaderIndices = indices;
|
||||
return indices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin the header of the section currently at the top of the view, pushing it up as
|
||||
* the next section's header scrolls into it, and reuse the consumer's renderItem so the
|
||||
* pinned copy matches the real header row's appearance.
|
||||
*
|
||||
* The pinned header is the first child of the scrolling body and stays put via position:
|
||||
* sticky, taking its width from the body's content box so it lines up with the rows. The
|
||||
* outer element has zero height (so it adds no space to the flow); the inner (opaque)
|
||||
* element holds the rendered header, overflows downward over the rows, and is the part that
|
||||
* translates, so a header pushed up by the next section is clipped at the top of the body
|
||||
* (by the body's own overflow) rather than spilling over the column header.
|
||||
*/
|
||||
_updateStickySectionHeader = () => {
|
||||
if (!this.props.stickySectionHeaders || !this._stickyHeader || !this._jsWindow) {
|
||||
return;
|
||||
}
|
||||
let clip = this._stickyHeader;
|
||||
let content = this._stickyHeaderContent;
|
||||
let headerIndices = this._getSectionHeaderIndices();
|
||||
let scrollTop = this._jsWindow.targetElement.scrollTop;
|
||||
// The section in view is the last header at or above the top of the view; the
|
||||
// next header (if any) is what pushes it up
|
||||
let currentIndex = -1;
|
||||
let nextIndex = -1;
|
||||
for (let index of headerIndices) {
|
||||
if (this._jsWindow._getItemPosition(index) <= scrollTop) {
|
||||
currentIndex = index;
|
||||
}
|
||||
else {
|
||||
nextIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Show the pinned copy only once the header row has scrolled up past the top edge of
|
||||
// the view
|
||||
let stuck = currentIndex != -1
|
||||
&& scrollTop > this._jsWindow._getItemPosition(currentIndex);
|
||||
if (!stuck) {
|
||||
clip.style.display = 'none';
|
||||
this._stickyHeaderIndex = null;
|
||||
return;
|
||||
}
|
||||
clip.style.display = '';
|
||||
clip.classList.add('stuck');
|
||||
// Re-render only when the pinned section changes. Use _renderItem (not the raw
|
||||
// renderItem prop) so the pinned copy gets the same post-processing as a real row
|
||||
// (e.g. the tree's indent/twisty spacer), then drop its id to avoid duplicating the
|
||||
// real row's.
|
||||
if (this._stickyHeaderIndex !== currentIndex) {
|
||||
this._stickyHeaderIndex = currentIndex;
|
||||
let node = this._renderItem(currentIndex);
|
||||
node.removeAttribute('id');
|
||||
// Strip the focus ring: focus defaults to row 0, which can be a header, but a
|
||||
// pinned header shouldn't show focus
|
||||
node.classList.remove('focused');
|
||||
content.textContent = '';
|
||||
content.appendChild(node);
|
||||
}
|
||||
// Geometry is all CSS now: the clip is the first child of the scrolling body, has zero
|
||||
// height (so it takes no space in the flow and the rows below aren't shifted down), and
|
||||
// pins itself with position: sticky. Its content overflows downward over the rows and
|
||||
// gets its width from the body's content box, so it lines up with the real rows.
|
||||
// Push the pinned header up as the next section's header approaches the top
|
||||
let translateY = 0;
|
||||
if (nextIndex != -1) {
|
||||
let nextTop = this._jsWindow._getItemPosition(nextIndex) - scrollTop;
|
||||
if (nextTop < this._rowHeight) {
|
||||
translateY = nextTop - this._rowHeight;
|
||||
}
|
||||
}
|
||||
content.style.transform = `translateY(${translateY}px)`;
|
||||
}
|
||||
|
||||
updateFontSize = () => {
|
||||
|
|
@ -1359,8 +1665,6 @@ class VirtualizedTable extends React.Component {
|
|||
return parseFloat(height.split('px')[0]);
|
||||
}
|
||||
|
||||
_debouncedRerender = Zotero.Utilities.debounce(this.rerender, 200);
|
||||
|
||||
_updateWidth() {
|
||||
if (!this.props.showHeader) return;
|
||||
const jsWindow = document.querySelector(`#${this._jsWindowID} .windowed-list`);
|
||||
|
|
@ -1407,10 +1711,19 @@ class VirtualizedTable extends React.Component {
|
|||
&& row <= this._jsWindow.getLastVisibleRow();
|
||||
}
|
||||
|
||||
toggleOpenState(index, ...args) {
|
||||
let onToggleOpenState = this.props.toggleOpenState;
|
||||
if (typeof onToggleOpenState == 'function') return onToggleOpenState(index, ...args);
|
||||
}
|
||||
|
||||
async _resetColumns() {
|
||||
this.invalidate();
|
||||
this._columns = new Columns(this);
|
||||
await new Promise((resolve) => {this.forceUpdate(resolve)});
|
||||
return new Promise((resolve) => {
|
||||
this.forceUpdate(() => {
|
||||
this._jsWindow.invalidate();
|
||||
resolve();
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
// Set aria-activedescendant on table container
|
||||
|
|
@ -1423,6 +1736,143 @@ class VirtualizedTable extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VirtualizedTree wraps VirtualizedTable to provide common tree affordances:
|
||||
* - Adds an indent spacer based on depth to the first visible cell
|
||||
* - Adds a twisty for non-empty containers
|
||||
* - Sets tree-specific ARIA attributes on rows and the container
|
||||
* - Wires twisty mouse handlers to toggle container open state
|
||||
*
|
||||
* Consumers should provide isContainer/isContainerEmpty/isContainerOpen/onToggleOpenState
|
||||
* and getParentIndex(index) to compute ancestry.
|
||||
*/
|
||||
class VirtualizedTree extends VirtualizedTable {
|
||||
static propTypes = { ...VirtualizedTable.propTypes,
|
||||
getParentIndex: PropTypes.func.isRequired,
|
||||
isContainer: PropTypes.func.isRequired,
|
||||
isContainerEmpty: PropTypes.func.isRequired,
|
||||
isContainerOpen: PropTypes.func.isRequired,
|
||||
onToggleOpenState: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
_toggledOpenStateIndex = null;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.className += " virtualized-tree";
|
||||
this.firstColumnExtraWidth += 16; // 16px for twisty
|
||||
}
|
||||
|
||||
toggleOpenState(index, ...args) {
|
||||
this._toggledOpenStateIndex = index;
|
||||
return this.props.onToggleOpenState(index, ...args);
|
||||
}
|
||||
|
||||
_renderItem(index, oldElem=null) {
|
||||
let node = super._renderItem(index, oldElem);
|
||||
if (!(node instanceof (node?.ownerDocument?.defaultView || window).Element)) {
|
||||
return node;
|
||||
}
|
||||
node = this._addIndentAndTwisty(node, index);
|
||||
this._setRowAria(node, index);
|
||||
return node;
|
||||
}
|
||||
|
||||
_getDepth(index) {
|
||||
let depth = 0;
|
||||
try {
|
||||
let parent = typeof this.props.getParentIndex == 'function' ? this.props.getParentIndex(index) : -1;
|
||||
while (parent != -1 && typeof parent == 'number') {
|
||||
depth++;
|
||||
parent = this.props.getParentIndex(parent);
|
||||
}
|
||||
}
|
||||
catch (e) {}
|
||||
return depth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an indent spacer and twisty to the first cell of the node
|
||||
*
|
||||
* We add it to the first cell instead of as a separate pseudo-cell or just elements before
|
||||
* the first cell because otherwise it messes with column spacing.
|
||||
*
|
||||
* @param node {HTMLElement} The rendered item row
|
||||
* @param index {number} The index of the node being rendered
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_addIndentAndTwisty(node, index) {
|
||||
let firstCell = node.querySelector('.cell');
|
||||
if (!firstCell) return node;
|
||||
|
||||
let twisty;
|
||||
if (this.props.isContainerEmpty(index)) {
|
||||
twisty = firstCell.querySelector('.spacer-twisty');
|
||||
if (!twisty) {
|
||||
twisty = node.ownerDocument.createElement('span');
|
||||
firstCell.prepend(twisty);
|
||||
twisty.classList.add('spacer-twisty');
|
||||
}
|
||||
firstCell.querySelector(`:scope > .twisty`)?.remove();
|
||||
}
|
||||
else {
|
||||
twisty = firstCell.querySelector('.twisty');
|
||||
if (!twisty) {
|
||||
twisty = getCSSIcon('twisty');
|
||||
twisty.classList.add('twisty');
|
||||
twisty.style.pointerEvents = 'auto';
|
||||
twisty.addEventListener('mousedown', (event) => event.stopPropagation());
|
||||
twisty.addEventListener('mouseup', (event) => {
|
||||
this.toggleOpenState(index);
|
||||
event.stopPropagation();
|
||||
}, { passive: true });
|
||||
twisty.addEventListener('dblclick', (event) => event.stopImmediatePropagation(), { passive: true });
|
||||
firstCell.prepend(twisty);
|
||||
}
|
||||
firstCell.querySelector(`:scope > .spacer-twisty`)?.remove();
|
||||
|
||||
// Apply the twisty animation
|
||||
if (this._toggledOpenStateIndex == index) {
|
||||
twisty.classList.toggle('open', !this.props.isContainerOpen(index));
|
||||
requestAnimationFrame(() => {
|
||||
twisty.classList.toggle('open', this.props.isContainerOpen(index));
|
||||
this._toggledOpenStateIndex = null;
|
||||
});
|
||||
}
|
||||
else {
|
||||
twisty.classList.toggle('open', this.props.isContainerOpen(index));
|
||||
}
|
||||
}
|
||||
|
||||
let indentSpan = firstCell.querySelector('.cell-indent');
|
||||
if (!indentSpan) {
|
||||
indentSpan = node.ownerDocument.createElement('span');
|
||||
indentSpan.className = 'cell-indent';
|
||||
firstCell.prepend(indentSpan);
|
||||
}
|
||||
// Use padding for indent similar to ItemTree
|
||||
const CHILD_INDENT = 16;
|
||||
indentSpan.style.paddingInlineStart = (CHILD_INDENT * this._getDepth(index)) + 'px';
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
_setRowAria(node, index) {
|
||||
const depth = this._getDepth(index);
|
||||
node.setAttribute('role', 'treeitem');
|
||||
node.setAttribute('aria-level', depth + 1);
|
||||
if (!this.props.isContainerEmpty(index)) {
|
||||
node.setAttribute('aria-expanded', !!this.props.isContainerOpen(index));
|
||||
}
|
||||
else {
|
||||
node.removeAttribute('aria-expanded');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VirtualizedTree.propTypes = Object.assign({}, VirtualizedTable.propTypes);
|
||||
VirtualizedTree.defaultProps = Object.assign({}, VirtualizedTable.defaultProps, { role: 'tree' });
|
||||
|
||||
/**
|
||||
* Create a function that calls the given function `fn` only once per animation
|
||||
* frame.
|
||||
|
|
@ -1468,9 +1918,10 @@ var Columns = class {
|
|||
// Fixed width columns can sometimes somehow obtain a width property
|
||||
// this fixes it for users that may have run into the bug
|
||||
if (column.fixedWidth && typeof columnsSettings[column.dataKey] == "object") {
|
||||
delete columnsSettings[column.dataKey].width;;
|
||||
delete columnsSettings[column.dataKey].width;
|
||||
}
|
||||
column = Object.assign({}, column, columnsSettings[column.dataKey]);
|
||||
// Don't load column settings for disabled columns (they are overriden to be hidden)
|
||||
column = Object.assign({}, column, column.disabled ? {} : columnsSettings[column.dataKey]);
|
||||
column.className = cx(column.className, column.dataKey, column.dataKey + this._cssSuffix,
|
||||
{ 'fixed-width': column.fixedWidth });
|
||||
if (column.type) {
|
||||
|
|
@ -1490,33 +1941,22 @@ var Columns = class {
|
|||
// if new columns got added recently
|
||||
columns.forEach((column, index) => column.ordinal = index);
|
||||
|
||||
// Setting column widths
|
||||
const visibleColumns =
|
||||
columns.reduce((accumulator, column) => accumulator += column.hidden ? 0 : 1, 0);
|
||||
const containerWidth = this._virtualizedTable._containerWidth;
|
||||
// Compute initial CSS widths for visible columns. Widths are not persisted
|
||||
// from here — they flow to disk only when the user resizes (see onResize).
|
||||
let columnWidths = {};
|
||||
for (let i = 0; i < columns.length; i++) {
|
||||
let column = columns[i];
|
||||
|
||||
if (!column.hidden) {
|
||||
if (column.width) {
|
||||
columnWidths[column.dataKey] = column.width;
|
||||
}
|
||||
else {
|
||||
column.flex = column.flex || 1;
|
||||
columnWidths[column.dataKey] = column.width = containerWidth / visibleColumns * (column.flex || 1);
|
||||
}
|
||||
for (let column of columns) {
|
||||
if (column.disabled || column.hidden) continue;
|
||||
if (column.width) {
|
||||
columnWidths[column.dataKey] = column.width;
|
||||
}
|
||||
else {
|
||||
column.flex = column.flex || 1;
|
||||
columnWidths[column.dataKey] = column.width = this._computeFlexWidth(column);
|
||||
}
|
||||
// Serializing back column settings for storage
|
||||
columnsSettings[column.dataKey] = this._getColumnPrefsToPersist(column);
|
||||
}
|
||||
// Storing back persist settings to account for legacy upgrades
|
||||
this._storePrefs(columnsSettings);
|
||||
|
||||
this._adjustColumnWidths();
|
||||
// Set column width CSS rules
|
||||
this.onResize(columnWidths);
|
||||
// Whew, all this just to get a list of columns
|
||||
}
|
||||
|
||||
_initializeStyleMap() {
|
||||
|
|
@ -1526,16 +1966,17 @@ var Columns = class {
|
|||
if (this._stylesheet) {
|
||||
this._columnStyleMap = {};
|
||||
for (let i = 0; i < this._stylesheet.sheet.cssRules.length; i++) {
|
||||
const cssText = this._stylesheet.sheet.cssRules[i].cssText;
|
||||
const dataKey = cssText.slice(1, cssText.indexOf('-'));
|
||||
const selector = this._stylesheet.sheet.cssRules[i].selectorText;
|
||||
const dataKey = selector.slice(1, selector.length - this._cssSuffix.length);
|
||||
this._columnStyleMap[dataKey] = i;
|
||||
}
|
||||
for (let i = 0; i < this._virtualizedTable.props.columns.length; i++) {
|
||||
let column = this._virtualizedTable.props.columns[i];
|
||||
if (column.dataKey in this._columnStyleMap) continue;
|
||||
let escapedDataKey = window.CSS.escape(column.dataKey);
|
||||
if (escapedDataKey in this._columnStyleMap) continue;
|
||||
const ruleIndex = Object.keys(this._columnStyleMap).length;
|
||||
this._stylesheet.sheet.insertRule(`.${window.CSS.escape(column.dataKey) + this._cssSuffix} {flex-basis: 100px}`, ruleIndex);
|
||||
this._columnStyleMap[column.dataKey] = ruleIndex;
|
||||
this._stylesheet.sheet.insertRule(`.${escapedDataKey + this._cssSuffix} {flex-basis: 100px}`, ruleIndex);
|
||||
this._columnStyleMap[escapedDataKey] = ruleIndex;
|
||||
}
|
||||
} else {
|
||||
this._stylesheet = document.createElement('style');
|
||||
|
|
@ -1544,8 +1985,9 @@ var Columns = class {
|
|||
this._columnStyleMap = {};
|
||||
for (let i = 0; i < this._virtualizedTable.props.columns.length; i++) {
|
||||
let column = this._virtualizedTable.props.columns[i];
|
||||
this._stylesheet.sheet.insertRule(`.${window.CSS.escape(column.dataKey) + this._cssSuffix} {flex-basis: 100px}`, i);
|
||||
this._columnStyleMap[column.dataKey] = i;
|
||||
let escapedDataKey = window.CSS.escape(column.dataKey);
|
||||
this._stylesheet.sheet.insertRule(`.${escapedDataKey + this._cssSuffix} {flex-basis: 100px}`, i);
|
||||
this._columnStyleMap[escapedDataKey] = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1578,24 +2020,6 @@ var Columns = class {
|
|||
this._virtualizedTable.props.storeColumnPrefs(prefs);
|
||||
}
|
||||
|
||||
_adjustColumnWidths = () => {
|
||||
if (!this._virtualizedTable.props.firstColumnExtraWidth) {
|
||||
return;
|
||||
}
|
||||
|
||||
const extraWidth = this._virtualizedTable.props.firstColumnExtraWidth;
|
||||
this._columns.filter(c => !c.hidden).forEach((column, index) => {
|
||||
const isFirstColumn = index === 0;
|
||||
if (column.fixedWidth) {
|
||||
column.width = isFirstColumn ? parseInt(column.originalWidth) + extraWidth : column.originalWidth;
|
||||
}
|
||||
if (column.staticWidth) {
|
||||
column.minWidth = isFirstColumn ? (column.originalMinWidth ?? 20) + extraWidth : column.originalMinWidth;
|
||||
column.width = isFirstColumn ? Math.max(parseInt(column.width) ?? 0, column.minWidth) : column.width;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Programatically sets the injected CSS width rules for each column.
|
||||
* This is necessary for performance reasons
|
||||
|
|
@ -1607,28 +2031,36 @@ var Columns = class {
|
|||
var prefs = this._getPrefs();
|
||||
}
|
||||
|
||||
let visibleColumns = this.getAsArray().filter(column => !column.hidden);
|
||||
|
||||
for (let [dataKey, width] of Object.entries(columnWidths)) {
|
||||
if (typeof dataKey == "number") {
|
||||
dataKey = this._columns[dataKey].dataKey;
|
||||
}
|
||||
const column = this._columns.find(column => column.dataKey == dataKey);
|
||||
const styleIndex = this._columnStyleMap[dataKey];
|
||||
if (column.hidden) continue;
|
||||
const styleIndex = this._columnStyleMap[window.CSS.escape(dataKey)];
|
||||
const columnPadding = column.iconLabel ? 0 : COLUMN_PADDING;
|
||||
if (storePrefs && !column.fixedWidth) {
|
||||
column.width = width;
|
||||
prefs[dataKey] = this._getColumnPrefsToPersist(column);
|
||||
let cssWidth = width;
|
||||
// It's set in CSS, so we subtract it here to prevent sliding
|
||||
if (column.dataKey === visibleColumns[0].dataKey) {
|
||||
cssWidth -= this._virtualizedTable.firstColumnExtraWidth;
|
||||
}
|
||||
if (column.fixedWidth) {
|
||||
width = column.width;
|
||||
cssWidth = width = column.width;
|
||||
}
|
||||
if (column.fixedWidth && column.width || column.staticWidth) {
|
||||
this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('flex', `0 0`, `important`);
|
||||
this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('max-width', `${width}px`, 'important');
|
||||
this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('min-width', `${width}px`, 'important');
|
||||
this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('max-width', `calc(var(--extra-width, 0px) + ${cssWidth}px`, 'important');
|
||||
this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('min-width', `calc(var(--extra-width, 0px) + ${cssWidth}px`, 'important');
|
||||
} else {
|
||||
width = (width - columnPadding);
|
||||
Zotero.debug(`Columns ${dataKey} width ${width}`);
|
||||
this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('flex-basis', `${width}px`);
|
||||
width -= columnPadding;
|
||||
this._stylesheet.sheet.cssRules[styleIndex].style.setProperty('flex-basis', `calc(var(--extra-width, 0px) + ${cssWidth}px`);
|
||||
}
|
||||
Zotero.debug(`Columns ${dataKey} width ${width}`);
|
||||
if (storePrefs) {
|
||||
column.width = width;
|
||||
prefs[dataKey] = this._getColumnPrefsToPersist(column);
|
||||
}
|
||||
}
|
||||
if (storePrefs) {
|
||||
|
|
@ -1646,7 +2078,6 @@ var Columns = class {
|
|||
return a.ordinal - b.ordinal;
|
||||
});
|
||||
|
||||
this._adjustColumnWidths();
|
||||
this.onResize(Object.fromEntries(this._columns.map(c => [c.dataKey, c.width])));
|
||||
|
||||
let prefs = this._getPrefs();
|
||||
|
|
@ -1686,7 +2117,6 @@ var Columns = class {
|
|||
this._columns.find(c => c.dataKey === 'title').hidden = false;
|
||||
}
|
||||
|
||||
this._adjustColumnWidths();
|
||||
this.onResize(Object.fromEntries(this._columns.map(c => [c.dataKey, c.width])));
|
||||
this._storePrefs(prefs);
|
||||
this._updateVirtualizedTable();
|
||||
|
|
@ -1696,15 +2126,34 @@ var Columns = class {
|
|||
const column = this._columns[index];
|
||||
column.hidden = !column.hidden;
|
||||
|
||||
let prefs = this._getPrefs();
|
||||
if (prefs[column.dataKey]) {
|
||||
prefs[column.dataKey].hidden = column.hidden;
|
||||
if (!column.hidden && !column.width && !(column.fixedWidth || column.staticWidth)) {
|
||||
column.width = this._computeFlexWidth(column);
|
||||
}
|
||||
this._adjustColumnWidths();
|
||||
|
||||
let prefs = this._getPrefs();
|
||||
prefs[column.dataKey] = prefs[column.dataKey] || {};
|
||||
prefs[column.dataKey].hidden = column.hidden;
|
||||
this.onResize(Object.fromEntries(this._columns.map(c => [c.dataKey, c.width])));
|
||||
this._storePrefs(prefs);
|
||||
this._updateVirtualizedTable();
|
||||
}
|
||||
|
||||
_computeFlexWidth(column) {
|
||||
const containerWidth = this._virtualizedTable._containerWidth;
|
||||
const visibleColumns = this._columns.filter(c => !c.hidden);
|
||||
let fixedWidth = 0;
|
||||
let totalFlex = 0;
|
||||
for (let col of visibleColumns) {
|
||||
if (col.fixedWidth || col.staticWidth || !col.flex) {
|
||||
fixedWidth += parseFloat(col.width) || col.minWidth || 0;
|
||||
}
|
||||
else {
|
||||
totalFlex++;
|
||||
}
|
||||
}
|
||||
let availableWidth = containerWidth - fixedWidth;
|
||||
return availableWidth / totalFlex * (column.flex || 1);
|
||||
}
|
||||
|
||||
toggleSort(sortIndex) {
|
||||
if (!this._virtualizedTable.props.onColumnSort) return;
|
||||
|
|
@ -1724,8 +2173,9 @@ var Columns = class {
|
|||
}
|
||||
}
|
||||
});
|
||||
this._virtualizedTable.props.onColumnSort(sortIndex, sortedColumn.sortDirection);
|
||||
let result = this._virtualizedTable.props.onColumnSort(sortIndex, sortedColumn.sortDirection);
|
||||
this._virtualizedTable.forceUpdate();
|
||||
return result;
|
||||
}
|
||||
|
||||
getAsArray() {
|
||||
|
|
@ -1735,8 +2185,8 @@ var Columns = class {
|
|||
|
||||
function renderCell(index, data, column, dir = null) {
|
||||
column = column || { dataKey: "" };
|
||||
if (column.renderer) {
|
||||
return column.renderer(index, data, column, dir);
|
||||
if (column.renderCell) {
|
||||
return column.renderCell(index, data, column, dir);
|
||||
}
|
||||
let span = document.createElement('span');
|
||||
span.className = `cell ${column.className}`;
|
||||
|
|
@ -1837,12 +2287,10 @@ function makeRowRenderer(getRowData) {
|
|||
else {
|
||||
div.appendChild(renderCell(index, rowData[column.dataKey], column));
|
||||
}
|
||||
let columnName = column.label;
|
||||
if (column.label in Zotero.Intl.strings) {
|
||||
columnName = Zotero.getString(column.label);
|
||||
}
|
||||
if (typeof rowData[column.dataKey] === "string") {
|
||||
ariaLabel += `${columnName}: ${rowData[column.dataKey]} `;
|
||||
let columnName = formatColumnName(column);
|
||||
let value = rowData[column.dataKey];
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
ariaLabel += `${columnName}: ${value} `;
|
||||
}
|
||||
else {
|
||||
ariaLabel += `${columnName} `;
|
||||
|
|
@ -1862,7 +2310,8 @@ function formatColumnName(column) {
|
|||
if (column.label in Zotero.Intl.strings) {
|
||||
return Zotero.getString(column.label);
|
||||
}
|
||||
else if (/^[^\s]+\w\.\w[^\s]+$/.test(column.label)) {
|
||||
// Dotted keys (.properties) or hyphenated keys with 3+ segments (Fluent)
|
||||
else if (/^[^\s]+\w\.\w[^\s]+$/.test(column.label) || /^\w+(-\w+){2,}$/.test(column.label)) {
|
||||
try {
|
||||
let labelString = Zotero.getString(column.label);
|
||||
if (labelString !== column.label) {
|
||||
|
|
@ -1877,6 +2326,8 @@ function formatColumnName(column) {
|
|||
}
|
||||
|
||||
module.exports = VirtualizedTable;
|
||||
module.exports.VirtualizedTree = VirtualizedTree;
|
||||
|
||||
module.exports.TreeSelection = TreeSelection;
|
||||
module.exports.TreeSelectionStub = TreeSelectionStub;
|
||||
module.exports.renderCell = renderCell;
|
||||
|
|
|
|||
|
|
@ -205,8 +205,11 @@ module.exports = class {
|
|||
* @param {Integer} index
|
||||
* @param {Boolean} forceScrollToTop If true, the row will be scrolled to the top of the scrollbox
|
||||
* even if it is below the current scroll window.
|
||||
* @param {Integer} topOffset Amount of space reserved at the top of the scrollbox (e.g. for a
|
||||
* sticky section header that overlays the rows). When scrolling a row into view from above, the
|
||||
* row is positioned below this offset rather than flush with the top edge.
|
||||
*/
|
||||
scrollToRow(index, forceScrollToTop = false) {
|
||||
scrollToRow(index, forceScrollToTop = false, topOffset = 0) {
|
||||
const { scrollOffset } = this;
|
||||
const itemCount = this._getItemCount();
|
||||
const height = this.getWindowHeight();
|
||||
|
|
@ -214,11 +217,18 @@ module.exports = class {
|
|||
index = Math.max(0, Math.min(index, itemCount - 1));
|
||||
let startPosition = this._getItemPosition(index);
|
||||
let endPosition = this._getItemPosition(index + 1);
|
||||
if (startPosition < scrollOffset) {
|
||||
// If forceScrollToTop is set, always scroll to the start position even if the row is
|
||||
// already visible. This is used when restoring scroll position, where we need an exact
|
||||
// first-visible-row rather than just ensuring the row is within view.
|
||||
if (forceScrollToTop) {
|
||||
this.scrollTo(startPosition);
|
||||
return;
|
||||
}
|
||||
if (startPosition - topOffset < scrollOffset) {
|
||||
this.scrollTo(startPosition - topOffset);
|
||||
}
|
||||
else if (endPosition > scrollOffset + height) {
|
||||
this.scrollTo(forceScrollToTop ? startPosition : endPosition - height - 1);
|
||||
this.scrollTo(endPosition - height - 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,9 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
|
|||
this.searchBoxRef = React.createRef();
|
||||
|
||||
this.displayAllTags = Zotero.Prefs.get('tagSelector.displayAllTags');
|
||||
// Library IDs of the selected collection tree rows, in collections-list order.
|
||||
// Usually one, but a cross-library selection scopes the tag selector to several.
|
||||
this.libraryIDs = [];
|
||||
// Not stored in state to avoid an unnecessary refresh. Instead, when a tag is selected, we
|
||||
// trigger the selection handler, which updates the visible items, which triggers
|
||||
// onItemViewChanged(), which triggers a refresh with the new tags.
|
||||
|
|
@ -101,10 +104,13 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
|
|||
componentDidUpdate(_prevProps, _prevState) {
|
||||
Zotero.debug("Tag selector updated");
|
||||
|
||||
// If we changed collections, scroll to top
|
||||
if (this.collectionTreeRow && this.collectionTreeRow.id != this.prevTreeViewID) {
|
||||
this.tagListRef.current.scrollToTop();
|
||||
this.prevTreeViewID = this.collectionTreeRow.id;
|
||||
// If we changed the set of collections, scroll to top
|
||||
if (this.collectionTreeRows) {
|
||||
let treeViewIDs = this.collectionTreeRows.map(row => row.id).sort();
|
||||
if (!Zotero.Utilities.arrayEquals(treeViewIDs, this.prevTreeViewIDs)) {
|
||||
this.tagListRef.current.scrollToTop();
|
||||
this.prevTreeViewIDs = treeViewIDs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -114,24 +120,107 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely fetch tags from the selected collection tree rows, returning [] on search error.
|
||||
* CollectionTreeRow.getTags() calls getSearchResults() under the hood, which throws
|
||||
* Zotero.CollectionTreeRow.SearchError if the underlying search query fails (e.g., a
|
||||
* saved search with invalid conditions). The tag selector should degrade gracefully in
|
||||
* that case — showing no tags — rather than throwing upwards and breaking the UI.
|
||||
* Real bugs (TypeError, etc.) are re-thrown so they surface in tests and logs.
|
||||
*/
|
||||
async _safeGetTags(...args) {
|
||||
try {
|
||||
let rows = this.collectionTreeRows || [];
|
||||
// A single row uses its own cached temp table directly; multiple rows are
|
||||
// combined into one temp table and query to avoid a per-row query
|
||||
let tags = rows.length == 1
|
||||
? await rows[0].getTags(...args)
|
||||
: await Zotero.CollectionTreeRow.getTagsAcrossRows(rows, ...args);
|
||||
// Multiple rows (collections, or collections across libraries) can return
|
||||
// the same tag, so dedupe by name
|
||||
return this._dedupeTags(tags);
|
||||
}
|
||||
catch (e) {
|
||||
if (e instanceof Zotero.CollectionTreeRow.SearchError) {
|
||||
Zotero.logError(e);
|
||||
return [];
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether more than one library is currently in scope (cross-library selection)
|
||||
*/
|
||||
get multiLibrary() {
|
||||
return this.libraryIDs.length > 1;
|
||||
}
|
||||
|
||||
_dedupeTags(tags) {
|
||||
let seen = new Set();
|
||||
let result = [];
|
||||
for (let tag of tags) {
|
||||
if (!seen.has(tag.tag)) {
|
||||
seen.add(tag.tag);
|
||||
result.push(tag);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tags across the selected libraries (for "Display All Tags"), deduped by name
|
||||
*
|
||||
* @param {Number[]} [tagIDs] - Limit to the given tag IDs
|
||||
*/
|
||||
async _getAllTagsInLibraries(tagIDs) {
|
||||
let results = await Promise.all(
|
||||
this.libraryIDs.map(libraryID => Zotero.Tags.getAllWithin({ libraryID, tagIDs }))
|
||||
);
|
||||
return this._dedupeTags(results.flat());
|
||||
}
|
||||
|
||||
_getSelectedLibraryIDs(collectionTreeRows, fallbackLibraryID) {
|
||||
let ids = [];
|
||||
for (let row of collectionTreeRows || []) {
|
||||
let id = row.ref && row.ref.libraryID;
|
||||
if (id !== undefined && id !== null && !ids.includes(id)) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
if (!ids.length && fallbackLibraryID) {
|
||||
ids.push(fallbackLibraryID);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Colored tags are a per-library synced setting, so a unified cross-library list
|
||||
* can't coherently show them (the same color can mean different tags in different
|
||||
* libraries). Only show colored tags when a single library is in scope. The items
|
||||
* list still shows each item's own library's swatches via Item.getItemsListTags().
|
||||
*/
|
||||
_getScopeColors() {
|
||||
if (this.libraryIDs.length !== 1) {
|
||||
return new Map();
|
||||
}
|
||||
return Zotero.Tags.getColors(this.libraryIDs[0]);
|
||||
}
|
||||
|
||||
// Update trigger #1 (triggered by ZoteroPane)
|
||||
async onItemViewChanged({ collectionTreeRow, libraryID }) {
|
||||
async onItemViewChanged({ collectionTreeRows, libraryID }) {
|
||||
Zotero.debug('Updating tag selector from current view');
|
||||
|
||||
var prevLibraryID = this.libraryID;
|
||||
this.collectionTreeRow = collectionTreeRow;
|
||||
this.libraryID = libraryID;
|
||||
|
||||
|
||||
var prevLibraryIDs = this.libraryIDs;
|
||||
this.collectionTreeRows = collectionTreeRows;
|
||||
this.libraryIDs = this._getSelectedLibraryIDs(collectionTreeRows, libraryID);
|
||||
this.libraryID = this.libraryIDs[0];
|
||||
|
||||
var newState = {
|
||||
loaded: true
|
||||
};
|
||||
if (prevLibraryID != libraryID) {
|
||||
if (libraryID) {
|
||||
newState.tagColors = Zotero.Tags.getColors(libraryID);
|
||||
}
|
||||
else {
|
||||
newState.tagColors = new Map();
|
||||
}
|
||||
if (!Zotero.Utilities.arrayEquals(prevLibraryIDs, this.libraryIDs)) {
|
||||
newState.tagColors = this._getScopeColors();
|
||||
}
|
||||
var { tags, scope } = await this.getTagsAndScope();
|
||||
newState.tags = tags;
|
||||
|
|
@ -145,14 +234,14 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
|
|||
if (ids.some(val => val.split('/')[1] == 'tagColors')) {
|
||||
Zotero.debug("Updating tag selector after tag color change");
|
||||
this.setState({
|
||||
tagColors: Zotero.Tags.getColors(this.libraryID)
|
||||
tagColors: this._getScopeColors()
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore anything other than deletes in duplicates view
|
||||
if (this.collectionTreeRow && this.collectionTreeRow.isDuplicates()) {
|
||||
if (this.collectionTreeRows?.[0]?.isDuplicates()) {
|
||||
switch (event) {
|
||||
case 'delete':
|
||||
case 'trash':
|
||||
|
|
@ -192,11 +281,9 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
|
|||
}
|
||||
// Check tags for each tag type to see if they're in view/scope
|
||||
for (let [type, tagIDs] of tagsByType) {
|
||||
changedTagsInScope.push(...await this.collectionTreeRow.getTags([type], tagIDs));
|
||||
changedTagsInScope.push(...await this._safeGetTags([type], tagIDs));
|
||||
if (this.displayAllTags) {
|
||||
changedTagsInView.push(
|
||||
...await Zotero.Tags.getAllWithin({ libraryID: this.libraryID, tagIDs })
|
||||
);
|
||||
changedTagsInView.push(...await this._getAllTagsInLibraries(tagIDs));
|
||||
}
|
||||
}
|
||||
if (!this.displayAllTags) {
|
||||
|
|
@ -315,11 +402,11 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
|
|||
}
|
||||
|
||||
async getTagsAndScope() {
|
||||
var tags = await this.collectionTreeRow.getTags();
|
||||
var tags = await this._safeGetTags();
|
||||
// The scope is all visible tags, not all tags in the library
|
||||
var scope = new Set(tags.map(t => t.tag));
|
||||
if (this.displayAllTags) {
|
||||
tags = await Zotero.Tags.getAll(this.libraryID);
|
||||
tags = await this._getAllTagsInLibraries();
|
||||
}
|
||||
|
||||
// If tags haven't changed, return previous array without sorting again
|
||||
|
|
@ -497,10 +584,10 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
|
|||
});
|
||||
tags = coloredTags.concat(tags);
|
||||
|
||||
// Filter
|
||||
// Filter (case- and accent-insensitive, matching search conditions)
|
||||
if (this.state.searchString) {
|
||||
let lcStr = this.state.searchString.toLowerCase();
|
||||
tags = tags.filter(tag => tag.tag.toLowerCase().includes(lcStr));
|
||||
let normalizedStr = Zotero.Utilities.Internal.normalizeForSearch(this.state.searchString);
|
||||
tags = tags.filter(tag => Zotero.Utilities.Internal.normalizeForSearch(tag.tag).includes(normalizedStr));
|
||||
}
|
||||
|
||||
// Prepare tag objects for list component
|
||||
|
|
@ -563,6 +650,14 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
|
|||
for (let i = 0; i < tagContextMenu.childNodes.length; i++) {
|
||||
tagContextMenu.childNodes[i].disabled = this.state.viewOnly;
|
||||
}
|
||||
// In a cross-library selection, disable per-library actions that would only
|
||||
// affect one of the selected libraries. Deleting still works -- it removes the
|
||||
// tag from all selected libraries (see openDeletePrompt())
|
||||
if (this.multiLibrary) {
|
||||
for (let id of ['assign-color-tag', 'rename-tag', 'split-tag']) {
|
||||
document.getElementById(id).disabled = true;
|
||||
}
|
||||
}
|
||||
ev.preventDefault();
|
||||
|
||||
tagContextMenu.openPopupAtScreen(
|
||||
|
|
@ -644,7 +739,11 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
|
|||
ids = ids.split(',');
|
||||
var items = Zotero.Items.get(ids);
|
||||
var value = elem.textContent;
|
||||
|
||||
|
||||
Zotero.UndoHistory.stageAction(
|
||||
remove ? 'undo-action-remove-tag' : 'undo-action-add-tag',
|
||||
{ count: items.length }
|
||||
);
|
||||
for (let i=0; i<items.length; i++) {
|
||||
let item = items[i];
|
||||
if (remove) {
|
||||
|
|
@ -720,6 +819,7 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
|
|||
if (dataOut.result.op === 'split') {
|
||||
const itemIDs = await Zotero.Tags.getTagItems(this.libraryID, oldTagID);
|
||||
await Zotero.DB.executeTransaction(async () => {
|
||||
Zotero.UndoHistory.stageAction('undo-action-split-tag');
|
||||
for (const itemID of itemIDs) {
|
||||
const item = await Zotero.Items.getAsync(itemID);
|
||||
const tagType = item.getTagType(oldTagName);
|
||||
|
|
@ -783,12 +883,16 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
|
|||
|
||||
var tagID = Zotero.Tags.getID(this.contextTag.name);
|
||||
|
||||
if (tagID) {
|
||||
await Zotero.Tags.removeFromLibrary(this.libraryID, tagID);
|
||||
}
|
||||
// If only a tag color setting, remove that
|
||||
else {
|
||||
await Zotero.Tags.setColor(this.libraryID, this.contextTag.name, false);
|
||||
// Delete from every selected library. removeFromLibrary() only clears the color
|
||||
// for tags that have items in the library, so also clear any remaining color-only
|
||||
// setting (e.g. a tag that's a real tag in one library but only colored in another)
|
||||
for (let libraryID of this.libraryIDs) {
|
||||
if (tagID) {
|
||||
await Zotero.Tags.removeFromLibrary(libraryID, tagID);
|
||||
}
|
||||
if (Zotero.Tags.getColors(libraryID).has(this.contextTag.name)) {
|
||||
await Zotero.Tags.setColor(libraryID, this.contextTag.name, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ function getContextMenuPosition(targetElement) {
|
|||
*/
|
||||
export function createContextMenuEvent(targetElement) {
|
||||
let { clientX, clientY } = getContextMenuPosition(targetElement);
|
||||
let win = targetElement.ownerGlobal;
|
||||
let win = targetElement.documentGlobal;
|
||||
let screenX = win.mozInnerScreenX + clientX;
|
||||
let screenY = win.mozInnerScreenY + clientY;
|
||||
// Need to use initNSMouseEvent() to set inputSource, so just construct a
|
||||
|
|
|
|||
|
|
@ -53,8 +53,8 @@ var ZoteroContextPane = new function () {
|
|||
return this.splitter.getAttribute('state') === 'collapsed';
|
||||
},
|
||||
set: (collapsed) => {
|
||||
_contextPane.setAttribute('collapsed', !!collapsed);
|
||||
_contextPaneInner.setAttribute('collapsed', !!collapsed);
|
||||
_contextPane.toggleAttribute('collapsed', !!collapsed);
|
||||
_contextPaneInner.toggleAttribute('collapsed', !!collapsed);
|
||||
_contextPaneSplitter.setAttribute('state', collapsed ? 'collapsed' : 'open');
|
||||
_contextPaneSplitterStacked.setAttribute('state', collapsed ? 'collapsed' : 'open');
|
||||
this.update();
|
||||
|
|
@ -141,7 +141,7 @@ var ZoteroContextPane = new function () {
|
|||
return;
|
||||
}
|
||||
if (_isStacked()) {
|
||||
_contextPaneSplitterStacked.setAttribute('hidden', false);
|
||||
_contextPaneSplitterStacked.removeAttribute('hidden');
|
||||
_contextPaneSplitter.setAttribute('hidden', true);
|
||||
_contextPane.classList.add('stacked');
|
||||
_contextPane.classList.remove('standard');
|
||||
|
|
@ -159,7 +159,7 @@ var ZoteroContextPane = new function () {
|
|||
_contextPaneSplitter.setAttribute('state', this.collapsed ? 'collapsed' : 'open');
|
||||
}
|
||||
else {
|
||||
_contextPaneSplitter.setAttribute('hidden', false);
|
||||
_contextPaneSplitter.removeAttribute('hidden');
|
||||
_contextPaneSplitterStacked.setAttribute('hidden', true);
|
||||
_contextPane.classList.add('standard');
|
||||
_contextPane.classList.remove('stacked');
|
||||
|
|
@ -180,12 +180,12 @@ var ZoteroContextPane = new function () {
|
|||
var height = null;
|
||||
if (_isStacked()) {
|
||||
height = 0;
|
||||
if (_contextPane.getAttribute('collapsed') != 'true') {
|
||||
if (!_contextPane.hasAttribute('collapsed')) {
|
||||
height = _contextPaneInner.getBoundingClientRect().height;
|
||||
}
|
||||
}
|
||||
|
||||
_contextPaneInner.setAttribute('collapsed', this.collapsed ? 'true' : 'false');
|
||||
_contextPaneInner.toggleAttribute('collapsed', this.collapsed);
|
||||
|
||||
let tabContent = _getTabContent();
|
||||
if (tabContent) {
|
||||
|
|
|
|||
|
|
@ -28,12 +28,13 @@
|
|||
Services.scriptloader.loadSubScript("chrome://zotero/content/include.js", this);
|
||||
Services.scriptloader.loadSubScript("chrome://global/content/customElements.js", this);
|
||||
Services.scriptloader.loadSubScript("chrome://zotero/content/elements/base.js", this);
|
||||
Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemPaneSection.js', this);
|
||||
Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemTreeMenuBar.js', this);
|
||||
|
||||
{
|
||||
// https://searchfox.org/mozilla-central/rev/8e885f04a0a4ff6d64ea59741c10d9b8e45d9ff8/toolkit/content/customElements.js#826-832
|
||||
for (let [tag, script] of [
|
||||
['advanced-search-deck', 'chrome://zotero/content/elements/advancedSearchDeck.js'],
|
||||
['advanced-search-pane', 'chrome://zotero/content/elements/advancedSearchPane.js'],
|
||||
['attachment-box', 'chrome://zotero/content/elements/attachmentBox.js'],
|
||||
['attachment-preview', 'chrome://zotero/content/elements/attachmentPreview.js'],
|
||||
['attachment-preview-box', 'chrome://zotero/content/elements/attachmentPreviewBox.js'],
|
||||
|
|
@ -50,8 +51,10 @@ Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemTreeMe
|
|||
['note-box', 'chrome://zotero/content/elements/noteBox.js'],
|
||||
['note-editor', 'chrome://zotero/content/elements/noteEditor.js'],
|
||||
['notes-box', 'chrome://zotero/content/elements/notesBox.js'],
|
||||
['query-textbox', 'chrome://zotero/content/elements/queryTextbox.js'],
|
||||
['quick-search-textbox', 'chrome://zotero/content/elements/quickSearchTextbox.js'],
|
||||
['related-box', 'chrome://zotero/content/elements/relatedBox.js'],
|
||||
['search-textbox', 'chrome://zotero/content/elements/searchTextbox.js'],
|
||||
['shadow-autocomplete-input', 'chrome://zotero/content/elements/shadowAutocompleteInput.js'],
|
||||
['split-menu-button', 'chrome://zotero/content/elements/splitMenuButton.js'],
|
||||
['tabs-menu-panel', 'chrome://zotero/content/elements/tabsMenuPanel.js'],
|
||||
|
|
@ -62,6 +65,7 @@ Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemTreeMe
|
|||
['zoterosearchcondition', 'chrome://zotero/content/elements/zoteroSearch.js'],
|
||||
['zoterosearchtextbox', 'chrome://zotero/content/elements/zoteroSearch.js'],
|
||||
['zoterosearchagefield', 'chrome://zotero/content/elements/zoteroSearch.js'],
|
||||
['item-pane-custom-section', 'chrome://zotero/content/elements/itemPaneCustomSection.js'],
|
||||
['item-pane-header', 'chrome://zotero/content/elements/itemPaneHeader.js'],
|
||||
['editable-text', 'chrome://zotero/content/elements/editableText.js'],
|
||||
['item-pane-sidenav', 'chrome://zotero/content/elements/itemPaneSidenav.js'],
|
||||
|
|
@ -78,6 +82,7 @@ Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemTreeMe
|
|||
['libraries-collections-box', 'chrome://zotero/content/elements/librariesCollectionsBox.js'],
|
||||
['autocomplete-textarea', 'chrome://zotero/content/elements/autocompleteTextArea.js'],
|
||||
['bubble-input', 'chrome://zotero/content/elements/bubbleInput.js'],
|
||||
['file-renaming-settings', 'chrome://zotero/content/elements/fileRenamingSettings.js']
|
||||
]) {
|
||||
customElements.setElementCreationCallback(tag, () => {
|
||||
Services.scriptloader.loadSubScript(script, window);
|
||||
|
|
@ -335,6 +340,89 @@ Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemTreeMe
|
|||
delete document.createXULElement("menulist");
|
||||
}
|
||||
|
||||
|
||||
// Space on menulist should open the popup
|
||||
// Space on menulist option will select it, same as Enter
|
||||
document.addEventListener("keydown", async (event) => {
|
||||
let target = event.originalTarget;
|
||||
if (target.tagName !== "menulist") return;
|
||||
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return;
|
||||
if (event.key !== " ") return;
|
||||
|
||||
if (target.open) {
|
||||
// Simulate blinking of the selected menuitem on macOS (same as on Return keypress)
|
||||
if (Zotero.isMac) {
|
||||
let interval = 70;
|
||||
target.activeChild.removeAttribute("_moz-menuactive");
|
||||
await Zotero.Promise.delay(interval);
|
||||
target.activeChild.setAttribute("_moz-menuactive", true);
|
||||
await Zotero.Promise.delay(interval);
|
||||
target.activeChild.removeAttribute("_moz-menuactive");
|
||||
await Zotero.Promise.delay(interval);
|
||||
target.activeChild.doCommand();
|
||||
target.addEventListener("popuphiding", () => {
|
||||
target.selectedItem.setAttribute("selected", true);
|
||||
}, { once: true });
|
||||
target.open = false;
|
||||
}
|
||||
// No blinking happens on Linux or Windows
|
||||
else {
|
||||
target.activeChild.doCommand();
|
||||
// Timeout to avoid empty context menu behind an alert or dialog if one
|
||||
// appears on 'command' event on Windows (https://github.com/zotero/zotero/issues/5633)
|
||||
setTimeout(() => {
|
||||
target.open = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
else {
|
||||
target.open = true;
|
||||
}
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}, true);
|
||||
|
||||
if (Zotero.isWin) {
|
||||
// ArrowUp/ArrowDown should change the active menuitem without triggering the command event.
|
||||
// Otherwise navigating menulist options via keyboard triggers alerts meant to fire after
|
||||
// a confirmed selection (e.g. language change alert in preferences). Only relevant to Windows.
|
||||
document.addEventListener("keydown", (event) => {
|
||||
let target = event.originalTarget;
|
||||
if (target.tagName !== "menulist") return;
|
||||
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return;
|
||||
if (!(["ArrowUp", "ArrowDown"].includes(event.key) && target.open)) return;
|
||||
|
||||
// Locate the deepest open menu
|
||||
let currentMenu = target;
|
||||
while (currentMenu.activeChild?.tagName === "menu" && currentMenu.activeChild.open) {
|
||||
currentMenu = currentMenu.activeChild;
|
||||
}
|
||||
|
||||
// Determine which menu item should be marked as selected
|
||||
let nextItem;
|
||||
// If the menu has no active child, arrowDown will select the first item
|
||||
if (!currentMenu.activeChild) {
|
||||
if (event.key !== "ArrowDown") return;
|
||||
nextItem = currentMenu.querySelector("menuitem,menu");
|
||||
}
|
||||
// If there is an active child, arrowUp/Down will move to the previous/next item
|
||||
else {
|
||||
let node = currentMenu.activeChild;
|
||||
do {
|
||||
node = event.key === "ArrowUp" ? node.previousElementSibling : node.nextElementSibling;
|
||||
} while (node && !["menu", "menuitem"].includes(node.tagName));
|
||||
nextItem = node;
|
||||
}
|
||||
|
||||
// Set the item as active to highlight it
|
||||
if (nextItem) {
|
||||
currentMenu.activeChild = nextItem;
|
||||
}
|
||||
// Prevent the default event handling which fires the 'command' event
|
||||
event.stopImmediatePropagation();
|
||||
}, true);
|
||||
}
|
||||
|
||||
// inject custom CSS into FF built-in custom elements
|
||||
const InjectCSSConfig = {
|
||||
global: [
|
||||
|
|
@ -354,7 +442,6 @@ Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemTreeMe
|
|||
}
|
||||
],
|
||||
mac: [
|
||||
"wizard",
|
||||
{
|
||||
element: "dialog",
|
||||
// The `attachShadow` are cleared in <dialog>, we need to monkey-patch after `connectedCallback`.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
/* eslint-disable no-extend-native */
|
||||
|
||||
// Patches Date.prototype.toLocale*String in whichever global this script is
|
||||
// loaded into, so that date formatting follows the app locale when no
|
||||
// explicit locale is passed
|
||||
|
||||
let originalToLocaleString = Date.prototype.toLocaleString;
|
||||
Date.prototype.toLocaleString = function (locales, options) {
|
||||
if (locales === undefined || (Array.isArray(locales) && !locales.length)) {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@
|
|||
"use strict";
|
||||
|
||||
{
|
||||
const { ItemPaneSectionElementBase } = ChromeUtils.importESModule(
|
||||
"chrome://zotero/content/elements/itemPaneSectionElementBase.mjs",
|
||||
{ global: "current" }
|
||||
);
|
||||
ChromeUtils.importESModule("chrome://zotero/content/actors/ActorManager.mjs");
|
||||
|
||||
const SANDBOX_ALL_FLAGS = 0xFFFFF;
|
||||
|
|
@ -39,6 +43,10 @@
|
|||
</collapsible-section>
|
||||
`);
|
||||
|
||||
_mode = 'view';
|
||||
|
||||
_fieldAlternatives = {};
|
||||
|
||||
get item() {
|
||||
return this._item;
|
||||
}
|
||||
|
|
@ -66,6 +74,36 @@
|
|||
super.editable = editable;
|
||||
}
|
||||
|
||||
get mode() {
|
||||
return this._mode;
|
||||
}
|
||||
|
||||
set mode(val) {
|
||||
switch (val) {
|
||||
case 'view':
|
||||
case 'edit':
|
||||
case 'merge':
|
||||
break;
|
||||
case 'fieldmerge':
|
||||
this._fieldAlternatives = {};
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Invalid mode '${val}'`);
|
||||
}
|
||||
this._mode = val;
|
||||
this.setAttribute('mode', val);
|
||||
}
|
||||
|
||||
set fieldAlternatives(val) {
|
||||
if (val.constructor.name != 'Object') {
|
||||
throw Error('fieldAlternatives must be an Object in <abstract-box>.fieldAlternatives');
|
||||
}
|
||||
if (this._mode != 'fieldmerge') {
|
||||
throw Error('fieldAlternatives is valid only in fieldmerge mode in <abstract-box>.fieldAlternatives');
|
||||
}
|
||||
this._fieldAlternatives = val;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._notifierID = Zotero.Notifier.registerObserver(this, ['item', 'tab'], 'abstractBox');
|
||||
|
||||
|
|
@ -100,7 +138,13 @@
|
|||
throw new Error('Item has not been added to library');
|
||||
}
|
||||
this._item.setField('abstractNote', this._abstractField.value);
|
||||
await this._item.saveTx();
|
||||
await this._item.saveTx({
|
||||
undoAction: 'undo-action-edit-field',
|
||||
undoActionArgs: {
|
||||
field: Zotero.ItemFields.getLocalizedString('abstractNote'),
|
||||
count: 1
|
||||
}
|
||||
});
|
||||
}
|
||||
this._forceRenderAll();
|
||||
}
|
||||
|
|
@ -157,6 +201,59 @@
|
|||
}
|
||||
this._abstractField.readOnly = !this.editable;
|
||||
this._abstractField.setAttribute('aria-label', Zotero.ItemFields.getLocalizedString('abstractNote'));
|
||||
|
||||
this._renderFieldVersionButton();
|
||||
}
|
||||
|
||||
_renderFieldVersionButton() {
|
||||
let existing = this.querySelector('.zotero-field-version-button');
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
}
|
||||
|
||||
if (this._mode !== 'fieldmerge') {
|
||||
return;
|
||||
}
|
||||
|
||||
let alternatives = this._fieldAlternatives?.abstractNote;
|
||||
if (!alternatives || !alternatives.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
let button = document.createXULElement('toolbarbutton');
|
||||
button.className = 'zotero-field-version-button zotero-clicky-merge';
|
||||
document.l10n.setAttributes(button, 'itembox-button-merge', {
|
||||
field: Zotero.ItemFields.getLocalizedString('abstractNote') || ''
|
||||
});
|
||||
|
||||
let popup = button.appendChild(document.createXULElement('menupopup'));
|
||||
for (let v of alternatives) {
|
||||
let menuitem = document.createXULElement('menuitem');
|
||||
let sv = Zotero.Utilities.ellipsize(v, 60);
|
||||
menuitem.setAttribute('label', sv);
|
||||
if (v != sv) {
|
||||
menuitem.setAttribute('tooltiptext', v);
|
||||
}
|
||||
menuitem.setAttribute('originalValue', v);
|
||||
menuitem.addEventListener('command', () => {
|
||||
this.item.setField('abstractNote', menuitem.getAttribute('originalValue'));
|
||||
this._forceRenderAll();
|
||||
});
|
||||
popup.appendChild(menuitem);
|
||||
}
|
||||
|
||||
button.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.screenX) {
|
||||
popup.openPopupAtScreen(event.screenX - 5, event.screenY + 5, true);
|
||||
}
|
||||
else {
|
||||
popup.openPopup(button, 'after_start');
|
||||
}
|
||||
});
|
||||
|
||||
this.querySelector('.body').appendChild(button);
|
||||
}
|
||||
|
||||
_ensureFeedAbstractBrowserExists = Zotero.Utilities.Internal.serial(async () => {
|
||||
|
|
|
|||
83
chrome/content/zotero/elements/advancedSearchDeck.js
Normal file
83
chrome/content/zotero/elements/advancedSearchDeck.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
***** BEGIN LICENSE BLOCK *****
|
||||
|
||||
Copyright © 2025 Corporation for Digital Scholarship
|
||||
Vienna, Virginia, USA
|
||||
https://www.zotero.org
|
||||
|
||||
This file is part of Zotero.
|
||||
|
||||
Zotero is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Zotero is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
***** END LICENSE BLOCK *****
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
{
|
||||
class AdvancedSearchDeck extends XULElementBase {
|
||||
content = MozXULElement.parseXULToFragment(`
|
||||
<deck>
|
||||
<advanced-search-pane id="zotero-temporary-advanced-search-pane" type="temporary"/>
|
||||
<advanced-search-pane id="zotero-saved-advanced-search-pane" type="saved"/>
|
||||
</deck>
|
||||
`);
|
||||
|
||||
init() {
|
||||
this.hidden = true;
|
||||
this._state = 'closed';
|
||||
|
||||
this.deck = this.firstChild;
|
||||
}
|
||||
|
||||
get state() {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
set state(state) {
|
||||
switch (state) {
|
||||
case 'open':
|
||||
this.hidden = false;
|
||||
break;
|
||||
case 'collapsed':
|
||||
case 'closed':
|
||||
this.hidden = true;
|
||||
break;
|
||||
default:
|
||||
throw new Error('Invalid state: ' + state);
|
||||
}
|
||||
this._state = state;
|
||||
}
|
||||
|
||||
get selectedSearchType() {
|
||||
return this.deck.selectedIndex === 0 ? 'temporary' : 'saved';
|
||||
}
|
||||
|
||||
set selectedSearchType(selectedSearchType) {
|
||||
switch (selectedSearchType) {
|
||||
case 'temporary':
|
||||
this.deck.selectedIndex = 0;
|
||||
break;
|
||||
case 'saved':
|
||||
this.deck.selectedIndex = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
get pane() {
|
||||
return this.deck.selectedPanel;
|
||||
}
|
||||
}
|
||||
customElements.define("advanced-search-deck", AdvancedSearchDeck);
|
||||
}
|
||||
407
chrome/content/zotero/elements/advancedSearchPane.js
Normal file
407
chrome/content/zotero/elements/advancedSearchPane.js
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
/*
|
||||
***** BEGIN LICENSE BLOCK *****
|
||||
|
||||
Copyright © 2025 Corporation for Digital Scholarship
|
||||
Vienna, Virginia, USA
|
||||
https://www.zotero.org
|
||||
|
||||
This file is part of Zotero.
|
||||
|
||||
Zotero is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Zotero is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
***** END LICENSE BLOCK *****
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
{
|
||||
class AdvancedSearchPane extends XULElementBase {
|
||||
content = MozXULElement.parseXULToFragment(`
|
||||
<hbox class="saved-search-name-row">
|
||||
<label control="saved-search-name" data-l10n-id="new-collection-name"/>
|
||||
<html:input type="text" id="saved-search-name"/>
|
||||
</hbox>
|
||||
<zoterosearch/>
|
||||
<hbox class="advanced-search-buttons">
|
||||
<button class="cancel-button" data-l10n-id="cancel-button"/>
|
||||
<button class="search-button" data-l10n-id="search-button" default="true"/>
|
||||
<button class="clear-button" data-l10n-id="clear-button"/>
|
||||
<button class="save-button" data-l10n-id="save-search-new-button"/>
|
||||
</hbox>
|
||||
`);
|
||||
|
||||
_active = false;
|
||||
|
||||
init() {
|
||||
this._nameField = this.querySelector('#saved-search-name');
|
||||
this._searchElem = this.querySelector('zoterosearch');
|
||||
this._cancelButton = this.querySelector('.cancel-button');
|
||||
this._searchButton = this.querySelector('.search-button');
|
||||
this._clearButton = this.querySelector('.clear-button');
|
||||
this._saveButton = this.querySelector('.save-button');
|
||||
|
||||
this._searchElem.addEventListener('input', () => this._searchElem.updateSearch());
|
||||
this._searchElem.addEventListener('command', () => this._searchElem.updateSearch());
|
||||
|
||||
this._cancelButton.addEventListener('command', () => this.cancel());
|
||||
this._searchButton.addEventListener('command', () => this.submit());
|
||||
this._clearButton.addEventListener('command', () => this.clear());
|
||||
this._saveButton.addEventListener('command', () => this.save());
|
||||
|
||||
if (!['temporary', 'saved'].includes(this.type)) {
|
||||
throw new Error(`Invalid type: ${this.type}`);
|
||||
}
|
||||
|
||||
if (this.type === 'saved') {
|
||||
this._saveButton.setAttribute('default', 'true');
|
||||
// Editing an existing search saves changes directly, so just "Save"
|
||||
// (vs. "Save Search…" for creating a new one, which prompts for a name)
|
||||
this._saveButton.setAttribute('data-l10n-id', 'save-search-edit-button');
|
||||
}
|
||||
|
||||
this.addEventListener('keydown', this._handleKeyDown);
|
||||
}
|
||||
|
||||
_handleKeyDown = async (event) => {
|
||||
this._searchElem.updateSearch();
|
||||
// Shift-Enter adds a new condition (handled by the search element), so
|
||||
// don't run/save the search for it
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
// Enter on a focused button runs that button (which the platform only does
|
||||
// for Space), not the pane's default action
|
||||
let button = event.target.closest && event.target.closest('button, toolbarbutton');
|
||||
if (button) {
|
||||
button.click();
|
||||
return;
|
||||
}
|
||||
if (this.type === 'temporary') {
|
||||
await this.submit();
|
||||
}
|
||||
else {
|
||||
await this.save();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns {'temporary' | 'saved'}
|
||||
*/
|
||||
get type() {
|
||||
return this.getAttribute('type');
|
||||
}
|
||||
|
||||
get search() {
|
||||
return this._search;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ID of the saved search being edited, for type "saved"
|
||||
*/
|
||||
get editedSearchID() {
|
||||
return this._searchID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the search has been submitted and should filter the items list
|
||||
*/
|
||||
get active() {
|
||||
return this._active;
|
||||
}
|
||||
|
||||
set search(search) {
|
||||
this._active = false;
|
||||
if (this.type === 'saved') {
|
||||
if (!search?.id) {
|
||||
throw new Error('Cannot edit unsaved search');
|
||||
}
|
||||
this._searchID = search.id;
|
||||
this._search = search.clone();
|
||||
this._nameField.value = search.name;
|
||||
}
|
||||
else if (search) {
|
||||
this._search = search.clone();
|
||||
}
|
||||
else {
|
||||
this._search = new Zotero.Search();
|
||||
// Default a fresh search to top-level items, so a condition on a child
|
||||
// (e.g. attachment content) maps up to its item without any grouping
|
||||
this._search.addCondition('resultLevel', 'item');
|
||||
this._search.addCondition('title', 'contains', '');
|
||||
}
|
||||
this._searchElem.search = this._search;
|
||||
this._loadedState = this.type === 'saved' ? this._getState() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's current name and conditions, for comparison against the state the
|
||||
* search was loaded with
|
||||
*
|
||||
* The search is serialized through the editor, since rendering an existing search
|
||||
* can normalize it (e.g., folding a legacy 'noChildren' condition into the result
|
||||
* level), and the normalized form is what a save would write.
|
||||
*/
|
||||
_getState() {
|
||||
this._searchElem.updateSearch();
|
||||
return {
|
||||
name: this._nameField.value,
|
||||
conditions: this._search.toJSON().conditions
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the name or conditions have been edited since the search was loaded,
|
||||
* for type "saved"
|
||||
*/
|
||||
get hasChanges() {
|
||||
if (this.type !== 'saved') {
|
||||
throw new Error('hasChanges is unsupported for temporary search');
|
||||
}
|
||||
if (!this._loadedState) {
|
||||
return false;
|
||||
}
|
||||
let state = this._getState();
|
||||
return state.name !== this._loadedState.name
|
||||
|| state.conditions.length !== this._loadedState.conditions.length
|
||||
|| state.conditions.some((condition, i) => !Zotero.Searches.conditionEquals(
|
||||
condition, this._loadedState.conditions[i]
|
||||
));
|
||||
}
|
||||
|
||||
_ensureSearch() {
|
||||
if (!this._search && this.type === 'temporary') {
|
||||
this.search = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Allow saving at an editable library or group root (but not a feed) or with
|
||||
// collections and/or saved searches selected within a single editable library,
|
||||
// in which case the selection is added to the saved search as scope conditions
|
||||
// (see _addScopeConditions())
|
||||
_canSaveInSelection() {
|
||||
let collectionTreeRows = ZoteroPane.getCollectionTreeRows();
|
||||
if (!collectionTreeRows.length) {
|
||||
return false;
|
||||
}
|
||||
let libraryID = collectionTreeRows[0].ref?.libraryID;
|
||||
return collectionTreeRows.every((row) => {
|
||||
if (row.ref?.libraryID !== libraryID || !row.editable) {
|
||||
return false;
|
||||
}
|
||||
return (row.isLibrary(true) && !row.isFeed())
|
||||
|| row.isCollection()
|
||||
|| row.isSearch();
|
||||
});
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this._ensureSearch();
|
||||
let libraryID = ZoteroPane.getSelectedLibraryIDs()[0];
|
||||
// Keep the previous library when the selected row doesn't have one (e.g., Feeds)
|
||||
if (libraryID) {
|
||||
this._search.libraryID = libraryID;
|
||||
}
|
||||
// Set the libraries the search applies to: a temporary search spans all
|
||||
// selected libraries, a saved search just its own. Used to scope value
|
||||
// autocomplete and, when more than one library is involved, to drop the
|
||||
// Collection/Saved Search condition, which can only resolve within one library.
|
||||
if (this.type === 'temporary') {
|
||||
let libraryIDs = [];
|
||||
for (let row of ZoteroPane.getCollectionTreeRows()) {
|
||||
let id = row.ref && row.ref.libraryID;
|
||||
if (id !== undefined && id !== null && !libraryIDs.includes(id)) {
|
||||
libraryIDs.push(id);
|
||||
}
|
||||
}
|
||||
this._searchElem.scopeLibraryIDs = libraryIDs;
|
||||
}
|
||||
else {
|
||||
this._searchElem.scopeLibraryIDs = [this._search.libraryID];
|
||||
}
|
||||
this._searchElem.search = this._search;
|
||||
this._saveButton.disabled = this.type === 'temporary' && !this._canSaveInSelection();
|
||||
}
|
||||
|
||||
async cancel() {
|
||||
await ZoteroPane.setSavedSearchEditorState('closed');
|
||||
}
|
||||
|
||||
async submit() {
|
||||
if (this.type === 'saved') {
|
||||
throw new Error('submit() is unsupported for saved search');
|
||||
}
|
||||
|
||||
this._searchElem.updateSearch();
|
||||
this._active = true;
|
||||
await ZoteroPane.itemsView.setFilter('advanced-search', this._search);
|
||||
}
|
||||
|
||||
async clear() {
|
||||
if (this.type === 'saved') {
|
||||
throw new Error('clear() is unsupported for saved search');
|
||||
}
|
||||
|
||||
this.search = null;
|
||||
await ZoteroPane.itemsView.setFilter('advanced-search', null);
|
||||
}
|
||||
|
||||
async save() {
|
||||
this._searchElem.updateSearch();
|
||||
|
||||
if (this.type === 'saved') {
|
||||
let search = Zotero.Searches.get(this._searchID);
|
||||
if (!search) {
|
||||
throw new Error('Missing search');
|
||||
}
|
||||
search.fromJSON(this._search.toJSON());
|
||||
search.name = this._nameField.value;
|
||||
await search.saveTx();
|
||||
await ZoteroPane.setSavedSearchEditorState('closed');
|
||||
Zotero_Tabs.rename('zotero-pane', search.name);
|
||||
return;
|
||||
}
|
||||
|
||||
let collectionTreeRows = ZoteroPane.getCollectionTreeRows();
|
||||
if (!this._canSaveInSelection()) {
|
||||
throw new Error('Can only save in an editable library, collection, or saved search');
|
||||
}
|
||||
this._ensureSearch();
|
||||
|
||||
let libraryID = collectionTreeRows[0].ref.libraryID;
|
||||
let searches = await Zotero.Searches.getAll(libraryID);
|
||||
let prefix = Zotero.getString('pane.collections.untitled');
|
||||
let defaultName = Zotero.Utilities.Internal.getNextName(
|
||||
prefix,
|
||||
searches.map(s => s.name).filter(n => n.startsWith(prefix))
|
||||
);
|
||||
|
||||
// Prompt for a name, defaulting to the next "Untitled" name
|
||||
let [title, message] = await document.l10n.formatValues([
|
||||
'save-search-name-title',
|
||||
'save-search-name-message'
|
||||
]);
|
||||
let nameObj = { value: defaultName };
|
||||
if (!Services.prompt.prompt(window, title, message, nameObj, null, {})) {
|
||||
return;
|
||||
}
|
||||
let name = nameObj.value.trim() || defaultName;
|
||||
|
||||
let search = this._search.clone(libraryID);
|
||||
search.name = name;
|
||||
// If saving within collections or saved searches rather than at the library
|
||||
// root, scope the search to the selection
|
||||
if (!collectionTreeRows.some(row => row.isLibrary(true))) {
|
||||
this._addScopeConditions(search, collectionTreeRows);
|
||||
}
|
||||
await search.saveTx();
|
||||
|
||||
await ZoteroPane.setAdvancedSearchState('closed');
|
||||
}
|
||||
|
||||
// Scope a search to the given collection/saved search rows by adding a top-level
|
||||
// collection/savedSearch condition for each -- an 'any' group of them when more
|
||||
// than one row is selected. If the search's own join mode is 'any', its existing
|
||||
// conditions move into an 'any' group of their own so the scope conditions apply
|
||||
// to every result.
|
||||
_addScopeConditions(search, collectionTreeRows) {
|
||||
let conditions = Object.values(search.getConditions());
|
||||
|
||||
// Split the existing conditions into top-level markers/flags, which are
|
||||
// position-independent and stay at the top level, and the rest, which may get
|
||||
// wrapped in a group below. The search always comes from updateSearch(), so
|
||||
// these are the only markers/flags that can appear at the top level.
|
||||
const FLAGS = ['resultLevel', 'recursive', 'includeParentsAndChildren'];
|
||||
let joinMode = 'all';
|
||||
let flags = [];
|
||||
let rest = [];
|
||||
let depth = 0;
|
||||
// Number of top-level conditions and groups -- a single one doesn't need wrapping
|
||||
let units = 0;
|
||||
for (let condition of conditions) {
|
||||
if (condition.condition == 'groupStart') {
|
||||
if (!depth) {
|
||||
units++;
|
||||
}
|
||||
depth++;
|
||||
rest.push(condition);
|
||||
}
|
||||
else if (condition.condition == 'groupEnd') {
|
||||
depth--;
|
||||
rest.push(condition);
|
||||
}
|
||||
else if (!depth && condition.condition == 'joinMode') {
|
||||
joinMode = condition.operator;
|
||||
}
|
||||
else if (!depth && FLAGS.includes(condition.condition)) {
|
||||
flags.push(condition);
|
||||
}
|
||||
else {
|
||||
if (!depth) {
|
||||
units++;
|
||||
}
|
||||
rest.push(condition);
|
||||
}
|
||||
}
|
||||
|
||||
let scope = collectionTreeRows.map(row => ({
|
||||
condition: row.isCollection() ? 'collection' : 'savedSearch',
|
||||
operator: 'is',
|
||||
value: row.ref.key
|
||||
}));
|
||||
// Search subcollections when the collections view does
|
||||
if (Zotero.Prefs.get('recursiveCollections')
|
||||
&& scope.some(c => c.condition == 'collection')
|
||||
&& !flags.some(c => c.condition == 'recursive')) {
|
||||
flags.push({ condition: 'recursive', operator: 'true', value: null });
|
||||
}
|
||||
if (scope.length > 1) {
|
||||
scope = [
|
||||
{ condition: 'groupStart', operator: 'true', value: '' },
|
||||
{ condition: 'joinMode', operator: 'any', value: null },
|
||||
...scope,
|
||||
{ condition: 'groupEnd', operator: 'true', value: '' }
|
||||
];
|
||||
}
|
||||
if (joinMode == 'any' && units > 1) {
|
||||
rest = [
|
||||
{ condition: 'groupStart', operator: 'true', value: '' },
|
||||
{ condition: 'joinMode', operator: 'any', value: null },
|
||||
...rest,
|
||||
{ condition: 'groupEnd', operator: 'true', value: '' }
|
||||
];
|
||||
}
|
||||
|
||||
// Rebuild the search's conditions in the new order. removeCondition()
|
||||
// renumbers the remaining conditions, so 0 is always the next one to remove.
|
||||
let count = conditions.length;
|
||||
for (let i = 0; i < count; i++) {
|
||||
search.removeCondition(0);
|
||||
}
|
||||
for (let condition of [...scope, ...rest, ...flags]) {
|
||||
search.addCondition(
|
||||
condition.condition + (condition.mode ? '/' + condition.mode : ''),
|
||||
condition.operator,
|
||||
condition.value
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
focus(options) {
|
||||
let menu = this._searchElem.querySelector('#conditionsmenu');
|
||||
// focusVisible so the focus ring shows even though we're focusing
|
||||
// programmatically (in response to a click)
|
||||
menu.focus({ focusVisible: true, ...options });
|
||||
}
|
||||
}
|
||||
customElements.define("advanced-search-pane", AdvancedSearchPane);
|
||||
}
|
||||
|
|
@ -39,13 +39,23 @@
|
|||
return this._items || [];
|
||||
}
|
||||
|
||||
get filter() {
|
||||
return (this._filter || "").toLowerCase();
|
||||
}
|
||||
|
||||
set filter(val) {
|
||||
this._filter = val;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._body = this.querySelector('.body');
|
||||
this._notifierID = Zotero.Notifier.registerObserver(this, ['item']);
|
||||
this._body.addEventListener('keydown', this._handleKeyDown);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
Zotero.Notifier.unregisterObserver(this._notifierID);
|
||||
this._body.removeEventListener('keydown', this._handleKeyDown);
|
||||
}
|
||||
|
||||
notify(action, type, ids) {
|
||||
|
|
@ -75,7 +85,7 @@
|
|||
|
||||
let topLevelItems = Zotero.Items.getTopLevel(this.items);
|
||||
|
||||
// Remove collapsible sections for top-level items whose annotations are no longer selected
|
||||
// Remove collapsible sections for top-level items that no longer have any annotations
|
||||
for (let section of [...this.querySelectorAll("collapsible-section")]) {
|
||||
let parentID = section.dataset.pane.split("-")[1];
|
||||
if (!topLevelItems.some(item => item.id == parentID)) {
|
||||
|
|
@ -83,7 +93,8 @@
|
|||
}
|
||||
}
|
||||
for (let parentItem of topLevelItems) {
|
||||
let selectedAnnotations = this.items.filter(item => item.topLevelItem.id == parentItem.id);
|
||||
let allAnnotations = this.items.filter(item => item.topLevelItem.id == parentItem.id);
|
||||
let visibleAnnotations = allAnnotations.filter(item => this._passesFilter(item));
|
||||
// Create a collapsible section for each top-level item if it does not exist yet
|
||||
let section = this.querySelector(`[data-pane="annotations-${parentItem.id}"]`);
|
||||
if (!section) {
|
||||
|
|
@ -98,17 +109,23 @@
|
|||
section.appendChild(sectionBody);
|
||||
this._body.append(section);
|
||||
}
|
||||
document.l10n.setArgs(section, { count: selectedAnnotations.length });
|
||||
// Add annotations into this collapsible section
|
||||
for (let annotation of selectedAnnotations) {
|
||||
// Skip rows that already exist
|
||||
if (this.querySelector(`annotation-row[annotation-id="${annotation.id}"]`)) continue;
|
||||
let row = document.createXULElement('annotation-row');
|
||||
row.annotation = annotation;
|
||||
section.querySelector('.body').append(row);
|
||||
document.l10n.setArgs(section, { count: visibleAnnotations.length });
|
||||
// Hide section if all of its annotations are filtered out
|
||||
section.toggleAttribute("hidden", visibleAnnotations.length === 0);
|
||||
// Add annotations into this collapsible section (create if they don't exist)
|
||||
for (let annotation of allAnnotations) {
|
||||
let row = this.querySelector(`annotation-row[annotation-id="${annotation.id}"]`);
|
||||
if (!row) {
|
||||
row = document.createXULElement('annotation-row');
|
||||
row.annotation = annotation;
|
||||
section.querySelector('.body').append(row);
|
||||
}
|
||||
// Hide annotation rows that are filtered out
|
||||
row.toggleAttribute("hidden", !this._passesFilter(annotation));
|
||||
}
|
||||
}
|
||||
// Remove annotation rows for annotations that are no longer selected
|
||||
|
||||
// Remove annotation rows for annotations that are no longer in this.items
|
||||
for (let row of [...this.querySelectorAll("annotation-row")]) {
|
||||
let rowID = row.getAttribute("annotation-id");
|
||||
if (!this.items.some(obj => obj.id == rowID)) {
|
||||
|
|
@ -128,6 +145,40 @@
|
|||
append,
|
||||
});
|
||||
}
|
||||
|
||||
_passesFilter(annotation) {
|
||||
if (!this.filter) return true;
|
||||
let text = (annotation.annotationText || "").toLowerCase();
|
||||
let comment = (annotation.annotationComment || "").toLowerCase();
|
||||
let tags = (annotation.getTags() || []).map(tag => tag.tag.toLowerCase()).join(" ");
|
||||
return text.includes(this.filter) || comment.includes(this.filter) || tags.includes(this.filter);
|
||||
}
|
||||
|
||||
// Handle arrowUp/Down navigation between focused annotation rows
|
||||
_handleKeyDown = (event) => {
|
||||
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
|
||||
|
||||
let currentRow = event.target.closest('annotation-row');
|
||||
if (!currentRow) return;
|
||||
|
||||
let visibleRows = [...this.querySelectorAll('annotation-row:not([hidden])')];
|
||||
let currentIndex = visibleRows.indexOf(currentRow);
|
||||
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
let nextIndex;
|
||||
if (event.key === 'ArrowDown') {
|
||||
nextIndex = currentIndex + 1;
|
||||
}
|
||||
else {
|
||||
nextIndex = currentIndex - 1;
|
||||
}
|
||||
|
||||
if (nextIndex >= 0 && nextIndex < visibleRows.length) {
|
||||
visibleRows[nextIndex].focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
customElements.define("annotation-items-pane", AnnotationItemsPane);
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
<html:div class="head">
|
||||
<image class="icon"/>
|
||||
<html:div class="title"/>
|
||||
<html:div class="action"/>
|
||||
</html:div>
|
||||
<html:div class="body"/>
|
||||
<html:div class="tags"/>
|
||||
|
|
@ -39,7 +40,7 @@
|
|||
_annotation = null;
|
||||
|
||||
static get observedAttributes() {
|
||||
return ['annotation-id'];
|
||||
return ['annotation-id', 'action'];
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
|
|
@ -47,6 +48,9 @@
|
|||
case 'annotation-id':
|
||||
this._annotation = Zotero.Items.get(newValue);
|
||||
break;
|
||||
case 'action':
|
||||
this.action = newValue;
|
||||
break;
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
|
|
@ -60,6 +64,21 @@
|
|||
this.setAttribute('annotation-id', annotation.id);
|
||||
}
|
||||
|
||||
get action() {
|
||||
return this._action;
|
||||
}
|
||||
|
||||
set action(val) {
|
||||
if (!val) {
|
||||
this._action = null;
|
||||
return;
|
||||
}
|
||||
if (val !== "plus") {
|
||||
throw new Error("Invalid button value: " + val);
|
||||
}
|
||||
this._action = val;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._head = this.querySelector('.head');
|
||||
this._title = this.querySelector('.title');
|
||||
|
|
@ -69,7 +88,7 @@
|
|||
}
|
||||
|
||||
render() {
|
||||
if (!this.initialized) return;
|
||||
if (!this.initialized || !this._annotation) return;
|
||||
|
||||
this._title.textContent = Zotero.getString('pdfReader.page') + ' '
|
||||
+ (this._annotation.annotationPageLabel || '-');
|
||||
|
|
@ -79,6 +98,14 @@
|
|||
type = 'area';
|
||||
}
|
||||
this.querySelector('.icon').src = 'chrome://zotero/skin/16/universal/annotate-' + type + '.svg';
|
||||
this.querySelector('.action').replaceChildren();
|
||||
if (this.action) {
|
||||
let icon = document.createXULElement('toolbarbutton');
|
||||
icon.classList.add('zotero-clicky');
|
||||
icon.classList.add('zotero-clicky-' + this.action);
|
||||
icon.setAttribute('action', this.action);
|
||||
this.querySelector('.action').append(icon);
|
||||
}
|
||||
this._body.replaceChildren();
|
||||
|
||||
if (['image', 'ink'].includes(this._annotation.annotationType)) {
|
||||
|
|
@ -126,7 +153,7 @@
|
|||
this.style.setProperty('--annotation-color', this._annotation.annotationColor);
|
||||
// A11y - make focusable + add screen reader's labels
|
||||
this.setAttribute("tabindex", 0);
|
||||
let annotationTypeStr = Zotero.getString(`pdfReader.${this.annotation.annotationType}Annotation`);
|
||||
let annotationTypeStr = Zotero.getString(`reader-${this.annotation.annotationType}-annotation`);
|
||||
let a11yLabel = this._annotation.annotationText ? `${Zotero.getString('pdfReader.annotationText')}: ${this._annotation.annotationText}.` : annotationTypeStr;
|
||||
let ariaComment = this._annotation.annotationComment ? `${Zotero.getString('pdfReader.annotationComment')}: ${this._annotation.annotationComment}.` : '';
|
||||
let ariaTags = tags.length ? `${Zotero.getString('itemFields.tags')}: ${tags.map(tag => tag.tag).join(', ')}.` : '';
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@
|
|||
*/
|
||||
|
||||
{
|
||||
const { ItemPaneSectionElementBase } = ChromeUtils.importESModule(
|
||||
"chrome://zotero/content/elements/itemPaneSectionElementBase.mjs",
|
||||
{ global: "current" }
|
||||
);
|
||||
|
||||
class AttachmentAnnotationsBox extends ItemPaneSectionElementBase {
|
||||
content = MozXULElement.parseXULToFragment(`
|
||||
<collapsible-section data-l10n-id="section-attachments-annotations" data-pane="attachment-annotations">
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@
|
|||
|
||||
|
||||
{
|
||||
const { ItemPaneSectionElementBase } = ChromeUtils.importESModule(
|
||||
"chrome://zotero/content/elements/itemPaneSectionElementBase.mjs",
|
||||
{ global: "current" }
|
||||
);
|
||||
let { canRenameFileFromParent, renameFileFromParent } = ChromeUtils.importESModule("chrome://zotero/content/renameFiles.mjs");
|
||||
class AttachmentBox extends ItemPaneSectionElementBase {
|
||||
content = MozXULElement.parseXULToFragment(`
|
||||
|
|
@ -320,12 +324,17 @@
|
|||
}
|
||||
|
||||
notify(event, _type, ids, _extraData) {
|
||||
if (ids.includes(this.item?.parentItem?.id)) {
|
||||
// Ensure the "Rename from Parent" button is visible after the parent item changes (#5542)
|
||||
this._resetRenderedFlags();
|
||||
if (event == 'modify' && ids.includes(this.item?.parentItem?.id)) {
|
||||
// Ensure the "Rename from Parent" button is visible after the parent item changes (#5542, #5816)
|
||||
if (this.hidden) {
|
||||
this._resetRenderedFlags();
|
||||
}
|
||||
else {
|
||||
this.updateInfo();
|
||||
}
|
||||
}
|
||||
if (event != 'modify' || !this.item?.id || !ids.includes(this.item.id)) return;
|
||||
|
||||
|
||||
Promise.all([
|
||||
this.updateInfo(),
|
||||
this.updatePreview()
|
||||
|
|
@ -340,6 +349,9 @@
|
|||
this._previewDiscarded = false;
|
||||
this.previewElem.render();
|
||||
}
|
||||
else {
|
||||
this.previewElem.keepAlive();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -581,7 +593,7 @@
|
|||
}
|
||||
|
||||
if (show) {
|
||||
reindexButton.setAttribute('hidden', false);
|
||||
reindexButton.removeAttribute('hidden');
|
||||
}
|
||||
else {
|
||||
reindexButton.setAttribute('hidden', true);
|
||||
|
|
@ -753,7 +765,13 @@
|
|||
|
||||
_handleTitleBlur = () => {
|
||||
this.item.setField('title', this._id('title').value);
|
||||
this.item.saveTx();
|
||||
this.item.saveTx({
|
||||
undoAction: 'undo-action-edit-field',
|
||||
undoActionArgs: {
|
||||
field: Zotero.ItemFields.getLocalizedString('title'),
|
||||
count: 1
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
_handleFileNameFocus = () => {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@
|
|||
*/
|
||||
|
||||
{
|
||||
const { ItemPaneSectionElementBase } = ChromeUtils.importESModule(
|
||||
"chrome://zotero/content/elements/itemPaneSectionElementBase.mjs",
|
||||
{ global: "current" }
|
||||
);
|
||||
|
||||
class PreviewRenderAbortError extends Error {
|
||||
constructor() {
|
||||
super("AttachmentPreview render aborted");
|
||||
|
|
@ -263,7 +268,7 @@
|
|||
|
||||
notify(event, type, ids, extraData) {
|
||||
if (!this.item) return;
|
||||
if (this.isReaderType && this._reader) {
|
||||
if (this.isReaderType && this._reader && this._reader._internalReader) {
|
||||
// Following chrome/content/zotero/xpcom/reader.js
|
||||
if (event === "delete") {
|
||||
let disappearedIDs = this._reader.annotationItemIDs.filter(x => ids.includes(x));
|
||||
|
|
@ -345,6 +350,19 @@
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the last task UID to keep the preview alive. This prevents deferred discard when
|
||||
* the preview is still in use. Useful when the preview moves out of view but later enters
|
||||
* the view again before the discard timeout.
|
||||
*/
|
||||
async keepAlive() {
|
||||
if (!this._lastTaskUID || this._isProcessingTask) {
|
||||
return;
|
||||
}
|
||||
let newUID = `${Date.now()}-${Math.random()}`;
|
||||
this._lastTaskUID = newUID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all pending tasks and reset processing states.
|
||||
*/
|
||||
|
|
@ -405,13 +423,18 @@
|
|||
|
||||
this._debug(`Processing task ${task.type} (${uid})`);
|
||||
|
||||
switch (task.type) {
|
||||
case "render":
|
||||
await Promise.race([this._processRender(task.data), Zotero.Promise.delay(3000)]);
|
||||
break;
|
||||
case "discard":
|
||||
await Promise.race([this._processDiscard(task.data), Zotero.Promise.delay(3000)]);
|
||||
break;
|
||||
try {
|
||||
switch (task.type) {
|
||||
case "render":
|
||||
await Promise.race([this._processRender(task.data), Zotero.Promise.delay(3000)]);
|
||||
break;
|
||||
case "discard":
|
||||
await Promise.race([this._processDiscard(task.data), Zotero.Promise.delay(3000)]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
this._debug(`Task ${task.type} (${uid}) failed: ${e}`);
|
||||
}
|
||||
|
||||
this._isProcessingTask = false;
|
||||
|
|
@ -436,6 +459,12 @@
|
|||
|
||||
this._debug(`Rendering item ${itemID}, previewType: ${previewType}`);
|
||||
|
||||
// Capture the current task UID to detect zombie renders.
|
||||
// When _processTask's Promise.race times out, this render continues
|
||||
// as a zombie while a new task starts. The new task changes
|
||||
// _lastTaskUID, so the zombie can detect it's outdated.
|
||||
let taskUID = this._lastTaskUID;
|
||||
|
||||
this._isRendering = true;
|
||||
let success = false;
|
||||
|
||||
|
|
@ -474,7 +503,11 @@
|
|||
}
|
||||
}
|
||||
finally {
|
||||
this.setPreviewStatus(success ? "success" : "fail");
|
||||
// Only update status if this is still the current task,
|
||||
// not a zombie from a timed-out Promise.race
|
||||
if (this._lastTaskUID === taskUID) {
|
||||
this.setPreviewStatus(success ? "success" : "fail");
|
||||
}
|
||||
this._isRendering = false;
|
||||
|
||||
this._debug(`Render processed, item ${itemID} ${success ? "succeeded" : "failed"}`);
|
||||
|
|
@ -517,6 +550,16 @@
|
|||
let nextPreview = this._id("next-preview");
|
||||
if (nextPreview) {
|
||||
nextPreview.id = "preview";
|
||||
// If the browser's document is already loaded, resolve the promise
|
||||
// immediately. After the rename, its DOMContentLoaded would match
|
||||
// "preview" instead of "next-preview", so the normal handler might
|
||||
// not resolve this. We must check that readyState is "interactive"
|
||||
// or "complete" (not just that contentWindow exists) because
|
||||
// contentWindow can exist before DOMContentLoaded fires.
|
||||
let readyState = nextPreview.contentDocument?.readyState;
|
||||
if (readyState === "interactive" || readyState === "complete") {
|
||||
this._nextPreviewInitializePromise.resolve();
|
||||
}
|
||||
}
|
||||
this._debug("Preview discarded");
|
||||
|
||||
|
|
@ -612,10 +655,10 @@
|
|||
*/
|
||||
async _renderReader(itemID) {
|
||||
this.setPreviewStatus("loading");
|
||||
// This only need to be awaited during first load
|
||||
// This only need to be awaited during first load.
|
||||
// _initializePromise (awaited in _processTask) already ensures both
|
||||
// preview and next-preview are ready on first load.
|
||||
await this._previewInitializePromise.promise;
|
||||
// This should be awaited in the following refreshes
|
||||
await this._nextPreviewInitializePromise.promise;
|
||||
|
||||
this._tryAbortRender(itemID);
|
||||
|
||||
|
|
@ -670,6 +713,13 @@
|
|||
_handleReaderLoad(event) {
|
||||
if (this._id("preview")?.contentWindow?.document === event.target) {
|
||||
this._previewInitializePromise.resolve();
|
||||
// If there is no next-preview element, this preview was likely
|
||||
// a next-preview promoted by _processDiscard before its
|
||||
// DOMContentLoaded fired. Resolve that promise too so
|
||||
// _processDiscard doesn't hang awaiting it.
|
||||
if (!this._id("next-preview")) {
|
||||
this._nextPreviewInitializePromise.resolve();
|
||||
}
|
||||
}
|
||||
else if (this._id("next-preview")?.contentWindow?.document === event.target) {
|
||||
this._nextPreviewInitializePromise.resolve();
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@
|
|||
|
||||
|
||||
{
|
||||
const { ItemPaneSectionElementBase } = ChromeUtils.importESModule(
|
||||
"chrome://zotero/content/elements/itemPaneSectionElementBase.mjs",
|
||||
{ global: "current" }
|
||||
);
|
||||
|
||||
class AttachmentPreviewBox extends ItemPaneSectionElementBase {
|
||||
content = MozXULElement.parseXULToFragment(`
|
||||
<collapsible-section data-l10n-id="section-attachment-preview" data-pane="attachment-preview">
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ import { getCSSItemTypeIcon } from 'components/icons';
|
|||
<html:span class="icon"/>
|
||||
<html:span class="label"/>
|
||||
</html:div>
|
||||
<toolbarbutton class="zotero-clicky zotero-clicky-minus" tabindex="0" data-l10n-id="section-button-remove" disabled="false">
|
||||
<toolbarbutton class="zotero-clicky zotero-clicky-minus" tabindex="0" data-l10n-id="section-button-remove">
|
||||
<image class="toolbarbutton-icon"/>
|
||||
<label class="toolbarbutton-text" />
|
||||
</toolbarbutton>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,11 @@
|
|||
"use strict";
|
||||
|
||||
{
|
||||
const { ItemPaneSectionElementBase } = ChromeUtils.importESModule(
|
||||
"chrome://zotero/content/elements/itemPaneSectionElementBase.mjs",
|
||||
{ global: "current" }
|
||||
);
|
||||
|
||||
class AttachmentsBox extends ItemPaneSectionElementBase {
|
||||
content = MozXULElement.parseXULToFragment(`
|
||||
<collapsible-section data-l10n-id="section-attachments" data-pane="attachments" extra-buttons="add">
|
||||
|
|
@ -214,6 +219,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.previewElem.keepAlive();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._renderStage = "final";
|
||||
|
|
|
|||
|
|
@ -36,11 +36,20 @@
|
|||
this._body = this.querySelector('.bubble-input.body');
|
||||
this._body.addEventListener('click', this._onBodyClick.bind(this));
|
||||
this._lastFocusedInput = null;
|
||||
this.showJustAddedPlaceholder = false;
|
||||
|
||||
Utils.init(this);
|
||||
DragDropHandler.init(this);
|
||||
}
|
||||
|
||||
set sortable(value) {
|
||||
this.setAttribute("sortable", !!value);
|
||||
}
|
||||
|
||||
get sortable() {
|
||||
return this.getAttribute("sortable") == "true";
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.refocusInput();
|
||||
}
|
||||
|
|
@ -115,11 +124,10 @@
|
|||
if (isOnlyInput) {
|
||||
document.l10n.setAttributes(this._body.firstChild, `integration-citationDialog-single-input-${dialogType}`);
|
||||
}
|
||||
// otherwise, add a regular aria descriptions and placeholders to all inputs
|
||||
// otherwise, set default placeholders for all inputs, with special handling
|
||||
// of the last input after a bubble is added with visible placeholder
|
||||
else {
|
||||
for (let input of [...this.querySelectorAll(".input")]) {
|
||||
document.l10n.setAttributes(input, `integration-citationDialog-input-${dialogType}`);
|
||||
}
|
||||
Utils.setupInputPlaceholders(this.showJustAddedPlaceholder, dialogType);
|
||||
}
|
||||
// If any two inputs end up next to each other (e.g. after bubble is deleted),
|
||||
// have them merged
|
||||
|
|
@ -212,7 +220,7 @@
|
|||
*/
|
||||
_createBubble(content, dialogReferenceID) {
|
||||
let bubble = document.createElement("div");
|
||||
bubble.setAttribute("draggable", "true");
|
||||
bubble.setAttribute("draggable", this.sortable);
|
||||
bubble.setAttribute("role", "button");
|
||||
bubble.setAttribute("tabindex", "0");
|
||||
bubble.setAttribute("data-l10n-id", "integration-citationDialog-aria-bubble");
|
||||
|
|
@ -245,7 +253,7 @@
|
|||
*/
|
||||
_onBubbleKeydown(event) {
|
||||
let bubble = event.target;
|
||||
if (["ArrowLeft", "ArrowRight"].includes(event.key) && event.shiftKey) {
|
||||
if (["ArrowLeft", "ArrowRight"].includes(event.key) && event.shiftKey && this.sortable) {
|
||||
// On Shift-Left/Right swap focused bubble with it's neighbor
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
|
@ -331,10 +339,20 @@
|
|||
Utils.notifyDialog("handle-input", { query: input.value, eventType: "focus" });
|
||||
});
|
||||
input.addEventListener("blur", async (event) => {
|
||||
// When the window itself loses focus (e.g., Cmd/Alt-Tab to another app),
|
||||
// the input remains focused, so keep its state unchanged
|
||||
if (!document.hasFocus()) return;
|
||||
// record this input as last focused if it's not empty OR if the focus left bubbleInput altogether
|
||||
if (!Utils.isInputEmpty(input) || !this.contains(event.relatedTarget)) {
|
||||
this._lastFocusedInput = input;
|
||||
}
|
||||
// Collapse a placeholder input back to regular size once focus leaves.
|
||||
// The placeholder attributes are reset on the next refresh()
|
||||
if (input.classList.contains("just-added-placeholder")) {
|
||||
input.classList.remove("just-added-placeholder");
|
||||
input.removeAttribute("title");
|
||||
input.style.minWidth = "";
|
||||
}
|
||||
});
|
||||
return input;
|
||||
}
|
||||
|
|
@ -390,9 +408,11 @@
|
|||
},
|
||||
|
||||
handleDragStart(event) {
|
||||
if (!this.bubbleInput.sortable) return false;
|
||||
this.dragBubble = event.target;
|
||||
event.dataTransfer.setData("text/plain", '<span id="zotero-drag"/>');
|
||||
event.stopPropagation();
|
||||
return true;
|
||||
},
|
||||
|
||||
handleDragEnter(event) {
|
||||
|
|
@ -432,7 +452,6 @@
|
|||
// Handle drag-drop of items from the citationDialog into bubble-input to add them
|
||||
if (itemIDs) {
|
||||
itemIDs = itemIDs.split(",");
|
||||
console.log(itemIDs);
|
||||
let newIndex = 0;
|
||||
if (this.dragOver) {
|
||||
newIndex = [...this.bubbleInput.querySelectorAll(".bubble")].findIndex(node => this.dragOver == node);
|
||||
|
|
@ -587,9 +606,95 @@
|
|||
this.bubbleInput._body.appendChild(span);
|
||||
let spanWidth = span.getBoundingClientRect().width;
|
||||
span.remove();
|
||||
// set min-width of 1px if the input is focused to ensure
|
||||
// that the cursor is always visible
|
||||
if (document.activeElement == input && !spanWidth) {
|
||||
spanWidth = 1;
|
||||
}
|
||||
return spanWidth;
|
||||
},
|
||||
|
||||
getTextWidth(text) {
|
||||
let span = document.createElement("span");
|
||||
span.classList = "input";
|
||||
span.innerText = text;
|
||||
this.bubbleInput._body.appendChild(span);
|
||||
let spanWidth = span.getBoundingClientRect().width;
|
||||
span.remove();
|
||||
return spanWidth;
|
||||
},
|
||||
|
||||
// Return the longest prefix of `text` such that prefix + "…" fits within maxWidth,
|
||||
// or the original text if it already fits. Used for placeholder truncation, since
|
||||
// text-overflow:ellipsis doesn't work on <input> in Firefox chrome.
|
||||
truncateToWidth(text, maxWidth) {
|
||||
if (this.getTextWidth(text) <= maxWidth) return text;
|
||||
let ellipsis = "…";
|
||||
for (let i = text.length - 1; i > 0; i--) {
|
||||
let candidate = text.slice(0, i) + ellipsis;
|
||||
if (this.getTextWidth(candidate) <= maxWidth) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return ellipsis;
|
||||
},
|
||||
|
||||
// Set aria-descriptions and placeholders on every input in the bubble-input.
|
||||
// For the just-added input (the last one, when showJustAddedPlaceholder is set), the
|
||||
// placeholder is truncated to the space remaining on its line and passed to Fluent as
|
||||
// a variable.
|
||||
setupInputPlaceholders(showJustAddedPlaceholder, dialogType) {
|
||||
let allInputs = [...this.bubbleInput.querySelectorAll(".input")];
|
||||
let lastInput = allInputs[allInputs.length - 1];
|
||||
let bodyRight = this.bubbleInput._body.getBoundingClientRect().right;
|
||||
for (let input of allInputs) {
|
||||
let isJustAdded = showJustAddedPlaceholder && input === lastInput;
|
||||
if (!isJustAdded) {
|
||||
// If the just-added placeholder was dismissed (e.g. by right-arrow at the
|
||||
// end of the input) while the input is still focused and empty, keep the
|
||||
// visible placeholder but fall back to the default search prompt, since
|
||||
// searching still works. It collapses when the input loses focus (see the
|
||||
// blur handler in _createInputElem())
|
||||
if (input.classList.contains("just-added-placeholder")
|
||||
&& document.activeElement == input && !input.value) {
|
||||
let placeholder = this.truncateToWidth(
|
||||
" " + Zotero.getString("integration-citationDialog-search-for-items"),
|
||||
parseFloat(input.style.minWidth) || Infinity);
|
||||
document.l10n.setAttributes(input, "integration-citationDialog-just-added-input-citation", { placeholder, title: "" });
|
||||
continue;
|
||||
}
|
||||
input.classList.remove("just-added-placeholder");
|
||||
document.l10n.setAttributes(input, `integration-citationDialog-input-${dialogType}`);
|
||||
// Clear any stale title and min-width left over from a previous just-added state
|
||||
input.removeAttribute("title");
|
||||
input.style.minWidth = "";
|
||||
}
|
||||
if (isJustAdded && !input.classList.contains("just-added-placeholder")) {
|
||||
input.classList.add("just-added-placeholder");
|
||||
// Leading NBSP gives a small visual gap between the cursor and the placeholder
|
||||
// text (CSS padding/text-indent on input or ::placeholder both move the cursor too)
|
||||
let fullPlaceholder = " " + Zotero.getString("integration-citationDialog-just-added-input-placeholder");
|
||||
let availableWidth = bodyRight - input.getBoundingClientRect().left - 20;
|
||||
let placeholderWidth = this.getTextWidth(fullPlaceholder);
|
||||
let placeholder, title, minWidth;
|
||||
if (availableWidth >= placeholderWidth) {
|
||||
placeholder = fullPlaceholder;
|
||||
title = "";
|
||||
minWidth = placeholderWidth;
|
||||
}
|
||||
else {
|
||||
placeholder = this.truncateToWidth(fullPlaceholder, availableWidth);
|
||||
title = fullPlaceholder;
|
||||
minWidth = availableWidth;
|
||||
}
|
||||
// min-width keeps the placeholder visible when the input is empty, but
|
||||
// lets the input grow to fit content the user types beyond the placeholder.
|
||||
input.style.minWidth = minWidth + 'px';
|
||||
document.l10n.setAttributes(input, "integration-citationDialog-just-added-input-citation", { placeholder, title });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// If a bubble is removed between two inputs we need to combine them
|
||||
combineNeighboringInputs(startingNode) {
|
||||
let node = startingNode;
|
||||
|
|
|
|||
|
|
@ -43,7 +43,8 @@
|
|||
set open(newOpen) {
|
||||
newOpen = !!newOpen;
|
||||
let oldOpen = this.open;
|
||||
if (oldOpen === newOpen || this.empty || !this.collapsible) return;
|
||||
if (oldOpen === newOpen || this.empty) return;
|
||||
if (!newOpen && !this.collapsible) return;
|
||||
this.render();
|
||||
|
||||
// Force open before getting scrollHeight, so we get the right value
|
||||
|
|
@ -118,9 +119,22 @@
|
|||
this.setAttribute('no-collapse', val);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
get showContextMenu() {
|
||||
return !this.getAttribute("no-context-menu");
|
||||
}
|
||||
|
||||
set showContextMenu(val) {
|
||||
if (val) {
|
||||
this.removeAttribute('no-context-menu');
|
||||
}
|
||||
else {
|
||||
this.setAttribute('no-context-menu', val);
|
||||
}
|
||||
}
|
||||
|
||||
static get observedAttributes() {
|
||||
return ['open', 'empty', 'label', 'summary', 'extra-buttons'];
|
||||
return ['open', 'empty', 'label', 'summary', 'extra-buttons', 'no-collapse'];
|
||||
}
|
||||
|
||||
attributeChangedCallback(name) {
|
||||
|
|
@ -141,7 +155,10 @@
|
|||
this._head = document.createElement('div');
|
||||
this._head.role = 'button';
|
||||
this._head.className = 'head';
|
||||
this._head.setAttribute("tabindex", "0");
|
||||
// remove header from focus order in non-collapsible sections
|
||||
if (!this._disableCollapsing || !this.collapsible) {
|
||||
this._head.setAttribute("tabindex", "0");
|
||||
}
|
||||
this._head.addEventListener('mousedown', this._handleMouseDown);
|
||||
this._head.addEventListener('click', this._handleClick);
|
||||
this._head.addEventListener('keydown', this._handleKeyDown);
|
||||
|
|
@ -196,7 +213,7 @@
|
|||
}
|
||||
|
||||
_buildContextMenu() {
|
||||
let containerRoot = this.closest('.zotero-view-item-container, context-notes-list');
|
||||
let containerRoot = this.closest('.item-pane-container-root, .zotero-view-item-container, context-notes-list');
|
||||
|
||||
let contextMenu = document.createXULElement('menupopup');
|
||||
|
||||
|
|
@ -295,7 +312,7 @@
|
|||
|
||||
let canMoveUp = sidenav?.isPaneMovable(this.dataset.pane, 'up');
|
||||
let canMoveDown = sidenav?.isPaneMovable(this.dataset.pane, 'down');
|
||||
let canReset = sidenav?.isOrderChanged();
|
||||
let canReset = !!sidenav?.container?.supportsReorder && sidenav?.isOrderChanged();
|
||||
|
||||
moveSectionUp.hidden = !canMoveUp;
|
||||
moveSectionDown.hidden = !canMoveDown;
|
||||
|
|
@ -345,7 +362,7 @@
|
|||
}
|
||||
|
||||
_saveOpenState() {
|
||||
if (this._disableSavingOpenState) return;
|
||||
if (this._disableSavingOpenState || this._skipSaveOpenState) return;
|
||||
Zotero.Prefs.set(`panes.${this.dataset.pane}.open`, this.open);
|
||||
}
|
||||
|
||||
|
|
@ -380,11 +397,11 @@
|
|||
}
|
||||
|
||||
get _disableContextMenu() {
|
||||
return !this._getSidenav() || !!this.closest('annotation-items-pane');
|
||||
return !this._getSidenav() || !this.showContextMenu || !!this.closest('annotation-items-pane');
|
||||
}
|
||||
|
||||
_handleClick = (event) => {
|
||||
if (this._disableCollapsing) return;
|
||||
if (this._disableCollapsing || !this.collapsible) return;
|
||||
if (event.target.closest('.section-custom-button, menupopup')) return;
|
||||
this.open = !this.open;
|
||||
};
|
||||
|
|
@ -414,7 +431,7 @@
|
|||
}
|
||||
// Space/Enter toggle section open/closed.
|
||||
// ArrowLeft/ArrowRight on actual header will close/open (depending on locale direction)
|
||||
if (["ArrowLeft", "ArrowRight", " ", "Enter"].includes(event.key) && !this._disableCollapsing) {
|
||||
if (["ArrowLeft", "ArrowRight", " ", "Enter"].includes(event.key) && !this._disableCollapsing && this.collapsible) {
|
||||
stopEvent();
|
||||
this.open = ([" ", "Enter"].includes(event.key)) ? !this.open : (event.key == Zotero.arrowNextKey);
|
||||
event.target.focus();
|
||||
|
|
@ -477,7 +494,7 @@
|
|||
this._title.textContent = this.label;
|
||||
this._summary.textContent = this.summary;
|
||||
let twisty = this._head.querySelector('.twisty');
|
||||
twisty.hidden = this._disableCollapsing;
|
||||
twisty.hidden = this._disableCollapsing || !this.collapsible;
|
||||
document.l10n.setAttributes(twisty, `section-button-${this.open ? "collapse" : "expand"}`, { section: this._paneName || "" });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -224,9 +224,9 @@
|
|||
else {
|
||||
this._disableNotesContext();
|
||||
}
|
||||
_contextPaneSplitter.setAttribute('hidden', false);
|
||||
_contextPaneSplitter.removeAttribute('hidden');
|
||||
|
||||
_contextPane.setAttribute('collapsed', !(_contextPaneSplitter.getAttribute('state') != 'collapsed'));
|
||||
_contextPane.toggleAttribute('collapsed', _contextPaneSplitter.getAttribute('state') == 'collapsed');
|
||||
|
||||
this._sidenav.hidden = false;
|
||||
|
||||
|
|
|
|||
|
|
@ -24,26 +24,39 @@
|
|||
*/
|
||||
|
||||
{
|
||||
class DuplicatesMergePane extends XULElementBase {
|
||||
content = MozXULElement.parseXULToFragment(`
|
||||
<groupbox>
|
||||
<button id="zotero-duplicates-merge-button" data-l10n-id="item-pane-duplicates-merge-items" />
|
||||
</groupbox>
|
||||
|
||||
<groupbox id="zotero-duplicates-merge-version-select">
|
||||
<description>&zotero.duplicatesMerge.versionSelect;</description>
|
||||
<hbox>
|
||||
<richlistbox id="zotero-duplicates-merge-original-date" rows="0"/>
|
||||
</hbox>
|
||||
</groupbox>
|
||||
|
||||
<groupbox id="zotero-duplicates-merge-field-select">
|
||||
<description>&zotero.duplicatesMerge.fieldSelect;</description>
|
||||
</groupbox>
|
||||
const { ItemPaneContainerBase } = ChromeUtils.importESModule(
|
||||
"chrome://zotero/content/elements/itemPaneContainerBase.mjs",
|
||||
{ global: "current" }
|
||||
);
|
||||
|
||||
<vbox id="zotero-duplicates-merge-info-box-container">
|
||||
<info-box id="zotero-duplicates-merge-info-box"/>
|
||||
let lazy = {};
|
||||
ChromeUtils.defineESModuleGetters(lazy, {
|
||||
mergeItems: "chrome://zotero/content/mergeItems.mjs",
|
||||
});
|
||||
|
||||
class DuplicatesMergePane extends ItemPaneContainerBase {
|
||||
content = MozXULElement.parseXULToFragment(`
|
||||
<vbox id="zotero-duplicates-merge-controls">
|
||||
<groupbox>
|
||||
<button id="zotero-duplicates-merge-button" data-l10n-id="item-pane-duplicates-merge-items" />
|
||||
</groupbox>
|
||||
|
||||
<groupbox id="zotero-duplicates-merge-version-select">
|
||||
<description>&zotero.duplicatesMerge.versionSelect;</description>
|
||||
<hbox>
|
||||
<richlistbox id="zotero-duplicates-merge-original-date" rows="0"/>
|
||||
</hbox>
|
||||
</groupbox>
|
||||
|
||||
<groupbox id="zotero-duplicates-merge-field-select">
|
||||
<description>&zotero.duplicatesMerge.fieldSelect;</description>
|
||||
</groupbox>
|
||||
</vbox>
|
||||
|
||||
<html:div id="zotero-duplicates-merge-view-item" class="zotero-view-item" tabindex="0">
|
||||
<info-box id="zotero-duplicates-merge-info-box" data-pane="info"/>
|
||||
<abstract-box id="zotero-duplicates-merge-abstract-box" data-pane="abstract"/>
|
||||
</html:div>
|
||||
`, ['chrome://zotero/locale/zotero.dtd']);
|
||||
|
||||
init() {
|
||||
|
|
@ -52,6 +65,10 @@
|
|||
this._otherItems = [];
|
||||
this._ignoreFields = ['dateAdded', 'dateModified', 'accessDate'];
|
||||
|
||||
this._paneParent = this.querySelector('#zotero-duplicates-merge-view-item');
|
||||
this._infoBox = this.querySelector('#zotero-duplicates-merge-info-box');
|
||||
this._abstractBox = this.querySelector('#zotero-duplicates-merge-abstract-box');
|
||||
|
||||
this.querySelector("#zotero-duplicates-merge-button").addEventListener(
|
||||
"command", () => this.merge());
|
||||
this.querySelector("#zotero-duplicates-merge-original-date").addEventListener(
|
||||
|
|
@ -108,15 +125,14 @@
|
|||
// Update the UI
|
||||
//
|
||||
|
||||
let button = document.getElementById('zotero-duplicates-merge-button');
|
||||
let versionSelect = document.getElementById('zotero-duplicates-merge-version-select');
|
||||
let itembox = document.getElementById('zotero-duplicates-merge-info-box');
|
||||
let fieldSelect = document.getElementById('zotero-duplicates-merge-field-select');
|
||||
let button = this.querySelector('#zotero-duplicates-merge-button');
|
||||
let versionSelect = this.querySelector('#zotero-duplicates-merge-version-select');
|
||||
let fieldSelect = this.querySelector('#zotero-duplicates-merge-field-select');
|
||||
|
||||
let alternatives = oldestItem.multiDiff(otherItems, this._ignoreFields);
|
||||
if (alternatives) {
|
||||
// Populate richlistbox with Date Added values from all items
|
||||
let dateList = document.getElementById('zotero-duplicates-merge-original-date');
|
||||
let dateList = this.querySelector('#zotero-duplicates-merge-original-date');
|
||||
dateList.innerHTML = '';
|
||||
|
||||
let numRows = 0;
|
||||
|
|
@ -138,9 +154,9 @@
|
|||
|
||||
document.l10n.setArgs(button, { count: otherItems.length + 1 });
|
||||
versionSelect.hidden = fieldSelect.hidden = !alternatives;
|
||||
itembox.hiddenFields = alternatives ? [] : ['dateAdded', 'dateModified'];
|
||||
// Since the header of the collapsible section is hidden, the section has to be opened
|
||||
itembox.open = true;
|
||||
this._infoBox.hiddenFields = alternatives ? [] : ['dateAdded', 'dateModified'];
|
||||
this._infoBox.open = true;
|
||||
this._abstractBox.open = true;
|
||||
|
||||
this.setMaster(0);
|
||||
|
||||
|
|
@ -148,8 +164,8 @@
|
|||
}
|
||||
|
||||
setMaster(pos) {
|
||||
let itembox = document.getElementById('zotero-duplicates-merge-info-box');
|
||||
itembox.mode = 'fieldmerge';
|
||||
this._infoBox.mode = 'fieldmerge';
|
||||
this._abstractBox.mode = 'fieldmerge';
|
||||
|
||||
this._otherItems = this._items.concat();
|
||||
let item = this._otherItems.splice(pos, 1)[0];
|
||||
|
|
@ -163,26 +179,36 @@
|
|||
for (let i in alternatives) {
|
||||
alternatives[i].unshift(itemValues[i] !== undefined ? itemValues[i] : '');
|
||||
}
|
||||
itembox.fieldAlternatives = alternatives;
|
||||
this._infoBox.fieldAlternatives = alternatives;
|
||||
this._abstractBox.fieldAlternatives = {
|
||||
abstractNote: alternatives.abstractNote
|
||||
};
|
||||
}
|
||||
else {
|
||||
this._infoBox.fieldAlternatives = {};
|
||||
this._abstractBox.fieldAlternatives = {};
|
||||
}
|
||||
|
||||
this._masterItem = item;
|
||||
itembox.item = item.clone();
|
||||
// The item.id is null which equals to _lastRenderItemID, so we need to force render it
|
||||
itembox._forceRenderAll();
|
||||
// Share the same clone between info-box and abstract-box so that
|
||||
// alternative selections from either side accumulate on a single item.
|
||||
let clone = item.clone();
|
||||
this._infoBox.item = clone;
|
||||
this._abstractBox.item = clone;
|
||||
// The clone's item.id is null which equals to _lastRenderItemID, so we need to force render
|
||||
this._infoBox._forceRenderAll();
|
||||
this._abstractBox._forceRenderAll();
|
||||
}
|
||||
|
||||
async merge() {
|
||||
let itembox = document.getElementById('zotero-duplicates-merge-info-box');
|
||||
Zotero.CollectionTreeCache.clear();
|
||||
// Update master item with any field alternatives from the item box
|
||||
// Update master item with any field alternatives chosen in the boxes
|
||||
let json = this._masterItem.toJSON();
|
||||
// Exclude certain properties that are empty in the cloned object, so we don't clobber them
|
||||
const { relations: _r, collections: _c, tags: _t, ...keep } = itembox.item.toJSON();
|
||||
const { relations: _r, collections: _c, tags: _t, ...keep } = this._infoBox.item.toJSON();
|
||||
Object.assign(json, keep);
|
||||
|
||||
this._masterItem.fromJSON(json);
|
||||
Zotero.Items.merge(this._masterItem, this._otherItems);
|
||||
await lazy.mergeItems(this._masterItem, this._otherItems);
|
||||
}
|
||||
}
|
||||
customElements.define("duplicates-merge-pane", DuplicatesMergePane);
|
||||
|
|
|
|||
|
|
@ -38,9 +38,7 @@
|
|||
|
||||
class EditableText extends XULElementBase {
|
||||
_input;
|
||||
|
||||
_resizeObserver;
|
||||
|
||||
|
||||
_ignoredWindowInactiveBlur = false;
|
||||
|
||||
_focusMousedownEvent = false;
|
||||
|
|
@ -76,7 +74,30 @@
|
|||
});
|
||||
return span;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A single ResizeObserver shared by every EditableText in the document.
|
||||
* When inputs resize, recompute the 'overflowing' state for all affected
|
||||
* fields at once, batching layout reads before writes:
|
||||
* toggling 'overflowing' makes the layout dirty, and measuring the width
|
||||
* requires a clean layout, so interleaving the two would force a reflow
|
||||
* per measurement, which adds up to multiple seconds of layout
|
||||
* calculations on outlier items with thousands of creators.
|
||||
*
|
||||
* See {@link #batchSizeToContent()}.
|
||||
*/
|
||||
static _resizeObserver = new ResizeObserver((entries) => {
|
||||
let editableTexts = entries
|
||||
.map(entry => entry.target.closest('editable-text'))
|
||||
.filter(editableText => editableText?._input);
|
||||
// Read phase: measure everything first
|
||||
let overflowing = editableTexts.map(editableText => editableText._isOverflowing());
|
||||
// Write phase: apply the class changes in one batch
|
||||
for (let i = 0; i < editableTexts.length; i++) {
|
||||
editableTexts[i].classList.toggle('overflowing', overflowing[i]);
|
||||
}
|
||||
});
|
||||
|
||||
get noWrap() {
|
||||
return this.hasAttribute('nowrap');
|
||||
}
|
||||
|
|
@ -137,6 +158,14 @@
|
|||
this.setAttribute('value', value || '');
|
||||
}
|
||||
|
||||
get values() {
|
||||
return this._values ? this._values : [this.value];
|
||||
}
|
||||
|
||||
set values(values) {
|
||||
this._values = values;
|
||||
}
|
||||
|
||||
get initialValue() {
|
||||
return this._input?.dataset.initialValue ?? '';
|
||||
}
|
||||
|
|
@ -180,6 +209,23 @@
|
|||
return this._input;
|
||||
}
|
||||
|
||||
get multipleValues() {
|
||||
return this.hasAttribute('multiple-values');
|
||||
}
|
||||
|
||||
set multipleValues(multipleValues) {
|
||||
this.toggleAttribute('multiple-values', !!multipleValues);
|
||||
}
|
||||
|
||||
// true if the value is unchanged since the last blur event. Relevant for batch editing.
|
||||
get cancelled() {
|
||||
return this.hasAttribute('cancelled');
|
||||
}
|
||||
|
||||
set cancelled(cancelled) {
|
||||
this.toggleAttribute('cancelled', !!cancelled);
|
||||
}
|
||||
|
||||
_resetTextDirection() {
|
||||
this._input?.removeAttribute('dir');
|
||||
}
|
||||
|
|
@ -199,6 +245,27 @@
|
|||
sizeToContent = () => {
|
||||
this.style.maxWidth = this._getContentWidth() + 'px';
|
||||
};
|
||||
|
||||
/**
|
||||
* Size multiple elements to their content in a single batch.
|
||||
*
|
||||
* sizeToContent() reads layout and then updates layout styles.
|
||||
* Calling it once per element interleaves those reads and writes,
|
||||
* forcing a full synchronous reflow on every call. That's extremely
|
||||
* slow, especially if it occurs in a loop that also adds new elements
|
||||
* to the DOM on each iteration, as in the InfoBox.
|
||||
* Measuring everything first and only then applying the widths collapses
|
||||
* that to a single reflow regardless of how many elements are sized.
|
||||
*
|
||||
* @param {EditableText[]} elements
|
||||
*/
|
||||
static batchSizeToContent(elements) {
|
||||
elements = [...elements];
|
||||
let widths = elements.map(el => el._getContentWidth());
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
elements[i].style.maxWidth = widths[i] + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
attributeChangedCallback(name) {
|
||||
if (name === 'value' || name === 'dir') {
|
||||
|
|
@ -211,6 +278,13 @@
|
|||
this.render();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// Stop the shared observer from holding a reference to our input
|
||||
if (this._input) {
|
||||
EditableText._resizeObserver.unobserve(this._input);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let autocompleteParams = this.autocomplete;
|
||||
let autocompleteEnabled = !this.multiline && !!autocompleteParams;
|
||||
|
|
@ -245,6 +319,7 @@
|
|||
this.removeEventListener('keydown', this._captureAutocompleteKeydown, true);
|
||||
}
|
||||
|
||||
let oldInput = this._input;
|
||||
let focused = this.focused;
|
||||
let selectionStart = this._input?.selectionStart;
|
||||
let selectionEnd = this._input?.selectionEnd;
|
||||
|
|
@ -268,14 +343,22 @@
|
|||
this._input.setSelectionRange(selectionStart, selectionEnd, selectionDirection);
|
||||
}
|
||||
|
||||
this._resizeObserver?.disconnect();
|
||||
if (oldInput) {
|
||||
EditableText._resizeObserver.unobserve(oldInput);
|
||||
}
|
||||
// Only nowrap fields can overflow horizontally; textareas wrap
|
||||
if (this.noWrap) {
|
||||
this._resizeObserver = new ResizeObserver(this._handleInputResize);
|
||||
this._resizeObserver.observe(this._input);
|
||||
EditableText._resizeObserver.observe(this._input);
|
||||
}
|
||||
}
|
||||
this._input.readOnly = this.readOnly;
|
||||
this._input.placeholder = this.placeholder;
|
||||
this._input.removeAttribute('tabindex');
|
||||
// Read-only multiple-values fields are focusable (for keyboard access) but
|
||||
// can't be edited, so keep their "Multiple" placeholder visible while focused.
|
||||
// Editable fields clear it on focus (see _handleFocus), so don't reset it here.
|
||||
if (!(this.multipleValues && this.focused && !this.readOnly)) {
|
||||
this._input.placeholder = this.placeholder;
|
||||
}
|
||||
|
||||
if (this._input.tagName == "textarea") {
|
||||
// Reset to initial state
|
||||
|
|
@ -381,6 +464,9 @@
|
|||
this._ignoredWindowInactiveBlur = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.cancelled = false;
|
||||
this._clearValue = false;
|
||||
|
||||
let valueBeforeFocus = this.value;
|
||||
this.dispatchEvent(new CustomEvent('focus'));
|
||||
|
|
@ -400,7 +486,17 @@
|
|||
}
|
||||
|
||||
if (!('initialValue' in this._input.dataset)) {
|
||||
this._input.dataset.initialValue = this._input.value;
|
||||
this._input.dataset.initialValue = this.value;
|
||||
}
|
||||
|
||||
if (this.multipleValues) {
|
||||
if (!this.readOnly) {
|
||||
this._input.placeholder = '';
|
||||
}
|
||||
this._input.value = '';
|
||||
if (this._input.mController) {
|
||||
this._input.mController.startSearch("");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -410,12 +506,22 @@
|
|||
this._ignoredWindowInactiveBlur = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.multipleValues) {
|
||||
if (this.cancelled || (this._input.value === '' && !this._clearValue)) {
|
||||
this.value = '';
|
||||
this.placeholder = Zotero.getString('item-pane-batch-editing-multiple-values-placeholder');
|
||||
this.cancelled = true;
|
||||
}
|
||||
}
|
||||
|
||||
this.dispatchEvent(new Event('blur'));
|
||||
this._resetStateAfterBlur();
|
||||
};
|
||||
|
||||
_resetStateAfterBlur() {
|
||||
this._ignoredWindowInactiveBlur = false;
|
||||
this._clearValue = false;
|
||||
this._focusMousedownEvent = null;
|
||||
this.classList.remove('focused');
|
||||
this._input.scrollLeft = 0;
|
||||
|
|
@ -436,9 +542,14 @@
|
|||
}
|
||||
}
|
||||
else if (event.key === 'Escape') {
|
||||
let initialValue = this._input.dataset.initialValue ?? '';
|
||||
this.setAttribute('value', initialValue);
|
||||
this._input.value = initialValue;
|
||||
if (this.multipleValues) {
|
||||
this.cancelled = true;
|
||||
}
|
||||
else {
|
||||
let initialValue = this._input.dataset.initialValue ?? '';
|
||||
this.setAttribute('value', initialValue);
|
||||
this._input.value = initialValue;
|
||||
}
|
||||
this._input.blur();
|
||||
}
|
||||
};
|
||||
|
|
@ -497,10 +608,10 @@
|
|||
}
|
||||
};
|
||||
|
||||
_handleInputResize = () => {
|
||||
_isOverflowing() {
|
||||
// Very small floating-point-error allowance
|
||||
const EPSILON = 0.001;
|
||||
this.classList.toggle('overflowing',
|
||||
return (
|
||||
// We're overflowing if the field can scroll at least a pixel
|
||||
this._input.scrollLeftMax > 0
|
||||
// But sometimes it can scroll a *sub*pixel, and every single
|
||||
|
|
@ -513,7 +624,7 @@
|
|||
&& this._getContentWidth() > this._input.getBoundingClientRect().width + EPSILON
|
||||
)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
focus(options) {
|
||||
// If the window isn't active, the focus event won't fire yet,
|
||||
|
|
|
|||
389
chrome/content/zotero/elements/fileRenamingSettings.js
Normal file
389
chrome/content/zotero/elements/fileRenamingSettings.js
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
/*
|
||||
***** BEGIN LICENSE BLOCK *****
|
||||
|
||||
Copyright © 2026 Corporation for Digital Scholarship
|
||||
Vienna, Virginia, USA
|
||||
https://www.zotero.org
|
||||
|
||||
This file is part of Zotero.
|
||||
|
||||
Zotero is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Zotero is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
***** END LICENSE BLOCK *****
|
||||
*/
|
||||
|
||||
{
|
||||
const { DEFAULT_ATTACHMENT_RENAME_TEMPLATE } = ChromeUtils.importESModule("chrome://zotero/content/renameFiles.mjs");
|
||||
const { isTemplateValid } = ChromeUtils.importESModule("chrome://zotero/content/modules/templates.mjs");
|
||||
const DEFAULT_EXT = 'pdf';
|
||||
class FileRenameSettings extends XULElementBase {
|
||||
content = MozXULElement.parseXULToFragment(`
|
||||
<vbox>
|
||||
<groupbox id="file-rename-settings-section-main">
|
||||
<checkbox id="auto-rename-files"
|
||||
data-l10n-id="file-renaming-auto-rename-files"
|
||||
native="true"
|
||||
/>
|
||||
<vbox class="indented-pref" aria-labelledby="file-renaming-file-types" role="group">
|
||||
<label id="file-renaming-file-types" data-l10n-id="file-renaming-file-types"/>
|
||||
<hbox
|
||||
id="file-renaming-file-types-box"
|
||||
class="indented-pref"
|
||||
>
|
||||
<checkbox
|
||||
data-l10n-id="file-renaming-file-type-pdf"
|
||||
data-content-type="application/pdf"
|
||||
native="true"
|
||||
/>
|
||||
<checkbox
|
||||
data-l10n-id="file-renaming-file-type-epub"
|
||||
data-content-type="application/epub+zip"
|
||||
native="true"
|
||||
/>
|
||||
<checkbox
|
||||
data-l10n-id="file-renaming-file-type-image"
|
||||
data-content-type="image/"
|
||||
native="true"
|
||||
/>
|
||||
<checkbox
|
||||
data-l10n-id="file-renaming-file-type-audio"
|
||||
data-content-type="audio/"
|
||||
native="true"
|
||||
/>
|
||||
<checkbox
|
||||
data-l10n-id="file-renaming-file-type-video"
|
||||
data-content-type="video/"
|
||||
native="true"
|
||||
/>
|
||||
</hbox>
|
||||
</vbox>
|
||||
<checkbox id="rename-linked-files" class="indented-pref"
|
||||
data-l10n-id="file-renaming-rename-linked"
|
||||
preference="extensions.zotero.autoRenameFiles.linked"
|
||||
native="true"
|
||||
/>
|
||||
</groupbox>
|
||||
|
||||
<groupbox id="file-rename-settings-section-instructions">
|
||||
<label data-l10n-id="file-renaming-format-instructions" />
|
||||
<separator class="thin" />
|
||||
<label data-l10n-id="file-renaming-format-instructions-example"
|
||||
data-l10n-args='${JSON.stringify({ example: "{{ title truncate=\"50\" }}" })}' />
|
||||
<separator class="thin" />
|
||||
<label data-l10n-id="file-renaming-format-instructions-more">
|
||||
<label
|
||||
is="zotero-text-link"
|
||||
href="https://www.zotero.org/support/file_renaming"
|
||||
data-l10n-name="file-renaming-format-help-link"
|
||||
/>
|
||||
</label>
|
||||
<separator class="thin" />
|
||||
</groupbox>
|
||||
|
||||
<groupbox id="file-rename-settings-section-template">
|
||||
<html:label
|
||||
for="file-renaming-format-template"
|
||||
id="file-renaming-format-template-label"
|
||||
>
|
||||
<html:h2 data-l10n-id="file-renaming-format-template" />
|
||||
</html:label>
|
||||
<html:textarea
|
||||
aria-labelledby="file-renaming-format-template-label"
|
||||
id="file-renaming-format-template"
|
||||
rows="8"
|
||||
/>
|
||||
<html:div id="file-renaming-format-preview-section">
|
||||
<html:label id="file-renaming-format-preview-label">
|
||||
<html:h2
|
||||
data-l10n-id="file-renaming-format-preview"
|
||||
/>
|
||||
</html:label>
|
||||
<html:label
|
||||
aria-labelledby="file-renaming-format-preview-label"
|
||||
id="file-renaming-format-preview"
|
||||
/>
|
||||
</html:div>
|
||||
<label
|
||||
id="file-renaming-template-invalid"
|
||||
class="is-hidden"
|
||||
data-l10n-id="file-renaming-template-invalid"
|
||||
/>
|
||||
</groupbox>
|
||||
</vbox>
|
||||
`);
|
||||
|
||||
static get observedAttributes() {
|
||||
return [
|
||||
'auto-rename-enabled',
|
||||
'file-types',
|
||||
'format-template',
|
||||
'rename-linked-enabled',
|
||||
'rename-linked-hidden',
|
||||
'readonly',
|
||||
];
|
||||
}
|
||||
|
||||
get autoRenameEnabled() {
|
||||
return this.autoRenameToggleCheckbox.checked;
|
||||
}
|
||||
|
||||
set autoRenameEnabled(val) {
|
||||
this.autoRenameToggleCheckbox.checked = val;
|
||||
this.updateDisabled();
|
||||
}
|
||||
|
||||
get renameLinkedEnabled() {
|
||||
return this.renameLinkedCheckbox.checked;
|
||||
}
|
||||
|
||||
set renameLinkedEnabled(val) {
|
||||
this.renameLinkedCheckbox.checked = val;
|
||||
}
|
||||
|
||||
get enabledFileTypes() {
|
||||
let enabledTypes = new Set(
|
||||
(this._enabledFileTypes).split(',').filter(Boolean)
|
||||
);
|
||||
for (let checkbox of this.fileTypesCheckboxes.querySelectorAll('checkbox')) {
|
||||
if (checkbox.checked) {
|
||||
enabledTypes.add(checkbox.dataset.contentType);
|
||||
}
|
||||
else {
|
||||
enabledTypes.delete(checkbox.dataset.contentType);
|
||||
}
|
||||
}
|
||||
return [...enabledTypes].join(',');
|
||||
}
|
||||
|
||||
set enabledFileTypes(types) {
|
||||
this._enabledFileTypes = types;
|
||||
let enabledTypes = new Set(
|
||||
(this._enabledFileTypes).split(',').filter(Boolean)
|
||||
);
|
||||
for (let checkbox of this.fileTypesCheckboxes.querySelectorAll('checkbox')) {
|
||||
checkbox.checked = enabledTypes.has(checkbox.dataset.contentType);
|
||||
}
|
||||
}
|
||||
|
||||
get formatTemplate() {
|
||||
return this.formatTemplateTextarea.value;
|
||||
}
|
||||
|
||||
set formatTemplate(val) {
|
||||
this.formatTemplateTextarea.value = val;
|
||||
}
|
||||
|
||||
get templateValid() {
|
||||
return this._isTemplateValid(this.formatTemplate);
|
||||
}
|
||||
|
||||
// Validate `template`, memoizing the last result. Normalize first so the dialog judges the
|
||||
// same string the rename engine validates and renders (see Zotero.Attachments.normalizeRenameTemplate)
|
||||
_isTemplateValid(template) {
|
||||
let normalized = Zotero.Attachments.normalizeRenameTemplate(template);
|
||||
if (normalized !== this._validatedTemplate) {
|
||||
this._validatedTemplate = normalized;
|
||||
this._validatedResult = this._validateTemplate(normalized);
|
||||
}
|
||||
return this._validatedResult;
|
||||
}
|
||||
|
||||
// Engine parse behind an instance method, so tests can count parses (a frozen ESM
|
||||
// namespace export cannot be spied directly)
|
||||
_validateTemplate(normalized) {
|
||||
return isTemplateValid(normalized);
|
||||
}
|
||||
|
||||
handleChange = () => {
|
||||
let autoRenameEnabled = this.autoRenameEnabled;
|
||||
let enabledFileTypes = this.enabledFileTypes;
|
||||
let renameLinkedEnabled = this.renameLinkedEnabled;
|
||||
let formatTemplate = this.formatTemplate;
|
||||
this.dispatchEvent(new CustomEvent("change", {
|
||||
detail: {
|
||||
autoRenameEnabled,
|
||||
enabledFileTypes,
|
||||
renameLinkedEnabled,
|
||||
formatTemplate
|
||||
},
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
}));
|
||||
};
|
||||
|
||||
handleTemplateInput = () => {
|
||||
let formatString = this.formatTemplateTextarea.value;
|
||||
// A whitespace-only value will be reset to the default on blur; preview the default
|
||||
// (so any stale syntax-error message clears) but don't propagate the change yet
|
||||
if (formatString.replace(/\s/g, '') === '') {
|
||||
this._renderPreview(DEFAULT_ATTACHMENT_RENAME_TEMPLATE);
|
||||
return;
|
||||
}
|
||||
// Keep showing the previous preview while the user types through an invalid state.
|
||||
// If the template is still invalid on blur, handleTemplateBlur() will show the error.
|
||||
if (this.templateValid) {
|
||||
this.updatePreview();
|
||||
}
|
||||
this.handleChange();
|
||||
};
|
||||
|
||||
handleTemplateBlur = () => {
|
||||
let formatString = this.formatTemplateTextarea.value;
|
||||
if (formatString.replace(/\s/g, '') === '') {
|
||||
this.formatTemplateTextarea.value = DEFAULT_ATTACHMENT_RENAME_TEMPLATE;
|
||||
this.updatePreview();
|
||||
this.handleChange();
|
||||
}
|
||||
else {
|
||||
this.updatePreview();
|
||||
}
|
||||
};
|
||||
|
||||
handleRenameToggle = () => {
|
||||
this.autoRenameEnabled = this.autoRenameToggleCheckbox.checked;
|
||||
this.handleChange();
|
||||
};
|
||||
|
||||
updateDisabled = () => {
|
||||
let readonly = this.getAttribute('readonly') === 'true';
|
||||
for (let checkbox of this.fileTypesCheckboxes.querySelectorAll('checkbox')) {
|
||||
checkbox.disabled = readonly || !this.autoRenameEnabled;
|
||||
}
|
||||
this.autoRenameToggleCheckbox.disabled = readonly;
|
||||
this.renameLinkedCheckbox.disabled = readonly || !this.autoRenameEnabled;
|
||||
this.formatTemplateTextarea.readOnly = readonly;
|
||||
};
|
||||
|
||||
// Note: also used as an onSelect listener, so it must take no positional arguments
|
||||
updatePreview = () => {
|
||||
this._renderPreview(this.formatTemplate);
|
||||
};
|
||||
|
||||
_renderPreview = (formatString) => {
|
||||
let valid = this._isTemplateValid(formatString);
|
||||
this.previewSection.classList.toggle('is-hidden', !valid);
|
||||
this.invalidMessage.classList.toggle('is-hidden', valid);
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
let [item, ext, attachmentTitle] = this.getActiveItem() ?? [this.mockItem ?? this.makeMockItem(), DEFAULT_EXT, ''];
|
||||
let preview = Zotero.Attachments.getFileBaseNameFromItem(item, { formatString, attachmentTitle });
|
||||
this.querySelector('#file-renaming-format-preview').innerText = `${preview}.${ext}`;
|
||||
};
|
||||
|
||||
async init() {
|
||||
this.sectionMain = this.querySelector('#file-rename-settings-section-main');
|
||||
this.sectionInstructions = this.querySelector('#file-rename-settings-section-instructions');
|
||||
this.sectionTemplate = this.querySelector('#file-rename-settings-section-template');
|
||||
this.autoRenameToggleCheckbox = this.querySelector('#auto-rename-files');
|
||||
this.fileTypesCheckboxes = this.querySelector('#file-renaming-file-types-box');
|
||||
this.renameLinkedCheckbox = this.querySelector('#rename-linked-files');
|
||||
this.formatTemplateTextarea = this.querySelector('#file-renaming-format-template');
|
||||
this.previewSection = this.querySelector('#file-renaming-format-preview-section');
|
||||
this.invalidMessage = this.querySelector('#file-renaming-template-invalid');
|
||||
this.enabledFileTypes = this.getAttribute('file-types') ?? '';
|
||||
this.autoRenameEnabled = this.getAttribute('auto-rename-enabled') === 'true';
|
||||
this.renameLinkedCheckbox.checked = this.getAttribute('rename-linked-enabled') === 'true';
|
||||
this.formatTemplate = this.getAttribute('format-template') ?? '';
|
||||
this.renameLinkedCheckbox.hidden = this.getAttribute('rename-linked-hidden') === 'true';
|
||||
|
||||
this.autoRenameToggleCheckbox.addEventListener("command", this.handleRenameToggle);
|
||||
this.fileTypesCheckboxes.addEventListener("command", this.handleChange);
|
||||
this.renameLinkedCheckbox.addEventListener("command", this.handleChange);
|
||||
this.formatTemplateTextarea.addEventListener("input", this.handleTemplateInput);
|
||||
this.formatTemplateTextarea.addEventListener("blur", this.handleTemplateBlur);
|
||||
|
||||
this._itemsView = Zotero.getActiveZoteroPane()?.itemsView;
|
||||
if (this._itemsView) {
|
||||
this._itemsView.onSelect.addListener(this.updatePreview);
|
||||
}
|
||||
|
||||
this.updatePreview();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._itemsView?.onSelect.removeListener(this.updatePreview);
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
if (!this.sectionMain) return;
|
||||
|
||||
switch (name) {
|
||||
case 'auto-rename-enabled':
|
||||
this.autoRenameEnabled = newValue === 'true';
|
||||
break;
|
||||
case 'file-types':
|
||||
this.enabledFileTypes = newValue ?? '';
|
||||
break;
|
||||
case 'format-template':
|
||||
this.formatTemplate = newValue ?? '';
|
||||
this.updatePreview();
|
||||
break;
|
||||
case 'rename-linked-enabled':
|
||||
this.renameLinkedCheckbox.checked = newValue === 'true';
|
||||
break;
|
||||
case 'rename-linked-hidden':
|
||||
this.renameLinkedCheckbox.hidden = newValue === 'true';
|
||||
break;
|
||||
case 'readonly':
|
||||
this.updateDisabled();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
getActiveItem() {
|
||||
let selectedItem = Zotero.getActiveZoteroPane()?.getSelectedItems()?.[0];
|
||||
if (selectedItem) {
|
||||
if (selectedItem.isRegularItem() && !selectedItem.parentKey) {
|
||||
return [selectedItem, DEFAULT_EXT, ''];
|
||||
}
|
||||
if (selectedItem.isFileAttachment() && selectedItem.parentKey) {
|
||||
let ext = Zotero.Attachments.getCorrectFileExtension(selectedItem);
|
||||
let parentItem = Zotero.Items.getByLibraryAndKey(selectedItem.libraryID, selectedItem.parentKey);
|
||||
return [parentItem, ext ?? DEFAULT_EXT, selectedItem.getField('title')];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
makeMockItem() {
|
||||
this.mockItem = new Zotero.Item('journalArticle');
|
||||
this.mockItem.libraryID = Zotero.Libraries.userLibraryID;
|
||||
this.mockItem.setField('title', 'Example Title: Example Subtitle');
|
||||
this.mockItem.setCreators([
|
||||
{ firstName: 'Jane', lastName: 'Doe', creatorType: 'author' },
|
||||
{ firstName: 'John', lastName: 'Smith', creatorType: 'author' }
|
||||
]);
|
||||
this.mockItem.setField('shortTitle', 'Example Title');
|
||||
this.mockItem.setField('publicationTitle', 'Advances in Example Engineering');
|
||||
this.mockItem.setField('volume', '9');
|
||||
this.mockItem.setField('issue', '1');
|
||||
this.mockItem.setField('pages', '34-55');
|
||||
this.mockItem.setField('date', '2018');
|
||||
this.mockItem.setField('DOI', '10.1016/1234-example');
|
||||
this.mockItem.setField('ISSN', '1234-5678');
|
||||
this.mockItem.setField('abstractNote', 'This is an example abstract.');
|
||||
this.mockItem.setField('extra', 'This is an example Extra field.');
|
||||
this.mockItem.setField('accessDate', '2020-01-01');
|
||||
this.mockItem.setField('url', 'https://example.com');
|
||||
this.mockItem.setField('libraryCatalog', 'Example Library Catalog');
|
||||
return this.mockItem;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('file-renaming-settings', FileRenameSettings);
|
||||
}
|
||||
|
|
@ -41,6 +41,8 @@ const getAnchorOffset = (anchorEl, popoverEl, padding = 5) => {
|
|||
<panel type="arrow" align="top">
|
||||
<html:div class="panel-container">
|
||||
<html:div class="panel-text"></html:div>
|
||||
<html:button class="dismiss-button" hidden="hidden"
|
||||
data-l10n-id="general-got-it"></html:button>
|
||||
</html:div>
|
||||
</panel>
|
||||
`);
|
||||
|
|
@ -72,11 +74,23 @@ const getAnchorOffset = (anchorEl, popoverEl, padding = 5) => {
|
|||
|
||||
if (this.getAttribute("noautohide") == 'true'
|
||||
&& !this.hasAttribute('forward')) {
|
||||
let listener = () => {
|
||||
this.panel.removeEventListener("click", listener);
|
||||
let dismiss = () => {
|
||||
if (this._pref) {
|
||||
Zotero.Prefs.set(this._pref, true);
|
||||
}
|
||||
this.panel.hidePopup();
|
||||
};
|
||||
this.panel.addEventListener("click", listener);
|
||||
let dismissButton = this.querySelector('.dismiss-button');
|
||||
dismissButton.hidden = false;
|
||||
dismissButton.addEventListener("click", dismiss);
|
||||
this.panel.addEventListener('popupshown', () => {
|
||||
dismissButton.focus();
|
||||
});
|
||||
this.panel.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === "Escape") {
|
||||
dismiss();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -84,6 +98,7 @@ const getAnchorOffset = (anchorEl, popoverEl, padding = 5) => {
|
|||
* @param {Object} [options]
|
||||
* @param {String} [options.text] Text to use in place of firstRunGuidance.<about>
|
||||
* @param {DOMElement} [options.forEl] Anchor node
|
||||
* @param {Object} [options.l10nArgs] Localization arguments for text in the panel
|
||||
* @param {Boolean} [options.force] Show even if already shown
|
||||
*/
|
||||
async show(options) {
|
||||
|
|
@ -121,10 +136,10 @@ const getAnchorOffset = (anchorEl, popoverEl, padding = 5) => {
|
|||
|
||||
if (!useLastText) {
|
||||
if (!text) {
|
||||
text = await document.l10n.formatValue("first-run-guidance-" + about);
|
||||
text = await document.l10n.formatValue("first-run-guidance-" + about, options.l10nArgs || {});
|
||||
}
|
||||
text = text.split("\n");
|
||||
var descriptionNode = document.querySelector('.panel-text');
|
||||
var descriptionNode = this.querySelector('.panel-text');
|
||||
|
||||
while (descriptionNode.hasChildNodes()) {
|
||||
descriptionNode.removeChild(descriptionNode.firstChild);
|
||||
|
|
@ -143,14 +158,49 @@ const getAnchorOffset = (anchorEl, popoverEl, padding = 5) => {
|
|||
var f = () => {
|
||||
if (this.hasAttribute("foregroundonly") && Services.ww.activeWindow != window) return;
|
||||
|
||||
// If the anchor element is inside another frame, create a proxy
|
||||
// element in the parent document at the anchor's visual location
|
||||
if (forEl.ownerDocument !== document) {
|
||||
let iframe = forEl.documentGlobal.frameElement;
|
||||
if (iframe) {
|
||||
let iframeRect = iframe.getBoundingClientRect();
|
||||
let forElRect = forEl.getBoundingClientRect();
|
||||
let proxy = document.createElement('div');
|
||||
proxy.style.position = 'fixed';
|
||||
proxy.style.left = (iframeRect.left + forElRect.left) + 'px';
|
||||
proxy.style.top = (iframeRect.top + forElRect.top) + 'px';
|
||||
proxy.style.width = forElRect.width + 'px';
|
||||
proxy.style.height = forElRect.height + 'px';
|
||||
proxy.style.pointerEvents = 'none';
|
||||
document.documentElement.appendChild(proxy);
|
||||
forEl = proxy;
|
||||
this.panel.addEventListener('popuphidden', () => {
|
||||
proxy.remove();
|
||||
}, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (this.getAttribute('noautohide') === 'true') {
|
||||
this.panel.setAttribute('noautohide', 'true');
|
||||
}
|
||||
else {
|
||||
this.panel.removeAttribute('noautohide');
|
||||
}
|
||||
this.panel.openPopup(forEl, position || "after_start",
|
||||
x ? parseInt(x, 10) : 0, y ? parseInt(y, 10) : 0);
|
||||
|
||||
const anchorOffset = getAnchorOffset(forEl, this.panel);
|
||||
// On Win/Linux, the arrow appears disconnected from the panel if too close to the edge
|
||||
const padding = Zotero.isMac ? 5 : 10;
|
||||
const anchorOffset = getAnchorOffset(forEl, this.panel, padding);
|
||||
this.panel.style.setProperty('--anchor-x', `${anchorOffset}px`);
|
||||
|
||||
if (pref) {
|
||||
Zotero.Prefs.set(pref, true);
|
||||
if (this.getAttribute('noautohide') === 'true') {
|
||||
this._pref = pref;
|
||||
}
|
||||
else {
|
||||
Zotero.Prefs.set(pref, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -24,32 +24,12 @@
|
|||
*/
|
||||
|
||||
{
|
||||
const waitFrame = async () => {
|
||||
return waitNoLongerThan(new Promise((resolve) => {
|
||||
requestAnimationFrame(resolve);
|
||||
}), 30);
|
||||
};
|
||||
|
||||
const waitFrames = async (n) => {
|
||||
for (let i = 0; i < n; i++) {
|
||||
await waitFrame();
|
||||
}
|
||||
};
|
||||
const { ItemPaneContainerBase } = ChromeUtils.importESModule(
|
||||
"chrome://zotero/content/elements/itemPaneContainerBase.mjs",
|
||||
{ global: "current" }
|
||||
);
|
||||
|
||||
const waitDOMUpdate = async (timeout = 50) => {
|
||||
return new Promise((resolve) => {
|
||||
requestIdleCallback(resolve, { timeout });
|
||||
});
|
||||
};
|
||||
|
||||
const waitNoLongerThan = async (promise, ms = 1000) => {
|
||||
return Promise.race([
|
||||
promise,
|
||||
Zotero.Promise.delay(ms)
|
||||
]);
|
||||
};
|
||||
|
||||
class ItemDetails extends XULElementBase {
|
||||
class ItemDetails extends ItemPaneContainerBase {
|
||||
content = MozXULElement.parseXULToFragment(`
|
||||
<hbox id="zotero-view-item-container" class="zotero-view-item-container" flex="1">
|
||||
<html:div class="zotero-view-item-main">
|
||||
|
|
@ -88,6 +68,18 @@
|
|||
this._item = item;
|
||||
}
|
||||
|
||||
get extraItems() {
|
||||
return this._extraItems ?? [];
|
||||
}
|
||||
|
||||
set extraItems(val) {
|
||||
if (!Array.isArray(val)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._extraItems = val.filter(item => item instanceof Zotero.Item && item.isRegularItem());
|
||||
}
|
||||
|
||||
/*
|
||||
* For contextPane update
|
||||
*/
|
||||
|
|
@ -116,20 +108,20 @@
|
|||
this._tabID = tabID;
|
||||
}
|
||||
|
||||
get tabType() {
|
||||
return this.getAttribute('tabType');
|
||||
get collectionTreeRows() {
|
||||
return this._collectionTreeRows;
|
||||
}
|
||||
|
||||
set collectionTreeRows(collectionTreeRows) {
|
||||
this._collectionTreeRows = collectionTreeRows;
|
||||
}
|
||||
|
||||
set tabType(tabType) {
|
||||
this.setAttribute('tabType', tabType);
|
||||
get supportsPinning() {
|
||||
return true;
|
||||
}
|
||||
|
||||
get collectionTreeRow() {
|
||||
return this._collectionTreeRow;
|
||||
}
|
||||
|
||||
set collectionTreeRow(collectionTreeRow) {
|
||||
this._collectionTreeRow = collectionTreeRow;
|
||||
|
||||
get supportsReorder() {
|
||||
return true;
|
||||
}
|
||||
|
||||
get pinnedPane() {
|
||||
|
|
@ -151,47 +143,12 @@
|
|||
this._savePinnedPane();
|
||||
}
|
||||
|
||||
get _minScrollHeight() {
|
||||
return parseFloat(this._paneParent.style.getPropertyValue('--min-scroll-height') || 0);
|
||||
}
|
||||
|
||||
set _minScrollHeight(val) {
|
||||
this._paneParent.style.setProperty('--min-scroll-height', val + 'px');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience getter that delegates to our <item-pane> / <context-pane>
|
||||
* parent. This exists because the sidenav controls us, not the <item-pane>,
|
||||
* but needs to get/set its collapsed state.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get _collapsed() {
|
||||
let parentPane = this.closest('item-pane, context-pane');
|
||||
if (!parentPane) {
|
||||
return false;
|
||||
}
|
||||
return parentPane.collapsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience setter for collapsed state. See above.
|
||||
* @param {boolean} val
|
||||
*/
|
||||
set _collapsed(val) {
|
||||
let parentPane = this.closest('item-pane, context-pane');
|
||||
if (!parentPane) {
|
||||
return;
|
||||
}
|
||||
parentPane.collapsed = val;
|
||||
}
|
||||
|
||||
get sidenav() {
|
||||
return this._sidenav;
|
||||
return super.sidenav;
|
||||
}
|
||||
|
||||
set sidenav(sidenav) {
|
||||
this._sidenav = sidenav;
|
||||
sidenav.container = this;
|
||||
super.sidenav = sidenav;
|
||||
// Manually update once and further changes will be synced automatically to sidenav
|
||||
this.forceUpdateSideNav();
|
||||
}
|
||||
|
|
@ -293,7 +250,12 @@
|
|||
box.tabID = this.tabID;
|
||||
box.tabType = this.tabType;
|
||||
box.item = item;
|
||||
box.collectionTreeRow = this.collectionTreeRow;
|
||||
box.extraItems = this.extraItems;
|
||||
box.collectionTreeRows = this.collectionTreeRows;
|
||||
if (this.extraItems.length > 0) {
|
||||
// mark everything, except the header and the info pane, as hidden
|
||||
box.hidden = box.dataset.pane !== 'info' && box !== this._header;
|
||||
}
|
||||
// Discard hidden panes
|
||||
if (box.hidden && box.discard) {
|
||||
box.discard();
|
||||
|
|
@ -337,9 +299,9 @@
|
|||
if (!this.isPaneVisible(box.dataset.pane)) {
|
||||
continue;
|
||||
}
|
||||
await waitNoLongerThan(box.asyncRender(), 500);
|
||||
await this._waitNoLongerThan(box.asyncRender(), 500);
|
||||
// Make sure the layout is updated for next isPaneVisible check
|
||||
await waitDOMUpdate();
|
||||
await this._waitDOMUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -431,22 +393,6 @@
|
|||
}
|
||||
};
|
||||
|
||||
getPane(id) {
|
||||
return this._paneParent.querySelector(`:scope > [data-pane="${CSS.escape(id)}"]`);
|
||||
}
|
||||
|
||||
getEnabledPane(id) {
|
||||
return this._paneParent.querySelector(`:scope > [data-pane="${CSS.escape(id)}"]:not([hidden])`);
|
||||
}
|
||||
|
||||
getPanes() {
|
||||
return Array.from(this._paneParent.querySelectorAll(':scope > [data-pane]'));
|
||||
}
|
||||
|
||||
getEnabledPanes() {
|
||||
return Array.from(this._paneParent.querySelectorAll(':scope > [data-pane]:not([hidden])'));
|
||||
}
|
||||
|
||||
getVisiblePanes() {
|
||||
let panes = this.getPanes();
|
||||
let visiblePanes = [];
|
||||
|
|
@ -543,111 +489,29 @@
|
|||
}
|
||||
}
|
||||
|
||||
async scrollToPane(paneID, behavior = 'smooth') {
|
||||
let panes = this.getEnabledPanes();
|
||||
let paneIndex = panes.findIndex(elem => elem.dataset.pane == paneID);
|
||||
let pane = panes[paneIndex];
|
||||
if (!pane) return null;
|
||||
|
||||
let scrollPromise;
|
||||
|
||||
// If the itemPane is collapsed, just remember which pane needs to be scrolled to
|
||||
// when itemPane is expanded.
|
||||
if (this._collapsed) {
|
||||
this._lastScrollPaneID = paneID;
|
||||
return null;
|
||||
}
|
||||
|
||||
// If the pane is already at the top, no need to scroll
|
||||
if (Math.abs(pane.getBoundingClientRect().top - this._paneParent.getBoundingClientRect().top) < 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Temporarily disable intersection observer to prevent unwanted rendering
|
||||
_beforeScrollToPane(_pane) {
|
||||
// Suspend the intersection observer for the duration of the scroll so
|
||||
// the pane we're scrolling toward (and any panes that swing through
|
||||
// the viewport on the way) don't trigger lazy renders mid-flight.
|
||||
this._toggleIntersectionObserver(false);
|
||||
}
|
||||
|
||||
// The pane should always be at the very top
|
||||
// If there isn't enough stuff below it for it to be at the top, we add padding
|
||||
// We use a ::before pseudo-element for this so that we don't need to add another level to the DOM
|
||||
this._makeSpaceForPane(pane);
|
||||
if (behavior == 'smooth') {
|
||||
this._disableScrollHandler = true;
|
||||
scrollPromise = this._waitForScroll();
|
||||
scrollPromise.then(() => this._disableScrollHandler = false);
|
||||
}
|
||||
else {
|
||||
// Wait for the next DOM update to make sure the height is updated before rendering
|
||||
scrollPromise = waitDOMUpdate();
|
||||
}
|
||||
pane.scrollIntoView({ block: 'start', behavior });
|
||||
pane.focus();
|
||||
await scrollPromise;
|
||||
|
||||
// Check current and following panes for async render
|
||||
async _afterScrollToPane(_pane, panes, paneIndex) {
|
||||
// After the scroll settles, async-render the pane we landed on and
|
||||
// any visible panes below it, then re-enable the intersection observer
|
||||
for (let i = paneIndex; i < panes.length; i++) {
|
||||
let nextPane = panes[i];
|
||||
// Stop if the pane is not visible anymore
|
||||
// Stop at the first pane no longer in view
|
||||
if (!this.isPaneVisible(nextPane.dataset.pane)) {
|
||||
break;
|
||||
}
|
||||
if (nextPane.asyncRender) {
|
||||
await nextPane.asyncRender();
|
||||
// Wait for the next DOM update to make sure the layout is updated
|
||||
await waitDOMUpdate();
|
||||
await this._waitDOMUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
// Re-enable intersection observer
|
||||
this._toggleIntersectionObserver(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
_makeSpaceForPane(pane) {
|
||||
let oldMinScrollHeight = this._minScrollHeight;
|
||||
let newMinScrollHeight = this._getMinScrollHeightForPane(pane);
|
||||
if (newMinScrollHeight > oldMinScrollHeight) {
|
||||
this._minScrollHeight = newMinScrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
_getMinScrollHeightForPane(pane) {
|
||||
let paneRect = pane.getBoundingClientRect();
|
||||
let containerRect = this._paneParent.getBoundingClientRect();
|
||||
// No offsetTop property for XUL elements
|
||||
let offsetTop = paneRect.top - containerRect.top + this._paneParent.scrollTop;
|
||||
return offsetTop + containerRect.height;
|
||||
}
|
||||
|
||||
async _waitForScroll() {
|
||||
let scrollPromise = Zotero.Promise.defer();
|
||||
let lastScrollTop = this._paneParent.scrollTop;
|
||||
const checkScrollStart = () => {
|
||||
// If the scrollTop is not changed, wait for scroll to happen
|
||||
if (lastScrollTop === this._paneParent.scrollTop) {
|
||||
requestAnimationFrame(checkScrollStart);
|
||||
}
|
||||
// Wait for scroll to end
|
||||
else {
|
||||
requestAnimationFrame(checkScrollEnd);
|
||||
}
|
||||
};
|
||||
const checkScrollEnd = async () => {
|
||||
// Wait for 3 frames to make sure not further scrolls
|
||||
await waitFrames(3);
|
||||
if (lastScrollTop === this._paneParent.scrollTop) {
|
||||
scrollPromise.resolve();
|
||||
}
|
||||
else {
|
||||
lastScrollTop = this._paneParent.scrollTop;
|
||||
requestAnimationFrame(checkScrollEnd);
|
||||
}
|
||||
};
|
||||
checkScrollStart();
|
||||
// Abort after 3 seconds, which should be enough
|
||||
return Promise.race([
|
||||
scrollPromise.promise,
|
||||
Zotero.Promise.delay(3000)
|
||||
]);
|
||||
}
|
||||
|
||||
async blurOpenField() {
|
||||
|
|
|
|||
|
|
@ -40,8 +40,11 @@
|
|||
previousfocus="zotero-items-tree" />
|
||||
|
||||
<duplicates-merge-pane id="zotero-duplicates-merge-pane" />
|
||||
|
||||
<annotation-items-pane id="zotero-annotations-pane" />
|
||||
<groupbox id="batch-edit-prompt" pack="center" align="center" data-l10n-id="item-pane-batch-editing-prompt">
|
||||
<description id="batch-edit-prompt-message" />
|
||||
<button id="batch-edit-prompt-enable" data-l10n-id="item-pane-batch-editing-enable" />
|
||||
</groupbox>
|
||||
</deck>
|
||||
<item-pane-sidenav id="zotero-view-item-sidenav" no-context-notes="true" class="zotero-view-item-sidenav"/>
|
||||
`);
|
||||
|
|
@ -52,6 +55,8 @@
|
|||
this._duplicatesPane = this.querySelector("#zotero-duplicates-merge-pane");
|
||||
this._messagePane = this.querySelector("#zotero-item-message");
|
||||
this._annotationsPane = this.querySelector("#zotero-annotations-pane");
|
||||
this._batchEditEnableBtn = this.querySelector("#batch-edit-prompt button");
|
||||
this._batchEditPromptMessage = this.querySelector("#batch-edit-prompt-message");
|
||||
this._sidenav = this.querySelector("#zotero-view-item-sidenav");
|
||||
this._deck = this.querySelector("#zotero-item-pane-content");
|
||||
|
||||
|
|
@ -59,6 +64,14 @@
|
|||
|
||||
this._notifierID = Zotero.Notifier.registerObserver(this, ['item']);
|
||||
|
||||
this._batchEditEnableBtn.addEventListener("command", () => {
|
||||
this._isBatchEditEnabled = true;
|
||||
this._setBatchEditCollapsible(true);
|
||||
this.render();
|
||||
this.updateItemPaneButtons();
|
||||
});
|
||||
|
||||
this._isBatchEditEnabled = false;
|
||||
this._translationTarget = null;
|
||||
}
|
||||
|
||||
|
|
@ -74,12 +87,12 @@
|
|||
this._data = data;
|
||||
}
|
||||
|
||||
get collectionTreeRow() {
|
||||
return this._collectionTreeRow;
|
||||
get collectionTreeRows() {
|
||||
return this._collectionTreeRows;
|
||||
}
|
||||
|
||||
set collectionTreeRow(val) {
|
||||
this._collectionTreeRow = val;
|
||||
set collectionTreeRows(val) {
|
||||
this._collectionTreeRows = val;
|
||||
}
|
||||
|
||||
get itemsView() {
|
||||
|
|
@ -100,12 +113,12 @@
|
|||
}
|
||||
|
||||
get mode() {
|
||||
return ["message", "item", "note", "duplicates"][this._deck.selectedIndex];
|
||||
return ["message", "item", "note", "duplicates", "annotations", "batch-edit-prompt"][this._deck.selectedIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set mode of item pane
|
||||
* @param {"message" | "item" | "note" | "duplicates"} type view type
|
||||
* @param {"message" | "item" | "note" | "duplicates" | "annotations" | "batch-edit-prompt"} type view type
|
||||
*/
|
||||
set mode(type) {
|
||||
this.setAttribute("view-type", type);
|
||||
|
|
@ -126,10 +139,26 @@
|
|||
if (this.data.length > 0 && this.data.every(item => item.isAnnotation())) {
|
||||
return renderStatus = this.renderAnnotations(this.data);
|
||||
}
|
||||
|
||||
// reset the batch editing flag
|
||||
let IDs = this.data.map(item => item.id);
|
||||
if (!(IDs.length === this._prevIDs?.length && IDs.every((id, i) => id === this._prevIDs?.[i]))) {
|
||||
if (this._isBatchEditEnabled) {
|
||||
this._setBatchEditCollapsible(false);
|
||||
}
|
||||
this._isBatchEditEnabled = false;
|
||||
this._prevIDs = IDs;
|
||||
}
|
||||
|
||||
// Multiple items selected (not duplicates)
|
||||
if (!this.collectionTreeRows[0].isDuplicates() && this.data.length > 1 && this.data.every(item => item.isRegularItem() && !item.isFeedItem)) {
|
||||
// Hide the batch editing UI until the user opts-in
|
||||
renderStatus = this._isBatchEditEnabled ? this.renderItemPane(this.data) : this.renderBatchEditorPrompt();
|
||||
}
|
||||
// Single item selected
|
||||
if (this.data.length == 1) {
|
||||
else if (this.data.length === 1) {
|
||||
let item = this.data[0];
|
||||
|
||||
|
||||
// If a collection or search is selected, it must be in the trash.
|
||||
if (item instanceof Zotero.Collection || item instanceof Zotero.Search) {
|
||||
renderStatus = this.renderMessage();
|
||||
|
|
@ -141,7 +170,7 @@
|
|||
renderStatus = this.renderItemPane(item);
|
||||
}
|
||||
}
|
||||
// Zero or multiple items selected
|
||||
// No items selected or multiple, but includes some irregular items
|
||||
else {
|
||||
renderStatus = this.renderMessage();
|
||||
}
|
||||
|
|
@ -150,7 +179,7 @@
|
|||
|
||||
notify(action, type) {
|
||||
if (type == 'item' && action == 'modify') {
|
||||
if (this.collectionTreeRow && this.collectionTreeRow.isFeedsOrFeed()) {
|
||||
if (this.collectionTreeRows?.[0]?.isFeedsOrFeed()) {
|
||||
this.updateReadLabel();
|
||||
}
|
||||
}
|
||||
|
|
@ -175,9 +204,12 @@
|
|||
return true;
|
||||
}
|
||||
|
||||
async renderItemPane(item) {
|
||||
async renderItemPane(items) {
|
||||
let previousMode = this.mode;
|
||||
this.mode = "item";
|
||||
if (!Array.isArray(items)) {
|
||||
items = [items];
|
||||
}
|
||||
|
||||
// Fix https://forums.zotero.org/discussion/115450/zotero-7-beta-wrong-vertical-position-in-the-item-pane-after-switching-from-a-note
|
||||
if (previousMode === "note") {
|
||||
|
|
@ -186,20 +218,21 @@
|
|||
requestIdleCallback(resolve, { timeout: 50 });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
this._itemDetails.editable = this.editable;
|
||||
this._itemDetails.tabID = "zotero-pane";
|
||||
this._itemDetails.tabType = "library";
|
||||
this._itemDetails.item = item;
|
||||
this._itemDetails.collectionTreeRow = this.collectionTreeRow;
|
||||
this._itemDetails.item = items[0];
|
||||
this._itemDetails.extraItems = items.slice(1);
|
||||
this._itemDetails.collectionTreeRows = this.collectionTreeRows;
|
||||
|
||||
this._itemDetails.render();
|
||||
|
||||
if (this.getAttribute("collapsed") == "true") {
|
||||
if (this.hasAttribute("collapsed")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (item.isFeedItem) {
|
||||
|
||||
if (items[0].isFeedItem) {
|
||||
let lastTranslationTarget = Zotero.Prefs.get('feeds.lastTranslationTarget');
|
||||
if (lastTranslationTarget) {
|
||||
let id = parseInt(lastTranslationTarget.substr(1));
|
||||
|
|
@ -218,7 +251,7 @@
|
|||
// if (!item.isTranslated) {
|
||||
// item.translate();
|
||||
// }
|
||||
ZoteroPane.startItemReadTimeout(item.id);
|
||||
ZoteroPane.startItemReadTimeout(items[0].id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -229,7 +262,7 @@
|
|||
let count = this.data.length;
|
||||
|
||||
// Display duplicates merge interface in item pane
|
||||
if (this.collectionTreeRow.isDuplicates()) {
|
||||
if (this.collectionTreeRows[0].isDuplicates()) {
|
||||
if (!this.editable) {
|
||||
if (count) {
|
||||
msg = Zotero.getString('pane.item.duplicates.writeAccessRequired');
|
||||
|
|
@ -244,7 +277,7 @@
|
|||
|
||||
// On a Select All of more than a few items, display a row
|
||||
// count instead of the usual item type mismatch error
|
||||
let displayNumItemsOnTypeError = count > 5 && count == this.itemsView.rowCount;
|
||||
let displayNumItemsOnTypeError = count > 5 && count == this.itemsView.objectRowCount;
|
||||
|
||||
// Initialize the merge pane with the selected items
|
||||
this._duplicatesPane.setItems(this.data, displayNumItemsOnTypeError);
|
||||
|
|
@ -259,7 +292,7 @@
|
|||
if (count) {
|
||||
let key;
|
||||
// In the trash, we have to check the object type
|
||||
if (this.collectionTreeRow.isTrash()) {
|
||||
if (this.collectionTreeRows[0].isTrash()) {
|
||||
if (this.data.every(x => x instanceof Zotero.Collection)) {
|
||||
key = 'item-pane-message-collections-selected';
|
||||
}
|
||||
|
|
@ -279,8 +312,8 @@
|
|||
msg = { l10nId: key, l10nArgs: { count } };
|
||||
}
|
||||
else {
|
||||
let count = this.itemsView.rowCount;
|
||||
if (this.collectionTreeRow.isTrash()
|
||||
let count = this.itemsView.objectRowCount;
|
||||
if (this.collectionTreeRows[0].isTrash()
|
||||
&& this.itemsView._rows?.some(
|
||||
x => x.ref instanceof Zotero.Collection || x.ref instanceof Zotero.Search
|
||||
)) {
|
||||
|
|
@ -298,6 +331,16 @@
|
|||
return true;
|
||||
}
|
||||
|
||||
renderBatchEditorPrompt() {
|
||||
this.mode = 'batch-edit-prompt';
|
||||
document.l10n.setAttributes(
|
||||
this._batchEditPromptMessage,
|
||||
'item-pane-message-items-selected',
|
||||
{ count: this.data.length }
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
setItemPaneMessage(msg) {
|
||||
this.mode = "message";
|
||||
this._messagePane.render(msg);
|
||||
|
|
@ -315,7 +358,7 @@
|
|||
}
|
||||
|
||||
// My Publications buttons
|
||||
var isPublications = this.collectionTreeRow.isPublications();
|
||||
var isPublications = this.collectionTreeRows[0].isPublications();
|
||||
// Show in My Publications view if selected items are all notes or non-linked-file attachments
|
||||
var showMyPublicationsButtons = isPublications
|
||||
&& this.data.every((item) => {
|
||||
|
|
@ -331,13 +374,13 @@
|
|||
|
||||
// Trash button
|
||||
let nonDeletedItemsSelected = this.data.some(item => !item.deleted);
|
||||
if (this.collectionTreeRow.isTrash() && !nonDeletedItemsSelected) {
|
||||
if (this.collectionTreeRows[0].isTrash() && !nonDeletedItemsSelected) {
|
||||
container.renderCustomHead(this.renderTrashHead.bind(this));
|
||||
return;
|
||||
}
|
||||
|
||||
// Feed buttons
|
||||
if (this.collectionTreeRow.isFeedsOrFeed()) {
|
||||
if (this.collectionTreeRows[0].isFeedsOrFeed()) {
|
||||
container.renderCustomHead(this.renderFeedHead.bind(this));
|
||||
this.updateReadLabel();
|
||||
return;
|
||||
|
|
@ -348,6 +391,11 @@
|
|||
return;
|
||||
}
|
||||
|
||||
if (this._isBatchEditEnabled && this.data.length > 1) {
|
||||
container.renderCustomHead(this.renderBatchEditHead.bind(this));
|
||||
return;
|
||||
}
|
||||
|
||||
container.renderCustomHead();
|
||||
}
|
||||
|
||||
|
|
@ -413,7 +461,7 @@
|
|||
renderAnnotationsHead(data) {
|
||||
let { doc, append } = data;
|
||||
let button = doc.createXULElement("button");
|
||||
button.disabled = !this.collectionTreeRow.editable;
|
||||
button.disabled = !this.collectionTreeRows.every(o => o.editable);
|
||||
button.id = 'zotero-item-pane-note-from-annotations';
|
||||
if (Zotero.Items.getTopLevel(this.data).length == 1) {
|
||||
button.label = Zotero.getString('pane.items.menu.addNoteFromAnnotations');
|
||||
|
|
@ -426,6 +474,28 @@
|
|||
append(button);
|
||||
}
|
||||
|
||||
renderBatchEditHead(data) {
|
||||
let { doc, append } = data;
|
||||
let description = doc.createXULElement("description");
|
||||
document.l10n.setAttributes(
|
||||
description,
|
||||
'item-pane-batch-editing-header',
|
||||
{ count: this.data.length }
|
||||
);
|
||||
let icon = doc.createElement("span");
|
||||
icon.className = "batch-edit-head-icon";
|
||||
let doneButton = doc.createXULElement("button");
|
||||
doneButton.setAttribute("default", "true");
|
||||
document.l10n.setAttributes(doneButton, 'item-pane-batch-editing-done');
|
||||
doneButton.addEventListener("command", () => {
|
||||
this._setBatchEditCollapsible(false);
|
||||
this._isBatchEditEnabled = false;
|
||||
this.render();
|
||||
this.updateItemPaneButtons();
|
||||
});
|
||||
append(icon, description, doneButton);
|
||||
}
|
||||
|
||||
updateReadLabel() {
|
||||
var items = this.data;
|
||||
var isUnread = false;
|
||||
|
|
@ -535,6 +605,25 @@
|
|||
this.setTranslateButton();
|
||||
}
|
||||
|
||||
_setBatchEditCollapsible(enabled) {
|
||||
let section = this._itemDetails.querySelector('collapsible-section[data-pane="info"]');
|
||||
if (!section) return;
|
||||
if (enabled) {
|
||||
// Force open without saving to prefs, so the previous state is preserved
|
||||
section._skipSaveOpenState = true;
|
||||
section.open = true;
|
||||
section._skipSaveOpenState = false;
|
||||
section.collapsible = false;
|
||||
section.showContextMenu = false;
|
||||
}
|
||||
else {
|
||||
section.collapsible = true;
|
||||
section.showContextMenu = true;
|
||||
// Restore the pref-saved open state
|
||||
section._restoreOpenState();
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentPane(mode = undefined) {
|
||||
if (!mode) {
|
||||
// Guess a mode from the current data
|
||||
|
|
@ -543,7 +632,7 @@
|
|||
mode = "annotations";
|
||||
}
|
||||
// No/multiple objects are selected OR selected object is a trashed collection/search
|
||||
else if (!this.data.length || this.data.length > 1
|
||||
else if (!this.data.length || (this.data.length > 1 && !this._isBatchEditEnabled)
|
||||
|| this.data[0] instanceof Zotero.Collection || this.data[0] instanceof Zotero.Search) {
|
||||
mode = "message";
|
||||
}
|
||||
|
|
@ -595,7 +684,7 @@
|
|||
}
|
||||
|
||||
handleResize() {
|
||||
if (this.getAttribute("collapsed")) {
|
||||
if (this.hasAttribute("collapsed")) {
|
||||
this.removeAttribute("width");
|
||||
this.removeAttribute("height");
|
||||
}
|
||||
|
|
@ -636,18 +725,28 @@
|
|||
if (this.previousElementSibling.localName === "splitter") {
|
||||
this.previousElementSibling.setAttribute("state", "open");
|
||||
}
|
||||
this._sidenav.container = this._duplicatesPane;
|
||||
break;
|
||||
}
|
||||
case "annotations": {
|
||||
this._deck.selectedIndex = 4;
|
||||
break;
|
||||
}
|
||||
case "batch-edit-prompt": {
|
||||
this._deck.selectedIndex = 5;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let isViewingItem = type == "item";
|
||||
let isViewingDuplicates = type == "duplicates";
|
||||
if (previousViewType != "item" && isViewingItem) {
|
||||
this._itemDetails.forceUpdateSideNav();
|
||||
}
|
||||
this._itemDetails.sidenav.toggleDefaultStatus(!isViewingItem);
|
||||
// Switch sidenav back to item-details when leaving duplicates mode
|
||||
if (!isViewingDuplicates && this._sidenav.container !== this._itemDetails) {
|
||||
this._sidenav.container = this._itemDetails;
|
||||
}
|
||||
this._sidenav.toggleDefaultStatus(!isViewingItem && !isViewingDuplicates);
|
||||
}
|
||||
}
|
||||
customElements.define("item-pane", ItemPane);
|
||||
|
|
|
|||
257
chrome/content/zotero/elements/itemPaneContainerBase.mjs
Normal file
257
chrome/content/zotero/elements/itemPaneContainerBase.mjs
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
/*
|
||||
***** BEGIN LICENSE BLOCK *****
|
||||
|
||||
Copyright © 2026 Corporation for Digital Scholarship
|
||||
Vienna, Virginia, USA
|
||||
https://www.zotero.org
|
||||
|
||||
This file is part of Zotero.
|
||||
|
||||
Zotero is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Zotero is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
***** END LICENSE BLOCK *****
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base class for elements that host a vertical stack of [data-pane] sections
|
||||
* controlled by an <item-pane-sidenav>.
|
||||
*
|
||||
* Subclasses must set this._paneParent (the scrollable element directly
|
||||
* containing the [data-pane] children) in their init() before any of the
|
||||
* inherited container methods are called.
|
||||
*
|
||||
* Pin support and pane reordering are opt-in -- subclasses override
|
||||
* pinnedPane, changePaneOrder(), and initPaneOrder if they want them.
|
||||
* scrollToPane() handles smooth scrolling and adds bottom padding when
|
||||
* necessary so the target pane can sit at the very top of the container;
|
||||
* subclasses with extra per-scroll bookkeeping (intersection observers, async
|
||||
* render of following panes, etc.) can override the _beforeScrollToPane() and
|
||||
* _afterScrollToPane() hooks.
|
||||
*/
|
||||
export class ItemPaneContainerBase extends XULElementBase {
|
||||
_paneParent = null;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.classList.add('item-pane-container-root');
|
||||
}
|
||||
|
||||
get sidenav() {
|
||||
return this._sidenav;
|
||||
}
|
||||
|
||||
set sidenav(sidenav) {
|
||||
this._sidenav = sidenav;
|
||||
sidenav.container = this;
|
||||
}
|
||||
|
||||
get tabType() {
|
||||
return this.getAttribute('tabType');
|
||||
}
|
||||
|
||||
set tabType(val) {
|
||||
this.setAttribute('tabType', val);
|
||||
}
|
||||
|
||||
get _collapsed() {
|
||||
let parentPane = this.closest('item-pane, context-pane');
|
||||
return parentPane ? parentPane.collapsed : false;
|
||||
}
|
||||
|
||||
set _collapsed(val) {
|
||||
let parentPane = this.closest('item-pane, context-pane');
|
||||
if (parentPane) parentPane.collapsed = val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this container supports user-driven pinning of a pane.
|
||||
* The sidenav and section context menus honor this when deciding whether
|
||||
* to expose the pin/unpin UI. Subclasses that implement `pinnedPane`
|
||||
* should override this to return true.
|
||||
*/
|
||||
get supportsPinning() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this container supports user-driven pane reordering (drag in
|
||||
* the sidenav, reorder menuitems in the sidenav and section context
|
||||
* menus). Subclasses that implement changePaneOrder() and initPaneOrder()
|
||||
* should return true.
|
||||
*/
|
||||
get supportsReorder() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin support is opt-in. Default: not supported.
|
||||
*/
|
||||
get pinnedPane() {
|
||||
return null;
|
||||
}
|
||||
|
||||
set pinnedPane(_val) {}
|
||||
|
||||
getPane(id) {
|
||||
return this._paneParent.querySelector(`:scope > [data-pane="${CSS.escape(id)}"]`);
|
||||
}
|
||||
|
||||
getEnabledPane(id) {
|
||||
return this._paneParent.querySelector(`:scope > [data-pane="${CSS.escape(id)}"]:not([hidden])`);
|
||||
}
|
||||
|
||||
getPanes() {
|
||||
return Array.from(this._paneParent.querySelectorAll(':scope > [data-pane]'));
|
||||
}
|
||||
|
||||
getEnabledPanes() {
|
||||
return Array.from(this._paneParent.querySelectorAll(':scope > [data-pane]:not([hidden])'));
|
||||
}
|
||||
|
||||
async _waitFrame() {
|
||||
return this._waitNoLongerThan(new Promise((resolve) => {
|
||||
requestAnimationFrame(resolve);
|
||||
}), 30);
|
||||
}
|
||||
|
||||
async _waitFrames(n) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
await this._waitFrame();
|
||||
}
|
||||
}
|
||||
|
||||
async _waitDOMUpdate(timeout = 50) {
|
||||
return new Promise((resolve) => {
|
||||
requestIdleCallback(resolve, { timeout });
|
||||
});
|
||||
}
|
||||
|
||||
async _waitNoLongerThan(promise, ms = 1000) {
|
||||
return Promise.race([promise, Zotero.Promise.delay(ms)]);
|
||||
}
|
||||
|
||||
get _minScrollHeight() {
|
||||
return parseFloat(this._paneParent.style.getPropertyValue('--min-scroll-height') || 0);
|
||||
}
|
||||
|
||||
set _minScrollHeight(val) {
|
||||
this._paneParent.style.setProperty('--min-scroll-height', val + 'px');
|
||||
}
|
||||
|
||||
_makeSpaceForPane(pane) {
|
||||
let oldMinScrollHeight = this._minScrollHeight;
|
||||
let newMinScrollHeight = this._getMinScrollHeightForPane(pane);
|
||||
if (newMinScrollHeight > oldMinScrollHeight) {
|
||||
this._minScrollHeight = newMinScrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
_getMinScrollHeightForPane(pane) {
|
||||
let paneRect = pane.getBoundingClientRect();
|
||||
let containerRect = this._paneParent.getBoundingClientRect();
|
||||
// No offsetTop property for XUL elements
|
||||
let offsetTop = paneRect.top - containerRect.top + this._paneParent.scrollTop;
|
||||
return offsetTop + containerRect.height;
|
||||
}
|
||||
|
||||
async _waitForScroll() {
|
||||
let scrollPromise = Zotero.Promise.defer();
|
||||
let lastScrollTop = this._paneParent.scrollTop;
|
||||
const checkScrollStart = () => {
|
||||
// If the scrollTop is not changed, wait for scroll to happen
|
||||
if (lastScrollTop === this._paneParent.scrollTop) {
|
||||
requestAnimationFrame(checkScrollStart);
|
||||
}
|
||||
// Wait for scroll to end
|
||||
else {
|
||||
requestAnimationFrame(checkScrollEnd);
|
||||
}
|
||||
};
|
||||
const checkScrollEnd = async () => {
|
||||
// Wait for 3 frames to make sure not further scrolls
|
||||
await this._waitFrames(3);
|
||||
if (lastScrollTop === this._paneParent.scrollTop) {
|
||||
scrollPromise.resolve();
|
||||
}
|
||||
else {
|
||||
lastScrollTop = this._paneParent.scrollTop;
|
||||
requestAnimationFrame(checkScrollEnd);
|
||||
}
|
||||
};
|
||||
checkScrollStart();
|
||||
// Abort after 3 seconds, which should be enough
|
||||
return Promise.race([
|
||||
scrollPromise.promise,
|
||||
Zotero.Promise.delay(3000)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called immediately before the scroll begins, after we've decided we will scroll.
|
||||
*/
|
||||
_beforeScrollToPane(_pane) {}
|
||||
|
||||
/**
|
||||
* Called after the scroll has settled.
|
||||
*/
|
||||
async _afterScrollToPane(_pane, _panes, _paneIndex) {}
|
||||
|
||||
async scrollToPane(paneID, behavior = 'smooth') {
|
||||
let panes = this.getEnabledPanes();
|
||||
let paneIndex = panes.findIndex(elem => elem.dataset.pane == paneID);
|
||||
let pane = panes[paneIndex];
|
||||
if (!pane) return null;
|
||||
|
||||
// If the container is collapsed, just remember which pane needs to be
|
||||
// scrolled to when the container is expanded.
|
||||
if (this._collapsed) {
|
||||
this._lastScrollPaneID = paneID;
|
||||
return null;
|
||||
}
|
||||
|
||||
// If the pane is already at the top, no need to scroll
|
||||
if (Math.abs(pane.getBoundingClientRect().top - this._paneParent.getBoundingClientRect().top) < 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this._beforeScrollToPane(pane);
|
||||
|
||||
// The pane should always be at the very top.
|
||||
// If there isn't enough stuff below it for it to be at the top, we add
|
||||
// padding via the --min-scroll-height CSS variable so we don't need
|
||||
// to add another level to the DOM.
|
||||
this._makeSpaceForPane(pane);
|
||||
|
||||
let scrollPromise;
|
||||
if (behavior == 'smooth') {
|
||||
this._disableScrollHandler = true;
|
||||
scrollPromise = this._waitForScroll();
|
||||
scrollPromise.then(() => this._disableScrollHandler = false);
|
||||
}
|
||||
else {
|
||||
// Wait for the next DOM update to make sure the height is updated before rendering
|
||||
scrollPromise = this._waitDOMUpdate();
|
||||
}
|
||||
pane.scrollIntoView({ block: 'start', behavior });
|
||||
pane.focus();
|
||||
await scrollPromise;
|
||||
|
||||
await this._afterScrollToPane(pane, panes, paneIndex);
|
||||
return true;
|
||||
}
|
||||
|
||||
changePaneOrder() {}
|
||||
|
||||
initPaneOrder() {}
|
||||
}
|
||||
|
|
@ -23,162 +23,14 @@
|
|||
***** END LICENSE BLOCK *****
|
||||
*/
|
||||
|
||||
|
||||
class ItemPaneSectionElementBase extends XULElementBase {
|
||||
get item() {
|
||||
return this._item;
|
||||
}
|
||||
|
||||
set item(item) {
|
||||
this._item = item;
|
||||
if (this._handleItemChange) this._handleItemChange();
|
||||
}
|
||||
|
||||
get editable() {
|
||||
return this._editable;
|
||||
}
|
||||
|
||||
set editable(editable) {
|
||||
this._editable = editable;
|
||||
this.toggleAttribute('readonly', !editable);
|
||||
}
|
||||
|
||||
get tabID() {
|
||||
return this._tabID;
|
||||
}
|
||||
|
||||
set tabID(tabID) {
|
||||
this._tabID = tabID;
|
||||
}
|
||||
|
||||
get tabType() {
|
||||
return this._tabType;
|
||||
}
|
||||
|
||||
set tabType(tabType) {
|
||||
this._tabType = tabType;
|
||||
this.setAttribute('tabType', tabType);
|
||||
}
|
||||
|
||||
get collectionTreeRow() {
|
||||
return this._collectionTreeRow;
|
||||
}
|
||||
|
||||
set collectionTreeRow(collectionTreeRow) {
|
||||
this._collectionTreeRow = collectionTreeRow;
|
||||
}
|
||||
|
||||
_syncRenderPending = false;
|
||||
|
||||
_asyncRenderPending = false;
|
||||
|
||||
/** Controlled by parent element */
|
||||
skipRender = false;
|
||||
|
||||
get open() {
|
||||
return this._section?.open || false;
|
||||
}
|
||||
|
||||
set open(val) {
|
||||
if (this._section) {
|
||||
this._section.open = val;
|
||||
}
|
||||
}
|
||||
|
||||
get collapsible() {
|
||||
return this._section.collapsible;
|
||||
}
|
||||
|
||||
set collapsible(val) {
|
||||
this._section.collapsible = !!val;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (!this.render && !this.asyncRender) {
|
||||
Zotero.warn("Pane section must have method render or asyncRender.");
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this._section) {
|
||||
this._section.removeEventListener("toggle", this._handleSectionToggle);
|
||||
this._section = null;
|
||||
}
|
||||
this._resetRenderedFlags();
|
||||
}
|
||||
|
||||
initCollapsibleSection() {
|
||||
this._section = this.querySelector('collapsible-section');
|
||||
if (this._section) {
|
||||
this._section.addEventListener("toggle", this._handleSectionToggle);
|
||||
}
|
||||
}
|
||||
|
||||
_handleSectionToggle = async (event) => {
|
||||
if (event.target !== this._section || !this._section.open) {
|
||||
return;
|
||||
}
|
||||
await this._forceRenderAll();
|
||||
};
|
||||
|
||||
get _renderDependencies() {
|
||||
return [this._tabID, this._item?.id];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {"sync" | "async"} [type]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isAlreadyRendered(type = "sync") {
|
||||
let key = `_${type}RenderDependencies`;
|
||||
let pendingKey = `_${type}RenderPending`;
|
||||
let itemIDKey = `_${type}RenderItemID`;
|
||||
|
||||
let oldDependencies = this[key];
|
||||
let newDependencies = this._renderDependencies;
|
||||
|
||||
let isPending = this[pendingKey];
|
||||
let isRendered = Zotero.Utilities.arrayEquals(oldDependencies, newDependencies);
|
||||
if (this.skipRender) {
|
||||
if (!isRendered) {
|
||||
this[pendingKey] = true;
|
||||
}
|
||||
// Skip render
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isPending && isRendered) {
|
||||
return true;
|
||||
}
|
||||
this[key] = newDependencies;
|
||||
this[pendingKey] = false;
|
||||
this[itemIDKey] = this.item?.id;
|
||||
return false;
|
||||
}
|
||||
|
||||
_resetRenderedFlags() {
|
||||
// Clear cached flags to allow re-rendering
|
||||
delete this._syncRenderDependencies;
|
||||
delete this._syncRenderItemID;
|
||||
delete this._asyncRenderDependencies;
|
||||
delete this._asyncRenderItemID;
|
||||
}
|
||||
|
||||
async _forceRenderAll() {
|
||||
this._resetRenderedFlags();
|
||||
if (this.hidden || this.skipRender) {
|
||||
this._syncRenderPending = true;
|
||||
this._asyncRenderPending = true;
|
||||
return;
|
||||
}
|
||||
if (this.render) this.render();
|
||||
if (this.asyncRender) await this.asyncRender();
|
||||
}
|
||||
}
|
||||
"use strict";
|
||||
|
||||
{
|
||||
const { ItemPaneSectionElementBase } = ChromeUtils.importESModule(
|
||||
"chrome://zotero/content/elements/itemPaneSectionElementBase.mjs",
|
||||
{ global: "current" }
|
||||
);
|
||||
|
||||
class ItemPaneCustomSection extends ItemPaneSectionElementBase {
|
||||
_hooks = {};
|
||||
|
||||
|
|
@ -26,6 +26,11 @@
|
|||
"use strict";
|
||||
|
||||
{
|
||||
const { ItemPaneSectionElementBase } = ChromeUtils.importESModule(
|
||||
"chrome://zotero/content/elements/itemPaneSectionElementBase.mjs",
|
||||
{ global: "current" }
|
||||
);
|
||||
|
||||
const PREF_HEADER_MODE = 'itemPaneHeader';
|
||||
const PREF_BIB_ENTRY_STYLE = 'itemPaneHeader.bibEntry.style';
|
||||
const PREF_BIB_ENTRY_LOCALE = 'itemPaneHeader.bibEntry.locale';
|
||||
|
|
@ -65,6 +70,10 @@
|
|||
|
||||
_editable = true;
|
||||
|
||||
get _renderDependencies() {
|
||||
return [this._tabID, this._item?.id, this.extraItems?.length ?? 0];
|
||||
}
|
||||
|
||||
get item() {
|
||||
return this._item;
|
||||
}
|
||||
|
|
@ -145,8 +154,8 @@
|
|||
event.preventDefault();
|
||||
let menupopup = ZoteroPane.buildFieldTransformMenu({
|
||||
target: this.titleField,
|
||||
onTransform: (newValue) => {
|
||||
this._setTransformedValue(newValue);
|
||||
onTransform: (newValues) => {
|
||||
this._setTransformedValue(newValues[0]);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -192,9 +201,15 @@
|
|||
if (newValue.toLowerCase().startsWith(shortTitleVal.toLowerCase())) {
|
||||
this._item.setField('shortTitle', newValue.substring(0, shortTitleVal.length));
|
||||
}
|
||||
await this._item.saveTx();
|
||||
await this._item.saveTx({
|
||||
undoAction: 'undo-action-edit-field',
|
||||
undoActionArgs: {
|
||||
field: Zotero.ItemFields.getLocalizedString(this._titleFieldID),
|
||||
count: 1
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async save() {
|
||||
if (!this.editable) {
|
||||
return;
|
||||
|
|
@ -204,7 +219,13 @@
|
|||
throw new Error('Item has not been added to library');
|
||||
}
|
||||
this._item.setField(this._titleFieldID, this.titleField.value);
|
||||
await this._item.saveTx();
|
||||
await this._item.saveTx({
|
||||
undoAction: 'undo-action-edit-field',
|
||||
undoActionArgs: {
|
||||
field: Zotero.ItemFields.getLocalizedString(this._titleFieldID),
|
||||
count: 1
|
||||
}
|
||||
});
|
||||
}
|
||||
this._forceRenderAll();
|
||||
}
|
||||
|
|
@ -226,6 +247,12 @@
|
|||
if (this._item.isAttachment()) {
|
||||
headerMode = 'title';
|
||||
}
|
||||
|
||||
if (this.extraItems?.length) {
|
||||
headerMode = 'none';
|
||||
}
|
||||
|
||||
this.classList.toggle('batch-edit', !!this.extraItems?.length);
|
||||
|
||||
this.title.hidden = true;
|
||||
this.creatorYear.hidden = true;
|
||||
|
|
@ -316,7 +343,7 @@
|
|||
_getCSLEngine(style, locale) {
|
||||
// Create Engine if not cached (first run with this style)
|
||||
if (this._cslEngineStyleID !== style.styleID || this._cslEngineLocale !== locale) {
|
||||
this._cslEngine = style.getCiteProc(locale, 'html');
|
||||
this._cslEngine = style.getCiteProc(locale, 'html', { cache: true });
|
||||
this._cslEngineStyleID = style.styleID;
|
||||
this._cslEngineLocale = locale;
|
||||
this._bibEntryCache.clear();
|
||||
|
|
@ -411,7 +438,7 @@
|
|||
let menuitem = document.createXULElement('menuitem');
|
||||
menuitem.setAttribute('data-l10n-id', 'item-pane-header-' + headerMode);
|
||||
menuitem.setAttribute('type', 'radio');
|
||||
menuitem.setAttribute('checked', headerMode === selectedMode);
|
||||
menuitem.toggleAttribute('checked', headerMode === selectedMode);
|
||||
menuitem.addEventListener('command', () => {
|
||||
Zotero.Prefs.set(PREF_HEADER_MODE, headerMode);
|
||||
});
|
||||
|
|
|
|||
178
chrome/content/zotero/elements/itemPaneSectionElementBase.mjs
Normal file
178
chrome/content/zotero/elements/itemPaneSectionElementBase.mjs
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
/*
|
||||
***** BEGIN LICENSE BLOCK *****
|
||||
|
||||
Copyright © 2024 Corporation for Digital Scholarship
|
||||
Vienna, Virginia, USA
|
||||
https://www.zotero.org
|
||||
|
||||
This file is part of Zotero.
|
||||
|
||||
Zotero is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Zotero is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
***** END LICENSE BLOCK *****
|
||||
*/
|
||||
|
||||
export class ItemPaneSectionElementBase extends XULElementBase {
|
||||
get item() {
|
||||
return this._item;
|
||||
}
|
||||
|
||||
set item(item) {
|
||||
this._item = item;
|
||||
if (this._handleItemChange) this._handleItemChange();
|
||||
}
|
||||
|
||||
get editable() {
|
||||
return this._editable;
|
||||
}
|
||||
|
||||
set editable(editable) {
|
||||
this._editable = editable;
|
||||
this.toggleAttribute('readonly', !editable);
|
||||
}
|
||||
|
||||
get tabID() {
|
||||
return this._tabID;
|
||||
}
|
||||
|
||||
set tabID(tabID) {
|
||||
this._tabID = tabID;
|
||||
}
|
||||
|
||||
get tabType() {
|
||||
return this._tabType;
|
||||
}
|
||||
|
||||
set tabType(tabType) {
|
||||
this._tabType = tabType;
|
||||
this.setAttribute('tabType', tabType);
|
||||
}
|
||||
|
||||
get collectionTreeRows() {
|
||||
return this._collectionTreeRows;
|
||||
}
|
||||
|
||||
set collectionTreeRows(collectionTreeRows) {
|
||||
this._collectionTreeRows = collectionTreeRows;
|
||||
}
|
||||
|
||||
_syncRenderPending = false;
|
||||
|
||||
_asyncRenderPending = false;
|
||||
|
||||
/** Controlled by parent element */
|
||||
skipRender = false;
|
||||
|
||||
get open() {
|
||||
return this._section?.open || false;
|
||||
}
|
||||
|
||||
set open(val) {
|
||||
if (this._section) {
|
||||
this._section.open = val;
|
||||
}
|
||||
}
|
||||
|
||||
get collapsible() {
|
||||
return this._section.collapsible;
|
||||
}
|
||||
|
||||
set collapsible(val) {
|
||||
this._section.collapsible = !!val;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (!this.render && !this.asyncRender) {
|
||||
Zotero.warn("Pane section must have method render or asyncRender.");
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this._section) {
|
||||
this._section.removeEventListener("toggle", this._handleSectionToggle);
|
||||
this._section = null;
|
||||
}
|
||||
this._resetRenderedFlags();
|
||||
}
|
||||
|
||||
initCollapsibleSection() {
|
||||
this._section = this.querySelector('collapsible-section');
|
||||
if (this._section) {
|
||||
this._section.addEventListener("toggle", this._handleSectionToggle);
|
||||
}
|
||||
}
|
||||
|
||||
_handleSectionToggle = async (event) => {
|
||||
if (event.target !== this._section || !this._section.open) {
|
||||
return;
|
||||
}
|
||||
await this._forceRenderAll();
|
||||
};
|
||||
|
||||
get _renderDependencies() {
|
||||
return [this._tabID, this._item?.id];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {"sync" | "async"} [type]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isAlreadyRendered(type = "sync") {
|
||||
let key = `_${type}RenderDependencies`;
|
||||
let pendingKey = `_${type}RenderPending`;
|
||||
let itemIDKey = `_${type}RenderItemID`;
|
||||
|
||||
let oldDependencies = this[key];
|
||||
let newDependencies = this._renderDependencies;
|
||||
|
||||
let isPending = this[pendingKey];
|
||||
let isRendered = Zotero.Utilities.arrayEquals(oldDependencies, newDependencies);
|
||||
if (this.skipRender) {
|
||||
if (!isRendered) {
|
||||
this[pendingKey] = true;
|
||||
}
|
||||
// Skip render
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isPending && isRendered) {
|
||||
return true;
|
||||
}
|
||||
this[key] = newDependencies;
|
||||
this[pendingKey] = false;
|
||||
this[itemIDKey] = this.item?.id;
|
||||
return false;
|
||||
}
|
||||
|
||||
_resetRenderedFlags() {
|
||||
// Clear cached flags to allow re-rendering
|
||||
delete this._syncRenderDependencies;
|
||||
delete this._syncRenderItemID;
|
||||
delete this._asyncRenderDependencies;
|
||||
delete this._asyncRenderItemID;
|
||||
}
|
||||
|
||||
async _forceRenderAll() {
|
||||
this._resetRenderedFlags();
|
||||
if (this.hidden || this.skipRender) {
|
||||
this._syncRenderPending = true;
|
||||
this._asyncRenderPending = true;
|
||||
return;
|
||||
}
|
||||
if (this.render) this.render();
|
||||
if (this.asyncRender) await this.asyncRender();
|
||||
}
|
||||
}
|
||||
|
|
@ -112,6 +112,15 @@
|
|||
set container(val) {
|
||||
if (this._container == val) return;
|
||||
this._container = val;
|
||||
// When changing containers, hide all data-pane buttons up front; they'll
|
||||
// be unhidden during render() for panes present in the new container.
|
||||
// Otherwise, buttons for panes from the previous container would linger.
|
||||
if (val) {
|
||||
for (let button of this.querySelectorAll('.btn[data-pane]')) {
|
||||
if (button.dataset.pane === 'context-notes') continue;
|
||||
button.parentElement.hidden = true;
|
||||
}
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
|
||||
|
|
@ -188,6 +197,10 @@
|
|||
}
|
||||
|
||||
isPanePinnable(id) {
|
||||
// The active container can opt out of pin support entirely
|
||||
if (!this.container?.supportsPinning) {
|
||||
return false;
|
||||
}
|
||||
if (['context-notes', 'context-all-notes', 'context-item-notes'].includes(id)) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -199,6 +212,10 @@
|
|||
}
|
||||
|
||||
isPaneOrderable(paneID) {
|
||||
// The active container can opt out of reordering entirely
|
||||
if (!this.container?.supportsReorder) {
|
||||
return false;
|
||||
}
|
||||
let orderable
|
||||
// Built-in or orderable custom sections
|
||||
= this._builtInPanes.includes(paneID) || Zotero.ItemPaneManager.isSectionOrderable(paneID);
|
||||
|
|
@ -823,13 +840,24 @@
|
|||
this._contextMenuTarget = paneID;
|
||||
|
||||
let isPinnable = this.isPanePinnable(paneID);
|
||||
this.querySelector('.zotero-menuitem-pin').hidden = !isPinnable || this.pinnedPane == paneID;
|
||||
this.querySelector('.zotero-menuitem-unpin').hidden = !isPinnable || this.pinnedPane != paneID;
|
||||
let pinHidden = !isPinnable || this.pinnedPane == paneID;
|
||||
let unpinHidden = !isPinnable || this.pinnedPane != paneID;
|
||||
this.querySelector('.zotero-menuitem-pin').hidden = pinHidden;
|
||||
this.querySelector('.zotero-menuitem-unpin').hidden = unpinHidden;
|
||||
this.querySelector('.zotero-menuitem-pin-separator').hidden = !isPinnable;
|
||||
|
||||
this.querySelector('.zotero-menuitem-reorder-up').hidden = !this.isPaneMovable(paneID, 'up');
|
||||
this.querySelector('.zotero-menuitem-reorder-down').hidden = !this.isPaneMovable(paneID, 'down');
|
||||
this.querySelector('.zotero-menuitem-reorder-reset').hidden = !this.isOrderChanged();
|
||||
let canReorder = !!this.container?.supportsReorder;
|
||||
let moveUpHidden = !this.isPaneMovable(paneID, 'up');
|
||||
let moveDownHidden = !this.isPaneMovable(paneID, 'down');
|
||||
let resetHidden = !canReorder || !this.isOrderChanged();
|
||||
this.querySelector('.zotero-menuitem-reorder-up').hidden = moveUpHidden;
|
||||
this.querySelector('.zotero-menuitem-reorder-down').hidden = moveDownHidden;
|
||||
this.querySelector('.zotero-menuitem-reorder-reset').hidden = resetHidden;
|
||||
|
||||
// Don't open an empty popup
|
||||
if (pinHidden && unpinHidden && moveUpHidden && moveDownHidden && resetHidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.querySelector('.context-menu')
|
||||
.openPopupAtScreen(event.screenX, event.screenY, true);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@
|
|||
import { getCSSIcon } from 'components/icons';
|
||||
|
||||
{
|
||||
const { ItemPaneSectionElementBase } = ChromeUtils.importESModule(
|
||||
"chrome://zotero/content/elements/itemPaneSectionElementBase.mjs",
|
||||
{ global: "current" }
|
||||
);
|
||||
|
||||
class LibrariesCollectionsBox extends ItemPaneSectionElementBase {
|
||||
content = MozXULElement.parseXULToFragment(`
|
||||
<collapsible-section data-l10n-id="section-libraries-collections" data-pane="libraries-collections" extra-buttons="add">
|
||||
|
|
@ -61,7 +66,7 @@ import { getCSSIcon } from 'components/icons';
|
|||
}
|
||||
|
||||
get _renderDependencies() {
|
||||
return [...super._renderDependencies, this.collectionTreeRow?.id];
|
||||
return [...super._renderDependencies, this.collectionTreeRows?.map(o => o.id).join(',')];
|
||||
}
|
||||
|
||||
init() {
|
||||
|
|
@ -143,14 +148,17 @@ import { getCSSIcon } from 'components/icons';
|
|||
Zotero.getString('pane.items.removeFromOther', [obj.name])
|
||||
)) {
|
||||
contextItem.removeFromCollection(obj.id);
|
||||
contextItem.saveTx();
|
||||
contextItem.saveTx({
|
||||
undoAction: 'undo-action-remove-from-collection',
|
||||
undoActionArgs: { count: 1 }
|
||||
});
|
||||
}
|
||||
});
|
||||
row.append(remove);
|
||||
}
|
||||
|
||||
let isCurrent = this.tabType === 'library'
|
||||
&& this.collectionTreeRow?.id == obj.treeViewID;
|
||||
&& this.collectionTreeRows.map(o => o.id).includes(obj.treeViewID);
|
||||
box.classList.toggle('current', isCurrent);
|
||||
|
||||
// Disable clicky if this is a context row or we're already in the library/collection it points to
|
||||
|
|
|
|||
|
|
@ -70,6 +70,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
get value() {
|
||||
return super.value;
|
||||
}
|
||||
|
||||
set value(value) {
|
||||
if (!this.itemCount) {
|
||||
this._preconnectedValue = value;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@
|
|||
|
||||
|
||||
{
|
||||
const { ItemPaneSectionElementBase } = ChromeUtils.importESModule(
|
||||
"chrome://zotero/content/elements/itemPaneSectionElementBase.mjs",
|
||||
{ global: "current" }
|
||||
);
|
||||
let { countWords } = ChromeUtils.importESModule("resource://zotero/allfaz.mjs").default;
|
||||
|
||||
class NoteBox extends ItemPaneSectionElementBase {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue