Compare commits
602
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be5603d41b | ||
|
|
4e074ae18e | ||
|
|
a4ae3e1c7f | ||
|
|
1a2297e943 | ||
|
|
611e79fc1e | ||
|
|
50817bed9a | ||
|
|
1f9f0f5c22 | ||
|
|
d776a2b003 | ||
|
|
7517d81046 | ||
|
|
091a01ba67 | ||
|
|
bad14d1b94 | ||
|
|
c4bb2f5eba | ||
|
|
d2a815c4bc | ||
|
|
3bc1d2f602 | ||
|
|
bd96c3caa3 | ||
|
|
f8ab6977ed | ||
|
|
8285b39e06 | ||
|
|
8c4969cc44 | ||
|
|
1f950f93dd | ||
|
|
505a40d077 | ||
|
|
0cabce66ed | ||
|
|
df90fb1e0e | ||
|
|
e39e92b025 | ||
|
|
07836f7176 | ||
|
|
0f36d3077b | ||
|
|
33c8761e80 | ||
|
|
157703527a | ||
|
|
5921027e80 | ||
|
|
31700d5c71 | ||
|
|
c3cb498ce1 | ||
|
|
f168a0187d | ||
|
|
877c995b47 | ||
|
|
e572b3410e | ||
|
|
a2501e47c3 | ||
|
|
2b6730288a | ||
|
|
0efd44fd90 | ||
|
|
187b0decae | ||
|
|
75846cf045 | ||
|
|
544ffb4ae7 | ||
|
|
eea9097740 | ||
|
|
35a88bb398 | ||
|
|
4e3f67df3d | ||
|
|
7131792ded | ||
|
|
41c28cc70a | ||
|
|
aa12c4ab9d | ||
|
|
c4bb7a9be1 | ||
|
|
fdf38a72ac | ||
|
|
29e3f55e07 | ||
|
|
0a18d1ac0f | ||
|
|
12c0e18dfe | ||
|
|
875ec45928 | ||
|
|
b79973fc2e | ||
|
|
6b98eb5cbe | ||
|
|
32e2a228b9 | ||
|
|
bbf397454d | ||
|
|
d3ba5975a0 | ||
|
|
6c12731215 | ||
|
|
4fe07bcd52 | ||
|
|
846c28001e | ||
|
|
5c43a5678a | ||
|
|
4b4e55dbe2 | ||
|
|
fe1049ee99 | ||
|
|
73017c1a30 | ||
|
|
9c187b5ae5 | ||
|
|
7fbbc2f942 | ||
|
|
1b448fef23 | ||
|
|
881653eef1 | ||
|
|
d8476c6f7b | ||
|
|
6eb2709477 | ||
|
|
05405bf2e8 | ||
|
|
b18a50fec5 | ||
|
|
be9de6596f | ||
|
|
71631c6731 | ||
|
|
548b95ddb6 | ||
|
|
e0d8ab9f10 | ||
|
|
a629b5a93e | ||
|
|
ce9301f145 | ||
|
|
d0e8378a02 | ||
|
|
424ab2bb6d | ||
|
|
5fb4da30a4 | ||
|
|
f78da3dfc9 | ||
|
|
14b3fb4048 | ||
|
|
d27a481d96 | ||
|
|
24eb4d1e79 | ||
|
|
3c35aa31c1 | ||
|
|
363a97aa5e | ||
|
|
306a20c37f | ||
|
|
e06bd8435d | ||
|
|
76f89df609 | ||
|
|
f0a966d3a3 | ||
|
|
9eac3bb958 | ||
|
|
fc93620118 | ||
|
|
15739f7370 | ||
|
|
1ae8533702 | ||
|
|
ea3204c78c | ||
|
|
6e72f59fcf | ||
|
|
24cadf88f8 | ||
|
|
4b645d8460 | ||
|
|
a4498fdbbc | ||
|
|
2e8efdb2bb | ||
|
|
e92e476f49 | ||
|
|
4a6a829401 | ||
|
|
c03d0a091f | ||
|
|
2f5df33020 | ||
|
|
5d517861f0 | ||
|
|
59c87f06a2 | ||
|
|
f76b2f8289 | ||
|
|
7c4e798776 | ||
|
|
317fc7371a | ||
|
|
c7d38593dd | ||
|
|
a13a13d90c | ||
|
|
666671ee59 | ||
|
|
d01f502e70 | ||
|
|
3fd5983df6 | ||
|
|
6afdad6fe4 | ||
|
|
449ab78991 | ||
|
|
95887d7493 | ||
|
|
965a73ab49 | ||
|
|
067fb76ebd | ||
|
|
d47205a561 | ||
|
|
1be9da3685 | ||
|
|
a8c1329676 | ||
|
|
d63765a9b1 | ||
|
|
c87d56c939 | ||
|
|
e234d6b6c3 | ||
|
|
edaeba4a4e | ||
|
|
9d8a362583 | ||
|
|
c63df3c46d | ||
|
|
ffba6f1212 | ||
|
|
b7dd9757aa | ||
|
|
f64ce3acc4 | ||
|
|
25597d1f05 | ||
|
|
9f5baaef6e | ||
|
|
a3861da125 | ||
|
|
2b874d8427 | ||
|
|
ba50784022 | ||
|
|
37950bd0ed | ||
|
|
bb6a4c4305 | ||
|
|
42e97b4a79 | ||
|
|
45e2798d37 | ||
|
|
218dd43764 | ||
|
|
4d9c936b86 | ||
|
|
69a3a2c568 | ||
|
|
7a9347bd2a | ||
|
|
4e275b1518 | ||
|
|
865083a835 | ||
|
|
15be0ec026 | ||
|
|
ab74426cbc | ||
|
|
f1adffef6b | ||
|
|
359d4337eb | ||
|
|
e77ad7c809 | ||
|
|
aa46196d1e | ||
|
|
e5ccfb0c5e | ||
|
|
e3c7df25c8 | ||
|
|
f11fc17a2c | ||
|
|
ecc79ce50f | ||
|
|
a10592c349 | ||
|
|
5ac90d01e6 | ||
|
|
dd3016b96b | ||
|
|
875f4eea29 | ||
|
|
78839a50fe | ||
|
|
f557f06bc5 | ||
|
|
c8998eb1ba | ||
|
|
b5b1a3b0d7 | ||
|
|
ea1b5a77f5 | ||
|
|
5c74051aab | ||
|
|
daffa894d8 | ||
|
|
67a3285412 | ||
|
|
9f40a4c5ac | ||
|
|
356f11b9c1 | ||
|
|
d5f13c4afb | ||
|
|
69532bdc1f | ||
|
|
55e1c3d6dd | ||
|
|
6718160381 | ||
|
|
e0f7c711ed | ||
|
|
43066a0822 | ||
|
|
f0706e2e6f | ||
|
|
3edc4e36b3 | ||
|
|
47cad77312 | ||
|
|
99a2f28440 | ||
|
|
53c4340d76 | ||
|
|
5a9cce4a08 | ||
|
|
4fbc9c62d9 | ||
|
|
a1b4698988 | ||
|
|
e01681af09 | ||
|
|
880d1c4a1f | ||
|
|
7c1292faa3 | ||
|
|
286e691fd2 | ||
|
|
c99d03b9bc | ||
|
|
58c2d3f5d8 | ||
|
|
988f6646c2 | ||
|
|
f56ca6373c | ||
|
|
3cfe6b9f75 | ||
|
|
65180cbb94 | ||
|
|
928184c581 | ||
|
|
e8b78de1a5 | ||
|
|
6fb547e8ce | ||
|
|
d86e84253a | ||
|
|
99b60e5f5f | ||
|
|
059722de6c | ||
|
|
16a6ee1136 | ||
|
|
f9c4938401 | ||
|
|
bdf1ddaada | ||
|
|
cf94624aa9 | ||
|
|
f42f89ca21 | ||
|
|
ae632e3562 | ||
|
|
0fd6314391 | ||
|
|
1ae49df1b0 | ||
|
|
16841f18e1 | ||
|
|
80863182d4 | ||
|
|
7c2e9861d7 | ||
|
|
7deb1c1de9 | ||
|
|
559fd2dc89 | ||
|
|
0cb7328aa1 | ||
|
|
664e5e999f | ||
|
|
37e919f43f | ||
|
|
d0780a72a7 | ||
|
|
f785134dfe | ||
|
|
de405efdbc | ||
|
|
fa47ded3ab | ||
|
|
e2be14d7c7 | ||
|
|
e2b5f6042c | ||
|
|
941a508d4b | ||
|
|
ebb7a78cab | ||
|
|
b4a5401b22 | ||
|
|
870edda123 | ||
|
|
156d4b0ffc | ||
|
|
304e4a8a24 | ||
|
|
8731682090 | ||
|
|
348f43f1bc | ||
|
|
161563d6f5 | ||
|
|
931c82361b | ||
|
|
61b332e814 | ||
|
|
4b3e53f4d0 | ||
|
|
f3c9e89920 | ||
|
|
1248647aa4 | ||
|
|
b3832d107f | ||
|
|
a0da8e9fb2 | ||
|
|
8794200c2a | ||
|
|
c2526969b1 | ||
|
|
3b4f6b39a5 | ||
|
|
a9cf88b3ff | ||
|
|
3d4be0a913 | ||
|
|
9252807364 | ||
|
|
db85bfa5f9 | ||
|
|
3822ec4536 | ||
|
|
b278367e3b | ||
|
|
13e73bd342 | ||
|
|
1047f3ecdc | ||
|
|
f09c922c46 | ||
|
|
be9ce8ede6 | ||
|
|
7f2681c1c6 | ||
|
|
91fdd958c2 | ||
|
|
b70a2fb607 | ||
|
|
950d52d147 | ||
|
|
d054cd23ed | ||
|
|
f4d8d64b9d | ||
|
|
9763bb410b | ||
|
|
db3ca83f29 | ||
|
|
c219f130c8 | ||
|
|
dc4f457b21 | ||
|
|
9a471b7903 | ||
|
|
67d3242f37 | ||
|
|
fb08bc5ce5 | ||
|
|
9d9b28af8e | ||
|
|
8ad02933c9 | ||
|
|
d5dc3f27db | ||
|
|
625d937f64 | ||
|
|
2bd20130b6 | ||
|
|
fdf6ae3ddd | ||
|
|
fc9ef1f8ae | ||
|
|
8cd1d1f684 | ||
|
|
e79c457774 | ||
|
|
d3f53b5a38 | ||
|
|
19a1184b1c | ||
|
|
ad40cb5fb9 | ||
|
|
a38934b099 | ||
|
|
3cf33869d6 | ||
|
|
41195f4c75 | ||
|
|
6db0802461 | ||
|
|
087300f9c3 | ||
|
|
abc736d32e | ||
|
|
b3a359760c | ||
|
|
5f86d48e6f | ||
|
|
1c17fcf337 | ||
|
|
1c6f5c685f | ||
|
|
473f592615 | ||
|
|
aba4b55ca0 | ||
|
|
4a53a95b2d | ||
|
|
a6f34fbfde | ||
|
|
472371d5a8 | ||
|
|
d7b8cd5e71 | ||
|
|
58cee5ea16 | ||
|
|
38efd4eb7c | ||
|
|
d4910b4398 | ||
|
|
ff34416c3c | ||
|
|
f8c2541d30 | ||
|
|
d9749e3a6f | ||
|
|
b77be4f5b0 | ||
|
|
fb7bfe0a5d | ||
|
|
79f6cecab4 | ||
|
|
0472e88d91 | ||
|
|
f710b2ce1d | ||
|
|
027ded1f8f | ||
|
|
3c8e2e2720 | ||
|
|
1bd87fc33e | ||
|
|
4009a4a4c1 | ||
|
|
339baab7ee | ||
|
|
a6c769901d | ||
|
|
a65aed5862 | ||
|
|
8b4bd480cc | ||
|
|
9c46455c35 | ||
|
|
47626338ba | ||
|
|
e290293bf1 | ||
|
|
688eadf17a | ||
|
|
daf34d7cdd | ||
|
|
1410a66a36 | ||
|
|
157d485685 | ||
|
|
4f07402936 | ||
|
|
9a4a1711fe | ||
|
|
7502d6f44d | ||
|
|
bed9bb4270 | ||
|
|
406d75287d | ||
|
|
8c604e0b04 | ||
|
|
ed15392097 | ||
|
|
af86034339 | ||
|
|
e8817056f7 | ||
|
|
a757bfe587 | ||
|
|
44aedfd623 | ||
|
|
58299311d0 | ||
|
|
89c25369fc | ||
|
|
be10dba4bd | ||
|
|
c655cf5861 | ||
|
|
2082fbd5eb | ||
|
|
55156f45c9 | ||
|
|
5bf0f97df8 | ||
|
|
ce2f04ab6d | ||
|
|
f7b2a96206 | ||
|
|
a6550f8b1f | ||
|
|
66968d90c0 | ||
|
|
442d0a157d | ||
|
|
4a85f02eae | ||
|
|
dfd33aee1c | ||
|
|
c5b466f281 | ||
|
|
1d242bab75 | ||
|
|
40284a7e30 | ||
|
|
ea08f566ff | ||
|
|
ca671f0d00 | ||
|
|
6e677f8857 | ||
|
|
e47e6329cc | ||
|
|
11395b655a | ||
|
|
ca520d2eb6 | ||
|
|
25dda21811 | ||
|
|
8a57ea73fb | ||
|
|
6729359ba2 | ||
|
|
f687389046 | ||
|
|
a7f4f9714f | ||
|
|
7ba238fb53 | ||
|
|
b845f8ba95 | ||
|
|
996309f52f | ||
|
|
8d12a0384b | ||
|
|
51f4223f4b | ||
|
|
241f0f3a2a | ||
|
|
34d5f86d53 | ||
|
|
3fde7cf3ad | ||
|
|
6913fc8257 | ||
|
|
437da72f3d | ||
|
|
9a2b8d97bd | ||
|
|
1e4f101219 | ||
|
|
c0a02a05d8 | ||
|
|
246f995b56 | ||
|
|
db28cc5690 | ||
|
|
26aa12019e | ||
|
|
1baa9c1f91 | ||
|
|
d64c646b68 | ||
|
|
021af60cf3 | ||
|
|
09dbdb9961 | ||
|
|
484dcc6b9c | ||
|
|
7571a4aa2d | ||
|
|
dc68305dcb | ||
|
|
386acda1dc | ||
|
|
a2606f1dc2 | ||
|
|
a2c91e0296 | ||
|
|
8148545295 | ||
|
|
70c8ea6584 | ||
|
|
f30143b5ea | ||
|
|
cf76bc3636 | ||
|
|
0fc152f623 | ||
|
|
377fd889c5 | ||
|
|
0a4c9a0ae8 | ||
|
|
1a4f930b50 | ||
|
|
49bbeadc48 | ||
|
|
5470e48e26 | ||
|
|
0fcb7f5152 | ||
|
|
0f30ca94ab | ||
|
|
468676d2f4 | ||
|
|
40623f939c | ||
|
|
110fd860b3 | ||
|
|
edf5d3fda4 | ||
|
|
e395878f92 | ||
|
|
f48b9b5a6c | ||
|
|
971656b76c | ||
|
|
48ec66cc30 | ||
|
|
966028e0a6 | ||
|
|
21eb223882 | ||
|
|
e10bd2d261 | ||
|
|
0728bbdd6a | ||
|
|
53e556ddab | ||
|
|
611b10b477 | ||
|
|
85a000422d | ||
|
|
d80091d244 | ||
|
|
5217f6a86f | ||
|
|
fc33746d4c | ||
|
|
cc449b3d2f | ||
|
|
8de2318f43 | ||
|
|
24f781d398 | ||
|
|
25f752339f | ||
|
|
fe95b4e32a | ||
|
|
2b671d91fd | ||
|
|
dcc3348c12 | ||
|
|
31fb3fc8ae | ||
|
|
a0b3db6895 | ||
|
|
6c7b6ebd4d | ||
|
|
3c86e02efe | ||
|
|
c48baac1ea | ||
|
|
311396cfac | ||
|
|
762614b2d3 | ||
|
|
31f2c71575 | ||
|
|
50f1af83f6 | ||
|
|
6c200d7c97 | ||
|
|
6954ffc050 | ||
|
|
c8a237aba4 | ||
|
|
7fa43a9e50 | ||
|
|
2e6c20d68d | ||
|
|
5af45cda57 | ||
|
|
cab5802547 | ||
|
|
d3a57f222e | ||
|
|
053f32c428 | ||
|
|
d63166b581 | ||
|
|
51710972eb | ||
|
|
0e13872311 | ||
|
|
eac5342fa5 | ||
|
|
a8e931f306 | ||
|
|
00fd96f4a4 | ||
|
|
0d5fa13d65 | ||
|
|
6270253c24 | ||
|
|
5da5d7cdc4 | ||
|
|
55bd353daf | ||
|
|
c5d31c1972 | ||
|
|
843906208c | ||
|
|
2338a07bab | ||
|
|
67d8828aea | ||
|
|
caa0fbc2fd | ||
|
|
8a85b344f8 | ||
|
|
b4662a175d | ||
|
|
fb08785ad5 | ||
|
|
2bb155a9e4 | ||
|
|
c4715fc95c | ||
|
|
bd637d0141 | ||
|
|
785fb990b4 | ||
|
|
f8c2c080f9 | ||
|
|
47220929bf | ||
|
|
3b4bb618dd | ||
|
|
9adfcd01b6 | ||
|
|
64e764b996 | ||
|
|
76eed47487 | ||
|
|
05b2f769e4 | ||
|
|
40b124bc2c | ||
|
|
4ab0ffde58 | ||
|
|
ab77beb3fe | ||
|
|
170828b308 | ||
|
|
08139bc3ee | ||
|
|
4589c46f16 | ||
|
|
6b924a4757 | ||
|
|
1602528e47 | ||
|
|
d28140bbd1 | ||
|
|
3f559a50ea | ||
|
|
e7a4eb3331 | ||
|
|
7b27f85b39 | ||
|
|
7204a426af | ||
|
|
2aad1a9bef | ||
|
|
dbadc73a57 | ||
|
|
59bc13c5b0 | ||
|
|
5c370cbe33 | ||
|
|
475739328c | ||
|
|
411e40d117 | ||
|
|
88e5f3f33b | ||
|
|
72dcdc6c52 | ||
|
|
d8f2b803c7 | ||
|
|
fdd3cf03b4 | ||
|
|
06184f1cb2 | ||
|
|
c26f4bdeeb | ||
|
|
04ae7480cf | ||
|
|
21705a0859 | ||
|
|
a2076cf9a9 | ||
|
|
d4aafbf351 | ||
|
|
7351363982 | ||
|
|
c86a63c54b | ||
|
|
3c5c58fc38 | ||
|
|
498d7e772a | ||
|
|
c594cc20fc | ||
|
|
5105fb40d3 | ||
|
|
b13eeb9cc0 | ||
|
|
49ef3dad78 | ||
|
|
a60f2cfac3 | ||
|
|
2eea42859e | ||
|
|
64f549848d | ||
|
|
5201b18cae | ||
|
|
8f92286cce | ||
|
|
27be5d43a2 | ||
|
|
e64541463c | ||
|
|
6a03dfd7ac | ||
|
|
f01dfa5f93 | ||
|
|
a57aa879e2 | ||
|
|
403190e888 | ||
|
|
6acc8866ed | ||
|
|
8e7a21f316 | ||
|
|
8411e907af | ||
|
|
eb88355229 | ||
|
|
1b68223798 | ||
|
|
33d7f4b884 | ||
|
|
05dd6c1e9c | ||
|
|
42f2a077b2 | ||
|
|
0bb76bdc8b | ||
|
|
efafaaddd2 | ||
|
|
a9ffc2d879 | ||
|
|
1fdbba1ca1 | ||
|
|
4b88e1a533 | ||
|
|
7a31ea5cba | ||
|
|
6633ba5244 | ||
|
|
5b954a7a53 | ||
|
|
c30206bfb2 | ||
|
|
cf9a02ae38 | ||
|
|
9f5aa33e95 | ||
|
|
ae3749bab9 | ||
|
|
c1c4ff8e31 | ||
|
|
ff41eb1d57 | ||
|
|
f23839f2b9 | ||
|
|
7cab4e8749 | ||
|
|
8a3316a5c4 | ||
|
|
631eb2e584 | ||
|
|
ed246ce6dd | ||
|
|
fc35b17d24 | ||
|
|
cee3d93864 | ||
|
|
fca99fee86 | ||
|
|
3dd0ba2024 | ||
|
|
871e35c10b | ||
|
|
5c9420ed9c | ||
|
|
8edc83893f | ||
|
|
a226addcc4 | ||
|
|
2402af9e82 | ||
|
|
31f95589cb | ||
|
|
2ac6b1f56b | ||
|
|
6449ead83d | ||
|
|
322f025794 | ||
|
|
9ab22b97f8 | ||
|
|
35747cf9fb | ||
|
|
f094438dab | ||
|
|
04f06042b8 | ||
|
|
a476170fca | ||
|
|
5a579362ff | ||
|
|
d797179844 | ||
|
|
a500915223 | ||
|
|
59a2e4f616 | ||
|
|
e125019f57 | ||
|
|
6ba823abd2 | ||
|
|
f44fa6e579 | ||
|
|
87ff83bd2d | ||
|
|
7dd396154d | ||
|
|
af5fe9b69d | ||
|
|
807364c63b | ||
|
|
6315323a48 | ||
|
|
6a9271cabb | ||
|
|
a1c56bd644 | ||
|
|
ebc3b9ee4d | ||
|
|
19772b5797 | ||
|
|
6314efb82b | ||
|
|
5cda95eeb2 | ||
|
|
0c71dea00c | ||
|
|
53539e0cdf | ||
|
|
9a24aff537 | ||
|
|
43769460fc | ||
|
|
2e62eb09f5 | ||
|
|
1ef3e291c2 | ||
|
|
3694f2501b | ||
|
|
c3d58d7111 | ||
|
|
da45cc36a3 | ||
|
|
c04cba29d9 | ||
|
|
cacecac865 | ||
|
|
10f7cc832a | ||
|
|
e5f37a866c | ||
|
|
5a4fbd1564 | ||
|
|
7bcab03b8a | ||
|
|
51fe9e5af2 | ||
|
|
2406d24cb8 | ||
|
|
745d2529e5 | ||
|
|
af9699e362 | ||
|
|
71b38687c4 | ||
|
|
0a8f33dc83 | ||
|
|
0f45b7aead | ||
|
|
a44bf46bef | ||
|
|
ec86bf55a8 |
No files matched your search
+25
-2
@@ -15,6 +15,8 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with: { python-version: "3.11" }
|
||||
- run: pip install -r requirements.txt
|
||||
- run: pip install -r requirements-audit.txt
|
||||
- run: python3 -m pip_audit -r requirements.txt --strict
|
||||
- run: python3 -m pytest tests/ -q
|
||||
|
||||
build-release:
|
||||
@@ -36,9 +38,30 @@ jobs:
|
||||
name: release-bundle
|
||||
path: dist/
|
||||
|
||||
browser-journey:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-release
|
||||
env:
|
||||
STACKCHAIN_RUN_RELEASE_E2E: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with: { python-version: "3.11" }
|
||||
- name: Download assembled release bundle
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: release-bundle
|
||||
path: dist
|
||||
- name: Install browser test dependencies
|
||||
run: |
|
||||
pip install -r requirements-e2e.txt
|
||||
python3 -m playwright install --with-deps chromium
|
||||
- name: Exercise packaged mobile work journeys
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py tests/e2e/test_mobile_following_release.py tests/e2e/test_mobile_detail_watch_release.py tests/e2e/test_mobile_pull_reviewer_status_release.py tests/e2e/test_mobile_pull_reviewer_feedback_release.py -q tests/e2e/test_mobile_address_review_feedback_release.py tests/e2e/test_mobile_cancel_pull_review_request_release.py tests/e2e/test_mobile_authored_pull_queue_release.py
|
||||
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, build-release]
|
||||
needs: [lint, build-release, browser-journey]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -59,7 +82,7 @@ jobs:
|
||||
RELEASE_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||
|
||||
(cd dist && sha256sum -c ./*.sha256)
|
||||
python3 scripts/verify_release.py --input-dir dist --commit "$TARGET"
|
||||
python3 scripts/verify_release.py --input-dir dist --commit "$TARGET" --repository .
|
||||
|
||||
printf '{"tag_name":"%s","target_commitish":"%s","name":"Release Candidate %s","body":"CI-tested release candidate for commit %s. Verify downloads with the attached SHA-256 checksum.","draft":true,"prerelease":true}\n' \
|
||||
"$TAG" "$TARGET" "$TAG" "$TARGET" > /tmp/release.json
|
||||
|
||||
@@ -19,29 +19,31 @@ threads, create and self-assign issues, discover, claim, and release issue assig
|
||||
list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues,
|
||||
inspect/comment on assigned pull
|
||||
requests, merge assigned pull requests, and submit pull-request reviews.
|
||||
Assigned-issue and assigned-pull-request comments can include one PNG, JPEG, or WebP screenshot; each mobile composer automatically optimizes oversized screenshots on-device to fit the 2 MB upload boundary while leaving already-valid files unchanged.
|
||||
For online delivery, the screenshot uploads before the comment is posted—to the exact assigned issue or pull request—and produces one Markdown comment; validation or upload
|
||||
failures keep both the typed comment and removable preview available for retry. Offline screenshot comments
|
||||
admit their text and image bytes to IndexedDB before confirmation, keep only bounded metadata in
|
||||
localStorage, and use checkpointed upload/comment identities so reconnect retries cannot duplicate
|
||||
either stage. The mobile **New issue** capture-first stage accepts the same image formats before a
|
||||
repository is chosen. **Save to Drafts** durably writes the optimized Blob to IndexedDB before
|
||||
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the exact
|
||||
preview when the operator later chooses a repository. The source Draft remains available until its
|
||||
screenshot has safely transferred to the issue outbox; discard and bounded pruning remove the Blob.
|
||||
Repository-aware durable admission likewise stores the screenshot with its account-bound outbox
|
||||
Assigned-issue, assigned-pull-request, and unread-update conversation composers accept an ordered bundle of up to five PNG, JPEG, or WebP photos. Repeated camera captures append to the bundle, the gallery picker accepts multiple images, and each composer automatically optimizes oversized screenshots on-device to fit the 2 MB upload boundary. Before sending, the selected conversation photo can use the same touch editor as New issue evidence to crop, privacy-redact, highlight, or add an arrow; Apply replaces only that flattened derivative while preserving its caption and bundle position, and Cancel leaves the original unchanged.
|
||||
For online delivery, every photo uploads before the comment is posted to the exact conversation target, producing one ordered Markdown comment or reply; validation or upload failures keep the typed text and removable preview available for retry. Offline photo conversations admit every image Blob to IndexedDB before confirmation, keep only bounded metadata in localStorage, and checkpoint each upload separately so reconnect resumes at the first unconfirmed photo without duplicating an upload, comment, reply, or reply-and-read transition. The mobile **New issue** capture-first stage accepts an ordered evidence bundle of up to
|
||||
five PNG, JPEG, or WebP screenshots before a repository is chosen, optimizing each image independently
|
||||
to the 2 MB boundary. **Save to Drafts** durably writes every optimized Blob to IndexedDB before
|
||||
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the ordered
|
||||
bundle when the operator later chooses a repository. A scrollable thumbnail tray lets the operator
|
||||
review every restored image before filing; **Move earlier**, **Move later**, and **Remove selected**
|
||||
change the durable evidence order without changing the issue title or note. The active screenshot's
|
||||
optional **Evidence note** stays paired with that image through reorder, Draft restore, offline delivery,
|
||||
and retry, then appears as a Markdown-safe caption immediately before its uploaded image. The selected screenshot can be cropped, privacy-redacted, highlighted, and marked with touch-drawn arrows before a flattened derivative replaces it; undo, reset, and cancel keep editing reversible without changing its note or bundle position. The source Draft remains
|
||||
available until its evidence has safely transferred to the issue outbox; discard and bounded pruning remove every Blob.
|
||||
Repository-aware durable admission likewise stores the evidence bundle with its account-bound outbox
|
||||
capture, avoiding base64 quota pressure and synchronous multi-megabyte writes. Online and background
|
||||
delivery send the original bytes as multipart form data, avoiding the roughly 33% base64 wire
|
||||
expansion. Existing queued base64 screenshot payloads remain readable and are converted only at
|
||||
delivery time.
|
||||
Delivery creates the issue exactly once, then uploads and comments with
|
||||
the image; after a partial failure, retry resumes with the confirmed issue instead of
|
||||
creating a duplicate.
|
||||
Delivery creates the issue exactly once, then uploads each image under a checkpointed per-image
|
||||
identity and posts one ordered Markdown evidence comment. After a partial failure, retry resumes with the confirmed issue and from
|
||||
the first unconfirmed image instead of duplicating the issue or earlier uploads. The installed PWA
|
||||
Share Target accepts the same bounded multi-image bundle through sign-in continuation.
|
||||
Pull-request replies and mobile My Work issue and PR comments use Gitea's
|
||||
issue-comment API. In issue, pull-request, and unread-update conversations, typing
|
||||
at least two characters after `@` offers repository-scoped teammate suggestions;
|
||||
touch or keyboard selection inserts the login without leaving the draft. Mention
|
||||
lookup failure never blocks literal text or comment delivery. Mobile issue capture requires issue
|
||||
issue-comment API. In issue, pull-request, unread-update, and active Today progress
|
||||
conversations, typing at least two characters after `@` offers repository-scoped
|
||||
teammate suggestions; touch or keyboard selection inserts the login without leaving
|
||||
the draft. Mention lookup failure never blocks literal text or comment delivery. Mobile issue capture requires issue
|
||||
creation and assignment permission. A fresh capture never silently targets the first repository:
|
||||
the operator must explicitly choose one, either from the paginated browser or through the bounded
|
||||
authenticated repository search. Search results include only visible repository identities, stale
|
||||
@@ -56,23 +58,129 @@ repository milestone and due date; the dashboard validates both and sends them w
|
||||
self-assignment in the single create request, so planned work appears in its release
|
||||
lane immediately. On a cold offline launch, **Save for filing** stores up to 20
|
||||
account-bound title/description captures without selecting a repository or entering the
|
||||
mutation outbox. Drafts marks them **Needs filing**; after a fresh reconnect confirms
|
||||
the same Gitea login, **Choose repository** restores the capture to the normal planning
|
||||
and durable delivery flow. A different or unconfirmed account can only copy or discard
|
||||
mutation outbox. Drafts marks them **Needs filing** and saves locally first. After a fresh
|
||||
reconnect confirms the same Gitea login, a bounded, revisioned collection synchronizes the title,
|
||||
description, blockers, and ordered screenshot evidence; another signed-in device can then use
|
||||
**Choose repository** to continue the normal planning and durable delivery flow. Draft cards report
|
||||
whether they are synced or still pending locally. Concurrent changes surface a conflict, and
|
||||
successful discard or outbox admission propagates deletion so a stale device cannot resurrect the
|
||||
draft. Synchronization is limited to 20 drafts and 12 MiB of decoded evidence per account; failure
|
||||
never blocks local capture. A different or unconfirmed account can only copy or discard
|
||||
the private content. Issue capture and authored mobile actions (issue
|
||||
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
||||
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
|
||||
another worker replays a confirmed result instead of posting duplicate content. The ordered,
|
||||
another worker replays a confirmed result instead of posting duplicate content. Mobile Search previews
|
||||
let operators assign an eligible issue directly into Later at an exact local return time without filling
|
||||
Today or interrupting active work. Cancel and browser Back preserve the Search preview without assigning;
|
||||
confirmation claims only when needed, syncs the Later plan across devices, and returns to the preserved
|
||||
query, filters, results, and scroll position. If assignment succeeds but Later storage fails, the issue
|
||||
remains recoverable in My Work and the dashboard reports the partial outcome instead of claiming success.
|
||||
Eligible open issue previews also expose **Plan ahead**: choose one of the next seven local dates, review
|
||||
that day's current load and capacity, and enter a required estimate without leaving Search. Existing Week
|
||||
Ahead placement is moved only after an explicit day change, never duplicated; full days are blocked and
|
||||
over-capacity admission requires a second confirmation. The action claims unassigned work only after these
|
||||
checks, stages the canonical issue through the account-bound Week Ahead outbox, and preserves the Search
|
||||
preview through cancel or browser Back. Offline saves report **sync pending**; if assignment succeeds but
|
||||
local planning fails, the dashboard reports **Assigned, not planned** and leaves the issue recoverable in My Work.
|
||||
The live mobile Week Ahead overview also offers **Remove from week** for active planned work. It removes only the
|
||||
private planning placement—not assignment, issue state, or Gitea content—and immediately recalculates the day's
|
||||
load. A 10-second **Undo** receipt restores the exact day, list position, and estimate. Both transitions use the
|
||||
account-bound Week Ahead outbox, remain **sync pending** after delivery failure, and stay unavailable in a read-only
|
||||
offline snapshot.
|
||||
Search selection mode extends that flow across several open issues with **Plan Week Ahead**. One phone-safe
|
||||
review assigns each issue a future day and estimate, validates five-item limits and daily capacity before any
|
||||
claim, and requires a second confirmation for overload. Confirmed rows are staged into one canonical Week
|
||||
Ahead update and flushed together; partial assignment or offline sync is reported without discarding the
|
||||
selection, so claimed work remains recoverable from My Work.
|
||||
Commentable mobile Search previews support camera capture and gallery selection for up to five ordered
|
||||
photos, including captions, crop/annotation/redaction review, and metadata-stripping re-encoding. Operators
|
||||
can queue photo-only, text-only, or mixed replies without leaving their Search pass, including while offline.
|
||||
Durable admission account-binds the exact issue/pull kind and stores image bytes in IndexedDB before the UI
|
||||
clears or advances. Reconnect revalidates the visible Search target through the narrower preview attachment
|
||||
and comment routes instead of assigned-work routes. Existing per-photo upload operation IDs and confirmed
|
||||
Markdown checkpoints survive handoff, so retry resumes from the first unfinished photo and the final comment
|
||||
posts once. **Send & next** advances only after durable admission; a storage failure keeps the current result,
|
||||
text, evidence, and Search position unchanged.
|
||||
Named mobile Search views preserve the query, type, status, and optional repository scope. They are
|
||||
bounded to 20 per confirmed account and synchronize through a revisioned SQLite collection, so another
|
||||
device can reopen the exact Search with one tap while stale writes surface a conflict instead of silently
|
||||
overwriting newer views. Rename and delete affect only the saved view, never Gitea work; an unavailable
|
||||
sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default
|
||||
`.stackchain-state/saved-searches.sqlite3` path.
|
||||
|
||||
Confirmed **Watch issue** and **Watch pull request** actions on open Search results and assigned My Work
|
||||
issue/pull-request details feed the mobile **Following** queue, including work already assigned to you or a teammate.
|
||||
The detail control loads authoritative Gitea state, remains single-flight while changing it, and refreshes Following only
|
||||
after the account-scoped collection confirms the same mutation. This completes the read → watch → revisit flow without
|
||||
changing ownership, review assignment, or scheduling work.
|
||||
Following is a read-first, account-scoped collection: it is encrypted at rest, revisioned, bounded to
|
||||
50 typed items, and synchronized across signed-in devices. Opening a row reuses the correct issue or
|
||||
pull-request Search Preview; confirmed **Stop watching** removes an open item. When watched work
|
||||
closes or merges, the sequential review exposes **Stop watching & next** so the completed item can be
|
||||
retired without leaving the preview; the next captured change opens immediately, and retiring the
|
||||
final item completes the Following phase. Changed cards explain whether work closed, reopened, changed title, or
|
||||
received other activity. During review, conversation messages newer than the prior reviewed revision are highlighted,
|
||||
and the revision is acknowledged only after both detail and conversation context load; a failed conversation load keeps
|
||||
that exact change unseen for Retry or **Keep for later & next**. For open work that still needs thought, **Keep for later & next**
|
||||
restores only the loaded revision to the unseen queue and continues the captured pass without changing
|
||||
Gitea state, ownership, planning, or watch status. Failed or unconfirmed Gitea mutations leave the collection
|
||||
and current review position unchanged. Following counts never influence the recommended Work queue. Set
|
||||
`STACKCHAIN_FOLLOWING_DB` to override `.stackchain-state/following.sqlite3`.
|
||||
|
||||
Each device can explicitly opt in to **Notify me when Following changes**. Stackchain polls that
|
||||
channel independently, validates the device session immediately before delivery, and checkpoints
|
||||
the exact unseen revision set so unchanged work stays silent across workers. Lock-screen payloads
|
||||
contain only a bounded count and the `#/my-work/following` route—never repository names, titles,
|
||||
bodies, or comments. Disabling Following alerts leaves Updates, deadlines, and start-day reminders
|
||||
unchanged.
|
||||
|
||||
Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged.
|
||||
The mobile queue sheet begins with **Prepare Today**, a live briefing that totals Agenda, Attention, Updates, Filed,
|
||||
and unseen Following activity, then opens the highest-priority non-empty review queue. Following refreshes only when
|
||||
Prepare Today is opened; selecting that phase starts its changed-first sequential review directly. Starting the pass
|
||||
saves a confirmed-account, local-day checkpoint: finishing Agenda, Updates, the final Filed review, or the final
|
||||
Following revision returns to a focused handoff using fresh queue counts, while reopening the queue sheet resumes the
|
||||
next live phase. **Finish for now** removes only that local
|
||||
checkpoint. Once urgent review is clear the pass continues the existing Today plan, or opens Find Work when Today
|
||||
is empty; the briefing and checkpoint never change Gitea state.
|
||||
Filed separates actionable **Needs review** from a browsable **Reviewed** history, so acknowledgement clears the
|
||||
queue without erasing the delegated-work record. Reviewed cards reopen the existing read-only issue detail and
|
||||
conversation, while the Filed badge continues to count actionable outcomes only. A later Gitea update moves that
|
||||
issue back to Needs review automatically. Acknowledgements synchronize the exact Gitea `updated_at` revision in
|
||||
bounded batches to the confirmed account and preserve the same Reviewed state on other signed-in devices. Offline
|
||||
or failed synchronization keeps the local acknowledgement and retries on the next healthy dashboard refresh
|
||||
without blocking **Acknowledge & next**. Set
|
||||
`STACKCHAIN_COMPLETED_FILED_REVIEW_DB` to override the default
|
||||
`.stackchain-state/completed-filed-reviews.sqlite3` path.
|
||||
Search previews also
|
||||
let operators assign an eligible issue and add it to Today without starting or replacing active work.
|
||||
The queue action keeps the Search query, filters, results, and scroll position available for continued
|
||||
planning, reports an existing Today item without duplicating it, and uses the same capacity, sync, and
|
||||
offline-warm path as other Today admission flows. The ordered,
|
||||
five-item Today plan syncs across the operator's devices. **Plan Today** also stores available minutes
|
||||
and a per-item estimate with the account-scoped plan, continuously showing planned/free or over-capacity
|
||||
time. An over-capacity plan requires a second explicit save, legacy plans migrate with unestimated work,
|
||||
and an active Today session shows the current estimate plus estimated remaining runway. Adding an issue through **Plan Today**
|
||||
first previews its Gitea dependencies: unresolved blockers are listed with links and require the
|
||||
explicit **Add blocked item anyway** override, while an unavailable dependency lookup is reported
|
||||
as unknown rather than unblocked. Starting a Today work session also
|
||||
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
|
||||
as unknown rather than unblocked. Completing any Today item now exposes a 10-second, touch-safe
|
||||
**Undo** receipt. Undo restores the item's original order and estimate, queues the inverse cross-device
|
||||
plan changes, and leaves the already-advanced work session on its current item; expiry, capacity, or a
|
||||
concurrently changed plan is reported without overwriting newer work. Rescheduling Today work into
|
||||
Week Ahead uses a bounded account-scoped FIFO on the device: multiple offline moves survive reload,
|
||||
deliver oldest-first with stable operation IDs, and rebase each later move on the confirmed plan revisions
|
||||
without concurrent requests. Starting a Today work session also
|
||||
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. An opt-in, privacy-safe lock-screen notification mirrors the current pause/resume control and adds **Finish current**: its opaque one-shot action is bound to the exact active item, reuses **Done & next** or recap, and never changes the underlying Gitea issue or pull request. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. Cross-device **Continue here** handoff transfers the bounded per-item timing ledger—not only the active item—so the receiving phone keeps the complete recap and optional time-log durations; legacy single-item sessions migrate automatically. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
|
||||
After wrap-up, **Share day summary** opens a private review of the exact worked-on and tomorrow selections. Every row is opt-in adjustable, actual time is excluded by default, and an optional bounded note is previewed before the native share sheet or clipboard fallback. Canceling or closing keeps the account-scoped device draft; successful sharing or **Discard draft** removes it.
|
||||
|
||||
A running issue or pull request also exposes **Add update** without advancing Today. The operator can type or dictate a progress note, review and explicitly append, replace, or discard the transcript, add up to five photo-evidence items, then save privately or admit the exact comment to durable delivery. When work is blocked, **Post blocker & move on** requires a future return time, admits the comment before any planning change, defers the item to Later, and continues the existing Today session. An admitted planning transition remains checkpointed for retry, so a storage or Today-removal failure cannot post the blocker twice. Final transcripts—not audio—are bounded to 2,000 characters and isolated by confirmed account and Today item; closing the sheet aborts listening while leaving text and photos usable.
|
||||
After a reload or installed-app
|
||||
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed);
|
||||
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed). In an open
|
||||
assigned issue, the mobile detail sheet renders Markdown checklist items as touch-safe controls and keeps
|
||||
**Add step** directly beside the plan. A new step is normalized, checked for duplicates, and appended without
|
||||
exposing or replacing the full issue description. Offline additions enter the same account-bound durable
|
||||
issue-content queue as checklist toggles; reconnect conflict review applies unique local additions to the
|
||||
latest remote body while preserving remote prose and task-state changes.
|
||||
**Comment & next** on that current issue or pull request posts the handoff online or admits it
|
||||
to durable account-bound delivery, then removes the item only from Today and opens the next
|
||||
one without closing or merging it. **Reply & next** provides the same one-action continuation
|
||||
@@ -90,7 +198,46 @@ failure preserves both the reply draft and checkpoint. Finishing or choosing **E
|
||||
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
|
||||
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
|
||||
and reconnecting or returning to the dashboard refreshes server truth after replaying queued
|
||||
offline operations. Planning edits can remain offline for up to 30 days. After that, the
|
||||
offline operations. **Plan Tomorrow** remains independent from active Today work and durably admits the exact
|
||||
ordered plan, capacity, estimates, next local date, timezone, and server revision to account-bound browser
|
||||
storage before closing on a phone. The Queues summary marks it **sync pending** while offline; reconnect,
|
||||
foreground, and midnight lifecycle checks share one delivery flight. A successful account receipt removes
|
||||
the pending copy, while a revision conflict preserves both the phone plan and fresh server snapshot for
|
||||
review. Unsynced Tomorrow work is never promoted into Today.
|
||||
**Week Ahead** opens as a read-first seven-day mobile overview, so operators can inspect the next planned day,
|
||||
work titles and references, load versus capacity, overloads, and pending sync without staging a change. When Today
|
||||
is empty and no work session is active, **Start this day early** confirms the next planned date, item count, minutes,
|
||||
and capacity before atomically moving only that day into Today; offline, pending, stale, or non-empty plans remain
|
||||
unchanged. **Edit day** enters one date and returns to the refreshed overview; **Edit week** starts the continuous
|
||||
planning pass. Closing an issue from its Week Ahead detail retires that identity and estimate from every future
|
||||
week day in one durable update while preserving sibling order and daily capacity. The open overview recalculates
|
||||
immediately; an unavailable save remains visibly **sync pending** and retries through the existing lifecycle.
|
||||
**Plan Week Ahead** continues through seven local dates and now finishes on a mobile review step instead of
|
||||
closing after the seventh save. The review shows planned minutes against each day’s capacity, marks overloads,
|
||||
and flags work assigned to more than one date. Operators can move an item to another date without copying it;
|
||||
the estimate follows the item and the server rejects duplicate cross-day assignments without advancing the
|
||||
week revision. When capacity changes create overload, **Reflow remaining week** previews a deterministic,
|
||||
order-preserving redistribution across the seven visible dates. No day exceeds its capacity or five-item limit;
|
||||
work that cannot fit is named and remains in My Work, while missing estimates block apply. Cancel performs no
|
||||
write, and apply stages one durable whole-week transition through the existing conflict-safe sync path.
|
||||
Confirmation remains disabled while duplicates exist or the account-bound week is still syncing.
|
||||
Rapid saves and review moves use one network flight plus a coalesced latest-state delivery, so later staged days
|
||||
are not stranded behind an earlier request. From the same review, **Refresh from calendar** reads a local
|
||||
`.ics` file on-device, unions overlapping busy periods within chosen working hours, and compares every current
|
||||
capacity with refreshed availability and planned load. If refreshed availability creates an overload, the review
|
||||
previews the deterministic destination of moved work and reports anything that cannot fit. One explicit
|
||||
**Apply capacities & reflow** action stages the complete seven-day plan once—there is no transient overloaded
|
||||
save—and preserves consented free windows alongside the redistributed work. A refresh that already fits keeps
|
||||
the direct one-write capacity path. IANA `TZID` values from Google and Outlook calendars are converted
|
||||
to the device's local workday, including daylight-saving transitions. Daily and weekly recurrence (`COUNT`,
|
||||
`UNTIL`, `INTERVAL`, and weekly `BYDAY`), `RDATE`/`EXDATE`, and all-day events are evaluated with work bounded
|
||||
to the seven review dates; cancelled and transparent events do not consume capacity. If a time zone is unknown,
|
||||
or a recurring event that could affect the week uses another frequency or unsupported rule part, the review
|
||||
reports only an affected-event count and disables apply rather than understating busy time. Export a simpler
|
||||
seven-day calendar to proceed. Raw calendar data and event
|
||||
metadata are never persisted, rendered in diagnostics, or sent; only the reviewed capacity-minute totals use
|
||||
the existing encrypted, account-bound Week Ahead sync.
|
||||
Planning edits can remain offline for up to 30 days. After that, the
|
||||
expired edit is discarded visibly and the account plan is kept rather than replaying stale
|
||||
intent. The server retains no more than 4,096 operation receipts per account and removes
|
||||
receipts older than the same 30-day window; client base revisions keep a pruned replay from
|
||||
@@ -129,7 +276,30 @@ and runs up to three deliveries concurrently, but claims a record only when a de
|
||||
is ready. Claims have unique fencing tokens and are renewed before every network stage;
|
||||
completion, release, failure, and delivery checkpoints are token-fenced so an expired worker
|
||||
cannot alter a newer crash-recovery claim. Device purge cancels an active drain before closing
|
||||
private outbox storage. Results are coordinated through a bounded SQLite ledger. Set `STACKCHAIN_STATE_DIR` to a
|
||||
private outbox storage. Results are coordinated through a bounded SQLite ledger. All private SQLite stores enforce a
|
||||
filesystem boundary independently of the service umask: the database directory is repaired to
|
||||
owner-only `0700`, database and SQLite sidecar files are owner-only `0600`, and symlinked database
|
||||
paths are rejected before access. Worker-shared live and Find Work snapshots add AES-256-GCM
|
||||
envelopes authenticated to their store identity (and live generation), so copied databases do not
|
||||
expose issue bodies, titles, notification metadata, or repository context. The authored-action
|
||||
idempotency ledger encrypts both request fingerprints and confirmed upstream responses, authenticating
|
||||
each envelope to its operation key and field purpose so rows and fields cannot be substituted.
|
||||
Existing plaintext snapshot and ledger rows migrate atomically on their first read without changing
|
||||
freshness, revisions, ordering, replay, or conflict semantics. Synchronized unfiled Draft collections
|
||||
use a separate AES-256-GCM key and authenticate the account and revision; existing plaintext rows
|
||||
likewise migrate on first read. Synchronized Saved Search collections and completed Filed review
|
||||
receipts use the private-state key and authenticate each envelope to its normalized account, preventing
|
||||
rows from being substituted between operators. Existing plaintext Saved Searches migrate atomically on
|
||||
first read without advancing their revision; existing completed Filed receipts migrate transactionally at
|
||||
startup without changing order or acknowledgement state. Missing, wrong, or modified key material
|
||||
returns no saved-view or receipt content. Other private stores
|
||||
are not encrypted at the application layer. Web
|
||||
Push subscriptions use a third, independent
|
||||
AES-256-GCM key authenticated to the device session, with keyed endpoint indexes preserving
|
||||
single-device enrollment without retaining capability URLs. Existing plaintext subscriptions
|
||||
migrate atomically at startup without resetting delivery checkpoints or reminder schedules. Secure
|
||||
host access, encrypted volumes, and private backups are still required.
|
||||
Set `STACKCHAIN_STATE_DIR` to a
|
||||
persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an explicit
|
||||
SQLite path); the local default is `.stackchain-state/idempotency.sqlite3`. Ledger reads and
|
||||
writes run outside the request event loop, and lock admission is bounded to 100 ms by
|
||||
@@ -194,6 +364,44 @@ export STACKCHAIN_PASSKEY_MAX_CHALLENGES_PER_SOURCE=10
|
||||
export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000
|
||||
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
|
||||
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
|
||||
# Required for worker-shared live/Find Work snapshots, synchronized Today/Later
|
||||
# planning, Saved Search state, completed Filed review receipts, and the Security activity journal. Keep this key independent
|
||||
# from the Draft key and inject the
|
||||
# base64 encoding of exactly 32 random bytes from a secret manager. Never commit
|
||||
# it. Missing, malformed, wrong-key, or modified state fails closed without
|
||||
# returning content. Legacy Today/Later, Saved Search, and Security activity rows
|
||||
# migrate atomically on first use without changing logical revisions or journal IDs.
|
||||
# The single-key setting remains the compatible first deployment and writes v1 envelopes.
|
||||
export STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
|
||||
# After all workers run keyring-capable code, configure at most four named keys and
|
||||
# select the active write key. Keep the old key present during the rolling deployment.
|
||||
# Key IDs use 1-32 letters, digits, underscores, or hyphens.
|
||||
export STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEYS='{"legacy":"<old-base64-key>","2026-08":"<new-base64-key>"}'
|
||||
export STACKCHAIN_PRIVATE_STATE_ACTIVE_KEY_ID='2026-08'
|
||||
# New writes use authenticated v2 envelopes carrying the active key ID. Rewrap every
|
||||
# shared store with the restart-safe command; output contains store-level counts only.
|
||||
# Run it again until every store reports failed=0, migrated=0, and current=total,
|
||||
# then remove the old key from every worker. Retain all keys and investigate if it exits 1.
|
||||
python3 scripts/rotate_private_state.py
|
||||
# Required for cross-device unfiled Draft sync. The single-key setting remains
|
||||
# supported for the first deployment of keyring-capable code and writes v1 envelopes.
|
||||
# Inject the base64 encoding of exactly 32 random bytes from a secret manager.
|
||||
export STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
|
||||
# After every worker runs keyring-capable code, replace the single-key setting with
|
||||
# a bounded JSON object (at most eight keys) and name one active write key. During
|
||||
# the first rotation, preserve the original single key under the reserved `legacy`
|
||||
# ID so existing v1 envelopes remain readable. Key IDs use 1-32 letters, digits,
|
||||
# underscores, or hyphens. Never commit either setting.
|
||||
export STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEYS='{"legacy":"<old-base64-key>","2026-08":"<new-base64-key>"}'
|
||||
export STACKCHAIN_UNFILED_DRAFT_ACTIVE_KEY_ID='2026-08'
|
||||
# New writes use authenticated v2 envelopes carrying the active key ID. Reads
|
||||
# atomically rewrap plaintext, v1, and inactive-key rows without advancing their
|
||||
# logical revision. Run the content-free, restart-safe migration until it exits 0:
|
||||
python3 scripts/rotate_unfiled_drafts.py
|
||||
# A result such as {"current":42,"failed":0,"migrated":0,"total":42} proves the
|
||||
# old key has no remaining row dependencies. Only then remove `legacy`/old keys and
|
||||
# restart. A nonzero exit reports unreadable row counts but never account or Draft
|
||||
# content. Roll back only to a keyring-capable build and retain every configured key.
|
||||
# Trust forwarding headers only from these immediate reverse-proxy networks.
|
||||
export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
|
||||
# Optional Web Push. Generate a VAPID key pair outside the repo and inject it.
|
||||
@@ -202,13 +410,19 @@ export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
|
||||
# 09:00 in the device's local timezone without opening the dashboard. Bursts send
|
||||
# three individual alerts followed by one private digest that opens Updates. A later
|
||||
# comment on an already-delivered thread triggers a fresh alert when Gitea advances
|
||||
# that thread's updated_at revision; unchanged and older snapshots remain silent.
|
||||
# that thread's updated_at revision; unchanged and older snapshots remain silent. Opt-in
|
||||
# Following alerts use generic copy and deep-link to the changed-first Following review.
|
||||
# Browser push services must resolve exclusively to public IP addresses. Stackchain
|
||||
# validates endpoints at enrollment and again before delivery, rejects redirects,
|
||||
# and removes legacy subscriptions that resolve to private or reserved networks.
|
||||
export STACKCHAIN_VAPID_PUBLIC_KEY='<url-safe-public-key>'
|
||||
export STACKCHAIN_VAPID_PRIVATE_KEY='<private-key-from-secret-manager>'
|
||||
export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com'
|
||||
# Required whenever all three VAPID settings enable Web Push. Keep this key
|
||||
# independent from the snapshot and Draft keys. Startup authenticates every retained
|
||||
# subscription and fails closed for a missing, malformed, wrong, or modified key.
|
||||
# Back up the key separately: losing it makes existing device enrollments unrecoverable.
|
||||
export STACKCHAIN_PUSH_STATE_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
|
||||
# Optional; defaults to a 30-second poll, 10-second endpoint deadline,
|
||||
# 8 concurrently dispatched devices, 60-second renewable cross-worker lease,
|
||||
# and STACKCHAIN_STATE_DIR/push-subscriptions.sqlite3. Threads stay ordered
|
||||
@@ -227,6 +441,29 @@ export STACKCHAIN_PUSH_DB='/var/lib/stackchain-dashboard/push-subscriptions.sqli
|
||||
uvicorn src.main:app --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
`GITEA_URL` is also the trust boundary for every Gitea resource link returned
|
||||
to the browser. Configure the externally reachable scheme, host, and subpath
|
||||
(for example `https://forge.example.com/git`); cross-origin, downgraded, and
|
||||
same-host links outside that subpath are discarded.
|
||||
|
||||
For a systemd deployment, keep those values in a root-readable environment file
|
||||
(`chmod 600`), reference it with `EnvironmentFile=`, and keep secrets out of the unit
|
||||
command line and repository. Liveness alone does not prove that operators can use the
|
||||
service: `/healthz` intentionally remains healthy when authentication is missing. After
|
||||
each restart or proxy change, run the complete public-subpath smoke journey with the
|
||||
operator secret supplied only through the process environment:
|
||||
|
||||
```bash
|
||||
STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>' \
|
||||
python3 scripts/verify_deployment.py \
|
||||
https://forge.example.com/dashboard/
|
||||
```
|
||||
|
||||
The verifier requires readiness, rendered sign-in, a manifest whose `scope` and
|
||||
`start_url` remain inside the supplied subpath, and an authenticated mobile Home with
|
||||
its New-issue entry. It emits only a small JSON result and never includes the operator
|
||||
secret in success or failure output.
|
||||
|
||||
Token and passkey sign-in failures are scoped to a hashed canonical client address and
|
||||
persisted across workers and restarts. Public passkey option issuance has a separate
|
||||
fixed-window admission budget in the same ledger, and live challenges are bounded per
|
||||
@@ -259,8 +496,13 @@ place, their live sessions remain valid, and their idle clock starts at migratio
|
||||
The same sheet includes **Security activity**, a reverse-chronological journal of
|
||||
successful token/passkey sign-ins, passkey enrollments, sign-outs, remote device
|
||||
revocations, issue closures, and pull-request merges. The separate SQLite journal
|
||||
retains at most 10,000 events for 90 days and stores only bounded device labels and
|
||||
action targets. It never stores access tokens, cookies, session/CSRF values,
|
||||
retains at most 10,000 events for 90 days. Its bounded event kind, authentication
|
||||
method, device label, and action target are sealed at rest with the private-state
|
||||
encryption key and authenticated against the immutable event ID. Legacy plaintext
|
||||
journals migrate atomically without changing IDs, timestamps, order, or pending
|
||||
operations. Missing or wrong key material and modified ciphertext fail closed with
|
||||
no partial activity response. The journal never stores access tokens, cookies,
|
||||
session/CSRF values,
|
||||
credential IDs, public keys, challenges, attestation data, raw network addresses,
|
||||
request bodies, or comment content. Keep its database on the same class of persistent,
|
||||
writable storage as the session registry. Before passkey credential creation, session
|
||||
@@ -328,6 +570,11 @@ is assembled in source order into one content-addressed JavaScript response. Das
|
||||
HTML and the offline worker reference that exact fingerprint, while the runtime receives
|
||||
immutable caching and HTML/worker responses remain revalidated. The stylesheet and
|
||||
fingerprinted runtime are same-origin assets included atomically in the offline PWA shell.
|
||||
Device Setup also checks whether the browser has granted persistent storage without prompting.
|
||||
Choose **Protect offline work** to request protection from automatic storage-pressure eviction;
|
||||
Stackchain reports **Protected**, **Best effort**, **Denied**, or an unavailable/retryable state
|
||||
truthfully. Offline work continues when protection is unavailable or denied, but the browser may
|
||||
remove best-effort data, so persistence does not replace backups or device security.
|
||||
Use **Sign out & clear this device** on shared devices; it clears Stackchain's
|
||||
offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without
|
||||
removing unrelated forge preferences. Rotate either dashboard secret by replacing
|
||||
@@ -408,11 +655,13 @@ filter, preserves the selected release lane, shows the current draft count, resp
|
||||
the device safe area, and moves out of the way while a full-screen task is open.
|
||||
Desktop layout is unchanged.
|
||||
|
||||
Mobile **Search** provides touch-sized **Type** and **Status** controls. Operators can
|
||||
scope results to issues, pull requests, open work, closed work, or all accessible work;
|
||||
the server applies that scope before pagination. The selected scope is bounded and
|
||||
addressable, survives preview/back, reload, sharing, and sign-in continuation, and a
|
||||
scope change cancels obsolete requests before restarting at the first page.
|
||||
Mobile **Search** provides touch-sized **Repository**, **Type**, and **Status** controls.
|
||||
Operators can find and select a repository visible to their Gitea account, then scope results
|
||||
to that exact repository, issues, pull requests, open work, closed work, or all accessible work;
|
||||
the server applies every scope before pagination. Clearing Repository returns to organization-wide
|
||||
Search. The selected scope is bounded and addressable, survives preview/back, reload, sharing,
|
||||
and sign-in continuation, and a scope change cancels obsolete requests before restarting at the
|
||||
first page. Repository lookup failure leaves organization-wide Search usable.
|
||||
|
||||
My Work also has an account-synced **Later** queue. **Later today** defers an item for four
|
||||
hours, while **Tomorrow** returns it at 09:00 in the device's local timezone. **Choose date & time**
|
||||
|
||||
@@ -23,3 +23,20 @@
|
||||
- API contracts + versioning
|
||||
- independent deployability
|
||||
- observability: metrics, traces, structured logs
|
||||
|
||||
# Disk capacity incident
|
||||
|
||||
Root filesystem usage at or above 85% is an operations incident.
|
||||
|
||||
1. Capture the initial state with `df -h / /var/lib/gitea`.
|
||||
2. Inspect directory sizes and active processes before cleanup. Reclaim only
|
||||
disposable caches and abandoned temporary environments. Responders must not
|
||||
delete Gitea data, repositories, databases, secrets, or active release
|
||||
artifacts.
|
||||
3. Re-run `df -h / /var/lib/gitea`; usage must be below 85%.
|
||||
4. Confirm the release path with `systemctl is-active gitea act_runner`, then run
|
||||
the repository test suite.
|
||||
|
||||
Record the before and after usage in the incident ticket. If safe cleanup cannot
|
||||
restore capacity below 85%, keep the incident open and escalate storage
|
||||
expansion rather than removing durable data.
|
||||
@@ -0,0 +1,180 @@
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
function escapeText(value) {
|
||||
return String(value || '')
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\r?\n/g, '\\n')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/;/g, '\\;');
|
||||
}
|
||||
|
||||
function calendarDay(value) {
|
||||
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
return match ? match.slice(1).join('') : '';
|
||||
}
|
||||
|
||||
function nextDay(day) {
|
||||
const date = new Date(Date.UTC(
|
||||
Number(day.slice(0, 4)), Number(day.slice(4, 6)) - 1, Number(day.slice(6, 8)) + 1
|
||||
));
|
||||
return date.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
}
|
||||
|
||||
function uid(item) {
|
||||
const repository = String(item.repository || '').replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').toLowerCase();
|
||||
return `issue-${Number(item.number)}@${repository}`;
|
||||
}
|
||||
|
||||
function foldLine(line) {
|
||||
const encoder = new TextEncoder();
|
||||
const chunks = [];
|
||||
let chunk = '';
|
||||
let bytes = 0;
|
||||
let limit = 75;
|
||||
for (const character of String(line)) {
|
||||
const width = encoder.encode(character).length;
|
||||
if (chunk && bytes + width > limit) {
|
||||
chunks.push(chunk);
|
||||
chunk = character;
|
||||
bytes = width;
|
||||
limit = 74;
|
||||
} else {
|
||||
chunk += character;
|
||||
bytes += width;
|
||||
}
|
||||
}
|
||||
chunks.push(chunk);
|
||||
return chunks.join('\r\n ');
|
||||
}
|
||||
|
||||
function serializeAgendaCalendar(items, { generatedOn } = {}) {
|
||||
const stampDay = String(generatedOn || new Date().toISOString().slice(0, 10).replace(/-/g, ''));
|
||||
const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Stackchain//Agenda Snapshot//EN',
|
||||
'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 'X-WR-CALNAME:Stackchain Agenda'];
|
||||
(items || []).forEach(item => {
|
||||
const day = calendarDay(item.due_date);
|
||||
if (!day || !Number.isInteger(Number(item.number)) || !item.repository) return;
|
||||
lines.push(
|
||||
'BEGIN:VEVENT',
|
||||
`UID:${uid(item)}`,
|
||||
`DTSTAMP:${stampDay}T000000Z`,
|
||||
`DTSTART;VALUE=DATE:${day}`,
|
||||
`DTEND;VALUE=DATE:${nextDay(day)}`,
|
||||
`SUMMARY:${escapeText(item.title)}`,
|
||||
`DESCRIPTION:${escapeText(`${item.repository}#${item.number} · Stackchain Agenda snapshot`)}`,
|
||||
`URL:${String(item.url || '')}`,
|
||||
'TRANSP:TRANSPARENT',
|
||||
'END:VEVENT',
|
||||
);
|
||||
});
|
||||
lines.push('END:VCALENDAR');
|
||||
return lines.map(foldLine).join('\r\n') + '\r\n';
|
||||
}
|
||||
|
||||
async function deliverCalendarSnapshot({
|
||||
text,
|
||||
filename,
|
||||
navigator,
|
||||
document,
|
||||
urlApi,
|
||||
FileCtor,
|
||||
}) {
|
||||
const file = new FileCtor([text], filename, { type: 'text/calendar;charset=utf-8' });
|
||||
const sharePayload = { files: [file], title: 'Stackchain Agenda', text: 'Agenda calendar snapshot' };
|
||||
if (typeof navigator?.share === 'function' && typeof navigator?.canShare === 'function' &&
|
||||
navigator.canShare(sharePayload)) {
|
||||
await navigator.share(sharePayload);
|
||||
return 'shared';
|
||||
}
|
||||
const href = urlApi.createObjectURL(file);
|
||||
try {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = href;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
} finally {
|
||||
urlApi.revokeObjectURL(href);
|
||||
}
|
||||
return 'downloaded';
|
||||
}
|
||||
|
||||
function mountAgendaCalendarExport({
|
||||
qs,
|
||||
getItems,
|
||||
escapeHtml,
|
||||
onDone,
|
||||
windowObject = root,
|
||||
navigatorObject = root.navigator,
|
||||
documentObject = root.document,
|
||||
urlApi = root.URL,
|
||||
FileCtor = root.File,
|
||||
}) {
|
||||
const sheet = qs('#agenda-export-sheet');
|
||||
let items = [];
|
||||
let scrollY = 0;
|
||||
let trigger = null;
|
||||
const selectedItems = () => Array.from(qs('#agenda-export-items').querySelectorAll('input[type="checkbox"]'))
|
||||
.filter(checkbox => checkbox.checked)
|
||||
.map(checkbox => items[Number(checkbox.value)])
|
||||
.filter(Boolean);
|
||||
const updateSelection = () => {
|
||||
const selected = selectedItems();
|
||||
qs('#share-agenda-export').disabled = selected.length === 0;
|
||||
qs('#agenda-export-status').textContent = selected.length + ' of ' + items.length +
|
||||
(items.length === 1 ? ' deadline selected.' : ' deadlines selected.');
|
||||
};
|
||||
const close = () => {
|
||||
sheet.close();
|
||||
windowObject.scrollTo({ top:scrollY, behavior:'instant' });
|
||||
trigger?.focus();
|
||||
};
|
||||
qs('#open-agenda-export').addEventListener('click', event => {
|
||||
items = getItems();
|
||||
scrollY = windowObject.scrollY;
|
||||
trigger = event.currentTarget;
|
||||
qs('#agenda-export-items').innerHTML = items.map((item, index) =>
|
||||
'<label class="agenda-export-item"><input type="checkbox" value="' + index + '" checked> ' +
|
||||
'<span><strong>' + escapeHtml(item.title) + '</strong><small>' +
|
||||
escapeHtml(item.repository + '#' + item.number + ' · ' + item.due_date.slice(0, 10)) +
|
||||
'</small></span></label>'
|
||||
).join('');
|
||||
qs('#agenda-export-items').querySelectorAll('input[type="checkbox"]').forEach(checkbox =>
|
||||
checkbox.addEventListener('change', updateSelection)
|
||||
);
|
||||
updateSelection();
|
||||
sheet.showModal();
|
||||
qs('#cancel-agenda-export').focus();
|
||||
});
|
||||
qs('#cancel-agenda-export').addEventListener('click', close);
|
||||
sheet.addEventListener('cancel', event => {
|
||||
event.preventDefault();
|
||||
close();
|
||||
});
|
||||
qs('#share-agenda-export').addEventListener('click', async () => {
|
||||
const selected = selectedItems();
|
||||
if (!selected.length) return;
|
||||
const button = qs('#share-agenda-export');
|
||||
button.disabled = true;
|
||||
qs('#agenda-export-status').textContent = 'Preparing calendar snapshot…';
|
||||
const day = new Date().toISOString().slice(0, 10);
|
||||
try {
|
||||
const text = serializeAgendaCalendar(selected, { generatedOn:day.replace(/-/g, '') });
|
||||
const result = await deliverCalendarSnapshot({
|
||||
text, filename:'stackchain-agenda-' + day + '.ics',
|
||||
navigator:navigatorObject, document:documentObject, urlApi, FileCtor,
|
||||
});
|
||||
onDone(result);
|
||||
close();
|
||||
} catch (error) {
|
||||
qs('#agenda-export-status').textContent = error?.name === 'AbortError' ?
|
||||
'Share cancelled. Nothing was exported.' : 'Calendar export failed. Retry without leaving Agenda.';
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const api = { calendarDay, deliverCalendarSnapshot, escapeText, foldLine, mountAgendaCalendarExport, serializeAgendaCalendar };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else root.StackchainAgendaCalendar = api;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
+163
-21
@@ -1,10 +1,50 @@
|
||||
function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 50 }) {
|
||||
function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, getOwnerLogin = () => '', createOperationId, mergeChecklistConflict, now = () => Date.now(), maxItems = 50 }) {
|
||||
const storageKey = 'stackchain.authored-outbox.v1';
|
||||
const makeId = createOperationId || (() =>
|
||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
||||
);
|
||||
const pending = new Map();
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker']);
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'search-reply', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker', 'issue-content']);
|
||||
|
||||
function messageAttachments(message) {
|
||||
const values = Array.isArray(message?.attachments) ? message.attachments :
|
||||
(Array.isArray(message?.attachment) ? message.attachment : (message?.attachment ? [message.attachment] : []));
|
||||
return values.filter(Boolean).slice(0, 5);
|
||||
}
|
||||
|
||||
function attachmentMetadata(value) {
|
||||
const note = String(value.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
||||
return {
|
||||
filename: String(value.filename || ''),
|
||||
contentType: String(value.contentType || ''),
|
||||
stored: true,
|
||||
...(note ? { note } : {}),
|
||||
...(value.operationId ? { operationId:String(value.operationId).slice(0, 128) } : {}),
|
||||
...(value.confirmed?.markdown ? { confirmed:{ markdown:String(value.confirmed.markdown) } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function durableAttachment(value) {
|
||||
const note = String(value.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
||||
return {
|
||||
filename: String(value.filename || ''),
|
||||
contentType: String(value.contentType || ''),
|
||||
...(note ? { note } : {}),
|
||||
...(value.operationId ? { operationId:String(value.operationId).slice(0, 128) } : {}),
|
||||
...(value.confirmed?.markdown ? { confirmed:{ markdown:String(value.confirmed.markdown) } } : {}),
|
||||
...(value.blob ? { blob:value.blob } : { data:String(value.data || '') }),
|
||||
};
|
||||
}
|
||||
|
||||
function checklistOperation(value) {
|
||||
const action = String(value?.action || '');
|
||||
if (!['rename', 'remove', 'move-earlier', 'move-later'].includes(action)) return null;
|
||||
return {
|
||||
action,
|
||||
index:Number(value.index),
|
||||
...(action === 'rename' ? { label:String(value.label || '') } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function reviewFingerprint(message) {
|
||||
return JSON.stringify({
|
||||
@@ -15,6 +55,20 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
});
|
||||
}
|
||||
|
||||
function operationFingerprint(message) {
|
||||
return JSON.stringify({
|
||||
kind:String(message.kind || ''), repository:String(message.repository || ''),
|
||||
number:Number(message.number || 0), notificationId:Number(message.notificationId || 0),
|
||||
body:String(message.body || ''), targetKind:String(message.targetKind || ''),
|
||||
decision:String(message.decision || 'comment'), expectedHeadSha:String(message.expectedHeadSha || ''),
|
||||
comments:Array.isArray(message.comments) ? message.comments : [],
|
||||
blockerRepository:String(message.blockerRepository || ''), blockerNumber:Number(message.blockerNumber || 0),
|
||||
present:message.present === true, title:String(message.title || ''),
|
||||
expectedUpdatedAt:String(message.expectedUpdatedAt || ''),
|
||||
attachments:messageAttachments(message).map(attachmentMetadata),
|
||||
});
|
||||
}
|
||||
|
||||
function clearConfirmedReviewState(item) {
|
||||
if (item.kind !== 'pull-review') return;
|
||||
try {
|
||||
@@ -45,12 +99,20 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
|
||||
function enqueue(message, mirror = true) {
|
||||
if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.');
|
||||
if (message.kind === 'search-reply' && !['issue', 'pull'].includes(message.targetKind)) {
|
||||
throw new Error('Choose an exact Search result before queueing a reply.');
|
||||
}
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing a message.');
|
||||
const items = read();
|
||||
const requestedOperationId = String(message.operationId || '').slice(0, 128);
|
||||
const existing = requestedOperationId && items.find(item => item.operationId === requestedOperationId);
|
||||
if (existing) return { ...existing };
|
||||
if (existing) {
|
||||
if (operationFingerprint(existing) !== operationFingerprint(message)) {
|
||||
throw new Error('This operation ID is already bound to a different queued action.');
|
||||
}
|
||||
return { ...existing };
|
||||
}
|
||||
if (message.kind === 'pull-review') {
|
||||
const queuedReview = items.find(item => item.kind === 'pull-review' &&
|
||||
item.repository === String(message.repository || '') &&
|
||||
@@ -61,8 +123,26 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
throw new Error('A review for this saved head is already queued. Open Drafts to inspect or discard it first.');
|
||||
}
|
||||
}
|
||||
if (message.kind === 'issue-content') {
|
||||
const queuedContent = items.find(item => item.kind === 'issue-content' && item.status === 'queued' &&
|
||||
item.ownerLogin === ownerLogin && item.repository === String(message.repository || '') &&
|
||||
item.number === Number(message.number || 0));
|
||||
if (queuedContent) {
|
||||
const operation = checklistOperation(message.checklistOperation);
|
||||
const replacement = {
|
||||
...queuedContent,
|
||||
operationId: String(requestedOperationId || makeId()).slice(0, 128),
|
||||
title: String(message.title || ''),
|
||||
body: String(message.body || ''),
|
||||
...(operation ? { checklistOperations:[...(queuedContent.checklistOperations || []), operation] } : {}),
|
||||
};
|
||||
write(items.map(item => item.id === queuedContent.id ? replacement : item), mirror);
|
||||
return replacement;
|
||||
}
|
||||
}
|
||||
if (items.length >= maxItems) throw new Error('Message outbox is full. Send or discard a queued message first.');
|
||||
const id = String(requestedOperationId || makeId()).slice(0, 128);
|
||||
const attachments = messageAttachments(message);
|
||||
const item = {
|
||||
id,
|
||||
operationId: requestedOperationId || id,
|
||||
@@ -74,14 +154,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
ownerLogin,
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
...(message.kind === 'search-reply' ? { targetKind:String(message.targetKind) } : {}),
|
||||
...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}),
|
||||
...(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read'].includes(message.kind) && message.attachment ? {
|
||||
attachment: {
|
||||
filename: String(message.attachment.filename || ''),
|
||||
contentType: String(message.attachment.contentType || ''),
|
||||
stored: true,
|
||||
},
|
||||
} : {}),
|
||||
...(['issue-comment', 'pull-comment', 'search-reply', 'update-reply', 'update-reply-read'].includes(message.kind) && attachments.length ?
|
||||
(attachments.length === 1 ? { attachment:attachmentMetadata(attachments[0]) } :
|
||||
{ attachments:attachments.map(attachmentMetadata) }) : {}),
|
||||
...(message.kind === 'pull-review' ? {
|
||||
decision: String(message.decision || 'comment'),
|
||||
expectedHeadSha: String(message.expectedHeadSha || ''),
|
||||
@@ -96,6 +173,14 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
blockerNumber: Number(message.blockerNumber || 0),
|
||||
present: message.present === true,
|
||||
} : {}),
|
||||
...(message.kind === 'issue-content' ? {
|
||||
title: String(message.title || ''),
|
||||
baseBody: String(message.baseBody ?? message.body ?? ''),
|
||||
expectedUpdatedAt: String(message.expectedUpdatedAt || ''),
|
||||
...(checklistOperation(message.checklistOperation) ? {
|
||||
checklistOperations:[checklistOperation(message.checklistOperation)],
|
||||
} : {}),
|
||||
} : {}),
|
||||
};
|
||||
items.push(item);
|
||||
write(items, mirror);
|
||||
@@ -103,8 +188,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
}
|
||||
|
||||
async function enqueueDurably(message) {
|
||||
const previousItems = read();
|
||||
const item = enqueue(message, false);
|
||||
if (message.attachment && ['update-reply', 'update-reply-read'].includes(message.kind) &&
|
||||
const attachments = messageAttachments(message);
|
||||
if (attachments.length && ['search-reply', 'update-reply', 'update-reply-read'].includes(message.kind) &&
|
||||
(!backgroundSync?.reconcile || !backgroundSync?.requestSync)) {
|
||||
write(read().filter(candidate => candidate.id !== item.id), false);
|
||||
throw new Error('Screenshot delivery needs IndexedDB. Your reply and screenshot are still here; retry.');
|
||||
@@ -112,19 +199,15 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
return { item, background: false, durability: 'foreground-only' };
|
||||
}
|
||||
const durableItems = read().map(candidate => candidate.id === item.id && message.attachment ? {
|
||||
const durableItems = read().map(candidate => candidate.id === item.id && attachments.length ? {
|
||||
...candidate,
|
||||
attachment: {
|
||||
filename: String(message.attachment.filename || ''),
|
||||
contentType: String(message.attachment.contentType || ''),
|
||||
...(message.attachment.blob ? { blob: message.attachment.blob } :
|
||||
{ data: String(message.attachment.data || '') }),
|
||||
},
|
||||
...(attachments.length === 1 ? { attachment:durableAttachment(attachments[0]) } :
|
||||
{ attachments:attachments.map(durableAttachment) }),
|
||||
} : candidate);
|
||||
try {
|
||||
await backgroundSync.reconcile(durableItems, 'authored');
|
||||
} catch (error) {
|
||||
write(read().filter(candidate => candidate.id !== item.id), false);
|
||||
write(previousItems, false);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
@@ -171,9 +254,16 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
if (item.kind === 'issue-blocker') {
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers';
|
||||
}
|
||||
if (item.kind === 'issue-content') {
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/content';
|
||||
}
|
||||
if (item.kind === 'pull-review') {
|
||||
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
|
||||
}
|
||||
if (item.kind === 'search-reply') {
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
|
||||
'/preview/comments?kind=' + encodeURIComponent(item.targetKind);
|
||||
}
|
||||
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
|
||||
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments';
|
||||
}
|
||||
@@ -246,9 +336,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
decision: item.decision,
|
||||
expected_head_sha: item.expectedHeadSha,
|
||||
comments: item.comments,
|
||||
} : item.kind === 'issue-content' ? {
|
||||
title:item.title, body:item.body, expected_updated_at:item.expectedUpdatedAt,
|
||||
} : { body: item.body };
|
||||
result = await fetchJson(endpoint(item), {
|
||||
method: 'POST',
|
||||
method: item.kind === 'issue-content' ? 'PATCH' : 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
@@ -256,6 +348,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (item.kind === 'issue-content' &&
|
||||
(result?.number !== item.number || result?.title !== item.title || result?.body !== item.body)) {
|
||||
throw new Error('Checklist update was not confirmed.');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!result) return { blocked: true };
|
||||
@@ -334,6 +430,51 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
return retryItem(id, currentLogin);
|
||||
}
|
||||
|
||||
async function resolveIssueContentConflict(id, latest, currentLogin) {
|
||||
const previousItems = read();
|
||||
const item = previousItems.find(candidate => candidate.id === id);
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
if (!item || item.kind !== 'issue-content' || item.status !== 'attention') {
|
||||
throw new Error('This checklist conflict is no longer available.');
|
||||
}
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) {
|
||||
throw new Error('Confirm the account that queued this checklist update.');
|
||||
}
|
||||
if (typeof mergeChecklistConflict !== 'function') {
|
||||
throw new Error('Checklist conflict review is unavailable.');
|
||||
}
|
||||
const merged = mergeChecklistConflict({
|
||||
baseBody: String(item.baseBody ?? item.body ?? ''),
|
||||
localBody: String(item.body || ''),
|
||||
remoteBody: String(latest?.body || ''),
|
||||
});
|
||||
if (merged.conflicts?.length) return merged;
|
||||
if (!String(latest?.updated_at || '')) throw new Error('Latest issue revision is unavailable.');
|
||||
const rebased = {
|
||||
...item,
|
||||
operationId: String(makeId()).slice(0, 128),
|
||||
title: String(latest?.title || ''),
|
||||
baseBody: String(latest?.body || ''),
|
||||
body: merged.body,
|
||||
expectedUpdatedAt: String(latest.updated_at),
|
||||
status: 'queued',
|
||||
};
|
||||
delete rebased.error;
|
||||
delete rebased.deliveryState;
|
||||
const nextItems = previousItems.map(candidate => candidate.id === id ? rebased : candidate);
|
||||
write(nextItems, false);
|
||||
if (backgroundSync?.reconcile) {
|
||||
try { await backgroundSync.reconcile(nextItems, 'authored'); }
|
||||
catch (error) {
|
||||
write(previousItems, false);
|
||||
throw error;
|
||||
}
|
||||
try { await backgroundSync.requestSync?.(); }
|
||||
catch (_error) { /* Foreground retry remains available. */ }
|
||||
}
|
||||
return { ...merged, item: rebased };
|
||||
}
|
||||
|
||||
function reconcileBackground(records) {
|
||||
const statuses = new Map((records || []).map(item => [item.id, item]));
|
||||
const items = read().flatMap(item => {
|
||||
@@ -359,7 +500,8 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
return items;
|
||||
}
|
||||
|
||||
return { enqueue, enqueueDurably, update, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
|
||||
return { enqueue, enqueueDurably, update, discard, flush, retry, resolveIssueContentConflict,
|
||||
reconcileBackground, list: () => read().map(item => ({ ...item })) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createAuthoredOutbox;
|
||||
@@ -91,13 +91,25 @@ function createIssueSyncStore({
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const preservedBundle = current.operationId === replacement.operationId &&
|
||||
current.attachments?.every(value => value?.data || value?.blob) &&
|
||||
replacement.attachments?.every(value => value?.stored && !value.data && !value.blob)
|
||||
? current.attachments : null;
|
||||
if (current.operationId === replacement.operationId &&
|
||||
(current.attachment?.data || current.attachment?.blob) &&
|
||||
replacement.attachment?.stored &&
|
||||
!replacement.attachment.data && !replacement.attachment.blob) {
|
||||
replacement = { ...replacement, attachment: current.attachment };
|
||||
!replacement.attachment.data && !replacement.attachment.blob || preservedBundle) {
|
||||
replacement = {
|
||||
...replacement,
|
||||
...(preservedBundle ? {attachments:preservedBundle} : {attachment:current.attachment}),
|
||||
};
|
||||
incoming.set(current.id, replacement);
|
||||
}
|
||||
if (current.kind === 'issue-content' && replacement.kind === 'issue-content' &&
|
||||
current.operationId !== replacement.operationId && current.status === 'sending' &&
|
||||
Number(current.claimUntil) > Number(now())) {
|
||||
throw new Error('Checklist sync is already delivering. Retry this change.');
|
||||
}
|
||||
if ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
|
||||
(['attention', 'authorization'].includes(current.status) &&
|
||||
replacement.status === current.status) ||
|
||||
@@ -174,10 +186,15 @@ function createIssueSyncStore({
|
||||
const preservedAttachment = (current?.attachment?.data || current?.attachment?.blob) &&
|
||||
item?.attachment?.stored && !item.attachment.data && !item.attachment.blob
|
||||
? { attachment: current.attachment } : {};
|
||||
const preservedAttachments = current?.operationId === item?.operationId &&
|
||||
current?.attachments?.every(value => value?.data || value?.blob) &&
|
||||
item?.attachments?.every(value => value?.stored && !value.data && !value.blob)
|
||||
? { attachments:current.attachments } : {};
|
||||
const next = current && current.operationId === item.operationId ? {
|
||||
...item, ...preservedAttachment,
|
||||
...item, ...preservedAttachment, ...preservedAttachments,
|
||||
...(current.deliveredIssue ? { deliveredIssue: current.deliveredIssue } : {}),
|
||||
...(current.attachmentMarkdown ? { attachmentMarkdown: current.attachmentMarkdown } : {}),
|
||||
...(current.attachmentMarkdowns ? { attachmentMarkdowns:current.attachmentMarkdowns } : {}),
|
||||
} : { ...item };
|
||||
await records.put(next);
|
||||
return next;
|
||||
@@ -353,6 +370,9 @@ function createBackgroundIssueSync({
|
||||
const resource = item.kind === 'pull-comment' ? 'pull' : 'issue';
|
||||
return { id: item.id, status, kind: 'message', route: '#/my-work/' + resource + '/' + repository + '/' + encodeURIComponent(item.number) };
|
||||
}
|
||||
if (typeof delivered.url === 'string' && /^https?:\/\//.test(delivered.url)) {
|
||||
return { id: item.id, status, kind: 'issue', url: delivered.url };
|
||||
}
|
||||
return { id: item.id, status, kind: 'issue', route: '#/my-work/issue/' + repository + '/' + encodeURIComponent(delivered.number) };
|
||||
}
|
||||
|
||||
@@ -400,6 +420,24 @@ function createBackgroundIssueSync({
|
||||
},
|
||||
};
|
||||
}
|
||||
if (item.kind === 'issue-content') {
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/content',
|
||||
options: {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: item.title,
|
||||
body: item.body,
|
||||
expected_updated_at: item.expectedUpdatedAt,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (item.kind === 'pull-review') {
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
|
||||
@@ -426,6 +464,13 @@ function createBackgroundIssueSync({
|
||||
item,
|
||||
);
|
||||
}
|
||||
if (item.kind === 'search-reply') {
|
||||
return authoredRequest(
|
||||
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
|
||||
'/preview/comments?kind=' + encodeURIComponent(item.targetKind),
|
||||
item,
|
||||
);
|
||||
}
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/issues',
|
||||
options: {
|
||||
@@ -439,6 +484,8 @@ function createBackgroundIssueSync({
|
||||
title: item.title,
|
||||
body: item.body,
|
||||
label_ids: item.labelIds,
|
||||
...(item.unassigned ? { unassigned: true } : {}),
|
||||
...(item.assignee ? { assignee: item.assignee } : {}),
|
||||
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
|
||||
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
|
||||
}),
|
||||
@@ -466,31 +513,75 @@ function createBackgroundIssueSync({
|
||||
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
|
||||
}
|
||||
|
||||
function evidenceMarkdown(attachment, markdown, index) {
|
||||
const note = String(attachment?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
||||
if (!note) return markdown;
|
||||
const escaped = note.replace(/([\\`*_[\]{}()<>#+\-.!|])/g, '\\$1');
|
||||
return '**Screenshot ' + (index + 1) + ' — ' + escaped + '**\n\n' + markdown;
|
||||
}
|
||||
|
||||
function conversationAttachments(item) {
|
||||
return (Array.isArray(item?.attachments) ? item.attachments : [item?.attachment]).filter(Boolean);
|
||||
}
|
||||
|
||||
async function uploadConversationAttachments(item, url) {
|
||||
let current = item;
|
||||
const attachments = conversationAttachments(current);
|
||||
const confirmedMarkdowns = attachments.map(value => String(value?.confirmed?.markdown || ''));
|
||||
const firstMissing = confirmedMarkdowns.findIndex(value => !value);
|
||||
const leadingConfirmed = confirmedMarkdowns.slice(0, firstMissing < 0 ? confirmedMarkdowns.length : firstMissing);
|
||||
const markdowns = Array.isArray(current.attachmentMarkdowns) ?
|
||||
current.attachmentMarkdowns.slice(0, attachments.length) :
|
||||
(current.attachmentMarkdown ? [current.attachmentMarkdown] : leadingConfirmed);
|
||||
for (let index = markdowns.length; index < attachments.length; index += 1) {
|
||||
const uploaded = await requestStage(current, url, {
|
||||
method:'POST',
|
||||
headers:{
|
||||
Accept:'application/json',
|
||||
'Idempotency-Key':String(attachments[index]?.operationId || stageOperationId(
|
||||
current.operationId, attachments.length === 1 ? 'attachment' : 'attachment-' + index,
|
||||
)).slice(0, 128),
|
||||
},
|
||||
body:attachmentMultipart(attachments[index]),
|
||||
});
|
||||
const markdown = String(uploaded?.markdown || '');
|
||||
if (!markdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
markdowns.push(markdown);
|
||||
await checkpointClaim(current, stored => ({
|
||||
...stored, attachmentMarkdowns:markdowns.slice(),
|
||||
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
|
||||
}));
|
||||
current = {
|
||||
...current, attachmentMarkdowns:markdowns.slice(),
|
||||
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
current,
|
||||
markdown:markdowns.map((value, index) =>
|
||||
evidenceMarkdown(attachments[index], value, index)).join('\n\n'),
|
||||
};
|
||||
}
|
||||
|
||||
async function deliverReplyRead(item) {
|
||||
let current = item;
|
||||
if (!current.replyConfirmed) {
|
||||
let attachmentMarkdown = current.attachmentMarkdown;
|
||||
if (current.attachment && !attachmentMarkdown) {
|
||||
const uploaded = await requestStage(current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', {
|
||||
method:'POST',
|
||||
headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') },
|
||||
body:attachmentMultipart(current.attachment),
|
||||
});
|
||||
attachmentMarkdown = String(uploaded?.markdown || '');
|
||||
if (!attachmentMarkdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown }));
|
||||
current = { ...current, attachmentMarkdown };
|
||||
let attachmentMarkdown = '';
|
||||
if (conversationAttachments(current).length) {
|
||||
const uploaded = await uploadConversationAttachments(current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments');
|
||||
current = uploaded.current;
|
||||
attachmentMarkdown = uploaded.markdown;
|
||||
}
|
||||
const text = String(current.body || '').trim();
|
||||
const replyBody = attachmentMarkdown ?
|
||||
(text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown) : text;
|
||||
const options = authoredRequest('', { ...current, body:replyBody }).options;
|
||||
if (current.attachment) options.headers['Idempotency-Key'] = stageOperationId(current.operationId, 'reply');
|
||||
if (conversationAttachments(current).length) options.headers['Idempotency-Key'] = stageOperationId(current.operationId, 'reply');
|
||||
await requestStage(
|
||||
current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply',
|
||||
@@ -525,8 +616,31 @@ function createBackgroundIssueSync({
|
||||
deliveredIssue = await requestStage(item, request.url, request.options);
|
||||
await checkpointClaim(item, current => ({ ...current, deliveredIssue }));
|
||||
}
|
||||
let attachmentMarkdown = item.attachmentMarkdown;
|
||||
if (!attachmentMarkdown) {
|
||||
const blockers = Array.isArray(item.blockers) ? item.blockers : [];
|
||||
let deliveredBlockers = Math.min(Number(item.deliveredBlockers) || 0, blockers.length);
|
||||
for (let index = deliveredBlockers; index < blockers.length; index += 1) {
|
||||
const blocker = blockers[index];
|
||||
await requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/blockers',
|
||||
{
|
||||
method:'PATCH',
|
||||
headers:{
|
||||
Accept:'application/json', 'Content-Type':'application/json',
|
||||
'Idempotency-Key':stageOperationId(item.operationId, 'blocker-' + index),
|
||||
},
|
||||
body:JSON.stringify({repository:blocker.repository, number:blocker.number, present:true}),
|
||||
},
|
||||
);
|
||||
deliveredBlockers = index + 1;
|
||||
await checkpointClaim(item, current => ({ ...current, deliveredIssue, deliveredBlockers }));
|
||||
}
|
||||
const attachments = (Array.isArray(item.attachments) ? item.attachments : [item.attachment]).filter(Boolean);
|
||||
if (!attachments.length) return deliveredIssue;
|
||||
const attachmentMarkdowns = Array.isArray(item.attachmentMarkdowns)
|
||||
? item.attachmentMarkdowns.slice(0, attachments.length)
|
||||
: (item.attachmentMarkdown ? [item.attachmentMarkdown] : []);
|
||||
for (let index = attachmentMarkdowns.length; index < attachments.length; index += 1) {
|
||||
const uploaded = await requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/attachments',
|
||||
@@ -534,19 +648,27 @@ function createBackgroundIssueSync({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
|
||||
'Idempotency-Key': stageOperationId(
|
||||
item.operationId, attachments.length === 1 ? 'attachment' : 'attachment-' + index,
|
||||
),
|
||||
},
|
||||
body: attachmentMultipart(item.attachment),
|
||||
body: attachmentMultipart(attachments[index]),
|
||||
},
|
||||
);
|
||||
attachmentMarkdown = String(uploaded?.markdown || '');
|
||||
if (!attachmentMarkdown) {
|
||||
const markdown = String(uploaded?.markdown || '');
|
||||
if (!markdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await checkpointClaim(item, current => ({ ...current, deliveredIssue, attachmentMarkdown }));
|
||||
attachmentMarkdowns.push(markdown);
|
||||
await checkpointClaim(item, current => ({
|
||||
...current, deliveredIssue, attachmentMarkdowns:attachmentMarkdowns.slice(),
|
||||
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
|
||||
}));
|
||||
}
|
||||
const attachmentMarkdown = attachmentMarkdowns.map((markdown, index) =>
|
||||
evidenceMarkdown(attachments[index], markdown, index)).join('\n\n');
|
||||
await requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/comments',
|
||||
@@ -565,28 +687,9 @@ function createBackgroundIssueSync({
|
||||
async function deliverScreenshotComment(item) {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
|
||||
let attachmentMarkdown = item.attachmentMarkdown;
|
||||
if (!attachmentMarkdown) {
|
||||
const uploaded = await requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/attachments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
|
||||
},
|
||||
body: attachmentMultipart(item.attachment),
|
||||
},
|
||||
);
|
||||
attachmentMarkdown = String(uploaded?.markdown || '');
|
||||
if (!attachmentMarkdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await checkpointClaim(item, current => ({ ...current, attachmentMarkdown }));
|
||||
}
|
||||
const uploaded = await uploadConversationAttachments(item,
|
||||
base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/attachments');
|
||||
const attachmentMarkdown = uploaded.markdown;
|
||||
const text = String(item.body || '').trim();
|
||||
return requestStage(
|
||||
item,
|
||||
@@ -602,26 +705,35 @@ function createBackgroundIssueSync({
|
||||
);
|
||||
}
|
||||
|
||||
async function deliverUpdateScreenshotReply(item) {
|
||||
async function deliverSearchReply(item) {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
let current = item;
|
||||
let attachmentMarkdown = current.attachmentMarkdown;
|
||||
if (!attachmentMarkdown) {
|
||||
const uploaded = await requestStage(current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', {
|
||||
method:'POST',
|
||||
headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') },
|
||||
body:attachmentMultipart(current.attachment),
|
||||
});
|
||||
attachmentMarkdown = String(uploaded?.markdown || '');
|
||||
if (!attachmentMarkdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown }));
|
||||
current = { ...current, attachmentMarkdown };
|
||||
let attachmentMarkdown = '';
|
||||
if (conversationAttachments(current).length) {
|
||||
const uploaded = await uploadConversationAttachments(current,
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(current.number) +
|
||||
'/preview/attachments?kind=' + encodeURIComponent(current.targetKind));
|
||||
current = uploaded.current;
|
||||
attachmentMarkdown = uploaded.markdown;
|
||||
}
|
||||
const text = String(current.body || '').trim();
|
||||
return requestStage(current,
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(current.number) +
|
||||
'/preview/comments?kind=' + encodeURIComponent(current.targetKind), {
|
||||
method:'POST',
|
||||
headers:{Accept:'application/json','Content-Type':'application/json',
|
||||
'Idempotency-Key':stageOperationId(current.operationId, 'comment')},
|
||||
body:JSON.stringify({body:attachmentMarkdown ?
|
||||
(text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown) : text}),
|
||||
});
|
||||
}
|
||||
|
||||
async function deliverUpdateScreenshotReply(item) {
|
||||
const uploaded = await uploadConversationAttachments(item,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/attachments');
|
||||
const current = uploaded.current;
|
||||
const attachmentMarkdown = uploaded.markdown;
|
||||
const text = String(current.body || '').trim();
|
||||
return requestStage(current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply', {
|
||||
method:'POST',
|
||||
@@ -635,10 +747,13 @@ function createBackgroundIssueSync({
|
||||
const request = deliveryRequest(item);
|
||||
try {
|
||||
const delivered = item.kind === 'update-reply-read' ? await deliverReplyRead(item) :
|
||||
item.kind === 'update-reply' && item.attachment ? await deliverUpdateScreenshotReply(item) :
|
||||
item.attachment && ['issue-comment', 'pull-comment'].includes(item.kind) ?
|
||||
item.kind === 'search-reply' ? await deliverSearchReply(item) :
|
||||
item.kind === 'update-reply' && conversationAttachments(item).length ? await deliverUpdateScreenshotReply(item) :
|
||||
conversationAttachments(item).length && ['issue-comment', 'pull-comment'].includes(item.kind) ?
|
||||
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
|
||||
await deliverIssueCapture(item) : await requestStage(item, request.url, request.options);
|
||||
await deliverIssueCapture(item) : item.attachments?.length && !item.kind ?
|
||||
await deliverIssueCapture(item) : item.blockers?.length && !item.kind ?
|
||||
await deliverIssueCapture(item) : await requestStage(item, request.url, request.options);
|
||||
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
|
||||
const error = new Error('Issue closure was not confirmed.');
|
||||
error.status = 422;
|
||||
@@ -654,6 +769,12 @@ function createBackgroundIssueSync({
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (item.kind === 'issue-content' &&
|
||||
(delivered?.number !== item.number || delivered?.title !== item.title || delivered?.body !== item.body)) {
|
||||
const error = new Error('Checklist update was not confirmed.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await completeClaim(item, delivered);
|
||||
const receipt = receiptFor(item, 'confirmed', delivered);
|
||||
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
||||
|
||||
@@ -7,9 +7,13 @@ function createBatchFindWork({
|
||||
onProgress = () => {},
|
||||
storage = typeof localStorage === 'undefined' ? null : localStorage,
|
||||
owner = () => '',
|
||||
journalName = 'find-work-batch',
|
||||
queueFailureReason = 'assigned but Today sync is unavailable',
|
||||
autoMount = true,
|
||||
}) {
|
||||
let request = null;
|
||||
const journalKey = () => 'stackchain.find-work-batch.v1.' + encodeURIComponent(String(owner() || ''));
|
||||
const journalKey = () => 'stackchain.' + journalName + '.v1.' +
|
||||
encodeURIComponent(String(owner() || ''));
|
||||
|
||||
function readJournal() {
|
||||
if (!storage || !String(owner() || '')) return null;
|
||||
@@ -55,7 +59,7 @@ function createBatchFindWork({
|
||||
entry.state = 'assigned';
|
||||
writeJournal(journal);
|
||||
}
|
||||
const queueResult = await queue(confirmed);
|
||||
const queueResult = await queue(confirmed, journal.context);
|
||||
if (queueResult === 'queued' || queueResult === 'exists') {
|
||||
queued.push(key(item));
|
||||
entry.state = 'queued';
|
||||
@@ -64,7 +68,7 @@ function createBatchFindWork({
|
||||
}
|
||||
writeJournal(journal);
|
||||
} else {
|
||||
failed.push({ key: key(item), reason: 'assigned but Today sync is unavailable', assigned: true });
|
||||
failed.push({ key: key(item), reason: queueFailureReason, assigned: true });
|
||||
}
|
||||
} catch (error) {
|
||||
failed.push({ key: key(item), reason: error?.message || 'assignment failed' });
|
||||
@@ -78,7 +82,7 @@ function createBatchFindWork({
|
||||
return outcome;
|
||||
}
|
||||
|
||||
function run(items, estimates = {}) {
|
||||
function run(items, estimates = {}, context = null) {
|
||||
if (request) return request;
|
||||
const selected = Array.isArray(items) ? items.slice() : [];
|
||||
const available = Math.max(0, Number(capacity()) || 0);
|
||||
@@ -109,7 +113,7 @@ function createBatchFindWork({
|
||||
}
|
||||
}
|
||||
const journal = {
|
||||
owner: String(owner() || ''), available,
|
||||
owner: String(owner() || ''), available, context,
|
||||
items: selected.map(item => ({ item, estimate: estimates[key(item)] || null, state: 'pending' })),
|
||||
};
|
||||
writeJournal(journal);
|
||||
@@ -125,6 +129,11 @@ function createBatchFindWork({
|
||||
return request;
|
||||
}
|
||||
|
||||
function pending() {
|
||||
const journal = readJournal();
|
||||
return journal ? journal.items.filter(item => item.state !== 'queued').length : 0;
|
||||
}
|
||||
|
||||
function mountRecovery(button, opener) {
|
||||
const show = () => {
|
||||
const journal = readJournal();
|
||||
@@ -144,10 +153,10 @@ function createBatchFindWork({
|
||||
opener.addEventListener('click', show);
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') mountRecovery(
|
||||
if (autoMount && typeof document !== 'undefined') mountRecovery(
|
||||
document.getElementById('resume-find-work-batch'), document.getElementById('find-work')
|
||||
);
|
||||
return { run, resume, mountRecovery };
|
||||
return { run, resume, pending, mountRecovery };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createBatchFindWork;
|
||||
@@ -0,0 +1,141 @@
|
||||
function mergeChecklistConflict({ baseBody, localBody, remoteBody, operations = [] }) {
|
||||
const taskPattern = /^(\s*[-*+]\s+\[)([ xX])(\]\s+)(.*)$/;
|
||||
|
||||
function tasks(body) {
|
||||
const entries = [];
|
||||
String(body || '').split('\n').forEach((line, lineIndex) => {
|
||||
const match = line.match(taskPattern);
|
||||
if (!match) return;
|
||||
const label = match[4].trim();
|
||||
const key = label.replace(/\s+/g, ' ').toLocaleLowerCase();
|
||||
if (!key) return;
|
||||
entries.push({ key, label, checked: match[2].toLowerCase() === 'x', lineIndex, match });
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
function grouped(entries) {
|
||||
const result = new Map();
|
||||
entries.forEach(entry => result.set(entry.key, [...(result.get(entry.key) || []), entry]));
|
||||
return result;
|
||||
}
|
||||
|
||||
function replayOperations(body, requested, reportChanges) {
|
||||
let lines = String(body || '').split('\n');
|
||||
const visibleTasks = value => tasks(value).filter(entry => /^[-*+]/.test(entry.match[1]));
|
||||
const logical = visibleTasks(baseBody).map(entry => ({ key:entry.key, label:entry.label }));
|
||||
const replayed = [];
|
||||
for (const operation of requested) {
|
||||
const index = Number(operation?.index);
|
||||
const target = logical[index];
|
||||
if (!target) return { body:null, changes:replayed, conflict:{ label:'Checklist step', reason:'missing' } };
|
||||
let entries = visibleTasks(lines.join('\n'));
|
||||
const matches = entries.filter(entry => entry.key === target.key);
|
||||
if (matches.length !== 1) {
|
||||
return { body:null, changes:replayed, conflict:{
|
||||
label:target.label, reason:matches.length ? 'ambiguous' : 'missing',
|
||||
} };
|
||||
}
|
||||
const match = matches[0];
|
||||
if (operation.action === 'rename') {
|
||||
const label = String(operation.label || '').trim().replace(/\s+/g, ' ');
|
||||
const key = label.toLocaleLowerCase();
|
||||
if (!label || entries.some(entry => entry.key === key && entry.lineIndex !== match.lineIndex)) {
|
||||
return { body:null, changes:replayed, conflict:{ label:target.label, reason:'ambiguous' } };
|
||||
}
|
||||
lines[match.lineIndex] = match.match[1] + match.match[2] + match.match[3] + label;
|
||||
if (reportChanges) replayed.push({ label:target.label, renamed:label });
|
||||
target.label = label;
|
||||
target.key = key;
|
||||
} else if (operation.action === 'remove') {
|
||||
lines.splice(match.lineIndex, 1);
|
||||
logical.splice(index, 1);
|
||||
if (reportChanges) replayed.push({ label:target.label, removed:true });
|
||||
} else if (operation.action === 'move-earlier' || operation.action === 'move-later') {
|
||||
const neighborIndex = operation.action === 'move-earlier' ? index - 1 : index + 1;
|
||||
const neighbor = logical[neighborIndex];
|
||||
const neighborMatches = neighbor ? entries.filter(entry => entry.key === neighbor.key) : [];
|
||||
if (neighborMatches.length !== 1 || Math.abs(neighborMatches[0].lineIndex - match.lineIndex) !== 1) {
|
||||
return { body:null, changes:replayed, conflict:{ label:target.label, reason:'order-changed' } };
|
||||
}
|
||||
const neighborLine = neighborMatches[0].lineIndex;
|
||||
[lines[match.lineIndex], lines[neighborLine]] = [lines[neighborLine], lines[match.lineIndex]];
|
||||
[logical[index], logical[neighborIndex]] = [logical[neighborIndex], logical[index]];
|
||||
if (reportChanges) replayed.push({
|
||||
label:target.label, moved:operation.action === 'move-earlier' ? 'earlier' : 'later',
|
||||
});
|
||||
}
|
||||
}
|
||||
return { body:lines.join('\n'), changes:replayed, conflict:null };
|
||||
}
|
||||
|
||||
if (Array.isArray(operations) && operations.length) {
|
||||
const baseReplay = replayOperations(baseBody, operations, false);
|
||||
const remoteReplay = replayOperations(remoteBody, operations, true);
|
||||
const conflict = baseReplay.conflict || remoteReplay.conflict;
|
||||
if (conflict) return { body:null, changes:remoteReplay.changes || [], conflicts:[conflict] };
|
||||
const residual = mergeChecklistConflict({
|
||||
baseBody:baseReplay.body, localBody, remoteBody:remoteReplay.body,
|
||||
});
|
||||
return {
|
||||
body:residual.body,
|
||||
changes:[...remoteReplay.changes, ...residual.changes],
|
||||
conflicts:residual.conflicts,
|
||||
};
|
||||
}
|
||||
|
||||
const base = grouped(tasks(baseBody));
|
||||
const local = grouped(tasks(localBody));
|
||||
const remoteEntries = tasks(remoteBody);
|
||||
const remote = grouped(remoteEntries);
|
||||
const changes = [];
|
||||
const conflicts = [];
|
||||
|
||||
for (const [key, baseMatches] of base) {
|
||||
const localMatches = local.get(key) || [];
|
||||
if (baseMatches.length !== 1 || localMatches.length !== 1) continue;
|
||||
if (baseMatches[0].checked === localMatches[0].checked) continue;
|
||||
const remoteMatches = remote.get(key) || [];
|
||||
if (remoteMatches.length !== 1) {
|
||||
conflicts.push({
|
||||
label: baseMatches[0].label,
|
||||
reason: remoteMatches.length ? 'ambiguous' : 'missing',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
changes.push({ label: remoteMatches[0].label, checked: localMatches[0].checked });
|
||||
}
|
||||
|
||||
for (const [key, localMatches] of local) {
|
||||
if (base.has(key)) continue;
|
||||
if (localMatches.length !== 1) {
|
||||
conflicts.push({ label:localMatches[0].label, reason:'ambiguous' });
|
||||
continue;
|
||||
}
|
||||
const remoteMatches = remote.get(key) || [];
|
||||
if (remoteMatches.length > 1) {
|
||||
conflicts.push({ label: localMatches[0].label, reason: 'ambiguous' });
|
||||
continue;
|
||||
}
|
||||
if (remoteMatches.length === 0) {
|
||||
changes.push({ label: localMatches[0].label, checked:localMatches[0].checked, added:true });
|
||||
}
|
||||
}
|
||||
|
||||
if (conflicts.length) return { body: null, changes, conflicts };
|
||||
const desired = new Map(changes.map(change => [
|
||||
change.label.replace(/\s+/g, ' ').toLocaleLowerCase(), change.checked,
|
||||
]));
|
||||
const lines = String(remoteBody || '').split('\n');
|
||||
remoteEntries.forEach(entry => {
|
||||
if (!desired.has(entry.key)) return;
|
||||
const marker = desired.get(entry.key) ? 'x' : ' ';
|
||||
lines[entry.lineIndex] = entry.match[1] + marker + entry.match[3] + entry.match[4];
|
||||
});
|
||||
changes.filter(change => change.added).forEach(change => {
|
||||
lines.push('- [' + (change.checked ? 'x' : ' ') + '] ' + change.label);
|
||||
});
|
||||
return { body: lines.join('\n'), changes, conflicts: [] };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = mergeChecklistConflict;
|
||||
+26
-7
@@ -15,6 +15,15 @@
|
||||
return current;
|
||||
};
|
||||
|
||||
filterCommands.searchUrl = function searchUrl(query, page, scope, continuation) {
|
||||
const streamPages = continuation ?
|
||||
(continuation.issues ? '&issues_page=' + encodeURIComponent(continuation.issues) : '') +
|
||||
(continuation.pulls ? '&pulls_page=' + encodeURIComponent(continuation.pulls) : '') : '';
|
||||
return 'api/v1/search?q=' + encodeURIComponent(query) + '&limit=10&page=' + page +
|
||||
'&kind=' + encodeURIComponent(scope.kind) + '&state=' + encodeURIComponent(scope.state) +
|
||||
(scope.repository ? '&repository=' + encodeURIComponent(scope.repository) : '') + streamPages;
|
||||
};
|
||||
|
||||
filterCommands.createGlobalSearchController = function createGlobalSearchController(options) {
|
||||
const search = options.search;
|
||||
const onState = options.onState;
|
||||
@@ -28,29 +37,34 @@
|
||||
function publish(next) {
|
||||
state = next;
|
||||
onState(next);
|
||||
return state;
|
||||
}
|
||||
|
||||
async function requestPage(query, page, current, append) {
|
||||
async function requestPage(query, page, current, append, continuation) {
|
||||
const requestController = new AbortController();
|
||||
activeController = requestController;
|
||||
try {
|
||||
const requestScope = { ...scope };
|
||||
const result = await search(query, requestController.signal, page, requestScope);
|
||||
const result = await search(
|
||||
query, requestController.signal, page, requestScope, continuation
|
||||
);
|
||||
const partial = result.partial === true;
|
||||
const incoming = result.items || result;
|
||||
const combined = append ? state.items.concat(incoming) : incoming;
|
||||
const items = [...new Map((combined || []).map(item =>
|
||||
[`${item.kind}:${item.repository}:${item.number}`, item]
|
||||
)).values()];
|
||||
if (current === generation) publish({
|
||||
if (current === generation) return publish({
|
||||
status: 'ready', query, items, partial,
|
||||
more: !!result.has_more,
|
||||
next: result.next_page,
|
||||
continuation:result.continuation,
|
||||
failedStreams:result.failed_streams || [],
|
||||
scope:requestScope,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error && error.name === 'AbortError') return;
|
||||
if (current === generation) publish(append
|
||||
if (current === generation) return publish(append
|
||||
? { ...state, status: 'ready' }
|
||||
: { status: 'error', query, items: [], error, scope:{ ...scope } });
|
||||
} finally {
|
||||
@@ -74,20 +88,25 @@
|
||||
timer = setTimeout(() => requestPage(query, 1, current, false), delay);
|
||||
},
|
||||
setScope(value) {
|
||||
const repository = typeof value?.repository === 'string' &&
|
||||
/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value.repository.trim())
|
||||
? value.repository.trim() : '';
|
||||
const next = {
|
||||
kind:['all', 'issue', 'pull'].includes(value?.kind) ? value.kind : 'all',
|
||||
state:['all', 'open', 'closed'].includes(value?.state) ? value.state : 'all',
|
||||
...(repository ? { repository } : {}),
|
||||
};
|
||||
if (next.kind === scope.kind && next.state === scope.state) return;
|
||||
if (next.kind === scope.kind && next.state === scope.state &&
|
||||
(next.repository || '') === (scope.repository || '')) return;
|
||||
const query = state.query;
|
||||
scope = next;
|
||||
this.setQuery(query);
|
||||
},
|
||||
loadMore() {
|
||||
if (state.status !== 'ready' || !state.more || activeController) return;
|
||||
if (state.status !== 'ready' || !state.more || activeController) return Promise.resolve(state);
|
||||
const current = generation;
|
||||
const page = state.next;
|
||||
requestPage(state.query, page, current, true);
|
||||
return requestPage(state.query, page, current, true, state.continuation);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
function createConversationActionHydrator({ load, activate }) {
|
||||
let actions = null;
|
||||
let pending = null;
|
||||
const wired = new WeakSet();
|
||||
|
||||
async function ensure() {
|
||||
if (actions) return actions;
|
||||
if (!pending) pending = load().then(() => actions = activate()).catch(error => {
|
||||
pending = null;
|
||||
throw error;
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
|
||||
function show({ root, state, paint, wire, retry }) {
|
||||
if (actions) {
|
||||
retry.hidden = true;
|
||||
if (!wired.has(root)) {
|
||||
wire(actions);
|
||||
wired.add(root);
|
||||
}
|
||||
paint(state, actions);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
paint(state, null);
|
||||
retry.hidden = true;
|
||||
return ensure().then(controller => {
|
||||
if (!wired.has(root)) {
|
||||
wire(controller);
|
||||
wired.add(root);
|
||||
}
|
||||
paint(state, controller);
|
||||
return true;
|
||||
}).catch(() => {
|
||||
retry.hidden = false;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
return {show,get:ensure};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createConversationActionHydrator;
|
||||
@@ -0,0 +1,88 @@
|
||||
(function (root, factory) {
|
||||
const createConversationPhotoDrafts = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = createConversationPhotoDrafts;
|
||||
else root.createConversationPhotoDrafts = createConversationPhotoDrafts;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
return function createConversationPhotoDrafts({ store, lanes }) {
|
||||
const states = {};
|
||||
Object.entries(lanes || {}).forEach(([kind, lane]) => {
|
||||
states[kind] = { ...lane, target:null, generation:0, restoring:false, pending:Promise.resolve() };
|
||||
});
|
||||
|
||||
function state(kind) {
|
||||
const value = states[kind];
|
||||
if (!value) throw new Error('Unknown conversation photo draft lane.');
|
||||
return value;
|
||||
}
|
||||
|
||||
async function checkpoint(kind) {
|
||||
const current = state(kind);
|
||||
if (!current.target || current.restoring) return null;
|
||||
const target = { ...current.target };
|
||||
const attachments = await current.controller.serialize();
|
||||
const save = current.pending.then(() => store.save(target, attachments));
|
||||
current.pending = save.catch(() => null);
|
||||
try { return await save; }
|
||||
catch (error) { current.onError?.(error); throw error; }
|
||||
}
|
||||
|
||||
async function open(kind, target) {
|
||||
const current = state(kind);
|
||||
const generation = ++current.generation;
|
||||
current.target = { ...target };
|
||||
await current.pending;
|
||||
const attachments = await store.load(target);
|
||||
if (generation !== current.generation) return false;
|
||||
current.restoring = true;
|
||||
try {
|
||||
current.controller.clear();
|
||||
if (attachments?.length) current.controller.restore(attachments);
|
||||
} finally { current.restoring = false; }
|
||||
return Boolean(attachments?.length);
|
||||
}
|
||||
|
||||
async function leave(kind) {
|
||||
const current = state(kind);
|
||||
const generation = current.generation;
|
||||
if (!current.target) return true;
|
||||
try { await checkpoint(kind); }
|
||||
catch (_error) { return false; }
|
||||
if (generation === current.generation) {
|
||||
current.restoring = true;
|
||||
try { current.controller.clear(); }
|
||||
finally {
|
||||
current.restoring = false;
|
||||
current.target = null;
|
||||
current.generation += 1;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function complete(kind) {
|
||||
const current = state(kind);
|
||||
if (!current.target) return false;
|
||||
const target = { ...current.target };
|
||||
await current.pending;
|
||||
await store.remove(target);
|
||||
current.restoring = true;
|
||||
try { current.controller.clear(); }
|
||||
finally {
|
||||
current.restoring = false;
|
||||
current.target = null;
|
||||
current.generation += 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function switchTo(kind, target, isCurrent = () => true) {
|
||||
if (!await leave(kind) || !isCurrent()) return false;
|
||||
return open(kind, target);
|
||||
}
|
||||
|
||||
function hasTarget(kind) { return Boolean(state(kind).target); }
|
||||
return { checkpoint, open, switchTo, leave, complete, hasTarget };
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
(function (root, factory) {
|
||||
const createConversationReplyDraftStore = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = createConversationReplyDraftStore;
|
||||
else root.createConversationReplyDraftStore = createConversationReplyDraftStore;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
const dbName = 'stackchain-conversation-reply-drafts-v1';
|
||||
const storeName = 'drafts';
|
||||
|
||||
function requestResult(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result ?? null);
|
||||
request.onerror = () => reject(request.error || new Error('Conversation photo draft storage failed.'));
|
||||
});
|
||||
}
|
||||
|
||||
function createTransaction(indexedDB) {
|
||||
if (!indexedDB) return null;
|
||||
let databasePromise;
|
||||
function database() {
|
||||
if (!databasePromise) databasePromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, 1);
|
||||
request.onupgradeneeded = () => {
|
||||
if (!request.result.objectStoreNames.contains(storeName)) {
|
||||
request.result.createObjectStore(storeName, { keyPath:'id' });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('Conversation photo draft storage is unavailable.'));
|
||||
});
|
||||
return databasePromise;
|
||||
}
|
||||
return async (operation, key, value) => {
|
||||
const db = await database();
|
||||
const transaction = db.transaction(storeName, operation === 'get' ? 'readonly' : 'readwrite');
|
||||
const records = transaction.objectStore(storeName);
|
||||
if (operation === 'put') return requestResult(records.put(value));
|
||||
if (operation === 'delete') return requestResult(records.delete(key));
|
||||
return requestResult(records.get(key));
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedTarget(target) {
|
||||
const kind = String(target?.kind || '');
|
||||
if (kind === 'update') {
|
||||
const notificationId = Number(target?.notificationId || target?.notification_id || 0);
|
||||
if (!Number.isInteger(notificationId) || notificationId < 1) {
|
||||
throw new Error('Open an unread update before saving photo evidence.');
|
||||
}
|
||||
return { kind, notificationId };
|
||||
}
|
||||
const repository = String(target?.repository || '');
|
||||
const number = Number(target?.number || 0);
|
||||
if (!['issue', 'pull'].includes(kind) || !repository || !Number.isInteger(number) || number < 1) {
|
||||
throw new Error('Open a conversation before saving photo evidence.');
|
||||
}
|
||||
return { kind, repository, number };
|
||||
}
|
||||
|
||||
function attachment(value) {
|
||||
const blob = value?.blob;
|
||||
const data = String(value?.data || '');
|
||||
if (!blob && !data) throw new Error('A saved conversation photo is unavailable.');
|
||||
const note = String(value?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
||||
const operationId = String(value?.operationId || '').slice(0, 128);
|
||||
const markdown = String(value?.confirmed?.markdown || '');
|
||||
return {
|
||||
filename:String(value?.filename || ''), contentType:String(value?.contentType || ''),
|
||||
...(blob ? { blob } : { data }), ...(note ? { note } : {}),
|
||||
...(operationId ? { operationId } : {}), ...(markdown ? { confirmed:{ markdown } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return function createConversationReplyDraftStore({
|
||||
indexedDB = globalThis.indexedDB, transaction, getOwnerLogin = () => '', scope = 'conversation',
|
||||
} = {}) {
|
||||
const transact = transaction || createTransaction(indexedDB);
|
||||
const draftScope = String(scope || 'conversation').trim().slice(0, 64) || 'conversation';
|
||||
|
||||
function identity(target) {
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before saving conversation photos.');
|
||||
const normalized = normalizedTarget(target);
|
||||
const targetKey = normalized.kind === 'update' ? normalized.notificationId :
|
||||
normalized.repository + ':' + normalized.number;
|
||||
const identityParts = draftScope === 'conversation' ? [ownerLogin, normalized.kind, targetKey] :
|
||||
[ownerLogin, draftScope, normalized.kind, targetKey];
|
||||
return {
|
||||
ownerLogin, scope:draftScope, target:normalized,
|
||||
id:identityParts.map(value => encodeURIComponent(String(value))).join(':'),
|
||||
};
|
||||
}
|
||||
|
||||
async function save(target, values) {
|
||||
if (!transact) throw new Error('Photo draft storage needs IndexedDB. Your current photos are still here.');
|
||||
const { id, ownerLogin, scope:recordScope, target:normalized } = identity(target);
|
||||
const list = (Array.isArray(values) ? values : [values]).filter(Boolean).slice(0, 5).map(attachment);
|
||||
if (!list.length) { await transact('delete', id); return null; }
|
||||
await transact('put', id, { id, version:1, ownerLogin, scope:recordScope, ...normalized, attachments:list });
|
||||
return list;
|
||||
}
|
||||
|
||||
async function load(target) {
|
||||
if (!transact) return null;
|
||||
const { id, ownerLogin, scope:recordScope, target:normalized } = identity(target);
|
||||
const record = await transact('get', id);
|
||||
const scopeMatches = recordScope === 'conversation' ? (!record?.scope || record.scope === recordScope) :
|
||||
record?.scope === recordScope;
|
||||
const same = record?.version === 1 && record.ownerLogin === ownerLogin && scopeMatches &&
|
||||
record.kind === normalized.kind && (normalized.kind === 'update' ?
|
||||
Number(record.notificationId) === normalized.notificationId :
|
||||
record.repository === normalized.repository && Number(record.number) === normalized.number);
|
||||
if (!same || !Array.isArray(record.attachments) || !record.attachments.length) return null;
|
||||
return record.attachments.slice(0, 5).map(attachment);
|
||||
}
|
||||
|
||||
async function remove(target) {
|
||||
if (!transact) return false;
|
||||
const { id } = identity(target);
|
||||
await transact('delete', id);
|
||||
return true;
|
||||
}
|
||||
|
||||
return { save, load, remove };
|
||||
};
|
||||
});
|
||||
@@ -5,7 +5,40 @@ function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, anno
|
||||
return todayWork.read().length < todayWork.limit;
|
||||
}
|
||||
|
||||
function complete(issue) {
|
||||
function capacity(value) {
|
||||
if (String(value ?? '').trim() === '') return { valid: false, reason: 'required' };
|
||||
const minutes = Number(value);
|
||||
if (!Number.isInteger(minutes) || minutes < 5 || minutes > 1440) {
|
||||
return { valid: false, reason: 'range' };
|
||||
}
|
||||
const plan = todayWork.planning();
|
||||
const capacityMinutes = plan.capacity_minutes;
|
||||
if (!Number.isInteger(capacityMinutes)) {
|
||||
return { valid: true, minutes, capacityMinutes: null, plannedMinutes: null,
|
||||
remainingMinutes: null, projectedMinutes: null, fits: true };
|
||||
}
|
||||
const plannedMinutes = todayWork.read().reduce(
|
||||
(total, identity) => total + (Number.isInteger(plan.estimates[identity]) ? plan.estimates[identity] : 0), 0
|
||||
);
|
||||
const remainingMinutes = capacityMinutes - plannedMinutes;
|
||||
const projectedMinutes = remainingMinutes - minutes;
|
||||
return { valid: true, minutes, capacityMinutes, plannedMinutes, remainingMinutes,
|
||||
projectedMinutes, fits: projectedMinutes >= 0 };
|
||||
}
|
||||
|
||||
function capacityMessage(result) {
|
||||
if (!result.valid) return result.reason === 'required'
|
||||
? 'Required only for Create & start.'
|
||||
: 'Use a whole number from 5 to 1440 minutes.';
|
||||
if (result.capacityMinutes === null) {
|
||||
return result.minutes + ' min · Set a Today budget to see remaining time.';
|
||||
}
|
||||
return result.fits
|
||||
? result.minutes + ' min · ' + result.projectedMinutes + ' min will remain in Today.'
|
||||
: result.minutes + ' min · ' + Math.abs(result.projectedMinutes) + ' min over Today capacity.';
|
||||
}
|
||||
|
||||
function complete(issue, estimateMinutes) {
|
||||
const identity = todayWork.identity(issue);
|
||||
if (!identity || completed.has(identity)) return 'exists';
|
||||
const added = todayWork.add(issue);
|
||||
@@ -17,10 +50,25 @@ function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, anno
|
||||
announce('Created, but Today could not be saved on this device.');
|
||||
return 'unavailable';
|
||||
}
|
||||
let estimatedPlan = null;
|
||||
if (Number.isInteger(estimateMinutes)) {
|
||||
estimatedPlan = todayWork.planning();
|
||||
estimatedPlan.estimates[identity] = estimateMinutes;
|
||||
if (!todayWork.replacePlanning(estimatedPlan)) {
|
||||
announce('Created and added to Today, but its estimate could not be saved on this device.');
|
||||
return 'estimate-unavailable';
|
||||
}
|
||||
}
|
||||
if (!todaySync.enqueue('add', identity)) {
|
||||
announce('Created and saved to Today on this device, but account sync is unavailable.');
|
||||
return 'sync-unavailable';
|
||||
}
|
||||
if (estimatedPlan) {
|
||||
if (!todaySync.enqueueConfiguration(estimatedPlan.capacity_minutes, estimatedPlan.estimates)) {
|
||||
announce('Created and estimated on this device, but Today plan sync is unavailable.');
|
||||
return 'sync-unavailable';
|
||||
}
|
||||
}
|
||||
completed.add(identity);
|
||||
refresh();
|
||||
todaySync.flush();
|
||||
@@ -30,7 +78,7 @@ function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, anno
|
||||
return 'started';
|
||||
}
|
||||
|
||||
return { available, complete };
|
||||
return { available, capacity, capacityMessage, complete };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createCreateAndStart;
|
||||
@@ -8,6 +8,59 @@ function newIssueOperationId() {
|
||||
return String(Date.now()) + '-' + Math.random().toString(16).slice(2);
|
||||
}
|
||||
|
||||
function createIssueModalLifecycle({ root, background = [], document, requestClose }) {
|
||||
let launcher = null;
|
||||
let active = false;
|
||||
let backgroundState = [];
|
||||
const focusableSelector = [
|
||||
'a[href]', 'button:not([disabled])', 'input:not([disabled])',
|
||||
'select:not([disabled])', 'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
const focusable = () => Array.from(root.querySelectorAll(focusableSelector)).filter(element =>
|
||||
!element.hidden && !element.disabled && element.getClientRects().length > 0
|
||||
);
|
||||
function keydown(event) {
|
||||
if (!active) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
requestClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const controls = focusable();
|
||||
if (!controls.length) return;
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
root.addEventListener('keydown', keydown);
|
||||
return {
|
||||
open(trigger = null) {
|
||||
launcher = trigger?.isConnected ? trigger : document.activeElement;
|
||||
backgroundState = background.map(element => [element, element.inert]);
|
||||
backgroundState.forEach(([element]) => { element.inert = true; });
|
||||
active = true;
|
||||
(root.querySelector('#cancel-new-issue') || focusable()[0])?.focus();
|
||||
},
|
||||
close({ restore = true } = {}) {
|
||||
active = false;
|
||||
backgroundState.forEach(([element, inert]) => { element.inert = inert; });
|
||||
backgroundState = [];
|
||||
const panel = root.querySelector('.create-issue-panel');
|
||||
if (panel) panel.scrollTop = 0;
|
||||
if (restore && launcher?.isConnected) launcher.focus();
|
||||
launcher = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSharedContent(value = {}) {
|
||||
const clean = input => String(input || '').replace(/\s+/g, ' ').trim();
|
||||
const text = String(value.text || '').trim().slice(0, 9500);
|
||||
@@ -19,22 +72,244 @@ function normalizeSharedContent(value = {}) {
|
||||
return { title, body };
|
||||
}
|
||||
|
||||
function buildRelatedDraft(value = {}, template = null) {
|
||||
const repository = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(String(value.repository || ''))
|
||||
? String(value.repository) : '';
|
||||
const labelIds = Array.from(new Set((Array.isArray(value.labelIds) ? value.labelIds : [])
|
||||
.filter(id => Number.isInteger(id) && id > 0))).slice(0, 20);
|
||||
const draft = {repository, title:'', body:'', labelIds};
|
||||
const milestoneId = Number(value.milestoneId);
|
||||
if (Number.isInteger(milestoneId) && milestoneId > 0) draft.milestoneId = milestoneId;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(String(value.dueDate || ''))) draft.dueDate = String(value.dueDate);
|
||||
if (value.unassigned === true) draft.unassigned = true;
|
||||
else if (/^[A-Za-z0-9_.-]+$/.test(String(value.assignee || ''))) {
|
||||
draft.assignee = String(value.assignee);
|
||||
draft.assigneeName = String(value.assigneeName || value.assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
|
||||
}
|
||||
const templateId = String(value.templateId || '').slice(0, 80);
|
||||
if (templateId && template && String(template.id || '') === templateId) {
|
||||
draft.templateId = templateId;
|
||||
draft.templateName = String(value.templateName || template.name || 'Issue template').trim().slice(0, 80);
|
||||
draft.capturedBody = '';
|
||||
draft.body = String(template.body || '').trim().slice(0, 9000);
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
function relatedChecklistDraft(item, label) {
|
||||
const title = String(label || '').trim().replace(/\s+/g, ' ').slice(0, 240);
|
||||
if (!title) throw new Error('Choose a checklist step to file.');
|
||||
const repository = String(item?.repository || '');
|
||||
const number = Number(item?.number);
|
||||
const url = String(item?.url || '');
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) ||
|
||||
!Number.isInteger(number) || number < 1 || !/^https:\/\//.test(url)) {
|
||||
throw new Error('The parent issue link is unavailable.');
|
||||
}
|
||||
return {repository, title,
|
||||
body:'Related to [' + repository + '#' + number + '](' + url + ').', labelIds:[]};
|
||||
}
|
||||
|
||||
function linkChecklistTask(raw, targetIndex, child) {
|
||||
const url = String(child?.url || '');
|
||||
if (!/^https:\/\//.test(url)) throw new Error('The related issue link is unavailable.');
|
||||
const parts = String(raw || '').split(/(\r\n|\n|\r)/);
|
||||
let fenced = false;
|
||||
let taskIndex = 0;
|
||||
for (let index = 0; index < parts.length; index += 2) {
|
||||
const line = parts[index];
|
||||
if (/^\s*```/.test(line)) { fenced = !fenced; continue; }
|
||||
if (fenced) continue;
|
||||
const task = line.match(/^(\s*[-*+]\s+\[[ xX]\]\s+)(.*)$/);
|
||||
if (!task) continue;
|
||||
if (taskIndex === Number(targetIndex)) {
|
||||
if (/^\s*\[[^\]]+\]\([^)]+\)\s*$/.test(task[2])) throw new Error('That checklist step is already linked.');
|
||||
const label = task[2].trim().replace(/\s+/g, ' ');
|
||||
parts[index] = task[1] + '[' + label + '](' + url + ')';
|
||||
return parts.join('');
|
||||
}
|
||||
taskIndex += 1;
|
||||
}
|
||||
throw new Error('The checklist step is no longer available.');
|
||||
}
|
||||
|
||||
function createChecklistPromotion({ issueController, issueCapture, clearAttachments, onLinked, onStatus }) {
|
||||
let pending = null;
|
||||
const persistedPromotion = value => ({
|
||||
item:{repository:value.repository, number:value.number, url:value.url},
|
||||
detail:{title:value.title, body:value.body, updated_at:value.updatedAt}, taskIndex:value.taskIndex,
|
||||
});
|
||||
return {
|
||||
pending:() => Boolean(pending),
|
||||
start(context) {
|
||||
pending = {item:{...context.item}, detail:{...context.detail}, taskIndex:Number(context.taskIndex)};
|
||||
const draft = issueController.relatedTaskDraft(context.item, context.detail, context.taskIndex, context.label);
|
||||
issueCapture.saveDraft(draft);
|
||||
clearAttachments();
|
||||
return draft;
|
||||
},
|
||||
cancel() { if (!pending) return false; pending = null; issueCapture.clearDraft(); return true; },
|
||||
deliveryContext() {
|
||||
if (!pending) return null;
|
||||
return {repository:pending.item.repository, number:pending.item.number, url:pending.item.url,
|
||||
title:pending.detail.title, body:pending.detail.body, updatedAt:pending.detail.updated_at,
|
||||
taskIndex:pending.taskIndex};
|
||||
},
|
||||
async finish(child, persisted = null) {
|
||||
const promotion = pending || (persisted ? persistedPromotion(persisted) : null);
|
||||
if (!promotion || !child) return false;
|
||||
pending = null;
|
||||
try {
|
||||
const confirmed = await issueController.linkRelatedTask(
|
||||
promotion.item, promotion.detail, promotion.taskIndex, child
|
||||
);
|
||||
onLinked(promotion, confirmed);
|
||||
onStatus('Related issue created and linked from its parent checklist.');
|
||||
return true;
|
||||
} catch (error) {
|
||||
onStatus('Related issue created, but the parent link needs attention.' +
|
||||
(child.url ? ' ' + child.url : '') + ' ' + error.message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createIssueOwnerPicker(issueCapture, documentRef, onChange) {
|
||||
const NO_OWNER = '__unassigned__';
|
||||
const select = documentRef.querySelector('#create-issue-assignee');
|
||||
const status = documentRef.querySelector('#create-issue-assignee-status');
|
||||
const getRepository = () => documentRef.querySelector('#create-issue-repository').value;
|
||||
const loadOwners = repository => issueCapture.loadOwners(repository);
|
||||
let ownerRequest = 0;
|
||||
function reset(repository, selected = {}) {
|
||||
select.replaceChildren();
|
||||
const me = documentRef.createElement('option');
|
||||
me.value = '';
|
||||
me.textContent = 'Me';
|
||||
select.appendChild(me);
|
||||
const noOwner = documentRef.createElement('option');
|
||||
noOwner.value = NO_OWNER;
|
||||
noOwner.textContent = 'No owner';
|
||||
select.appendChild(noOwner);
|
||||
if (selected.unassigned === true) select.value = NO_OWNER;
|
||||
if (selected.assignee) {
|
||||
const option = documentRef.createElement('option');
|
||||
option.value = selected.assignee;
|
||||
option.textContent = (selected.assigneeName || selected.assignee) + ' (@' + selected.assignee + ')';
|
||||
option.dataset.name = selected.assigneeName || selected.assignee;
|
||||
select.appendChild(option);
|
||||
select.value = selected.assignee;
|
||||
}
|
||||
select.dataset.repository = '';
|
||||
status.textContent = repository ? 'Open the owner picker to load eligible teammates.' : 'Choose a repository first.';
|
||||
}
|
||||
|
||||
async function load(repository) {
|
||||
if (!repository || select.dataset.repository === repository) return;
|
||||
const request = ++ownerRequest;
|
||||
const selected = select.value;
|
||||
const selectedName = select.selectedOptions?.[0]?.dataset.name || '';
|
||||
status.textContent = 'Loading eligible teammates…';
|
||||
try {
|
||||
const owners = await loadOwners(repository);
|
||||
if (request !== ownerRequest || getRepository() !== repository) return;
|
||||
const selectedStillEligible = owners.some(owner => owner?.login === selected);
|
||||
reset(repository, selected === NO_OWNER ? {unassigned:true} :
|
||||
(selectedStillEligible ? {assignee:selected, assigneeName:selectedName} : {}));
|
||||
owners.forEach(owner => {
|
||||
if (!owner?.login || owner.login === selected) return;
|
||||
const option = documentRef.createElement('option');
|
||||
option.value = owner.login;
|
||||
option.textContent = (owner.name || owner.login) + ' (@' + owner.login + ')';
|
||||
option.dataset.name = owner.name || owner.login;
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.dataset.repository = repository;
|
||||
status.textContent = owners.length ? 'Choose yourself, no owner, or an eligible teammate.' :
|
||||
'Choose yourself or no owner; no eligible teammates are available.';
|
||||
} catch (_error) {
|
||||
if (request !== ownerRequest || getRepository() !== repository) return;
|
||||
status.textContent = 'Teammates could not be loaded. The issue will stay assigned to you.';
|
||||
}
|
||||
}
|
||||
function updateActions(hasRepository, hasBlockers, canStart) {
|
||||
const submit = documentRef.querySelector('#submit-new-issue');
|
||||
const start = documentRef.querySelector('#create-and-start-issue');
|
||||
const hasNoOwner = select.value === NO_OWNER;
|
||||
const hasTeammateOwner = Boolean(select.value) && !hasNoOwner;
|
||||
submit.disabled = !hasRepository;
|
||||
submit.textContent = hasNoOwner ? 'Create unassigned' :
|
||||
(hasTeammateOwner ? 'Create & assign' : 'Create & assign to me');
|
||||
start.disabled = !hasRepository || hasBlockers || hasTeammateOwner || hasNoOwner || !canStart;
|
||||
start.title = hasBlockers ? 'Blocked work cannot start until its blockers are complete.' :
|
||||
(hasNoOwner ? 'No owner work cannot be added to your Today queue.' :
|
||||
(hasTeammateOwner ? 'Work assigned to a teammate cannot be added to your Today queue.' : ''));
|
||||
}
|
||||
function fields() {
|
||||
return {
|
||||
assignee: select.value === NO_OWNER ? '' : select.value,
|
||||
assigneeName: select.value === NO_OWNER ? '' : (select.selectedOptions?.[0]?.dataset.name || ''),
|
||||
unassigned: select.value === NO_OWNER,
|
||||
};
|
||||
}
|
||||
function draft(labelIds, blockers, trim = false) {
|
||||
const value = id => documentRef.querySelector(id).value;
|
||||
const clean = input => trim ? input.trim() : input;
|
||||
return {
|
||||
repository:value('#create-issue-repository'),
|
||||
title:clean(value('#create-issue-title')), body:clean(value('#create-issue-body')),
|
||||
labelIds, milestoneId:Number(value('#create-issue-milestone')) || null,
|
||||
dueDate:value('#create-issue-due-date'), ...fields(), blockers,
|
||||
};
|
||||
}
|
||||
select.addEventListener('focus', () => {
|
||||
const repository = getRepository();
|
||||
if (repository) load(repository);
|
||||
});
|
||||
select.addEventListener('change', onChange);
|
||||
return { reset, load, updateActions, fields, draft };
|
||||
}
|
||||
|
||||
function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOperationId }) {
|
||||
const storageKey = 'stackchain.issue-capture.v1';
|
||||
const sharedStorageKey = 'stackchain.issue-share.v1';
|
||||
const followUpStorageKey = 'stackchain.issue-follow-up.v1';
|
||||
let pending = null;
|
||||
let issueTemplates = [];
|
||||
let activeTemplate = null;
|
||||
let templateRequest = 0;
|
||||
let duplicateRequest = 0;
|
||||
let repositorySearchRequest = 0;
|
||||
let filingMetadataRequest = 0;
|
||||
let blockerSearchRequest = 0;
|
||||
let duplicateState = {status: 'idle', key: '', candidates: []};
|
||||
let acknowledgedDuplicateKey = '';
|
||||
const repositoryPageRequests = new Map();
|
||||
const safeLabelIds = value => Array.from(new Set(
|
||||
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
|
||||
)).slice(0, 20);
|
||||
const safeBlockers = value => {
|
||||
const seen = new Set();
|
||||
return (Array.isArray(value) ? value : []).reduce((items, blocker) => {
|
||||
const repository = String(blocker?.repository || '').trim();
|
||||
const number = Number(blocker?.number);
|
||||
const key = repository + '#' + number;
|
||||
if (items.length >= 5 || seen.has(key) ||
|
||||
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) ||
|
||||
!Number.isInteger(number) || number < 1) return items;
|
||||
seen.add(key);
|
||||
items.push({repository, number,
|
||||
title:String(blocker?.title || '').replace(/\s+/g, ' ').trim().slice(0, 255)});
|
||||
return items;
|
||||
}, []);
|
||||
};
|
||||
const emptyDraft = () => ({ repository: '', title: '', body: '', labelIds: [] });
|
||||
const safeMilestoneId = value => Number.isInteger(Number(value)) && Number(value) > 0 ? Number(value) : null;
|
||||
const safeDueDate = value => /^\d{4}-\d{2}-\d{2}$/.test(String(value || '')) ? String(value) : '';
|
||||
const safeAssignee = value => /^[A-Za-z0-9_.-]+$/.test(String(value || '')) ? String(value) : '';
|
||||
const safeEstimate = value => Number.isInteger(Number(value)) && Number(value) >= 5 && Number(value) <= 1440 ?
|
||||
Number(value) : null;
|
||||
|
||||
function loadStored() {
|
||||
try {
|
||||
@@ -47,10 +322,28 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
labelIds: safeLabelIds(parsed.labelIds),
|
||||
operationId: String(parsed.operationId || '').slice(0, 128),
|
||||
};
|
||||
if (parsed.unassigned === true) draft.unassigned = true;
|
||||
if (typeof parsed.templateName === 'string' && parsed.templateName.trim()) {
|
||||
draft.templateName = parsed.templateName.trim().slice(0, 80);
|
||||
draft.templateId = String(parsed.templateId || '').slice(0, 80);
|
||||
draft.capturedBody = String(parsed.capturedBody || '').slice(0, 10000);
|
||||
}
|
||||
const assignee = safeAssignee(parsed.assignee);
|
||||
if (assignee) {
|
||||
draft.assignee = assignee;
|
||||
draft.assigneeName = String(parsed.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
|
||||
}
|
||||
const milestoneId = safeMilestoneId(parsed.milestoneId);
|
||||
const dueDate = safeDueDate(parsed.dueDate);
|
||||
if (milestoneId !== null) draft.milestoneId = milestoneId;
|
||||
if (dueDate) draft.dueDate = dueDate;
|
||||
const estimateMinutes = safeEstimate(parsed.estimateMinutes);
|
||||
if (estimateMinutes !== null) draft.estimateMinutes = estimateMinutes;
|
||||
if (['create', 'create-and-start'].includes(parsed.completionIntent)) {
|
||||
draft.completionIntent = parsed.completionIntent;
|
||||
}
|
||||
const blockers = safeBlockers(parsed.blockers);
|
||||
if (blockers.length) draft.blockers = blockers;
|
||||
return draft;
|
||||
} catch (_error) {
|
||||
return {...emptyDraft(), operationId: ''};
|
||||
@@ -70,13 +363,33 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
body: String(draft?.body || ''),
|
||||
labelIds: safeLabelIds(draft?.labelIds),
|
||||
};
|
||||
if (draft?.unassigned === true) safe.unassigned = true;
|
||||
if (typeof draft?.templateName === 'string' && draft.templateName.trim()) {
|
||||
safe.templateName = draft.templateName.trim().slice(0, 80);
|
||||
safe.templateId = String(draft.templateId || '').slice(0, 80);
|
||||
safe.capturedBody = String(draft.capturedBody || '').slice(0, 10000);
|
||||
}
|
||||
const assignee = safe.unassigned ? '' : safeAssignee(draft?.assignee);
|
||||
if (assignee) {
|
||||
safe.assignee = assignee;
|
||||
safe.assigneeName = String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
|
||||
}
|
||||
const milestoneId = safeMilestoneId(draft?.milestoneId);
|
||||
const dueDate = safeDueDate(draft?.dueDate);
|
||||
if (milestoneId !== null) safe.milestoneId = milestoneId;
|
||||
if (dueDate) safe.dueDate = dueDate;
|
||||
const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate']
|
||||
const estimateMinutes = safeEstimate(draft?.estimateMinutes);
|
||||
if (estimateMinutes !== null) safe.estimateMinutes = estimateMinutes;
|
||||
if (['create', 'create-and-start'].includes(draft?.completionIntent)) {
|
||||
safe.completionIntent = draft.completionIntent;
|
||||
}
|
||||
const blockers = safeBlockers(draft?.blockers);
|
||||
if (blockers.length) safe.blockers = blockers;
|
||||
const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate', 'assignee', 'assigneeName', 'unassigned',
|
||||
'templateName', 'templateId', 'capturedBody', 'estimateMinutes', 'completionIntent']
|
||||
.every(key => (previous[key] || '') === (safe[key] || '')) &&
|
||||
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds);
|
||||
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds) &&
|
||||
JSON.stringify(previous.blockers || []) === JSON.stringify(safe.blockers || []);
|
||||
writeStored({...safe, operationId: unchanged ? previous.operationId : ''});
|
||||
return safe;
|
||||
}
|
||||
@@ -182,6 +495,266 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
);
|
||||
}
|
||||
|
||||
function loadOwners(repository) {
|
||||
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
return fetchJson('api/v1/repos/' + encoded + '/issue-assignees').then(owners =>
|
||||
Array.isArray(owners) ? owners : []
|
||||
);
|
||||
}
|
||||
|
||||
function loadTemplates(repository) {
|
||||
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
return fetchJson('api/v1/repos/' + encoded + '/issue-templates').then(templates =>
|
||||
Array.isArray(templates) ? templates.slice(0, 20) : []
|
||||
);
|
||||
}
|
||||
|
||||
async function loadFilingMetadata(repository) {
|
||||
const selectedRepository = String(repository || '');
|
||||
const encoded = selectedRepository.split('/').map(encodeURIComponent).join('/');
|
||||
const request = ++filingMetadataRequest;
|
||||
const payload = await fetchJson(
|
||||
'api/v1/repos/' + encoded + '/issue-filing-metadata'
|
||||
);
|
||||
if (request !== filingMetadataRequest) {
|
||||
return {status:'stale', repository:selectedRepository};
|
||||
}
|
||||
const section = name => {
|
||||
const value = payload?.[name];
|
||||
const available = value?.available === true;
|
||||
return {
|
||||
available,
|
||||
items:available && Array.isArray(value?.items) ? value.items : [],
|
||||
...(!available && typeof value?.error === 'string' ? {error:value.error} : {}),
|
||||
};
|
||||
};
|
||||
return {
|
||||
status:'ready', repository:selectedRepository,
|
||||
labels:section('labels'), milestones:section('milestones'),
|
||||
templates:section('templates'),
|
||||
};
|
||||
}
|
||||
|
||||
function applyTemplate(draft, template, availableLabels = []) {
|
||||
const source = {...(draft || {})};
|
||||
const capturedBody = source.templateName ? String(source.capturedBody || '') : String(source.body || '');
|
||||
if (!template) {
|
||||
delete source.templateName;
|
||||
delete source.templateId;
|
||||
delete source.capturedBody;
|
||||
return {...source, body: capturedBody};
|
||||
}
|
||||
const scaffold = String(template.body || '').trim().slice(0, 9000);
|
||||
const body = [capturedBody.trim(), scaffold].filter(Boolean).join('\n\n---\n\n').slice(0, 10000);
|
||||
const validByName = new Map((Array.isArray(availableLabels) ? availableLabels : [])
|
||||
.filter(label => Number.isInteger(label?.id) && typeof label?.name === 'string')
|
||||
.map(label => [label.name.toLowerCase(), label.id]));
|
||||
const templateLabelIds = (Array.isArray(template.labels) ? template.labels : [])
|
||||
.map(name => validByName.get(String(name).toLowerCase())).filter(Boolean);
|
||||
return {
|
||||
...source,
|
||||
title: source.title || String(template.title || '').trim().slice(0, 255),
|
||||
body,
|
||||
labelIds: safeLabelIds([...(source.labelIds || []), ...templateLabelIds]),
|
||||
templateId: String(template.id || '').slice(0, 80),
|
||||
templateName: String(template.name || 'Issue template').trim().slice(0, 80),
|
||||
capturedBody,
|
||||
};
|
||||
}
|
||||
|
||||
function setTemplateState(draft) {
|
||||
activeTemplate = draft?.templateName ? {
|
||||
templateName:draft.templateName, templateId:draft.templateId,
|
||||
capturedBody:draft.capturedBody || '',
|
||||
} : null;
|
||||
}
|
||||
|
||||
function templateFields(draft) {
|
||||
return activeTemplate ? {...draft, ...activeTemplate} : draft;
|
||||
}
|
||||
|
||||
function restoreTemplate(draft, availableLabels) {
|
||||
const restored = applyTemplate(templateFields(draft), null, availableLabels);
|
||||
activeTemplate = null;
|
||||
return restored;
|
||||
}
|
||||
|
||||
async function loadTemplateOptions(repository, elements, selectedId = '') {
|
||||
const request = ++templateRequest;
|
||||
issueTemplates = [];
|
||||
elements.select.innerHTML = '<option value="">Blank issue</option>';
|
||||
elements.field.hidden = true;
|
||||
if (!repository) return;
|
||||
elements.status.textContent = 'Loading issue types…';
|
||||
try {
|
||||
const templates = await loadTemplates(repository);
|
||||
if (request !== templateRequest || elements.getRepository() !== repository) return;
|
||||
issueTemplates = templates;
|
||||
templates.forEach(template => {
|
||||
const option = elements.document.createElement('option');
|
||||
option.value = template.id;
|
||||
option.textContent = template.name;
|
||||
elements.select.appendChild(option);
|
||||
});
|
||||
elements.field.hidden = templates.length === 0;
|
||||
elements.select.value = templates.some(template => template.id === selectedId) ? selectedId : '';
|
||||
elements.status.textContent = templates.length ?
|
||||
'Choose a repository guide or keep a blank issue.' : 'Blank issue selected.';
|
||||
} catch (_error) {
|
||||
if (request !== templateRequest || elements.getRepository() !== repository) return;
|
||||
elements.status.textContent = 'Issue types could not be loaded. Blank issue filing is still available.';
|
||||
}
|
||||
}
|
||||
|
||||
function selectTemplate(identifier, draft, availableLabels) {
|
||||
const template = issueTemplates.find(item => item.id === identifier) || null;
|
||||
const applied = applyTemplate(templateFields(draft), template, availableLabels);
|
||||
activeTemplate = template ? {
|
||||
templateId:applied.templateId, templateName:applied.templateName,
|
||||
capturedBody:applied.capturedBody,
|
||||
} : null;
|
||||
return {draft: applied, template};
|
||||
}
|
||||
|
||||
function relatedDraft(draft) {
|
||||
const template = issueTemplates.find(item => String(item?.id || '') === String(draft?.templateId || '')) || null;
|
||||
return buildRelatedDraft(draft, template);
|
||||
}
|
||||
|
||||
function bindTemplatePicker(elements, callbacks) {
|
||||
let labels = [];
|
||||
elements.select.addEventListener('change', event => {
|
||||
const {draft, template} = selectTemplate(event.target.value, callbacks.getDraft(), labels);
|
||||
callbacks.setDraft(draft);
|
||||
const selected = new Set(draft.labelIds || []);
|
||||
elements.labelInputs().forEach(input => { input.checked = selected.has(Number(input.value)); });
|
||||
elements.status.textContent = template ?
|
||||
template.name + (template.about ? ' — ' + template.about : '') : 'Blank issue selected.';
|
||||
callbacks.changed();
|
||||
});
|
||||
return {
|
||||
fields:templateFields,
|
||||
reset:setTemplateState,
|
||||
setLabels(value) { labels = Array.isArray(value) ? value : []; },
|
||||
load(repository, selectedId) {
|
||||
return loadTemplateOptions(repository, elements, selectedId);
|
||||
},
|
||||
render(repository, section, selectedId = '') {
|
||||
++templateRequest;
|
||||
issueTemplates = [];
|
||||
elements.select.innerHTML = '<option value="">Blank issue</option>';
|
||||
elements.field.hidden = true;
|
||||
if (!repository || elements.getRepository() !== repository) return;
|
||||
if (section?.available !== true) {
|
||||
elements.status.textContent = 'Issue types could not be loaded. Blank issue filing is still available.';
|
||||
return;
|
||||
}
|
||||
issueTemplates = Array.isArray(section.items) ? section.items.slice(0, 20) : [];
|
||||
issueTemplates.forEach(template => {
|
||||
const option = elements.document.createElement('option');
|
||||
option.value = template.id;
|
||||
option.textContent = template.name;
|
||||
elements.select.appendChild(option);
|
||||
});
|
||||
elements.field.hidden = issueTemplates.length === 0;
|
||||
elements.select.value = issueTemplates.some(template => template.id === selectedId) ? selectedId : '';
|
||||
elements.status.textContent = issueTemplates.length ?
|
||||
'Choose a repository guide or keep a blank issue.' : 'Blank issue selected.';
|
||||
},
|
||||
changeRepository(draft) {
|
||||
const restored = restoreTemplate(draft, labels);
|
||||
callbacks.setDraft(restored);
|
||||
return restored;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function bindFilingMetadata(elements, templatePicker) {
|
||||
function renderLabels(repository, section, selectedIds = []) {
|
||||
elements.labelList.replaceChildren();
|
||||
if (!repository) {
|
||||
elements.labelStatus.textContent = 'Choose a repository to load labels.';
|
||||
return;
|
||||
}
|
||||
if (section?.available !== true) {
|
||||
templatePicker.setLabels([]);
|
||||
elements.labelStatus.textContent = 'Labels could not be loaded. You can still create the issue without labels.';
|
||||
return;
|
||||
}
|
||||
const priorities = new Set(['p0', 'priority-high', 'critical']);
|
||||
const labels = (Array.isArray(section.items) ? section.items : []).slice().sort((left, right) =>
|
||||
Number(priorities.has(String(right?.name || '').toLowerCase())) -
|
||||
Number(priorities.has(String(left?.name || '').toLowerCase()))
|
||||
);
|
||||
templatePicker.setLabels(labels);
|
||||
const selected = new Set(selectedIds.map(Number));
|
||||
labels.forEach(label => {
|
||||
const option = elements.document.createElement('label');
|
||||
option.className = 'create-issue-label-option';
|
||||
const input = elements.document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.name = 'create-issue-label';
|
||||
input.value = String(Number(label.id));
|
||||
input.checked = selected.has(Number(label.id));
|
||||
const name = elements.document.createElement('span');
|
||||
name.textContent = label.name;
|
||||
option.append(input, name);
|
||||
elements.labelList.appendChild(option);
|
||||
});
|
||||
elements.labelStatus.textContent = labels.length ?
|
||||
'Select labels to triage this issue.' : 'This repository has no labels.';
|
||||
}
|
||||
|
||||
function renderMilestones(repository, section, selectedId = null) {
|
||||
elements.milestoneSelect.replaceChildren();
|
||||
const blank = elements.document.createElement('option');
|
||||
blank.value = '';
|
||||
blank.textContent = 'No milestone';
|
||||
elements.milestoneSelect.appendChild(blank);
|
||||
if (!repository) {
|
||||
elements.milestoneStatus.textContent = 'Choose a repository to load milestones.';
|
||||
return;
|
||||
}
|
||||
if (section?.available !== true) {
|
||||
elements.milestoneStatus.textContent = 'Milestones could not be loaded. You can still create an unplanned issue.';
|
||||
return;
|
||||
}
|
||||
const milestones = Array.isArray(section.items) ? section.items : [];
|
||||
milestones.forEach(milestone => {
|
||||
const option = elements.document.createElement('option');
|
||||
option.value = String(Number(milestone.id));
|
||||
option.textContent = milestone.title;
|
||||
elements.milestoneSelect.appendChild(option);
|
||||
});
|
||||
if (selectedId) elements.milestoneSelect.value = String(selectedId);
|
||||
elements.milestoneStatus.textContent = milestones.length ?
|
||||
'Choose the release lane for this issue.' : 'This repository has no open milestones.';
|
||||
}
|
||||
|
||||
async function load(repository, selected = {}) {
|
||||
renderLabels(repository, null, selected.labelIds || []);
|
||||
renderMilestones(repository, null, selected.milestoneId);
|
||||
templatePicker.render(repository, null, selected.templateId);
|
||||
if (!repository) return;
|
||||
elements.labelStatus.textContent = 'Loading labels…';
|
||||
elements.milestoneStatus.textContent = 'Loading milestones…';
|
||||
elements.templateStatus.textContent = 'Loading issue types…';
|
||||
try {
|
||||
const metadata = await loadFilingMetadata(repository);
|
||||
if (metadata.status !== 'ready' || elements.getRepository() !== repository) return;
|
||||
renderLabels(repository, metadata.labels, selected.labelIds || []);
|
||||
renderMilestones(repository, metadata.milestones, selected.milestoneId);
|
||||
templatePicker.render(repository, metadata.templates, selected.templateId);
|
||||
} catch (_error) {
|
||||
if (elements.getRepository() !== repository) return;
|
||||
renderLabels(repository, {available:false}, selected.labelIds || []);
|
||||
renderMilestones(repository, {available:false}, selected.milestoneId);
|
||||
templatePicker.render(repository, {available:false}, selected.templateId);
|
||||
}
|
||||
}
|
||||
return {load};
|
||||
}
|
||||
|
||||
function loadRepositoryPage(page) {
|
||||
const safePage = Math.max(1, Math.floor(Number(page) || 1));
|
||||
if (repositoryPageRequests.has(safePage)) return repositoryPageRequests.get(safePage);
|
||||
@@ -216,6 +789,21 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
}
|
||||
}
|
||||
|
||||
async function searchBlockers(value) {
|
||||
const query = String(value || '').trim().slice(0, 80);
|
||||
const request = ++blockerSearchRequest;
|
||||
if (query.length < 2) return {status:'idle', items:[]};
|
||||
try {
|
||||
const payload = await fetchJson('api/v1/search?q=' + encodeURIComponent(query) + '&limit=20');
|
||||
if (request !== blockerSearchRequest) return {status:'stale', items:[]};
|
||||
return {status:'ready', items:(Array.isArray(payload?.items) ? payload.items : [])
|
||||
.filter(item => item?.kind === 'issue' && item?.state === 'open').slice(0, 20)};
|
||||
} catch (error) {
|
||||
if (request !== blockerSearchRequest) return {status:'stale', items:[]};
|
||||
return {status:'failed', items:[], error};
|
||||
}
|
||||
}
|
||||
|
||||
function duplicateKey(draft) {
|
||||
const repository = String(draft?.repository || '').trim();
|
||||
const title = String(draft?.title || '').replace(/\s+/g, ' ').trim();
|
||||
@@ -276,6 +864,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: saved.title, body: saved.body, label_ids: saved.labelIds,
|
||||
...(saved.unassigned ? {unassigned:true} : {}),
|
||||
...(saved.assignee ? {assignee: saved.assignee} : {}),
|
||||
...(saved.milestoneId ? {milestone_id: saved.milestoneId} : {}),
|
||||
...(saved.dueDate ? {due_date: saved.dueDate + 'T23:59:59Z'} : {}),
|
||||
}),
|
||||
@@ -287,8 +877,10 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
}
|
||||
|
||||
return {
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage,
|
||||
searchRepositories, findDuplicates,
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadOwners, loadTemplates,
|
||||
loadFilingMetadata, applyTemplate, setTemplateState, templateFields, restoreTemplate, loadTemplateOptions,
|
||||
selectTemplate, buildRelatedDraft:relatedDraft, bindTemplatePicker, bindFilingMetadata, loadRepositoryPage,
|
||||
searchRepositories, searchBlockers, findDuplicates,
|
||||
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
||||
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
||||
stageFollowUp, pendingFollowUp, acceptFollowUp, discardFollowUp,
|
||||
@@ -296,5 +888,11 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
}
|
||||
|
||||
createIssueCapture.normalizeSharedContent = normalizeSharedContent;
|
||||
createIssueCapture.createOwnerPicker = createIssueOwnerPicker;
|
||||
createIssueCapture.buildRelatedDraft = buildRelatedDraft;
|
||||
createIssueCapture.relatedChecklistDraft = relatedChecklistDraft;
|
||||
createIssueCapture.linkChecklistTask = linkChecklistTask;
|
||||
createIssueCapture.createChecklistPromotion = createChecklistPromotion;
|
||||
createIssueCapture.createModalLifecycle = createIssueModalLifecycle;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
|
||||
+806
-20
File diff suppressed because it is too large.
Load diff
+2300
-453
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,142 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else {
|
||||
root.createDeviceStorage = factory;
|
||||
root.createDeviceStorage.mount = document => factory({
|
||||
summary:document.querySelector('#device-storage-summary'),
|
||||
detail:document.querySelector('#device-storage-detail'),
|
||||
clearCachesButton:document.querySelector('#clear-device-caches'),
|
||||
clearAllButton:document.querySelector('#clear-private-device-data'),
|
||||
localStorage:root.localStorage,
|
||||
sessionStorage:root.sessionStorage,
|
||||
caches:root.caches,
|
||||
storageManager:root.navigator?.storage,
|
||||
privateDatabases:root.stackchainPrivateDatabases,
|
||||
inspectPrivateDatabases:root.inspectStackchainPrivateDatabases,
|
||||
clearPrivateDeviceData:root.stackchainPrivateDeviceData,
|
||||
});
|
||||
}
|
||||
})(typeof self !== 'undefined' ? self : this, function createDeviceStorage(options) {
|
||||
let privateItemCount = 0;
|
||||
let privateRecordCount = 0;
|
||||
let inventoryUnavailable = false;
|
||||
let fullClearArmed = false;
|
||||
let persistence = { state:'unavailable', detail:'Storage protection is unavailable in this browser.', label:'Unavailable' };
|
||||
const ownedCaches = async () => (await options.caches?.keys?.() || [])
|
||||
.filter(name => name.startsWith('stackchain-dashboard-'));
|
||||
|
||||
function ownedStorageCount(storage) {
|
||||
if (!storage) return 0;
|
||||
let count = 0;
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
if (storage.key(index)?.startsWith('stackchain.')) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function megabytes(value) {
|
||||
return Math.round((Number(value) || 0) / (1024 * 1024));
|
||||
}
|
||||
|
||||
async function inspectPersistence() {
|
||||
if (typeof options.storageManager?.persisted !== 'function' || typeof options.storageManager?.persist !== 'function') {
|
||||
persistence = {state:'unavailable', detail:'Storage protection is unavailable in this browser.', label:'Unavailable'};
|
||||
return persistence;
|
||||
}
|
||||
try {
|
||||
const protectedStorage = await options.storageManager.persisted();
|
||||
persistence = protectedStorage
|
||||
? {state:'complete', detail:'Offline work is protected from automatic browser storage cleanup.', label:'Protected'}
|
||||
: {state:'incomplete', detail:'Offline work uses best-effort browser storage and may be removed under storage pressure.', label:'Best effort'};
|
||||
} catch (_error) {
|
||||
persistence = {state:'incomplete', detail:'Storage protection could not be checked. Retry to protect offline work.', label:'Check failed'};
|
||||
}
|
||||
return persistence;
|
||||
}
|
||||
|
||||
function persistenceReadiness() {
|
||||
return {state:persistence.state, detail:persistence.detail};
|
||||
}
|
||||
|
||||
async function requestPersistence() {
|
||||
if (typeof options.storageManager?.persist !== 'function') return persistenceReadiness();
|
||||
let granted = false;
|
||||
try { granted = await options.storageManager.persist(); }
|
||||
catch (_error) {
|
||||
persistence = {state:'incomplete', detail:'Storage protection request failed. Offline work still uses best-effort storage; retry when ready.', label:'Request failed'};
|
||||
options.detail.textContent = options.detail.textContent.replace(/Storage protection: [^·]+/, 'Storage protection: Request failed ');
|
||||
return persistenceReadiness();
|
||||
}
|
||||
await refresh();
|
||||
if (!granted && persistence.state !== 'complete') {
|
||||
persistence = {state:'incomplete', detail:'The browser did not grant storage protection. Offline work still works but may be removed under storage pressure.', label:'Denied'};
|
||||
options.detail.textContent = options.detail.textContent.replace(/Storage protection: [^·]+/, 'Storage protection: Denied ');
|
||||
}
|
||||
return persistenceReadiness();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
let estimate = null;
|
||||
try { estimate = await options.storageManager?.estimate?.(); }
|
||||
catch (_error) { /* Storage estimates are optional. */ }
|
||||
await inspectPersistence();
|
||||
options.summary.textContent = estimate?.quota
|
||||
? `${megabytes(estimate.usage)} MB of ${megabytes(estimate.quota)} MB browser storage used.`
|
||||
: 'Browser storage usage is unavailable.';
|
||||
const itemCount = ownedStorageCount(options.localStorage)
|
||||
+ (options.sessionStorage === options.localStorage ? 0 : ownedStorageCount(options.sessionStorage));
|
||||
privateItemCount = itemCount;
|
||||
const inventory = await options.inspectPrivateDatabases?.(options.privateDatabases)
|
||||
|| { recordCount: 0, unavailable: true };
|
||||
privateRecordCount = inventory.recordCount;
|
||||
inventoryUnavailable = inventory.unavailable;
|
||||
const cacheCount = (await ownedCaches()).length;
|
||||
options.detail.textContent = `Storage protection: ${persistence.label} · ${itemCount} private browser item${itemCount === 1 ? '' : 's'} · `
|
||||
+ `${inventoryUnavailable ? 'private work status unknown' : `${privateRecordCount} private work record${privateRecordCount === 1 ? '' : 's'}`} · `
|
||||
+ `${cacheCount} cached app cop${cacheCount === 1 ? 'y' : 'ies'}`;
|
||||
return { itemCount, privateRecordCount, inventoryUnavailable, cacheCount };
|
||||
}
|
||||
|
||||
async function clearCaches() {
|
||||
options.clearCachesButton.disabled = true;
|
||||
try {
|
||||
await Promise.all((await ownedCaches()).map(name => options.caches.delete(name)));
|
||||
await refresh();
|
||||
options.summary.textContent = 'Cached app copies cleared. Private work was kept.';
|
||||
} catch (error) {
|
||||
options.summary.textContent = `Cached copies could not be cleared: ${error.message}`;
|
||||
} finally {
|
||||
options.clearCachesButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearAll() {
|
||||
if ((privateItemCount > 0 || privateRecordCount > 0 || inventoryUnavailable) && !fullClearArmed) {
|
||||
fullClearArmed = true;
|
||||
options.clearAllButton.textContent = 'Confirm: clear private work';
|
||||
options.summary.textContent = 'Private drafts or queued work may not be synced. Press confirm to clear them from this device.';
|
||||
return;
|
||||
}
|
||||
options.clearAllButton.disabled = true;
|
||||
try {
|
||||
await options.clearPrivateDeviceData();
|
||||
privateItemCount = 0;
|
||||
fullClearArmed = false;
|
||||
options.clearAllButton.textContent = 'Clear all private data';
|
||||
options.detail.textContent = `Storage protection: ${persistence.label} · 0 private browser items · 0 private work records · 0 cached app copies`;
|
||||
options.summary.textContent = 'All Stackchain private data was cleared from this device.';
|
||||
} catch (error) {
|
||||
options.summary.textContent = `Private data was not fully cleared: ${error.message}`;
|
||||
} finally {
|
||||
options.clearAllButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
options.clearCachesButton.addEventListener('click', clearCaches);
|
||||
options.clearAllButton.addEventListener('click', clearAll);
|
||||
return refresh();
|
||||
}
|
||||
|
||||
return { refresh, start, persistenceReadiness, requestPersistence };
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
function showDraftCapacityDialog(captures) {
|
||||
function showDraftCapacityDialog(captures, qs) {
|
||||
const state = captures.capacity();
|
||||
if (!state.full) return false;
|
||||
const oldest = state.oldest;
|
||||
@@ -11,7 +11,10 @@ function showDraftCapacityDialog(captures) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function bindDraftCapacityDialog() {
|
||||
function bindDraftCapacityDialog({
|
||||
qs, saveIssueCaptureDraft, closeCreateIssueSheet, mobileTaskDock, refreshMyWorkView,
|
||||
unfiledCaptures, currentIssueCaptureDraft, createIssueAttachmentController, issueCapture,
|
||||
}) {
|
||||
qs('#keep-editing-draft').addEventListener('click', () => {
|
||||
qs('#draft-capacity-sheet').hidden = true;
|
||||
qs('#save-unfiled-issue').focus();
|
||||
@@ -40,10 +43,10 @@ function bindDraftCapacityDialog() {
|
||||
const button = qs('#replace-oldest-draft');
|
||||
button.disabled = true;
|
||||
try {
|
||||
const draft = {
|
||||
title:qs('#create-issue-title').value.trim(), body:qs('#create-issue-body').value.trim(),
|
||||
attachment:await createIssueAttachmentController.serialize(),
|
||||
};
|
||||
const draft = currentIssueCaptureDraft();
|
||||
const evidence = await createIssueAttachmentController.serialize();
|
||||
Object.assign(draft,
|
||||
Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence});
|
||||
const saved = await unfiledCaptures.replaceOldest(draft, oldest.id);
|
||||
qs('#draft-capacity-sheet').hidden = true;
|
||||
issueCapture.clearDraft();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
function createDraftFilingSession({list}) {
|
||||
let orderedIds = [];
|
||||
let currentId = '';
|
||||
let reviewTrigger = null;
|
||||
|
||||
function visibleIds() {
|
||||
const available = new Set(list().map(item => item.id));
|
||||
@@ -49,17 +50,42 @@ function createDraftFilingSession({list}) {
|
||||
|
||||
const session = {start, current:state, next:()=>move(1), previous:()=>move(-1), removeCurrentAndNext};
|
||||
session.attach = (qs, dependencies) => {
|
||||
const openCapture = async captureId => {
|
||||
const openCapture = async (captureId, trigger = null) => {
|
||||
const resumed = await dependencies.captures.resume(captureId, dependencies.getLogin());
|
||||
dependencies.issueCapture.saveDraft(resumed);
|
||||
const issueCapture = dependencies.capture?.() || dependencies.issueCapture;
|
||||
issueCapture.saveDraft(resumed);
|
||||
dependencies.setResumedId(captureId);
|
||||
await dependencies.openSheet();
|
||||
dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment);
|
||||
await dependencies.openSheet(false);
|
||||
const evidence = resumed.attachments || resumed.attachment;
|
||||
dependencies.attachment[evidence ? 'restore' : 'clear'](evidence);
|
||||
dependencies.setFilingMode(true);
|
||||
session.render();
|
||||
if (trigger) {
|
||||
await dependencies.review(resumed);
|
||||
reviewTrigger = trigger;
|
||||
const submitter = resumed.completionIntent === 'create-and-start' ?
|
||||
qs('#create-and-start-issue') : qs('#submit-new-issue');
|
||||
const disabled = submitter.disabled;
|
||||
submitter.disabled = false;
|
||||
qs('#create-issue-form').requestSubmit(submitter);
|
||||
submitter.disabled = disabled;
|
||||
}
|
||||
};
|
||||
return Object.assign(session, {
|
||||
nextCapture() { const state = session.current(); return state ? openCapture(state.id) : null; },
|
||||
reviewCapture(captureId, trigger) {
|
||||
session.start(captureId);
|
||||
return openCapture(captureId, trigger);
|
||||
},
|
||||
open(captureId, trigger = null) {
|
||||
session.start(captureId);
|
||||
return openCapture(captureId, trigger);
|
||||
},
|
||||
takeTrigger() {
|
||||
const trigger = reviewTrigger;
|
||||
reviewTrigger = null;
|
||||
return trigger;
|
||||
},
|
||||
render() {
|
||||
const state = session.current();
|
||||
qs('#draft-filing-session').hidden = !state;
|
||||
|
||||
+23
-4
@@ -155,6 +155,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
.filter(Boolean).join('\n\n');
|
||||
const ownerLogin = String(item.ownerLogin || '').trim();
|
||||
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
|
||||
const checklistConflict = !quarantined && item.kind === 'issue-content' &&
|
||||
item.status === 'attention' && typeof item.baseBody === 'string';
|
||||
return {
|
||||
id: 'stackchain.authored-outbox.v1:' + item.id,
|
||||
outbox_id: item.id,
|
||||
@@ -162,7 +164,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
kind: 'authored-outbox',
|
||||
status: item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' :
|
||||
(item.status === 'authorization' || isClosure ? 'authorization' : 'queued')),
|
||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||
label: checklistConflict ? 'Checklist conflict' : item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||
(item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
|
||||
(isReview ? (item.status === 'authorization' ? 'Review awaiting authorization' : 'Queued review') :
|
||||
(isClosure ? 'Awaiting authorization' : 'Queued message'))),
|
||||
@@ -172,6 +174,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
title: target,
|
||||
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
|
||||
copy_text: copyText,
|
||||
checklist_conflict: checklistConflict,
|
||||
...(isReview ? { head_sha: item.expectedHeadSha } : {}),
|
||||
quarantined,
|
||||
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
||||
@@ -237,7 +240,14 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
}
|
||||
|
||||
function partition(items = list()) {
|
||||
const deliveries = items.filter(item => item.kind === 'issue-outbox' || item.kind === 'authored-outbox');
|
||||
const isDelivery = item => item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
|
||||
const needsAction = item => isDelivery(item) && (
|
||||
item.status === 'attention' || item.status === 'authorization' || item.status === 'completion' ||
|
||||
item.delivery_state === 'uncertain' || item.quarantined
|
||||
);
|
||||
const deliveries = items.filter(isDelivery).sort((left, right) =>
|
||||
Number(needsAction(right)) - Number(needsAction(left))
|
||||
);
|
||||
const drafts = items.filter(item => item.kind !== 'issue-outbox' && item.kind !== 'authored-outbox');
|
||||
const counts = deliveries.reduce((summary, item) => {
|
||||
const state = item.status === 'sending' ? 'sending' : (item.status === 'attention' ? 'attention' :
|
||||
@@ -248,10 +258,19 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
const retryable = deliveries.filter(item =>
|
||||
item.status === 'queued' && !item.authorization_required && !item.quarantined && item.delivery_state !== 'uncertain'
|
||||
);
|
||||
return { drafts, deliveries, counts, retryable };
|
||||
return { drafts, deliveries, counts, retryable, actionable:deliveries.filter(needsAction).length };
|
||||
}
|
||||
|
||||
return { list, discard, partition };
|
||||
function deliveryLabel(item) {
|
||||
if (item.quarantined) return 'Identity protected';
|
||||
if (item.status === 'completion') return 'Created · ready to start';
|
||||
if (item.status === 'attention') return 'Needs attention';
|
||||
if (item.status === 'sending') return 'Sending';
|
||||
if (item.status === 'authorization') return 'Awaiting authorization';
|
||||
return 'Queued for sync';
|
||||
}
|
||||
|
||||
return { list, discard, partition, deliveryLabel };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftInbox;
|
||||
@@ -0,0 +1,9 @@
|
||||
function filedClaimEligible(item, detail) {
|
||||
return Boolean(
|
||||
item?.is_filed && !item?.is_assigned && !item?.is_completed &&
|
||||
item?.state === 'open' && detail?.state === 'open' &&
|
||||
Array.isArray(detail?.assignees) && detail.assignees.length === 0
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = filedClaimEligible;
|
||||
@@ -0,0 +1,292 @@
|
||||
(function (root, factory) {
|
||||
const exports = factory();
|
||||
if (typeof module === 'object' && module.exports) {
|
||||
module.exports = exports.createFollowing;
|
||||
module.exports.attachFollowing = exports.attachFollowing;
|
||||
module.exports.planningDisposition = exports.planningDisposition;
|
||||
} else {
|
||||
root.createFollowing = exports.createFollowing;
|
||||
root.attachFollowing = exports.attachFollowing;
|
||||
root.followingPlanningDisposition = exports.planningDisposition;
|
||||
}
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
function planningDisposition(detail, controls) {
|
||||
const active = detail?.following === true && detail.kind === 'issue' && detail.state === 'open' &&
|
||||
Boolean(detail.claimable || detail.assigned_to_me);
|
||||
const result = active ? {active:true,today:'Add to Today & next',later:'Later & next',week:'Week Ahead & next'} : {active:false};
|
||||
if (!controls && globalThis.document) {
|
||||
const query = selector => globalThis.document.querySelector(selector);
|
||||
controls = {root:query('.search-preview-primary-actions'),claim:query('#claim-search-result'),
|
||||
today:query('#queue-search-result'),later:query('#defer-search-result'),week:query('#plan-search-result'),
|
||||
start:query('#start-search-result'),watch:query('#watch-search-result')};
|
||||
}
|
||||
controls?.root?.classList.toggle('following-disposition-mode', active);
|
||||
if (active && controls) {
|
||||
controls.claim.hidden = controls.start.hidden = controls.watch.hidden = true;
|
||||
controls.today.textContent = result.today;
|
||||
controls.later.textContent = result.later;
|
||||
controls.week.textContent = result.week;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function createFollowing(options) {
|
||||
let generation = 0;
|
||||
let snapshot = {revision:0, items:[]};
|
||||
let review = null;
|
||||
|
||||
function sameItem(left, right) {
|
||||
return (left?.kind || 'issue') === (right?.kind || 'issue') &&
|
||||
left?.repository === right?.repository && Number(left?.number) === Number(right?.number);
|
||||
}
|
||||
|
||||
function publish(status, error) {
|
||||
const state = {status, revision:snapshot.revision, items:[...snapshot.items]};
|
||||
state.degraded = snapshot.degraded === true;
|
||||
state.refreshFailures = Number(snapshot.refreshFailures) || 0;
|
||||
if (review?.acknowledged.size) state.reviewSummary = {
|
||||
reviewed:review.acknowledged.size,
|
||||
remaining:snapshot.items.filter(item => item.has_unseen_change === true).length,
|
||||
};
|
||||
if (error) state.error = error;
|
||||
options.render?.(state);
|
||||
if (status === 'ready') options.onCount?.(
|
||||
snapshot.items.filter(item => item.has_unseen_change === true).length);
|
||||
return state;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const requestGeneration = ++generation;
|
||||
publish('loading');
|
||||
try {
|
||||
const result = await options.fetchJson('api/v1/following', {headers:{Accept:'application/json'}});
|
||||
if (requestGeneration !== generation) return snapshot;
|
||||
snapshot = {
|
||||
revision:Number(result?.revision) || 0,
|
||||
items:Array.isArray(result?.items) ? result.items.slice(0, 50)
|
||||
.map(item => ({...item, kind:item.kind === 'pull' ? 'pull' : 'issue'})) : [],
|
||||
degraded:result?.degraded === true,
|
||||
refreshFailures:Number(result?.refresh_failures) || 0,
|
||||
};
|
||||
publish('ready');
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
if (requestGeneration === generation) publish('error', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function acknowledge(item) {
|
||||
const current = snapshot.items.find(candidate => sameItem(candidate, item) &&
|
||||
candidate.updated_at === item?.updated_at);
|
||||
if (!current || current.has_unseen_change !== true || typeof options.onAcknowledge !== 'function') return false;
|
||||
await options.onAcknowledge(current);
|
||||
current.has_unseen_change = false;
|
||||
review?.acknowledged.add(current.kind + ':' + current.repository + '#' + current.number + '@' + current.updated_at);
|
||||
publish('ready');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function keepForLater(item) {
|
||||
const exact = candidate => sameItem(candidate, item) && candidate.updated_at === item?.updated_at;
|
||||
const current = snapshot.items.find(exact);
|
||||
const index = review?.active ? review.items.findIndex(exact) : -1;
|
||||
if (!current || index < 0 || typeof options.onKeep !== 'function') return null;
|
||||
await options.onKeep(current);
|
||||
current.has_unseen_change = true;
|
||||
review.acknowledged.delete(current.kind + ':' + current.repository + '#' +
|
||||
current.number + '@' + current.updated_at);
|
||||
review.items.splice(index, 1);
|
||||
const next = review.items[index] || null;
|
||||
if (!next) review.active = false;
|
||||
publish('ready');
|
||||
if (next) await open(snapshot.items.findIndex(candidate => sameItem(candidate, next)));
|
||||
return next;
|
||||
}
|
||||
|
||||
async function open(index) {
|
||||
const item = snapshot.items[Number(index)];
|
||||
if (!item) return false;
|
||||
await options.onOpen?.({...item, following:true});
|
||||
await acknowledge(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function startReview() {
|
||||
const items = snapshot.items
|
||||
.filter(item => item.has_unseen_change === true)
|
||||
.map(item => ({...item, following:true}));
|
||||
if (!items.length) return false;
|
||||
review = {items, more:false, active:true, acknowledged:new Set()};
|
||||
await open(snapshot.items.indexOf(snapshot.items.find(item => sameItem(item, items[0]))));
|
||||
return true;
|
||||
}
|
||||
|
||||
function finishReview() {
|
||||
const completed = Boolean(review?.completed || (review?.active &&
|
||||
!snapshot.items.some(item => item.has_unseen_change === true)));
|
||||
if (review) review.active = false;
|
||||
if (completed && !review.completed) {
|
||||
review.completed = true;
|
||||
options.onReviewComplete?.();
|
||||
}
|
||||
publish('ready');
|
||||
return completed;
|
||||
}
|
||||
|
||||
function retire(item) {
|
||||
const same = candidate => sameItem(candidate, item);
|
||||
const index = review?.active ? review.items.findIndex(same) : -1;
|
||||
snapshot.items = snapshot.items.filter(candidate => !same(candidate));
|
||||
if (index < 0) {
|
||||
publish('ready');
|
||||
return null;
|
||||
}
|
||||
review.items.splice(index, 1);
|
||||
const next = review.items[index] || null;
|
||||
if (!next) {
|
||||
review.active = false;
|
||||
if (!review.completed) {
|
||||
review.completed = true;
|
||||
options.onReviewComplete?.();
|
||||
}
|
||||
}
|
||||
publish('ready');
|
||||
return next ? {...next} : null;
|
||||
}
|
||||
|
||||
return {
|
||||
load, open, startReview, previewLoaded:acknowledge, keepForLater, finishReview, retire,
|
||||
session:() => review?.active ? {items:[...review.items], more:false} : null,
|
||||
items:() => snapshot.items.map(item => ({...item})),
|
||||
count:() => snapshot.items.length,
|
||||
};
|
||||
}
|
||||
|
||||
function attachFollowing(onOpen, hooks = {}) {
|
||||
const document = globalThis.document;
|
||||
const query = selector => document.querySelector(selector);
|
||||
const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, character =>
|
||||
({'&':'&','<':'<','>':'>','"':'"',"'":'''})[character]);
|
||||
const formatTime = value => new Date(value).toLocaleString();
|
||||
const fetchJson = async (url, options) => {
|
||||
const response = await fetch(url, options);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.detail || payload.error || 'Following is temporarily unavailable.');
|
||||
return payload;
|
||||
};
|
||||
const changeRevision = (item, action) => {
|
||||
const [owner, repo] = item.repository.split('/');
|
||||
return fetchJson('api/v1/following/' + encodeURIComponent(owner) + '/' +
|
||||
encodeURIComponent(repo) + '/issues/' + item.number + '/' + action +
|
||||
'?kind=' + encodeURIComponent(item.kind), {
|
||||
method:'PUT', headers:{'Content-Type':'application/json', Accept:'application/json'},
|
||||
body:JSON.stringify({updated_at:item.updated_at}),
|
||||
});
|
||||
};
|
||||
let feature;
|
||||
let activeReviewItem = null;
|
||||
const show = () => query('#following-sheet').open || query('#following-sheet').showModal();
|
||||
function render(state) {
|
||||
hooks.onStatus?.(state.status);
|
||||
const list = query('#following-list');
|
||||
const status = query('#following-status');
|
||||
const reviewButton = query('#review-following');
|
||||
query('#retry-following').hidden = state.status !== 'error';
|
||||
if (state.status === 'loading') return void (status.textContent = 'Loading watched items…');
|
||||
if (state.status === 'error') return void (status.textContent = state.error?.message || 'Following is temporarily unavailable.');
|
||||
const unseen = state.items.filter(item => item.has_unseen_change === true).length;
|
||||
reviewButton.hidden = unseen === 0;
|
||||
reviewButton.textContent = unseen === 1 ? 'Review new activity' : 'Review ' + unseen + ' new changes';
|
||||
status.textContent = (state.reviewSummary
|
||||
? 'Reviewed ' + state.reviewSummary.reviewed + ' changes · ' + state.reviewSummary.remaining + ' still need review. '
|
||||
: '') + (state.degraded ? 'Some watched items could not be refreshed. Showing last known details. ' : '') + (state.items.length
|
||||
? state.items.length + (state.items.length === 1 ? ' watched item.' : ' watched items.')
|
||||
: 'No watched items yet. Watch an issue or pull request from Search to keep it here.');
|
||||
list.innerHTML = state.items.map((item, index) =>
|
||||
'<button class="following-card' + (item.has_unseen_change ? ' has-unseen-change' : '') +
|
||||
'" type="button" data-following-index="' + index + '"><span>' +
|
||||
(item.has_unseen_change ? '<em>' + escapeHtml(item.change_summary || 'New activity') + '</em>' : '') + '<strong>' +
|
||||
escapeHtml(item.title) + '</strong><small>' + escapeHtml(
|
||||
(item.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + item.repository + ' #' + item.number +
|
||||
' · ' + item.state + ' · ' + formatTime(item.updated_at)) +
|
||||
'</small></span><span aria-hidden="true">›</span></button>').join('');
|
||||
list.querySelectorAll('[data-following-index]').forEach(button => button.addEventListener('click', () => {
|
||||
query('#following-sheet').close();
|
||||
feature.open(Number(button.dataset.followingIndex)).catch(() => {});
|
||||
}));
|
||||
}
|
||||
feature = createFollowing({
|
||||
fetchJson, render,
|
||||
onCount:count => {
|
||||
const value = query('[data-mobile-queue-count="following"]');
|
||||
value.textContent = count;
|
||||
value.closest('button').setAttribute('aria-label', 'Following, ' + count +
|
||||
(count === 1 ? ' unseen change' : ' unseen changes'));
|
||||
hooks.onCount?.(count, feature.items());
|
||||
},
|
||||
onOpen:item => {
|
||||
activeReviewItem = item;
|
||||
return onOpen(item);
|
||||
},
|
||||
onReviewComplete:hooks.onReviewComplete,
|
||||
onAcknowledge:item => changeRevision(item, 'seen'),
|
||||
onKeep:item => changeRevision(item, 'keep'),
|
||||
});
|
||||
query('#close-following').addEventListener('click', () => query('#following-sheet').close());
|
||||
query('#review-following').addEventListener('click', () => {
|
||||
query('#following-sheet').close();
|
||||
feature.startReview().catch(() => {
|
||||
show();
|
||||
});
|
||||
});
|
||||
query('#retry-following').addEventListener('click', () => feature.load().catch(() => {}));
|
||||
const keepButton = query('#keep-following-for-later');
|
||||
keepButton.addEventListener('click', async () => {
|
||||
const status = query('#keep-following-status');
|
||||
keepButton.disabled = true;
|
||||
status.textContent = 'Keeping this change for later…';
|
||||
try {
|
||||
if (!await feature.keepForLater(activeReviewItem)) query('#close-search-preview').click();
|
||||
status.textContent = 'Kept for later.';
|
||||
} catch (_) {
|
||||
status.textContent = 'Could not keep this change for later. Retry when ready.';
|
||||
keepButton.disabled = false;
|
||||
}
|
||||
});
|
||||
return {
|
||||
load:feature.load,
|
||||
review:feature.startReview,
|
||||
async route() {
|
||||
await feature.load().then(feature.startReview).catch(() => false) || show();
|
||||
},
|
||||
open() {
|
||||
show();
|
||||
feature.load().catch(() => {});
|
||||
return 'opened-following';
|
||||
},
|
||||
session:feature.session,
|
||||
previewLoaded:feature.previewLoaded,
|
||||
keepForLater:feature.keepForLater,
|
||||
retire:feature.retire,
|
||||
preview(state) {
|
||||
const disposition = planningDisposition(state?.detail || state?.item);
|
||||
keepButton.hidden = state?.item?.following !== true || disposition.active;
|
||||
keepButton.disabled = false;
|
||||
if (keepButton.hidden || state.status === 'loading') query('#keep-following-status').textContent = '';
|
||||
},
|
||||
returnToFollowing() {
|
||||
const completed = feature.finishReview();
|
||||
if (completed) return 'completed-following';
|
||||
show();
|
||||
globalThis.requestAnimationFrame?.(() => {
|
||||
const target = query('#review-following:not([hidden])') || query('.following-card') || query('#close-following');
|
||||
target?.focus();
|
||||
});
|
||||
return 'returned-following';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {createFollowing, attachFollowing, planningDisposition};
|
||||
});
|
||||
+1262
-104
File diff suppressed because it is too large.
Load diff
+277
-118
@@ -1,55 +1,28 @@
|
||||
(function(root, factory) {
|
||||
const api = factory();
|
||||
const review = typeof module === 'object' && module.exports ? require('./issue-evidence-review.js') : root.issueEvidenceReview;
|
||||
const editor = typeof module === 'object' && module.exports ? require('./issue-evidence-editor.js') : root.issueEvidenceEditor;
|
||||
const api = factory(review, editor);
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.issueAttachment = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function() {
|
||||
})(typeof self !== 'undefined' ? self : this, function(issueEvidenceReview, issueEvidenceEditor) {
|
||||
'use strict';
|
||||
|
||||
const MAX_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_PIXELS = issueEvidenceReview.MAX_PIXELS;
|
||||
const MAX_FILES = 5;
|
||||
const MAX_FILES_MESSAGE = 'Up to 5 screenshots. Remove one before adding another.';
|
||||
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
|
||||
function namedBlob(blob, name) {
|
||||
if (typeof File === 'function') {
|
||||
return new File([blob], name, { type: blob.type });
|
||||
}
|
||||
Object.defineProperty(blob, 'name', { value: name, configurable: true });
|
||||
return blob;
|
||||
function normalizeNote(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
||||
}
|
||||
|
||||
async function optimizeImage(file, environment = {}) {
|
||||
if (file.size <= MAX_BYTES) return file;
|
||||
const decode = environment.createImageBitmap || globalThis.createImageBitmap;
|
||||
const makeCanvas = environment.createCanvas || (() => document.createElement('canvas'));
|
||||
if (typeof decode !== 'function') {
|
||||
throw new Error('This browser cannot optimize the screenshot. Try cropping it and choose it again.');
|
||||
}
|
||||
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await decode(file);
|
||||
const canvas = makeCanvas();
|
||||
const context = canvas && canvas.getContext && canvas.getContext('2d');
|
||||
if (!context || !bitmap.width || !bitmap.height) throw new Error('decode');
|
||||
let scale = Math.min(1, Math.sqrt(MAX_BYTES / file.size) * 0.92);
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
||||
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
||||
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||
const blob = await new Promise(resolve => {
|
||||
canvas.toBlob(resolve, file.type, file.type === 'image/png' ? undefined : 0.86);
|
||||
});
|
||||
if (!blob) throw new Error('encode');
|
||||
if (blob.size > 0 && blob.size <= MAX_BYTES) return namedBlob(blob, file.name);
|
||||
scale *= 0.8;
|
||||
}
|
||||
} catch (_error) {
|
||||
throw new Error('The screenshot could not be optimized. Try cropping it and choose it again.');
|
||||
} finally {
|
||||
if (bitmap && typeof bitmap.close === 'function') bitmap.close();
|
||||
}
|
||||
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
|
||||
function escapeMarkdown(value) {
|
||||
return value.replace(/([\\`*_[\]{}()<>#+\-.!|])/g, '\\$1');
|
||||
}
|
||||
|
||||
const optimizeImage = issueEvidenceReview.optimizeImage;
|
||||
|
||||
function multipart(attachment) {
|
||||
let blob = attachment?.blob;
|
||||
if (!blob && attachment?.data) {
|
||||
@@ -65,32 +38,44 @@
|
||||
|
||||
function create(options) {
|
||||
const upload = options.upload;
|
||||
const maxFiles = options.maxFiles === MAX_FILES ? MAX_FILES : 1;
|
||||
const inspectPixels = options.inspectPixels === true;
|
||||
const optimizeSelectedImage = options.optimizeImage || optimizeImage;
|
||||
const onCheckpoint = options.onCheckpoint;
|
||||
const createOperationId = options.createOperationId || (() => {
|
||||
if (typeof globalThis !== 'undefined' && globalThis.crypto?.randomUUID) {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||
});
|
||||
let selected = null;
|
||||
let confirmed = null;
|
||||
let serialized = null;
|
||||
let operationId = null;
|
||||
let selected = [];
|
||||
let selectionGeneration = 0;
|
||||
let restoring = false;
|
||||
|
||||
function changed() {
|
||||
if (!restoring && typeof onCheckpoint === 'function') {
|
||||
Promise.resolve().then(onCheckpoint).catch(() => { /* Caller surfaces durable-storage failures. */ });
|
||||
}
|
||||
}
|
||||
|
||||
function commitSelection(file) {
|
||||
if (!file || !IMAGE_TYPES.has(file.type) || !Number.isFinite(file.size) ||
|
||||
file.size <= 0 || file.size > MAX_BYTES) {
|
||||
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
|
||||
}
|
||||
selected = file;
|
||||
confirmed = null;
|
||||
serialized = null;
|
||||
operationId = createOperationId();
|
||||
if (selected.length >= maxFiles) {
|
||||
if (maxFiles === 1) selected = [];
|
||||
else throw new Error(MAX_FILES_MESSAGE);
|
||||
}
|
||||
selected.push({file, note:'', confirmed:null, serialized:null, operationId:createOperationId()});
|
||||
changed();
|
||||
return state();
|
||||
}
|
||||
|
||||
function select(file) {
|
||||
if (selected.length >= maxFiles && maxFiles > 1) {
|
||||
throw new Error(MAX_FILES_MESSAGE);
|
||||
}
|
||||
const generation = ++selectionGeneration;
|
||||
if (!file || !IMAGE_TYPES.has(file.type)) {
|
||||
throw new Error('Choose a PNG, JPEG, or WebP screenshot.');
|
||||
@@ -98,7 +83,7 @@
|
||||
if (!Number.isFinite(file.size) || file.size <= 0) {
|
||||
throw new Error('Choose a screenshot that is 2 MB or smaller.');
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
if (file.size > MAX_BYTES || inspectPixels) {
|
||||
return Promise.resolve(optimizeSelectedImage(file)).then(optimized => {
|
||||
if (generation !== selectionGeneration) return state();
|
||||
return commitSelection(optimized);
|
||||
@@ -109,92 +94,193 @@
|
||||
|
||||
function clear() {
|
||||
selectionGeneration += 1;
|
||||
selected = null;
|
||||
confirmed = null;
|
||||
serialized = null;
|
||||
operationId = null;
|
||||
selected = [];
|
||||
}
|
||||
|
||||
function remove(index) {
|
||||
const position = Number(index);
|
||||
if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
|
||||
selectionGeneration += 1;
|
||||
selected.splice(position, 1);
|
||||
changed();
|
||||
return state();
|
||||
}
|
||||
|
||||
function move(fromIndex, toIndex) {
|
||||
const from = Number(fromIndex);
|
||||
const to = Number(toIndex);
|
||||
if (!Number.isInteger(from) || !Number.isInteger(to) || from < 0 ||
|
||||
from >= selected.length || to < 0 || to >= selected.length || from === to) return state();
|
||||
const [item] = selected.splice(from, 1);
|
||||
selected.splice(to, 0, item);
|
||||
changed();
|
||||
return state();
|
||||
}
|
||||
|
||||
function replace(index, file) {
|
||||
const position = Number(index);
|
||||
if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
|
||||
if (!file || !IMAGE_TYPES.has(file.type) || !Number.isFinite(file.size) ||
|
||||
file.size <= 0 || file.size > MAX_BYTES) {
|
||||
throw new Error('The edited screenshot must be a PNG, JPEG, or WebP image no larger than 2 MB.');
|
||||
}
|
||||
selectionGeneration += 1;
|
||||
selected[position] = {
|
||||
...selected[position], file, confirmed:null, serialized:null, operationId:createOperationId(),
|
||||
};
|
||||
changed();
|
||||
return state();
|
||||
}
|
||||
|
||||
function setNote(index, value) {
|
||||
const position = Number(index);
|
||||
if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state();
|
||||
selected[position].note = normalizeNote(value);
|
||||
selected[position].serialized = null;
|
||||
changed();
|
||||
return state();
|
||||
}
|
||||
|
||||
function note(index) {
|
||||
const position = Number(index);
|
||||
return Number.isInteger(position) && selected[position] ? selected[position].note : '';
|
||||
}
|
||||
|
||||
function restore(value) {
|
||||
restoring = true;
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
clear();
|
||||
if (value.length > maxFiles) throw new Error(MAX_FILES_MESSAGE);
|
||||
value.forEach(attachment => restoreOne(attachment));
|
||||
return state();
|
||||
}
|
||||
clear();
|
||||
restoreOne(value);
|
||||
return state();
|
||||
} finally { restoring = false; }
|
||||
}
|
||||
|
||||
function restoreOne(value) {
|
||||
const contentType = String(value?.contentType || '');
|
||||
const filename = String(value?.filename || '');
|
||||
const blob = value?.blob;
|
||||
const data = String(value?.data || '');
|
||||
if (!blob && !data) {
|
||||
clear();
|
||||
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
|
||||
}
|
||||
const padding = (data.match(/=*$/) || [''])[0].length;
|
||||
const size = blob ? Number(blob.size) : Math.max(1, Math.floor(data.length * 3 / 4) - padding);
|
||||
select({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) });
|
||||
serialized = blob ? { filename, contentType, blob } : { filename, contentType, data };
|
||||
return state();
|
||||
commitSelection({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) });
|
||||
const item = selected[selected.length - 1];
|
||||
item.note = normalizeNote(value?.note);
|
||||
item.serialized = {
|
||||
...(blob ? { filename, contentType, blob } : { filename, contentType, data }),
|
||||
...(item.note ? {note:item.note} : {}),
|
||||
};
|
||||
const operationId = String(value?.operationId || '').slice(0, 128);
|
||||
if (operationId) item.operationId = operationId;
|
||||
const markdown = String(value?.confirmed?.markdown || '');
|
||||
if (markdown) item.confirmed = { markdown };
|
||||
}
|
||||
|
||||
function state() {
|
||||
return selected ? {
|
||||
name: selected.name,
|
||||
size: selected.size,
|
||||
uploaded: Boolean(confirmed),
|
||||
} : null;
|
||||
const values = selected.map(item => ({
|
||||
name: item.file.name,
|
||||
size: item.file.size,
|
||||
uploaded: Boolean(item.confirmed),
|
||||
}));
|
||||
return values.length > 1 ? values : (values[0] || null);
|
||||
}
|
||||
|
||||
function serializeItem(item) {
|
||||
if (!item.serialized) item.serialized = {
|
||||
filename:item.file.name, contentType:item.file.type, blob:item.file.blob || item.file,
|
||||
...(item.note ? {note:item.note} : {}),
|
||||
};
|
||||
return {
|
||||
...item.serialized,
|
||||
operationId:item.operationId,
|
||||
...(item.confirmed?.markdown ? { confirmed:{ markdown:item.confirmed.markdown } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function serialize() {
|
||||
if (!selected) return null;
|
||||
if (!serialized) {
|
||||
serialized = {
|
||||
filename: selected.name,
|
||||
contentType: selected.type,
|
||||
blob: selected.blob || selected,
|
||||
};
|
||||
}
|
||||
return { ...serialized };
|
||||
if (!selected.length) return null;
|
||||
const values = selected.map(serializeItem);
|
||||
return values.length > 1 ? values : values[0];
|
||||
}
|
||||
|
||||
async function prepareComment(item, body) {
|
||||
async function prepareComment(target, body) {
|
||||
const text = String(body || '').trim();
|
||||
if (!selected) return text;
|
||||
if (!confirmed) {
|
||||
const attachment = await serialize();
|
||||
confirmed = await upload({
|
||||
repository: item.repository,
|
||||
number: item.number,
|
||||
filename: attachment.filename,
|
||||
content_type: attachment.contentType,
|
||||
...(attachment.blob ? { blob: attachment.blob } : { data: attachment.data }),
|
||||
operation_id: operationId,
|
||||
});
|
||||
if (!confirmed || typeof confirmed.markdown !== 'string' || !confirmed.markdown) {
|
||||
confirmed = null;
|
||||
throw new Error('The server did not confirm the screenshot upload.');
|
||||
if (!selected.length) return text;
|
||||
const markdown = [];
|
||||
for (const evidence of selected) {
|
||||
if (!evidence.confirmed) {
|
||||
const attachment = serializeItem(evidence);
|
||||
evidence.confirmed = await upload({
|
||||
repository: target.repository,
|
||||
number: target.number,
|
||||
filename: attachment.filename,
|
||||
content_type: attachment.contentType,
|
||||
...(attachment.blob ? { blob: attachment.blob } : { data: attachment.data }),
|
||||
operation_id: evidence.operationId,
|
||||
});
|
||||
if (!evidence.confirmed || typeof evidence.confirmed.markdown !== 'string' || !evidence.confirmed.markdown) {
|
||||
evidence.confirmed = null;
|
||||
throw new Error('The server did not confirm the screenshot upload.');
|
||||
}
|
||||
await onCheckpoint?.();
|
||||
}
|
||||
if (evidence.note) {
|
||||
markdown.push('**Screenshot ' + (markdown.length + 1) + ' — ' + escapeMarkdown(evidence.note) + '**\n\n' +
|
||||
evidence.confirmed.markdown);
|
||||
} else markdown.push(evidence.confirmed.markdown);
|
||||
}
|
||||
return text ? text + '\n\n' + confirmed.markdown : confirmed.markdown;
|
||||
const evidence = markdown.join('\n\n');
|
||||
return text ? text + '\n\n' + evidence : evidence;
|
||||
}
|
||||
|
||||
return { select, restore, clear, state, serialize, prepareComment };
|
||||
return { select, restore, remove, move, replace, setNote, note, clear, state, serialize, prepareComment };
|
||||
}
|
||||
|
||||
function mount(options) {
|
||||
const controller = create(options);
|
||||
const inputs = Array.from(new Set((options.inputs || [options.input]).filter(Boolean)));
|
||||
if (!inputs.includes(options.input)) inputs.push(options.input);
|
||||
const controller = create({
|
||||
...options, inspectPixels:true,
|
||||
maxFiles:options.maxFiles || (options.input?.multiple ? MAX_FILES : 1),
|
||||
});
|
||||
const clearSelection = controller.clear;
|
||||
const restoreSelection = controller.restore;
|
||||
const reviewEnabled = Boolean(options.tray && options.earlier && options.later);
|
||||
let previewUrl = '';
|
||||
let selectionSequence = 0;
|
||||
const review = reviewEnabled ? issueEvidenceReview.create({
|
||||
...options, edit:options.editor?.edit, controller,
|
||||
}) : null;
|
||||
|
||||
function setBusy(busy) {
|
||||
options.input.disabled = Boolean(busy);
|
||||
options.remove.disabled = Boolean(busy);
|
||||
function setBusy(value) {
|
||||
inputs.forEach(input => { input.disabled = Boolean(value); });
|
||||
options.remove.disabled = Boolean(value);
|
||||
if (options.editor?.edit) options.editor.edit.disabled = Boolean(value) || !controller.state();
|
||||
review?.setBusy(value);
|
||||
}
|
||||
|
||||
function clearPreview() {
|
||||
selectionSequence += 1;
|
||||
if (previewUrl) options.revokeObjectURL(previewUrl);
|
||||
previewUrl = '';
|
||||
review?.clear();
|
||||
if (options.editor?.edit) options.editor.edit.disabled = true;
|
||||
options.image.src = '';
|
||||
options.preview.hidden = true;
|
||||
options.input.value = '';
|
||||
options.input.disabled = false;
|
||||
inputs.forEach(input => {
|
||||
input.value = '';
|
||||
input.disabled = false;
|
||||
});
|
||||
clearSelection();
|
||||
options.onChange?.(controller.state());
|
||||
}
|
||||
|
||||
function showPreview(file, optimized) {
|
||||
@@ -203,57 +289,130 @@
|
||||
options.image.src = previewUrl;
|
||||
options.meta.textContent = file.name + ' · ' + Math.ceil(file.size / 1024) + ' KB';
|
||||
options.preview.hidden = false;
|
||||
if (options.editor?.edit) options.editor.edit.disabled = false;
|
||||
options.status.textContent = optimized ? 'Screenshot optimized and ready to upload.' :
|
||||
(options.readyMessage || 'Screenshot ready to upload with this comment.');
|
||||
}
|
||||
|
||||
options.input.addEventListener('change', event => {
|
||||
const file = event.target.files && event.target.files[0];
|
||||
function selectFromInput(event) {
|
||||
const sourceInput = event.target;
|
||||
const files = Array.from(sourceInput.files || []);
|
||||
const sequence = ++selectionSequence;
|
||||
let result;
|
||||
try {
|
||||
result = controller.select(file);
|
||||
} catch (error) {
|
||||
if (!files.length) return;
|
||||
let first;
|
||||
try { first = controller.select(files[0]); }
|
||||
catch (error) {
|
||||
options.status.textContent = error.message;
|
||||
options.input.value = '';
|
||||
sourceInput.value = '';
|
||||
return;
|
||||
}
|
||||
if (!result || typeof result.then !== 'function') {
|
||||
showPreview(file, false);
|
||||
if (!reviewEnabled && files.length === 1 && (!first || typeof first.then !== 'function')) {
|
||||
showPreview(files[0], false);
|
||||
return;
|
||||
}
|
||||
options.status.textContent = 'Optimizing screenshot…';
|
||||
options.input.disabled = true;
|
||||
return result.then(() => controller.serialize()).then(value => {
|
||||
if (sequence === selectionSequence) showPreview(value.blob, true);
|
||||
let optimized = Boolean(first && typeof first.then === 'function');
|
||||
if (optimized) {
|
||||
options.status.textContent = 'Optimizing screenshot…';
|
||||
inputs.forEach(input => { input.disabled = true; });
|
||||
}
|
||||
const selectAll = files.slice(1).reduce((pending, file) => pending.then(async () => {
|
||||
const result = controller.select(file);
|
||||
if (result && typeof result.then === 'function') {
|
||||
optimized = true;
|
||||
options.status.textContent = 'Optimizing screenshot…';
|
||||
inputs.forEach(input => { input.disabled = true; });
|
||||
await result;
|
||||
}
|
||||
}), Promise.resolve(first));
|
||||
return selectAll.then(() => controller.serialize()).then(value => {
|
||||
if (sequence !== selectionSequence) return;
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
const latest = values[values.length - 1];
|
||||
if (reviewEnabled) {
|
||||
review.render(values, values.length - 1, optimized);
|
||||
} else {
|
||||
showPreview(latest.blob, optimized);
|
||||
if (values.length > 1) {
|
||||
options.meta.textContent = values.length + ' screenshots ready · latest: ' + latest.filename;
|
||||
options.status.textContent = values.length + ' screenshots ready to file in this order.';
|
||||
}
|
||||
}
|
||||
options.onChange?.(controller.state());
|
||||
}).catch(error => {
|
||||
if (sequence === selectionSequence) {
|
||||
options.status.textContent = error.message;
|
||||
options.input.value = '';
|
||||
sourceInput.value = '';
|
||||
}
|
||||
}).finally(() => {
|
||||
if (sequence === selectionSequence) options.input.disabled = false;
|
||||
if (sequence === selectionSequence) inputs.forEach(input => { input.disabled = false; });
|
||||
});
|
||||
});
|
||||
}
|
||||
inputs.forEach(input => input.addEventListener('change', selectFromInput));
|
||||
options.remove.addEventListener('click', () => {
|
||||
clearPreview();
|
||||
options.status.textContent = options.removedMessage || 'Screenshot removed. Your comment is unchanged.';
|
||||
const current = controller.state();
|
||||
const count = Array.isArray(current) ? current.length : (current ? 1 : 0);
|
||||
if (count <= 1) {
|
||||
clearPreview();
|
||||
options.status.textContent = options.removedMessage || 'Screenshot removed. Your comment is unchanged.';
|
||||
return Promise.resolve();
|
||||
}
|
||||
const removedIndex = reviewEnabled ? review.activeIndex() : count - 1;
|
||||
controller.remove(removedIndex);
|
||||
selectionSequence += 1;
|
||||
review?.afterRemoval(removedIndex, count);
|
||||
return controller.serialize().then(value => {
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
if (reviewEnabled) {
|
||||
review.render(values, review.activeIndex());
|
||||
} else {
|
||||
const latest = values[values.length - 1];
|
||||
showPreview(latest.blob, false);
|
||||
options.meta.textContent = values.length + ' screenshots ready · latest: ' + latest.filename;
|
||||
}
|
||||
options.status.textContent = options.removedMessage || 'Latest screenshot removed. Your text is unchanged.';
|
||||
options.onChange?.(controller.state());
|
||||
});
|
||||
});
|
||||
|
||||
function restorePreview(value) {
|
||||
clearPreview();
|
||||
const restored = controller.restore(value);
|
||||
previewUrl = value.blob ? options.createObjectURL(value.blob) :
|
||||
'data:' + value.contentType + ';base64,' + value.data;
|
||||
const restored = restoreSelection(value);
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
if (reviewEnabled) {
|
||||
review.render(values, values.length - 1);
|
||||
return restored;
|
||||
}
|
||||
const latest = values[values.length - 1];
|
||||
previewUrl = latest.blob ? options.createObjectURL(latest.blob) :
|
||||
'data:' + latest.contentType + ';base64,' + latest.data;
|
||||
options.image.src = previewUrl;
|
||||
options.meta.textContent = restored.name + ' · ' + Math.ceil(restored.size / 1024) + ' KB';
|
||||
options.meta.textContent = values.length > 1 ?
|
||||
values.length + ' screenshots ready · latest: ' + latest.filename :
|
||||
restored.name + ' · ' + Math.ceil(restored.size / 1024) + ' KB';
|
||||
options.preview.hidden = false;
|
||||
options.status.textContent = options.readyMessage || 'Screenshot ready to upload with this comment.';
|
||||
options.status.textContent = values.length > 1 ?
|
||||
values.length + ' screenshots ready to file in this order.' :
|
||||
(options.readyMessage || 'Screenshot ready to upload with this comment.');
|
||||
return restored;
|
||||
}
|
||||
|
||||
if (review && options.editor && issueEvidenceEditor) {
|
||||
issueEvidenceEditor.mount({
|
||||
...options.editor,
|
||||
controller,
|
||||
getActiveIndex:review.activeIndex,
|
||||
optimizeImage,
|
||||
onApplied: async index => {
|
||||
const value = await controller.serialize();
|
||||
review.render(value, index);
|
||||
options.status.textContent = options.editor.appliedMessage ||
|
||||
'Edited screenshot flattened and ready to file.';
|
||||
},
|
||||
});
|
||||
options.editor.edit.disabled = !controller.state();
|
||||
}
|
||||
return Object.assign(controller, { clear: clearPreview, restore: restorePreview, setBusy });
|
||||
}
|
||||
|
||||
return { create, mount, multipart, optimizeImage, MAX_BYTES };
|
||||
return { create, mount, multipart, optimizeImage, MAX_BYTES, MAX_PIXELS };
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
(function(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.issueEvidenceEditor = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function() {
|
||||
'use strict';
|
||||
|
||||
function boundedRect(value, bounds) {
|
||||
const x = Math.max(bounds.x, Math.min(bounds.x + bounds.width - 1, Math.round(Number(value.x) || 0)));
|
||||
const y = Math.max(bounds.y, Math.min(bounds.y + bounds.height - 1, Math.round(Number(value.y) || 0)));
|
||||
const width = Math.max(1, Math.min(bounds.x + bounds.width - x, Math.round(Number(value.width) || 0)));
|
||||
const height = Math.max(1, Math.min(bounds.y + bounds.height - y, Math.round(Number(value.height) || 0)));
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
function boundedPoint(value, bounds) {
|
||||
return {
|
||||
x: Math.max(bounds.x, Math.min(bounds.x + bounds.width, Math.round(Number(value.x) || 0))),
|
||||
y: Math.max(bounds.y, Math.min(bounds.y + bounds.height, Math.round(Number(value.y) || 0))),
|
||||
};
|
||||
}
|
||||
|
||||
function createModel(size) {
|
||||
const full = { x:0, y:0, width:Math.max(1, Math.round(size.width)), height:Math.max(1, Math.round(size.height)) };
|
||||
let crop = { ...full };
|
||||
let redactions = [];
|
||||
let annotations = [];
|
||||
let history = [];
|
||||
|
||||
function remember() {
|
||||
history.push({
|
||||
crop:{...crop}, redactions:redactions.map(value => ({...value})),
|
||||
annotations:annotations.map(value => ({...value})),
|
||||
});
|
||||
}
|
||||
function intersects(annotation, bounds) {
|
||||
const left = annotation.type === 'arrow' ? Math.min(annotation.startX, annotation.endX) : annotation.x;
|
||||
const top = annotation.type === 'arrow' ? Math.min(annotation.startY, annotation.endY) : annotation.y;
|
||||
const right = annotation.type === 'arrow' ? Math.max(annotation.startX, annotation.endX) : annotation.x + annotation.width;
|
||||
const bottom = annotation.type === 'arrow' ? Math.max(annotation.startY, annotation.endY) : annotation.y + annotation.height;
|
||||
return left < bounds.x + bounds.width && top < bounds.y + bounds.height && right > bounds.x && bottom > bounds.y;
|
||||
}
|
||||
function clampAnnotation(annotation) {
|
||||
if (annotation.type === 'highlight') {
|
||||
const x = Math.max(crop.x, annotation.x);
|
||||
const y = Math.max(crop.y, annotation.y);
|
||||
const right = Math.min(crop.x + crop.width, annotation.x + annotation.width);
|
||||
const bottom = Math.min(crop.y + crop.height, annotation.y + annotation.height);
|
||||
return { type:'highlight', x, y, width:Math.max(1, right - x), height:Math.max(1, bottom - y) };
|
||||
}
|
||||
const start = boundedPoint({x:annotation.startX, y:annotation.startY}, crop);
|
||||
const end = boundedPoint({x:annotation.endX, y:annotation.endY}, crop);
|
||||
return { type:'arrow', startX:start.x, startY:start.y, endX:end.x, endY:end.y };
|
||||
}
|
||||
function setCrop(value) {
|
||||
remember();
|
||||
crop = boundedRect(value, crop);
|
||||
redactions = redactions.filter(rectangle =>
|
||||
rectangle.x < crop.x + crop.width && rectangle.y < crop.y + crop.height &&
|
||||
rectangle.x + rectangle.width > crop.x && rectangle.y + rectangle.height > crop.y
|
||||
).map(rectangle => boundedRect(rectangle, crop));
|
||||
annotations = annotations.filter(annotation => intersects(annotation, crop)).map(clampAnnotation);
|
||||
return snapshot();
|
||||
}
|
||||
function addRedaction(value) {
|
||||
remember();
|
||||
redactions.push(boundedRect(value, crop));
|
||||
return snapshot();
|
||||
}
|
||||
function addHighlight(value) {
|
||||
remember();
|
||||
annotations.push({ type:'highlight', ...boundedRect(value, crop) });
|
||||
return snapshot();
|
||||
}
|
||||
function addArrow(value) {
|
||||
remember();
|
||||
const start = boundedPoint({x:value.startX, y:value.startY}, crop);
|
||||
const end = boundedPoint({x:value.endX, y:value.endY}, crop);
|
||||
annotations.push({ type:'arrow', startX:start.x, startY:start.y, endX:end.x, endY:end.y });
|
||||
return snapshot();
|
||||
}
|
||||
function undo() {
|
||||
const previous = history.pop();
|
||||
if (previous) {
|
||||
crop = previous.crop;
|
||||
redactions = previous.redactions;
|
||||
annotations = previous.annotations;
|
||||
}
|
||||
return snapshot();
|
||||
}
|
||||
function reset() {
|
||||
remember();
|
||||
crop = { ...full };
|
||||
redactions = [];
|
||||
annotations = [];
|
||||
return snapshot();
|
||||
}
|
||||
function snapshot() {
|
||||
return {
|
||||
crop:{...crop}, redactions:redactions.map(value => ({...value})),
|
||||
annotations:annotations.map(value => ({...value})),
|
||||
};
|
||||
}
|
||||
return { addArrow, addHighlight, addRedaction, reset, setCrop, snapshot, undo };
|
||||
}
|
||||
|
||||
function namedBlob(blob, name) {
|
||||
if (typeof File === 'function') return new File([blob], name, {type:blob.type});
|
||||
Object.defineProperty(blob, 'name', {value:name, configurable:true});
|
||||
return blob;
|
||||
}
|
||||
|
||||
function paint(options) {
|
||||
const state = options.model.snapshot();
|
||||
const maxDimension = Number.isFinite(options.maxDimension) ? options.maxDimension : Infinity;
|
||||
const scale = Math.min(1, maxDimension / Math.max(state.crop.width, state.crop.height));
|
||||
const width = Math.max(1, Math.round(state.crop.width * scale));
|
||||
const height = Math.max(1, Math.round(state.crop.height * scale));
|
||||
options.canvas.width = width;
|
||||
options.canvas.height = height;
|
||||
const context = options.canvas.getContext('2d');
|
||||
if (!context) throw new Error('Screenshot editing is unavailable in this browser.');
|
||||
context.drawImage(options.source, state.crop.x, state.crop.y, state.crop.width, state.crop.height, 0, 0, width, height);
|
||||
context.fillStyle = '#000000';
|
||||
state.redactions.forEach(rectangle => context.fillRect(
|
||||
Math.round((rectangle.x - state.crop.x) * scale),
|
||||
Math.round((rectangle.y - state.crop.y) * scale),
|
||||
Math.round(rectangle.width * scale),
|
||||
Math.round(rectangle.height * scale),
|
||||
));
|
||||
state.annotations.forEach(annotation => {
|
||||
if (annotation.type === 'highlight') {
|
||||
context.fillStyle = 'rgba(250, 204, 21, 0.38)';
|
||||
context.fillRect(
|
||||
Math.round((annotation.x - state.crop.x) * scale),
|
||||
Math.round((annotation.y - state.crop.y) * scale),
|
||||
Math.round(annotation.width * scale),
|
||||
Math.round(annotation.height * scale),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const startX = (annotation.startX - state.crop.x) * scale;
|
||||
const startY = (annotation.startY - state.crop.y) * scale;
|
||||
const endX = (annotation.endX - state.crop.x) * scale;
|
||||
const endY = (annotation.endY - state.crop.y) * scale;
|
||||
const angle = Math.atan2(endY - startY, endX - startX);
|
||||
const head = Math.max(10, Math.min(24, 16 * scale));
|
||||
context.save();
|
||||
context.strokeStyle = '#facc15';
|
||||
context.lineWidth = Math.max(4, 6 * scale);
|
||||
context.lineCap = 'round';
|
||||
context.lineJoin = 'round';
|
||||
context.beginPath();
|
||||
context.moveTo(Math.round(startX), Math.round(startY));
|
||||
context.lineTo(Math.round(endX), Math.round(endY));
|
||||
context.lineTo(endX - head * Math.cos(angle - Math.PI / 6), endY - head * Math.sin(angle - Math.PI / 6));
|
||||
context.moveTo(Math.round(endX), Math.round(endY));
|
||||
context.lineTo(endX - head * Math.cos(angle + Math.PI / 6), endY - head * Math.sin(angle + Math.PI / 6));
|
||||
context.stroke();
|
||||
context.restore();
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
async function flatten(options) {
|
||||
paint(options);
|
||||
const type = ['image/png', 'image/jpeg', 'image/webp'].includes(options.type) ? options.type : 'image/png';
|
||||
const blob = await new Promise(resolve => options.canvas.toBlob(resolve, type, type === 'image/png' ? undefined : 0.9));
|
||||
if (!blob || !blob.size) throw new Error('The edited screenshot could not be saved. Try again.');
|
||||
return namedBlob(blob, options.name || 'edited-screenshot.png');
|
||||
}
|
||||
|
||||
function attachmentBlob(value) {
|
||||
if (value.blob) return value.blob;
|
||||
const binary = atob(String(value.data || ''));
|
||||
return new Blob([Uint8Array.from(binary, character => character.charCodeAt(0))], {type:value.contentType});
|
||||
}
|
||||
|
||||
function mount(options) {
|
||||
let source = null;
|
||||
let model = null;
|
||||
let mode = 'redact';
|
||||
let start = null;
|
||||
let selectedIndex = -1;
|
||||
let previousFocus = null;
|
||||
|
||||
function status(message) { options.status.textContent = message; }
|
||||
function setMode(value) {
|
||||
mode = value;
|
||||
options.crop.setAttribute('aria-pressed', value === 'crop' ? 'true' : 'false');
|
||||
options.redact.setAttribute('aria-pressed', value === 'redact' ? 'true' : 'false');
|
||||
options.highlight.setAttribute('aria-pressed', value === 'highlight' ? 'true' : 'false');
|
||||
options.arrow.setAttribute('aria-pressed', value === 'arrow' ? 'true' : 'false');
|
||||
status({
|
||||
crop:'Drag around the part to keep.', redact:'Drag over private information to hide it.',
|
||||
highlight:'Drag around the detail to highlight.', arrow:'Drag toward the detail you want to point out.',
|
||||
}[value]);
|
||||
}
|
||||
function render() {
|
||||
if (source && model) paint({source, model, canvas:options.canvas, maxDimension:1600});
|
||||
}
|
||||
function close() {
|
||||
if (!source && !model) return;
|
||||
options.dialog.hidden = true;
|
||||
if (source && typeof source.close === 'function') source.close();
|
||||
source = null;
|
||||
model = null;
|
||||
start = null;
|
||||
(previousFocus || options.edit).focus();
|
||||
}
|
||||
async function open() {
|
||||
const value = await options.controller.serialize();
|
||||
const values = (Array.isArray(value) ? value : [value]).filter(Boolean);
|
||||
selectedIndex = options.getActiveIndex();
|
||||
const selected = values[selectedIndex];
|
||||
if (!selected) return;
|
||||
previousFocus = options.document.activeElement;
|
||||
status('Opening screenshot editor…');
|
||||
try {
|
||||
const decode = options.decode || globalThis.createImageBitmap;
|
||||
if (typeof decode !== 'function') throw new Error('decode');
|
||||
source = await decode(attachmentBlob(selected));
|
||||
model = createModel({width:source.width, height:source.height});
|
||||
options.dialog.hidden = false;
|
||||
render();
|
||||
setMode('redact');
|
||||
options.redact.focus();
|
||||
} catch (_error) {
|
||||
source = null;
|
||||
status('This browser cannot edit the screenshot. Your original is unchanged.');
|
||||
}
|
||||
}
|
||||
function point(event) {
|
||||
const box = options.canvas.getBoundingClientRect();
|
||||
const state = model.snapshot();
|
||||
return {
|
||||
x:state.crop.x + Math.round((event.clientX - box.left) * state.crop.width / box.width),
|
||||
y:state.crop.y + Math.round((event.clientY - box.top) * state.crop.height / box.height),
|
||||
};
|
||||
}
|
||||
options.canvas.addEventListener('pointerdown', event => {
|
||||
if (!model) return;
|
||||
start = point(event);
|
||||
options.canvas.setPointerCapture?.(event.pointerId);
|
||||
});
|
||||
options.canvas.addEventListener('pointerup', event => {
|
||||
if (!model || !start) return;
|
||||
const end = point(event);
|
||||
const dragStart = start;
|
||||
const rectangle = {
|
||||
x:Math.min(dragStart.x, end.x), y:Math.min(dragStart.y, end.y),
|
||||
width:Math.abs(end.x - dragStart.x), height:Math.abs(end.y - dragStart.y),
|
||||
};
|
||||
start = null;
|
||||
const tooSmall = mode === 'arrow' ? Math.hypot(end.x - dragStart.x, end.y - dragStart.y) < 8 :
|
||||
rectangle.width < 3 || rectangle.height < 3;
|
||||
if (tooSmall) {
|
||||
status('Drag a larger area on the screenshot.');
|
||||
return;
|
||||
}
|
||||
if (mode === 'crop') model.setCrop(rectangle);
|
||||
else if (mode === 'redact') model.addRedaction(rectangle);
|
||||
else if (mode === 'highlight') model.addHighlight(rectangle);
|
||||
else model.addArrow({startX:dragStart.x, startY:dragStart.y, endX:end.x, endY:end.y});
|
||||
render();
|
||||
status({
|
||||
crop:'Crop applied. Undo or reset if needed.', redact:'Private area hidden with an opaque redaction.',
|
||||
highlight:'Highlight added.', arrow:'Arrow added.',
|
||||
}[mode]);
|
||||
});
|
||||
options.edit.addEventListener('click', open);
|
||||
options.crop.addEventListener('click', () => setMode('crop'));
|
||||
options.redact.addEventListener('click', () => setMode('redact'));
|
||||
options.highlight.addEventListener('click', () => setMode('highlight'));
|
||||
options.arrow.addEventListener('click', () => setMode('arrow'));
|
||||
options.undo.addEventListener('click', () => { if (model) { model.undo(); render(); status('Last edit undone.'); } });
|
||||
options.reset.addEventListener('click', () => { if (model) { model.reset(); render(); status('Crop, redactions, and annotations reset.'); } });
|
||||
options.cancel.addEventListener('click', close);
|
||||
options.dialog.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); close(); } });
|
||||
options.apply.addEventListener('click', async () => {
|
||||
if (!model || !source) return;
|
||||
options.apply.disabled = true;
|
||||
status('Applying flattened edit…');
|
||||
try {
|
||||
const current = await options.controller.serialize();
|
||||
const values = Array.isArray(current) ? current : [current];
|
||||
const selected = values[selectedIndex];
|
||||
let derivative = await flatten({
|
||||
source, model, canvas:options.exportCanvas, maxDimension:2048,
|
||||
name:selected.filename, type:selected.contentType,
|
||||
});
|
||||
if (options.optimizeImage) derivative = await options.optimizeImage(derivative);
|
||||
options.controller.replace(selectedIndex, derivative);
|
||||
await options.onApplied(selectedIndex);
|
||||
close();
|
||||
} catch (error) {
|
||||
status(error.message || 'The edited screenshot could not be applied. Your original is unchanged.');
|
||||
} finally {
|
||||
options.apply.disabled = false;
|
||||
}
|
||||
});
|
||||
return { close, open };
|
||||
}
|
||||
|
||||
return { createModel, flatten, mount, paint };
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
(function(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.issueEvidenceReview = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function() {
|
||||
'use strict';
|
||||
|
||||
const MAX_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_PIXELS = 12 * 1024 * 1024;
|
||||
const MAX_DIMENSION = 8192;
|
||||
|
||||
function namedBlob(blob, name) {
|
||||
if (typeof File === 'function') return new File([blob], name, { type: blob.type });
|
||||
Object.defineProperty(blob, 'name', { value: name, configurable: true });
|
||||
return blob;
|
||||
}
|
||||
|
||||
async function optimizeImage(file, environment = {}) {
|
||||
const decode = environment.createImageBitmap || globalThis.createImageBitmap;
|
||||
const makeCanvas = environment.createCanvas || (() => document.createElement('canvas'));
|
||||
if (typeof decode !== 'function') throw new Error('This browser cannot safely inspect the screenshot. Try cropping it and choose it again.');
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await decode(file);
|
||||
const width = Number(bitmap.width);
|
||||
const height = Number(bitmap.height);
|
||||
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) {
|
||||
throw new Error('decode');
|
||||
}
|
||||
const pixelScale = Math.min(1, Math.sqrt(MAX_PIXELS / (width * height)));
|
||||
const dimensionScale = Math.min(1, MAX_DIMENSION / Math.max(width, height));
|
||||
const byteScale = file.size > MAX_BYTES ? Math.sqrt(MAX_BYTES / file.size) * 0.92 : 1;
|
||||
let scale = Math.min(pixelScale, dimensionScale, byteScale);
|
||||
const canvas = makeCanvas();
|
||||
const context = canvas && canvas.getContext && canvas.getContext('2d');
|
||||
if (!context) throw new Error('decode');
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
canvas.width = Math.max(1, Math.floor(width * scale));
|
||||
canvas.height = Math.max(1, Math.floor(height * scale));
|
||||
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||
const blob = await new Promise(resolve => canvas.toBlob(resolve, file.type, file.type === 'image/png' ? undefined : 0.86));
|
||||
if (!blob) throw new Error('encode');
|
||||
if (blob.size > 0 && blob.size <= MAX_BYTES) return namedBlob(blob, file.name);
|
||||
scale *= 0.8;
|
||||
}
|
||||
} catch (_error) {
|
||||
throw new Error('The screenshot could not be optimized. Try cropping it and choose it again.');
|
||||
} finally {
|
||||
if (bitmap && typeof bitmap.close === 'function') bitmap.close();
|
||||
}
|
||||
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
|
||||
}
|
||||
|
||||
function create(options) {
|
||||
const controller = options.controller;
|
||||
const documentRef = options.document || document;
|
||||
let activeIndex = 0;
|
||||
let busy = false;
|
||||
let previewUrl = '';
|
||||
let thumbnailUrls = [];
|
||||
|
||||
function revoke(url) {
|
||||
if (url && !url.startsWith('data:')) options.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function attachmentUrl(attachment) {
|
||||
return attachment?.blob ? options.createObjectURL(attachment.blob) :
|
||||
'data:' + attachment.contentType + ';base64,' + attachment.data;
|
||||
}
|
||||
|
||||
function count() {
|
||||
const value = controller.state();
|
||||
return Array.isArray(value) ? value.length : (value ? 1 : 0);
|
||||
}
|
||||
|
||||
function setBusy(value) {
|
||||
busy = Boolean(value);
|
||||
options.earlier.disabled = busy || activeIndex <= 0;
|
||||
options.later.disabled = busy || activeIndex >= count() - 1;
|
||||
if (options.note) options.note.disabled = busy;
|
||||
if (options.edit) options.edit.disabled = busy || !count();
|
||||
Array.from(options.tray.children || []).forEach(button => { button.disabled = busy; });
|
||||
}
|
||||
|
||||
function updateNote(values) {
|
||||
if (!options.note) return;
|
||||
options.note.value = controller.note(activeIndex);
|
||||
options.note.disabled = busy;
|
||||
if (options.noteLabel) {
|
||||
options.noteLabel.textContent = 'Evidence note for screenshot ' + (activeIndex + 1) + ' of ' +
|
||||
values.length + ' (optional)';
|
||||
}
|
||||
}
|
||||
|
||||
function update(values, optimized = false) {
|
||||
if (!values.length) return;
|
||||
activeIndex = Math.max(0, Math.min(activeIndex, values.length - 1));
|
||||
revoke(previewUrl);
|
||||
previewUrl = attachmentUrl(values[activeIndex]);
|
||||
options.image.src = previewUrl;
|
||||
const current = values[activeIndex];
|
||||
const size = current.blob ? current.blob.size : Math.max(1, Math.floor(String(current.data || '').length * 3 / 4));
|
||||
options.meta.textContent = 'Screenshot ' + (activeIndex + 1) + ' of ' + values.length + ' · ' +
|
||||
current.filename + ' · ' + Math.ceil(size / 1024) + ' KB';
|
||||
options.preview.hidden = false;
|
||||
if (options.edit) options.edit.disabled = busy;
|
||||
Array.from(options.tray.children || []).forEach((button, index) =>
|
||||
button.setAttribute('aria-pressed', index === activeIndex ? 'true' : 'false'));
|
||||
options.earlier.disabled = busy || activeIndex === 0;
|
||||
options.later.disabled = busy || activeIndex === values.length - 1;
|
||||
updateNote(values);
|
||||
options.status.textContent = optimized ? 'Screenshots optimized and ready to file in this order.' :
|
||||
values.length + ' screenshots ready to file in this order.';
|
||||
}
|
||||
|
||||
function render(value, index = activeIndex, optimized = false) {
|
||||
const values = (Array.isArray(value) ? value : [value]).filter(Boolean);
|
||||
activeIndex = Math.max(0, Math.min(index, values.length - 1));
|
||||
thumbnailUrls.forEach(revoke);
|
||||
thumbnailUrls = [];
|
||||
options.tray.replaceChildren(...values.map((item, itemIndex) => {
|
||||
const button = documentRef.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'issue-evidence-thumbnail';
|
||||
button.setAttribute('aria-label', 'Review screenshot ' + (itemIndex + 1) + ' of ' + values.length + ': ' + item.filename);
|
||||
const thumbnail = documentRef.createElement('img');
|
||||
thumbnail.alt = '';
|
||||
thumbnail.src = attachmentUrl(item);
|
||||
thumbnailUrls.push(thumbnail.src);
|
||||
button.appendChild(thumbnail);
|
||||
const position = documentRef.createElement('span');
|
||||
position.textContent = String(itemIndex + 1);
|
||||
button.appendChild(position);
|
||||
button.addEventListener('click', () => { activeIndex = itemIndex; update(values); });
|
||||
return button;
|
||||
}));
|
||||
options.tray.hidden = !values.length;
|
||||
if (values.length) update(values, optimized);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
revoke(previewUrl);
|
||||
thumbnailUrls.forEach(revoke);
|
||||
previewUrl = '';
|
||||
thumbnailUrls = [];
|
||||
activeIndex = 0;
|
||||
options.tray.replaceChildren();
|
||||
options.tray.hidden = true;
|
||||
options.earlier.disabled = true;
|
||||
options.later.disabled = true;
|
||||
if (options.note) {
|
||||
options.note.value = '';
|
||||
options.note.disabled = true;
|
||||
}
|
||||
if (options.edit) options.edit.disabled = true;
|
||||
if (options.noteLabel) options.noteLabel.textContent = 'Evidence note (optional)';
|
||||
}
|
||||
|
||||
function afterRemoval(removedIndex, previousCount) {
|
||||
activeIndex = Math.min(removedIndex, previousCount - 2);
|
||||
}
|
||||
|
||||
function move(offset) {
|
||||
const total = count();
|
||||
const destination = activeIndex + offset;
|
||||
if (destination < 0 || destination >= total) return Promise.resolve();
|
||||
controller.move(activeIndex, destination);
|
||||
activeIndex = destination;
|
||||
return controller.serialize().then(value => {
|
||||
render(value, activeIndex);
|
||||
options.status.textContent = 'Screenshot moved to position ' + (activeIndex + 1) + ' of ' + total + '.';
|
||||
});
|
||||
}
|
||||
|
||||
options.earlier.addEventListener('click', () => move(-1));
|
||||
options.later.addEventListener('click', () => move(1));
|
||||
options.note?.addEventListener('input', event => {
|
||||
controller.setNote(activeIndex, event.target.value);
|
||||
});
|
||||
clear();
|
||||
return { activeIndex: () => activeIndex, afterRemoval, clear, render, setBusy };
|
||||
}
|
||||
|
||||
return { create, optimizeImage, MAX_PIXELS };
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.createIssueFilingReceipt = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
function createIssueFilingReceipt({
|
||||
root, heading, key, title, ownership, openLink, shareButton, filedButton,
|
||||
relatedButton, fileAnotherButton, doneButton, status, navigator = {}, clipboard = navigator.clipboard,
|
||||
onFileRelated = function () {},
|
||||
onFileAnother = function () {},
|
||||
onViewFiled = function () {},
|
||||
}) {
|
||||
let active = null;
|
||||
let relatedPlan = null;
|
||||
let restoreFocus = null;
|
||||
|
||||
function close() {
|
||||
root.hidden = true;
|
||||
active = null;
|
||||
relatedPlan = null;
|
||||
const target = restoreFocus;
|
||||
restoreFocus = null;
|
||||
target?.focus?.();
|
||||
}
|
||||
|
||||
async function share() {
|
||||
if (!active) return;
|
||||
const payload = {
|
||||
title: active.repository + '#' + active.number + ' · ' + active.title,
|
||||
text: active.title,
|
||||
url: active.url,
|
||||
};
|
||||
if (typeof navigator.share === 'function') {
|
||||
try {
|
||||
await navigator.share(payload);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') return;
|
||||
}
|
||||
}
|
||||
if (typeof clipboard?.writeText !== 'function') {
|
||||
status.textContent = 'Issue link is ready in Open in Gitea.';
|
||||
return;
|
||||
}
|
||||
await clipboard.writeText(active.url);
|
||||
status.textContent = 'Issue link copied.';
|
||||
}
|
||||
|
||||
function show(issue, returnTarget, reusablePlan = null) {
|
||||
active = issue;
|
||||
relatedPlan = reusablePlan;
|
||||
restoreFocus = returnTarget || null;
|
||||
key.textContent = issue.repository + '#' + issue.number;
|
||||
title.textContent = issue.title;
|
||||
ownership.textContent = issue.assignees?.length
|
||||
? 'Assigned to @' + issue.assignees.join(', @') + '.'
|
||||
: 'Created with no owner.';
|
||||
openLink.href = issue.url;
|
||||
status.textContent = '';
|
||||
if (relatedButton) relatedButton.hidden = !relatedPlan;
|
||||
root.hidden = false;
|
||||
heading.focus();
|
||||
}
|
||||
|
||||
shareButton.addEventListener('click', share);
|
||||
filedButton?.addEventListener('click', () => {
|
||||
if (!active) return;
|
||||
const issue = active;
|
||||
close();
|
||||
onViewFiled(issue);
|
||||
});
|
||||
root.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Escape' || root.hidden) return;
|
||||
event.preventDefault();
|
||||
close();
|
||||
});
|
||||
doneButton.addEventListener('click', close);
|
||||
relatedButton?.addEventListener('click', () => {
|
||||
if (!relatedPlan) return;
|
||||
const plan = relatedPlan;
|
||||
close();
|
||||
onFileRelated(plan);
|
||||
});
|
||||
fileAnotherButton.addEventListener('click', () => {
|
||||
close();
|
||||
onFileAnother();
|
||||
});
|
||||
|
||||
return { show, close, share };
|
||||
}
|
||||
|
||||
return createIssueFilingReceipt;
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
(function(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.createIssueFilingReview = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function() {
|
||||
'use strict';
|
||||
|
||||
const INTENT_LABELS = {
|
||||
'create-and-assign': 'Create & assign to me',
|
||||
'create-and-start': 'Create & start',
|
||||
'follow-up-and-next': 'Create follow-up & next',
|
||||
};
|
||||
|
||||
function clone(value) {
|
||||
if (typeof structuredClone === 'function') return structuredClone(value);
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function create(options) {
|
||||
let reviewed = null;
|
||||
let trigger = null;
|
||||
let busy = false;
|
||||
let evidenceItems = [];
|
||||
const generatedUrls = new Set();
|
||||
|
||||
function releaseEvidenceUrls() {
|
||||
generatedUrls.forEach(url => options.revokeObjectURL?.(url));
|
||||
generatedUrls.clear();
|
||||
evidenceItems = [];
|
||||
}
|
||||
|
||||
function evidenceUrl(attachment) {
|
||||
if (attachment?.blob) {
|
||||
const url = options.createObjectURL(attachment.blob);
|
||||
generatedUrls.add(url);
|
||||
return url;
|
||||
}
|
||||
if (attachment?.data) {
|
||||
return 'data:' + String(attachment.contentType || 'application/octet-stream') +
|
||||
';base64,' + String(attachment.data);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function selectEvidence(index) {
|
||||
const selected = evidenceItems[index];
|
||||
if (!selected) return;
|
||||
evidenceItems.forEach((item, position) =>
|
||||
item.button.setAttribute('aria-pressed', position === index ? 'true' : 'false'));
|
||||
const filename = selected.attachment.filename || 'Screenshot';
|
||||
options.evidencePreview.hidden = false;
|
||||
options.evidenceImage.src = selected.url;
|
||||
options.evidenceImage.alt = 'Evidence ' + (index + 1) + ' of ' + evidenceItems.length + ': ' + filename;
|
||||
options.evidencePosition.textContent = 'Evidence ' + (index + 1) + ' of ' + evidenceItems.length;
|
||||
options.evidenceFilename.textContent = filename;
|
||||
options.evidenceNote.textContent = selected.attachment.note || 'No evidence note.';
|
||||
}
|
||||
|
||||
function renderVisualEvidence(attachments) {
|
||||
releaseEvidenceUrls();
|
||||
options.evidenceEmpty.hidden = attachments.length > 0;
|
||||
options.evidencePreview.hidden = true;
|
||||
options.evidenceImage.src = '';
|
||||
evidenceItems = attachments.map((attachment, index) => {
|
||||
const url = evidenceUrl(attachment);
|
||||
const item = options.document.createElement('li');
|
||||
const button = options.document.createElement('button');
|
||||
const image = options.document.createElement('img');
|
||||
const filename = attachment.filename || 'Screenshot';
|
||||
button.setAttribute('type', 'button');
|
||||
button.className = 'issue-filing-evidence-selector';
|
||||
button.setAttribute('aria-label', 'Preview evidence ' + (index + 1) + ' of ' + attachments.length + ': ' + filename);
|
||||
button.setAttribute('aria-pressed', 'false');
|
||||
image.src = url;
|
||||
image.alt = '';
|
||||
button.appendChild(image);
|
||||
item.appendChild(button);
|
||||
return { attachment, button, item, url };
|
||||
});
|
||||
evidenceItems.forEach((entry, index) =>
|
||||
entry.button.addEventListener('click', () => selectEvidence(index)));
|
||||
options.evidenceList.replaceChildren(...evidenceItems.map(entry => entry.item));
|
||||
if (evidenceItems.length) selectEvidence(0);
|
||||
}
|
||||
|
||||
function close() {
|
||||
options.sheet.hidden = true;
|
||||
const previousTrigger = trigger;
|
||||
trigger = null;
|
||||
reviewed = null;
|
||||
busy = false;
|
||||
releaseEvidenceUrls();
|
||||
options.confirmButton.disabled = false;
|
||||
previousTrigger?.focus();
|
||||
}
|
||||
|
||||
function render(payload) {
|
||||
const draft = payload.draft;
|
||||
options.repository.textContent = draft.repository || 'No repository';
|
||||
options.intent.textContent = draft.unassigned === true ? 'Create unassigned' :
|
||||
(INTENT_LABELS[payload.intent] || payload.intent);
|
||||
if (options.issueType) options.issueType.textContent = draft.templateName || 'Blank issue';
|
||||
options.title.textContent = draft.title;
|
||||
options.body.textContent = draft.body || 'No note provided.';
|
||||
const labels = (draft.labels || []).map(label => typeof label === 'string' ? label : label.name);
|
||||
const milestone = draft.milestone?.title || draft.milestoneTitle || 'No milestone';
|
||||
const dueDate = draft.dueDate || draft.due_date || 'No due date';
|
||||
const owner = draft.unassigned === true ? 'Owner: No owner' : (draft.assignee
|
||||
? 'Owner: ' + (draft.assigneeName || draft.assignee) + ' (@' + draft.assignee + ')'
|
||||
: 'Assigned to you');
|
||||
const planning = [];
|
||||
if (Number.isInteger(draft.estimateMinutes)) {
|
||||
planning.push('Estimate: ' + draft.estimateMinutes + ' min');
|
||||
const projected = draft.todayCapacity?.projectedMinutes;
|
||||
if (Number.isInteger(projected)) planning.push(projected >= 0
|
||||
? 'Today after start: ' + projected + ' min free'
|
||||
: 'Today after start: ' + Math.abs(projected) + ' min over capacity');
|
||||
}
|
||||
options.metadata.textContent = [
|
||||
labels.length ? 'Labels: ' + labels.join(', ') : 'No labels',
|
||||
'Milestone: ' + milestone,
|
||||
'Due: ' + dueDate,
|
||||
owner,
|
||||
...planning,
|
||||
].join(' · ');
|
||||
if (options.blockerList) {
|
||||
options.blockerList.replaceChildren(...(draft.blockers || []).map(blocker => {
|
||||
const item = options.document.createElement('li');
|
||||
item.textContent = blocker.repository + ' #' + blocker.number + ' — ' + blocker.title;
|
||||
return item;
|
||||
}));
|
||||
}
|
||||
const attachments = (draft.attachments || (draft.attachment ? [draft.attachment] : [])).filter(Boolean);
|
||||
if (options.evidencePreview) {
|
||||
renderVisualEvidence(attachments);
|
||||
return;
|
||||
}
|
||||
options.evidenceList.replaceChildren(...attachments.map((attachment, index) => {
|
||||
const item = options.document.createElement('li');
|
||||
item.textContent = (index + 1) + '. ' + (attachment.filename || 'Screenshot') +
|
||||
(attachment.note ? ' — ' + attachment.note : '');
|
||||
return item;
|
||||
}));
|
||||
}
|
||||
|
||||
function open(payload, opener) {
|
||||
reviewed = clone(payload);
|
||||
trigger = opener || null;
|
||||
busy = false;
|
||||
options.confirmButton.disabled = false;
|
||||
render(reviewed);
|
||||
options.sheet.hidden = false;
|
||||
options.backButton.focus();
|
||||
}
|
||||
|
||||
options.backButton.addEventListener('click', close);
|
||||
options.confirmButton.addEventListener('click', async () => {
|
||||
if (!reviewed || busy) return;
|
||||
busy = true;
|
||||
options.confirmButton.disabled = true;
|
||||
try {
|
||||
await options.onConfirm(reviewed);
|
||||
close();
|
||||
} catch (error) {
|
||||
busy = false;
|
||||
options.confirmButton.disabled = false;
|
||||
if (options.status) options.status.textContent = error.message;
|
||||
options.confirmButton.focus();
|
||||
}
|
||||
});
|
||||
options.document.addEventListener('keydown', event => {
|
||||
if (!options.sheet.hidden && event.key === 'Escape' && !busy) {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
});
|
||||
return { close, open };
|
||||
}
|
||||
|
||||
return create;
|
||||
});
|
||||
+224
-40
@@ -11,10 +11,80 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
const blob = value?.blob;
|
||||
const data = String(value?.data || '');
|
||||
if (!filename || !['image/png', 'image/jpeg', 'image/webp'].includes(contentType)) return undefined;
|
||||
if (blob) return { filename, contentType, blob };
|
||||
if (!data && value?.stored === true) return { filename, contentType, stored: true };
|
||||
const note = String(value?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
||||
const noteValue = note ? { note } : {};
|
||||
if (blob) return { filename, contentType, blob, ...noteValue };
|
||||
if (!data && value?.stored === true) return { filename, contentType, stored: true, ...noteValue };
|
||||
if (!data) return undefined;
|
||||
return { filename, contentType, data };
|
||||
return { filename, contentType, data, ...noteValue };
|
||||
}
|
||||
|
||||
function captureAttachments(values) {
|
||||
if (!Array.isArray(values)) return undefined;
|
||||
if (values.length > 5) throw new Error('You can attach up to 5 screenshots.');
|
||||
const attachments = values.map(captureAttachment);
|
||||
if (attachments.some(value => !value)) {
|
||||
throw new Error('Some screenshots are unavailable. Choose them again before queueing.');
|
||||
}
|
||||
return attachments.length ? attachments : undefined;
|
||||
}
|
||||
|
||||
function captureBlockers(values) {
|
||||
if (!Array.isArray(values)) return undefined;
|
||||
const seen = new Set();
|
||||
const blockers = [];
|
||||
for (const value of values) {
|
||||
const repository = String(value?.repository || '').trim();
|
||||
const number = Number(value?.number);
|
||||
const key = repository + '#' + number;
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) ||
|
||||
!Number.isInteger(number) || number < 1 || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
blockers.push({
|
||||
repository, number,
|
||||
title: String(value?.title || '').replace(/\s+/g, ' ').trim().slice(0, 255),
|
||||
});
|
||||
if (blockers.length === 5) break;
|
||||
}
|
||||
return blockers.length ? blockers : undefined;
|
||||
}
|
||||
|
||||
function captureEstimate(value) {
|
||||
const estimate = Number(value);
|
||||
return Number.isInteger(estimate) && estimate >= 5 && estimate <= 1440 ? estimate : undefined;
|
||||
}
|
||||
|
||||
function captureRelatedDraft(value) {
|
||||
const repository = String(value?.repository || '');
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) return undefined;
|
||||
const plan = {
|
||||
repository, title:'', body:String(value?.body || '').slice(0, 9000),
|
||||
labelIds:Array.from(new Set((Array.isArray(value?.labelIds) ? value.labelIds : [])
|
||||
.filter(id => Number.isInteger(id) && id > 0))).slice(0, 20),
|
||||
};
|
||||
const milestoneId = Number(value?.milestoneId);
|
||||
if (Number.isInteger(milestoneId) && milestoneId > 0) plan.milestoneId = milestoneId;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(String(value?.dueDate || ''))) plan.dueDate = String(value.dueDate);
|
||||
if (value?.unassigned === true) plan.unassigned = true;
|
||||
else if (/^[A-Za-z0-9_.-]+$/.test(String(value?.assignee || ''))) {
|
||||
plan.assignee = String(value.assignee);
|
||||
plan.assigneeName = String(value.assigneeName || value.assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
|
||||
}
|
||||
const templateId = String(value?.templateId || '').slice(0, 80);
|
||||
if (templateId) {
|
||||
plan.templateId = templateId;
|
||||
plan.templateName = String(value?.templateName || 'Issue template').trim().slice(0, 80);
|
||||
plan.capturedBody = '';
|
||||
}
|
||||
const promotion = value?.checklistPromotion;
|
||||
if (promotion?.repository && promotion?.number && promotion?.url && promotion?.updatedAt) {
|
||||
plan.checklistPromotion = {
|
||||
repository:String(promotion.repository), number:Number(promotion.number), url:String(promotion.url),
|
||||
title:String(promotion.title || ''), body:String(promotion.body || '').slice(0, 10000),
|
||||
updatedAt:String(promotion.updatedAt), taskIndex:Number(promotion.taskIndex),
|
||||
};
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
function read() {
|
||||
@@ -51,11 +121,28 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
};
|
||||
const assignee = /^[A-Za-z0-9_.-]+$/.test(String(draft?.assignee || ''))
|
||||
? String(draft.assignee) : '';
|
||||
if (assignee) {
|
||||
item.assignee = assignee;
|
||||
item.assigneeName = String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255);
|
||||
}
|
||||
if (draft?.unassigned === true) item.unassigned = true;
|
||||
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
|
||||
if (sourceCaptureId) item.sourceCaptureId = sourceCaptureId;
|
||||
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
|
||||
if (draft?.completionIntent === 'create-and-start' && !assignee && !item.unassigned) {
|
||||
item.completionIntent = 'create-and-start';
|
||||
item.estimateMinutes = captureEstimate(draft?.estimateMinutes);
|
||||
}
|
||||
const attachment = captureAttachment(draft?.attachment);
|
||||
if (attachment) item.attachment = attachment;
|
||||
const attachments = captureAttachments(draft?.attachments);
|
||||
if (attachments) item.attachments = attachments;
|
||||
const blockers = captureBlockers(draft?.blockers);
|
||||
if (blockers) item.blockers = blockers;
|
||||
const relatedDraft = captureRelatedDraft(draft?.relatedDraft);
|
||||
if (relatedDraft) item.relatedDraft = relatedDraft;
|
||||
|
||||
item.operationId = item.id;
|
||||
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
|
||||
item.milestoneId = Number(draft.milestoneId);
|
||||
@@ -65,14 +152,21 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
}
|
||||
|
||||
function localIndexItem(item) {
|
||||
if (!item?.attachment?.data && !item?.attachment?.blob) return item;
|
||||
const hasAttachmentBytes = item?.attachment?.data || item?.attachment?.blob;
|
||||
const hasBundleBytes = Array.isArray(item?.attachments) &&
|
||||
item.attachments.some(value => value?.data || value?.blob);
|
||||
if (!hasAttachmentBytes && !hasBundleBytes) return item;
|
||||
return {
|
||||
...item,
|
||||
attachment: {
|
||||
...(hasAttachmentBytes ? {attachment: {
|
||||
filename: item.attachment.filename,
|
||||
contentType: item.attachment.contentType,
|
||||
stored: true,
|
||||
},
|
||||
}} : {}),
|
||||
...(Array.isArray(item.attachments) ? {attachments:item.attachments.map(value => ({
|
||||
filename:value.filename, contentType:value.contentType, stored:true,
|
||||
...(value.note ? {note:value.note} : {}),
|
||||
}))} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,50 +214,90 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
async function hydrateForEdit(id) {
|
||||
const item = read().find(candidate => candidate.id === id);
|
||||
if (!item) return null;
|
||||
if (!item.attachment?.stored || item.attachment.data || item.attachment.blob) return { ...item };
|
||||
const storedBundle = Array.isArray(item.attachments) && item.attachments.some(value => value?.stored);
|
||||
const storedAttachment = item.attachment?.stored && !item.attachment.data && !item.attachment.blob;
|
||||
if (!storedAttachment && !storedBundle) return { ...item };
|
||||
if (!backgroundSync?.get) {
|
||||
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
|
||||
}
|
||||
const durable = await backgroundSync.get(id);
|
||||
const attachment = captureAttachment(durable?.attachment);
|
||||
if ((!attachment?.data && !attachment?.blob) || durable?.operationId !== item.operationId) {
|
||||
const attachments = captureAttachments(durable?.attachments);
|
||||
if (durable?.operationId !== item.operationId ||
|
||||
(storedAttachment && (!attachment?.data && !attachment?.blob)) ||
|
||||
(storedBundle && (!attachments || attachments.length !== item.attachments.length))) {
|
||||
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
|
||||
}
|
||||
return { ...item, attachment };
|
||||
return {
|
||||
...item,
|
||||
...(attachment ? {attachment} : {}),
|
||||
...(attachments ? {attachments} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function prepareUpdate(id, draft) {
|
||||
let updated = null;
|
||||
const items = read().map(item => {
|
||||
if (item.id !== id) return item;
|
||||
const nextRepository = String(draft?.repository || '');
|
||||
const nextTitle = String(draft?.title || '');
|
||||
const nextBody = String(draft?.body || '');
|
||||
const nextLabelIds = Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [];
|
||||
const nextMilestoneId = Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0
|
||||
const repository = String(draft?.repository || '');
|
||||
const title = String(draft?.title || '');
|
||||
const body = String(draft?.body || '');
|
||||
const labelIds = Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [];
|
||||
const unassigned = draft?.unassigned === true;
|
||||
const assignee = !unassigned && /^[A-Za-z0-9_.-]+$/.test(String(draft?.assignee || ''))
|
||||
? String(draft.assignee) : undefined;
|
||||
const assigneeName = assignee
|
||||
? String(draft?.assigneeName || assignee).replace(/\s+/g, ' ').trim().slice(0, 255)
|
||||
: undefined;
|
||||
const milestoneId = Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0
|
||||
? Number(draft.milestoneId) : undefined;
|
||||
const nextDueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
|
||||
const dueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
|
||||
? String(draft.dueDate) : undefined;
|
||||
const nextAttachment = captureAttachment(draft?.attachment);
|
||||
const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(nextAttachment || null);
|
||||
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|
||||
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|
||||
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate
|
||||
|| attachmentChanged;
|
||||
const attachment = captureAttachment(draft?.attachment);
|
||||
const attachments = captureAttachments(draft?.attachments);
|
||||
const blockers = captureBlockers(draft?.blockers);
|
||||
const relatedDraft = captureRelatedDraft(draft?.relatedDraft);
|
||||
const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(attachment || null) ||
|
||||
JSON.stringify(item.attachments || null) !== JSON.stringify(attachments || null);
|
||||
const changed = item.repository !== repository || item.title !== title || item.body !== body
|
||||
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(labelIds)
|
||||
|| Boolean(item.unassigned) !== unassigned
|
||||
|| item.assignee !== assignee || item.assigneeName !== assigneeName
|
||||
|| item.milestoneId !== milestoneId || item.dueDate !== dueDate
|
||||
|| item.estimateMinutes !== captureEstimate(draft?.estimateMinutes)
|
||||
|| attachmentChanged || JSON.stringify(item.blockers || null) !== JSON.stringify(blockers || null)
|
||||
|| JSON.stringify(item.relatedDraft || null) !== JSON.stringify(relatedDraft || null);
|
||||
updated = {
|
||||
...item,
|
||||
repository: nextRepository, title: nextTitle,
|
||||
body: nextBody, labelIds: nextLabelIds,
|
||||
milestoneId: nextMilestoneId, dueDate: nextDueDate, attachment: nextAttachment,
|
||||
repository, title, body, labelIds,
|
||||
unassigned: unassigned || undefined,
|
||||
assignee, assigneeName, milestoneId, dueDate,
|
||||
attachment, attachments, blockers, relatedDraft,
|
||||
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
|
||||
status: 'queued',
|
||||
};
|
||||
if (draft?.completionIntent === 'create-and-start') updated.completionIntent = 'create-and-start';
|
||||
else delete updated.completionIntent;
|
||||
if (nextMilestoneId === undefined) delete updated.milestoneId;
|
||||
if (nextDueDate === undefined) delete updated.dueDate;
|
||||
if (nextAttachment === undefined) delete updated.attachment;
|
||||
if (attachmentChanged) delete updated.attachmentMarkdown;
|
||||
if (draft?.completionIntent === 'create-and-start' && !assignee && !unassigned) {
|
||||
updated.completionIntent = 'create-and-start';
|
||||
updated.estimateMinutes = captureEstimate(draft?.estimateMinutes);
|
||||
} else {
|
||||
delete updated.completionIntent;
|
||||
delete updated.estimateMinutes;
|
||||
}
|
||||
if (assignee === undefined) {
|
||||
delete updated.assignee;
|
||||
delete updated.assigneeName;
|
||||
}
|
||||
if (!unassigned) delete updated.unassigned;
|
||||
if (milestoneId === undefined) delete updated.milestoneId;
|
||||
if (dueDate === undefined) delete updated.dueDate;
|
||||
if (attachment === undefined) delete updated.attachment;
|
||||
if (attachments === undefined) delete updated.attachments;
|
||||
if (blockers === undefined) delete updated.blockers;
|
||||
if (relatedDraft === undefined) delete updated.relatedDraft;
|
||||
if (attachmentChanged) {
|
||||
delete updated.attachmentMarkdown;
|
||||
delete updated.attachmentMarkdowns;
|
||||
}
|
||||
delete updated.error;
|
||||
delete updated.deliveryState;
|
||||
return updated;
|
||||
@@ -213,6 +347,13 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
|
||||
}
|
||||
|
||||
function evidenceMarkdown(attachment, markdown, index) {
|
||||
const note = String(attachment?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
||||
if (!note) return markdown;
|
||||
const escaped = note.replace(/([\\`*_[\]{}()<>#+\-.!|])/g, '\\$1');
|
||||
return '**Screenshot ' + (index + 1) + ' — ' + escaped + '**\n\n' + markdown;
|
||||
}
|
||||
|
||||
function attachmentMultipart(attachment) {
|
||||
let blob = attachment?.blob;
|
||||
if (!blob && attachment?.data) {
|
||||
@@ -237,34 +378,68 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: item.title, body: item.body, label_ids: item.labelIds,
|
||||
...(item.unassigned ? { unassigned: true } : {}),
|
||||
...(item.assignee ? { assignee: item.assignee } : {}),
|
||||
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
|
||||
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
|
||||
}),
|
||||
});
|
||||
if (item.attachment) persistDeliveryStage(item.id, { deliveredIssue: issue });
|
||||
if (item.attachment || item.attachments || item.blockers?.length) {
|
||||
persistDeliveryStage(item.id, { deliveredIssue: issue });
|
||||
}
|
||||
}
|
||||
if (!item.attachment) return issue;
|
||||
let markdown = item.attachmentMarkdown;
|
||||
if (!markdown) {
|
||||
const blockers = Array.isArray(item.blockers) ? item.blockers : [];
|
||||
let deliveredBlockers = Math.min(Number(item.deliveredBlockers) || 0, blockers.length);
|
||||
for (let index = deliveredBlockers; index < blockers.length; index += 1) {
|
||||
const blocker = blockers[index];
|
||||
await fetchJson(
|
||||
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/blockers',
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'blocker-' + index),
|
||||
},
|
||||
body: JSON.stringify({repository:blocker.repository, number:blocker.number, present:true}),
|
||||
},
|
||||
);
|
||||
deliveredBlockers = index + 1;
|
||||
persistDeliveryStage(item.id, { deliveredIssue:issue, deliveredBlockers });
|
||||
}
|
||||
const attachments = Array.isArray(item.attachments) ? item.attachments :
|
||||
(item.attachment ? [item.attachment] : []);
|
||||
if (!attachments.length) return issue;
|
||||
const markdowns = Array.isArray(item.attachmentMarkdowns)
|
||||
? item.attachmentMarkdowns.slice(0, attachments.length)
|
||||
: (item.attachmentMarkdown ? [item.attachmentMarkdown] : []);
|
||||
for (let index = markdowns.length; index < attachments.length; index += 1) {
|
||||
const uploaded = await fetchJson(
|
||||
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/attachments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
|
||||
'Idempotency-Key': stageOperationId(
|
||||
item.operationId, attachments.length === 1 ? 'attachment' : 'attachment-' + index,
|
||||
),
|
||||
},
|
||||
body: attachmentMultipart(item.attachment),
|
||||
body: attachmentMultipart(attachments[index]),
|
||||
},
|
||||
);
|
||||
markdown = String(uploaded?.markdown || '');
|
||||
const markdown = String(uploaded?.markdown || '');
|
||||
if (!markdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
persistDeliveryStage(item.id, { deliveredIssue: issue, attachmentMarkdown: markdown });
|
||||
markdowns.push(markdown);
|
||||
persistDeliveryStage(item.id, {
|
||||
deliveredIssue: issue, attachmentMarkdowns:markdowns.slice(),
|
||||
...(attachments.length === 1 ? {attachmentMarkdown:markdown} : {}),
|
||||
});
|
||||
}
|
||||
const markdown = markdowns.map((value, index) =>
|
||||
evidenceMarkdown(attachments[index], value, index)).join('\n\n');
|
||||
await fetchJson(
|
||||
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/comments',
|
||||
{
|
||||
@@ -329,6 +504,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
async function flushQueue(currentLogin) {
|
||||
const confirmed = [];
|
||||
const completions = [];
|
||||
const filings = [];
|
||||
|
||||
let blocked = 0;
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
for (const item of read()) {
|
||||
@@ -337,17 +514,20 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
const result = await sendItem(item, currentLogin);
|
||||
if (result.issue) {
|
||||
confirmed.push(result.issue);
|
||||
if (result.item?.relatedDraft) filings.push({issue:result.issue, relatedDraft:result.item.relatedDraft});
|
||||
|
||||
if (result.item?.completionIntent) completions.push({
|
||||
id: result.item.id,
|
||||
intent: result.item.completionIntent,
|
||||
ownerLogin: result.item.ownerLogin,
|
||||
operationId: result.item.operationId,
|
||||
estimateMinutes: result.item.estimateMinutes,
|
||||
issue: result.issue,
|
||||
});
|
||||
}
|
||||
if (result.transient) break;
|
||||
}
|
||||
return { confirmed, completions, remaining: read(), blocked };
|
||||
return { confirmed, completions, filings, remaining: read(), blocked };
|
||||
}
|
||||
|
||||
async function flush(currentLogin) {
|
||||
@@ -376,11 +556,14 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
const result = await sendItem(queued, currentLogin);
|
||||
return {
|
||||
confirmed: result.issue ? [result.issue] : [],
|
||||
filings: result.issue && result.item?.relatedDraft ? [{issue:result.issue, relatedDraft:result.item.relatedDraft}] : [],
|
||||
|
||||
completions: result.issue && result.item?.completionIntent ? [{
|
||||
id: result.item.id,
|
||||
intent: result.item.completionIntent,
|
||||
ownerLogin: result.item.ownerLogin,
|
||||
operationId: result.item.operationId,
|
||||
estimateMinutes: result.item.estimateMinutes,
|
||||
issue: result.issue,
|
||||
}] : [],
|
||||
remaining: read(), blocked: 0,
|
||||
@@ -425,6 +608,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
intent: item.completionIntent,
|
||||
ownerLogin: item.ownerLogin,
|
||||
operationId: item.operationId,
|
||||
estimateMinutes: item.estimateMinutes,
|
||||
issue: item.deliveredIssue,
|
||||
}));
|
||||
}
|
||||
|
||||
+397
-9
@@ -18,11 +18,146 @@ function createPlanningLoader({ loadLabels, loadMilestones }) {
|
||||
};
|
||||
}
|
||||
|
||||
function createIssueSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||
function escapeOptionHtml(value) {
|
||||
return String(value || '').replace(/[&<>"']/g, character => ({
|
||||
'&':'&', '<':'<', '>':'>', '"':'"', "'":''',
|
||||
})[character]);
|
||||
}
|
||||
|
||||
function appendChecklistTask(body, label) {
|
||||
const normalized = String(label || '').trim().replace(/\s+/g, ' ');
|
||||
if (!normalized) throw new Error('Enter a checklist step.');
|
||||
const key = normalized.toLocaleLowerCase();
|
||||
const duplicate = String(body || '').split('\n').some(line => {
|
||||
const match = line.match(/^\s*[-*+]\s+\[[ xX]\]\s+(.*)$/);
|
||||
return match && match[1].trim().replace(/\s+/g, ' ').toLocaleLowerCase() === key;
|
||||
});
|
||||
if (duplicate) throw new Error('That checklist step already exists.');
|
||||
const prefix = body ? String(body).replace(/\s+$/, '') + '\n' : '';
|
||||
return prefix + '- [ ] ' + normalized;
|
||||
}
|
||||
|
||||
function manageChecklistTask(raw, targetIndex, operation = {}) {
|
||||
const parts = String(raw || '').split(/(\r\n|\n|\r)/);
|
||||
let fenced = false;
|
||||
let taskIndex = 0;
|
||||
for (let index = 0; index < parts.length; index += 2) {
|
||||
const line = parts[index];
|
||||
if (/^\s*```/.test(line)) { fenced = !fenced; continue; }
|
||||
if (fenced) continue;
|
||||
const task = line.match(/^([-*+]\s+\[[ xX]\]\s+)(.*)$/);
|
||||
if (!task) continue;
|
||||
if (taskIndex === Number(targetIndex)) {
|
||||
if (operation.action === 'rename') {
|
||||
const label = String(operation.label || '').trim().replace(/\s+/g, ' ');
|
||||
if (!label) throw new Error('Enter a checklist step.');
|
||||
const key = label.toLocaleLowerCase();
|
||||
let inFence = false;
|
||||
const duplicate = parts.some((candidate, candidateIndex) => {
|
||||
if (candidateIndex % 2) return false;
|
||||
if (/^\s*```/.test(candidate)) { inFence = !inFence; return false; }
|
||||
if (inFence || candidateIndex === index) return false;
|
||||
const match = candidate.match(/^[-*+]\s+\[[ xX]\]\s+(.*)$/);
|
||||
return match && match[1].trim().replace(/\s+/g, ' ').toLocaleLowerCase() === key;
|
||||
});
|
||||
if (duplicate) throw new Error('That checklist step already exists.');
|
||||
parts[index] = task[1] + label;
|
||||
} else if (operation.action === 'remove') {
|
||||
if (index + 1 < parts.length) parts.splice(index, 2);
|
||||
else if (index > 0) parts.splice(index - 1, 2);
|
||||
} else if (operation.action === 'move-earlier' && index >= 2 && /^(\s*[-*+]\s+\[[ xX]\]\s+)/.test(parts[index - 2])) {
|
||||
[parts[index - 2], parts[index]] = [parts[index], parts[index - 2]];
|
||||
} else if (operation.action === 'move-later' && index + 2 < parts.length && /^(\s*[-*+]\s+\[[ xX]\]\s+)/.test(parts[index + 2])) {
|
||||
[parts[index], parts[index + 2]] = [parts[index + 2], parts[index]];
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
taskIndex += 1;
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function checklistIsComplete(raw) {
|
||||
const lines = String(raw || '').split(/(?:\r\n|\n|\r)/);
|
||||
let fenced = false;
|
||||
let found = false;
|
||||
for (const line of lines) {
|
||||
if (/^\s*```/.test(line)) { fenced = !fenced; continue; }
|
||||
if (fenced) continue;
|
||||
const task = line.match(/^\s*[-*+]\s+\[([ xX])\]\s+/);
|
||||
if (!task) continue;
|
||||
found = true;
|
||||
if (task[1].toLocaleLowerCase() !== 'x') return false;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function completeDelegatedParentTask(raw, childUrl) {
|
||||
const url = String(childUrl || '');
|
||||
if (!/^https:\/\/[^\s)]+$/.test(url)) throw new Error('The delegated issue link is unavailable.');
|
||||
const parts = String(raw || '').split(/(\r\n|\n|\r)/);
|
||||
let fenced = false;
|
||||
let taskIndex = 0;
|
||||
for (let index = 0; index < parts.length; index += 2) {
|
||||
const line = parts[index];
|
||||
if (/^\s*```/.test(line)) { fenced = !fenced; continue; }
|
||||
if (fenced) continue;
|
||||
const task = line.match(/^(\s*[-*+]\s+\[)([ xX])(\]\s+.*)$/);
|
||||
if (!task) continue;
|
||||
const linkedTask = task[3].match(/^\]\s+\[[^\]]+\]\((https:\/\/[^\s)]+)\)\s*$/);
|
||||
if (linkedTask?.[1] === url) {
|
||||
const alreadyCompleted = task[2].toLocaleLowerCase() === 'x';
|
||||
if (!alreadyCompleted) parts[index] = task[1] + 'x' + task[3];
|
||||
const body = parts.join('');
|
||||
return { body, taskIndex, alreadyCompleted, checklistComplete:checklistIsComplete(body) };
|
||||
}
|
||||
taskIndex += 1;
|
||||
}
|
||||
throw new Error('The parent checklist no longer links to this delegated issue.');
|
||||
}
|
||||
|
||||
async function finishDelegatedParentReview({ parentItem, parentDetail, childUrl, updateContent, closeParent }) {
|
||||
const completion = completeDelegatedParentTask(parentDetail?.body, childUrl);
|
||||
if (!completion.alreadyCompleted) {
|
||||
await updateContent(parentItem, {
|
||||
title:parentDetail.title,
|
||||
body:completion.body,
|
||||
expectedUpdatedAt:parentDetail.updated_at,
|
||||
});
|
||||
}
|
||||
if (completion.checklistComplete) await closeParent(parentItem);
|
||||
return { updated:!completion.alreadyCompleted, closed:completion.checklistComplete };
|
||||
}
|
||||
|
||||
function delegatedParentReference(childDetail) {
|
||||
const body = String(childDetail?.body || '');
|
||||
const match = body.match(/(?:^|\n)Related to \[([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#([1-9]\d*)\]\((https:\/\/[^\s)]+)\)\.(?:\r?$|\s)/m);
|
||||
if (!match) return null;
|
||||
return { repository:match[1], number:Number(match[2]), url:match[3] };
|
||||
}
|
||||
|
||||
function resolveDelegatedParent(child, childDetail, parentDetail) {
|
||||
const parent = delegatedParentReference(childDetail);
|
||||
if (!parent) return null;
|
||||
try {
|
||||
const completion = completeDelegatedParentTask(parentDetail?.body, child?.url);
|
||||
return {
|
||||
parent,
|
||||
taskIndex:completion.taskIndex,
|
||||
alreadyCompleted:completion.alreadyCompleted,
|
||||
checklistComplete:completion.checklistComplete,
|
||||
};
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.renderMarkdown, toggleTask = renderMarkdown?.toggleTask, manageTask: manageTaskTransform = manageChecklistTask, relatedTaskDraft: relatedTaskDraftTransform = (...args) => globalThis.createIssueCapture?.relatedChecklistDraft(...args), linkRelatedTask: linkRelatedTaskTransform = (...args) => globalThis.createIssueCapture?.linkChecklistTask(...args), enqueueDurably, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||
let commentRequest = null;
|
||||
let closeRequest = null;
|
||||
let releaseRequest = null;
|
||||
let handoffRequest = null;
|
||||
let reassignRequest = null;
|
||||
let labelRequest = null;
|
||||
let editRequest = null;
|
||||
let dueDateRequest = null;
|
||||
@@ -38,16 +173,25 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
const milestoneDraftKey = item => 'stackchain.issue-milestone.v1:' + item.repository + '#' + item.number;
|
||||
|
||||
return {
|
||||
delegatedParentReference,
|
||||
resolveDelegatedParent,
|
||||
completeDelegatedParentTask,
|
||||
readOnly(item) {
|
||||
return Boolean(item?.is_completed || (item?.is_filed && !item?.is_assigned));
|
||||
},
|
||||
load(item) {
|
||||
return fetchJson(issuePath(item) + '/detail', {
|
||||
const access = this.readOnly(item) ? '?access=filed' : '';
|
||||
return fetchJson(issuePath(item) + '/detail' + access, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
conversation(item, initialPage) {
|
||||
const access = this.readOnly(item) ? '&access=filed' : '';
|
||||
const pager = createConversationPager({
|
||||
loadPage: page => fetchJson(issuePath(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
|
||||
headers: { Accept: 'application/json' },
|
||||
}),
|
||||
loadPage: page => fetchJson(
|
||||
issuePath(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20' + access,
|
||||
{ headers: { Accept: 'application/json' } },
|
||||
),
|
||||
});
|
||||
pager.reset(initialPage || { comments: [], page: 1, older_page: null, total: 0 });
|
||||
return pager;
|
||||
@@ -64,7 +208,8 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
});
|
||||
},
|
||||
loadHandoffCandidates(item) {
|
||||
return fetchJson(issuePath(item) + '/handoff-candidates', {
|
||||
const access = item?.is_filed ? '?access=filed' : '';
|
||||
return fetchJson(issuePath(item) + '/handoff-candidates' + access, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
@@ -111,7 +256,8 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
updateContent(item, draft) {
|
||||
if (editRequest) return editRequest;
|
||||
this.saveEditDraft(item, draft);
|
||||
editRequest = fetchJson(issuePath(item) + '/content', {
|
||||
const access = this.readOnly(item) ? '?access=filed' : '';
|
||||
editRequest = fetchJson(issuePath(item) + '/content' + access, {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -129,6 +275,218 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
}).finally(() => { editRequest = null; });
|
||||
return editRequest;
|
||||
},
|
||||
toggleTask(item, detail, taskIndex, checked) {
|
||||
if (typeof toggleTask !== 'function') return Promise.reject(new Error('Checklist updates are unavailable.'));
|
||||
return this.updateContent(item, {
|
||||
title: detail.title,
|
||||
body: toggleTask(detail.body, taskIndex, checked),
|
||||
expectedUpdatedAt: detail.updated_at,
|
||||
});
|
||||
},
|
||||
manageTask(item, detail, taskIndex, operation) {
|
||||
if (typeof manageTaskTransform !== 'function') return Promise.reject(new Error('Checklist management is unavailable.'));
|
||||
return this.updateContent(item, {
|
||||
title: detail.title,
|
||||
body: manageTaskTransform(detail.body, taskIndex, operation),
|
||||
expectedUpdatedAt: detail.updated_at,
|
||||
});
|
||||
},
|
||||
relatedTaskDraft(item, _detail, _taskIndex, label) {
|
||||
if (typeof relatedTaskDraftTransform !== 'function') throw new Error('Related issue capture is unavailable.');
|
||||
return relatedTaskDraftTransform(item, label);
|
||||
},
|
||||
linkRelatedTask(item, detail, taskIndex, child) {
|
||||
if (typeof linkRelatedTaskTransform !== 'function') return Promise.reject(new Error('Related issue linking is unavailable.'));
|
||||
return this.updateContent(item, {
|
||||
title:detail.title,
|
||||
body:linkRelatedTaskTransform(detail.body, taskIndex, child),
|
||||
expectedUpdatedAt:detail.updated_at,
|
||||
});
|
||||
},
|
||||
async addTask(item, detail, label) {
|
||||
const body = appendChecklistTask(detail.body, label);
|
||||
return this.updateContent(item, {
|
||||
title: detail.title,
|
||||
body,
|
||||
expectedUpdatedAt: detail.updated_at,
|
||||
});
|
||||
},
|
||||
async queueAddedTask(item, detail, label) {
|
||||
if (typeof enqueueDurably !== 'function') {
|
||||
throw new Error('Offline checklist updates are unavailable.');
|
||||
}
|
||||
const body = appendChecklistTask(detail.body, label);
|
||||
await enqueueDurably({ kind:'issue-content', repository:item.repository, number:item.number,
|
||||
title:detail.title, baseBody:detail.body, body, expectedUpdatedAt:detail.updated_at });
|
||||
return { queued:true, detail:{ ...detail, body, checklist_pending:true } };
|
||||
},
|
||||
async queueManagedTask(item, detail, taskIndex, operation) {
|
||||
if (typeof manageTaskTransform !== 'function' || typeof enqueueDurably !== 'function') {
|
||||
throw new Error('Offline checklist management is unavailable.');
|
||||
}
|
||||
const body = manageTaskTransform(detail.body, taskIndex, operation);
|
||||
const checklistOperation = {
|
||||
action:String(operation?.action || ''), index:Number(taskIndex),
|
||||
...(operation?.label ? { label:String(operation.label) } : {}),
|
||||
};
|
||||
await enqueueDurably({ kind:'issue-content', repository:item.repository, number:item.number,
|
||||
title:detail.title, baseBody:detail.body, body, expectedUpdatedAt:detail.updated_at, checklistOperation });
|
||||
return { queued:true, detail:{ ...detail, body, checklist_pending:true } };
|
||||
},
|
||||
async queueTask(item, detail, taskIndex, checked) {
|
||||
if (typeof toggleTask !== 'function' || typeof enqueueDurably !== 'function') {
|
||||
throw new Error('Offline checklist updates are unavailable.');
|
||||
}
|
||||
const body = toggleTask(detail.body, taskIndex, checked);
|
||||
await enqueueDurably({ kind:'issue-content', repository:item.repository, number:item.number,
|
||||
title:detail.title, baseBody:detail.body, body, expectedUpdatedAt:detail.updated_at });
|
||||
return { queued:true, detail:{ ...detail, body, checklist_pending:true } };
|
||||
},
|
||||
pendingTask(item, detail, items, ownerLogin) {
|
||||
const pending = [...(items || [])].reverse().find(candidate => candidate?.kind === 'issue-content' &&
|
||||
candidate.repository === item?.repository && Number(candidate.number) === Number(item?.number) &&
|
||||
candidate.ownerLogin === ownerLogin && ['queued', 'sending', 'attention'].includes(candidate.status));
|
||||
if (!pending) return { ...detail };
|
||||
return { ...detail, title:pending.title, body:pending.body, checklist_pending:true };
|
||||
},
|
||||
bindTaskToggles({ container, status, retry, current, confirmed, restore }) {
|
||||
container.addEventListener('change', async event => {
|
||||
const control = event.target.closest('input.task-list-toggle');
|
||||
const state = current();
|
||||
if (!control || !state?.item || !state?.detail?.updated_at) return;
|
||||
container.classList.add('checklist-pending');
|
||||
container.querySelectorAll('input.task-list-toggle').forEach(input => { input.disabled = true; });
|
||||
status.textContent = 'Updating checklist…';
|
||||
try {
|
||||
const result = await (state.offline ? this.queueTask : this.toggleTask).call(
|
||||
this, state.item, state.detail, Number(control.dataset.taskIndex), control.checked
|
||||
);
|
||||
const latest = current();
|
||||
if (latest?.item?.repository === state.item.repository && latest.item.number === state.item.number) {
|
||||
confirmed(state.item, state.detail, state.offline ? result.detail : result);
|
||||
if (state.offline) status.textContent = 'Checklist queued. Pending sync.';
|
||||
else status.textContent = 'Checklist updated.';
|
||||
}
|
||||
} catch (error) {
|
||||
const latest = current();
|
||||
if (latest?.item?.repository === state.item.repository && latest.item.number === state.item.number) {
|
||||
restore(state.detail);
|
||||
status.textContent = error.message + ' Checklist was not changed; reload latest or use Edit issue.';
|
||||
retry.hidden = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
bindTaskManagement({ container, editor, label, earlier, later, fileRelated, onFileRelated, remove, cancel, status, sheetStatus, retry, current, confirmed }) {
|
||||
let taskIndex = null;
|
||||
let trigger = null;
|
||||
const reset = (returnFocus = false) => {
|
||||
editor.hidden = true;
|
||||
status.textContent = '';
|
||||
if (returnFocus && trigger?.isConnected) trigger.focus();
|
||||
taskIndex = null;
|
||||
trigger = null;
|
||||
};
|
||||
container.addEventListener('click', event => {
|
||||
const control = event.target.closest('button.task-list-manage');
|
||||
if (!control || !current()?.detail?.updated_at) return;
|
||||
taskIndex = Number(control.dataset.taskIndex);
|
||||
trigger = control;
|
||||
label.value = control.dataset.taskLabel || '';
|
||||
earlier.disabled = control.dataset.taskFirst === 'true';
|
||||
later.disabled = control.dataset.taskLast === 'true';
|
||||
fileRelated.disabled = /^\s*\[[^\]]+\]\([^)]+\)\s*$/.test(label.value);
|
||||
editor.hidden = false;
|
||||
status.textContent = fileRelated.disabled ? 'This step already links to related work.' :
|
||||
'Rename, reorder, remove, or file this step as related work.';
|
||||
label.focus();
|
||||
});
|
||||
const run = async operation => {
|
||||
const state = current();
|
||||
const selectedIndex = taskIndex;
|
||||
if (!state?.item || !state.detail?.updated_at || selectedIndex === null) return;
|
||||
if (operation.action === 'remove' && !globalThis.confirm('Remove this checklist step?')) return;
|
||||
const controls = editor.querySelectorAll('button,input');
|
||||
controls.forEach(control => { control.disabled = true; });
|
||||
status.textContent = state.offline ? 'Queueing checklist change…' : 'Updating checklist…';
|
||||
try {
|
||||
const result = await (state.offline ? this.queueManagedTask : this.manageTask).call(
|
||||
this, state.item, state.detail, selectedIndex, operation
|
||||
);
|
||||
if (current()?.item !== state.item) return;
|
||||
confirmed(state.item, state.detail, state.offline ? result.detail : result);
|
||||
reset();
|
||||
sheetStatus.textContent = state.offline ? 'Checklist change queued. Pending sync.' : 'Checklist step updated.';
|
||||
const focusIndex = operation.action === 'remove' ? Math.max(0, selectedIndex - 1) : selectedIndex;
|
||||
container.querySelector('button.task-list-manage[data-task-index="' + focusIndex + '"]')?.focus();
|
||||
} catch (error) {
|
||||
if (current()?.item === state.item) {
|
||||
status.textContent = error.message + ' Your change is still here; retry.';
|
||||
retry.hidden = false;
|
||||
label.focus();
|
||||
}
|
||||
} finally {
|
||||
if (!editor.hidden) controls.forEach(control => { control.disabled = false; });
|
||||
}
|
||||
};
|
||||
editor.addEventListener('submit', event => { event.preventDefault(); run({ action:'rename', label:label.value }); });
|
||||
earlier.addEventListener('click', () => run({ action:'move-earlier' }));
|
||||
later.addEventListener('click', () => run({ action:'move-later' }));
|
||||
fileRelated.addEventListener('click', async () => {
|
||||
const state = current();
|
||||
const selectedIndex = taskIndex;
|
||||
if (!state?.item || !state.detail?.updated_at || selectedIndex === null || fileRelated.disabled) return;
|
||||
try {
|
||||
await onFileRelated({ item:state.item, detail:state.detail, taskIndex:selectedIndex,
|
||||
label:label.value, trigger });
|
||||
reset();
|
||||
} catch (error) {
|
||||
status.textContent = error.message + ' The checklist step was not changed.';
|
||||
label.focus();
|
||||
}
|
||||
});
|
||||
remove.addEventListener('click', () => run({ action:'remove' }));
|
||||
cancel.addEventListener('click', () => reset(true));
|
||||
return { reset };
|
||||
},
|
||||
renderTasks(container, detail, interactive) {
|
||||
container.classList.toggle('checklist-pending', detail.checklist_pending === true);
|
||||
container.innerHTML = renderMarkdown(
|
||||
detail.body || 'No description provided.', {
|
||||
interactiveTasks: Boolean(interactive),
|
||||
manageTasks: Boolean(interactive),
|
||||
}
|
||||
);
|
||||
},
|
||||
checklistCompletion(container, { interactive, today, offline, dismissed = false }) {
|
||||
const tasks = Array.from(container.querySelectorAll('input.task-list-toggle'));
|
||||
const visible = Boolean(interactive && !dismissed && tasks.length && tasks.every(task => task.checked));
|
||||
return {
|
||||
visible,
|
||||
status: offline ? 'Checklist complete · pending sync' : 'Checklist complete',
|
||||
label: today ? (offline ? 'Queue close & next' : 'Close & next') :
|
||||
(offline ? 'Queue issue closure' : 'Close issue'),
|
||||
};
|
||||
},
|
||||
mergeContent(snapshot, item, detail, confirmed, replace) {
|
||||
return {
|
||||
snapshot: snapshot ? replace(snapshot, item.repository, item.number, confirmed) : snapshot,
|
||||
item: { ...item, ...confirmed, key:item.key },
|
||||
detail: { ...detail, ...confirmed },
|
||||
};
|
||||
},
|
||||
renderMilestoneEditor(item, confirmed, milestones, document = globalThis.document) {
|
||||
const select = document.querySelector('#issue-milestone');
|
||||
const status = document.querySelector('#issue-milestone-status');
|
||||
select.innerHTML = '<option value="">No milestone</option>' + milestones.map(milestone =>
|
||||
'<option value="' + Number(milestone.id) + '">' + escapeOptionHtml(milestone.title) + '</option>'
|
||||
).join('');
|
||||
select.value = String(this.loadMilestoneDraft(item) ?? confirmed?.id ?? '');
|
||||
select.disabled = false;
|
||||
document.querySelector('#save-issue-milestone').disabled = false;
|
||||
status.textContent = confirmed ? 'Planned for ' + confirmed.title + '.' : 'No milestone set.';
|
||||
},
|
||||
|
||||
loadDueDateDraft(item) {
|
||||
try {
|
||||
const raw = storage?.getItem(dueDateDraftKey(item));
|
||||
@@ -208,11 +566,13 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
},
|
||||
close(item) {
|
||||
if (closeRequest) return closeRequest;
|
||||
closeRequest = fetchJson(issuePath(item) + '/close', {
|
||||
const access = item?.is_filed && !item?.is_assigned ? '?access=filed' : '';
|
||||
closeRequest = fetchJson(issuePath(item) + '/close' + access, {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(result => {
|
||||
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
|
||||
globalThis.dispatchEvent?.(new CustomEvent('stackchain:issue-closed',{detail:item}));
|
||||
return result;
|
||||
}).finally(() => { closeRequest = null; });
|
||||
return closeRequest;
|
||||
@@ -254,6 +614,28 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
}).finally(() => { handoffRequest = null; });
|
||||
return handoffRequest;
|
||||
},
|
||||
reassign(item, recipient) {
|
||||
if (reassignRequest) return reassignRequest;
|
||||
const expectedAssignees = Array.isArray(item?.assignees) ? item.assignees.slice() : [];
|
||||
reassignRequest = fetchJson(issuePath(item) + '/reassign', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ recipient, expected_assignees: expectedAssignees }),
|
||||
}).then(result => {
|
||||
if (
|
||||
result?.number !== item.number ||
|
||||
result?.recipient !== recipient ||
|
||||
!Array.isArray(result.assignees) ||
|
||||
result.assignees.length !== 1 ||
|
||||
result.assignees[0] !== recipient ||
|
||||
JSON.stringify(result.previous_assignees) !== JSON.stringify(expectedAssignees)
|
||||
) {
|
||||
throw new Error('Issue reassignment was not confirmed.');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { reassignRequest = null; });
|
||||
return reassignRequest;
|
||||
},
|
||||
comment(item, body) {
|
||||
if (commentRequest) return commentRequest;
|
||||
this.saveDraft(item, body);
|
||||
@@ -262,7 +644,8 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
operationId = storage?.getItem(operationKey(item)) || String(createOperationId()).slice(0, 128);
|
||||
storage?.setItem(operationKey(item), operationId);
|
||||
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
|
||||
commentRequest = fetchJson(issuePath(item) + '/comments', {
|
||||
const access = this.readOnly(item) ? '?access=filed' : '';
|
||||
commentRequest = fetchJson(issuePath(item) + '/comments' + access, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': operationId },
|
||||
body: JSON.stringify({ body }),
|
||||
@@ -280,4 +663,9 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
|
||||
createIssueSheet.createPlanningLoader = createPlanningLoader;
|
||||
|
||||
createIssueSheet.manageChecklistTask = manageChecklistTask;
|
||||
createIssueSheet.completeDelegatedParentTask = completeDelegatedParentTask;
|
||||
createIssueSheet.finishDelegatedParentReview = finishDelegatedParentReview;
|
||||
createIssueSheet.delegatedParentReference = delegatedParentReference;
|
||||
createIssueSheet.resolveDelegatedParent = resolveDelegatedParent;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;
|
||||
@@ -90,7 +90,8 @@ function createLaterPicker({
|
||||
onState({ open:true, message:result.message, value:String(input || '') });
|
||||
return false;
|
||||
}
|
||||
const confirmed = onConfirm(item, result.value, context);
|
||||
const confirmed = typeof item?.confirm === 'function' ? item.confirm(result.value) :
|
||||
onConfirm(item, result.value, context);
|
||||
if (confirmed !== true && typeof confirmed !== 'function') return false;
|
||||
committed = true;
|
||||
afterClose = typeof confirmed === 'function' ? confirmed : null;
|
||||
|
||||
@@ -160,15 +160,17 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
||||
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
function enqueue(action, itemId, wakeAt = null) {
|
||||
function enqueue(action, itemId, wakeAt = null, handoff = null) {
|
||||
if (!['defer', 'restore'].includes(action) || !itemId ||
|
||||
(action === 'defer' && typeof wakeAt !== 'string')) return false;
|
||||
(action === 'defer' && typeof wakeAt !== 'string') ||
|
||||
![null, 'today'].includes(handoff)) return false;
|
||||
pending().filter(operation => operation.item_id === itemId)
|
||||
.forEach(operation => removeOperation(operation.operation_id));
|
||||
const operation = {
|
||||
operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt,
|
||||
base_revision: Math.max(0, savedRevision()),
|
||||
};
|
||||
if (handoff) operation.handoff = handoff;
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||
@@ -189,9 +191,11 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
||||
const marker = migrationPrefix + storageKey.slice(prefix.length);
|
||||
try {
|
||||
if (storage.getItem(marker)) return false;
|
||||
Object.entries(records || {}).forEach(([itemId, wakeAt]) =>
|
||||
enqueue('defer', itemId, wakeAt)
|
||||
);
|
||||
Object.entries(records || {}).forEach(([itemId, record]) => {
|
||||
const wakeAt = record && typeof record === 'object' ? record.wake_at : record;
|
||||
const handoff = record?.handoff === 'today' ? 'today' : null;
|
||||
enqueue('defer', itemId, wakeAt, handoff);
|
||||
});
|
||||
storage.setItem(marker, '1');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
|
||||
+22
-10
@@ -38,7 +38,7 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
}
|
||||
}
|
||||
|
||||
function defer(item, until) {
|
||||
function defer(item, until, options = {}) {
|
||||
const key = storageKey();
|
||||
const id = identity(item);
|
||||
const wake = new Date(until);
|
||||
@@ -46,9 +46,10 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
if (!id || Number.isNaN(wake.getTime()) || wake <= now()) return 'invalid';
|
||||
const records = read();
|
||||
const wakeAt = wake.toISOString();
|
||||
records[id] = wakeAt;
|
||||
const handoff = options.handoff === 'today' ? 'today' : null;
|
||||
records[id] = handoff ? { wake_at:wakeAt, handoff } : wakeAt;
|
||||
if (!write(records)) return 'unavailable';
|
||||
onChange('defer', id, wakeAt);
|
||||
onChange('defer', id, wakeAt, handoff);
|
||||
return 'deferred';
|
||||
}
|
||||
|
||||
@@ -93,9 +94,13 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
function adopt(records) {
|
||||
if (!records || typeof records !== 'object' || Array.isArray(records)) return false;
|
||||
const normalized = {};
|
||||
Object.entries(records).forEach(([id, wake]) => {
|
||||
Object.entries(records).forEach(([id, record]) => {
|
||||
const wake = record && typeof record === 'object' ? record.wake_at : record;
|
||||
const wakeTime = new Date(wake).getTime();
|
||||
if (id && Number.isFinite(wakeTime)) normalized[id] = new Date(wakeTime).toISOString();
|
||||
if (id && Number.isFinite(wakeTime)) {
|
||||
const wakeAt = new Date(wakeTime).toISOString();
|
||||
normalized[id] = record?.handoff === 'today' ? { wake_at:wakeAt, handoff:'today' } : wakeAt;
|
||||
}
|
||||
});
|
||||
return write(normalized);
|
||||
}
|
||||
@@ -119,25 +124,32 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
const wakeTimes = [];
|
||||
const expired = [];
|
||||
|
||||
Object.entries(records).forEach(([id, wake]) => {
|
||||
const handoffs = [];
|
||||
Object.entries(records).forEach(([id, record]) => {
|
||||
const wake = record && typeof record === 'object' ? record.wake_at : record;
|
||||
const wakeTime = new Date(wake).getTime();
|
||||
if (!Number.isFinite(wakeTime) || wakeTime <= current) {
|
||||
if (Number.isFinite(wakeTime)) expired.push(id);
|
||||
if (Number.isFinite(wakeTime)) {
|
||||
expired.push(id);
|
||||
if (record?.handoff === 'today') handoffs.push(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pruneMissing && !available.has(id)) return;
|
||||
retained[id] = new Date(wakeTime).toISOString();
|
||||
const wakeAt = new Date(wakeTime).toISOString();
|
||||
retained[id] = record?.handoff === 'today' ? { wake_at:wakeAt, handoff:'today' } : wakeAt;
|
||||
wakeTimes.push(wakeTime);
|
||||
});
|
||||
|
||||
if (JSON.stringify(retained) !== JSON.stringify(records) && write(retained) && expired.length) {
|
||||
onExpire(expired);
|
||||
onExpire(expired, handoffs);
|
||||
}
|
||||
schedule(wakeTimes, current);
|
||||
const deferredIds = new Set(Object.keys(retained));
|
||||
const active = (items || []).filter(item => !deferredIds.has(identity(item)));
|
||||
const later = (items || []).flatMap(item => {
|
||||
const wake = retained[identity(item)];
|
||||
const record = retained[identity(item)];
|
||||
const wake = record && typeof record === 'object' ? record.wake_at : record;
|
||||
return wake ? [{ ...item, deferred_until: wake }] : [];
|
||||
});
|
||||
return { active, later };
|
||||
|
||||
@@ -74,5 +74,112 @@
|
||||
return { run };
|
||||
}
|
||||
|
||||
return { describe, createRefreshController };
|
||||
function createSheetController(options) {
|
||||
let ownsDetour = false;
|
||||
let backgroundState = null;
|
||||
|
||||
function focusableControls() {
|
||||
return [...options.sheet.querySelectorAll('button:not([disabled]), [tabindex]:not([tabindex="-1"])')]
|
||||
.filter(control => !control.hidden);
|
||||
}
|
||||
|
||||
function containBackground() {
|
||||
if (backgroundState) return;
|
||||
backgroundState = new Map((options.backgroundElements || []).map(element => [element, element.inert]));
|
||||
backgroundState.forEach((_wasInert, element) => { element.inert = true; });
|
||||
}
|
||||
|
||||
function releaseBackground() {
|
||||
if (!backgroundState) return;
|
||||
backgroundState.forEach((wasInert, element) => { element.inert = wasInert; });
|
||||
backgroundState = null;
|
||||
}
|
||||
|
||||
function finishClose() {
|
||||
if (options.sheet.hidden) return;
|
||||
options.sheet.hidden = true;
|
||||
options.trigger.setAttribute('aria-expanded', 'false');
|
||||
if (options.pausedStatus) options.pausedStatus.hidden = true;
|
||||
releaseBackground();
|
||||
if (ownsDetour) options.timerView?.finishDetour?.();
|
||||
ownsDetour = false;
|
||||
options.trigger.focus?.();
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (options.history?.state?.liveDataStatus) {
|
||||
options.history.back();
|
||||
return;
|
||||
}
|
||||
finishClose();
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (!options.sheet.hidden) return;
|
||||
ownsDetour = options.timerView?.beginDetour?.('live-data-status')?.reason === 'live-data-status';
|
||||
if (options.pausedStatus) options.pausedStatus.hidden = !ownsDetour;
|
||||
if (options.returnButton) options.returnButton.hidden = !ownsDetour;
|
||||
containBackground();
|
||||
options.sheet.hidden = false;
|
||||
options.trigger.setAttribute('aria-expanded', 'true');
|
||||
if (options.history && !options.history.state?.liveDataStatus) {
|
||||
options.history.pushState({ ...options.history.state, liveDataStatus:true }, '');
|
||||
}
|
||||
options.closeButton.focus();
|
||||
}
|
||||
|
||||
function start() {
|
||||
options.trigger.addEventListener('click', open);
|
||||
options.closeButton.addEventListener('click', close);
|
||||
options.returnButton?.addEventListener('click', close);
|
||||
options.sheet.addEventListener('click', event => {
|
||||
if (event.target === options.sheet) close();
|
||||
});
|
||||
options.escapeTarget?.addEventListener('keydown', event => {
|
||||
if (options.sheet.hidden) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault?.();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const controls = focusableControls();
|
||||
if (!controls.length) return;
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && event.target === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && event.target === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
options.historyTarget?.addEventListener('popstate', event => {
|
||||
if (!options.sheet.hidden && !event.state?.liveDataStatus) finishClose();
|
||||
});
|
||||
}
|
||||
|
||||
return { start, open, close };
|
||||
}
|
||||
|
||||
function mount(options) {
|
||||
const document = options.document;
|
||||
const qs = selector => document.querySelector(selector);
|
||||
const trigger = qs('#open-live-data-status');
|
||||
trigger.addEventListener('click', options.onOpen);
|
||||
return createSheetController({
|
||||
sheet:qs('#live-data-status-sheet'), trigger,
|
||||
closeButton:qs('#close-live-data-status'),
|
||||
returnButton:qs('#return-from-live-data-status'),
|
||||
pausedStatus:qs('#live-data-status-today-paused'),
|
||||
timerView:options.timerView,
|
||||
history:options.window.history,
|
||||
historyTarget:options.window,
|
||||
escapeTarget:document,
|
||||
backgroundElements:[qs('header'), qs('main'), qs('#mobile-task-dock')].filter(Boolean),
|
||||
});
|
||||
}
|
||||
|
||||
return { describe, createRefreshController, createSheetController, mount };
|
||||
});
|
||||
+14
-3
@@ -4,7 +4,8 @@
|
||||
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
|
||||
function validShareContinuation(value) {
|
||||
if (typeof value !== 'string' || !value.startsWith('./?') || value.includes('#')) return './';
|
||||
const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 5, search: 200, preview: 200 };
|
||||
const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 6, search: 200,
|
||||
preview: 200, search_kind: 5, search_state: 6, search_repository: 161 };
|
||||
const params = new URLSearchParams(value.slice(3));
|
||||
const entries = Array.from(params.entries());
|
||||
if (!entries.length) return './';
|
||||
@@ -18,15 +19,25 @@
|
||||
const search = params.get('search');
|
||||
const preview = params.get('preview');
|
||||
if (search !== null || preview !== null) {
|
||||
if (entries.some(([name]) => !['search', 'preview'].includes(name))) return './';
|
||||
if (entries.some(([name]) => ![
|
||||
'search', 'preview', 'search_kind', 'search_state', 'search_repository'
|
||||
].includes(name))) return './';
|
||||
if (search === null || preview === null) return './';
|
||||
if (!/^(issue|pull):[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+:[1-9]\d*$/.test(preview)) return './';
|
||||
const kind = params.get('search_kind');
|
||||
const state = params.get('search_state');
|
||||
const repository = params.get('search_repository');
|
||||
if (kind !== null && !['all', 'issue', 'pull'].includes(kind)) return './';
|
||||
if (state !== null && !['all', 'open', 'closed'].includes(state)) return './';
|
||||
if (repository !== null && !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) return './';
|
||||
return value;
|
||||
}
|
||||
const launch = params.get('launch');
|
||||
const shared = params.get('shared');
|
||||
if (launch && !['continue', 'new', 'agenda'].includes(launch)) return './';
|
||||
if (shared !== null && (shared !== 'image' || launch !== 'new')) return './';
|
||||
if (shared === 'bundle') {
|
||||
if (launch !== 'new' || entries.some(([name]) => !['launch', 'shared'].includes(name))) return './';
|
||||
} else if (shared !== null && (shared !== 'image' || launch !== 'new')) return './';
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "./",
|
||||
"action": "./share-target",
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": {
|
||||
|
||||
+43
-6
@@ -32,23 +32,33 @@
|
||||
return rendered.replace(/\u0000CODE(\d+)\u0000/g, (_match, index) => code[Number(index)]);
|
||||
}
|
||||
|
||||
function renderList(lines) {
|
||||
function renderList(lines, options, firstTaskIndex) {
|
||||
const taskList = lines.every(line => /^[-*+]\s+\[[ xX]\]\s+/.test(line));
|
||||
const items = lines.map(line => {
|
||||
const items = lines.map((line, offset) => {
|
||||
let body = line.replace(/^[-*+]\s+/, '');
|
||||
if (!taskList) return '<li>' + renderInline(body) + '</li>';
|
||||
const checked = /^\[[xX]\]\s+/.test(body);
|
||||
body = body.replace(/^\[[ xX]\]\s+/, '');
|
||||
return '<li class="task-list-item"><input type="checkbox" disabled' +
|
||||
(checked ? ' checked' : '') + '> ' + renderInline(body) + '</li>';
|
||||
const control = options.interactiveTasks ?
|
||||
' class="task-list-toggle" data-task-index="' + (firstTaskIndex + offset) +
|
||||
'" aria-label="Mark ' + escapeHtml(body) + (checked ? ' incomplete"' : ' complete"') :
|
||||
' disabled';
|
||||
const manage = options.interactiveTasks && options.manageTasks ?
|
||||
'<button type="button" class="task-list-manage" data-task-index="' + (firstTaskIndex + offset) +
|
||||
'" data-task-label="' + escapeHtml(body) + '" aria-label="Manage step: ' + escapeHtml(body) + '"' +
|
||||
(offset === 0 ? ' data-task-first="true"' : '') +
|
||||
(offset === lines.length - 1 ? ' data-task-last="true"' : '') + '>Manage</button>' : '';
|
||||
return '<li class="task-list-item"><input type="checkbox"' + control +
|
||||
(checked ? ' checked' : '') + '> ' + renderInline(body) + manage + '</li>';
|
||||
}).join('');
|
||||
return '<ul' + (taskList ? ' class="task-list"' : '') + '>' + items + '</ul>';
|
||||
}
|
||||
|
||||
return function renderMarkdown(raw) {
|
||||
function renderMarkdown(raw, options = {}) {
|
||||
const lines = String(raw || '').replace(/\r\n?/g, '\n').split('\n');
|
||||
const output = [];
|
||||
let index = 0;
|
||||
let taskIndex = 0;
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
if (!line.trim()) {
|
||||
@@ -90,7 +100,10 @@
|
||||
list.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
output.push(renderList(list));
|
||||
output.push(renderList(list, options, taskIndex));
|
||||
if (list.every(candidate => /^[-*+]\s+\[[ xX]\]\s+/.test(candidate))) {
|
||||
taskIndex += list.length;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const paragraph = [];
|
||||
@@ -103,5 +116,29 @@
|
||||
output.push('<p>' + renderInline(paragraph.join('\n')).replace(/\n/g, '<br>') + '</p>');
|
||||
}
|
||||
return output.join('');
|
||||
}
|
||||
|
||||
renderMarkdown.toggleTask = function toggleTask(raw, targetIndex, checked) {
|
||||
const parts = String(raw || '').split(/(\r\n|\n|\r)/);
|
||||
let fenced = false;
|
||||
let taskIndex = 0;
|
||||
for (let index = 0; index < parts.length; index += 2) {
|
||||
const line = parts[index];
|
||||
if (/^\s*```/.test(line)) {
|
||||
fenced = !fenced;
|
||||
continue;
|
||||
}
|
||||
if (fenced) continue;
|
||||
const task = line.match(/^([-*+]\s+\[)([ xX])(\])/);
|
||||
if (!task) continue;
|
||||
if (taskIndex === Number(targetIndex)) {
|
||||
parts[index] = task[1] + (checked ? 'x' : ' ') + task[3] + line.slice(task[0].length);
|
||||
return parts.join('');
|
||||
}
|
||||
taskIndex += 1;
|
||||
}
|
||||
return parts.join('');
|
||||
};
|
||||
|
||||
return renderMarkdown;
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
(function(root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileAppBadge = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileAppBadge({
|
||||
control,
|
||||
status,
|
||||
container,
|
||||
navigator,
|
||||
storage,
|
||||
serviceWorker,
|
||||
}) {
|
||||
const ENABLED_KEY = 'stackchain.app-badge.enabled.v1';
|
||||
let enabled = false;
|
||||
const confirmedCounts = {updates:0, following:0};
|
||||
let renderedCount = null;
|
||||
|
||||
|
||||
function available() {
|
||||
return typeof navigator?.setAppBadge === 'function'
|
||||
&& typeof navigator?.clearAppBadge === 'function';
|
||||
}
|
||||
|
||||
async function syncPreference() {
|
||||
try {
|
||||
const registration = await serviceWorker?.ready;
|
||||
registration?.active?.postMessage({type:'stackchain-app-badge-preference', enabled});
|
||||
return Boolean(registration?.active);
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncCount(channel, count) {
|
||||
if (!enabled) return;
|
||||
try {
|
||||
const registration = await serviceWorker?.ready;
|
||||
registration?.active?.postMessage({type:'stackchain-app-badge-count', channel, count});
|
||||
} catch (_error) { /* A later refresh can retry. */ }
|
||||
}
|
||||
|
||||
async function render() {
|
||||
const confirmedCount = Math.min(9999, confirmedCounts.updates + confirmedCounts.following);
|
||||
if (!enabled || !available() || renderedCount === confirmedCount) return true;
|
||||
try {
|
||||
if (confirmedCount > 0) await navigator.setAppBadge(confirmedCount);
|
||||
else await navigator.clearAppBadge();
|
||||
renderedCount = confirmedCount;
|
||||
if (status) status.textContent = 'App icon badge is on and up to date.';
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (status) status.textContent = 'Could not update the app icon badge. Stackchain still has the confirmed count.';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function change() {
|
||||
enabled = Boolean(control?.checked);
|
||||
if (enabled) storage?.setItem(ENABLED_KEY, 'true');
|
||||
else storage?.removeItem(ENABLED_KEY);
|
||||
await syncPreference();
|
||||
if (enabled) {
|
||||
if (confirmedCounts.updates > 0) await syncCount('updates', confirmedCounts.updates);
|
||||
if (confirmedCounts.following > 0) await syncCount('following', confirmedCounts.following);
|
||||
}
|
||||
if (!enabled && available()) {
|
||||
confirmedCounts.updates = 0;
|
||||
confirmedCounts.following = 0;
|
||||
try {
|
||||
await navigator.clearAppBadge();
|
||||
renderedCount = null;
|
||||
if (status) status.textContent = 'App icon badge is off.';
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (status) status.textContent = 'App icon badge is off, but the browser could not clear the old count.';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return render();
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (!available()) {
|
||||
if (container) container.hidden = true;
|
||||
if (control) control.disabled = true;
|
||||
if (status) status.textContent = 'App icon badges are unavailable in this browser.';
|
||||
return false;
|
||||
}
|
||||
enabled = storage?.getItem(ENABLED_KEY) === 'true';
|
||||
if (enabled) void syncPreference();
|
||||
if (control) {
|
||||
control.checked = enabled;
|
||||
control.addEventListener('change', change);
|
||||
}
|
||||
if (status) status.textContent = enabled ? 'App icon badge is on.' : 'App icon badge is off.';
|
||||
return true;
|
||||
}
|
||||
|
||||
function readiness() {
|
||||
if (!available()) return {state:'unavailable', detail:'App icon badges are unavailable in this browser.'};
|
||||
return enabled
|
||||
? {state:'complete', detail:'The app icon shows review attention.'}
|
||||
: {state:'incomplete', detail:'Show review attention without opening Stackchain.'};
|
||||
}
|
||||
|
||||
async function enable() {
|
||||
if (!available()) return false;
|
||||
if (control) control.checked = true;
|
||||
await change();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function reconcile(channel, count, authoritative = false) {
|
||||
if (!Object.hasOwn(confirmedCounts, channel)
|
||||
|| authoritative !== true || !Number.isSafeInteger(count) || count < 0 || count > 9999) return false;
|
||||
confirmedCounts[channel] = count;
|
||||
await syncCount(channel, count);
|
||||
return render();
|
||||
}
|
||||
|
||||
return {start, change, reconcile, readiness, enable};
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
function createMobileCreateIssueNavigation(options) {
|
||||
const buttons = options.buttons || {};
|
||||
const targets = options.targets || {};
|
||||
const listeners = new Map();
|
||||
const prefersReducedMotion = options.prefersReducedMotion || (() => false);
|
||||
const targetNames = new Map(Object.entries(targets).map(([name, target]) => [target, name]));
|
||||
let filingAvailable = !options.filing?.hidden;
|
||||
let observer = null;
|
||||
let navigationLockUntil = 0;
|
||||
|
||||
function select(name) {
|
||||
Object.entries(buttons).forEach(([key, button]) => {
|
||||
if (!button) return;
|
||||
if (key === name) button.setAttribute('aria-current', 'location');
|
||||
else button.removeAttribute('aria-current');
|
||||
});
|
||||
}
|
||||
|
||||
function setFilingAvailable(available) {
|
||||
filingAvailable = Boolean(available);
|
||||
const button = buttons.file;
|
||||
if (!button) return;
|
||||
if (filingAvailable) button.removeAttribute('aria-disabled');
|
||||
else button.setAttribute('aria-disabled', 'true');
|
||||
}
|
||||
|
||||
function navigate(name) {
|
||||
if (name === 'file') setFilingAvailable(!options.filing?.hidden);
|
||||
if (name === 'file' && !filingAvailable) return false;
|
||||
const target = targets[name];
|
||||
if (!target) return false;
|
||||
target.scrollIntoView({
|
||||
block: 'start',
|
||||
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
|
||||
});
|
||||
navigationLockUntil = Date.now() + 500;
|
||||
select(name);
|
||||
return true;
|
||||
}
|
||||
|
||||
function reset(available = !options.filing?.hidden) {
|
||||
setFilingAvailable(available);
|
||||
select('describe');
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
Object.entries(buttons).forEach(([name, button]) => {
|
||||
if (!button || listeners.has(button)) return;
|
||||
const listener = event => {
|
||||
event.preventDefault();
|
||||
navigate(name);
|
||||
};
|
||||
listeners.set(button, listener);
|
||||
button.addEventListener('click', listener);
|
||||
});
|
||||
reset();
|
||||
const observe = options.observe || ((handler, observedTargets, root) => {
|
||||
if (typeof IntersectionObserver === 'undefined') return null;
|
||||
const instance = new IntersectionObserver(handler, {
|
||||
root,
|
||||
rootMargin: '-20% 0px -60% 0px',
|
||||
threshold: [0, 0.25, 0.5, 0.75, 1],
|
||||
});
|
||||
observedTargets.forEach(target => instance.observe(target));
|
||||
return instance;
|
||||
});
|
||||
observer = observe(entries => {
|
||||
if (Date.now() < navigationLockUntil) return;
|
||||
const visible = entries
|
||||
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
|
||||
.filter(entry => targetNames.get(entry.target) !== 'file' || filingAvailable)
|
||||
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
|
||||
if (visible) select(targetNames.get(visible.target));
|
||||
}, Array.from(targetNames.keys()).filter(Boolean), options.root || null);
|
||||
},
|
||||
stop() {
|
||||
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
|
||||
listeners.clear();
|
||||
if (observer) observer.disconnect();
|
||||
observer = null;
|
||||
},
|
||||
navigate,
|
||||
reset,
|
||||
select,
|
||||
setFilingAvailable,
|
||||
};
|
||||
}
|
||||
|
||||
function attachMobileCreateIssueNavigation({ document, window }) {
|
||||
const bySection = name => document.querySelector('[data-create-issue-section="' + name + '"]');
|
||||
const filing = document.getElementById('create-issue-filing');
|
||||
const sheet = document.getElementById('create-issue-sheet');
|
||||
const navigation = createMobileCreateIssueNavigation({
|
||||
root:document.querySelector('.create-issue-panel'),
|
||||
buttons:{describe:bySection('describe'), evidence:bySection('evidence'), file:bySection('file')},
|
||||
targets:{
|
||||
describe:document.getElementById('create-issue-describe'),
|
||||
evidence:document.getElementById('create-issue-evidence'),
|
||||
file:filing,
|
||||
},
|
||||
filing,
|
||||
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
});
|
||||
navigation.start();
|
||||
document.getElementById('file-new-issue').addEventListener('click', () => {
|
||||
setTimeout(() => navigation.navigate('file'), 0);
|
||||
});
|
||||
new MutationObserver(() => {
|
||||
navigation.setFilingAvailable(!filing.hidden);
|
||||
if (!filing.hidden && sheet.classList.contains('open')) navigation.navigate('file');
|
||||
}).observe(filing, {attributes:true, attributeFilter:['hidden']});
|
||||
new MutationObserver(() => {
|
||||
if (sheet.classList.contains('open')) navigation.reset(!filing.hidden);
|
||||
}).observe(sheet, {
|
||||
attributes:true, attributeFilter:['class'],
|
||||
});
|
||||
return navigation;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = createMobileCreateIssueNavigation;
|
||||
else attachMobileCreateIssueNavigation({document, window});
|
||||
@@ -0,0 +1,156 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileDeliveryRecovery = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileDeliveryRecovery(options) {
|
||||
const byId = id => typeof document === 'undefined' ? null : document.getElementById(id);
|
||||
const elements = options.elements || (typeof document === 'undefined' ? null : {
|
||||
dialog: byId('mobile-delivery-recovery'), close: byId('close-mobile-delivery-recovery'),
|
||||
title: byId('mobile-delivery-recovery-title'), destination: byId('mobile-delivery-recovery-destination'),
|
||||
reason: byId('mobile-delivery-recovery-reason'), progress: byId('mobile-delivery-recovery-progress'),
|
||||
action: byId('mobile-delivery-recovery-action'), status: byId('mobile-delivery-recovery-status'),
|
||||
});
|
||||
const priority = {authorization: 0, attention: 1, uncertain: 2, waiting: 3};
|
||||
|
||||
function identity(item) {
|
||||
return String(item?.outbox_id || item?.id || '');
|
||||
}
|
||||
|
||||
function state(item) {
|
||||
if (item?.status === 'authorization') return 'authorization';
|
||||
if (item?.status === 'attention' || item?.checklist_conflict || item?.quarantined) return 'attention';
|
||||
if (item?.delivery_state === 'uncertain') return 'uncertain';
|
||||
return 'waiting';
|
||||
}
|
||||
|
||||
function primary(item) {
|
||||
const kind = state(item);
|
||||
if (kind === 'authorization') {
|
||||
return item?.outbox_kind === 'issue-close' ? 'Authorize & close' : 'Authorize & send';
|
||||
}
|
||||
if (item?.checklist_conflict) return 'Review changes';
|
||||
if (kind === 'attention') return item?.quarantined ? 'Copy content' : 'Open current item';
|
||||
if (kind === 'uncertain') return 'Verified not posted — retry';
|
||||
return 'Retry now';
|
||||
}
|
||||
|
||||
function ordered() {
|
||||
return [...(options.getItems ? options.getItems() : [])].sort((left, right) => {
|
||||
const byState = priority[state(left)] - priority[state(right)];
|
||||
if (byState) return byState;
|
||||
return identity(left).localeCompare(identity(right));
|
||||
});
|
||||
}
|
||||
|
||||
const stateLabels = {
|
||||
authorization: 'Authorization required',
|
||||
attention: 'Needs attention',
|
||||
uncertain: 'Delivery uncertain',
|
||||
waiting: 'Ready to retry',
|
||||
};
|
||||
let currentId = '';
|
||||
let active = false;
|
||||
let inFlight = null;
|
||||
|
||||
function currentItem(items) {
|
||||
return items.find(item => identity(item) === currentId) || items[0];
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
const items = ordered();
|
||||
const item = currentItem(items);
|
||||
if (item) currentId = identity(item);
|
||||
else currentId = '';
|
||||
const index = item ? items.indexOf(item) : -1;
|
||||
return {
|
||||
count: items.length,
|
||||
current: item ? {
|
||||
id: identity(item),
|
||||
state: state(item),
|
||||
primary: primary(item),
|
||||
position: index + 1,
|
||||
total: items.length,
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
|
||||
function render() {
|
||||
const result = snapshot();
|
||||
if (!elements) return result;
|
||||
const items = ordered();
|
||||
const item = currentItem(items);
|
||||
if (!item) {
|
||||
if (active) {
|
||||
active = false;
|
||||
if (elements.dialog?.open) elements.dialog.close();
|
||||
if (options.onComplete) options.onComplete();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
elements.title.textContent = item.title || 'Queued delivery';
|
||||
elements.destination.textContent = [item.repository, item.details].filter(Boolean).join(' · ') || 'Saved delivery';
|
||||
elements.reason.textContent = item.last_attempt_error || item.ownership || stateLabels[state(item)];
|
||||
elements.progress.textContent = 'Delivery ' + result.current.position + ' of ' + result.current.total + ' · ' + stateLabels[state(item)];
|
||||
elements.action.textContent = primary(item);
|
||||
if (elements.status) elements.status.textContent = '';
|
||||
return result;
|
||||
}
|
||||
|
||||
function open() {
|
||||
const queue = byId('mobile-queue-sheet');
|
||||
if (queue?.open) queue.close();
|
||||
if (options.beforeOpen) options.beforeOpen();
|
||||
currentId = '';
|
||||
const result = snapshot();
|
||||
if (!result.current) return 'empty';
|
||||
active = true;
|
||||
render();
|
||||
if (elements?.dialog && !elements.dialog.open) elements.dialog.showModal();
|
||||
return 'opened';
|
||||
}
|
||||
|
||||
async function attend(item) {
|
||||
const index = options.getIndex ? options.getIndex(item) : -1;
|
||||
const selector = item.quarantined ? '.draft-copy' :
|
||||
item.checklist_conflict ? '.draft-review-checklist' : '.draft-resume';
|
||||
const action = document.querySelector('#my-work-list ' + selector + '[data-draft-index="' + index + '"]');
|
||||
if (!action) throw new Error('Delivery changed.');
|
||||
elements.dialog.close();
|
||||
action.scrollIntoView();
|
||||
action.focus();
|
||||
return await action.onclick();
|
||||
}
|
||||
|
||||
function activate() {
|
||||
if (inFlight) return inFlight;
|
||||
const items = ordered();
|
||||
const item = currentItem(items);
|
||||
const perform = state(item) === 'attention' ? attend : options.activate;
|
||||
if (!item) return Promise.resolve(render());
|
||||
if (elements?.action) elements.action.disabled = true;
|
||||
if (elements?.status) elements.status.textContent = 'Working…';
|
||||
inFlight = (async () => {
|
||||
let failure = null;
|
||||
try {
|
||||
await perform(item, state(item));
|
||||
} catch (error) {
|
||||
failure = String(error?.message || 'Delivery could not be completed.').trim().slice(0, 160);
|
||||
} finally {
|
||||
if (elements?.action) elements.action.disabled = false;
|
||||
}
|
||||
const result = render();
|
||||
if (failure && elements?.status) {
|
||||
if (active && !elements.dialog.open) elements.dialog.showModal();
|
||||
elements.status.textContent = failure + ' Try again.';
|
||||
}
|
||||
return result;
|
||||
})().finally(() => { inFlight = null; });
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (elements?.action?.addEventListener) elements.action.addEventListener('click', activate);
|
||||
if (elements?.close?.addEventListener) elements.close.addEventListener('click', () => elements.dialog.close());
|
||||
}
|
||||
|
||||
return {activate, open, render, snapshot, start};
|
||||
});
|
||||
@@ -5,12 +5,35 @@
|
||||
const promptDismissKey = 'stackchain.device-setup-prompt-dismissed-until';
|
||||
const promptDismissMs = 7 * 24 * 60 * 60 * 1000;
|
||||
const steps = [
|
||||
['install', options.installButton, options.installStatus, options.install],
|
||||
['offline', options.offlineButton, options.offlineStatus, options.enableOffline],
|
||||
['push', options.pushButton, options.pushStatus, options.enablePush],
|
||||
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline],
|
||||
];
|
||||
['install', options.installButton, options.installStatus, options.install, 'Install'],
|
||||
['offline', options.offlineButton, options.offlineStatus, options.enableOffline, 'Enable'],
|
||||
['protection', options.protectionButton, options.protectionStatus, options.protectStorage, 'Protect'],
|
||||
['push', options.pushButton, options.pushStatus, options.enablePush, 'Enable'],
|
||||
['appBadge', options.appBadgeButton, options.appBadgeStatus, options.enableAppBadge, 'Enable'],
|
||||
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline, 'Enable'],
|
||||
].filter(([_name, button, status, action]) => button && status && action);
|
||||
let trigger = options.launcher;
|
||||
let ownsDetour = false;
|
||||
let backgroundInert = null;
|
||||
let pendingResume = true;
|
||||
|
||||
function focusableControls() {
|
||||
return [...options.sheet.querySelectorAll(
|
||||
'button:not([disabled]), select:not([disabled]), input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
)].filter(control => !control.hidden);
|
||||
}
|
||||
|
||||
function containBackground() {
|
||||
if (backgroundInert) return;
|
||||
backgroundInert = new Map((options.backgroundElements || []).map(element => [element, element.inert]));
|
||||
backgroundInert.forEach((_wasInert, element) => { element.inert = true; });
|
||||
}
|
||||
|
||||
function releaseBackground() {
|
||||
if (!backgroundInert) return;
|
||||
backgroundInert.forEach((wasInert, element) => { element.inert = wasInert; });
|
||||
backgroundInert = null;
|
||||
}
|
||||
|
||||
function readinessCounts(readiness) {
|
||||
const available = steps.filter(([name]) => readiness[name].state !== 'unavailable').length;
|
||||
@@ -33,9 +56,10 @@
|
||||
const readiness = await options.getReadiness();
|
||||
let available = 0;
|
||||
let complete = 0;
|
||||
steps.forEach(([name, button, status]) => {
|
||||
steps.forEach(([name, button, status, _action, defaultActionLabel]) => {
|
||||
const step = readiness[name];
|
||||
status.textContent = step.detail;
|
||||
button.textContent = step.actionLabel || defaultActionLabel;
|
||||
button.hidden = step.state === 'complete' || step.state === 'unavailable';
|
||||
button.disabled = step.state === 'pending';
|
||||
if (step.state !== 'unavailable') available += 1;
|
||||
@@ -50,16 +74,35 @@
|
||||
|
||||
async function open(event) {
|
||||
trigger = event?.currentTarget || options.launcher;
|
||||
if (options.isMobile?.()) {
|
||||
ownsDetour = options.timerView?.beginDetour?.('device-setup')?.reason === 'device-setup';
|
||||
}
|
||||
await render();
|
||||
containBackground();
|
||||
options.sheet.hidden = false;
|
||||
if (options.history && !options.history.state?.deviceSetup) {
|
||||
options.history.pushState({...options.history.state, deviceSetup:true}, '');
|
||||
}
|
||||
options.closeButton.focus();
|
||||
}
|
||||
|
||||
function close() {
|
||||
function finishClose(resume = true) {
|
||||
options.sheet.hidden = true;
|
||||
releaseBackground();
|
||||
if (resume && ownsDetour) options.timerView?.finishDetour?.();
|
||||
ownsDetour = false;
|
||||
trigger?.focus?.();
|
||||
}
|
||||
|
||||
function close(resume = true) {
|
||||
if (options.history?.state?.deviceSetup) {
|
||||
pendingResume = resume;
|
||||
options.history.back();
|
||||
return;
|
||||
}
|
||||
finishClose(resume);
|
||||
}
|
||||
|
||||
async function start() {
|
||||
options.launcher.addEventListener('click', open);
|
||||
options.promptLauncher?.addEventListener('click', open);
|
||||
@@ -70,11 +113,33 @@
|
||||
options.promptCard.hidden = true;
|
||||
});
|
||||
options.closeButton.addEventListener('click', close);
|
||||
options.returnButton?.addEventListener('click', () => close(false));
|
||||
options.sheet.addEventListener('click', event => {
|
||||
if (event.target === options.sheet) close();
|
||||
});
|
||||
options.escapeTarget.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape' && !options.sheet.hidden) close();
|
||||
if (event.key === 'Escape' && !options.sheet.hidden) {
|
||||
event.preventDefault?.();
|
||||
close();
|
||||
}
|
||||
if (event.key !== 'Tab' || options.sheet.hidden) return;
|
||||
const controls = focusableControls();
|
||||
if (!controls.length) return;
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && event.target === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && event.target === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
options.historyTarget?.addEventListener('popstate', event => {
|
||||
if (!options.sheet.hidden && !event.state?.deviceSetup) {
|
||||
finishClose(pendingResume);
|
||||
pendingResume = true;
|
||||
}
|
||||
});
|
||||
steps.forEach(([_name, button, _status, action]) => {
|
||||
button.addEventListener('click', async () => {
|
||||
@@ -97,18 +162,31 @@ if (typeof module === 'object' && module.exports) {
|
||||
|
||||
function mountMobileDeviceSetup(options) {
|
||||
const qs = selector => options.document.querySelector(selector);
|
||||
const window = options.window || options.document.defaultView;
|
||||
return createMobileDeviceSetup({
|
||||
launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'),
|
||||
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
|
||||
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
|
||||
protectionButton:qs('#device-setup-protection'),
|
||||
appBadgeButton:qs('#device-setup-app-badge'),
|
||||
deadlineButton:qs('#device-setup-deadline'),
|
||||
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
|
||||
protectionStatus:qs('#device-setup-protection-status'),
|
||||
pushStatus:qs('#device-setup-push-status'), deadlineStatus:qs('#device-setup-deadline-status'),
|
||||
appBadgeStatus:qs('#device-setup-app-badge-status'),
|
||||
readyStatus:qs('#device-setup-ready-status'),
|
||||
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
|
||||
promptLauncher:qs('#finish-device-setup'), promptDismiss:qs('#dismiss-device-readiness'),
|
||||
promptStorage:options.promptStorage,
|
||||
returnButton:qs('#return-from-device-setup'),
|
||||
timerView:options.timerView,
|
||||
isMobile:options.isMobile || (() => innerWidth <= 600),
|
||||
escapeTarget:options.document,
|
||||
history:window?.history,
|
||||
historyTarget:window,
|
||||
backgroundElements:[
|
||||
qs('header'), qs('main'), qs('#mobile-task-dock'),
|
||||
].filter(Boolean),
|
||||
getReadiness:() => ({
|
||||
install:options.installApp.state(),
|
||||
offline:!options.offlineAvailable()
|
||||
@@ -116,16 +194,16 @@ function mountMobileDeviceSetup(options) {
|
||||
: options.offlineEnabled()
|
||||
? {state:'complete', detail:qs('#offline-work-status').textContent || 'Offline work is saved.'}
|
||||
: {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'},
|
||||
push:qs('#push-updates').disabled
|
||||
? {state:'unavailable', detail:qs('#push-update-status').textContent || 'Update notifications are unavailable.'}
|
||||
: qs('#push-updates').checked
|
||||
? {state:'complete', detail:'New update notifications are enabled.'}
|
||||
: {state:'incomplete', detail:qs('#push-update-status').textContent || 'New update notifications are off.'},
|
||||
protection:options.storageProtectionReadiness(),
|
||||
push:options.notificationReadiness(),
|
||||
appBadge:options.appBadgeReadiness(),
|
||||
deadline:options.deadlineReadiness(),
|
||||
}),
|
||||
install:() => options.installApp.install(),
|
||||
enableOffline:options.enableOffline,
|
||||
protectStorage:options.protectStorage,
|
||||
enablePush:options.enablePush,
|
||||
enableAppBadge:options.enableAppBadge,
|
||||
enableDeadline:options.enableDeadline,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.createMobileFindWorkNavigation = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
const stages = ['discover', 'review', 'fit'];
|
||||
|
||||
return function createMobileFindWorkNavigation(options) {
|
||||
const document = options.document;
|
||||
const buttons = options.buttons || Object.fromEntries(stages.map(name =>
|
||||
[name, document?.querySelector('[data-find-work-stage="' + name + '"]')]
|
||||
));
|
||||
const sections = options.sections || Object.fromEntries(stages.map(name =>
|
||||
[name, document?.getElementById('find-work-' + name + '-stage')]
|
||||
));
|
||||
const history = options.history;
|
||||
const eventTarget = options.eventTarget;
|
||||
const onStageChange = options.onStageChange || (() => {});
|
||||
const listeners = new Map();
|
||||
let current = 'discover';
|
||||
let selectedCount = 0;
|
||||
let recovery = false;
|
||||
let started = false;
|
||||
|
||||
function escape(value) {
|
||||
return String(value || '').replace(/[&<>"']/g, character =>
|
||||
({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' })[character]
|
||||
);
|
||||
}
|
||||
|
||||
function renderReview() {
|
||||
const reviewList = options.reviewList || document?.getElementById('find-work-review-list');
|
||||
const reviewSummary = options.reviewSummary || document?.getElementById('find-work-review-summary');
|
||||
if (!reviewList) return;
|
||||
const items = options.controller?.selectedItems?.() || options.getSelectedItems?.() || [];
|
||||
const planning = options.todayWork?.planning?.();
|
||||
const today = options.todayWork ? {
|
||||
limit:options.todayWork.limit, count:options.todayWork.read().length,
|
||||
capacityMinutes:planning.capacity_minutes, estimates:planning.estimates,
|
||||
} : options.getTodayState?.() || {};
|
||||
const remainingSlots = Math.max(0, (Number(today.limit) || 0) - (Number(today.count) || 0));
|
||||
const planned = Object.values(today.estimates || {}).reduce((sum, minutes) => sum + minutes, 0);
|
||||
const remainingMinutes = today.capacityMinutes === null || today.capacityMinutes === undefined ? null :
|
||||
Math.max(0, today.capacityMinutes - planned);
|
||||
const formatMinutes = minutes => [Math.floor(minutes / 60) ? Math.floor(minutes / 60) + 'h' : '',
|
||||
minutes % 60 ? minutes % 60 + 'm' : ''].filter(Boolean).join(' ') || '0m';
|
||||
if (reviewSummary) reviewSummary.textContent = items.length + ' selected · ' + remainingSlots +
|
||||
' Today slot' + (remainingSlots === 1 ? '' : 's') + ' open' +
|
||||
(remainingMinutes === null ? '' : ' · ' + formatMinutes(remainingMinutes) + ' available') + '.';
|
||||
reviewList.innerHTML = items.map(item => {
|
||||
const id = String(item.repository || '') + '#' + String(item.number || '');
|
||||
return '<article class="find-work-review-item"><div><span class="small">' + escape(id) +
|
||||
'</span><strong>' + escape(item.title || 'Untitled work') + '</strong></div>' +
|
||||
'<button type="button" data-find-work-review-remove="' + escape(id) + '">Remove</button></article>';
|
||||
}).join('');
|
||||
reviewList.querySelectorAll('[data-find-work-review-remove]').forEach(button =>
|
||||
button.addEventListener('click', () => {
|
||||
const item = items.find(candidate => String(candidate.repository || '') + '#' +
|
||||
String(candidate.number || '') === button.dataset.findWorkReviewRemove);
|
||||
if (item) (options.controller?.toggleSelection || options.toggleSelection)?.(item);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function allowed(name) {
|
||||
return stages.includes(name) && (name === 'discover' || selectedCount > 0 || recovery);
|
||||
}
|
||||
|
||||
function paint(name, focus = false, notify = true) {
|
||||
if (!allowed(name)) name = 'discover';
|
||||
current = name;
|
||||
stages.forEach(stage => {
|
||||
const button = buttons[stage];
|
||||
const section = sections[stage];
|
||||
if (button) {
|
||||
button.disabled = stage !== 'discover' && selectedCount === 0 && !recovery;
|
||||
if (stage === name) button.setAttribute('aria-current', 'step');
|
||||
else button.removeAttribute('aria-current');
|
||||
}
|
||||
if (section) section.hidden = stage !== name;
|
||||
});
|
||||
if (name === 'review') renderReview();
|
||||
if (name === 'fit') options.openFit?.(options.controller?.selectedItems?.() || []);
|
||||
if (notify) onStageChange(name);
|
||||
if (focus) sections[name]?.focus?.();
|
||||
return name;
|
||||
}
|
||||
|
||||
function stateFor(name) {
|
||||
const state = { ...(history?.state || {}) };
|
||||
if (name === 'discover') delete state.findWorkStage;
|
||||
else state.findWorkStage = name;
|
||||
return state;
|
||||
}
|
||||
|
||||
function go(name) {
|
||||
if (!allowed(name) || name === current) return false;
|
||||
const currentIndex = stages.indexOf(current);
|
||||
const nextIndex = stages.indexOf(name);
|
||||
if (nextIndex < currentIndex && history?.go) {
|
||||
history.go(nextIndex - currentIndex);
|
||||
return true;
|
||||
}
|
||||
if (nextIndex === currentIndex - 1 && history?.back) {
|
||||
history.back();
|
||||
return true;
|
||||
}
|
||||
history?.pushState?.(stateFor(name), '');
|
||||
paint(name, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
function onPopState(event) {
|
||||
if (event.state?.taskOverlay !== 'find') return;
|
||||
paint(event.state?.findWorkStage || 'discover', true);
|
||||
}
|
||||
|
||||
function sync(state = {}) {
|
||||
selectedCount = Math.max(0, Number(state.selectedCount) || 0);
|
||||
recovery = state.recovery === true;
|
||||
if (!allowed(current)) paint('discover');
|
||||
else paint(current, false, false);
|
||||
}
|
||||
|
||||
function open(hasRecovery) {
|
||||
if (options.controller) {
|
||||
selectedCount = options.controller.selection().count;
|
||||
recovery = hasRecovery === true;
|
||||
}
|
||||
const restored = history?.state?.taskOverlay === 'find' ? history.state.findWorkStage : null;
|
||||
return paint(allowed(restored) ? restored : 'discover');
|
||||
}
|
||||
|
||||
function complete() {
|
||||
selectedCount = 0;
|
||||
recovery = false;
|
||||
history?.replaceState?.(stateFor('discover'), '');
|
||||
return paint('discover');
|
||||
}
|
||||
|
||||
function back() {
|
||||
if (current === 'discover') return false;
|
||||
history?.back?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
stages.forEach(name => {
|
||||
const button = buttons[name];
|
||||
if (!button) return;
|
||||
const listener = event => { event.preventDefault(); go(name); };
|
||||
listeners.set(button, listener);
|
||||
button.addEventListener('click', listener);
|
||||
});
|
||||
if (document) {
|
||||
const bind = (id, action) => {
|
||||
const button = document.getElementById(id);
|
||||
if (!button) return;
|
||||
const listener = event => { event.preventDefault(); action(); };
|
||||
listeners.set(button, listener);
|
||||
button.addEventListener('click', listener);
|
||||
};
|
||||
bind('select-find-work', () => { complete(); options.controller?.startSelection?.(); });
|
||||
bind('cancel-find-work-selection', () => { complete(); options.controller?.cancelSelection?.(); });
|
||||
bind('claim-selected-work', () => go('review'));
|
||||
bind('continue-find-work-fit', () => go('fit'));
|
||||
bind('back-find-work-discover', back);
|
||||
bind('cancel-find-work-estimates', back);
|
||||
bind('close-find-work', () => history?.go?.(-(stages.indexOf(current) + 1)));
|
||||
}
|
||||
eventTarget?.addEventListener?.('popstate', onPopState);
|
||||
paint('discover');
|
||||
},
|
||||
stop() {
|
||||
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
|
||||
listeners.clear();
|
||||
eventTarget?.removeEventListener?.('popstate', onPopState);
|
||||
started = false;
|
||||
},
|
||||
sync,
|
||||
open,
|
||||
go,
|
||||
back,
|
||||
complete,
|
||||
renderReview,
|
||||
stage: () => current,
|
||||
};
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileFirstTask = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileFirstTask(options) {
|
||||
const doc = typeof document !== 'undefined' ? document : null;
|
||||
const win = typeof window !== 'undefined' ? window : null;
|
||||
options = Object.assign({
|
||||
storage: typeof localStorage !== 'undefined' ? localStorage : null,
|
||||
isOnline: () => typeof navigator === 'undefined' || navigator.onLine,
|
||||
mediaQuery: win?.matchMedia('(max-width: 600px)') || {matches:false},
|
||||
eventTarget: win,
|
||||
setTimer: (...args) => globalThis.setTimeout(...args),
|
||||
sheet: doc?.querySelector('#mobile-first-task'),
|
||||
title: doc?.querySelector('#mobile-first-task-title'),
|
||||
findButton: doc?.querySelector('#mobile-first-task-find'),
|
||||
createButton: doc?.querySelector('#mobile-first-task-create'),
|
||||
setupButton: doc?.querySelector('#mobile-first-task-setup'),
|
||||
closeButton: doc?.querySelector('#close-mobile-first-task'),
|
||||
status: doc?.querySelector('#mobile-first-task-status'),
|
||||
coach: doc?.querySelector('[data-mobile-first-task-coach]'),
|
||||
receipt: doc?.querySelector('#mobile-first-task-receipt'),
|
||||
isTodayActive: () => false,
|
||||
onFind: () => doc?.querySelector('#find-work')?.click(),
|
||||
onCreate: () => doc?.querySelector('#new-issue')?.click(),
|
||||
onSetup: () => doc?.querySelector('#open-device-setup')?.click(),
|
||||
}, options);
|
||||
const prefix = 'stackchain.first-task.v1:';
|
||||
|
||||
function account() {
|
||||
return String(options.getLogin?.() || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function key() {
|
||||
const login = account();
|
||||
return login ? prefix + login : '';
|
||||
}
|
||||
|
||||
function state() {
|
||||
const currentKey = key();
|
||||
if (!currentKey) return '';
|
||||
try { return options.storage.getItem(currentKey) || ''; }
|
||||
catch (_error) { return ''; }
|
||||
}
|
||||
|
||||
function store(value) {
|
||||
const currentKey = key();
|
||||
const current = state();
|
||||
if (!currentKey || current === value || current === 'complete') return false;
|
||||
try {
|
||||
options.storage.setItem(currentKey, value);
|
||||
return true;
|
||||
} catch (_error) { return false; }
|
||||
}
|
||||
|
||||
function adoptRemote(value) {
|
||||
return ['coaching', 'complete'].includes(value) && store(value);
|
||||
}
|
||||
|
||||
function required() {
|
||||
return Boolean(options.mediaQuery.matches && account() && !options.isTodayActive() && !state());
|
||||
}
|
||||
|
||||
function renderCoach() {
|
||||
if (!options.coach) return;
|
||||
const visible = options.mediaQuery.matches && state() === 'coaching' && options.isTodayActive();
|
||||
options.coach.hidden = !visible;
|
||||
const host = options.coach.closest?.('[data-mobile-today-hud]');
|
||||
if (visible && host) host.hidden = false;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const online = options.isOnline();
|
||||
const awaitingStart = options.hasWork();
|
||||
options.findButton.disabled = !online;
|
||||
if (options.title) options.title.textContent = awaitingStart ? 'Finish starting your first task' : 'Start your first task';
|
||||
options.findButton.textContent = 'Find & start';
|
||||
options.createButton.textContent = 'Create & start';
|
||||
options.status.textContent = awaitingStart ?
|
||||
'Your task is ready. Start it from Find or create and start another task.' : online ?
|
||||
'Choose a task to claim and start, or create and start one of your own.' :
|
||||
'You are offline. Create a task now and it will stay in Drafts until you reconnect.';
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (!required()) return false;
|
||||
render();
|
||||
if (!options.sheet.open) options.sheet.showModal();
|
||||
(options.findButton.disabled ? options.createButton : options.findButton).focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (account() && options.isTodayActive() && state() !== 'complete') {
|
||||
store('coaching');
|
||||
if (options.sheet.open) options.sheet.close();
|
||||
renderCoach();
|
||||
return 'coaching';
|
||||
}
|
||||
renderCoach();
|
||||
if (options.sheet.open) render();
|
||||
if (required() && options.hasWork()) return 'awaiting-start';
|
||||
return required() ? 'required' : 'inactive';
|
||||
}
|
||||
|
||||
function completeOutcome() {
|
||||
if (state() !== 'coaching') return false;
|
||||
store('complete');
|
||||
options.eventTarget?.dispatchEvent?.(new CustomEvent('stackchain:first-task-complete'));
|
||||
renderCoach();
|
||||
if (options.receipt) {
|
||||
options.receipt.hidden = false;
|
||||
options.setTimer?.(() => { options.receipt.hidden = true; }, 6000);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function handoff(callback, requiresOnline = false) {
|
||||
if (requiresOnline && !options.isOnline()) return;
|
||||
if (options.sheet.open) options.sheet.close();
|
||||
callback();
|
||||
}
|
||||
|
||||
function start() {
|
||||
options.findButton.addEventListener('click', () => handoff(options.onFind, true));
|
||||
options.createButton.addEventListener('click', () => handoff(options.onCreate));
|
||||
options.setupButton.addEventListener('click', () => handoff(options.onSetup));
|
||||
options.closeButton.addEventListener('click', () => {
|
||||
if (options.sheet.open) options.sheet.close();
|
||||
});
|
||||
options.eventTarget?.addEventListener('online', render);
|
||||
options.eventTarget?.addEventListener('offline', render);
|
||||
}
|
||||
|
||||
globalThis.stackchainFirstTaskCompleting = () => state() === 'coaching';
|
||||
return {required, open, refresh, render, start, completeOutcome, adoptRemote};
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileInsights = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function (o) {
|
||||
const route = '#/insights';
|
||||
const events = o.eventTarget || self;
|
||||
const location = o.location || self.location;
|
||||
let active = false;
|
||||
let previous = '#/my-work';
|
||||
let retries = 0;
|
||||
|
||||
function attr(element, name, value) {
|
||||
if (value === null) element.removeAttribute(name);
|
||||
else element.setAttribute(name, value);
|
||||
}
|
||||
|
||||
function render(open, focus = false) {
|
||||
const wasActive = active;
|
||||
active = Boolean(open && o.mediaQuery.matches);
|
||||
const detour = active ? o.detour?.() : null;
|
||||
const started = detour?.beginDetour('insights');
|
||||
if (detour && !started && retries++ < 40) setTimeout(sync, 250);
|
||||
else if (started && retries) { retries = 0; setTimeout(sync, 500); }
|
||||
else retries = 0;
|
||||
if (wasActive && !active) o.detour?.()?.finishDetour();
|
||||
attr(o.root, 'data-mobile-open', active ? 'true' : null);
|
||||
attr(o.root, 'role', active ? 'dialog' : null);
|
||||
attr(o.root, 'aria-modal', active ? 'true' : null);
|
||||
attr(o.root, 'aria-hidden', active || !o.mediaQuery.matches ? null : 'true');
|
||||
o.root.inert = !active && o.mediaQuery.matches;
|
||||
o.backgrounds.forEach(element => { element.inert = active; });
|
||||
o.dock.hidden = active;
|
||||
attr(o.hud, 'data-overlay-hidden', active ? 'true' : null);
|
||||
if (active) o.closeButton.focus();
|
||||
else if (focus) o.launcher.focus();
|
||||
}
|
||||
|
||||
function url(hash) {
|
||||
return (location.pathname || '') + (location.search || '') + hash;
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (!o.mediaQuery.matches || active) return false;
|
||||
previous = location.hash && location.hash !== route ? location.hash : '#/my-work';
|
||||
const menu = o.menu || o.launcher.closest?.('details');
|
||||
if (menu) menu.open = false;
|
||||
o.history.pushState({ ...(o.history.state || {}), mobileInsights:true, previousHash:previous }, '', url(route));
|
||||
render(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
function sync() {
|
||||
render(location.hash === route, active && location.hash !== route);
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!active) return false;
|
||||
if (o.history.state?.mobileInsights) o.history.back();
|
||||
else {
|
||||
const state = { ...(o.history.state || {}) };
|
||||
delete state.mobileInsights;
|
||||
delete state.previousHash;
|
||||
o.history.replaceState(state, '', url(previous));
|
||||
render(false, true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function returnToToday() {
|
||||
if (!active) return false;
|
||||
const state = { ...(o.history.state || {}) };
|
||||
delete state.mobileInsights;
|
||||
delete state.previousHash;
|
||||
o.history.replaceState(state, '', url(previous));
|
||||
render(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
function start() {
|
||||
o.launcher.addEventListener('click', open);
|
||||
o.closeButton.addEventListener('click', close);
|
||||
(o.returnButton || o.root.querySelector?.('[data-return-from-detour]'))?.addEventListener('click', returnToToday);
|
||||
events.addEventListener('popstate', sync);
|
||||
events.addEventListener('hashchange', sync);
|
||||
events.addEventListener('keydown', event => {
|
||||
if (active && event.key === 'Escape') {
|
||||
event.preventDefault?.();
|
||||
close();
|
||||
}
|
||||
});
|
||||
o.mediaQuery.addEventListener?.('change', sync);
|
||||
sync();
|
||||
}
|
||||
|
||||
return { start, open, close, sync, current:() => active };
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
function createMobileIssueDetailNavigation(options) {
|
||||
const buttons = options.buttons || {};
|
||||
const targets = options.targets || {};
|
||||
const listeners = new Map();
|
||||
const prefersReducedMotion = options.prefersReducedMotion || (() => false);
|
||||
const targetNames = new Map(Object.entries(targets).map(([name, target]) => [target, name]));
|
||||
let observer = null;
|
||||
|
||||
function select(name) {
|
||||
Object.entries(buttons).forEach(([key, button]) => {
|
||||
if (!button) return;
|
||||
if (key === name) button.setAttribute('aria-current', 'location');
|
||||
else button.removeAttribute('aria-current');
|
||||
});
|
||||
}
|
||||
|
||||
function navigate(name, navigationOptions = {}) {
|
||||
const target = targets[name];
|
||||
if (!target) return false;
|
||||
const prepare = options.beforeNavigate && options.beforeNavigate[name];
|
||||
if (prepare) prepare(target);
|
||||
if (name === 'actions' && options.planning) options.planning.open = true;
|
||||
target.scrollIntoView({
|
||||
block: 'start',
|
||||
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
|
||||
});
|
||||
if (name === 'reply' && navigationOptions.focus !== false) target.focus();
|
||||
select(name);
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
Object.entries(buttons).forEach(([name, button]) => {
|
||||
if (!button || listeners.has(button)) return;
|
||||
const listener = event => {
|
||||
event.preventDefault();
|
||||
navigate(name);
|
||||
if (options.onSectionChange) options.onSectionChange(name, { replace:false });
|
||||
};
|
||||
listeners.set(button, listener);
|
||||
button.addEventListener('click', listener);
|
||||
});
|
||||
select('overview');
|
||||
const observe = options.observe || ((handler, observedTargets) => {
|
||||
if (typeof IntersectionObserver === 'undefined') return null;
|
||||
const instance = new IntersectionObserver(handler, {
|
||||
root: options.root || null,
|
||||
rootMargin: '-20% 0px -60% 0px',
|
||||
threshold: [0, 0.25, 0.5, 0.75, 1],
|
||||
});
|
||||
observedTargets.forEach(target => instance.observe(target));
|
||||
return instance;
|
||||
});
|
||||
observer = observe(entries => {
|
||||
const visible = entries
|
||||
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
|
||||
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
|
||||
if (visible) {
|
||||
const name = targetNames.get(visible.target);
|
||||
select(name);
|
||||
if (options.onSectionChange) options.onSectionChange(name, { replace:true });
|
||||
}
|
||||
}, Array.from(targetNames.keys()).filter(Boolean));
|
||||
},
|
||||
stop() {
|
||||
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
|
||||
listeners.clear();
|
||||
if (observer) observer.disconnect();
|
||||
observer = null;
|
||||
},
|
||||
navigate,
|
||||
select,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = createMobileIssueDetailNavigation;
|
||||
@@ -0,0 +1,104 @@
|
||||
function createMobilePlanTodayNavigation(options) {
|
||||
const buttons = options.buttons || {};
|
||||
const targets = options.targets || {};
|
||||
const root = options.root;
|
||||
const listeners = new Map();
|
||||
const prefersReducedMotion = options.prefersReducedMotion || (() => false);
|
||||
const now = options.now || (() => Date.now());
|
||||
const targetNames = new Map(Object.entries(targets).map(([name, target]) => [target, name]));
|
||||
let observer = null;
|
||||
let navigationLockUntil = 0;
|
||||
|
||||
function select(name) {
|
||||
Object.entries(buttons).forEach(([key, button]) => {
|
||||
if (!button) return;
|
||||
if (key === name) button.setAttribute('aria-current', 'location');
|
||||
else button.removeAttribute('aria-current');
|
||||
});
|
||||
}
|
||||
|
||||
function navigate(name) {
|
||||
const target = targets[name];
|
||||
if (!root || !target) return false;
|
||||
root.scrollTo({
|
||||
top: Math.max(0, target.offsetTop - root.offsetTop),
|
||||
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
|
||||
});
|
||||
navigationLockUntil = now() + 500;
|
||||
select(name);
|
||||
return true;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
select('fit');
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
Object.entries(buttons).forEach(([name, button]) => {
|
||||
if (!button || listeners.has(button)) return;
|
||||
const listener = event => {
|
||||
event.preventDefault();
|
||||
navigate(name);
|
||||
};
|
||||
listeners.set(button, listener);
|
||||
button.addEventListener('click', listener);
|
||||
});
|
||||
reset();
|
||||
const observe = options.observe || ((handler, observedTargets, observedRoot) => {
|
||||
if (typeof IntersectionObserver === 'undefined') return null;
|
||||
const instance = new IntersectionObserver(handler, {
|
||||
root: observedRoot,
|
||||
rootMargin: '-20% 0px -60% 0px',
|
||||
threshold: [0, 0.25, 0.5, 0.75, 1],
|
||||
});
|
||||
observedTargets.forEach(target => instance.observe(target));
|
||||
return instance;
|
||||
});
|
||||
observer = observe(entries => {
|
||||
if (now() < navigationLockUntil) return;
|
||||
const visible = entries
|
||||
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
|
||||
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
|
||||
if (visible) select(targetNames.get(visible.target));
|
||||
}, Array.from(targetNames.keys()).filter(Boolean), root);
|
||||
},
|
||||
stop() {
|
||||
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
|
||||
listeners.clear();
|
||||
if (observer) observer.disconnect();
|
||||
observer = null;
|
||||
},
|
||||
navigate,
|
||||
reset,
|
||||
select,
|
||||
};
|
||||
}
|
||||
|
||||
function attachMobilePlanTodayNavigation({document, window}) {
|
||||
const bySection = name => document.querySelector('[data-plan-today-section="' + name + '"]');
|
||||
const sheet = document.getElementById('plan-today-sheet');
|
||||
const panel = document.querySelector('.plan-today-panel');
|
||||
const navigation = createMobilePlanTodayNavigation({
|
||||
root: panel,
|
||||
buttons: {
|
||||
fit: bySection('fit'),
|
||||
today: bySection('today'),
|
||||
available: bySection('available'),
|
||||
},
|
||||
targets: {
|
||||
fit: document.getElementById('plan-today-fit'),
|
||||
today: document.getElementById('plan-today-selected'),
|
||||
available: document.getElementById('plan-today-available-work'),
|
||||
},
|
||||
prefersReducedMotion: () => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
});
|
||||
navigation.start();
|
||||
new MutationObserver(() => {
|
||||
if (!sheet.hidden) navigation.reset();
|
||||
}).observe(sheet, {attributes: true, attributeFilter: ['hidden']});
|
||||
return navigation;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = createMobilePlanTodayNavigation;
|
||||
else attachMobilePlanTodayNavigation({document, window});
|
||||
@@ -0,0 +1,88 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
else root.createMobilePullRefresh = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobilePullRefresh(options) {
|
||||
const surface = options.surface || document.querySelector('#my-work');
|
||||
const indicator = options.indicator || document.querySelector('#mobile-pull-refresh');
|
||||
const threshold = options.threshold || 64;
|
||||
const isMobile = options.isMobile || (() => window.matchMedia('(max-width: 600px)').matches);
|
||||
const getScrollY = options.getScrollY || (() => window.scrollY);
|
||||
const isBlocked = options.isBlocked || (() => Array.from(options.overlays || document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal')).some(overlay =>
|
||||
(overlay.checkVisibility ? overlay.checkVisibility() : !overlay.closest('[hidden]')) &&
|
||||
(overlay.classList.contains('open') || overlay.getAttribute('aria-modal') === 'true')));
|
||||
let gesture = null;
|
||||
let inFlight = null;
|
||||
|
||||
function show(state, text) {
|
||||
indicator.hidden = false;
|
||||
indicator.dataset.state = state;
|
||||
indicator.textContent = text;
|
||||
}
|
||||
|
||||
function resetGesture() {
|
||||
gesture = null;
|
||||
}
|
||||
|
||||
function startsOnExcludedTarget(target) {
|
||||
if (!target) return false;
|
||||
if (target.closest?.('button, a, input, textarea, select, summary, [contenteditable="true"], [role="button"]')) return true;
|
||||
for (let node = target; node && node !== surface; node = node.parentElement) {
|
||||
if ((node.scrollWidth || 0) > (node.clientWidth || 0)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function onPointerDown(event) {
|
||||
if (inFlight || event.pointerType !== 'touch' || !isMobile() || getScrollY() > 0 || isBlocked() || startsOnExcludedTarget(event.target)) return;
|
||||
gesture = {id:event.pointerId, x:event.clientX, y:event.clientY, ready:false};
|
||||
surface.setPointerCapture?.(event.pointerId);
|
||||
}
|
||||
|
||||
function onPointerMove(event) {
|
||||
if (!gesture || gesture.id !== event.pointerId) return;
|
||||
const dx = event.clientX - gesture.x;
|
||||
const dy = event.clientY - gesture.y;
|
||||
if (dy <= 0 || Math.abs(dx) > dy) return resetGesture();
|
||||
event.preventDefault();
|
||||
gesture.ready = dy >= threshold;
|
||||
show(gesture.ready ? 'ready' : 'pulling', gesture.ready ? 'Release to refresh' : 'Pull to refresh');
|
||||
}
|
||||
|
||||
async function onPointerUp(event) {
|
||||
if (!gesture || gesture.id !== event.pointerId) return;
|
||||
const shouldRefresh = gesture.ready;
|
||||
resetGesture();
|
||||
surface.releasePointerCapture?.(event.pointerId);
|
||||
if (!shouldRefresh || inFlight) return;
|
||||
show('refreshing', 'Refreshing My Work…');
|
||||
inFlight = Promise.resolve().then(options.refresh);
|
||||
try {
|
||||
await inFlight;
|
||||
show('success', 'My Work is up to date');
|
||||
} catch (_) {
|
||||
show('error', 'Could not refresh · pull to retry');
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerCancel() {
|
||||
resetGesture();
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
surface.addEventListener('pointerdown', onPointerDown);
|
||||
surface.addEventListener('pointermove', onPointerMove);
|
||||
surface.addEventListener('pointerup', onPointerUp);
|
||||
surface.addEventListener('pointercancel', onPointerCancel);
|
||||
},
|
||||
stop() {
|
||||
surface.removeEventListener('pointerdown', onPointerDown);
|
||||
surface.removeEventListener('pointermove', onPointerMove);
|
||||
surface.removeEventListener('pointerup', onPointerUp);
|
||||
surface.removeEventListener('pointercancel', onPointerCancel);
|
||||
resetGesture();
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -9,10 +9,12 @@
|
||||
draft: 'No drafts are ready to open.',
|
||||
};
|
||||
const continuation = [
|
||||
['delivery', 'Recover Delivery'],
|
||||
['attention', 'Start Attention'],
|
||||
['today', 'Continue Today'],
|
||||
['update', 'Resume Updates'],
|
||||
['agenda', 'Open Agenda'],
|
||||
['filed', 'Review Filed'],
|
||||
['later', 'Start Later'],
|
||||
['draft', 'Open Drafts'],
|
||||
];
|
||||
@@ -27,9 +29,12 @@
|
||||
}
|
||||
|
||||
function open(name) {
|
||||
if (name === 'delivery' && options.openDelivery) return options.openDelivery();
|
||||
if (name === 'today') return options.openToday();
|
||||
if (name === 'agenda') return options.openAgenda();
|
||||
if (name === 'update' && options.openUpdates) return options.openUpdates();
|
||||
if (name === 'following' && options.openFollowing) return options.openFollowing();
|
||||
if (name === 'filed' && options.openFiled) return options.openFiled();
|
||||
options.selectFilter(name);
|
||||
const action = options.firstAction(name);
|
||||
if (!action) {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
function createMobileReviewDetailNavigation(options) {
|
||||
const buttons = options.buttons || {};
|
||||
const targets = options.targets || {};
|
||||
const listeners = new Map();
|
||||
const prefersReducedMotion = options.prefersReducedMotion || (() => false);
|
||||
const targetNames = new Map(Object.entries(targets).map(([name, target]) => [target, name]));
|
||||
let observer = null;
|
||||
|
||||
function select(name) {
|
||||
Object.entries(buttons).forEach(([key, button]) => {
|
||||
if (!button) return;
|
||||
if (key === name) button.setAttribute('aria-current', 'location');
|
||||
else button.removeAttribute('aria-current');
|
||||
});
|
||||
}
|
||||
|
||||
function navigate(name, navigationOptions = {}) {
|
||||
const target = targets[name];
|
||||
if (!target) return false;
|
||||
target.scrollIntoView({
|
||||
block:'start',
|
||||
behavior:prefersReducedMotion() ? 'auto' : 'smooth',
|
||||
});
|
||||
if (name === 'feedback' && options.summaryComposer && navigationOptions.focus !== false) {
|
||||
options.summaryComposer.focus({ preventScroll:true });
|
||||
}
|
||||
select(name);
|
||||
return true;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
select('overview');
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
Object.entries(buttons).forEach(([name, button]) => {
|
||||
if (!button || listeners.has(button)) return;
|
||||
const listener = event => {
|
||||
event.preventDefault();
|
||||
navigate(name);
|
||||
if (options.onSectionChange) options.onSectionChange(name, { replace:false });
|
||||
};
|
||||
listeners.set(button, listener);
|
||||
button.addEventListener('click', listener);
|
||||
});
|
||||
reset();
|
||||
const observe = options.observe || ((handler, observedTargets, root) => {
|
||||
if (typeof IntersectionObserver === 'undefined') return null;
|
||||
const instance = new IntersectionObserver(handler, {
|
||||
root:root || null,
|
||||
rootMargin:'-20% 0px -60% 0px',
|
||||
threshold:[0, 0.25, 0.5, 0.75, 1],
|
||||
});
|
||||
observedTargets.forEach(target => instance.observe(target));
|
||||
return instance;
|
||||
});
|
||||
observer = observe(entries => {
|
||||
const visible = entries
|
||||
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
|
||||
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
|
||||
if (visible) {
|
||||
const name = targetNames.get(visible.target);
|
||||
select(name);
|
||||
if (options.onSectionChange) options.onSectionChange(name, { replace:true });
|
||||
}
|
||||
}, Array.from(targetNames.keys()).filter(Boolean), options.root || null);
|
||||
},
|
||||
stop() {
|
||||
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
|
||||
listeners.clear();
|
||||
if (observer) observer.disconnect();
|
||||
observer = null;
|
||||
},
|
||||
navigate,
|
||||
reset,
|
||||
select,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = createMobileReviewDetailNavigation;
|
||||
@@ -0,0 +1,132 @@
|
||||
function createMobileSearchPreviewNavigation(options) {
|
||||
const buttons = options.buttons || {};
|
||||
const targets = options.targets || {};
|
||||
const focusTargets = options.focusTargets || {};
|
||||
const listeners = new Map();
|
||||
const prefersReducedMotion = options.prefersReducedMotion || (() => false);
|
||||
const targetNames = new Map(Object.entries(targets).map(([name, target]) => [target, name]));
|
||||
let replyAvailable = options.replyAvailable !== false;
|
||||
let observer = null;
|
||||
let navigationLockUntil = 0;
|
||||
|
||||
function select(name) {
|
||||
Object.entries(buttons).forEach(([key, button]) => {
|
||||
if (!button) return;
|
||||
if (key === name) button.setAttribute('aria-current', 'location');
|
||||
else button.removeAttribute('aria-current');
|
||||
});
|
||||
}
|
||||
|
||||
function setReplyAvailable(available) {
|
||||
replyAvailable = Boolean(available);
|
||||
const button = buttons.reply;
|
||||
if (!button) return;
|
||||
if (replyAvailable) button.removeAttribute('aria-disabled');
|
||||
else button.setAttribute('aria-disabled', 'true');
|
||||
}
|
||||
|
||||
function navigate(name) {
|
||||
if (name === 'reply' && !replyAvailable) return false;
|
||||
const target = targets[name];
|
||||
if (!target) return false;
|
||||
target.scrollIntoView({
|
||||
block: 'start',
|
||||
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
|
||||
});
|
||||
navigationLockUntil = Date.now() + 500;
|
||||
select(name);
|
||||
const focusTarget = focusTargets[name] || (name === 'reply' ? target : null);
|
||||
if (focusTarget) focusTarget.focus({preventScroll:true});
|
||||
return true;
|
||||
}
|
||||
|
||||
function reset(available = replyAvailable) {
|
||||
setReplyAvailable(available);
|
||||
select('overview');
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
Object.entries(buttons).forEach(([name, button]) => {
|
||||
if (!button || listeners.has(button)) return;
|
||||
const listener = event => {
|
||||
event.preventDefault();
|
||||
navigate(name);
|
||||
};
|
||||
listeners.set(button, listener);
|
||||
button.addEventListener('click', listener);
|
||||
});
|
||||
reset(replyAvailable);
|
||||
const observe = options.observe || ((handler, observedTargets, root) => {
|
||||
if (typeof IntersectionObserver === 'undefined') return null;
|
||||
const instance = new IntersectionObserver(handler, {
|
||||
root,
|
||||
rootMargin: '-20% 0px -60% 0px',
|
||||
threshold: [0, 0.25, 0.5, 0.75, 1],
|
||||
});
|
||||
observedTargets.forEach(target => instance.observe(target));
|
||||
return instance;
|
||||
});
|
||||
observer = observe(entries => {
|
||||
if (Date.now() < navigationLockUntil) return;
|
||||
const visible = entries
|
||||
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
|
||||
.filter(entry => targetNames.get(entry.target) !== 'reply' || replyAvailable)
|
||||
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
|
||||
if (visible) select(targetNames.get(visible.target));
|
||||
}, Array.from(targetNames.keys()).filter(Boolean), options.root || null);
|
||||
},
|
||||
stop() {
|
||||
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
|
||||
listeners.clear();
|
||||
if (observer) observer.disconnect();
|
||||
observer = null;
|
||||
},
|
||||
navigate,
|
||||
reset,
|
||||
select,
|
||||
setReplyAvailable,
|
||||
};
|
||||
}
|
||||
|
||||
function attachMobileSearchPreviewNavigation({document, window}) {
|
||||
const bySection = name => document.querySelector('[data-search-preview-section="' + name + '"]');
|
||||
const preview = document.getElementById('search-preview');
|
||||
const reply = document.getElementById('search-preview-reply-workspace');
|
||||
const navigation = createMobileSearchPreviewNavigation({
|
||||
root:document.querySelector('.search-preview-panel'),
|
||||
buttons:{
|
||||
overview:bySection('overview'),
|
||||
conversation:bySection('conversation'),
|
||||
changes:bySection('changes'),
|
||||
reply:bySection('reply'),
|
||||
actions:bySection('actions'),
|
||||
},
|
||||
targets:{
|
||||
overview:document.getElementById('search-preview-overview'),
|
||||
conversation:document.getElementById('search-preview-conversation'),
|
||||
changes:document.getElementById('search-preview-review'),
|
||||
reply,
|
||||
actions:document.getElementById('search-preview-actions'),
|
||||
},
|
||||
focusTargets:{reply:document.getElementById('search-preview-reply')},
|
||||
replyAvailable:!reply.hidden,
|
||||
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
});
|
||||
navigation.start();
|
||||
new MutationObserver(() => navigation.setReplyAvailable(!reply.hidden)).observe(reply, {
|
||||
attributes:true, attributeFilter:['hidden'],
|
||||
});
|
||||
new MutationObserver(() => {
|
||||
if (preview.classList.contains('open')) navigation.reset(!reply.hidden);
|
||||
}).observe(preview, {attributes:true, attributeFilter:['class']});
|
||||
['previous-search-result', 'next-search-result'].forEach(id => {
|
||||
document.getElementById(id).addEventListener('click', () => {
|
||||
setTimeout(() => navigation.reset(!reply.hidden), 0);
|
||||
});
|
||||
});
|
||||
return navigation;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = createMobileSearchPreviewNavigation;
|
||||
else attachMobileSearchPreviewNavigation({document, window});
|
||||
@@ -0,0 +1,189 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileStartDay = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileStartDay(options) {
|
||||
const checkpointKey = 'stackchain.mobile-start-day.v1';
|
||||
const storage = options.storage || (typeof localStorage !== 'undefined' ? localStorage : null);
|
||||
const reviewOrder = [
|
||||
['delivery', 'Delivery recovery'],
|
||||
['agenda', 'Agenda'],
|
||||
['attention', 'Attention'],
|
||||
['update', 'Updates'],
|
||||
['filed', 'Filed'],
|
||||
['following', 'Following'],
|
||||
];
|
||||
const anonymousItems = new WeakMap();
|
||||
let anonymousItemSequence = 0;
|
||||
|
||||
function count(value) {
|
||||
return Math.max(0, Number(value) || 0);
|
||||
}
|
||||
|
||||
function itemIdentity(item) {
|
||||
if (item === null || item === undefined) return '';
|
||||
if (typeof item !== 'object') return String(item);
|
||||
const explicit = item.outbox_id || item.id || item.key || item.url;
|
||||
if (explicit !== null && explicit !== undefined && String(explicit)) {
|
||||
return String(item.kind || 'item') + ':' + String(explicit);
|
||||
}
|
||||
if (!anonymousItems.has(item)) anonymousItems.set(item, 'anonymous:' + (++anonymousItemSequence));
|
||||
return anonymousItems.get(item);
|
||||
}
|
||||
|
||||
function reviewPhases(counts) {
|
||||
if (!options.getPhaseItems) {
|
||||
return reviewOrder
|
||||
.map(([name, label]) => ({name, label, count: count(counts[name])}))
|
||||
.filter(phase => phase.count > 0);
|
||||
}
|
||||
const items = options.getPhaseItems() || {};
|
||||
const seen = new Set();
|
||||
return reviewOrder.flatMap(([name, label]) => {
|
||||
let phaseCount = 0;
|
||||
(items[name] || []).forEach(item => {
|
||||
const identity = itemIdentity(item);
|
||||
if (!identity || seen.has(identity)) return;
|
||||
seen.add(identity);
|
||||
phaseCount += 1;
|
||||
});
|
||||
return phaseCount ? [{name, label, count:phaseCount}] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function localDay() {
|
||||
const now = new Date();
|
||||
const pad = value => String(value).padStart(2, '0');
|
||||
return now.getFullYear() + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate());
|
||||
}
|
||||
|
||||
function identity() {
|
||||
try {
|
||||
return {
|
||||
login: String(options.getLogin ? options.getLogin() : '').trim(),
|
||||
day: String(options.getDay ? options.getDay() : localDay()),
|
||||
};
|
||||
} catch (_) {
|
||||
return {login:'', day:''};
|
||||
}
|
||||
}
|
||||
|
||||
function checkpoint() {
|
||||
if (!storage) return null;
|
||||
const current = identity();
|
||||
if (!current.login || !current.day) return null;
|
||||
try {
|
||||
const saved = JSON.parse(storage.getItem(checkpointKey) || 'null');
|
||||
return saved?.login === current.login && saved?.day === current.day ? saved : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveCheckpoint(phase = null) {
|
||||
if (!storage) return false;
|
||||
const current = identity();
|
||||
if (!current.login || !current.day) return false;
|
||||
try {
|
||||
storage.setItem(checkpointKey, JSON.stringify({...current, phase}));
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearCheckpoint() {
|
||||
if (!storage || !checkpoint()) return false;
|
||||
try {
|
||||
storage.removeItem(checkpointKey);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function briefing() {
|
||||
const counts = options.getCounts ? options.getCounts() : {};
|
||||
let phases = reviewPhases(counts);
|
||||
const followingUnavailable = counts.followingUnavailable === true;
|
||||
if (followingUnavailable) {
|
||||
phases = phases.filter(phase => phase.name !== 'following');
|
||||
phases.push({name:'following', label:'Following unavailable', count:count(counts.following)});
|
||||
}
|
||||
const total = phases.reduce((sum, phase) => sum + phase.count, 0);
|
||||
const today = count(counts.today);
|
||||
const delivery = phases.find(phase => phase.name === 'delivery')?.count || 0;
|
||||
const other = total - delivery;
|
||||
const next = phases.length ? phases[0].name : (today ? 'today' : 'find');
|
||||
const nextLabel = next === 'delivery' ? 'Review Delivery' :
|
||||
(next === 'following' && followingUnavailable ? 'Retry Following' :
|
||||
(phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work')));
|
||||
const followingRetry = next === 'following' && followingUnavailable;
|
||||
return {
|
||||
total,
|
||||
next,
|
||||
label: nextLabel,
|
||||
summary: followingRetry ? 'Following needs retry before Today · ' + today + ' planned' :
|
||||
delivery ? delivery + (delivery === 1 ? ' delivery needs' : ' deliveries need') +
|
||||
' action before Today' + (other ? ' · ' + other + ' other ' + (other === 1 ? 'item' : 'items') : '') +
|
||||
' · ' + today + ' planned' :
|
||||
total ? total + ' items before Today · ' + today + ' planned' :
|
||||
(today ? 'Review clear · ' + today + ' planned' : 'Review clear · Today is empty'),
|
||||
phases,
|
||||
};
|
||||
}
|
||||
|
||||
function startNext() {
|
||||
const next = briefing().next;
|
||||
if (reviewOrder.some(([name]) => name === next)) saveCheckpoint(next);
|
||||
else clearCheckpoint();
|
||||
options.openQueue(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function state() {
|
||||
const current = briefing();
|
||||
return {active:Boolean(checkpoint()), next:current.next, label:current.label};
|
||||
}
|
||||
|
||||
function completePhase(phase) {
|
||||
const saved = checkpoint();
|
||||
if (!saved || (saved.phase && saved.phase !== phase)) return false;
|
||||
const current = render();
|
||||
saveCheckpoint();
|
||||
if (options.onHandoff) options.onHandoff(current);
|
||||
return true;
|
||||
}
|
||||
|
||||
function reconcile({authoritative = false, authoritativePhases = []} = {}) {
|
||||
const saved = checkpoint();
|
||||
if (!saved?.phase || (!authoritative && !authoritativePhases.includes(saved.phase))) return false;
|
||||
const counts = options.getCounts ? options.getCounts() : {};
|
||||
if (count(counts[saved.phase]) > 0) return false;
|
||||
return completePhase(saved.phase);
|
||||
}
|
||||
|
||||
function finish() {
|
||||
const cleared = clearCheckpoint();
|
||||
render();
|
||||
return cleared;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const current = briefing();
|
||||
if (!options.elements) return current;
|
||||
options.elements.summary.textContent = current.summary;
|
||||
options.elements.phases.textContent = current.phases.length ?
|
||||
current.phases.map(phase => phase.label + ' ' + phase.count).join(' · ') :
|
||||
'All urgent queues reviewed';
|
||||
options.elements.action.textContent = checkpoint() ? 'Resume preparation · ' + current.label : current.label;
|
||||
if (options.elements.finish) options.elements.finish.hidden = !checkpoint();
|
||||
return current;
|
||||
}
|
||||
|
||||
function start() {
|
||||
render();
|
||||
if (options.elements) options.elements.action.addEventListener('click', startNext);
|
||||
}
|
||||
|
||||
return {briefing, completePhase, finish, reconcile, render, start, startNext, state};
|
||||
});
|
||||
@@ -33,8 +33,9 @@
|
||||
options.queueSheet.showModal();
|
||||
}
|
||||
|
||||
function closeQueues() {
|
||||
function closeQueues(returnToToday = false) {
|
||||
options.queueSheet?.open && options.queueSheet.close();
|
||||
if (returnToToday) options.detour?.()?.finishDetour();
|
||||
}
|
||||
|
||||
function start() {
|
||||
@@ -42,14 +43,16 @@
|
||||
button.addEventListener('click', event => {
|
||||
launcher = event.currentTarget;
|
||||
select(name);
|
||||
name === 'queues' && options.queueSheet ? openQueues() : options.actions[name]();
|
||||
if (name === 'queues') options.detour?.()?.beginDetour(name);
|
||||
options.actions?.[name]?.();
|
||||
if (name === 'queues' && options.queueSheet) openQueues();
|
||||
});
|
||||
});
|
||||
if (options.queueSheet) {
|
||||
options.queueClose?.addEventListener('click', closeQueues);
|
||||
options.queueClose?.addEventListener('click', () => closeQueues(true));
|
||||
options.queueSheet.addEventListener('cancel', event => {
|
||||
event.preventDefault();
|
||||
closeQueues();
|
||||
closeQueues(true);
|
||||
});
|
||||
options.queueSheet.addEventListener('close', () => buttons.queues?.focus());
|
||||
Object.entries(options.queueRows || {}).forEach(([name, row]) => {
|
||||
@@ -66,13 +69,16 @@
|
||||
function updateWork(mode) {
|
||||
const text = {
|
||||
continue:'Continue', resume:'Resume', start:'Start', plan:'Plan', find:'Find',
|
||||
attention:'Attention', update:'Updates', agenda:'Agenda', later:'Later', draft:'Drafts',
|
||||
prepare:'Prepare', 'prepare-resume':'Resume prep',
|
||||
delivery:'Delivery', attention:'Attention', update:'Updates', agenda:'Agenda', filed:'Filed', later:'Later', draft:'Drafts',
|
||||
}[mode] || 'Work';
|
||||
const queue = ['Attention', 'Updates', 'Agenda', 'Later', 'Drafts'].includes(text);
|
||||
const queue = ['Delivery', 'Attention', 'Updates', 'Agenda', 'Filed', 'Later', 'Drafts'].includes(text);
|
||||
if (options.workLabel) options.workLabel.textContent = text;
|
||||
if (buttons.work) buttons.work.setAttribute('aria-label', queue
|
||||
? (mode === 'update' ? 'Resume ' : 'Open ') + text
|
||||
: mode === 'find' ? 'Find work' : text + (mode === 'work' ? '' : ' Today'));
|
||||
const actionLabel = mode === 'prepare' ? 'Prepare Today' :
|
||||
mode === 'prepare-resume' ? 'Resume preparation' :
|
||||
queue ? (mode === 'update' ? 'Resume ' : 'Open ') + text :
|
||||
mode === 'find' ? 'Find work' : text + (mode === 'work' ? '' : ' Today');
|
||||
if (buttons.work) buttons.work.setAttribute('aria-label', actionLabel);
|
||||
}
|
||||
|
||||
function updateAttention(count) {
|
||||
@@ -91,9 +97,9 @@
|
||||
}
|
||||
|
||||
function updateQueues(counts) {
|
||||
const names = 'today agenda attention update later draft'.split(' ');
|
||||
const names = 'today agenda delivery attention update filed later draft'.split(' ');
|
||||
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
|
||||
const actionableNames = ['today', 'attention', 'update', 'later', 'draft'];
|
||||
const actionableNames = ['today', 'delivery', 'attention', 'update', 'filed', 'later', 'draft'];
|
||||
const active = actionableNames.reduce((total, name) => total + (normalized[name] > 0 ? 1 : 0), 0);
|
||||
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
|
||||
element.textContent = String(normalized[name] || 0);
|
||||
@@ -117,8 +123,10 @@
|
||||
? 'Queues: no active queues; no upcoming deadlines'
|
||||
: 'Queues: Today ' + normalized.today
|
||||
+ ', Agenda ' + normalized.agenda + ' due'
|
||||
+ ', Delivery ' + normalized.delivery
|
||||
+ ', Attention ' + normalized.attention
|
||||
+ ', Updates ' + normalized.update
|
||||
+ ', Filed ' + normalized.filed
|
||||
+ ', Later ' + normalized.later
|
||||
+ ', Drafts ' + normalized.draft
|
||||
+ '; ' + active + ' active ' + (active === 1 ? 'queue' : 'queues');
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
else root.createMobileTodayCommandBar = factory;
|
||||
})(typeof window !== 'undefined' ? window : this, function createMobileTodayCommandBar({
|
||||
more,
|
||||
sheet,
|
||||
close,
|
||||
firstAction,
|
||||
endAction,
|
||||
existingEndControl,
|
||||
actionButtons = [],
|
||||
hud,
|
||||
dock,
|
||||
style,
|
||||
ResizeObserverImpl = typeof ResizeObserver === 'undefined' ? null : ResizeObserver,
|
||||
}) {
|
||||
if (!more || !sheet) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
if (sheet.open) sheet.close();
|
||||
};
|
||||
const open = () => {
|
||||
if (sheet.open) return;
|
||||
more.setAttribute('aria-expanded', 'true');
|
||||
sheet.showModal();
|
||||
firstAction?.focus();
|
||||
};
|
||||
|
||||
more.setAttribute('aria-expanded', 'false');
|
||||
more.addEventListener('click', open);
|
||||
close?.addEventListener('click', dismiss);
|
||||
actionButtons.forEach(button => button?.addEventListener('click', dismiss));
|
||||
endAction?.addEventListener('click', () => {
|
||||
dismiss();
|
||||
existingEndControl?.click();
|
||||
});
|
||||
sheet.addEventListener('cancel', event => {
|
||||
event.preventDefault();
|
||||
dismiss();
|
||||
});
|
||||
sheet.addEventListener('close', () => {
|
||||
more.setAttribute('aria-expanded', 'false');
|
||||
more.focus();
|
||||
});
|
||||
|
||||
const measure = () => {
|
||||
if (!style || !hud || !dock) return;
|
||||
style.setProperty('--mobile-today-clearance', `${hud.offsetHeight + dock.offsetHeight + 16}px`);
|
||||
};
|
||||
const observer = ResizeObserverImpl && hud && dock ? new ResizeObserverImpl(measure) : null;
|
||||
observer?.observe(hud);
|
||||
observer?.observe(dock);
|
||||
measure();
|
||||
|
||||
return { open, close:dismiss, measure, destroy:() => observer?.disconnect() };
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
function createMobileUpdateDetailNavigation(options) {
|
||||
const buttons = options.buttons || {};
|
||||
const targets = options.targets || {};
|
||||
const listeners = new Map();
|
||||
const prefersReducedMotion = options.prefersReducedMotion || (() => false);
|
||||
const targetNames = new Map(Object.entries(targets).map(([name, target]) => [target, name]));
|
||||
let observer = null;
|
||||
|
||||
function select(name) {
|
||||
Object.entries(buttons).forEach(([key, button]) => {
|
||||
if (!button) return;
|
||||
if (key === name) button.setAttribute('aria-current', 'location');
|
||||
else button.removeAttribute('aria-current');
|
||||
});
|
||||
}
|
||||
|
||||
function navigate(name, navigationOptions = {}) {
|
||||
if (name === 'activity') {
|
||||
if (options.jumpToNewActivity) options.jumpToNewActivity();
|
||||
select(name);
|
||||
return true;
|
||||
}
|
||||
const target = targets[name];
|
||||
if (!target) return false;
|
||||
if (name === 'context') target.open = true;
|
||||
target.scrollIntoView({
|
||||
block:'start',
|
||||
behavior:prefersReducedMotion() ? 'auto' : 'smooth',
|
||||
});
|
||||
if (name === 'reply' && options.replyComposer && navigationOptions.focus !== false) {
|
||||
options.replyComposer.focus({ preventScroll:true });
|
||||
}
|
||||
select(name);
|
||||
return true;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (targets.context) targets.context.open = false;
|
||||
select('activity');
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
Object.entries(buttons).forEach(([name, button]) => {
|
||||
if (!button || listeners.has(button)) return;
|
||||
const listener = event => {
|
||||
event.preventDefault();
|
||||
navigate(name);
|
||||
if (options.onSectionChange) options.onSectionChange(name, { replace:false });
|
||||
};
|
||||
listeners.set(button, listener);
|
||||
button.addEventListener('click', listener);
|
||||
});
|
||||
reset();
|
||||
const observe = options.observe || ((handler, observedTargets) => {
|
||||
if (typeof IntersectionObserver === 'undefined') return null;
|
||||
const instance = new IntersectionObserver(handler, {
|
||||
root:options.root || null,
|
||||
rootMargin:'-20% 0px -60% 0px',
|
||||
threshold:[0, 0.25, 0.5, 0.75, 1],
|
||||
});
|
||||
observedTargets.forEach(target => instance.observe(target));
|
||||
return instance;
|
||||
});
|
||||
observer = observe(entries => {
|
||||
const visible = entries
|
||||
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
|
||||
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
|
||||
if (visible) {
|
||||
const name = targetNames.get(visible.target);
|
||||
select(name);
|
||||
if (options.onSectionChange) options.onSectionChange(name, { replace:true });
|
||||
}
|
||||
}, Array.from(targetNames.keys()).filter(Boolean));
|
||||
},
|
||||
stop() {
|
||||
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
|
||||
listeners.clear();
|
||||
if (observer) observer.disconnect();
|
||||
observer = null;
|
||||
},
|
||||
navigate,
|
||||
reset,
|
||||
select,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = createMobileUpdateDetailNavigation;
|
||||
@@ -4,20 +4,26 @@
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileWorkEntry(options) {
|
||||
function mode() {
|
||||
if (options.isTodayActive()) return 'continue';
|
||||
const preparation = options.getPreparationState?.();
|
||||
if (preparation?.active) return 'prepare-resume';
|
||||
if (preparation?.next && !['today', 'find'].includes(preparation.next)) return 'prepare';
|
||||
const recommended = options.queueLauncher && options.queueLauncher.recommend();
|
||||
if (recommended && recommended.name !== 'today') return recommended.name;
|
||||
if (recommended && !['today', 'find'].includes(recommended.name)) return recommended.name;
|
||||
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
|
||||
if (options.getTodayCount() > 0) return 'start';
|
||||
if (options.getEligibleCount() > 0) return 'plan';
|
||||
if (options.shouldActivate?.()) return 'activate';
|
||||
return 'find';
|
||||
}
|
||||
|
||||
function open() {
|
||||
const current = mode();
|
||||
if (current === 'continue') options.continueToday();
|
||||
else if (['prepare', 'prepare-resume'].includes(current)) options.prepareToday();
|
||||
else if (current === 'resume') options.resumeToday();
|
||||
else if (current === 'start') options.startToday();
|
||||
else if (current === 'plan') options.planToday();
|
||||
else if (current === 'activate') options.openActivation();
|
||||
else if (current === 'find') options.findWork();
|
||||
else options.queueLauncher.open(current);
|
||||
return current;
|
||||
|
||||
+128
-2
@@ -70,19 +70,27 @@ function buildMyWork(data, now = new Date()) {
|
||||
);
|
||||
const assigned = (item.assignees || []).includes(login);
|
||||
const isReview = (item.work_reasons || []).includes('review_requested');
|
||||
const isFiled = (item.work_reasons || []).includes('created_by_me');
|
||||
const isAuthored = (item.work_reasons || []).includes('authored_by_me');
|
||||
const isCompleted = isFiled && item.state === 'closed';
|
||||
const due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null;
|
||||
const normalized = {
|
||||
...item,
|
||||
key: (item.repository || 'unknown') + '#' + item.number,
|
||||
is_review: isReview,
|
||||
is_filed: isFiled,
|
||||
is_authored: isAuthored,
|
||||
is_completed: isCompleted,
|
||||
is_assigned: assigned,
|
||||
has_update: false,
|
||||
...(due ? { due_label: due.label } : {}),
|
||||
reason: priorityLabel ? priorityLabel + ' priority' :
|
||||
(due && due.priority < 4 ? due.label :
|
||||
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work'))),
|
||||
(isReview ? 'Needs your review' : (isCompleted ? 'Completed · review outcome' :
|
||||
(assigned ? 'Assigned to you' : (isFiled ? 'Filed by you' :
|
||||
(isAuthored ? 'Authored by you' : 'Open work')))))),
|
||||
_priority: priorityLabel ? 0 :
|
||||
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (assigned ? 4 : 5))),
|
||||
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (isCompleted ? 3.5 : (assigned ? 4 : 5)))),
|
||||
};
|
||||
normalized.needs_attention = needsAttention(normalized);
|
||||
normalized.attention_reason = attentionReason(normalized);
|
||||
@@ -651,6 +659,8 @@ function createNotificationReplier({
|
||||
function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
|
||||
let filtered = items;
|
||||
if (selectedFilter === 'attention') filtered = items.filter(needsAttention);
|
||||
else if (selectedFilter === 'filed') filtered = items.filter((item) => item.is_filed);
|
||||
else if (selectedFilter === 'authored') filtered = items.filter((item) => item.is_authored);
|
||||
else if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review);
|
||||
else if (selectedFilter === 'update') filtered = items.filter((item) => item.has_update);
|
||||
else if (selectedFilter !== 'all') filtered = items.filter((item) => item.kind === selectedFilter);
|
||||
@@ -663,6 +673,111 @@ function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
|
||||
);
|
||||
}
|
||||
|
||||
function createCompletedFiledReview({
|
||||
storage,
|
||||
getLogin,
|
||||
key = 'stackchain.completed-filed-review.v1',
|
||||
}) {
|
||||
const login = () => String(getLogin?.() || '').trim();
|
||||
const ownerKey = () => key + ':' + login();
|
||||
const identity = item => String(item?.repository || '') + '#' + String(item?.number || '');
|
||||
const read = () => {
|
||||
if (!login()) return { items:{}, pending:{} };
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(ownerKey()) || '{}');
|
||||
return value && value.version === 1 && value.items && typeof value.items === 'object' ? {
|
||||
items:value.items,
|
||||
pending:value.pending && typeof value.pending === 'object' ? value.pending : {},
|
||||
} : { items:{}, pending:{} };
|
||||
} catch (_error) { return { items:{}, pending:{} }; }
|
||||
};
|
||||
const receipt = (item, stamp = String(item?.updated_at || '')) => ({
|
||||
repository:String(item?.repository || ''), number:item?.number, updated_at:stamp,
|
||||
});
|
||||
const save = value => storage?.setItem(ownerKey(), JSON.stringify({ version:1, ...value }));
|
||||
const partition = items => {
|
||||
const acknowledged = read().items;
|
||||
const needsReview = [];
|
||||
const reviewed = [];
|
||||
(items || []).forEach(item => {
|
||||
const isReviewed = item?.is_completed &&
|
||||
acknowledged[identity(item)] === String(item.updated_at || '');
|
||||
(isReviewed ? reviewed : needsReview).push(item);
|
||||
});
|
||||
reviewed.sort((left, right) => String(right.updated_at || '').localeCompare(String(left.updated_at || '')));
|
||||
return { needsReview, reviewed };
|
||||
};
|
||||
return {
|
||||
partition,
|
||||
visible(items) {
|
||||
return partition(items).needsReview;
|
||||
},
|
||||
pending() {
|
||||
const state = read();
|
||||
return Object.entries(state.pending).map(([itemIdentity, stamp]) => {
|
||||
const boundary = itemIdentity.lastIndexOf('#');
|
||||
return receipt({
|
||||
repository:itemIdentity.slice(0, boundary),
|
||||
number:Number(itemIdentity.slice(boundary + 1)),
|
||||
}, stamp);
|
||||
});
|
||||
},
|
||||
adopt(snapshot) {
|
||||
const state = read();
|
||||
let changed = false;
|
||||
(snapshot?.receipts || []).forEach(remote => {
|
||||
const itemIdentity = identity(remote);
|
||||
const stamp = String(remote?.updated_at || '');
|
||||
if (!remote?.repository || !Number.isInteger(remote?.number) || !stamp) return;
|
||||
if (!state.items[itemIdentity] || state.items[itemIdentity] < stamp) {
|
||||
state.items[itemIdentity] = stamp;
|
||||
changed = true;
|
||||
}
|
||||
if (state.pending[itemIdentity] && state.pending[itemIdentity] <= stamp) {
|
||||
delete state.pending[itemIdentity];
|
||||
}
|
||||
});
|
||||
try { save(state); } catch (_error) { return false; }
|
||||
return changed;
|
||||
},
|
||||
acknowledge(item) {
|
||||
const stamp = String(item?.updated_at || '');
|
||||
if (!login() || !item?.is_completed || !item?.repository || !Number.isInteger(item?.number) || !stamp) {
|
||||
return false;
|
||||
}
|
||||
const state = read();
|
||||
const entries = Object.entries({ ...state.items, [identity(item)]: stamp }).slice(-200);
|
||||
const pending = Object.entries({ ...state.pending, [identity(item)]: stamp }).slice(-200);
|
||||
try {
|
||||
save({ items:Object.fromEntries(entries), pending:Object.fromEntries(pending) });
|
||||
return true;
|
||||
} catch (_error) { return false; }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFiledHistoryTabs({ root, review, onSelect }) {
|
||||
const buttons = Array.from(root.querySelectorAll('[data-filed-view]'));
|
||||
let partition = { needsReview:[], reviewed:[] };
|
||||
buttons.forEach(button => button.addEventListener('click', () => onSelect(button.dataset.filedView)));
|
||||
return {
|
||||
prepare(items) {
|
||||
partition = review.partition(items);
|
||||
root.querySelector('#filed-needs-review-count').textContent =
|
||||
partition.needsReview.filter(item => item.is_filed).length;
|
||||
root.querySelector('#filed-reviewed-count').textContent = partition.reviewed.length;
|
||||
return partition.needsReview;
|
||||
},
|
||||
render(selected, visible) {
|
||||
root.hidden = !visible;
|
||||
buttons.forEach(button =>
|
||||
button.setAttribute('aria-pressed', String(button.dataset.filedView === selected))
|
||||
);
|
||||
return selected === 'reviewed' ? partition.reviewed : partition.needsReview;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function agendaMyWork(items, now = new Date()) {
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const today = localDay(start);
|
||||
@@ -958,10 +1073,18 @@ function findQueueItems(items, query) {
|
||||
});
|
||||
}
|
||||
|
||||
function filedFollowUpTarget(items) {
|
||||
const item = (items || []).find(candidate => candidate?.is_filed);
|
||||
if (!item) return null;
|
||||
return { kind:item.has_update ? 'update' : 'issue', item };
|
||||
}
|
||||
|
||||
function countMyWork(items) {
|
||||
return {
|
||||
all: items.length,
|
||||
attention: items.filter(needsAttention).length,
|
||||
filed: items.filter((item) => item.is_filed).length,
|
||||
authored: items.filter((item) => item.is_authored).length,
|
||||
issue: items.filter((item) => item.kind === 'issue').length,
|
||||
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
|
||||
review: items.filter((item) => item.is_review).length,
|
||||
@@ -971,6 +1094,8 @@ function countMyWork(items) {
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.filterMyWork = filterMyWork;
|
||||
buildMyWork.createCompletedFiledReview = createCompletedFiledReview;
|
||||
buildMyWork.createFiledHistoryTabs = createFiledHistoryTabs;
|
||||
buildMyWork.agendaMyWork = agendaMyWork;
|
||||
buildMyWork.milestoneLanes = milestoneLanes;
|
||||
buildMyWork.createWorkSession = createWorkSession;
|
||||
@@ -983,6 +1108,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.removeIssue = removeIssue;
|
||||
buildMyWork.summarizeMyWork = summarizeMyWork;
|
||||
buildMyWork.findQueueItems = findQueueItems;
|
||||
buildMyWork.filedFollowUpTarget = filedFollowUpTarget;
|
||||
buildMyWork.countMyWork = countMyWork;
|
||||
buildMyWork.acknowledgeNotification = acknowledgeNotification;
|
||||
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.inspectStackchainPrivateDatabases = factory(root.indexedDB);
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function createPrivateDataInspector(indexedDB) {
|
||||
function requestResult(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('Private storage inventory failed.'));
|
||||
request.onblocked = () => reject(new Error('Private storage inventory was blocked.'));
|
||||
});
|
||||
}
|
||||
|
||||
async function countDatabase(name) {
|
||||
const database = await requestResult(indexedDB.open(name));
|
||||
try {
|
||||
const storeNames = Array.from(database.objectStoreNames);
|
||||
if (!storeNames.length) return 0;
|
||||
const transaction = database.transaction(storeNames, 'readonly');
|
||||
const counts = await Promise.all(storeNames.map(storeName =>
|
||||
requestResult(transaction.objectStore(storeName).count())
|
||||
));
|
||||
return counts.reduce((total, count) => total + Number(count || 0), 0);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
return async function inspectPrivateDatabases(registeredNames) {
|
||||
if (!indexedDB?.databases) return { recordCount: 0, unavailable: true };
|
||||
try {
|
||||
const existing = new Set((await indexedDB.databases()).map(database => database.name));
|
||||
const counts = await Promise.all(
|
||||
registeredNames.filter(name => existing.has(name)).map(countDatabase)
|
||||
);
|
||||
return {
|
||||
recordCount: counts.reduce((total, count) => total + count, 0),
|
||||
unavailable: false,
|
||||
};
|
||||
} catch (_error) {
|
||||
return { recordCount: 0, unavailable: true };
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
(function (root) {
|
||||
const databases = Object.freeze([
|
||||
'stackchain-background-outbox-v1',
|
||||
'stackchain-offline-work-v2',
|
||||
'stackchain-unfiled-captures-v1',
|
||||
'stackchain-voice-transcripts-v1',
|
||||
'stackchain-search-reply-drafts-v1',
|
||||
'stackchain-conversation-reply-drafts-v1',
|
||||
'stackchain-today-action-mailbox-v1',
|
||||
'stackchain-app-badge-preference-v1',
|
||||
]);
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = databases;
|
||||
else root.stackchainPrivateDatabases = databases;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
||||
@@ -1,5 +1,10 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = options => factory({
|
||||
...options,
|
||||
privateDatabases: require('./private-data-registry.js'),
|
||||
});
|
||||
}
|
||||
else root.stackchainPrivateDeviceData = factory({
|
||||
localStorage: root.localStorage,
|
||||
sessionStorage: root.sessionStorage,
|
||||
@@ -7,9 +12,10 @@
|
||||
caches: root.caches,
|
||||
serviceWorker: root.navigator?.serviceWorker,
|
||||
MessageChannel: root.MessageChannel,
|
||||
privateDatabases: root.stackchainPrivateDatabases,
|
||||
});
|
||||
})(typeof window !== 'undefined' ? window : this, function createPrivateDeviceDataPurger({
|
||||
localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel,
|
||||
localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, privateDatabases,
|
||||
}) {
|
||||
function removeOwnedStorage(storage) {
|
||||
if (!storage) return;
|
||||
@@ -49,12 +55,10 @@
|
||||
}
|
||||
|
||||
return async function clearPrivateDeviceData() {
|
||||
await stopWorkerOutbox();
|
||||
removeOwnedStorage(localStorage);
|
||||
if (sessionStorage !== localStorage) removeOwnedStorage(sessionStorage);
|
||||
await stopWorkerOutbox();
|
||||
await deletePrivateDatabase('stackchain-background-outbox-v1');
|
||||
await deletePrivateDatabase('stackchain-offline-work-v2');
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
for (const name of privateDatabases) await deletePrivateDatabase(name);
|
||||
const keys = await caches?.keys?.() || [];
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
|
||||
+628
-5
@@ -12,12 +12,107 @@ function mergeEligibility(detail, reviewState) {
|
||||
'CI blocked by ' + blockers.join(', ') : 'CI must succeed before merging' };
|
||||
}
|
||||
if (!detail.head_sha) return { allowed: false, reason: 'Current head is unavailable' };
|
||||
const reviewer = (detail.reviewers || []).find(item => item?.blocking);
|
||||
if (reviewer?.status === 'waiting') {
|
||||
return { allowed: false, reason: 'Waiting for @' + reviewer.login + '’s review' };
|
||||
}
|
||||
if (reviewer?.status === 'changes_requested') {
|
||||
return { allowed: false, reason: '@' + reviewer.login + ' requested changes' };
|
||||
}
|
||||
if (reviewer?.status === 'outdated') {
|
||||
return { allowed: false, reason: '@' + reviewer.login + '’s review is outdated after new commits' };
|
||||
}
|
||||
if (reviewState && !reviewState.complete) {
|
||||
return { allowed: false, reason: 'Review every changed file before merging' };
|
||||
}
|
||||
return { allowed: true, reason: 'Ready to merge' };
|
||||
}
|
||||
|
||||
function renderReviewerFeedback(reviewer, escapeHtml) {
|
||||
const groups = new Map();
|
||||
(Array.isArray(reviewer?.comments) ? reviewer.comments : []).forEach(comment => {
|
||||
if (!comment?.path || !comment?.body) return;
|
||||
if (!groups.has(comment.path)) groups.set(comment.path, []);
|
||||
groups.get(comment.path).push(comment);
|
||||
});
|
||||
const summary = reviewer?.summary ? '<p class="pull-review-feedback-summary">' +
|
||||
escapeHtml(reviewer.summary) + '</p>' : '';
|
||||
const files = Array.from(groups.entries()).map(([path, comments]) =>
|
||||
'<section class="pull-review-feedback-file"><h4>' + escapeHtml(path) + '</h4><ul>' +
|
||||
comments.map(comment => '<li>' + escapeHtml(comment.body) +
|
||||
(comment.line ? '<small>Line ' + Number(comment.line) + '</small>' : '') + '</li>').join('') +
|
||||
'</ul><button type="button" data-review-feedback-file="' + escapeHtml(path) +
|
||||
'">View in changes</button></section>'
|
||||
).join('');
|
||||
const loadable = Number.isInteger(reviewer?.review_id) && reviewer.review_id > 0 &&
|
||||
['commented', 'changes_requested', 'outdated'].includes(reviewer?.status);
|
||||
const feedbackState = Array.isArray(reviewer?.comments) ? 'loaded' : reviewer?.feedback_state;
|
||||
const feedbackAction = !loadable || feedbackState === 'loaded' ? '' :
|
||||
feedbackState === 'loading' ? '<p class="small" role="status">Loading inline feedback…</p>' :
|
||||
'<p class="small" role="status">' + (feedbackState === 'error' ? 'Feedback unavailable. ' : '') +
|
||||
'<button type="button" data-load-review-feedback="' + Number(reviewer.review_id) + '">' +
|
||||
(feedbackState === 'error' ? 'Retry feedback' : 'Load inline feedback') + '</button></p>';
|
||||
const emptyLoaded = loadable && feedbackState === 'loaded' && !files ?
|
||||
'<p class="small">No inline comments on this review.</p>' : '';
|
||||
if (!summary && !files && !feedbackAction && !emptyLoaded) return '';
|
||||
const reviewedHead = reviewer?.status === 'outdated' && reviewer?.head_sha ?
|
||||
'<p class="small">Reviewed head ' + escapeHtml(reviewer.head_sha.slice(0, 8)) + '</p>' : '';
|
||||
const addressable = Number.isInteger(reviewer?.review_id) && reviewer.review_id > 0 &&
|
||||
['changes_requested', 'outdated'].includes(reviewer?.status) && feedbackState === 'loaded' &&
|
||||
(reviewer.comments || []).some(comment => Number.isInteger(comment?.id) && comment.id > 0);
|
||||
const address = addressable ? '<button type="button" class="address-review-feedback" data-address-review-feedback="' +
|
||||
Number(reviewer.review_id) + '">Address feedback</button>' : '';
|
||||
return '<details class="pull-review-feedback"><summary>Review feedback</summary>' +
|
||||
reviewedHead + summary + feedbackAction + emptyLoaded + files + address + '</details>';
|
||||
}
|
||||
|
||||
function renderReviewerStatuses(reviewers, escapeHtml) {
|
||||
const labels = {
|
||||
waiting: 'Waiting for review',
|
||||
approved: 'Approved current head',
|
||||
changes_requested: 'Changes requested',
|
||||
commented: 'Commented',
|
||||
outdated: 'Outdated after new commits',
|
||||
};
|
||||
return (Array.isArray(reviewers) ? reviewers : []).map(reviewer => {
|
||||
const status = labels[reviewer?.status] || 'Status unavailable';
|
||||
const cancel = reviewer?.status === 'waiting' && reviewer?.login ?
|
||||
'<button type="button" class="cancel-review-request" data-cancel-review-request="' +
|
||||
escapeHtml(reviewer.login) + '">Cancel request</button>' : '';
|
||||
return '<article class="pull-reviewer-status" data-reviewer-status="' +
|
||||
escapeHtml(reviewer?.status || 'unknown') + '" data-review-id="' +
|
||||
(Number.isInteger(reviewer?.review_id) ? Number(reviewer.review_id) : '') +
|
||||
'"><div class="pull-reviewer-status-heading"><strong>@' +
|
||||
escapeHtml(reviewer?.login || 'unknown') + '</strong><span>' + escapeHtml(status) + '</span></div>' +
|
||||
renderReviewerFeedback(reviewer, escapeHtml) + cancel + '</article>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderReviewerPanel(doc, detail, escapeHtml = value => String(value)
|
||||
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>')) {
|
||||
const reviewers = Array.isArray(detail?.reviewers) ? detail.reviewers : [];
|
||||
const openFeedback = new Set(Array.from(doc.querySelectorAll('.pull-review-feedback[open]'))
|
||||
.map(panel => panel.closest('.pull-reviewer-status')?.dataset.reviewId).filter(Boolean));
|
||||
doc.querySelector('#pull-reviewer-statuses').innerHTML = renderReviewerStatuses(reviewers, escapeHtml);
|
||||
openFeedback.forEach(reviewId => {
|
||||
const panel = doc.querySelector('.pull-reviewer-status[data-review-id="' + reviewId + '"] .pull-review-feedback');
|
||||
if (panel) panel.open = true;
|
||||
});
|
||||
doc.querySelectorAll('[data-review-feedback-file]').forEach(button => {
|
||||
button.addEventListener('click', () => focusPullFile(doc, button.dataset.reviewFeedbackFile));
|
||||
});
|
||||
doc.querySelector('#pull-reviewer-status').textContent = reviewers.length ?
|
||||
reviewers.length + ' reviewer status' + (reviewers.length === 1 ? '' : 'es') + ' for this pull request.' :
|
||||
'No review has been requested; existing merge policy is unchanged.';
|
||||
doc.querySelector('#request-updated-pull-review').hidden =
|
||||
!reviewers.some(reviewer => reviewer.status === 'outdated');
|
||||
}
|
||||
|
||||
function reviewEligibility(detail, reviewState, doc) {
|
||||
renderReviewerPanel(doc, detail);
|
||||
return mergeEligibility(detail, reviewState);
|
||||
}
|
||||
|
||||
function renderFile(file, index, reviewed, escapeHtml) {
|
||||
const filename = escapeHtml(file.filename || 'Unknown file');
|
||||
const panelId = 'pull-diff-' + index;
|
||||
@@ -41,18 +136,22 @@ function renderFile(file, index, reviewed, escapeHtml) {
|
||||
'" aria-pressed="' + String(reviewed) + '">' + (reviewed ? 'Reviewed' : 'Mark reviewed') + '</button></article>';
|
||||
}
|
||||
|
||||
function focusNextUnreviewed(doc, detail, state, controller) {
|
||||
if (!detail || !state) return;
|
||||
const filename = controller.nextUnreviewed(detail, state);
|
||||
function focusPullFile(doc, filename) {
|
||||
const article = Array.from(doc.querySelectorAll('#pull-files .pull-file'))
|
||||
.find(file => file.dataset.pullFilename === filename);
|
||||
const toggle = article?.querySelector('.pull-file-toggle');
|
||||
const panel = toggle && doc.getElementById(toggle.getAttribute('aria-controls'));
|
||||
if (!toggle || !panel) return;
|
||||
if (!toggle || !panel) return false;
|
||||
toggle.setAttribute('aria-expanded', 'true');
|
||||
panel.hidden = false;
|
||||
toggle.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
toggle.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
function focusNextUnreviewed(doc, detail, state, controller) {
|
||||
if (!detail || !state) return;
|
||||
focusPullFile(doc, controller.nextUnreviewed(detail, state));
|
||||
}
|
||||
|
||||
function removeFromSnapshot(data, item) {
|
||||
@@ -99,9 +198,374 @@ function resetOwnershipControls(doc, item, checkpointed) {
|
||||
qs('#release-pull').disabled = false;
|
||||
qs('#release-pull').textContent = checkpointed(item) ? 'Release & next' : 'Release assignment';
|
||||
qs('#pull-handoff-status').textContent = 'Load teammates to transfer ownership.';
|
||||
qs('#edit-pull-content').hidden = true;
|
||||
qs('#pull-edit-form').hidden = true;
|
||||
qs('#pull-edit-status').textContent = '';
|
||||
}
|
||||
|
||||
function bindOwnershipControls(doc, controller, getSelected, finish) {
|
||||
function resetReviewRequestControls(doc, detail) {
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
qs('#pull-review-request').open = false;
|
||||
qs('#pull-review-request').dataset.headSha = detail?.head_sha || '';
|
||||
qs('#pull-review-recipient').innerHTML = '<option value="">Select a teammate</option>';
|
||||
qs('#pull-review-recipient').disabled = true;
|
||||
qs('#confirm-pull-review').disabled = true;
|
||||
qs('#load-pull-reviewers').disabled = !detail?.head_sha;
|
||||
qs('#pull-review-request-status').textContent = detail?.head_sha ?
|
||||
'Load teammates to request a review.' : 'Load the current pull request before requesting review.';
|
||||
qs('#pull-reviewer-status').textContent = 'Reviewer status not loaded.';
|
||||
qs('#pull-reviewer-statuses').textContent = '';
|
||||
qs('#request-updated-pull-review').hidden = true;
|
||||
qs('#pull-feedback-pass').hidden = true;
|
||||
qs('#pull-feedback-status').textContent = '';
|
||||
}
|
||||
|
||||
function bindReviewRequestControls(doc, controller, getSelected, getDetail, getLogin) {
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
const load = qs('#load-pull-reviewers');
|
||||
if (load.dataset.reviewRequestBound === 'true') return;
|
||||
load.dataset.reviewRequestBound = 'true';
|
||||
controller.setReviewDetail = detail => {
|
||||
resetReviewRequestControls(doc, detail);
|
||||
controller.edit?.setDetail(detail, Boolean(detail?.saved_at));
|
||||
const publishable = detail?.state === 'open' && detail?.draft === true &&
|
||||
detail?.author === getLogin?.() && detail?.head_sha;
|
||||
qs('#publish-draft-pull').hidden = !publishable;
|
||||
if (publishable) qs('#pull-review-request').open = true;
|
||||
qs('#publish-draft-pull-status').textContent = '';
|
||||
};
|
||||
resetReviewRequestControls(doc, null);
|
||||
qs('#request-updated-pull-review').addEventListener('click', () => {
|
||||
qs('#pull-review-request').open = true;
|
||||
qs('#pull-review-request').scrollIntoView({ block:'center', behavior:'smooth' });
|
||||
load.focus();
|
||||
});
|
||||
controller.onReviewRequested = result => {
|
||||
const detail = getDetail?.();
|
||||
if (!detail || result.head_sha !== detail.head_sha) return;
|
||||
detail.reviewers = (detail.reviewers || []).filter(reviewer => reviewer.login !== result.reviewer);
|
||||
detail.reviewers.push({login:result.reviewer, status:'waiting', head_sha:result.head_sha, blocking:true});
|
||||
renderReviewerPanel(doc, detail);
|
||||
qs('#pull-merge-state').textContent = 'Waiting for @' + result.reviewer + '’s review';
|
||||
qs('#merge-pull').disabled = true;
|
||||
};
|
||||
qs('#publish-draft-pull').addEventListener('click', async () => {
|
||||
const selected = getSelected?.();
|
||||
const detail = getDetail?.();
|
||||
if (!selected || !detail?.head_sha || detail.draft !== true ||
|
||||
!globalThis.confirm('Publish ' + selected.key + ' as ready for review?')) return;
|
||||
const button = qs('#publish-draft-pull');
|
||||
button.disabled = true;
|
||||
qs('#publish-draft-pull-status').textContent = 'Publishing draft…';
|
||||
try {
|
||||
const result = await controller.publishReady(selected, detail.head_sha);
|
||||
if (getSelected?.() !== selected || getDetail?.() !== detail) return;
|
||||
Object.assign(detail, result);
|
||||
selected.title = result.title;
|
||||
qs('#pull-sheet-title').textContent = result.title;
|
||||
controller.setReviewDetail(detail);
|
||||
qs('#publish-draft-pull-status').textContent = 'Published and ready for review.';
|
||||
qs('#pull-review-request').open = true;
|
||||
load.focus();
|
||||
} catch (error) {
|
||||
qs('#publish-draft-pull-status').textContent = error.message + ' The pull request remains a draft; retry.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
qs('#pull-reviewer-statuses').addEventListener('click', async event => {
|
||||
const button = event.target.closest?.('[data-cancel-review-request]');
|
||||
if (!button || button.disabled) return;
|
||||
const selected = getSelected?.();
|
||||
const detail = getDetail?.();
|
||||
const reviewer = button.dataset.cancelReviewRequest;
|
||||
if (!selected || !detail?.head_sha || !reviewer ||
|
||||
!globalThis.confirm('Cancel @' + reviewer + '’s review request for ' + selected.key + '?')) return;
|
||||
button.disabled = true;
|
||||
qs('#pull-reviewer-status').textContent = 'Cancelling @' + reviewer + '’s review request…';
|
||||
try {
|
||||
const result = await controller.cancelReview(selected, reviewer, detail.head_sha);
|
||||
if (result?.head_sha !== detail.head_sha || result?.requested_reviewers?.includes(reviewer)) {
|
||||
throw new Error('Cancellation was not confirmed.');
|
||||
}
|
||||
detail.reviewers = (detail.reviewers || []).filter(candidate =>
|
||||
candidate.login !== reviewer || candidate.status !== 'waiting');
|
||||
renderReviewerPanel(doc, detail);
|
||||
const eligibility = mergeEligibility(detail, controller.reviewState(selected, detail));
|
||||
qs('#pull-merge-state').textContent = eligibility.reason;
|
||||
qs('#merge-pull').disabled = !eligibility.allowed;
|
||||
qs('#pull-review-request').open = true;
|
||||
qs('#pull-review-request-status').textContent = 'Review request cancelled. Choose a replacement reviewer.';
|
||||
load.disabled = false;
|
||||
load.focus();
|
||||
} catch (error) {
|
||||
qs('#pull-reviewer-status').textContent = error.message + ' Refresh review data before retrying.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
load.addEventListener('click', async () => {
|
||||
const selected = getSelected();
|
||||
if (!selected) return;
|
||||
const select = qs('#pull-review-recipient');
|
||||
load.disabled = true;
|
||||
qs('#pull-review-request-status').textContent = 'Loading eligible reviewers…';
|
||||
try {
|
||||
const count = renderHandoffCandidates(select, await controller.loadReviewCandidates(selected), doc);
|
||||
select.disabled = !count;
|
||||
qs('#confirm-pull-review').disabled = true;
|
||||
qs('#pull-review-request-status').textContent = count ?
|
||||
'Choose a teammate to review the current head.' : 'No eligible reviewers were found.';
|
||||
if (count) select.focus();
|
||||
else load.disabled = false;
|
||||
} catch (error) {
|
||||
qs('#pull-review-request-status').textContent = error.message + ' Retry loading reviewers.';
|
||||
load.disabled = false;
|
||||
load.focus();
|
||||
}
|
||||
});
|
||||
qs('#pull-review-recipient').addEventListener('change', event => {
|
||||
qs('#confirm-pull-review').disabled = !event.target.value;
|
||||
});
|
||||
qs('#confirm-pull-review').addEventListener('click', async () => {
|
||||
const selected = getSelected();
|
||||
const detail = { head_sha: qs('#pull-review-request').dataset.headSha };
|
||||
const reviewer = qs('#pull-review-recipient').value;
|
||||
if (!selected || !detail?.head_sha || !reviewer ||
|
||||
!globalThis.confirm('Request review of ' + selected.key + ' at ' + detail.head_sha.slice(0, 8) + ' from @' + reviewer + '?')) return;
|
||||
const button = qs('#confirm-pull-review');
|
||||
button.disabled = true;
|
||||
qs('#pull-review-request-status').textContent = 'Requesting review…';
|
||||
try {
|
||||
await controller.requestReview(selected, reviewer, detail.head_sha);
|
||||
qs('#pull-review-request-status').textContent = 'Review requested from @' + reviewer + '.';
|
||||
} catch (error) {
|
||||
qs('#pull-review-request-status').textContent = error.message + ' Selection kept; retry the request.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin) {
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
const panel = qs('#pull-feedback-pass');
|
||||
if (panel.dataset.feedbackBound === 'true') return;
|
||||
panel.dataset.feedbackBound = 'true';
|
||||
let active = null;
|
||||
const comments = () => (active?.reviewer?.comments || [])
|
||||
.filter(comment => Number.isInteger(comment?.id) && comment.id > 0);
|
||||
const current = () => comments()[active?.state?.index || 0];
|
||||
const save = () => {
|
||||
if (active) controller.saveFeedbackPass(active.item, active.detail, active.reviewer, active.login, active.state);
|
||||
};
|
||||
const render = () => {
|
||||
if (!active) return;
|
||||
const items = comments();
|
||||
active.state.index = Math.max(0, Math.min(Number(active.state.index) || 0, items.length - 1));
|
||||
const comment = current();
|
||||
const response = active.state.responses[String(comment.id)] || {};
|
||||
qs('#pull-feedback-progress').textContent = 'Comment ' + (active.state.index + 1) + ' of ' + items.length;
|
||||
qs('#pull-feedback-heading').textContent = 'Address @' + active.reviewer.login + '’s feedback';
|
||||
qs('#pull-feedback-location').textContent = comment.path + (comment.line ? ' · line ' + Number(comment.line) : '');
|
||||
qs('#pull-feedback-body').textContent = comment.body;
|
||||
qs('#pull-feedback-note').value = response.note || '';
|
||||
panel.querySelectorAll('[data-feedback-disposition]').forEach(button => {
|
||||
button.setAttribute('aria-pressed', String(button.dataset.feedbackDisposition === response.disposition));
|
||||
});
|
||||
qs('#previous-pull-feedback').disabled = active.state.index === 0;
|
||||
qs('#next-pull-feedback').disabled = active.state.index >= items.length - 1;
|
||||
qs('#finish-pull-feedback').disabled = active.state.posted === true ||
|
||||
items.some(item => !active.state.responses[String(item.id)]?.disposition);
|
||||
qs('#request-feedback-review').hidden = active.state.posted !== true;
|
||||
const found = focusPullFile(doc, comment.path);
|
||||
qs('#pull-feedback-context').textContent = found ?
|
||||
'Matching change opened below.' : 'Matching change is unavailable in this preview.';
|
||||
panel.scrollIntoView({ block:'center', behavior:'smooth' });
|
||||
};
|
||||
qs('#pull-reviewer-statuses').addEventListener('click', async event => {
|
||||
const loadButton = event.target.closest?.('[data-load-review-feedback]');
|
||||
if (loadButton && !loadButton.disabled) {
|
||||
const detail = getDetail?.();
|
||||
const item = getSelected?.();
|
||||
const reviewId = Number(loadButton.dataset.loadReviewFeedback);
|
||||
const reviewer = (detail?.reviewers || []).find(candidate => candidate.review_id === reviewId);
|
||||
if (!detail || !item || !reviewer) return;
|
||||
const refresh = reviewer.feedback_state === 'error';
|
||||
reviewer.feedback_state = 'loading';
|
||||
renderReviewerPanel(doc, detail);
|
||||
try {
|
||||
const result = await controller.loadFeedback(item, detail, reviewer, { refresh });
|
||||
if (getDetail?.() !== detail || getSelected?.() !== item ||
|
||||
result.head_sha !== detail.head_sha || Number(result.review_id) !== reviewId) return;
|
||||
reviewer.comments = Array.isArray(result.comments) ? result.comments : [];
|
||||
reviewer.feedback_state = 'loaded';
|
||||
renderReviewerPanel(doc, detail);
|
||||
doc.querySelector('[data-address-review-feedback="' + reviewId + '"]')?.focus();
|
||||
} catch (error) {
|
||||
if (getDetail?.() !== detail || getSelected?.() !== item) return;
|
||||
reviewer.feedback_state = 'error';
|
||||
renderReviewerPanel(doc, detail);
|
||||
doc.querySelector('[data-load-review-feedback="' + reviewId + '"]')?.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const button = event.target.closest?.('[data-address-review-feedback]');
|
||||
if (!button) return;
|
||||
const detail = getDetail?.();
|
||||
const item = getSelected?.();
|
||||
const reviewId = Number(button.dataset.addressReviewFeedback);
|
||||
const reviewer = (detail?.reviewers || []).find(candidate => candidate.review_id === reviewId);
|
||||
if (!detail || !item || !reviewer) return;
|
||||
const login = getLogin?.() || '';
|
||||
active = { item, detail, reviewer, login,
|
||||
state:controller.loadFeedbackPass(item, detail, reviewer, login) };
|
||||
panel.hidden = false;
|
||||
qs('#pull-feedback-status').textContent = active.state.posted ?
|
||||
'Response already posted. You can request an updated review.' : 'Progress saves on this device.';
|
||||
render();
|
||||
});
|
||||
panel.querySelector('.pull-feedback-dispositions').addEventListener('click', event => {
|
||||
const disposition = event.target.dataset?.feedbackDisposition;
|
||||
const comment = current();
|
||||
if (!active || !comment || !['addressed', 'discussion', 'skipped'].includes(disposition)) return;
|
||||
const key = String(comment.id);
|
||||
active.state.responses[key] = { ...(active.state.responses[key] || {}), disposition };
|
||||
save();
|
||||
render();
|
||||
});
|
||||
qs('#pull-feedback-note').addEventListener('input', event => {
|
||||
const comment = current();
|
||||
if (!active || !comment) return;
|
||||
const key = String(comment.id);
|
||||
active.state.responses[key] = { ...(active.state.responses[key] || {}), note:event.target.value.slice(0, 1000) };
|
||||
save();
|
||||
});
|
||||
qs('#previous-pull-feedback').addEventListener('click', () => {
|
||||
if (!active) return;
|
||||
active.state.index -= 1; save(); render();
|
||||
});
|
||||
qs('#next-pull-feedback').addEventListener('click', () => {
|
||||
if (!active) return;
|
||||
active.state.index += 1; save(); render();
|
||||
});
|
||||
qs('#close-pull-feedback').addEventListener('click', () => {
|
||||
panel.hidden = true;
|
||||
doc.querySelector('[data-address-review-feedback="' + active?.reviewer?.review_id + '"]')?.focus();
|
||||
});
|
||||
qs('#finish-pull-feedback').addEventListener('click', async () => {
|
||||
if (!active || qs('#finish-pull-feedback').disabled) return;
|
||||
const latest = getDetail?.();
|
||||
if (!latest || latest.head_sha !== active.state.headSha) {
|
||||
qs('#pull-feedback-status').textContent = 'The pull request changed. Refresh review data before posting.';
|
||||
return;
|
||||
}
|
||||
if (!globalThis.confirm('Post one feedback response to ' + active.item.key + '?')) return;
|
||||
qs('#finish-pull-feedback').disabled = true;
|
||||
qs('#pull-feedback-status').textContent = 'Posting feedback response…';
|
||||
try {
|
||||
await controller.postFeedbackResponse(active.item, active.detail, active.reviewer, active.login, active.state);
|
||||
active.state.posted = true;
|
||||
save();
|
||||
qs('#pull-feedback-status').textContent = 'Response posted. Request an updated review when ready.';
|
||||
render();
|
||||
} catch (error) {
|
||||
qs('#pull-feedback-status').textContent = error.message + ' Progress kept; retry without duplicating the response.';
|
||||
qs('#finish-pull-feedback').disabled = false;
|
||||
}
|
||||
});
|
||||
qs('#request-feedback-review').addEventListener('click', async () => {
|
||||
if (!active?.state?.posted) return;
|
||||
const button = qs('#request-feedback-review');
|
||||
button.disabled = true;
|
||||
qs('#pull-feedback-status').textContent = 'Requesting updated review…';
|
||||
try {
|
||||
await controller.requestReview(active.item, active.reviewer.login, active.detail.head_sha);
|
||||
qs('#pull-feedback-status').textContent = 'Response posted; updated review requested from @' + active.reviewer.login + '.';
|
||||
button.hidden = true;
|
||||
} catch (error) {
|
||||
qs('#pull-feedback-status').textContent = 'Response posted; review not requested. ' + error.message;
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindContextEditor(doc, controller, getSelected, getDetail, getLogin) {
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
const form = qs('#pull-edit-form');
|
||||
const controls = () => form.querySelectorAll('input,textarea,button');
|
||||
const draft = () => ({
|
||||
title:qs('#pull-edit-title').value.trim(), body:qs('#pull-edit-body').value,
|
||||
expectedHeadSha:getDetail()?.head_sha || '',
|
||||
});
|
||||
qs('#edit-pull-content').addEventListener('click', () => {
|
||||
const item = getSelected(), detail = getDetail();
|
||||
if (!item || !detail) return;
|
||||
const saved = controller.loadEditDraft(item) || {
|
||||
title:detail.title || '', body:detail.body || '', expectedHeadSha:detail.head_sha || '',
|
||||
};
|
||||
qs('#pull-edit-title').value = saved.title;
|
||||
qs('#pull-edit-body').value = saved.body;
|
||||
form.hidden = false;
|
||||
qs('#pull-edit-status').textContent = '';
|
||||
qs('#pull-edit-title').focus();
|
||||
});
|
||||
qs('#cancel-pull-edit').addEventListener('click', () => {
|
||||
form.hidden = true;
|
||||
qs('#edit-pull-content').focus();
|
||||
});
|
||||
['#pull-edit-title', '#pull-edit-body'].forEach(selector => qs(selector).addEventListener('input', () => {
|
||||
const item = getSelected();
|
||||
if (item && getDetail()) controller.saveEditDraft(item, draft());
|
||||
}));
|
||||
form.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const item = getSelected(), update = draft();
|
||||
if (!item || !getDetail()) return;
|
||||
if (!update.title) {
|
||||
qs('#pull-edit-status').textContent = 'Title is required.';
|
||||
qs('#pull-edit-title').focus();
|
||||
return;
|
||||
}
|
||||
controls().forEach(control => { control.disabled = true; });
|
||||
qs('#pull-edit-status').textContent = 'Saving review context…';
|
||||
try {
|
||||
const result = await controller.updateContent(item, update);
|
||||
if (getSelected() !== item) return;
|
||||
const detail = getDetail();
|
||||
Object.assign(detail, result);
|
||||
item.title = result.title;
|
||||
qs('#pull-sheet-title').textContent = result.title;
|
||||
qs('#pull-sheet-body').innerHTML = renderMarkdown(result.body || 'No description provided.');
|
||||
controller.setReviewDetail(detail);
|
||||
qs('#pull-sheet-status').textContent = 'Review context updated · ready to request review';
|
||||
form.hidden = true;
|
||||
qs('#pull-edit-status').textContent = '';
|
||||
qs('#edit-pull-content').focus();
|
||||
} catch (error) {
|
||||
qs('#pull-edit-status').textContent = error.message + ' Your change is still here; retry.';
|
||||
qs('#pull-edit-title').focus();
|
||||
} finally {
|
||||
controls().forEach(control => { control.disabled = false; });
|
||||
}
|
||||
});
|
||||
return {
|
||||
reset() {
|
||||
qs('#edit-pull-content').hidden = true;
|
||||
form.hidden = true;
|
||||
qs('#pull-edit-status').textContent = '';
|
||||
},
|
||||
setDetail(detail, offline = false) {
|
||||
const editable = !offline && detail?.state === 'open' && detail.author === getLogin();
|
||||
qs('#edit-pull-content').hidden = !editable;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function bindOwnershipControls(doc, controller, getSelected, finish, getDetail, getLogin) {
|
||||
bindReviewRequestControls(doc, controller, getSelected, getDetail, getLogin);
|
||||
bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin);
|
||||
controller.edit = bindContextEditor(doc, controller, getSelected, getDetail, getLogin);
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
const load = qs('#load-pull-handoff');
|
||||
if (load.dataset.ownershipBound === 'true') return;
|
||||
@@ -172,13 +636,24 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
let checkRequest = null;
|
||||
let ownershipRequest = null;
|
||||
let candidateRequest = null;
|
||||
let reviewCandidateRequest = null;
|
||||
let reviewRequestMutation = null;
|
||||
let reviewCancelMutation = null;
|
||||
let readyMutation = null;
|
||||
let editRequest = null;
|
||||
let feedbackRequest = null;
|
||||
const reviewRequests = new Map();
|
||||
const reviewCache = new Map();
|
||||
const feedbackRequests = new Map();
|
||||
const feedbackCache = new Map();
|
||||
const pathFor = item => 'api/v1/repos/' + String(item.repository || '').split('/')
|
||||
.map(encodeURIComponent).join('/') + '/pulls/' + encodeURIComponent(item.number);
|
||||
const draftKey = item => 'stackchain.pull-comment.v1:' + item.repository + '#' + item.number;
|
||||
const operationKey = item => draftKey(item) + ':operation';
|
||||
const editDraftKey = item => 'stackchain.pull-edit.v1:' + item.repository + '#' + item.number;
|
||||
const reviewKey = (item, detail) => 'stackchain.pull-review.v1:' + item.repository + '#' + item.number + ':' + detail.head_sha;
|
||||
const feedbackKey = (item, detail, reviewer, login) => 'stackchain.feedback-pass.v1:' +
|
||||
item.repository + '#' + item.number + ':' + reviewer.review_id + ':' + reviewer.head_sha + ':' + detail.head_sha + ':' + login;
|
||||
const fileNames = detail => (detail?.files || []).map(file => file.filename).filter(Boolean);
|
||||
|
||||
function reviewState(item, detail) {
|
||||
@@ -210,6 +685,25 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
reviewRequests.set(key, request);
|
||||
return request;
|
||||
},
|
||||
loadFeedback(item, detail, reviewer, { refresh = false } = {}) {
|
||||
const reviewId = Number(reviewer?.review_id);
|
||||
const headSha = detail?.head_sha || '';
|
||||
const key = item.repository + '#' + item.number + ':' + headSha + ':' + reviewId;
|
||||
if (!refresh && feedbackCache.has(key)) return Promise.resolve(feedbackCache.get(key));
|
||||
if (feedbackRequests.has(key)) return feedbackRequests.get(key);
|
||||
const request = fetchJson(pathFor(item) + '/reviews/' + encodeURIComponent(reviewId) +
|
||||
'/feedback?expected_head_sha=' + encodeURIComponent(headSha), {
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(result => {
|
||||
if (result?.head_sha !== headSha || Number(result?.review_id) !== reviewId) {
|
||||
throw new Error('Review feedback changed. Reload review data before retrying.');
|
||||
}
|
||||
feedbackCache.set(key, result);
|
||||
return result;
|
||||
}).finally(() => feedbackRequests.delete(key));
|
||||
feedbackRequests.set(key, request);
|
||||
return request;
|
||||
},
|
||||
loadChecks(item) {
|
||||
if (checkRequest) return checkRequest;
|
||||
checkRequest = fetchJson(pathFor(item) + '/checks', {
|
||||
@@ -224,6 +718,129 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
}).finally(() => { candidateRequest = null; });
|
||||
return candidateRequest;
|
||||
},
|
||||
loadReviewCandidates(item) {
|
||||
if (reviewCandidateRequest) return reviewCandidateRequest;
|
||||
reviewCandidateRequest = fetchJson(pathFor(item) + '/review-candidates', {
|
||||
headers: { Accept: 'application/json' },
|
||||
}).finally(() => { reviewCandidateRequest = null; });
|
||||
return reviewCandidateRequest;
|
||||
},
|
||||
requestReview(item, reviewer, expectedHeadSha) {
|
||||
if (reviewRequestMutation) return reviewRequestMutation;
|
||||
reviewRequestMutation = fetchJson(pathFor(item) + '/request-review', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reviewer, expected_head_sha: expectedHeadSha }),
|
||||
}).then(result => {
|
||||
this.onReviewRequested?.(result);
|
||||
return result;
|
||||
}).finally(() => { reviewRequestMutation = null; });
|
||||
return reviewRequestMutation;
|
||||
},
|
||||
cancelReview(item, reviewer, expectedHeadSha) {
|
||||
if (reviewCancelMutation) return reviewCancelMutation;
|
||||
reviewCancelMutation = fetchJson(pathFor(item) + '/request-review', {
|
||||
method: 'DELETE',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reviewer, expected_head_sha: expectedHeadSha }),
|
||||
}).then(result => {
|
||||
this.onReviewCancelled?.(result);
|
||||
return result;
|
||||
}).finally(() => { reviewCancelMutation = null; });
|
||||
return reviewCancelMutation;
|
||||
},
|
||||
publishReady(item, expectedHeadSha) {
|
||||
if (readyMutation) return readyMutation;
|
||||
readyMutation = fetchJson(pathFor(item) + '/ready', {
|
||||
method:'PATCH',
|
||||
headers:{ Accept:'application/json', 'Content-Type':'application/json' },
|
||||
body:JSON.stringify({ expected_head_sha:expectedHeadSha }),
|
||||
}).then(result => {
|
||||
if (result?.number !== item.number || result?.head_sha !== expectedHeadSha ||
|
||||
result?.state !== 'open' || result?.draft !== false || !result?.title) {
|
||||
throw new Error('Draft publication was not confirmed.');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { readyMutation = null; });
|
||||
return readyMutation;
|
||||
},
|
||||
loadEditDraft(item) {
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(editDraftKey(item)) || 'null');
|
||||
return value && typeof value.title === 'string' && typeof value.body === 'string' &&
|
||||
typeof value.expectedHeadSha === 'string' ? value : null;
|
||||
} catch (_error) { return null; }
|
||||
},
|
||||
saveEditDraft(item, draft) {
|
||||
try { storage?.setItem(editDraftKey(item), JSON.stringify(draft)); }
|
||||
catch (_error) { /* The edit fields remain the fallback. */ }
|
||||
},
|
||||
loadFeedbackPass(item, detail, reviewer, login) {
|
||||
const fresh = { headSha:detail.head_sha, index:0, responses:{}, posted:false };
|
||||
try {
|
||||
const saved = JSON.parse(storage?.getItem(feedbackKey(item, detail, reviewer, login)) || 'null');
|
||||
return saved && saved.headSha === detail.head_sha && saved.responses && typeof saved.responses === 'object'
|
||||
? { ...fresh, ...saved } : fresh;
|
||||
} catch (_error) { return fresh; }
|
||||
},
|
||||
saveFeedbackPass(item, detail, reviewer, login, state) {
|
||||
try { storage?.setItem(feedbackKey(item, detail, reviewer, login), JSON.stringify(state)); }
|
||||
catch (_error) { /* The visible pass remains usable without storage. */ }
|
||||
},
|
||||
feedbackSummary(reviewer, state) {
|
||||
const labels = { addressed:'Addressed', discussion:'Needs discussion', skipped:'Skip for now' };
|
||||
const lines = (reviewer.comments || []).filter(comment => Number.isInteger(comment?.id) && comment.id > 0)
|
||||
.map(comment => {
|
||||
const response = state.responses[String(comment.id)] || {};
|
||||
const path = String(comment.path || '').replaceAll('`', "'").replaceAll('\n', ' ').slice(0, 300);
|
||||
const body = String(comment.body || '').replaceAll('\n', ' ').slice(0, 180);
|
||||
const note = String(response.note || '').replaceAll('\n', ' ').slice(0, 1000);
|
||||
return '- `' + path + '` · comment #' + comment.id + ' · **' + (labels[response.disposition] || 'Unresolved') +
|
||||
'** — ' + body + (note ? '\n - Response: ' + note : '');
|
||||
});
|
||||
return ('Addressed review feedback from @' + reviewer.login + ' (review #' + reviewer.review_id + ').\n\n' +
|
||||
lines.join('\n')).slice(0, 10000);
|
||||
},
|
||||
postFeedbackResponse(item, detail, reviewer, login, state) {
|
||||
if (feedbackRequest) return feedbackRequest;
|
||||
const key = feedbackKey(item, detail, reviewer, login);
|
||||
let operationId;
|
||||
try {
|
||||
operationId = storage?.getItem(key + ':operation') || String(createOperationId()).slice(0, 128);
|
||||
storage?.setItem(key + ':operation', operationId);
|
||||
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
|
||||
feedbackRequest = fetchJson(pathFor(item) + '/comments', {
|
||||
method:'POST',
|
||||
headers:{ Accept:'application/json', 'Content-Type':'application/json', 'Idempotency-Key':operationId },
|
||||
body:JSON.stringify({ body:this.feedbackSummary(reviewer, state) }),
|
||||
}).then(result => {
|
||||
try { storage?.removeItem(key + ':operation'); } catch (_error) { /* Confirmed upstream. */ }
|
||||
return result;
|
||||
}).finally(() => { feedbackRequest = null; });
|
||||
return feedbackRequest;
|
||||
},
|
||||
updateContent(item, draft) {
|
||||
if (editRequest) return editRequest;
|
||||
this.saveEditDraft(item, draft);
|
||||
editRequest = fetchJson(pathFor(item) + '/content', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: draft.title,
|
||||
body: draft.body,
|
||||
expected_head_sha: draft.expectedHeadSha,
|
||||
}),
|
||||
}).then(result => {
|
||||
if (result?.number !== item.number || result?.title !== draft.title ||
|
||||
result?.body !== draft.body || result?.head_sha !== draft.expectedHeadSha) {
|
||||
throw new Error('Pull request update was not confirmed.');
|
||||
}
|
||||
try { storage?.removeItem(editDraftKey(item)); }
|
||||
catch (_error) { /* Confirmed upstream content is authoritative. */ }
|
||||
return result;
|
||||
}).finally(() => { editRequest = null; });
|
||||
return editRequest;
|
||||
},
|
||||
handoff(item, recipient) {
|
||||
if (ownershipRequest) return ownershipRequest;
|
||||
ownershipRequest = fetchJson(pathFor(item) + '/handoff', {
|
||||
@@ -314,6 +931,8 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
}
|
||||
|
||||
createPullSheet.mergeEligibility = mergeEligibility;
|
||||
createPullSheet.review = reviewEligibility;
|
||||
createPullSheet.renderReviewerStatuses = renderReviewerStatuses;
|
||||
createPullSheet.renderFile = renderFile;
|
||||
createPullSheet.focusNextUnreviewed = focusNextUnreviewed;
|
||||
createPullSheet.removeFromSnapshot = removeFromSnapshot;
|
||||
@@ -323,4 +942,8 @@ createPullSheet.renderHandoffCandidates = renderHandoffCandidates;
|
||||
createPullSheet.ownershipExitMessage = ownershipExitMessage;
|
||||
createPullSheet.resetOwnershipControls = resetOwnershipControls;
|
||||
createPullSheet.bindOwnershipControls = bindOwnershipControls;
|
||||
createPullSheet.bindContextEditor = bindContextEditor;
|
||||
createPullSheet.resetReviewRequestControls = resetReviewRequestControls;
|
||||
createPullSheet.bindReviewRequestControls = bindReviewRequestControls;
|
||||
createPullSheet.bindFeedbackControls = bindFeedbackControls;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;
|
||||
@@ -2,9 +2,70 @@
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createPushNotifications = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
|
||||
control, status, deadlineControl, deadlineStatus, deadlineHour, deadlineDays, notification, serviceWorker, fetchJson,
|
||||
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||
startDayControl, startDayStatus, startDayHour,
|
||||
followingControl, followingStatus,
|
||||
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines,
|
||||
notification, serviceWorker, fetchJson,
|
||||
}) {
|
||||
let configuration = null;
|
||||
let pendingIntent = null;
|
||||
let recoveryPromise = null;
|
||||
|
||||
function blockedReadiness() {
|
||||
return {
|
||||
state:'blocked',
|
||||
detail:'Notifications are blocked. Allow them in browser settings, then check again.',
|
||||
actionLabel:'Check again',
|
||||
};
|
||||
}
|
||||
|
||||
function pendingRecoveryReadiness() {
|
||||
return notification.permission === 'granted'
|
||||
? {state:'blocked', detail:'Notification permission changed. Check again to finish setup.', actionLabel:'Check again'}
|
||||
: blockedReadiness();
|
||||
}
|
||||
|
||||
function notificationReadiness() {
|
||||
if (!configuration?.available || control?.disabled) {
|
||||
return {state:'unavailable', detail:status?.textContent || 'Update notifications are unavailable.'};
|
||||
}
|
||||
if (configuration.subscribed) {
|
||||
return {state:'complete', detail:'New update notifications are enabled.'};
|
||||
}
|
||||
if (pendingIntent === 'updates') return pendingRecoveryReadiness();
|
||||
if (notification.permission === 'denied') return blockedReadiness();
|
||||
return {state:'incomplete', detail:status?.textContent || 'New update notifications are off.'};
|
||||
}
|
||||
|
||||
function renderDeliveryHealth() {
|
||||
const health = Object.values(configuration?.delivery_health || {});
|
||||
const degraded = health.find(item => item?.state === 'degraded');
|
||||
if (testControl) testControl.hidden = !configuration?.subscribed;
|
||||
if (configuration?.subscribed && degraded) {
|
||||
const count = Number(degraded.consecutive_failures || 1);
|
||||
status.textContent = `Update notifications need attention after ${count} failed ${count === 1 ? 'delivery' : 'deliveries'}. Send a test notification.`;
|
||||
return;
|
||||
}
|
||||
status.textContent = configuration?.subscribed
|
||||
? 'New update notifications enabled for this device.'
|
||||
: 'New update notifications are off for this device.';
|
||||
}
|
||||
|
||||
async function testDelivery() {
|
||||
if (!testControl) return;
|
||||
testControl.disabled = true;
|
||||
status.textContent = 'Sending a test notification…';
|
||||
try {
|
||||
await fetchJson('api/v1/push-subscription/test', {method:'POST'});
|
||||
configuration.delivery_health = {unread:{state:'healthy',consecutive_failures:0}};
|
||||
status.textContent = 'Test delivered. Update notifications are working on this device.';
|
||||
} catch (error) {
|
||||
status.textContent = 'Test delivery failed. Check your connection, then try again.';
|
||||
} finally {
|
||||
testControl.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formattedHour(value) {
|
||||
return `${String(Number(value)).padStart(2, '0')}:00`;
|
||||
@@ -16,10 +77,37 @@
|
||||
return `Deadline reminders enabled for ${formattedHour(hour)} local time, ${horizon}.`;
|
||||
}
|
||||
|
||||
function renderDeadlineSnooze() {
|
||||
if (!deadlineSnooze) return;
|
||||
const wakeAt = Number(configuration?.snoozed_until || 0);
|
||||
deadlineSnooze.hidden = !wakeAt;
|
||||
if (wakeAt && deadlineSnoozeStatus) {
|
||||
const localTime = new Intl.DateTimeFormat(undefined, {
|
||||
hour:'numeric', minute:'2-digit',
|
||||
}).format(new Date(wakeAt * 1000));
|
||||
deadlineSnoozeStatus.textContent = `Deadline reminders snoozed until ${localTime}.`;
|
||||
}
|
||||
}
|
||||
|
||||
async function reviewSnoozedDeadlines() {
|
||||
deadlineSnoozeReview.disabled = true;
|
||||
try {
|
||||
await fetchJson('api/v1/push-subscription/deadlines/snooze', {method:'DELETE'});
|
||||
configuration.snoozed_until = null;
|
||||
renderDeadlineSnooze();
|
||||
onReviewDeadlines?.();
|
||||
} catch (error) {
|
||||
deadlineSnoozeStatus.textContent = 'Could not resume deadline reminders. Check your connection and try again.';
|
||||
} finally {
|
||||
deadlineSnoozeReview.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function deadlineReadiness() {
|
||||
if (!configuration?.available || deadlineControl?.disabled) {
|
||||
return {state:'unavailable', detail:deadlineStatus?.textContent || 'Deadline reminders are unavailable.'};
|
||||
}
|
||||
if (pendingIntent === 'deadline') return pendingRecoveryReadiness();
|
||||
return configuration.deadline_enabled
|
||||
? {state:'complete', detail:enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)}
|
||||
: {state:'incomplete', detail:deadlineStatus?.textContent || 'Choose when to receive deadline reminders.'};
|
||||
@@ -39,16 +127,27 @@
|
||||
await fetchJson('api/v1/push-subscription', {method:'DELETE'});
|
||||
await subscription?.unsubscribe?.();
|
||||
control.checked = false;
|
||||
if (testControl) testControl.hidden = true;
|
||||
if (deadlineControl) deadlineControl.checked = false;
|
||||
if (startDayControl) startDayControl.checked = false;
|
||||
if (followingControl) followingControl.checked = false;
|
||||
configuration.subscribed = false;
|
||||
configuration.deadline_enabled = false;
|
||||
configuration.start_day_enabled = false;
|
||||
configuration.following_enabled = false;
|
||||
pendingIntent = null;
|
||||
status.textContent = 'New update notifications are off for this device.';
|
||||
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
|
||||
if (followingStatus) followingStatus.textContent = 'Following change alerts are off for this device.';
|
||||
}
|
||||
|
||||
async function ensureSubscription() {
|
||||
const permission = await notification.requestPermission();
|
||||
const permission = notification.permission === 'granted'
|
||||
? 'granted'
|
||||
: await notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
control.checked = false;
|
||||
status.textContent = 'Notifications are blocked. Allow them in your browser settings to enable updates.';
|
||||
status.textContent = blockedReadiness().detail;
|
||||
return null;
|
||||
}
|
||||
const registration = await serviceWorker.ready;
|
||||
@@ -67,11 +166,15 @@
|
||||
control.checked = true;
|
||||
status.textContent = 'New update notifications enabled for this device.';
|
||||
configuration.subscribed = true;
|
||||
if (testControl) testControl.hidden = false;
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async function enable() {
|
||||
await ensureSubscription();
|
||||
pendingIntent = 'updates';
|
||||
const subscription = await ensureSubscription();
|
||||
if (subscription) pendingIntent = null;
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async function change() {
|
||||
@@ -94,6 +197,7 @@
|
||||
try {
|
||||
const registration = await serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
if (deadlineControl.checked) pendingIntent = 'deadline';
|
||||
if (deadlineControl.checked && !subscription) subscription = await ensureSubscription();
|
||||
if (deadlineControl.checked && !subscription) {
|
||||
deadlineControl.checked = false;
|
||||
@@ -112,6 +216,7 @@
|
||||
configuration.reminder_hour = reminderHour;
|
||||
configuration.reminder_days = reminderDays;
|
||||
configuration.timezone = timezone;
|
||||
pendingIntent = null;
|
||||
deadlineStatus.textContent = deadlineControl.checked
|
||||
? enabledDeadlineText(reminderHour, reminderDays)
|
||||
: 'Deadline reminders are off for this device.';
|
||||
@@ -127,33 +232,143 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function changeStartDay() {
|
||||
startDayControl.disabled = true;
|
||||
if (startDayHour) startDayHour.disabled = true;
|
||||
try {
|
||||
const registration = await serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
if (startDayControl.checked) pendingIntent = 'start-day';
|
||||
if (startDayControl.checked && !subscription) subscription = await ensureSubscription();
|
||||
if (startDayControl.checked && !subscription) {
|
||||
startDayControl.checked = false;
|
||||
startDayStatus.textContent = status.textContent;
|
||||
return false;
|
||||
}
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
const reminderHour = Number(startDayHour?.value ?? configuration?.start_day_reminder_hour ?? 9);
|
||||
await fetchJson('api/v1/push-subscription/start-day', {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({enabled:startDayControl.checked, timezone, reminder_hour:reminderHour}),
|
||||
});
|
||||
configuration.start_day_enabled = startDayControl.checked;
|
||||
configuration.start_day_timezone = timezone;
|
||||
configuration.start_day_reminder_hour = reminderHour;
|
||||
pendingIntent = null;
|
||||
startDayStatus.textContent = startDayControl.checked
|
||||
? `Start-day reminder enabled for ${formattedHour(reminderHour)} local time.`
|
||||
: 'Start-day reminders are off for this device.';
|
||||
return true;
|
||||
} catch (_error) {
|
||||
startDayControl.checked = !startDayControl.checked;
|
||||
startDayStatus.textContent = 'Could not change start-day reminders. Check your connection and try again.';
|
||||
return false;
|
||||
} finally {
|
||||
startDayControl.disabled = false;
|
||||
if (startDayHour) startDayHour.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changeFollowing() {
|
||||
followingControl.disabled = true;
|
||||
try {
|
||||
const registration = await serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
if (followingControl.checked) pendingIntent = 'following';
|
||||
if (followingControl.checked && !subscription) subscription = await ensureSubscription();
|
||||
if (followingControl.checked && !subscription) {
|
||||
followingControl.checked = false;
|
||||
followingStatus.textContent = status.textContent;
|
||||
return false;
|
||||
}
|
||||
await fetchJson('api/v1/push-subscription/following', {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({enabled:followingControl.checked}),
|
||||
});
|
||||
configuration.following_enabled = followingControl.checked;
|
||||
pendingIntent = null;
|
||||
followingStatus.textContent = followingControl.checked
|
||||
? 'Following change alerts enabled for this device.'
|
||||
: 'Following change alerts are off for this device.';
|
||||
return true;
|
||||
} catch (_error) {
|
||||
followingControl.checked = !followingControl.checked;
|
||||
followingStatus.textContent = 'Could not change Following alerts. Check your connection and try again.';
|
||||
return false;
|
||||
} finally {
|
||||
followingControl.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function enableDeadline() {
|
||||
deadlineControl.checked = true;
|
||||
return changeDeadline();
|
||||
}
|
||||
|
||||
async function recoverPermission(intent = null) {
|
||||
if (!pendingIntent && ['updates', 'deadline', 'start-day', 'following'].includes(intent)) pendingIntent = intent;
|
||||
if (!pendingIntent || notification.permission !== 'granted') return false;
|
||||
if (recoveryPromise) return recoveryPromise;
|
||||
recoveryPromise = (async () => {
|
||||
if (pendingIntent === 'deadline') {
|
||||
deadlineControl.checked = true;
|
||||
return changeDeadline();
|
||||
}
|
||||
if (pendingIntent === 'start-day') {
|
||||
startDayControl.checked = true;
|
||||
return changeStartDay();
|
||||
}
|
||||
if (pendingIntent === 'following') {
|
||||
followingControl.checked = true;
|
||||
return changeFollowing();
|
||||
}
|
||||
return Boolean(await enable());
|
||||
})();
|
||||
try {
|
||||
return await recoveryPromise;
|
||||
} finally {
|
||||
recoveryPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (!control || !notification || !serviceWorker) return;
|
||||
control.addEventListener('change', change);
|
||||
testControl?.addEventListener('click', testDelivery);
|
||||
deadlineControl?.addEventListener('change', changeDeadline);
|
||||
startDayControl?.addEventListener('change', changeStartDay);
|
||||
followingControl?.addEventListener('change', changeFollowing);
|
||||
deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines);
|
||||
configuration = await fetchJson('api/v1/push-subscription');
|
||||
if (!configuration.available) {
|
||||
control.disabled = true;
|
||||
if (deadlineControl) deadlineControl.disabled = true;
|
||||
if (startDayControl) startDayControl.disabled = true;
|
||||
if (followingControl) followingControl.disabled = true;
|
||||
status.textContent = 'New update notifications are not available on this server.';
|
||||
return;
|
||||
}
|
||||
control.checked = Boolean(configuration.subscribed);
|
||||
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
|
||||
if (startDayControl) startDayControl.checked = Boolean(configuration.start_day_enabled);
|
||||
if (followingControl) followingControl.checked = Boolean(configuration.following_enabled);
|
||||
if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9);
|
||||
if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2);
|
||||
status.textContent = configuration.subscribed
|
||||
? 'New update notifications enabled for this device.'
|
||||
: 'New update notifications are off for this device.';
|
||||
if (startDayHour) startDayHour.value = String(configuration.start_day_reminder_hour ?? 9);
|
||||
renderDeliveryHealth();
|
||||
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
|
||||
? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)
|
||||
: 'Deadline reminders are off for this device.';
|
||||
if (startDayStatus) startDayStatus.textContent = configuration.start_day_enabled
|
||||
? `Start-day reminder enabled for ${formattedHour(configuration.start_day_reminder_hour)} local time.`
|
||||
: 'Start-day reminders are off for this device.';
|
||||
if (followingStatus) followingStatus.textContent = configuration.following_enabled
|
||||
? 'Following change alerts enabled for this device.'
|
||||
: 'Following change alerts are off for this device.';
|
||||
renderDeadlineSnooze();
|
||||
}
|
||||
|
||||
return {init, change, changeDeadline, enableDeadline, deadlineReadiness};
|
||||
return {init, change, changeDeadline, changeStartDay, changeFollowing, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
function createReconnectOutboxes({
|
||||
refresh,
|
||||
restoreIdentity,
|
||||
flushIssue,
|
||||
flushAuthored,
|
||||
flushNotificationReads,
|
||||
}) {
|
||||
let activeReconnect = null;
|
||||
let followUpRequested = false;
|
||||
|
||||
async function reconnectOnce() {
|
||||
const snapshot = await refresh();
|
||||
const freshness = snapshot?.freshness?.sections?.context;
|
||||
const identityFresh = snapshot?.context && !snapshot.context.error &&
|
||||
!freshness?.stale && !freshness?.degraded && !freshness?.revalidating;
|
||||
const login = identityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
||||
if (!login) return false;
|
||||
restoreIdentity(login);
|
||||
await Promise.all([
|
||||
flushIssue(),
|
||||
flushAuthored(),
|
||||
flushNotificationReads(),
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
return function reconnectOutboxes() {
|
||||
if (activeReconnect) {
|
||||
followUpRequested = true;
|
||||
return activeReconnect;
|
||||
}
|
||||
activeReconnect = (async () => {
|
||||
let result = false;
|
||||
do {
|
||||
followUpRequested = false;
|
||||
result = await reconnectOnce();
|
||||
} while (followUpRequested);
|
||||
return result;
|
||||
})().finally(() => { activeReconnect = null; });
|
||||
return activeReconnect;
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createReconnectOutboxes;
|
||||
@@ -0,0 +1,201 @@
|
||||
function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, dialog = null, statusNode = null, checksNode = null, releaseNode = null, listNode = null, documentRef = null, windowRef = null, setTimer = setTimeout, clearTimer = clearTimeout, pollMs = 30000 }) {
|
||||
const prefix = 'stackchain.release-receipt.v1:';
|
||||
const limit = 12;
|
||||
let entries = [];
|
||||
let refreshing = null;
|
||||
let timer = null;
|
||||
let bound = false;
|
||||
documentRef ||= typeof document !== 'undefined' ? document : null;
|
||||
windowRef ||= typeof window !== 'undefined' ? window : null;
|
||||
|
||||
const login = () => String(getLogin?.() || '').trim().toLowerCase();
|
||||
const key = () => prefix + login();
|
||||
const identity = value => String(value.repository || '') + '@' + String(value.commit_sha || '');
|
||||
const path = value => 'api/v1/repos/' + String(value.repository || '').split('/')
|
||||
.map(encodeURIComponent).join('/') + '/release-receipt/' + encodeURIComponent(value.commit_sha);
|
||||
|
||||
function valid(value, account) {
|
||||
return value && value.account === account && value.repository && value.commit_sha;
|
||||
}
|
||||
|
||||
function persist() {
|
||||
if (!entries.length) storage?.removeItem(key());
|
||||
else storage?.setItem(key(), JSON.stringify({ version: 2, account: login(), entries }));
|
||||
}
|
||||
|
||||
const hasPending = () => entries.some(entry => !entry.status?.release && entry.status?.label !== 'Checks failed');
|
||||
|
||||
function schedule(delay = pollMs) {
|
||||
if (!bound || documentRef?.hidden || !hasPending()) return;
|
||||
if (timer !== null) clearTimer(timer);
|
||||
timer = setTimer(async () => {
|
||||
timer = null;
|
||||
try { await refresh(); }
|
||||
catch (error) { if (statusNode) statusNode.textContent = error.message + ' Retry when connected.'; }
|
||||
schedule();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function restore() {
|
||||
const account = login();
|
||||
if (!account) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(storage?.getItem(prefix + account) || 'null');
|
||||
const stored = parsed?.version === 2 && parsed.account === account
|
||||
? parsed.entries : (valid(parsed, account) ? [parsed] : []);
|
||||
entries = Array.isArray(stored) ? stored.filter(value => valid(value, account)).slice(-limit) : [];
|
||||
if (entries.length && parsed?.version !== 2) persist();
|
||||
} catch (_error) { entries = []; }
|
||||
render();
|
||||
schedule();
|
||||
return entries.map(value => ({ ...value }));
|
||||
}
|
||||
|
||||
function capture(item, mergeResult) {
|
||||
const account = login();
|
||||
const commitSha = String(mergeResult?.merge_commit_sha || '').trim();
|
||||
if (!account || !item?.repository || !commitSha) throw new Error('The exact merge commit is unavailable.');
|
||||
if (!entries.length) restore();
|
||||
const entry = {
|
||||
account,
|
||||
repository: item.repository,
|
||||
number: Number(item.number),
|
||||
key: item.key || item.repository + '#' + item.number,
|
||||
commit_sha: commitSha,
|
||||
status: null,
|
||||
captured_at: new Date().toISOString(),
|
||||
};
|
||||
const existing = entries.findIndex(value => identity(value) === identity(entry));
|
||||
if (existing >= 0) {
|
||||
entry.status = entries[existing].status || null;
|
||||
entries.splice(existing, 1, entry);
|
||||
} else {
|
||||
entries.push(entry);
|
||||
entries = entries.slice(-limit);
|
||||
}
|
||||
persist();
|
||||
render();
|
||||
schedule();
|
||||
return { ...entry };
|
||||
}
|
||||
|
||||
function summarize(payload) {
|
||||
const checks = Array.isArray(payload?.checks) ? payload.checks : [];
|
||||
const failing = checks.filter(check => ['failure', 'error'].includes(check.state)).map(check => check.name).filter(Boolean);
|
||||
const pending = checks.filter(check => check.state === 'pending').map(check => check.name).filter(Boolean);
|
||||
if (payload?.release) return { ...payload, label: 'Released · ' + payload.release.tag, checks: [] };
|
||||
if (failing.length) return { ...payload, label: 'Checks failed', checks: failing };
|
||||
if (payload?.ci_state === 'success') return { ...payload, label: 'Checks passed · waiting for release', checks: [] };
|
||||
return { ...payload, label: 'Checks running', checks: pending };
|
||||
}
|
||||
|
||||
function render() {
|
||||
const visible = entries.map((entry, index) => ({ entry, index }))
|
||||
.sort((a, b) => Number(b.entry.status?.label === 'Checks failed') - Number(a.entry.status?.label === 'Checks failed') || a.index - b.index)
|
||||
.map(value => value.entry);
|
||||
const first = visible[0] || null;
|
||||
const failed = visible.filter(entry => entry.status?.label === 'Checks failed').length;
|
||||
if (launcher) {
|
||||
launcher.hidden = !entries.length;
|
||||
launcher.textContent = failed
|
||||
? failed + ' release ' + (failed === 1 ? 'failure' : 'failures') + ' · ' + visible.length + ' tracked'
|
||||
: visible.length + ' ' + (visible.length === 1 ? 'merge' : 'merges') + ' · tracking release';
|
||||
}
|
||||
if (statusNode) statusNode.textContent = first?.status?.label || 'Checking the exact merge commit…';
|
||||
if (checksNode) checksNode.textContent = (first?.status?.checks || []).join(', ');
|
||||
if (releaseNode) {
|
||||
releaseNode.hidden = !first?.status?.release?.url;
|
||||
if (first?.status?.release?.url) {
|
||||
releaseNode.href = first.status.release.url;
|
||||
releaseNode.textContent = 'Open release ' + first.status.release.tag;
|
||||
}
|
||||
}
|
||||
if (listNode) {
|
||||
const rows = visible.map(entry => {
|
||||
const row = document.createElement('article');
|
||||
row.className = 'release-watchlist-item';
|
||||
const copy = document.createElement('div');
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = entry.key;
|
||||
const state = document.createElement('span');
|
||||
state.className = 'small';
|
||||
state.textContent = entry.status?.label || 'Checking the exact merge commit…';
|
||||
copy.append(title, state);
|
||||
if (entry.status?.release?.url) {
|
||||
const link = document.createElement('a');
|
||||
link.href = entry.status.release.url;
|
||||
link.textContent = 'Open release ' + entry.status.release.tag;
|
||||
copy.append(link);
|
||||
}
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = 'Dismiss';
|
||||
button.setAttribute('aria-label', 'Dismiss ' + entry.key + ' from release tracking');
|
||||
button.addEventListener('click', () => dismiss(entry.repository, entry.commit_sha));
|
||||
row.append(copy, button);
|
||||
return row;
|
||||
});
|
||||
listNode.replaceChildren(...rows);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function refreshEntry(entry) {
|
||||
const payload = await fetchJson(path(entry), { headers: { Accept: 'application/json' } });
|
||||
if (payload?.commit_sha !== entry.commit_sha) throw new Error('Release evidence did not match the merged commit.');
|
||||
entry.status = summarize(payload);
|
||||
return entry.status;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (refreshing) return refreshing;
|
||||
if (!entries.length) restore();
|
||||
if (!entries.length) return [];
|
||||
refreshing = (async () => {
|
||||
const statuses = [];
|
||||
for (const entry of entries) {
|
||||
try { statuses.push(await refreshEntry(entry)); }
|
||||
catch (error) {
|
||||
entry.status = { label: 'Status unavailable', checks: [], error: String(error?.message || error) };
|
||||
statuses.push(entry.status);
|
||||
}
|
||||
}
|
||||
persist();
|
||||
render();
|
||||
return statuses;
|
||||
})();
|
||||
try { return await refreshing; }
|
||||
finally { refreshing = null; }
|
||||
}
|
||||
|
||||
function dismiss(repository, commitSha) {
|
||||
if (repository && commitSha) entries = entries.filter(entry => identity(entry) !== repository + '@' + commitSha);
|
||||
else entries = [];
|
||||
persist();
|
||||
render();
|
||||
schedule();
|
||||
if (!entries.length && dialog?.open) dialog.close();
|
||||
return entries.map(value => ({ ...value }));
|
||||
}
|
||||
|
||||
function bind() {
|
||||
bound = true;
|
||||
launcher?.addEventListener('click', async () => {
|
||||
dialog?.showModal?.();
|
||||
try { await refresh(); }
|
||||
catch (error) { if (statusNode) statusNode.textContent = error.message + ' Retry when connected.'; }
|
||||
});
|
||||
documentRef?.addEventListener('visibilitychange', () => {
|
||||
if (documentRef.hidden) {
|
||||
if (timer !== null) clearTimer(timer);
|
||||
timer = null;
|
||||
} else schedule(0);
|
||||
});
|
||||
windowRef?.addEventListener('online', () => schedule(0));
|
||||
schedule();
|
||||
}
|
||||
|
||||
return { capture, restore, refresh, dismiss, bind, fetchJson };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createReleaseReceipt;
|
||||
@@ -0,0 +1,159 @@
|
||||
(function (root, factory) {
|
||||
const createSavedSearches = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = createSavedSearches;
|
||||
if (root) root.createSavedSearches = createSavedSearches;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
function createSavedSearches(options) {
|
||||
const fetchJson = options.fetchJson;
|
||||
const createId = options.createId || (() => crypto.randomUUID().replace(/-/g, ''));
|
||||
const onOpen = options.onOpen;
|
||||
const onState = options.onState || (() => {});
|
||||
let current = { revision:0, views:[] };
|
||||
|
||||
function snapshot() {
|
||||
return { revision:current.revision, views:current.views.map(view => ({ ...view })) };
|
||||
}
|
||||
|
||||
function publish(status, message) {
|
||||
onState({ ...snapshot(), status, ...(message ? { message } : {}) });
|
||||
}
|
||||
|
||||
function normalizeSearch(name, search, id) {
|
||||
const cleanName = String(name || '').trim();
|
||||
const query = String(search?.query || '').trim();
|
||||
if (!cleanName || cleanName.length > 60) throw new Error('Name must be between 1 and 60 characters.');
|
||||
if (query.length < 2 || query.length > 200) throw new Error('Search query must be between 2 and 200 characters.');
|
||||
return {
|
||||
id, name:cleanName, query,
|
||||
kind:['all', 'issue', 'pull'].includes(search?.kind) ? search.kind : 'all',
|
||||
state:['all', 'open', 'closed'].includes(search?.state) ? search.state : 'all',
|
||||
repository:String(search?.repository || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
async function replace(views) {
|
||||
publish('saving');
|
||||
try {
|
||||
current = await fetchJson('api/v1/saved-searches', {
|
||||
method:'PUT', headers:{ 'Content-Type':'application/json' },
|
||||
body:JSON.stringify({ revision:current.revision, views }),
|
||||
});
|
||||
publish('ready');
|
||||
return snapshot();
|
||||
} catch (error) {
|
||||
const conflict = error?.status === 409 && error?.payload?.detail?.snapshot;
|
||||
if (conflict) {
|
||||
current = conflict;
|
||||
publish('conflict', 'Saved searches changed on another device.');
|
||||
} else {
|
||||
publish('error', 'Saved searches could not sync. Search still works.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async load() {
|
||||
publish('loading');
|
||||
try {
|
||||
current = await fetchJson('api/v1/saved-searches');
|
||||
publish('ready');
|
||||
} catch (error) {
|
||||
publish('error', 'Saved searches could not load. Search still works.');
|
||||
}
|
||||
return snapshot();
|
||||
},
|
||||
save(name, search) {
|
||||
if (current.views.length >= 20) return Promise.reject(new Error('Saved searches are limited to 20.'));
|
||||
const created = normalizeSearch(name, search, createId());
|
||||
return replace(current.views.concat(created));
|
||||
},
|
||||
rename(id, name) {
|
||||
const existing = current.views.find(view => view.id === id);
|
||||
if (!existing) return Promise.reject(new Error('Saved search no longer exists.'));
|
||||
const changed = normalizeSearch(name, existing, existing.id);
|
||||
return replace(current.views.map(view => view.id === id ? changed : view));
|
||||
},
|
||||
remove(id) {
|
||||
if (!current.views.some(view => view.id === id)) return Promise.resolve(snapshot());
|
||||
return replace(current.views.filter(view => view.id !== id));
|
||||
},
|
||||
open(id) {
|
||||
const selected = current.views.find(view => view.id === id);
|
||||
if (selected) onOpen({ ...selected });
|
||||
},
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
createSavedSearches.mount = function mountSavedSearches(document, fetch, search, applyScope, history) {
|
||||
const query = selector => document.querySelector(selector);
|
||||
const input = query('#cmd-input');
|
||||
const request = async (url, init) => {
|
||||
const response = await fetch(url, init);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const error = new Error(payload.detail?.message || payload.detail || 'Saved Search request failed.');
|
||||
error.status = response.status;
|
||||
error.payload = payload;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
let controller;
|
||||
function render(state) {
|
||||
const list = query('#saved-search-list');
|
||||
const status = query('#saved-search-status');
|
||||
list.replaceChildren();
|
||||
status.textContent = ({loading:'Loading…', saving:'Syncing…', ready:'',
|
||||
conflict:state.message, error:state.message})[state.status] || '';
|
||||
state.views.forEach(view => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'saved-search-row';
|
||||
const open = document.createElement('button');
|
||||
open.type = 'button'; open.className = 'saved-search-open saved-search-action';
|
||||
open.textContent = view.name; open.title = view.query;
|
||||
open.addEventListener('click', () => controller.open(view.id));
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'saved-search-row-actions';
|
||||
for (const action of ['Rename', 'Delete']) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button'; button.className = 'saved-search-action'; button.textContent = action;
|
||||
button.setAttribute('aria-label', action + ' ' + view.name);
|
||||
button.addEventListener('click', async () => {
|
||||
if (action === 'Rename') {
|
||||
const name = globalThis.prompt('Rename saved search', view.name);
|
||||
if (name !== null && name.trim()) await controller.rename(view.id, name).catch(() => {});
|
||||
} else if (globalThis.confirm('Delete saved search “' + view.name + '”?')) {
|
||||
await controller.remove(view.id).catch(() => {});
|
||||
}
|
||||
});
|
||||
actions.append(button);
|
||||
}
|
||||
row.append(open, actions); list.append(row);
|
||||
});
|
||||
}
|
||||
controller = createSavedSearches({ fetchJson:request, onState:render, onOpen:view => {
|
||||
search.setQuery('');
|
||||
input.value = view.query;
|
||||
applyScope(view);
|
||||
search.setQuery(view.query);
|
||||
history.update({query:view.query, scope:view});
|
||||
input.focus();
|
||||
}});
|
||||
query('#save-current-search').addEventListener('click', async () => {
|
||||
const name = query('#saved-search-name').value.trim();
|
||||
const current = {query:input.value.trim(), kind:query('#cmd-search-kind').value,
|
||||
state:query('#cmd-search-state').value, repository:query('#cmd-search-repository').value.trim()};
|
||||
if (!name || current.query.length < 2) {
|
||||
query('#saved-search-status').textContent = 'Enter a name and at least 2 search characters.';
|
||||
return;
|
||||
}
|
||||
await controller.save(name, current).then(() => { query('#saved-search-name').value = ''; })
|
||||
.catch(error => { query('#saved-search-status').textContent = error.message; });
|
||||
});
|
||||
return controller;
|
||||
};
|
||||
|
||||
return createSavedSearches;
|
||||
});
|
||||
@@ -0,0 +1,498 @@
|
||||
(function (root, factory) {
|
||||
const createSearchBatchPlan = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = createSearchBatchPlan;
|
||||
if (root) {
|
||||
root.createSearchBatchPlan = createSearchBatchPlan;
|
||||
root.mountSearchBatchPlanning = createSearchBatchPlan.mount;
|
||||
}
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
function createSearchBatchPlan({
|
||||
limit = 50,
|
||||
resolve = item => Promise.resolve(item),
|
||||
claim = item => Promise.resolve(item),
|
||||
onChange = () => {},
|
||||
} = {}) {
|
||||
const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50;
|
||||
const selected = new Map();
|
||||
let active = false;
|
||||
|
||||
const identity = item => [item?.kind || '', item?.repository || '', item?.number || ''].join(':');
|
||||
const eligible = item => item?.kind === 'issue' && item?.state === 'open' &&
|
||||
Boolean(item.repository) && Number.isInteger(item.number);
|
||||
function snapshot() {
|
||||
return { active, count:selected.size, items:Array.from(selected.values()) };
|
||||
}
|
||||
function changed() {
|
||||
const state = snapshot();
|
||||
onChange(state);
|
||||
return state;
|
||||
}
|
||||
function start() {
|
||||
active = true;
|
||||
selected.clear();
|
||||
return changed();
|
||||
}
|
||||
function cancel() {
|
||||
active = false;
|
||||
selected.clear();
|
||||
return changed();
|
||||
}
|
||||
function toggle(item) {
|
||||
if (!active) return 'inactive';
|
||||
if (!eligible(item)) return 'ineligible';
|
||||
const key = identity(item);
|
||||
if (selected.has(key)) {
|
||||
selected.delete(key);
|
||||
changed();
|
||||
return 'removed';
|
||||
}
|
||||
if (selected.size >= maximum) return 'limit';
|
||||
selected.set(key, { ...item });
|
||||
changed();
|
||||
return 'selected';
|
||||
}
|
||||
async function prepare(item) {
|
||||
const detail = await resolve(item);
|
||||
if (detail?.assigned_to_me) return detail;
|
||||
if (detail?.claimable) return claim(detail);
|
||||
throw new Error('This issue is no longer available to assign.');
|
||||
}
|
||||
function releaseRepository() {
|
||||
const repositories = new Set(Array.from(selected.values()).map(item => item.repository));
|
||||
return repositories.size === 1 ? repositories.values().next().value : null;
|
||||
}
|
||||
return { identity, eligible, start, cancel, toggle, snapshot, prepare, releaseRepository, limit:maximum };
|
||||
}
|
||||
|
||||
function createWeekBatchPlan({
|
||||
week,
|
||||
prepare = item => Promise.resolve(item),
|
||||
accept = item => item,
|
||||
identity = item => String(item?.repository || '') + '#' + String(item?.number || ''),
|
||||
maxItems = 5,
|
||||
onProgress = () => {},
|
||||
} = {}) {
|
||||
let running = null;
|
||||
const load = async () => {
|
||||
await week.load();
|
||||
return week.dates().map(day => ({ ...day, ...week.day(day.date) }));
|
||||
};
|
||||
function validate(items, assignments, days, confirmOverload = false) {
|
||||
const allowed = new Set(days.map(day => day.date));
|
||||
const errors = [];
|
||||
const simulated = new Map(days.map(day => [day.date, {
|
||||
ids:[...(day.ids || [])], estimates:{...(day.estimates || {})},
|
||||
capacity_minutes:Number(day.capacity_minutes) || 0,
|
||||
}]));
|
||||
items.forEach(item => {
|
||||
const id = identity(item), choice = assignments?.[id] || {};
|
||||
const estimate = Number(choice.estimate);
|
||||
if (!allowed.has(choice.date)) errors.push({ id, reason:'date-required' });
|
||||
if (!Number.isFinite(estimate) || estimate <= 0) errors.push({ id, reason:'estimate-required' });
|
||||
if (!allowed.has(choice.date) || !Number.isFinite(estimate) || estimate <= 0) return;
|
||||
simulated.forEach((day, date) => {
|
||||
if (day.ids.includes(id) && choice.date !== date) {
|
||||
day.ids = day.ids.filter(value => value !== id); delete day.estimates[id];
|
||||
}
|
||||
});
|
||||
const destination = simulated.get(choice.date);
|
||||
if (!destination.ids.includes(id) && destination.ids.length >= maxItems) {
|
||||
errors.push({ id, reason:'day-full', date:choice.date, limit:maxItems }); return;
|
||||
}
|
||||
if (!destination.ids.includes(id)) destination.ids.push(id);
|
||||
destination.estimates[id] = estimate;
|
||||
});
|
||||
const overloads = [];
|
||||
simulated.forEach((day, date) => {
|
||||
const planned = day.ids.reduce((sum, id) => sum + (Number(day.estimates[id]) || 0), 0);
|
||||
if (day.capacity_minutes > 0 && planned > day.capacity_minutes) {
|
||||
overloads.push({ date, planned_minutes:planned, capacity_minutes:day.capacity_minutes });
|
||||
}
|
||||
});
|
||||
if (errors.length) return { status:'invalid', errors, overloads:[] };
|
||||
if (overloads.length && !confirmOverload) return { status:'overload-confirmation-required', errors:[], overloads };
|
||||
return { status:'ready', errors:[], overloads };
|
||||
}
|
||||
async function preview(items) {
|
||||
const days = await load();
|
||||
return { days, placements:Object.fromEntries(items.map(item => [identity(item), week.placement(identity(item))])) };
|
||||
}
|
||||
function run(items, assignments, { confirmOverload = false } = {}) {
|
||||
if (running) return running;
|
||||
running = (async () => {
|
||||
const selected = Array.isArray(items) ? items.slice() : [];
|
||||
const days = await load();
|
||||
const checked = validate(selected, assignments, days, confirmOverload);
|
||||
if (checked.status !== 'ready') return checked;
|
||||
const failed = [], planned = [];
|
||||
for (let index = 0; index < selected.length; index += 1) {
|
||||
const source = selected[index], originalId = identity(source), choice = assignments[originalId];
|
||||
try {
|
||||
const confirmed = accept(await prepare(source)) || source;
|
||||
const id = identity(confirmed);
|
||||
const existing = week.placement(id);
|
||||
if (!week.place(id, choice.date, Number(choice.estimate), {move:Boolean(existing && existing.date !== choice.date)})) {
|
||||
failed.push({id:originalId, reason:'assigned-not-planned'});
|
||||
} else {
|
||||
week.rememberPendingItem?.(id, confirmed);
|
||||
planned.push(id);
|
||||
}
|
||||
} catch (error) {
|
||||
failed.push({id:originalId, reason:error?.message || 'assignment failed'});
|
||||
}
|
||||
onProgress({status:'running',processed:index + 1,selected:selected.length});
|
||||
}
|
||||
if (planned.length) {
|
||||
try { await week.flush(); }
|
||||
catch (_error) { return {status:'planned-pending',planned:planned.length,failed}; }
|
||||
}
|
||||
return {status:failed.length?'partial':'planned',planned:planned.length,failed};
|
||||
})().finally(() => { running = null; });
|
||||
return running;
|
||||
}
|
||||
return { preview, validate, run, pending:() => Boolean(running) };
|
||||
}
|
||||
createSearchBatchPlan.createWeekBatchPlan = createWeekBatchPlan;
|
||||
|
||||
createSearchBatchPlan.mount = function mountSearchBatchPlanning(
|
||||
document, batchFactory, todayWork, getOwner, fetchJson, previewPath, queueToday,
|
||||
todaySync, acceptIssue, lookup, render, escapeHtml, escapeAttribute,
|
||||
laterWork = null, laterPicker = null, weekPlan = null, storage = null
|
||||
) {
|
||||
const get = selector => document.querySelector(selector);
|
||||
let processor;
|
||||
let laterProcessor;
|
||||
let releaseProcessor;
|
||||
let releaseContext = null;
|
||||
const plan = createSearchBatchPlan({
|
||||
resolve:item => fetchJson(previewPath(item), {headers:{Accept:'application/json'}}),
|
||||
claim:detail => fetchJson(
|
||||
'api/v1/repos/' + detail.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(detail.number) + '/claim',
|
||||
{method:'PATCH', headers:{Accept:'application/json'}}
|
||||
),
|
||||
onChange:state => {
|
||||
get('#search-batch-actions').hidden = !state.active;
|
||||
get('#select-search-results').hidden = state.active;
|
||||
get('#queue-selected-search-results').disabled = state.count === 0;
|
||||
get('#defer-selected-search-results').disabled = state.count === 0;
|
||||
get('#plan-selected-search-results').disabled = state.count === 0;
|
||||
if (get('#week-selected-search-results')) get('#week-selected-search-results').disabled = state.count === 0;
|
||||
get('#search-selection-status').textContent = state.count ?
|
||||
state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No issues selected.';
|
||||
render();
|
||||
},
|
||||
});
|
||||
processor = batchFactory({
|
||||
capacity:() => Math.max(0, todayWork.limit - todayWork.read().length),
|
||||
owner:getOwner,
|
||||
timeBudget:() => {
|
||||
const planning = todayWork.planning();
|
||||
return {
|
||||
capacity_minutes:planning.capacity_minutes,
|
||||
planned_minutes:Object.values(planning.estimates || {}).reduce((sum, minutes) => sum + minutes, 0),
|
||||
};
|
||||
},
|
||||
journalName:'search-today-batch',
|
||||
autoMount:false,
|
||||
claim:item => plan.prepare(item),
|
||||
queue:confirmed => queueToday(acceptIssue(confirmed)),
|
||||
persistEstimate:(confirmed, minutes) => {
|
||||
const planning = todayWork.planning();
|
||||
planning.estimates[todayWork.identity(confirmed)] = minutes;
|
||||
todayWork.replacePlanning(planning);
|
||||
todaySync.enqueueConfiguration(planning.capacity_minutes, planning.estimates);
|
||||
todaySync.flush();
|
||||
},
|
||||
onProgress:progress => {
|
||||
get('#queue-selected-search-results').disabled = progress.status === 'running';
|
||||
if (progress.status === 'running') {
|
||||
get('#search-selection-status').textContent = 'Planning ' + progress.processed + ' of ' + progress.selected + '…';
|
||||
} else if (progress.status === 'full') {
|
||||
get('#search-selection-status').textContent = 'Today has ' + progress.available + ' remaining slot' +
|
||||
(progress.available === 1 ? '.' : 's.');
|
||||
} else if (progress.status === 'estimates-required') {
|
||||
get('#search-batch-estimate-summary').textContent = 'Estimate every selected issue.';
|
||||
} else if (progress.status === 'over-budget') {
|
||||
get('#search-batch-estimate-summary').textContent = progress.requested_minutes +
|
||||
' min selected · ' + progress.remaining_minutes + ' min remain.';
|
||||
} else if (progress.status === 'complete') {
|
||||
get('#cmd-search-action-status').textContent = progress.failed.length ?
|
||||
progress.queued.length + ' queued · ' + progress.failed.length + ' need retry.' :
|
||||
progress.queued.length + ' added to Today.';
|
||||
get('#resume-search-batch').hidden = processor.pending() === 0;
|
||||
closeEstimateReview();
|
||||
if (!progress.failed.length) plan.cancel();
|
||||
}
|
||||
},
|
||||
});
|
||||
laterProcessor = batchFactory({
|
||||
capacity:() => plan.limit,
|
||||
owner:getOwner,
|
||||
journalName:'search-later-batch',
|
||||
queueFailureReason:'assigned but Later could not be saved',
|
||||
autoMount:false,
|
||||
claim:item => plan.prepare(item),
|
||||
queue:(confirmed, context) => {
|
||||
const outcome = laterWork?.defer(acceptIssue(confirmed), context?.until);
|
||||
return outcome === 'deferred' ? 'queued' : outcome;
|
||||
},
|
||||
onProgress:progress => {
|
||||
get('#defer-selected-search-results').disabled = progress.status === 'running';
|
||||
if (progress.status === 'running') {
|
||||
get('#search-selection-status').textContent = 'Deferring ' + progress.processed + ' of ' + progress.selected + '…';
|
||||
} else if (progress.status === 'complete') {
|
||||
get('#cmd-search-action-status').textContent = progress.failed.length ?
|
||||
progress.queued.length + ' deferred · ' + progress.failed.length + ' need retry.' :
|
||||
progress.queued.length + ' added to Later.';
|
||||
restore();
|
||||
if (!progress.failed.length) plan.cancel();
|
||||
}
|
||||
},
|
||||
});
|
||||
releaseProcessor = batchFactory({
|
||||
capacity:() => plan.limit,
|
||||
owner:getOwner,
|
||||
journalName:'search-release-batch',
|
||||
queueFailureReason:'assigned but release planning could not be confirmed',
|
||||
autoMount:false,
|
||||
claim:item => plan.prepare(item),
|
||||
queue:async (confirmed, context) => {
|
||||
const issuePath = 'api/v1/repos/' + confirmed.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(confirmed.number);
|
||||
const releasePlan = await fetchJson(issuePath + '/release-plan', {
|
||||
method:'PATCH', headers:{Accept:'application/json','Content-Type':'application/json'},
|
||||
body:JSON.stringify({milestone_id:context.milestone_id, due_date:context.due_date || null}),
|
||||
});
|
||||
if (releasePlan?.number !== confirmed.number ||
|
||||
releasePlan?.milestone?.id !== context.milestone_id ||
|
||||
(context.due_date && releasePlan?.due_date !== context.due_date)) {
|
||||
throw new Error('Issue release plan was not confirmed.');
|
||||
}
|
||||
return 'queued';
|
||||
},
|
||||
onProgress:progress => {
|
||||
get('#confirm-search-release').disabled = progress.status === 'running';
|
||||
if (progress.status === 'running') {
|
||||
get('#search-release-summary').textContent = 'Planning ' + progress.processed + ' of ' + progress.selected + '…';
|
||||
} else if (progress.status === 'complete') {
|
||||
get('#cmd-search-action-status').textContent = progress.failed.length ?
|
||||
progress.queued.length + ' planned · ' + progress.failed.length + ' need retry.' :
|
||||
progress.queued.length + ' planned for ' + (releaseContext?.milestone_title || 'release') + '.';
|
||||
get('#resume-search-batch').hidden = releaseProcessor.pending() === 0;
|
||||
closeReleaseReview();
|
||||
if (!progress.failed.length) plan.cancel();
|
||||
}
|
||||
},
|
||||
});
|
||||
const weekProcessor = weekPlan ? createWeekBatchPlan({
|
||||
week:weekPlan,prepare:item=>plan.prepare(item),accept:acceptIssue,
|
||||
identity:item=>todayWork.identity(item),
|
||||
onProgress:progress=>{
|
||||
if (progress.status === 'running') get('#search-week-batch-summary').textContent =
|
||||
'Planning ' + progress.processed + ' of ' + progress.selected + '…';
|
||||
},
|
||||
storage,
|
||||
}) : null;
|
||||
let weekOverload = false;
|
||||
function weekAssignments() {
|
||||
const values = {};
|
||||
document.querySelectorAll('[data-search-week-batch-date]').forEach(select => {
|
||||
const id = select.dataset.searchWeekBatchDate;
|
||||
const estimate = document.querySelector('[data-search-week-batch-estimate="' + id + '"]');
|
||||
values[id] = {date:select.value,estimate:Number(estimate?.value)};
|
||||
});
|
||||
return values;
|
||||
}
|
||||
function closeWeekReview() {
|
||||
if (!get('#search-week-batch-review')) return;
|
||||
get('#search-week-batch-review').hidden = true;
|
||||
get('#search-batch-actions').hidden = !plan.snapshot().active;
|
||||
weekOverload = false;
|
||||
get('#confirm-search-week-batch').textContent = 'Assign & plan';
|
||||
}
|
||||
async function openWeekReview() {
|
||||
const items = plan.snapshot().items;
|
||||
if (!weekProcessor || !items.length) return;
|
||||
const button = get('#week-selected-search-results');
|
||||
button.disabled = true;
|
||||
get('#search-selection-status').textContent = 'Loading Week Ahead…';
|
||||
try {
|
||||
const preview = await weekProcessor.preview(items);
|
||||
const options = selected => preview.days.map(day => '<option value="' + escapeAttribute(day.date) + '"' +
|
||||
(day.date === selected ? ' selected' : '') + '>' + escapeHtml(day.label + ' · ' + (day.ids || []).length + ' planned') + '</option>').join('');
|
||||
get('#search-week-batch-list').innerHTML = items.map(item => {
|
||||
const id = todayWork.identity(item), existing = preview.placements[id];
|
||||
return '<div class="search-week-batch-row"><div><span class="small">' + escapeHtml(id) + '</span><strong>' +
|
||||
escapeHtml(item.title || 'Untitled work') + '</strong></div><div class="search-week-batch-row-controls"><label>Day<select data-search-week-batch-date="' +
|
||||
escapeAttribute(id) + '">' + options(existing?.date || preview.days[0]?.date) + '</select></label><label>Minutes<input type="number" inputmode="numeric" min="5" max="1440" step="5" value="' +
|
||||
escapeAttribute(existing?.estimate || '') + '" data-search-week-batch-estimate="' + escapeAttribute(id) +
|
||||
'" aria-label="Estimate for ' + escapeAttribute(item.title || id) + ' in minutes"></label></div></div>';
|
||||
}).join('');
|
||||
get('#search-week-batch-summary').textContent = items.length + ' selected · review every day and estimate before assignment.';
|
||||
get('#search-batch-actions').hidden = true;
|
||||
get('#search-week-batch-review').hidden = false;
|
||||
document.querySelector('[data-search-week-batch-date]')?.focus();
|
||||
} catch (error) {
|
||||
get('#search-selection-status').textContent = error?.message || 'Week Ahead is unavailable. Retry when connected.';
|
||||
} finally { button.disabled = false; }
|
||||
}
|
||||
async function confirmWeekReview() {
|
||||
if (!weekProcessor || weekProcessor.pending()) return;
|
||||
const button = get('#confirm-search-week-batch');
|
||||
button.disabled = true;
|
||||
const outcome = await weekProcessor.run(plan.snapshot().items, weekAssignments(), {confirmOverload:weekOverload});
|
||||
if (outcome.status === 'invalid') {
|
||||
const reasons = [...new Set(outcome.errors.map(error => error.reason))];
|
||||
get('#search-week-batch-summary').textContent = reasons.includes('day-full') ?
|
||||
'A day already has five items. Choose another day.' : 'Choose a valid day and estimate for every issue.';
|
||||
} else if (outcome.status === 'overload-confirmation-required') {
|
||||
weekOverload = true;
|
||||
button.textContent = 'Confirm over capacity';
|
||||
get('#search-week-batch-summary').textContent = outcome.overloads.map(day =>
|
||||
day.planned_minutes + ' of ' + day.capacity_minutes + ' min on ' + day.date).join(' · ') + '. Confirm to plan anyway.';
|
||||
} else {
|
||||
const pending = outcome.status === 'planned-pending';
|
||||
get('#cmd-search-action-status').textContent = outcome.planned + ' planned' +
|
||||
(pending ? ' · sync pending' : '') + (outcome.failed.length ? ' · ' + outcome.failed.length + ' need attention.' : '.');
|
||||
closeWeekReview();
|
||||
if (!outcome.failed.length) plan.cancel();
|
||||
}
|
||||
button.disabled = false;
|
||||
}
|
||||
function estimateValues() {
|
||||
return Object.fromEntries(Array.from(document.querySelectorAll('[data-search-batch-estimate]')).map(input =>
|
||||
[input.dataset.searchBatchEstimate, Number(input.value)]
|
||||
));
|
||||
}
|
||||
function closeEstimateReview() {
|
||||
get('#search-batch-estimate-review').hidden = true;
|
||||
get('#search-batch-actions').hidden = !plan.snapshot().active;
|
||||
}
|
||||
function openEstimateReview(items) {
|
||||
const planning = todayWork.planning();
|
||||
const planned = Object.values(planning.estimates || {}).reduce((sum, minutes) => sum + minutes, 0);
|
||||
const remaining = Math.max(0, planning.capacity_minutes - planned);
|
||||
get('#search-batch-estimate-summary').textContent = remaining +
|
||||
' min remain. Estimate before assigning.';
|
||||
get('#search-batch-estimate-list').innerHTML = items.map(item => {
|
||||
const id = String(item.repository || '') + '#' + String(item.number || '');
|
||||
const estimate = planning.estimates?.[id] || '';
|
||||
return '<div class="search-batch-estimate-row"><div><span class="small">' + escapeHtml(id) +
|
||||
'</span><strong>' + escapeHtml(item.title || 'Untitled work') + '</strong></div><label class="small">' +
|
||||
'<input type="number" inputmode="numeric" min="5" max="1440" step="5" value="' +
|
||||
escapeAttribute(estimate) + '" data-search-batch-estimate="' + escapeAttribute(id) +
|
||||
'" aria-label="Estimate for ' + escapeAttribute(item.title || id) + ' in minutes" /> min</label></div>';
|
||||
}).join('');
|
||||
get('#search-batch-actions').hidden = true;
|
||||
get('#search-batch-estimate-review').hidden = false;
|
||||
document.querySelector('[data-search-batch-estimate]')?.focus();
|
||||
}
|
||||
function closeReleaseReview() {
|
||||
get('#search-release-review').hidden = true;
|
||||
get('#search-batch-actions').hidden = !plan.snapshot().active;
|
||||
}
|
||||
async function openReleaseReview() {
|
||||
const items = plan.snapshot().items;
|
||||
const repository = plan.releaseRepository();
|
||||
if (!items.length) return;
|
||||
if (!repository) {
|
||||
get('#search-selection-status').textContent = 'Select issues from one repository to plan a release.';
|
||||
return;
|
||||
}
|
||||
get('#plan-selected-search-results').disabled = true;
|
||||
get('#search-selection-status').textContent = 'Loading release milestones…';
|
||||
try {
|
||||
const milestones = await fetchJson(
|
||||
'api/v1/repos/' + repository.split('/').map(encodeURIComponent).join('/') + '/milestones',
|
||||
{headers:{Accept:'application/json'}}
|
||||
);
|
||||
const open = (Array.isArray(milestones) ? milestones : []).filter(item => item?.state !== 'closed');
|
||||
get('#search-release-milestone').innerHTML = '<option value="">Choose milestone</option>' + open.map(item =>
|
||||
'<option value="' + Number(item.id) + '">' + escapeHtml(item.title) + '</option>'
|
||||
).join('');
|
||||
get('#search-release-list').innerHTML = items.map(item => '<div class="search-release-row"><span>' +
|
||||
escapeHtml(item.repository + ' #' + item.number) + '</span><strong>' +
|
||||
escapeHtml(item.title || 'Untitled work') + '</strong></div>').join('');
|
||||
get('#search-release-summary').textContent = items.length + ' issues in ' + repository + '. Assignment starts only after confirmation.';
|
||||
get('#search-batch-actions').hidden = true;
|
||||
get('#search-release-review').hidden = false;
|
||||
get('#search-release-milestone').focus();
|
||||
} catch (error) {
|
||||
get('#search-selection-status').textContent = error?.message || 'Milestones could not be loaded. Try again.';
|
||||
} finally {
|
||||
get('#plan-selected-search-results').disabled = false;
|
||||
}
|
||||
}
|
||||
get('#select-search-results').addEventListener('click', () => plan.start());
|
||||
get('#cancel-search-selection').addEventListener('click', () => plan.cancel());
|
||||
get('#queue-selected-search-results').addEventListener('click', () => {
|
||||
const items = plan.snapshot().items;
|
||||
const capacity = todayWork.planning().capacity_minutes;
|
||||
if (Number.isInteger(capacity) && capacity > 0) openEstimateReview(items);
|
||||
else processor.run(items);
|
||||
});
|
||||
get('#defer-selected-search-results').addEventListener('click', event => {
|
||||
const items = plan.snapshot().items;
|
||||
if (!items.length) return;
|
||||
laterPicker?.open({ items, confirm:until => () => laterProcessor.run(
|
||||
items, {}, { until:new Date(until).toISOString() }
|
||||
) }, event.currentTarget, 'search-batch');
|
||||
});
|
||||
get('#plan-selected-search-results').addEventListener('click', openReleaseReview);
|
||||
get('#week-selected-search-results')?.addEventListener('click', openWeekReview);
|
||||
get('#cancel-search-week-batch')?.addEventListener('click', closeWeekReview);
|
||||
get('#confirm-search-week-batch')?.addEventListener('click', confirmWeekReview);
|
||||
get('#cancel-search-release').addEventListener('click', closeReleaseReview);
|
||||
get('#confirm-search-release').addEventListener('click', () => {
|
||||
const milestoneSelect = get('#search-release-milestone');
|
||||
const milestoneId = Number(milestoneSelect.value);
|
||||
if (!Number.isInteger(milestoneId) || milestoneId < 1) {
|
||||
get('#search-release-summary').textContent = 'Choose an open milestone.';
|
||||
return;
|
||||
}
|
||||
const selectedOption = milestoneSelect.options?.[milestoneSelect.selectedIndex];
|
||||
const milestoneTitle = selectedOption?.textContent || selectedOption?.text || 'release';
|
||||
const dueDay = get('#search-release-due-date').value;
|
||||
releaseContext = {
|
||||
milestone_id:milestoneId, milestone_title:milestoneTitle,
|
||||
due_date:dueDay ? dueDay + 'T23:59:59Z' : null,
|
||||
};
|
||||
releaseProcessor.run(plan.snapshot().items, {}, releaseContext);
|
||||
});
|
||||
get('#cancel-search-batch-estimates').addEventListener('click', closeEstimateReview);
|
||||
get('#confirm-search-batch-estimates').addEventListener('click', () =>
|
||||
processor.run(plan.snapshot().items, estimateValues())
|
||||
);
|
||||
get('#resume-search-batch').addEventListener('click', () => {
|
||||
if (processor.pending()) return processor.resume();
|
||||
if (laterProcessor.pending()) return laterProcessor.resume();
|
||||
return releaseProcessor.resume();
|
||||
});
|
||||
get('#open-palette').addEventListener('click', () => restore());
|
||||
get('#cmd-results').addEventListener('change', event => {
|
||||
const index = event.target?.dataset?.searchSelect;
|
||||
if (index === undefined) return;
|
||||
const result = lookup(Number(index));
|
||||
if (result) plan.toggle(result);
|
||||
});
|
||||
function restore() {
|
||||
const pending = processor.pending() + laterProcessor.pending() + releaseProcessor.pending();
|
||||
get('#resume-search-batch').hidden = pending === 0;
|
||||
get('#resume-search-batch').textContent = 'Resume ' + pending + ' interrupted';
|
||||
}
|
||||
function resultHtml(result, index) {
|
||||
const allowed = result.kind === 'issue' && result.state === 'open';
|
||||
const selected = plan.snapshot().items.some(item => plan.identity(item) === plan.identity(result));
|
||||
return '<label class="cmd-select-result"' + (allowed ? '' : ' aria-disabled="true"') + '>' +
|
||||
'<input type="checkbox" data-search-select="' + index + '" ' + (selected ? 'checked ' : '') +
|
||||
(allowed ? '' : 'disabled ') + 'aria-label="Select ' + escapeAttribute(result.title) + '" />' +
|
||||
'<span>' + escapeHtml(result.title) + '</span><span class="cmd-meta">' +
|
||||
escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' +
|
||||
(allowed ? 'Open issue' : 'Not eligible') + '</span></label>';
|
||||
}
|
||||
return { plan, processor, laterProcessor, releaseProcessor, weekProcessor, restore, resultHtml };
|
||||
};
|
||||
|
||||
return createSearchBatchPlan;
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
function createSearchDefer({ claim, accept, defer, refresh, announce, formatTime }) {
|
||||
let request = null;
|
||||
|
||||
function run(detail, until) {
|
||||
if (request) return request;
|
||||
const wasClaimable = Boolean(detail?.claimable);
|
||||
request = Promise.resolve(wasClaimable ? claim(detail) : detail)
|
||||
.then(confirmed => {
|
||||
const item = accept(confirmed);
|
||||
const outcome = defer(item, until);
|
||||
refresh();
|
||||
if (outcome === 'deferred') {
|
||||
announce((wasClaimable ? 'Assigned and deferred until ' : 'Deferred until ') + formatTime(until) + '.');
|
||||
} else {
|
||||
announce(wasClaimable
|
||||
? 'Assignment succeeded, but Later could not be saved. Open this issue in My Work to retry.'
|
||||
: 'Could not save Later on this device. Retry scheduling.');
|
||||
}
|
||||
return outcome;
|
||||
})
|
||||
.finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
return { run, pending:() => Boolean(request) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createSearchDefer;
|
||||
+540
-13
@@ -1,61 +1,543 @@
|
||||
(function (root, factory) {
|
||||
const createSearchPreview = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = createSearchPreview;
|
||||
if (root) root.createSearchPreview = createSearchPreview;
|
||||
if (root) {
|
||||
root.createSearchPreview = createSearchPreview;
|
||||
root.safeSearchUrl = value => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
|
||||
} catch (_) { return ''; }
|
||||
};
|
||||
root.searchPreviewUrl = (state, defaultScope, location) => {
|
||||
const { query, preview, scope = defaultScope } = state;
|
||||
const params = new URLSearchParams({
|
||||
search:query || '', preview:preview.kind + ':' + preview.repository + ':' + preview.number,
|
||||
search_kind:scope.kind, search_state:scope.state,
|
||||
});
|
||||
if (scope.repository) params.set('search_repository', scope.repository);
|
||||
return new URL('?' + params, location.origin + location.pathname).href;
|
||||
};
|
||||
root.searchPreviewPath = item => {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
|
||||
'/preview?kind=' + encodeURIComponent(item.kind);
|
||||
};
|
||||
root.searchPreviewConversationPath = (item, page) => {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
const query = new URLSearchParams({ kind:item.kind, limit:'20' });
|
||||
if (page) query.set('page', String(page));
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
|
||||
'/preview/conversation?' + query.toString();
|
||||
};
|
||||
root.followingPullReviewPath = item => {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
return 'api/v1/following/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review-data';
|
||||
};
|
||||
root.searchPreviewReplyPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
|
||||
'/comments?kind=' + encodeURIComponent(item.kind);
|
||||
root.searchPreviewSubscriptionPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
|
||||
'/subscription?kind=' + encodeURIComponent(item.kind);
|
||||
root.searchPreviewSubscriptionOptions = fetchJson => {
|
||||
const options = {
|
||||
load:async detail => {
|
||||
if (['issue', 'pull'].includes(detail.kind) && detail.state === 'closed' && detail.following === true) {
|
||||
return {...detail, watching:true};
|
||||
}
|
||||
if (!(['issue', 'pull'].includes(detail.kind) && detail.state === 'open')) return detail;
|
||||
const result = await fetchJson(root.searchPreviewSubscriptionPath(detail), {headers:{Accept:'application/json'}});
|
||||
return {...detail, watching:result.watching === true};
|
||||
},
|
||||
watch:(detail,watching) => fetchJson(root.searchPreviewSubscriptionPath(detail), {
|
||||
method:watching ? 'PUT' : 'DELETE', headers:{Accept:'application/json'},
|
||||
}),
|
||||
review:item => fetchJson(root.followingPullReviewPath(item)),
|
||||
};
|
||||
options.preview = async item => options.load({...item, ...await fetchJson(root.searchPreviewPath(item), {
|
||||
headers:{Accept:'application/json'},
|
||||
})});
|
||||
return options;
|
||||
};
|
||||
root.createDetailWatch = ({fetchJson,refreshFollowing,onState}) => {
|
||||
const api=root.searchPreviewSubscriptionOptions(fetchJson);
|
||||
let item,watching=false,mutation;
|
||||
return {
|
||||
async open(next) {
|
||||
item=next; onState('loading',watching);
|
||||
const result=await api.load(next).catch(error=>{
|
||||
if(item===next)onState('error',watching,error);throw error;
|
||||
});
|
||||
if (item !== next) return;
|
||||
watching=result.watching === true; onState('ready',watching);
|
||||
},
|
||||
toggle() {
|
||||
if (mutation) return mutation;
|
||||
const next=!watching;
|
||||
onState(next?'watching':'unwatching',watching);
|
||||
mutation=api.watch(item,next).then(async result => {
|
||||
if (result?.watching !== next || result?.following_synced !== true)
|
||||
throw new Error(result?.error || 'Unconfirmed.');
|
||||
watching=next; await refreshFollowing();
|
||||
onState(watching?'watched':'unwatched',watching);
|
||||
}).catch(error => {onState('error',watching,error);throw error;})
|
||||
.finally(()=>{mutation=null;});
|
||||
return mutation;
|
||||
},
|
||||
};
|
||||
};
|
||||
root.searchPreviewWatchStatus = state => ({
|
||||
watching:'Starting watch…', unwatching:'Stopping watch…',
|
||||
watched:'Watching · available in Following. Future activity will appear in Updates.',
|
||||
unwatched:'Stopped watching · removed from Following. Assignment and planning are unchanged.',
|
||||
'watch-partial':'Watching in Gitea, but Following could not sync. Tap Stop watching, then Watch ' +
|
||||
(state.detail?.kind === 'pull' ? 'pull request' : 'issue') + ' to repair.',
|
||||
'watch-error':(state.error?.message || 'Watch status was not changed.') + ' Retry.',
|
||||
})[state.status] || '';
|
||||
root.renderSearchPreviewWatch = (detail, state, button) => {
|
||||
const watchableKind = ['issue', 'pull'].includes(detail.kind);
|
||||
const retiring = watchableKind && detail.state === 'closed' &&
|
||||
detail.following === true && detail.watching === true;
|
||||
button.hidden = !(watchableKind && detail.state === 'open') && !retiring;
|
||||
button.textContent = retiring ? 'Stop watching & next' :
|
||||
(detail.watching ? 'Stop watching' : 'Watch ' + (detail.kind === 'pull' ? 'pull request' : 'issue'));
|
||||
button.disabled = state.status === 'watching' || state.status === 'unwatching';
|
||||
};
|
||||
root.wireSearchPreviewWatch = (button, preview, getDetail) => button.addEventListener('click', () => {
|
||||
const detail = getDetail();
|
||||
if (detail) preview.setWatching(detail.watching !== true).catch(() => {});
|
||||
});
|
||||
root.searchPreviewReplyOptions = (fetchJson, storage, crypto) => ({
|
||||
storage,
|
||||
createOperationId:() => crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
|
||||
postReply:(item,body,operationId) => fetchJson(root.searchPreviewReplyPath(item), {
|
||||
method:'POST', headers:{Accept:'application/json','Content-Type':'application/json','Idempotency-Key':operationId},
|
||||
body:JSON.stringify({body}),
|
||||
}),
|
||||
});
|
||||
root.renderSearchPreviewConversation = (conversation, document, escapeHtml, formatTime, renderMarkdown) => {
|
||||
const comments = document.querySelector('#search-preview-comments');
|
||||
const status = document.querySelector('#search-preview-conversation-status');
|
||||
const retry = document.querySelector('#retry-search-preview-conversation');
|
||||
const older = document.querySelector('#load-older-search-preview-comments');
|
||||
if (!conversation) {
|
||||
comments.textContent = status.textContent = '';
|
||||
retry.hidden = older.hidden = true;
|
||||
return;
|
||||
}
|
||||
const isNew = comment => Boolean(conversation.reviewedAt && comment.created_at &&
|
||||
new Date(comment.created_at).getTime() > new Date(conversation.reviewedAt).getTime());
|
||||
const newCount = (conversation.comments || []).filter(isNew).length;
|
||||
comments.innerHTML = (conversation.comments || []).map(comment =>
|
||||
'<article class="search-preview-comment' + (isNew(comment) ? ' new-since-review' : '') +
|
||||
'" data-comment-id="' + Number(comment.id || 0) + '">' +
|
||||
'<div class="small">' + escapeHtml(comment.author || 'Unknown author') +
|
||||
(comment.created_at ? ' · ' + escapeHtml(formatTime(comment.created_at)) : '') +
|
||||
(isNew(comment) ? ' · <strong>New since last review</strong>' : '') + '</div>' +
|
||||
'<div class="markdown-content">' + renderMarkdown(comment.body || '') + '</div></article>'
|
||||
).join('');
|
||||
retry.hidden = conversation.status !== 'error';
|
||||
older.hidden = conversation.status !== 'ready' || !conversation.olderPage;
|
||||
older.disabled = conversation.status === 'loading';
|
||||
if (conversation.status === 'loading') status.textContent = conversation.comments?.length
|
||||
? 'Loading older messages…' : 'Loading current conversation…';
|
||||
else if (conversation.status === 'error') status.textContent =
|
||||
'Conversation unavailable. Preview and planning actions still work.';
|
||||
else if (!conversation.comments?.length) status.textContent = 'No conversation yet.';
|
||||
else status.textContent = conversation.comments.length +
|
||||
(conversation.comments.length === 1 ? ' message' : ' messages') +
|
||||
(newCount ? ' · ' + newCount + ' new since last review' : '') + '.';
|
||||
};
|
||||
root.renderSearchPreviewReply = (state, detail, preview, document) => {
|
||||
const section = document.querySelector('.search-preview-reply');
|
||||
const input = document.querySelector('#search-preview-reply');
|
||||
const status = document.querySelector('#search-preview-reply-status');
|
||||
const buttons = ['#send-search-preview-reply', '#send-search-preview-reply-next']
|
||||
.map(selector => document.querySelector(selector));
|
||||
section.hidden = detail?.commentable !== true;
|
||||
status.textContent = '';
|
||||
if (section.hidden) {
|
||||
buttons.forEach(button => { button.disabled = true; });
|
||||
return;
|
||||
}
|
||||
input.value = preview.replyDraft();
|
||||
const replying = state.status === 'replying';
|
||||
buttons.forEach(button => {
|
||||
button.disabled = replying || (!input.value.trim() && !preview.hasReplyAttachments?.());
|
||||
});
|
||||
if (replying) status.textContent = 'Saving reply for delivery…';
|
||||
else if (state.status === 'queued') status.textContent = 'Reply queued. It will send when connected.';
|
||||
else if (state.status === 'replied') status.textContent = 'Reply posted.';
|
||||
else if (state.status === 'reply-error') status.textContent =
|
||||
state.error?.message || 'Reply failed. Your draft is safe; retry when ready.';
|
||||
};
|
||||
root.renderSearchPreviewReview = (review, document, escapeHtml) => {
|
||||
const section = document.querySelector('#search-preview-review');
|
||||
const status = document.querySelector('#search-preview-review-status');
|
||||
const files = document.querySelector('#search-preview-files');
|
||||
const retry = document.querySelector('#retry-search-preview-review');
|
||||
section.hidden = !review;
|
||||
retry.hidden = review?.status !== 'error';
|
||||
files.innerHTML = '';
|
||||
if (!review) {
|
||||
status.textContent = '';
|
||||
return;
|
||||
}
|
||||
if (review.status === 'loading') {
|
||||
status.textContent = 'Loading CI and changed files…';
|
||||
return;
|
||||
}
|
||||
if (review.status === 'error') {
|
||||
status.textContent = 'Changes unavailable. This revision has not been marked reviewed.';
|
||||
return;
|
||||
}
|
||||
const data = review.data || {};
|
||||
const changed = Array.isArray(data.files) ? data.files : [];
|
||||
const ci = ({success:'CI passed', failure:'CI failed', error:'CI failed', pending:'CI pending'})[
|
||||
data.ci_state
|
||||
] || 'CI status unavailable';
|
||||
status.textContent = ci + ' · ' + changed.length + ' changed ' +
|
||||
(changed.length === 1 ? 'file.' : 'files.');
|
||||
files.innerHTML = changed.map(file => {
|
||||
const lines = (Array.isArray(file.diff_lines) ? file.diff_lines : []).map(raw => {
|
||||
const line = String(raw);
|
||||
const kind = line.startsWith('@@') ? 'hunk' : line.startsWith('+') ? 'added' :
|
||||
line.startsWith('-') ? 'removed' : 'context';
|
||||
return '<span class="pull-diff-line ' + kind + '">' + escapeHtml(line) + '</span>';
|
||||
}).join('');
|
||||
const diff = file.diff_available
|
||||
? '<pre class="pull-diff">' + lines +
|
||||
(file.diff_truncated ? '<span class="pull-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') + '</pre>'
|
||||
: '<div class="pull-diff-empty">' +
|
||||
(file.diff_binary ? 'Binary file · preview unavailable.' : 'Diff preview unavailable.') + '</div>';
|
||||
return '<article class="search-preview-file"><strong>' + escapeHtml(file.filename || 'Unknown file') +
|
||||
'</strong><span class="small">' + escapeHtml(file.status || 'changed') + ' · +' +
|
||||
Number(file.additions || 0) + ' / −' + Number(file.deletions || 0) + '</span>' + diff + '</article>';
|
||||
}).join('');
|
||||
};
|
||||
root.renderSearchPreviewWorkspaces = (state, detail, preview, document, escapeHtml) => {
|
||||
root.renderSearchPreviewReply(state, detail, preview, document);
|
||||
root.renderSearchPreviewReview(state.review, document, escapeHtml);
|
||||
document.querySelector('[data-search-preview-section="changes"]').hidden = !state.review;
|
||||
};
|
||||
}
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
return function createSearchPreview({ fetchJson, mutate, share, onState }) {
|
||||
return function createSearchPreview({ fetchJson, fetchConversation, fetchReview, mutate, watch, share, postReply, queueReply, prepareReply, afterReply, afterUnwatch, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, onOpened, navigationRoot, onState }) {
|
||||
if (Array.isArray(session)) {
|
||||
getSession = session[0];
|
||||
loadMore = () => session[1].loadMore();
|
||||
onNavigate = session[2];
|
||||
navigationRoot = document;
|
||||
}
|
||||
let generation = 0;
|
||||
let current = null;
|
||||
let mutationRequest = null;
|
||||
let shareRequest = null;
|
||||
let moveRequest = null;
|
||||
let replyRequest = null;
|
||||
let watchRequest = null;
|
||||
let conversation = null;
|
||||
let review = null;
|
||||
let openedRevision = null;
|
||||
|
||||
function sameItem(left, right) {
|
||||
return left && right && left.kind === right.kind && left.repository === right.repository &&
|
||||
Number(left.number) === Number(right.number);
|
||||
}
|
||||
|
||||
async function notifyOpened(item, requestGeneration) {
|
||||
const revision = [item?.kind, item?.repository, item?.number, item?.updated_at].join(':');
|
||||
if (requestGeneration !== generation || openedRevision === revision) return false;
|
||||
await onOpened?.({...item});
|
||||
if (requestGeneration === generation) openedRevision = revision;
|
||||
return true;
|
||||
}
|
||||
|
||||
function replyKey(item, suffix) {
|
||||
return 'stackchain.search-reply.' + [item?.kind, item?.repository, item?.number]
|
||||
.map(value => encodeURIComponent(String(value || ''))).join('.') + '.' + suffix;
|
||||
}
|
||||
|
||||
function stored(key) {
|
||||
try { return storage?.getItem(key) || ''; }
|
||||
catch (_) { return ''; }
|
||||
}
|
||||
|
||||
function save(key, value) {
|
||||
try {
|
||||
if (value) storage?.setItem(key, value);
|
||||
else storage?.removeItem(key);
|
||||
} catch (_) { /* Volatile drafts still remain in the textarea. */ }
|
||||
}
|
||||
|
||||
function navigation(item) {
|
||||
const session = typeof getSession === 'function' ? getSession() : null;
|
||||
const items = Array.isArray(session?.items) ? session.items : [];
|
||||
const index = items.findIndex(candidate => sameItem(candidate, item));
|
||||
if (index < 0) return null;
|
||||
return {
|
||||
position:index + 1,
|
||||
total:items.length,
|
||||
hasPrevious:index > 0,
|
||||
hasNext:index < items.length - 1 || session.more === true,
|
||||
};
|
||||
}
|
||||
|
||||
function publish(state) {
|
||||
if (conversation && !Object.prototype.hasOwnProperty.call(state, 'conversation')) {
|
||||
state = {...state, conversation};
|
||||
}
|
||||
if (review && !Object.prototype.hasOwnProperty.call(state, 'review')) state = {...state, review};
|
||||
const position = navigation(state.item || current);
|
||||
if (navigationRoot) {
|
||||
const bar = navigationRoot.querySelector('.search-preview-navigation');
|
||||
bar.hidden = !position;
|
||||
if (position) {
|
||||
navigationRoot.querySelector('#search-preview-position').textContent = position.position + ' of ' + position.total;
|
||||
navigationRoot.querySelector('#previous-search-result').disabled = !position.hasPrevious;
|
||||
navigationRoot.querySelector('#next-search-result').disabled = !position.hasNext;
|
||||
}
|
||||
}
|
||||
onState(position ? { ...state, navigation:position } : state);
|
||||
if (state.complete && navigationRoot) {
|
||||
navigationRoot.querySelector('#search-preview-status').textContent = 'Search pass complete.';
|
||||
}
|
||||
}
|
||||
|
||||
function run(action, pending, success, detail) {
|
||||
if (mutationRequest) return mutationRequest;
|
||||
onState({ status: pending, item: current, detail });
|
||||
publish({ status: pending, item: current, detail });
|
||||
mutationRequest = mutate(detail, action).then(result => {
|
||||
onState({ status: success, item: current, detail, result });
|
||||
publish({ status: success, item: current, detail, result });
|
||||
return result;
|
||||
}).catch(error => {
|
||||
onState({ status: 'ready', item: current, detail, error });
|
||||
publish({ status: 'ready', item: current, detail, error });
|
||||
throw error;
|
||||
}).finally(() => { mutationRequest = null; });
|
||||
return mutationRequest;
|
||||
}
|
||||
|
||||
return {
|
||||
function loadConversation(detail, requestGeneration, page) {
|
||||
if (typeof fetchConversation !== 'function') return Promise.resolve(null);
|
||||
const previousComments = page && Array.isArray(conversation?.comments)
|
||||
? conversation.comments : [];
|
||||
conversation = {
|
||||
status:'loading', comments:previousComments, olderPage:page ?? null,
|
||||
reviewedAt:detail?.following === true ? detail.reviewed_at : null,
|
||||
};
|
||||
publish({ status:'ready', item:current, detail, conversation });
|
||||
return fetchConversation(detail, page).then(result => {
|
||||
if (requestGeneration !== generation) return result;
|
||||
const incoming = Array.isArray(result?.comments) ? result.comments : [];
|
||||
const liveComments = Array.isArray(conversation?.comments) ? conversation.comments : previousComments;
|
||||
const comments = [...incoming, ...liveComments].filter((comment, index, all) =>
|
||||
all.findIndex(candidate => candidate?.id === comment?.id) === index);
|
||||
conversation = {
|
||||
status:'ready',
|
||||
comments,
|
||||
olderPage:result?.older_page ?? null,
|
||||
reviewedAt:detail?.following === true ? detail.reviewed_at : null,
|
||||
};
|
||||
publish({ status:'ready', item:current, detail:current, conversation });
|
||||
return result;
|
||||
}).catch(error => {
|
||||
if (requestGeneration === generation) {
|
||||
conversation = {
|
||||
status:'error', comments:previousComments,
|
||||
olderPage:page ?? conversation?.olderPage ?? null, error,
|
||||
reviewedAt:detail?.following === true ? detail.reviewed_at : null,
|
||||
};
|
||||
publish({ status:'ready', item:current, detail:current, conversation });
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
function loadReview(detail, requestGeneration) {
|
||||
if (!(detail?.following === true && detail?.kind === 'pull' && typeof fetchReview === 'function')) {
|
||||
review = null;
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
review = {status:'loading', data:null};
|
||||
publish({status:'ready', item:current, detail:current, conversation, review});
|
||||
return fetchReview(detail).then(data => {
|
||||
if (requestGeneration === generation) {
|
||||
review = {status:'ready', data};
|
||||
publish({status:'ready', item:current, detail:current, conversation, review});
|
||||
}
|
||||
return data;
|
||||
}).catch(error => {
|
||||
if (requestGeneration === generation) {
|
||||
review = {status:'error', data:null, error};
|
||||
publish({status:'ready', item:current, detail:current, conversation, review});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
async function acknowledgeWhenContextReady(requestGeneration) {
|
||||
const conversationReady = typeof fetchConversation !== 'function' || conversation?.status === 'ready';
|
||||
const reviewRequired = current?.following === true && current?.kind === 'pull' &&
|
||||
typeof fetchReview === 'function';
|
||||
if (conversationReady && (!reviewRequired || review?.status === 'ready')) {
|
||||
await notifyOpened(current, requestGeneration);
|
||||
}
|
||||
}
|
||||
|
||||
const api = {
|
||||
hasReplyAttachments() {
|
||||
return Boolean(hasAttachments?.());
|
||||
},
|
||||
open(item) {
|
||||
if (current && !sameItem(current, item) && hasAttachments?.()) clearAttachments?.();
|
||||
generation += 1;
|
||||
const requestGeneration = generation;
|
||||
current = { ...item };
|
||||
onState({ status: 'loading', item: current });
|
||||
return fetchJson(current).then(detail => {
|
||||
conversation = null;
|
||||
review = null;
|
||||
openedRevision = null;
|
||||
publish({ status: 'loading', item: current });
|
||||
return fetchJson(current).then(async detail => {
|
||||
if (requestGeneration === generation) {
|
||||
current = { ...current, ...detail };
|
||||
onState({ status: 'ready', item: current, detail });
|
||||
if (typeof fetchConversation === 'function') {
|
||||
const context = loadConversation(current, requestGeneration);
|
||||
if (current.following === true) {
|
||||
const reviewContext = loadReview(current, requestGeneration);
|
||||
await Promise.all([context, reviewContext]);
|
||||
await acknowledgeWhenContextReady(requestGeneration);
|
||||
} else {
|
||||
await notifyOpened(current, requestGeneration);
|
||||
}
|
||||
} else {
|
||||
publish({ status: 'ready', item: current, detail });
|
||||
await notifyOpened(current, requestGeneration);
|
||||
}
|
||||
}
|
||||
return detail;
|
||||
}).catch(error => {
|
||||
if (requestGeneration === generation) {
|
||||
onState({ status: 'error', item: current, error });
|
||||
publish({ status: 'error', item: current, error });
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
close() {
|
||||
clearAttachments?.();
|
||||
generation += 1;
|
||||
current = null;
|
||||
onState({ status: 'closed' });
|
||||
},
|
||||
previous() {
|
||||
const session = typeof getSession === 'function' ? getSession() : null;
|
||||
const items = Array.isArray(session?.items) ? session.items : [];
|
||||
const index = items.findIndex(item => sameItem(item, current));
|
||||
if (index <= 0) return Promise.resolve(current);
|
||||
const item = items[index - 1];
|
||||
if (typeof onNavigate === 'function') onNavigate(item);
|
||||
return api.open(item);
|
||||
},
|
||||
next() {
|
||||
if (moveRequest) return moveRequest;
|
||||
moveRequest = (async () => {
|
||||
let session = typeof getSession === 'function' ? getSession() : null;
|
||||
let items = Array.isArray(session?.items) ? session.items : [];
|
||||
let index = items.findIndex(item => sameItem(item, current));
|
||||
if (index < 0) return current;
|
||||
if (index === items.length - 1 && session?.more === true && typeof loadMore === 'function') {
|
||||
await loadMore();
|
||||
session = getSession();
|
||||
items = Array.isArray(session?.items) ? session.items : [];
|
||||
index = items.findIndex(item => sameItem(item, current));
|
||||
}
|
||||
if (index >= 0 && index < items.length - 1) {
|
||||
const item = items[index + 1];
|
||||
if (typeof onNavigate === 'function') onNavigate(item);
|
||||
return api.open(item);
|
||||
}
|
||||
publish({ status:'ready', item:current, detail:current, complete:true });
|
||||
return current;
|
||||
})().finally(() => { moveRequest = null; });
|
||||
return moveRequest;
|
||||
},
|
||||
async retryConversation() {
|
||||
if (!current) return null;
|
||||
const requestGeneration = generation;
|
||||
const result = await loadConversation(current, requestGeneration);
|
||||
if (current?.following === true) await acknowledgeWhenContextReady(requestGeneration);
|
||||
return result;
|
||||
},
|
||||
async retryReview() {
|
||||
if (!current) return null;
|
||||
const requestGeneration = generation;
|
||||
const result = await loadReview(current, requestGeneration);
|
||||
await acknowledgeWhenContextReady(requestGeneration);
|
||||
return result;
|
||||
},
|
||||
loadOlderConversation() {
|
||||
if (!current || !conversation?.olderPage) return Promise.resolve(null);
|
||||
return loadConversation(current, generation, conversation.olderPage);
|
||||
},
|
||||
saveReplyDraft(body) {
|
||||
if (!current) return '';
|
||||
const key = replyKey(current, 'draft');
|
||||
const next = String(body || '');
|
||||
if (stored(key) !== next) save(replyKey(current, 'operation'), '');
|
||||
save(key, next);
|
||||
return next;
|
||||
},
|
||||
replyDraft() {
|
||||
return current ? stored(replyKey(current, 'draft')) : '';
|
||||
},
|
||||
reply({ advance = false } = {}) {
|
||||
if (replyRequest) return replyRequest;
|
||||
if (!current || (typeof postReply !== 'function' && typeof queueReply !== 'function')) {
|
||||
return Promise.reject(new Error('Replying is unavailable.'));
|
||||
}
|
||||
const body = api.replyDraft().trim();
|
||||
if (!body && !hasAttachments?.()) return Promise.reject(new Error('Write a reply or add a photo first.'));
|
||||
const item = { ...current };
|
||||
const operationKey = replyKey(item, 'operation');
|
||||
let operationId = stored(operationKey);
|
||||
if (!operationId) {
|
||||
operationId = String(createOperationId?.() || Date.now()).slice(0, 128);
|
||||
save(operationKey, operationId);
|
||||
}
|
||||
publish({ status:'replying', item:current, detail:current, conversation });
|
||||
replyRequest = (typeof queueReply === 'function' ?
|
||||
Promise.resolve(queueReply(item, body, operationId)) :
|
||||
Promise.resolve(typeof prepareReply === 'function' ? prepareReply(item, body) : body)
|
||||
.then(preparedBody => {
|
||||
if (!String(preparedBody || '').trim()) throw new Error('Write a reply or add a photo first.');
|
||||
return postReply(item, preparedBody, operationId);
|
||||
})
|
||||
).then(async comment => {
|
||||
await afterReply?.(item);
|
||||
if (!comment?.queued) {
|
||||
const comments = [...(conversation?.comments || [])];
|
||||
if (!comments.some(candidate => candidate?.id === comment?.id)) comments.push(comment);
|
||||
conversation = { status:'ready', comments, olderPage:conversation?.olderPage ?? null };
|
||||
}
|
||||
save(replyKey(item, 'draft'), '');
|
||||
save(operationKey, '');
|
||||
clearAttachments?.();
|
||||
publish({ status:comment?.queued ? 'queued' : 'replied', item:current, detail:current, conversation, result:comment });
|
||||
return advance ? api.next().then(() => comment) : comment;
|
||||
}).catch(error => {
|
||||
publish({ status:'reply-error', item:current, detail:current, conversation, error });
|
||||
throw error;
|
||||
}).finally(() => { replyRequest = null; });
|
||||
return replyRequest;
|
||||
},
|
||||
share(url) {
|
||||
if (shareRequest) return shareRequest;
|
||||
if (!current || typeof share !== 'function') return Promise.reject(new Error('Sharing is unavailable.'));
|
||||
const detail = current;
|
||||
onState({ status:'sharing', item:current, detail });
|
||||
publish({ status:'sharing', item:current, detail });
|
||||
shareRequest = share(url).then(result => {
|
||||
onState({ status:result, item:current, detail });
|
||||
publish({ status:result, item:current, detail });
|
||||
return result;
|
||||
}).catch(error => {
|
||||
onState({ status:error?.name === 'AbortError' ? 'share-canceled' : 'share-error', item:current, detail, error });
|
||||
publish({ status:error?.name === 'AbortError' ? 'share-canceled' : 'share-error', item:current, detail, error });
|
||||
throw error;
|
||||
}).finally(() => { shareRequest = null; });
|
||||
return shareRequest;
|
||||
@@ -66,6 +548,51 @@
|
||||
reopen(detail) {
|
||||
return run('reopen', 'reopening', 'reopened', detail);
|
||||
},
|
||||
setWatching(watching) {
|
||||
if (watchRequest) return watchRequest;
|
||||
if (!current || typeof watch !== 'function') {
|
||||
return Promise.reject(new Error('Watching is unavailable.'));
|
||||
}
|
||||
const detail = current;
|
||||
publish({ status:watching ? 'watching' : 'unwatching', item:current, detail });
|
||||
watchRequest = watch(detail, watching).then(async result => {
|
||||
current = { ...current, watching:result?.watching === true };
|
||||
const status = result?.following_synced === false
|
||||
? 'watch-partial' : (watching ? 'watched' : 'unwatched');
|
||||
publish({ status, item:current, detail:current, result });
|
||||
if (!watching && result?.watching === false && typeof afterUnwatch === 'function') {
|
||||
const next = await afterUnwatch({...current});
|
||||
if (next) {
|
||||
onNavigate?.(next);
|
||||
await api.open(next);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}).catch(error => {
|
||||
publish({ status:'watch-error', item:current, detail, error });
|
||||
throw error;
|
||||
}).finally(() => { watchRequest = null; });
|
||||
return watchRequest;
|
||||
},
|
||||
};
|
||||
if (navigationRoot) {
|
||||
navigationRoot.querySelector('#previous-search-result').addEventListener('click', () => api.previous().catch(() => {}));
|
||||
navigationRoot.querySelector('#next-search-result').addEventListener('click', () => api.next().catch(() => {}));
|
||||
navigationRoot.querySelector('#retry-search-preview-conversation').addEventListener('click', () => api.retryConversation());
|
||||
navigationRoot.querySelector('#retry-search-preview-review').addEventListener('click', () => api.retryReview());
|
||||
navigationRoot.querySelector('#load-older-search-preview-comments').addEventListener('click', () => api.loadOlderConversation());
|
||||
const reply = navigationRoot.querySelector('#search-preview-reply');
|
||||
reply?.addEventListener('input', event => {
|
||||
api.saveReplyDraft(event.target.value);
|
||||
const disabled = !event.target.value.trim();
|
||||
navigationRoot.querySelector('#send-search-preview-reply').disabled = disabled;
|
||||
navigationRoot.querySelector('#send-search-preview-reply-next').disabled = disabled;
|
||||
});
|
||||
reply?.addEventListener('focus', event => event.target.scrollIntoView({ block:'center', behavior:'smooth' }));
|
||||
navigationRoot.querySelector('#send-search-preview-reply')?.addEventListener('click', () => api.reply().catch(() => {}));
|
||||
navigationRoot.querySelector('#send-search-preview-reply-next')?.addEventListener('click', () =>
|
||||
api.reply({ advance:true }).catch(() => {}));
|
||||
}
|
||||
return api;
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
(function (root, factory) {
|
||||
const createSearchReplyDraftStore = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = createSearchReplyDraftStore;
|
||||
else root.createSearchReplyDraftStore = createSearchReplyDraftStore;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
const dbName = 'stackchain-search-reply-drafts-v1';
|
||||
const storeName = 'drafts';
|
||||
|
||||
function requestResult(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result ?? null);
|
||||
request.onerror = () => reject(request.error || new Error('Search reply draft storage failed.'));
|
||||
});
|
||||
}
|
||||
|
||||
function createTransaction(indexedDB) {
|
||||
if (!indexedDB) return null;
|
||||
let databasePromise;
|
||||
function database() {
|
||||
if (!databasePromise) databasePromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, 1);
|
||||
request.onupgradeneeded = () => {
|
||||
if (!request.result.objectStoreNames.contains(storeName)) {
|
||||
request.result.createObjectStore(storeName, { keyPath:'id' });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('Search reply draft storage is unavailable.'));
|
||||
});
|
||||
return databasePromise;
|
||||
}
|
||||
return async (operation, key, value) => {
|
||||
const db = await database();
|
||||
const transaction = db.transaction(storeName, operation === 'get' ? 'readonly' : 'readwrite');
|
||||
const records = transaction.objectStore(storeName);
|
||||
if (operation === 'put') return requestResult(records.put(value));
|
||||
if (operation === 'delete') return requestResult(records.delete(key));
|
||||
return requestResult(records.get(key));
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedTarget(target) {
|
||||
const kind = String(target?.kind || '');
|
||||
const repository = String(target?.repository || '');
|
||||
const number = Number(target?.number || 0);
|
||||
if (!['issue', 'pull'].includes(kind) || !repository || !Number.isInteger(number) || number < 1) {
|
||||
throw new Error('Choose a Search result before saving photo evidence.');
|
||||
}
|
||||
return { kind, repository, number };
|
||||
}
|
||||
|
||||
function attachment(value) {
|
||||
const blob = value?.blob;
|
||||
const data = String(value?.data || '');
|
||||
if (!blob && !data) throw new Error('A saved Search photo is unavailable.');
|
||||
const note = String(value?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
||||
const operationId = String(value?.operationId || '').slice(0, 128);
|
||||
const markdown = String(value?.confirmed?.markdown || '');
|
||||
return {
|
||||
filename:String(value?.filename || ''),
|
||||
contentType:String(value?.contentType || ''),
|
||||
...(blob ? { blob } : { data }),
|
||||
...(note ? { note } : {}),
|
||||
...(operationId ? { operationId } : {}),
|
||||
...(markdown ? { confirmed:{ markdown } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return function createSearchReplyDraftStore({
|
||||
indexedDB = globalThis.indexedDB,
|
||||
transaction,
|
||||
getOwnerLogin = () => '',
|
||||
} = {}) {
|
||||
const transact = transaction || createTransaction(indexedDB);
|
||||
|
||||
function identity(target) {
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before saving Search photos.');
|
||||
const normalized = normalizedTarget(target);
|
||||
return {
|
||||
ownerLogin,
|
||||
target:normalized,
|
||||
id:[ownerLogin, normalized.kind, normalized.repository, normalized.number]
|
||||
.map(value => encodeURIComponent(String(value))).join(':'),
|
||||
};
|
||||
}
|
||||
|
||||
async function save(target, values) {
|
||||
if (!transact) throw new Error('Photo draft storage needs IndexedDB. Your current photos are still here.');
|
||||
const { id, ownerLogin, target:normalized } = identity(target);
|
||||
const list = (Array.isArray(values) ? values : [values]).filter(Boolean).slice(0, 5).map(attachment);
|
||||
if (!list.length) {
|
||||
await transact('delete', id);
|
||||
return null;
|
||||
}
|
||||
await transact('put', id, { id, version:1, ownerLogin, ...normalized, attachments:list });
|
||||
return list;
|
||||
}
|
||||
|
||||
async function load(target) {
|
||||
if (!transact) return null;
|
||||
const { id, ownerLogin, target:normalized } = identity(target);
|
||||
const record = await transact('get', id);
|
||||
if (record?.version !== 1 || record.ownerLogin !== ownerLogin || record.kind !== normalized.kind ||
|
||||
record.repository !== normalized.repository || Number(record.number) !== normalized.number ||
|
||||
!Array.isArray(record.attachments) || !record.attachments.length) return null;
|
||||
return record.attachments.slice(0, 5).map(attachment);
|
||||
}
|
||||
|
||||
async function remove(target) {
|
||||
if (!transact) return false;
|
||||
const { id } = identity(target);
|
||||
await transact('delete', id);
|
||||
return true;
|
||||
}
|
||||
|
||||
return { save, load, remove };
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
function createSearchWeekPlan({week,claim,accept=item=>item,identity,maxItems=5}={}) {
|
||||
let running=null;
|
||||
const eligible=detail=>Boolean(detail&&detail.kind==='issue'&&detail.state==='open'&&
|
||||
(detail.claimable||detail.assigned_to_me));
|
||||
const loadDay=date=>{
|
||||
const value=week.day(date),estimates=value.estimates||{};
|
||||
return {...value,planned_minutes:(value.ids||[]).reduce((total,id)=>total+(Number(estimates[id])||0),0)};
|
||||
};
|
||||
async function preview(detail) {
|
||||
if(!eligible(detail))return {eligible:false,days:[]};
|
||||
await week.load();
|
||||
const id=identity(detail),existing=week.placement(id);
|
||||
return {eligible:true,existing,days:week.dates().map(item=>({...item,...loadDay(item.date)}))};
|
||||
}
|
||||
function plan(detail,options={}) {
|
||||
if(running)return running;
|
||||
const perform=async()=>{
|
||||
if(!eligible(detail))return {status:'ineligible'};
|
||||
const estimate=Number(options.estimate);
|
||||
if(!Number.isFinite(estimate)||estimate<=0)return {status:'estimate-required'};
|
||||
await week.load();
|
||||
if(!week.dates().some(item=>item.date===options.date))return {status:'date-required'};
|
||||
const originalId=identity(detail),existing=week.placement(originalId);
|
||||
if(existing&&existing.date!==options.date&&!options.move)
|
||||
return {status:'already-planned',date:existing.date,estimate:existing.estimate};
|
||||
const destination=loadDay(options.date);
|
||||
const alreadyThere=(destination.ids||[]).includes(originalId);
|
||||
if(!alreadyThere&&(destination.ids||[]).length>=maxItems)return {status:'day-full',limit:maxItems};
|
||||
const previous=alreadyThere?(Number(destination.estimates?.[originalId])||0):0;
|
||||
const plannedMinutes=destination.planned_minutes-previous+estimate;
|
||||
if(Number(destination.capacity_minutes)>0&&plannedMinutes>Number(destination.capacity_minutes)&&!options.confirmOverload)
|
||||
return {status:'overload-confirmation-required',planned_minutes:plannedMinutes,
|
||||
capacity_minutes:Number(destination.capacity_minutes)};
|
||||
let item=detail,assigned=false;
|
||||
if(detail.claimable){item=await claim(detail);assigned=true;}
|
||||
item=accept(item)||item;
|
||||
const id=identity(item);
|
||||
if(!week.place(id,options.date,estimate,{move:Boolean(options.move)}))
|
||||
return {status:assigned?'assigned-not-planned':'not-planned',item};
|
||||
week.rememberPendingItem?.(id,item);
|
||||
try {
|
||||
await week.flush();
|
||||
return {status:'planned',date:options.date,estimate,item};
|
||||
} catch(_error) {
|
||||
return {status:'planned-pending',date:options.date,estimate,item};
|
||||
}
|
||||
};
|
||||
running=perform().finally(()=>{running=null;});
|
||||
return running;
|
||||
}
|
||||
return {eligible,preview,plan,pending:()=>Boolean(running)};
|
||||
}
|
||||
function createSearchWeekPlanUI({planner,document,window,getDetail,onPlanned=()=>{},announce=()=>{},escapeHtml,escapeAttribute}={}) {
|
||||
const qs=selector=>document.querySelector(selector);
|
||||
let trigger=null,detail=null,preview=null,overload=false;
|
||||
function close(navigate=false) {
|
||||
if(navigate&&window.history.state?.searchWeekPlan){window.history.back();return;}
|
||||
qs('#search-week-plan-sheet').hidden=true;
|
||||
detail=null;preview=null;overload=false;trigger?.focus?.();trigger=null;
|
||||
}
|
||||
function selectDate(date) {
|
||||
qs('#search-week-plan-days').querySelectorAll('[data-search-week-date]').forEach(button=>
|
||||
button.setAttribute('aria-pressed',String(button.dataset.searchWeekDate===date)));
|
||||
qs('#search-week-plan-days').dataset.selected=date;
|
||||
overload=false;qs('#confirm-search-week-plan').textContent='Plan';
|
||||
}
|
||||
async function open(button) {
|
||||
const selected=getDetail();
|
||||
if(!selected||planner.pending())return false;
|
||||
trigger=button;detail=selected;overload=false;
|
||||
const sheet=qs('#search-week-plan-sheet'),status=qs('#search-week-plan-status');
|
||||
sheet.hidden=false;status.textContent='Loading Week Ahead…';qs('#confirm-search-week-plan').disabled=true;
|
||||
window.history.pushState({...window.history.state,searchWeekPlan:true},'',window.location.href);
|
||||
qs('#search-week-plan-copy').textContent=detail.title||'Untitled issue';
|
||||
try {
|
||||
preview=await planner.preview(detail);
|
||||
const existing=preview.existing;
|
||||
qs('#search-week-plan-days').innerHTML=preview.days.map((day,index)=>
|
||||
'<button type="button" data-search-week-date="'+escapeAttribute(day.date)+'" aria-pressed="'+String(index===0)+'"><strong>'+escapeHtml(day.label)+
|
||||
'</strong><small>'+escapeHtml(day.planned_minutes+(day.capacity_minutes?' of '+day.capacity_minutes:'')+' min · '+day.ids.length+' planned')+'</small></button>').join('');
|
||||
qs('#search-week-plan-days').querySelectorAll('[data-search-week-date]').forEach(dayButton=>
|
||||
dayButton.addEventListener('click',()=>selectDate(dayButton.dataset.searchWeekDate)));
|
||||
selectDate(existing?.date||preview.days[0].date);
|
||||
qs('#search-week-plan-estimate').value=existing?.estimate||'';
|
||||
status.textContent=existing?'Already planned for '+existing.date+'. Choose another day to move it.':'Choose a day and add an estimate.';
|
||||
qs('#confirm-search-week-plan').disabled=false;qs('#search-week-plan-estimate').focus();return true;
|
||||
} catch(error) {status.textContent=(error.message||'Week Ahead is unavailable.')+' Retry when connected.';return false;}
|
||||
}
|
||||
async function confirm() {
|
||||
if(!detail||planner.pending())return;
|
||||
const date=qs('#search-week-plan-days').dataset.selected,estimate=Number(qs('#search-week-plan-estimate').value);
|
||||
const existing=preview?.existing,move=Boolean(existing&&existing.date!==date);
|
||||
const button=qs('#confirm-search-week-plan'),status=qs('#search-week-plan-status');button.disabled=true;
|
||||
const outcome=await planner.plan(detail,{date,estimate,move,confirmOverload:overload});
|
||||
if(outcome.status==='overload-confirmation-required'){
|
||||
overload=true;button.textContent='Confirm over capacity';
|
||||
status.textContent=outcome.planned_minutes+' min exceeds '+outcome.capacity_minutes+' min capacity. Confirm to plan anyway.';
|
||||
} else if(outcome.status==='estimate-required')status.textContent='Enter an estimate in minutes.';
|
||||
else if(outcome.status==='day-full')status.textContent='That day already has '+outcome.limit+' items. Choose another day.';
|
||||
else if(outcome.status==='already-planned')status.textContent='Already planned for '+outcome.date+'. Choose Move to change the day.';
|
||||
else if(outcome.status==='assigned-not-planned')status.textContent='Assigned, not planned. Find it in My Work and retry.';
|
||||
else if(outcome.status==='planned-pending'){announce('Planned for '+date+' · sync pending.');await onPlanned(outcome.item||detail,true,outcome);close(true);}
|
||||
else if(outcome.status==='planned'){announce('Planned for '+date+'.');await onPlanned(outcome.item||detail,false,outcome);close(true);}
|
||||
else status.textContent='Could not plan this issue. Retry.';
|
||||
button.disabled=false;
|
||||
}
|
||||
qs('#plan-search-result').addEventListener('click',event=>open(event.currentTarget));
|
||||
qs('#cancel-search-week-plan').addEventListener('click',()=>close(true));
|
||||
qs('#confirm-search-week-plan').addEventListener('click',confirm);
|
||||
window.addEventListener('popstate',()=>{if(!qs('#search-week-plan-sheet').hidden)close();});
|
||||
document.addEventListener('keydown',event=>{
|
||||
if(event.key==='Escape'&&!qs('#search-week-plan-sheet').hidden){event.preventDefault();close(true);}
|
||||
});
|
||||
return {open,close,confirm};
|
||||
}
|
||||
if(typeof module!=='undefined'&&module.exports){module.exports=createSearchWeekPlan;module.exports.UI=createSearchWeekPlanUI;}
|
||||
+116
-6
@@ -1,7 +1,7 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
else root.attachSecurityCenter = boundary => factory({ root, boundary });
|
||||
})(typeof window !== 'undefined' ? window : this, function attachSecurityCenter({ root, boundary }) {
|
||||
else root.attachSecurityCenter = (boundary, options = {}) => factory({ root, boundary, ...options });
|
||||
})(typeof window !== 'undefined' ? window : this, function attachSecurityCenter({ root, boundary, onOpen = () => {}, onClose = () => {} }) {
|
||||
const devicesButton = root.document.getElementById('active-devices');
|
||||
const devicesSheet = root.document.getElementById('active-devices-sheet');
|
||||
const devicesList = root.document.getElementById('active-devices-list');
|
||||
@@ -12,7 +12,99 @@
|
||||
const activityList = root.document.getElementById('security-activity-list');
|
||||
const activityStatus = root.document.getElementById('security-activity-status');
|
||||
const loadMoreActivity = root.document.getElementById('load-more-security-activity');
|
||||
const sectionButtons = Object.fromEntries(['activity', 'devices', 'passkeys'].map(section => [
|
||||
section, root.document.getElementById(`security-section-${section}`),
|
||||
]));
|
||||
const sections = Object.fromEntries(['activity', 'devices', 'passkeys'].map(section => [
|
||||
section, root.document.getElementById(`security-${section}-section`),
|
||||
]));
|
||||
let activityCursor = null;
|
||||
let backgroundInert = null;
|
||||
|
||||
const backgroundElements = ['header', 'main', '#mobile-task-dock']
|
||||
.map(selector => root.document.querySelector?.(selector))
|
||||
.filter(Boolean);
|
||||
const focusableControls = () => [...(devicesSheet?.querySelectorAll?.(
|
||||
'button:not([disabled]), select:not([disabled]), input:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'
|
||||
) || [])].filter(control => !control.hidden && !control.disabled);
|
||||
const containBackground = () => {
|
||||
if (!backgroundInert) {
|
||||
backgroundInert = new Map(backgroundElements.map(element => [element, element.inert]));
|
||||
}
|
||||
backgroundInert.forEach((_wasInert, element) => { element.inert = true; });
|
||||
};
|
||||
const releaseBackground = () => {
|
||||
if (!backgroundInert) return;
|
||||
backgroundInert.forEach((wasInert, element) => { element.inert = wasInert; });
|
||||
backgroundInert = null;
|
||||
};
|
||||
const finishClose = () => {
|
||||
devicesSheet.hidden = true;
|
||||
releaseBackground();
|
||||
devicesButton?.focus();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const navigate = (section, { history = true } = {}) => {
|
||||
if (!sections[section]) section = 'activity';
|
||||
Object.entries(sectionButtons).forEach(([name, button]) => {
|
||||
if (!button) return;
|
||||
if (name === section) button.setAttribute('aria-current', 'page');
|
||||
else button.removeAttribute('aria-current');
|
||||
});
|
||||
sections[section]?.scrollIntoView?.({ block: 'start' });
|
||||
if (history && root.history?.pushState) {
|
||||
root.history.pushState(
|
||||
{ ...(root.history.state || {}), stackchainSecuritySection: section },
|
||||
'',
|
||||
`${root.location.pathname}${root.location.search || ''}#security/${section}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Object.entries(sectionButtons).forEach(([section, button]) => {
|
||||
button?.addEventListener('click', () => navigate(section));
|
||||
});
|
||||
root.document.getElementById('close-active-devices')?.addEventListener('click', () => {
|
||||
if (root.history?.state?.stackchainSecuritySection) root.history.back();
|
||||
});
|
||||
const handleRouteChange = section => {
|
||||
if (section && !devicesSheet.hidden) {
|
||||
containBackground();
|
||||
navigate(section, { history: false });
|
||||
return;
|
||||
}
|
||||
if (!devicesSheet.hidden) {
|
||||
finishClose();
|
||||
}
|
||||
};
|
||||
root.addEventListener?.('popstate', event => {
|
||||
handleRouteChange(event.state?.stackchainSecuritySection);
|
||||
});
|
||||
root.addEventListener?.('hashchange', () => {
|
||||
handleRouteChange(root.history?.state?.stackchainSecuritySection);
|
||||
});
|
||||
root.document.addEventListener?.('keydown', event => {
|
||||
if (devicesSheet.hidden) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
if (root.history?.state?.stackchainSecuritySection) root.history.back();
|
||||
else finishClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const controls = focusableControls();
|
||||
if (!controls.length) return;
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && event.target === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && event.target === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
|
||||
const renderDevices = async () => {
|
||||
devicesStatus.textContent = 'Loading active devices…';
|
||||
@@ -39,8 +131,11 @@
|
||||
revoke.type = 'button';
|
||||
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
|
||||
revoke.addEventListener('click', async () => {
|
||||
if (device.current) await boundary.signOut();
|
||||
else if (await boundary.revokeActiveDevice(device)) await renderDevices();
|
||||
if (device.current && boundary.requestSignOut) await boundary.requestSignOut(revoke);
|
||||
else if (device.current) await boundary.signOut();
|
||||
else if (await boundary.revokeActiveDevice(device)) {
|
||||
await Promise.all([renderDevices(), renderSecurityActivity()]);
|
||||
}
|
||||
});
|
||||
row.append(details, revoke);
|
||||
devicesList.append(row);
|
||||
@@ -82,6 +177,7 @@
|
||||
: 'Passkey removed.';
|
||||
await renderPasskeys();
|
||||
if (outcome.session_revoked) await renderDevices();
|
||||
await renderSecurityActivity();
|
||||
} else {
|
||||
remove.disabled = false;
|
||||
passkeysStatus.textContent = `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`;
|
||||
@@ -122,7 +218,11 @@
|
||||
const details = root.document.createElement('span');
|
||||
details.className = 'small muted';
|
||||
details.textContent = formatted.detail;
|
||||
row.append(title, details);
|
||||
const action = root.document.createElement('button');
|
||||
action.type = 'button';
|
||||
action.textContent = 'Review devices';
|
||||
action.addEventListener('click', () => navigate('devices'));
|
||||
row.append(title, details, action);
|
||||
activityList.append(row);
|
||||
});
|
||||
}
|
||||
@@ -150,6 +250,13 @@
|
||||
.filter(value => typeof value === 'string' && value).join(' · ');
|
||||
details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`;
|
||||
row.append(title, details);
|
||||
if (event.kind === 'passkey_counter_anomaly') {
|
||||
const action = root.document.createElement('button');
|
||||
action.type = 'button';
|
||||
action.textContent = 'Review passkeys';
|
||||
action.addEventListener('click', () => navigate('passkeys'));
|
||||
row.append(action);
|
||||
}
|
||||
activityList.append(row);
|
||||
});
|
||||
activityCursor = page.next_cursor;
|
||||
@@ -167,8 +274,11 @@
|
||||
|
||||
const open = () => {
|
||||
if (!devicesSheet) return;
|
||||
onOpen();
|
||||
containBackground();
|
||||
devicesSheet.hidden = false;
|
||||
root.document.getElementById('close-active-devices')?.focus();
|
||||
navigate('activity', { history: root.history?.state?.stackchainSecuritySection !== 'activity' });
|
||||
return Promise.all([renderDevices(), renderPasskeys(), renderSecurityActivity()]);
|
||||
};
|
||||
devicesButton?.addEventListener('click', open);
|
||||
@@ -186,5 +296,5 @@
|
||||
enrollPasskey.disabled = false;
|
||||
}
|
||||
});
|
||||
return { open, renderDevices, renderPasskeys, renderSecurityActivity };
|
||||
return { open, navigate, renderDevices, renderPasskeys, renderSecurityActivity };
|
||||
});
|
||||
+490
-44
@@ -1,10 +1,151 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/private-data-registry.js');
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v101';
|
||||
const CACHE = 'stackchain-dashboard-shell-v138';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
const PUSH_ACTION_TIMEOUT_MS = self.__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS || 8000;
|
||||
const TODAY_ACTION_TTL_MS = 2 * 60 * 1000;
|
||||
const PRIVATE_DATABASES = self.stackchainPrivateDatabases;
|
||||
|
||||
function createAppBadgePreference() {
|
||||
const dbName = 'stackchain-app-badge-preference-v1';
|
||||
const storeName = 'preferences';
|
||||
const open = () => new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('App badge preference unavailable.'));
|
||||
});
|
||||
return {
|
||||
async get() {
|
||||
const database = await open();
|
||||
const enabled = await new Promise((resolve, reject) => {
|
||||
const request = database.transaction(storeName, 'readonly').objectStore(storeName).get('enabled');
|
||||
request.onsuccess = () => resolve(request.result === true);
|
||||
request.onerror = () => reject(request.error || new Error('App badge preference unavailable.'));
|
||||
});
|
||||
database.close();
|
||||
return enabled;
|
||||
},
|
||||
async set(enabled) {
|
||||
const database = await open();
|
||||
await new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(storeName, 'readwrite');
|
||||
transaction.objectStore(storeName).put(enabled === true, 'enabled');
|
||||
transaction.oncomplete = resolve;
|
||||
transaction.onerror = () => reject(transaction.error || new Error('App badge preference could not be saved.'));
|
||||
transaction.onabort = transaction.onerror;
|
||||
});
|
||||
database.close();
|
||||
},
|
||||
async getCounts() {
|
||||
const database = await open();
|
||||
const counts = {};
|
||||
for (const channel of ['updates', 'following']) {
|
||||
counts[channel] = await new Promise((resolve, reject) => {
|
||||
const request = database.transaction(storeName, 'readonly').objectStore(storeName).get('count:' + channel);
|
||||
request.onsuccess = () => resolve(Number.isSafeInteger(request.result) ? request.result : 0);
|
||||
request.onerror = () => reject(request.error || new Error('App badge count unavailable.'));
|
||||
});
|
||||
}
|
||||
database.close();
|
||||
return counts;
|
||||
},
|
||||
async setCount(channel, count) {
|
||||
const database = await open();
|
||||
await new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(storeName, 'readwrite');
|
||||
transaction.objectStore(storeName).put(count, 'count:' + channel);
|
||||
transaction.oncomplete = resolve;
|
||||
transaction.onerror = () => reject(transaction.error || new Error('App badge count could not be saved.'));
|
||||
transaction.onabort = transaction.onerror;
|
||||
});
|
||||
database.close();
|
||||
},
|
||||
async clearCounts() {
|
||||
await this.setCount('updates', 0);
|
||||
await this.setCount('following', 0);
|
||||
},
|
||||
};
|
||||
}
|
||||
const appBadgePreference = self.__STACKCHAIN_APP_BADGE_PREFERENCE || createAppBadgePreference();
|
||||
let renderedBackgroundBadgeCount = null;
|
||||
|
||||
async function reconcileBackgroundAppBadge(channel, count) {
|
||||
if (!['updates', 'following'].includes(channel)
|
||||
|| !Number.isSafeInteger(count) || count < 0 || count > 9999
|
||||
|| typeof self.registration.setAppBadge !== 'function'
|
||||
|| typeof self.registration.clearAppBadge !== 'function') return false;
|
||||
let enabled = false;
|
||||
try { enabled = await appBadgePreference.get(); } catch (_error) { return false; }
|
||||
if (!enabled) return false;
|
||||
try {
|
||||
await appBadgePreference.setCount(channel, count);
|
||||
const counts = await appBadgePreference.getCounts();
|
||||
const total = Math.min(9999, counts.updates + counts.following);
|
||||
if (renderedBackgroundBadgeCount === total) return false;
|
||||
if (total > 0) await self.registration.setAppBadge(total);
|
||||
else await self.registration.clearAppBadge();
|
||||
renderedBackgroundBadgeCount = total;
|
||||
return true;
|
||||
} catch (_error) { return false; }
|
||||
}
|
||||
|
||||
function createTodayActionStore() {
|
||||
const dbName = 'stackchain-today-action-mailbox-v1';
|
||||
const storeName = 'commands';
|
||||
const open = () => new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore(storeName, {keyPath:'clientId'});
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('Today action mailbox unavailable.'));
|
||||
});
|
||||
return {
|
||||
async put(clientId, command) {
|
||||
const database = await open();
|
||||
await new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(storeName, 'readwrite');
|
||||
transaction.objectStore(storeName).put({...command, clientId});
|
||||
transaction.oncomplete = resolve;
|
||||
transaction.onerror = () => reject(transaction.error || new Error('Today action could not be saved.'));
|
||||
transaction.onabort = transaction.onerror;
|
||||
});
|
||||
database.close();
|
||||
},
|
||||
async claim(clientId, now) {
|
||||
const database = await open();
|
||||
let command = null;
|
||||
await new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(storeName, 'readwrite');
|
||||
const store = transaction.objectStore(storeName);
|
||||
const request = store.get(clientId);
|
||||
request.onsuccess = () => {
|
||||
command = request.result || null;
|
||||
if (command) store.delete(clientId);
|
||||
};
|
||||
transaction.oncomplete = resolve;
|
||||
transaction.onerror = () => reject(transaction.error || new Error('Today action could not be claimed.'));
|
||||
transaction.onabort = transaction.onerror;
|
||||
});
|
||||
database.close();
|
||||
return command && command.expiresAt > now ? command : null;
|
||||
},
|
||||
async purge() {
|
||||
const database = await open();
|
||||
await new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(storeName, 'readwrite');
|
||||
transaction.objectStore(storeName).clear();
|
||||
transaction.oncomplete = resolve;
|
||||
transaction.onerror = () => reject(transaction.error || new Error('Today actions could not be purged.'));
|
||||
transaction.onabort = transaction.onerror;
|
||||
});
|
||||
database.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
const todayActionStore = self.__STACKCHAIN_TODAY_ACTION_STORE || createTodayActionStore();
|
||||
const SHELL = [
|
||||
BASE,
|
||||
BASE + 'manifest.webmanifest',
|
||||
@@ -13,19 +154,30 @@ const SHELL = [
|
||||
BASE + 'static/icons/stackchain-192.png',
|
||||
BASE + 'static/icons/stackchain-512.png',
|
||||
BASE + 'static/session.js',
|
||||
BASE + 'static/sign-out-review.js',
|
||||
BASE + 'static/feature-loader.js',
|
||||
BASE + 'static/workspace-bootstrap.js',
|
||||
BASE + 'static/conversation-action-hydrator.js',
|
||||
BASE + 'static/security-center.js',
|
||||
BASE + 'static/markdown.js',
|
||||
BASE + 'static/commands.js',
|
||||
BASE + 'static/saved-searches.js',
|
||||
BASE + 'static/search-preview.js',
|
||||
BASE + 'static/following.js',
|
||||
BASE + 'static/search-reply-draft-store.js',
|
||||
BASE + 'static/conversation-reply-draft-store.js',
|
||||
BASE + 'static/conversation-photo-drafts.js',
|
||||
BASE + 'static/search-defer.js',
|
||||
BASE + 'static/widgets.js',
|
||||
BASE + 'static/drafts.js',
|
||||
BASE + 'static/unfiled-captures.js',
|
||||
BASE + 'static/unfiled-draft-sync.js',
|
||||
BASE + 'static/shared-image-capture.js',
|
||||
BASE + 'static/draft-filing-session.js',
|
||||
BASE + 'static/draft-capacity-dialog.js',
|
||||
BASE + 'static/outbox-coordinator.js',
|
||||
BASE + 'static/issue-outbox.js',
|
||||
BASE + 'static/reconnect-outboxes.js',
|
||||
BASE + 'static/authored-outbox.js',
|
||||
BASE + 'static/offline-issue-close.js',
|
||||
BASE + 'static/offline-issue-blocker.js',
|
||||
@@ -34,13 +186,21 @@ const SHELL = [
|
||||
BASE + 'static/offline-today.js',
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/agenda-replan.js',
|
||||
BASE + 'static/agenda-calendar.js',
|
||||
BASE + 'static/protect-today.js',
|
||||
BASE + 'static/notification-undo.js',
|
||||
BASE + 'static/card-planning.js',
|
||||
BASE + 'static/work-selection.js',
|
||||
BASE + 'static/today-work.js',
|
||||
BASE + 'static/today-timer.js',
|
||||
BASE + 'static/today-break.js',
|
||||
BASE + 'static/today-progress.js',
|
||||
BASE + 'static/today-lock-screen.js',
|
||||
BASE + 'static/today-session-sync.js',
|
||||
BASE + 'static/today-recap.js',
|
||||
BASE + 'static/today-wrap-up.js',
|
||||
BASE + 'static/today-summary.js',
|
||||
BASE + 'static/today-handoff.js',
|
||||
BASE + 'static/today-completion.js',
|
||||
BASE + 'static/today-readiness.js',
|
||||
BASE + 'static/comment-next.js',
|
||||
@@ -48,6 +208,12 @@ const SHELL = [
|
||||
BASE + 'static/plan-today.js',
|
||||
BASE + 'static/plan-today-readiness.js',
|
||||
BASE + 'static/plan-today-preview.js',
|
||||
BASE + 'static/tomorrow-plan.js',
|
||||
BASE + 'static/week-calendar.js',
|
||||
BASE + 'static/week-calendar-import.js',
|
||||
BASE + 'static/week-plan.js',
|
||||
BASE + 'static/today-week-reschedule.js',
|
||||
BASE + 'static/search-week-plan.js',
|
||||
BASE + 'static/today-sync.js',
|
||||
BASE + 'static/today-rollover.js',
|
||||
BASE + 'static/update-ownership.js',
|
||||
@@ -59,23 +225,45 @@ const SHELL = [
|
||||
BASE + 'static/later-picker.js',
|
||||
BASE + 'static/pick-work.js',
|
||||
BASE + 'static/batch-find-work.js',
|
||||
BASE + 'static/search-batch-plan.js',
|
||||
BASE + 'static/conversation.js',
|
||||
BASE + 'static/comment-actions.js',
|
||||
BASE + 'static/issue-evidence-review.js',
|
||||
BASE + 'static/issue-evidence-editor.js',
|
||||
BASE + 'static/issue-attachment.js',
|
||||
BASE + 'static/issue-filing-review.js',
|
||||
BASE + 'static/issue-sheet.js',
|
||||
BASE + 'static/mobile-issue-detail-nav.js',
|
||||
BASE + 'static/mobile-update-detail-nav.js',
|
||||
BASE + 'static/mobile-review-detail-nav.js',
|
||||
BASE + 'static/mobile-search-preview-nav.js',
|
||||
BASE + 'static/mobile-plan-today-nav.js',
|
||||
BASE + 'static/mobile-find-work-nav.js',
|
||||
BASE + 'static/mobile-pull-refresh.js',
|
||||
BASE + 'static/checklist-conflict.js',
|
||||
BASE + 'static/voice-transcript-store.js',
|
||||
BASE + 'static/voice-issue-capture.js',
|
||||
BASE + 'static/voice-conversation-capture.js',
|
||||
BASE + 'static/create-issue-sheet.js',
|
||||
BASE + 'static/mobile-create-issue-nav.js',
|
||||
BASE + 'static/create-and-start.js',
|
||||
BASE + 'static/assign-and-start.js',
|
||||
BASE + 'static/filed-claim.js',
|
||||
BASE + 'static/queue-today.js',
|
||||
BASE + 'static/pull-sheet.js',
|
||||
BASE + 'static/review-sheet.js',
|
||||
BASE + 'static/release-receipt.js',
|
||||
BASE + 'static/work-route.js',
|
||||
BASE + 'static/task-overlay-history.js',
|
||||
BASE + 'static/context-poller.js',
|
||||
BASE + 'static/live-data-status.js',
|
||||
BASE + 'static/mobile-today-command-bar.js',
|
||||
BASE + 'static/mobile-task-dock.js',
|
||||
BASE + 'static/mobile-first-task.js',
|
||||
BASE + 'static/mobile-work-entry.js',
|
||||
BASE + 'static/mobile-queue-launcher.js',
|
||||
BASE + 'static/mobile-delivery-recovery.js',
|
||||
BASE + 'static/mobile-start-day.js',
|
||||
BASE + 'static/update-triage-session.js',
|
||||
BASE + 'static/update-review-handoff.js',
|
||||
BASE + 'static/update-read-position.js',
|
||||
@@ -85,13 +273,19 @@ const SHELL = [
|
||||
BASE + 'static/update-decision-transaction.js',
|
||||
BASE + 'static/agenda-session-launcher.js',
|
||||
BASE + 'static/mobile-launch.js',
|
||||
BASE + 'static/mobile-insights.js',
|
||||
BASE + 'static/mobile-app-shortcuts.js',
|
||||
BASE + 'static/mobile-app-badge.js',
|
||||
BASE + 'static/install-app.js',
|
||||
BASE + 'static/private-data-inventory.js',
|
||||
BASE + 'static/private-device-data.js',
|
||||
BASE + 'static/device-storage.js',
|
||||
BASE + 'static/mobile-device-setup.js',
|
||||
BASE + 'static/mobile-search-viewport.js',
|
||||
BASE + 'static/mobile-composer-viewport.js',
|
||||
BASE + 'static/mention-composer.js',
|
||||
BASE + 'static/push-notifications.js',
|
||||
BASE + 'static/issue-filing-receipt.js',
|
||||
BASE + 'static/background-issue-sync.js',
|
||||
];
|
||||
const OPTIONAL_FEATURES = [
|
||||
@@ -100,6 +294,7 @@ const OPTIONAL_FEATURES = [
|
||||
const SHARED_IMAGE_ID = 'shared-image';
|
||||
const SHARED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
const MAX_SHARED_IMAGE_BYTES = 12 * 1024 * 1024;
|
||||
const SHARE_TARGET_PATH = BASE + 'share-target';
|
||||
const sharedAttachmentStore = self.__STACKCHAIN_SHARED_ATTACHMENT_STORE || createUnfiledAttachmentStore();
|
||||
|
||||
function boundedShareField(form, name, limit) {
|
||||
@@ -107,34 +302,46 @@ function boundedShareField(form, name, limit) {
|
||||
return typeof value === 'string' ? value.trim().slice(0, limit) : '';
|
||||
}
|
||||
|
||||
async function acceptSharedContent(request) {
|
||||
const form = await request.formData();
|
||||
const images = form.getAll('image').filter(value => typeof value !== 'string' && value?.size > 0);
|
||||
let marker = '';
|
||||
await sharedAttachmentStore.delete(SHARED_IMAGE_ID).catch(() => {});
|
||||
if (images.length > 1) marker = 'multiple';
|
||||
else if (images.length === 1) {
|
||||
const image = images[0];
|
||||
if (!SHARED_IMAGE_TYPES.has(String(image.type || '')) || image.size > MAX_SHARED_IMAGE_BYTES) {
|
||||
marker = 'unsupported';
|
||||
} else {
|
||||
await sharedAttachmentStore.put(SHARED_IMAGE_ID, {
|
||||
filename:String(image.name || 'shared-screenshot').slice(0, 255),
|
||||
contentType:String(image.type), blob:image,
|
||||
});
|
||||
marker = 'image';
|
||||
}
|
||||
}
|
||||
function sharedContentRedirect(marker) {
|
||||
const target = new URL(BASE, self.location.origin);
|
||||
target.searchParams.set('launch', 'new');
|
||||
if (marker) target.searchParams.set('shared', marker);
|
||||
for (const [name, limit] of [['title', 255], ['text', 10000], ['url', 2048]]) {
|
||||
const value = boundedShareField(form, name, limit);
|
||||
if (value) target.searchParams.set(name, value);
|
||||
}
|
||||
return new Response(null, {status:303, headers:{Location:target.pathname + target.search}});
|
||||
}
|
||||
|
||||
async function acceptSharedContent(request) {
|
||||
if (request.headers.get('Sec-Fetch-Site') === 'cross-site') {
|
||||
return new Response('Cross-site share submissions are not accepted.', {
|
||||
status:403,
|
||||
headers:{'Content-Type':'text/plain; charset=utf-8','Cache-Control':'no-store'},
|
||||
});
|
||||
}
|
||||
const form = await request.formData();
|
||||
const images = form.getAll('image').filter(value => typeof value !== 'string' && value?.size > 0);
|
||||
const content = {
|
||||
title:boundedShareField(form, 'title', 255),
|
||||
text:boundedShareField(form, 'text', 10000),
|
||||
url:boundedShareField(form, 'url', 2048),
|
||||
};
|
||||
if (images.length > 5) return sharedContentRedirect('multiple');
|
||||
const supported = images.every(image =>
|
||||
SHARED_IMAGE_TYPES.has(String(image.type || '')) && image.size <= MAX_SHARED_IMAGE_BYTES
|
||||
);
|
||||
const totalBytes = images.reduce((total, image) => total + image.size, 0);
|
||||
if (!supported || totalBytes > MAX_SHARED_IMAGE_BYTES) return sharedContentRedirect('unsupported');
|
||||
if (!images.length && !Object.values(content).some(Boolean)) return sharedContentRedirect('unsupported');
|
||||
const attachments = images.map(image => ({
|
||||
filename:String(image.name || 'shared-screenshot').slice(0, 255),
|
||||
contentType:String(image.type), blob:image,
|
||||
}));
|
||||
try {
|
||||
await sharedAttachmentStore.put(SHARED_IMAGE_ID, {...content, attachments});
|
||||
} catch (_error) {
|
||||
return sharedContentRedirect('unavailable');
|
||||
}
|
||||
return sharedContentRedirect('bundle');
|
||||
}
|
||||
|
||||
async function fetchNavigation(request) {
|
||||
const controller = new AbortController();
|
||||
let timeout;
|
||||
@@ -190,9 +397,13 @@ async function deletePrivateDatabase(name) {
|
||||
});
|
||||
}
|
||||
|
||||
async function deletePrivateDatabases() {
|
||||
for (const name of PRIVATE_DATABASES) await deletePrivateDatabase(name);
|
||||
}
|
||||
|
||||
async function purgeRevokedSessionData() {
|
||||
await issueSync.purge();
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
await deletePrivateDatabases();
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
@@ -229,7 +440,7 @@ async function offlineLeaseState(cache) {
|
||||
|
||||
async function expiredOfflineResponse() {
|
||||
await issueSync.purge();
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
await deletePrivateDatabases();
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
@@ -313,7 +524,7 @@ async function flushAndNotify() {
|
||||
body: needsAuthorization ? 'Tap to authorize it in the Delivery center.' :
|
||||
needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.',
|
||||
tag: 'stackchain-delivery-' + receipt.id,
|
||||
data: { route: receipt.route },
|
||||
data: receipt.url ? { url: receipt.url } : { route: receipt.route },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -322,15 +533,40 @@ self.addEventListener('install', event => {
|
||||
event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting()));
|
||||
});
|
||||
|
||||
async function warmOptionalFeature(cache, staleCacheNames, asset) {
|
||||
for (const cacheName of staleCacheNames) {
|
||||
const staleCache = await caches.open(cacheName);
|
||||
const cached = await staleCache.match(asset);
|
||||
if (cached) {
|
||||
await cache.put(asset, cached);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await cache.add(asset);
|
||||
}
|
||||
|
||||
async function cachedOptionalFeature(request) {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(CACHE);
|
||||
await cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil((async () => {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
|
||||
.map(key => caches.delete(key))
|
||||
const staleCacheNames = keys.filter(
|
||||
key => key.startsWith('stackchain-dashboard-') && key !== CACHE
|
||||
);
|
||||
const cache = await caches.open(CACHE);
|
||||
await Promise.allSettled(OPTIONAL_FEATURES.map(asset => cache.add(asset)));
|
||||
await Promise.allSettled(
|
||||
OPTIONAL_FEATURES.map(asset => warmOptionalFeature(cache, staleCacheNames, asset))
|
||||
);
|
||||
await Promise.all(staleCacheNames.map(key => caches.delete(key)));
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
@@ -339,7 +575,69 @@ self.addEventListener('sync', event => {
|
||||
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify());
|
||||
});
|
||||
|
||||
async function updateTodayLockScreen(active, running, rawActionToken = '', rawBreakDeadline = 0) {
|
||||
const tag = 'stackchain-today-session';
|
||||
if (!active) {
|
||||
const notifications = await self.registration.getNotifications({ tag });
|
||||
notifications.forEach(notification => notification.close());
|
||||
return;
|
||||
}
|
||||
const actionToken = /^[A-Za-z0-9_-]{16,128}$/.test(rawActionToken) ? rawActionToken : '';
|
||||
const now = Date.now();
|
||||
const breakDeadline = Number(rawBreakDeadline);
|
||||
const onBreak = !running && actionToken && Number.isSafeInteger(breakDeadline) &&
|
||||
breakDeadline > now && breakDeadline <= now + 120 * 60 * 1000;
|
||||
const title = onBreak ? 'On a Today break' :
|
||||
running ? 'Today session running' : 'Today session paused';
|
||||
const body = onBreak ? 'Return at ' + new Date(breakDeadline).toLocaleTimeString([], {
|
||||
hour:'numeric', minute:'2-digit',
|
||||
}) : running ? 'Your active Today timer is running.' : 'Your active Today timer is paused.';
|
||||
await self.registration.showNotification(title, {
|
||||
body,
|
||||
tag,
|
||||
renotify:false,
|
||||
silent:true,
|
||||
actions: onBreak ? [
|
||||
{ action:'resume-today', title:'Resume now' },
|
||||
{ action:'open-today', title:'Open Today' },
|
||||
] : [
|
||||
{ action:running ? 'pause-today' : 'resume-today', title:running ? 'Pause' : 'Resume' },
|
||||
...(actionToken ? [{ action:'finish-today', title:'Finish current' }] : []),
|
||||
],
|
||||
data: { route:'#/my-work/today', ...(actionToken ? { actionToken } : {}) },
|
||||
});
|
||||
}
|
||||
|
||||
self.addEventListener('message', event => {
|
||||
if (event.data?.type === 'stackchain-app-badge-count') {
|
||||
event.waitUntil((async () => {
|
||||
if (!String(event.source?.url || '').startsWith(self.location.origin + BASE)) return;
|
||||
const channel = event.data.channel;
|
||||
const count = event.data.count;
|
||||
if (!['updates', 'following'].includes(channel)
|
||||
|| !Number.isSafeInteger(count) || count < 0 || count > 9999) return;
|
||||
try {
|
||||
if (await appBadgePreference.get()) {
|
||||
await appBadgePreference.setCount(channel, count);
|
||||
renderedBackgroundBadgeCount = null;
|
||||
}
|
||||
} catch (_error) { /* A later authoritative refresh can restore the count. */ }
|
||||
})());
|
||||
}
|
||||
if (event.data?.type === 'stackchain-app-badge-preference') {
|
||||
event.waitUntil((async () => {
|
||||
if (!String(event.source?.url || '').startsWith(self.location.origin + BASE)) return;
|
||||
const enabled = event.data.enabled === true;
|
||||
try {
|
||||
await appBadgePreference.set(enabled);
|
||||
renderedBackgroundBadgeCount = null;
|
||||
if (!enabled && typeof self.registration.clearAppBadge === 'function') {
|
||||
await appBadgePreference.clearCounts();
|
||||
await self.registration.clearAppBadge();
|
||||
}
|
||||
} catch (_error) { /* Page preference remains authoritative on next launch. */ }
|
||||
})());
|
||||
}
|
||||
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil((async () => {
|
||||
await issueSync.resume();
|
||||
await flushAndNotify();
|
||||
@@ -350,12 +648,43 @@ self.addEventListener('message', event => {
|
||||
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
|
||||
try {
|
||||
await issueSync.purge();
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
await todayActionStore.purge();
|
||||
await deletePrivateDatabases();
|
||||
await updateTodayLockScreen(false, false);
|
||||
event.ports?.[0]?.postMessage({ ok: true });
|
||||
} catch (error) {
|
||||
event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') });
|
||||
}
|
||||
})());
|
||||
if (event.data?.type === 'stackchain-today-lock-screen') {
|
||||
event.waitUntil((async () => {
|
||||
const active = event.data.active === true;
|
||||
if (!active) await todayActionStore.purge();
|
||||
await updateTodayLockScreen(
|
||||
active,
|
||||
event.data.running === true,
|
||||
String(event.data.actionToken || ''),
|
||||
Number(event.data.breakDeadlineAt || 0)
|
||||
);
|
||||
})());
|
||||
}
|
||||
if (event.data?.type === 'stackchain-claim-today-action') {
|
||||
event.waitUntil((async () => {
|
||||
const source = event.source;
|
||||
if (!source?.id || !String(source.url || '').startsWith(self.location.origin + BASE)) return;
|
||||
let command;
|
||||
try { command = await todayActionStore.claim(source.id, Date.now()); }
|
||||
catch (_error) { return; }
|
||||
if (!command || !['pause', 'resume', 'complete'].includes(command.action)) return;
|
||||
if (command.actionToken && !/^[A-Za-z0-9_-]{16,128}$/.test(command.actionToken)) return;
|
||||
if (command.action === 'complete' && !command.actionToken) return;
|
||||
source.postMessage?.({
|
||||
type:'stackchain-today-timer-action',
|
||||
action:command.action,
|
||||
...(command.actionToken ? {actionToken:command.actionToken} : {}),
|
||||
});
|
||||
})());
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener('push', event => {
|
||||
@@ -367,7 +696,23 @@ self.addEventListener('push', event => {
|
||||
const tag = String(payload.tag || '');
|
||||
const notificationId = Number(payload.notification_id);
|
||||
const updateCount = Number(payload.update_count);
|
||||
const unreadCount = typeof payload.unread_count === 'number' ? payload.unread_count : NaN;
|
||||
const deadlineCount = Number(payload.deadline_count);
|
||||
const followingCount = Number(payload.following_count);
|
||||
const planDate = String(payload.plan_date || '');
|
||||
if (
|
||||
route === '#/my-work/start-day'
|
||||
&& /^\d{4}-\d{2}-\d{2}$/.test(planDate)
|
||||
&& tag === 'stackchain-start-day-' + planDate
|
||||
) {
|
||||
event.waitUntil(self.registration.showNotification('Your planned day is ready', {
|
||||
body: 'Open Stackchain to prepare Today.',
|
||||
tag,
|
||||
actions: [{ action: 'prepare-today', title: 'Prepare Today' }],
|
||||
data: {route},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
route === '#/my-work/agenda'
|
||||
&& protectRoute === '#/my-work/agenda/protect-today'
|
||||
@@ -383,13 +728,31 @@ self.addEventListener('push', event => {
|
||||
tag,
|
||||
actions: [
|
||||
{ action: 'protect-today', title: 'Protect Today' },
|
||||
{ action: 'open-agenda', title: 'Open Agenda' },
|
||||
{ action: 'snooze-deadline', title: 'Remind in 1 hour' },
|
||||
],
|
||||
data: {route, protectRoute},
|
||||
}
|
||||
));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
route === '#/my-work/following'
|
||||
&& /^stackchain-following-[0-9a-f]{16}$/.test(tag)
|
||||
&& Number.isSafeInteger(followingCount)
|
||||
&& followingCount > 0
|
||||
&& followingCount <= 50
|
||||
) {
|
||||
event.waitUntil(Promise.all([
|
||||
reconcileBackgroundAppBadge('following', followingCount),
|
||||
self.registration.showNotification(
|
||||
followingCount + ' watched item' + (followingCount === 1 ? '' : 's') + ' changed', {
|
||||
body: 'Open Following to review the latest activity.',
|
||||
tag,
|
||||
data: {route},
|
||||
}),
|
||||
]));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
route === '#/my-work/updates'
|
||||
&& tag === 'stackchain-update-digest'
|
||||
@@ -397,11 +760,12 @@ self.addEventListener('push', event => {
|
||||
&& updateCount > 0
|
||||
&& updateCount <= 50
|
||||
) {
|
||||
event.waitUntil(self.registration.showNotification(updateCount + ' new work updates', {
|
||||
body: 'Tap to review them in Stackchain.',
|
||||
tag,
|
||||
data: {route},
|
||||
}));
|
||||
event.waitUntil(Promise.all([
|
||||
reconcileBackgroundAppBadge('updates', unreadCount),
|
||||
self.registration.showNotification(updateCount + ' new work updates', {
|
||||
body: 'Tap to review them in Stackchain.', tag, data: {route},
|
||||
}),
|
||||
]));
|
||||
return;
|
||||
}
|
||||
if (!/^#\/my-work\/update\/\d+$/.test(route) || !/^stackchain-update-\d+$/.test(tag)) return;
|
||||
@@ -421,7 +785,10 @@ self.addEventListener('push', event => {
|
||||
];
|
||||
options.data.notificationId = notificationId;
|
||||
}
|
||||
event.waitUntil(self.registration.showNotification('New work update', options));
|
||||
event.waitUntil(Promise.all([
|
||||
reconcileBackgroundAppBadge('updates', unreadCount),
|
||||
self.registration.showNotification('New work update', options),
|
||||
]));
|
||||
});
|
||||
|
||||
async function openWorkRoute(route) {
|
||||
@@ -433,8 +800,71 @@ async function openWorkRoute(route) {
|
||||
return client.focus();
|
||||
}
|
||||
|
||||
async function openCanonicalIssueUrl(rawUrl) {
|
||||
let target;
|
||||
try { target = new URL(rawUrl); } catch (_error) { return; }
|
||||
if (
|
||||
target.origin !== self.location.origin
|
||||
|| !/^\/git\/[^/]+\/[^/]+\/issues\/\d+$/.test(target.pathname)
|
||||
|| target.search || target.hash
|
||||
) return;
|
||||
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
|
||||
if (!client) return self.clients.openWindow(target.href);
|
||||
if (client.navigate) await client.navigate(target.href);
|
||||
return client.focus();
|
||||
}
|
||||
|
||||
async function applyTodayTimerAction(action, actionToken = '') {
|
||||
if (!['pause', 'resume', 'complete'].includes(action)) return;
|
||||
if (actionToken && !/^[A-Za-z0-9_-]{16,128}$/.test(actionToken)) return;
|
||||
if (action === 'complete' && !actionToken) return;
|
||||
const route = '#/my-work/today';
|
||||
const windows = await self.clients.matchAll({ type:'window', includeUncontrolled:true });
|
||||
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
|
||||
if (client) {
|
||||
client.postMessage?.({
|
||||
type:'stackchain-today-timer-action', action,
|
||||
...(actionToken ? { actionToken } : {}),
|
||||
});
|
||||
return client.focus?.();
|
||||
}
|
||||
const target = new URL(BASE + route, self.location.origin).href;
|
||||
const opened = await self.clients.openWindow(target);
|
||||
if (!opened?.id) return opened;
|
||||
try {
|
||||
await todayActionStore.put(opened.id, {
|
||||
action,
|
||||
...(actionToken ? {actionToken} : {}),
|
||||
expiresAt:Date.now() + TODAY_ACTION_TTL_MS,
|
||||
});
|
||||
} catch (_error) { /* The clean Today route remains safe; fail the action closed. */ }
|
||||
return opened;
|
||||
}
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
const route = String(event.notification.data?.route || '');
|
||||
const issueUrl = String(event.notification.data?.url || '');
|
||||
if (
|
||||
event.notification.tag === 'stackchain-today-session'
|
||||
&& route === '#/my-work/today'
|
||||
&& ['pause-today', 'resume-today', 'finish-today', 'open-today', ''].includes(event.action)
|
||||
) {
|
||||
event.notification.close();
|
||||
if (['pause-today', 'resume-today', 'finish-today'].includes(event.action)) {
|
||||
const action = event.action === 'pause-today' ? 'pause' :
|
||||
event.action === 'resume-today' ? 'resume' : 'complete';
|
||||
event.waitUntil(applyTodayTimerAction(action, String(event.notification.data?.actionToken || '')));
|
||||
} else {
|
||||
event.waitUntil(openWorkRoute(route));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (issueUrl) {
|
||||
event.notification.close();
|
||||
event.waitUntil(openCanonicalIssueUrl(issueUrl));
|
||||
return;
|
||||
}
|
||||
if (event.action === 'protect-today') {
|
||||
const protectRoute = String(event.notification.data?.protectRoute || route);
|
||||
if (
|
||||
@@ -445,10 +875,26 @@ self.addEventListener('notificationclick', event => {
|
||||
event.waitUntil(openWorkRoute(protectRoute));
|
||||
return;
|
||||
}
|
||||
if (event.action === 'open-agenda') {
|
||||
if (route !== '#/my-work/agenda') return;
|
||||
event.notification.close();
|
||||
event.waitUntil(openWorkRoute(route));
|
||||
if (event.action === 'snooze-deadline') {
|
||||
if (
|
||||
route !== '#/my-work/agenda'
|
||||
|| !/^stackchain-deadline-digest-\d{4}-\d{2}-\d{2}$/.test(event.notification.tag)
|
||||
) return;
|
||||
event.waitUntil((async () => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), PUSH_ACTION_TIMEOUT_MS);
|
||||
try {
|
||||
await fetchJson(BASE + 'api/v1/push-subscription/deadlines/snooze', {
|
||||
method: 'PATCH', headers: { Accept: 'application/json' }, signal: controller.signal,
|
||||
});
|
||||
event.notification.close();
|
||||
} catch (_error) {
|
||||
await openWorkRoute(route);
|
||||
event.notification.close();
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
})());
|
||||
return;
|
||||
}
|
||||
if (event.action === 'tomorrow') {
|
||||
@@ -514,7 +960,7 @@ self.addEventListener('fetch', event => {
|
||||
const requestUrl = new URL(request.url);
|
||||
if (
|
||||
request.method === 'POST' && request.mode === 'navigate'
|
||||
&& requestUrl.origin === self.location.origin && requestUrl.pathname === BASE
|
||||
&& requestUrl.origin === self.location.origin && requestUrl.pathname === SHARE_TARGET_PATH
|
||||
) {
|
||||
event.respondWith(acceptSharedContent(request));
|
||||
return;
|
||||
@@ -551,6 +997,6 @@ self.addEventListener('fetch', event => {
|
||||
return;
|
||||
}
|
||||
if (url.origin === self.location.origin && OPTIONAL_FEATURES.includes(url.pathname)) {
|
||||
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
|
||||
event.respondWith(cachedOptionalFeature(request));
|
||||
}
|
||||
});
|
||||
+55
-20
@@ -1,5 +1,10 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = options => factory({
|
||||
...options,
|
||||
privateDatabases: require('./private-data-registry.js'),
|
||||
});
|
||||
}
|
||||
else {
|
||||
const base = new URL('./', root.location.href).pathname;
|
||||
const originalFetch = root.fetch.bind(root);
|
||||
@@ -15,6 +20,7 @@
|
||||
serviceWorker: root.navigator?.serviceWorker,
|
||||
credentials: root.navigator?.credentials,
|
||||
MessageChannel: root.MessageChannel,
|
||||
privateDatabases: root.stackchainPrivateDatabases,
|
||||
location: root.location,
|
||||
addActivityListener: (type, listener, options) => root.addEventListener(type, listener, options),
|
||||
confirmAction: message => root.confirm(message),
|
||||
@@ -30,36 +36,55 @@
|
||||
root.fetch = boundary.fetch;
|
||||
const attach = () => {
|
||||
boundary.startActivityHeartbeat();
|
||||
boundary.startReconnectResume();
|
||||
const button = root.document.getElementById('sign-out');
|
||||
if (button) button.addEventListener('click', () => boundary.signOut());
|
||||
const allDevicesButton = root.document.getElementById('sign-out-all');
|
||||
if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices());
|
||||
const allButton = root.document.getElementById('sign-out-all');
|
||||
let review;
|
||||
const features = root.createFeatureLoader({
|
||||
document: root.document,
|
||||
urls: {
|
||||
'sign-out': root.document.querySelector('meta[name="stackchain-feature-sign-out"]')?.content || '',
|
||||
'security-center': root.document.querySelector('meta[name="stackchain-feature-security-center"]')?.content || '',
|
||||
},
|
||||
});
|
||||
const openReview = (mode, source) => features.run(
|
||||
'sign-out', { trigger: source }, () => {
|
||||
if (!review) review = root.createSignOutReview.mount(boundary);
|
||||
review.show(mode, source);
|
||||
},
|
||||
);
|
||||
[[button, 'current'], [allButton, 'all']].forEach(([source, mode]) =>
|
||||
source?.addEventListener('click', () => openReview(mode, source))
|
||||
);
|
||||
boundary.requestSignOut = source => openReview('current', source || button);
|
||||
const devicesButton = root.document.getElementById('active-devices');
|
||||
const devicesSheet = root.document.getElementById('active-devices-sheet');
|
||||
const devicesStatus = root.document.getElementById('active-devices-status');
|
||||
const closeDevices = root.document.getElementById('close-active-devices');
|
||||
const securityFeatures = root.createFeatureLoader({
|
||||
document: root.document,
|
||||
urls: {
|
||||
'security-center': root.document.querySelector(
|
||||
'meta[name="stackchain-feature-security-center"]'
|
||||
)?.content || '',
|
||||
},
|
||||
});
|
||||
const returnFromSecurity = root.document.getElementById('return-from-security-center');
|
||||
const beginSecurityDetour = () => {
|
||||
if (root.matchMedia?.('(max-width: 600px)')?.matches) {
|
||||
root.stackchainTodayTimerView?.beginDetour?.('security-center');
|
||||
}
|
||||
};
|
||||
let loadingSecurityCenter = false;
|
||||
const openSecurityCenter = async () => {
|
||||
if (loadingSecurityCenter) return;
|
||||
loadingSecurityCenter = true;
|
||||
beginSecurityDetour();
|
||||
devicesSheet.hidden = false;
|
||||
closeDevices?.focus();
|
||||
try {
|
||||
await securityFeatures.run('security-center', {
|
||||
await features.run('security-center', {
|
||||
trigger: devicesButton,
|
||||
status: devicesStatus,
|
||||
retryLabel: 'Tap Active devices to retry.',
|
||||
}, () => {
|
||||
devicesButton.removeEventListener('click', openSecurityCenter);
|
||||
root.attachSecurityCenter(boundary).open();
|
||||
root.attachSecurityCenter(boundary, {
|
||||
onOpen: beginSecurityDetour,
|
||||
onClose: () => root.stackchainTodayTimerView?.finishDetour?.('security-center'),
|
||||
}).open();
|
||||
});
|
||||
} finally {
|
||||
loadingSecurityCenter = false;
|
||||
@@ -71,6 +96,11 @@
|
||||
if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => {
|
||||
devicesSheet.hidden = true;
|
||||
devicesButton?.focus();
|
||||
root.stackchainTodayTimerView?.finishDetour?.('security-center');
|
||||
});
|
||||
if (returnFromSecurity && devicesSheet) returnFromSecurity.addEventListener('click', () => {
|
||||
if (root.history?.state?.stackchainSecuritySection) root.history.back();
|
||||
else devicesSheet.hidden = true;
|
||||
});
|
||||
boundary.refreshOfflineLease().then(valid => {
|
||||
if (valid) boundary.resumeQueuedWork();
|
||||
@@ -82,7 +112,7 @@
|
||||
root.stackchainSession = boundary;
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
|
||||
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, credentials, MessageChannel, location, confirmAction, addActivityListener,
|
||||
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, credentials, MessageChannel, privateDatabases, location, confirmAction, addActivityListener,
|
||||
promptAuthorization = () => null,
|
||||
onExpired = () => {},
|
||||
onClearError = () => {},
|
||||
@@ -381,6 +411,12 @@
|
||||
});
|
||||
}
|
||||
|
||||
function startReconnectResume() {
|
||||
addActivityListener?.('online', async () => {
|
||||
if (await refreshOfflineLease()) await resumeQueuedWork();
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshOfflineLease() {
|
||||
try {
|
||||
const response = await sessionFetch(base + 'api/v1/session');
|
||||
@@ -454,9 +490,7 @@
|
||||
if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage);
|
||||
try {
|
||||
await stopWorkerOutbox();
|
||||
await deletePrivateDatabase('stackchain-background-outbox-v1');
|
||||
await deletePrivateDatabase('stackchain-offline-work-v2');
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
for (const name of privateDatabases) await deletePrivateDatabase(name);
|
||||
} catch (_error) {
|
||||
const error = new Error('Could not clear private queued work from this device.');
|
||||
onClearError(error);
|
||||
@@ -581,8 +615,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function signOutAllDevices() {
|
||||
const confirmed = confirmAction?.('Sign out every device? You will need to sign in again everywhere.');
|
||||
async function signOutAllDevices({ reviewed = false } = {}) {
|
||||
const confirmed = reviewed || confirmAction?.('Sign out every device? You will need to sign in again everywhere.');
|
||||
if (!confirmed) return false;
|
||||
const response = await sessionFetch(base + 'api/v1/sessions', { method: 'DELETE' });
|
||||
if (!response.ok) throw new Error('Could not sign out all devices');
|
||||
@@ -613,6 +647,7 @@
|
||||
handleServiceWorkerMessage,
|
||||
refreshOfflineLease,
|
||||
resumeQueuedWork,
|
||||
startReconnectResume,
|
||||
recordActivity,
|
||||
startActivityHeartbeat,
|
||||
};
|
||||
|
||||
@@ -8,13 +8,46 @@
|
||||
const RECORD_ID = 'shared-image';
|
||||
const TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
|
||||
async function consume({marker, store, restore, status = () => {}}) {
|
||||
async function consume({marker, store, restore, restoreContent = () => {}, status = () => {}}) {
|
||||
if (!marker) return false;
|
||||
if (marker !== 'image') {
|
||||
const value = await store?.get(RECORD_ID);
|
||||
const attachments = Array.isArray(value?.attachments) ? value.attachments : null;
|
||||
if (marker === 'bundle') {
|
||||
const content = {
|
||||
title:typeof value?.title === 'string' ? value.title.slice(0, 255) : '',
|
||||
text:typeof value?.text === 'string' ? value.text.slice(0, 10000) : '',
|
||||
url:typeof value?.url === 'string' ? value.url.slice(0, 2048) : '',
|
||||
};
|
||||
const invalidAttachments = !attachments || attachments.length > 5 || attachments.some(attachment =>
|
||||
!attachment?.blob || !attachment.filename || !TYPES.has(String(attachment.contentType || '')));
|
||||
if (invalidAttachments || (!attachments.length && !Object.values(content).some(Boolean))) {
|
||||
status('The shared content is unavailable. Share it again.');
|
||||
return false;
|
||||
}
|
||||
restoreContent(content);
|
||||
if (attachments.length) restore(attachments);
|
||||
await store.delete(RECORD_ID);
|
||||
const noun = attachments.length === 1 ? 'screenshot' : attachments.length > 1 ? 'screenshots' : 'content';
|
||||
status(attachments.length
|
||||
? 'Shared content and ' + (attachments.length > 1 ? attachments.length + ' ' : '') + noun + ' ready to file with this issue.'
|
||||
: 'Shared content ready to file with this issue.');
|
||||
return true;
|
||||
}
|
||||
if (!['image', 'images'].includes(marker)) {
|
||||
status('Share one PNG, JPEG, or WebP screenshot.');
|
||||
return false;
|
||||
}
|
||||
const value = await store?.get(RECORD_ID);
|
||||
if (marker === 'images') {
|
||||
if (!attachments?.length || attachments.length > 5 || attachments.some(attachment =>
|
||||
!attachment?.blob || !attachment.filename || !TYPES.has(String(attachment.contentType || '')))) {
|
||||
status('The shared screenshots are unavailable. Share them again.');
|
||||
return false;
|
||||
}
|
||||
restore(attachments);
|
||||
await store.delete(RECORD_ID);
|
||||
status(attachments.length + ' shared screenshots ready to file with this issue.');
|
||||
return true;
|
||||
}
|
||||
if (!value?.blob || !value.filename || !TYPES.has(String(value.contentType || ''))) {
|
||||
status('The shared screenshot is unavailable. Share it again.');
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else {
|
||||
root.createSignOutReview = factory;
|
||||
root.createSignOutReview.mount = boundary => {
|
||||
const document = root.document;
|
||||
const review = factory({
|
||||
sheet: document.getElementById('sign-out-review-sheet'),
|
||||
heading: document.getElementById('sign-out-review-heading'),
|
||||
summary: document.getElementById('sign-out-review-summary'),
|
||||
warning: document.getElementById('sign-out-review-warning'),
|
||||
cancelButton: document.getElementById('cancel-sign-out'),
|
||||
confirmButton: document.getElementById('confirm-sign-out'),
|
||||
escapeTarget: document,
|
||||
historyTarget: root,
|
||||
history: root.history,
|
||||
backgroundTargets: Array.from(document.querySelectorAll('body > :not(#sign-out-review-sheet)')),
|
||||
getActiveElement: () => document.activeElement,
|
||||
localStorage: root.localStorage,
|
||||
sessionStorage: root.sessionStorage,
|
||||
privateDatabases: root.stackchainPrivateDatabases,
|
||||
inspectPrivateDatabases: root.inspectStackchainPrivateDatabases,
|
||||
onConfirm: mode => mode === 'all'
|
||||
? boundary.signOutAllDevices({ reviewed: true })
|
||||
: boundary.signOut(),
|
||||
});
|
||||
review.start();
|
||||
return review;
|
||||
};
|
||||
}
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function createSignOutReview(options) {
|
||||
let mode = 'current';
|
||||
let launcher = null;
|
||||
let open = false;
|
||||
let historyEntry = false;
|
||||
|
||||
function ownedItemCount(storage) {
|
||||
if (!storage) return 0;
|
||||
let count = 0;
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
if (storage.key(index)?.startsWith('stackchain.')) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async function show(nextMode, source) {
|
||||
mode = nextMode;
|
||||
launcher = source;
|
||||
const itemCount = ownedItemCount(options.localStorage)
|
||||
+ (options.sessionStorage === options.localStorage ? 0 : ownedItemCount(options.sessionStorage));
|
||||
let inventory = { recordCount: 0, unavailable: true };
|
||||
try {
|
||||
inventory = await options.inspectPrivateDatabases?.(options.privateDatabases) || inventory;
|
||||
} catch (_error) { /* Unknown private work must use the guarded confirmation path. */ }
|
||||
const recordCount = Math.max(0, Number(inventory.recordCount) || 0);
|
||||
options.heading.textContent = mode === 'all' ? 'Review sign out on every device' : 'Review sign out';
|
||||
options.summary.textContent = inventory.unavailable
|
||||
? `This device has ${itemCount} private browser item${itemCount === 1 ? '' : 's'}; private work status is unknown.`
|
||||
: `This device has ${itemCount} private browser item${itemCount === 1 ? '' : 's'} and ${recordCount} private work record${recordCount === 1 ? '' : 's'}.`;
|
||||
const atRisk = itemCount > 0 || recordCount > 0 || inventory.unavailable;
|
||||
if (mode === 'all') {
|
||||
options.warning.textContent = atRisk
|
||||
? 'Every device will need to sign in again. Private drafts or queued work on this device may not be synced and will be erased.'
|
||||
: 'Every device will need to sign in again. This device will be cleared after its session is revoked.';
|
||||
} else {
|
||||
options.warning.textContent = atRisk
|
||||
? 'Private drafts or queued work may not be synced. Signing out erases them from this device.'
|
||||
: 'Signing out clears this device after the session is revoked.';
|
||||
}
|
||||
options.confirmButton.textContent = atRisk
|
||||
? 'Sign out and erase private work'
|
||||
: (mode === 'all' ? 'Sign out all devices' : 'Sign out');
|
||||
options.sheet.hidden = false;
|
||||
(options.backgroundTargets || []).forEach(target => { target.inert = true; });
|
||||
open = true;
|
||||
options.history?.pushState?.({ stackchainSignOutReview: true }, '');
|
||||
historyEntry = Boolean(options.history?.back);
|
||||
options.cancelButton.focus();
|
||||
}
|
||||
|
||||
function close({ restoreFocus = true } = {}) {
|
||||
if (!open) return;
|
||||
options.sheet.hidden = true;
|
||||
(options.backgroundTargets || []).forEach(target => { target.inert = false; });
|
||||
open = false;
|
||||
if (restoreFocus) launcher?.focus?.();
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
if (historyEntry) {
|
||||
historyEntry = false;
|
||||
options.history.back();
|
||||
} else close();
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
options.confirmButton.disabled = true;
|
||||
try {
|
||||
await options.onConfirm(mode);
|
||||
close({ restoreFocus: false });
|
||||
} catch (_error) {
|
||||
options.warning.textContent = 'Sign out could not finish clearing private work. Close other Stackchain tabs, then retry.';
|
||||
} finally {
|
||||
options.confirmButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
options.launcher?.addEventListener('click', event => show('current', event.currentTarget || options.launcher));
|
||||
options.allLauncher?.addEventListener('click', event => show('all', event.currentTarget || options.allLauncher));
|
||||
options.cancelButton?.addEventListener('click', dismiss);
|
||||
options.confirmButton?.addEventListener('click', confirm);
|
||||
options.escapeTarget?.addEventListener('keydown', event => {
|
||||
if (!open) return;
|
||||
if (event.key === 'Escape') { event.preventDefault(); dismiss(); return; }
|
||||
if (event.key !== 'Tab') return;
|
||||
const focusable = Array.from(options.sheet.querySelectorAll('button:not([disabled])'));
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = options.getActiveElement?.();
|
||||
if (!event.shiftKey && active === last) { event.preventDefault(); first?.focus(); }
|
||||
if (event.shiftKey && active === first) { event.preventDefault(); last?.focus(); }
|
||||
});
|
||||
options.historyTarget?.addEventListener('popstate', () => {
|
||||
historyEntry = false;
|
||||
close();
|
||||
});
|
||||
}
|
||||
|
||||
return { start, show, close };
|
||||
});
|
||||
@@ -15,9 +15,13 @@
|
||||
}
|
||||
|
||||
function cleanScope(value) {
|
||||
const repository = typeof value?.repository === 'string' &&
|
||||
/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value.repository.trim())
|
||||
? value.repository.trim() : '';
|
||||
return {
|
||||
kind:['all', 'issue', 'pull'].includes(value?.kind) ? value.kind : 'all',
|
||||
state:['all', 'open', 'closed'].includes(value?.state) ? value.state : 'all',
|
||||
repository,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,9 +68,10 @@
|
||||
const query = cleanQuery(rawQuery);
|
||||
if (rawQuery.trim() && !query) return { kind:null };
|
||||
const preview = parsePreview(params.get('preview'));
|
||||
const hasScope = params.has('search_kind') || params.has('search_state');
|
||||
const hasScope = params.has('search_kind') || params.has('search_state') || params.has('search_repository');
|
||||
const scope = hasScope ? cleanScope({
|
||||
kind:params.get('search_kind'), state:params.get('search_state'),
|
||||
repository:params.get('search_repository'),
|
||||
}) : null;
|
||||
return {
|
||||
kind:preview ? 'search-preview' : 'search',
|
||||
@@ -96,11 +101,13 @@
|
||||
params.delete('preview');
|
||||
params.delete('search_kind');
|
||||
params.delete('search_state');
|
||||
params.delete('search_repository');
|
||||
if (searchKinds.has(detail.kind)) {
|
||||
params.set('search', detail.query || '');
|
||||
if (detail.scope) {
|
||||
params.set('search_kind', detail.scope.kind);
|
||||
params.set('search_state', detail.scope.state);
|
||||
if (detail.scope.repository) params.set('search_repository', detail.scope.repository);
|
||||
}
|
||||
if (detail.kind === 'search-preview' && detail.preview) params.set('preview', previewToken(detail.preview));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
function createTodayBreak({
|
||||
timer, sheet, openButtons = null, cancelButton, presetButtons = null, form = null,
|
||||
minutesInput = null, error = null, status, resumeButton,
|
||||
qs = null, queryAll = null,
|
||||
historyRef = null, windowRef = null,
|
||||
now = () => Date.now(), setIntervalRef = setInterval, clearIntervalRef = clearInterval,
|
||||
onChange = () => {}, onResume = () => {},
|
||||
}) {
|
||||
queryAll ||= selector => globalThis.document?.querySelectorAll(selector) || [];
|
||||
historyRef ||= globalThis.history;
|
||||
windowRef ||= globalThis.window;
|
||||
sheet ||= qs?.('#today-break-sheet');
|
||||
openButtons ||= queryAll?.('[data-today-break-open]') || [];
|
||||
cancelButton ||= qs?.('#cancel-today-break');
|
||||
presetButtons ||= queryAll?.('[data-today-break-minutes]') || [];
|
||||
form ||= qs?.('#today-break-custom-form');
|
||||
minutesInput ||= qs?.('#today-break-custom-minutes');
|
||||
error ||= qs?.('#today-break-error');
|
||||
status ||= qs?.('#today-break-status');
|
||||
resumeButton ||= qs?.('#resume-today-break');
|
||||
let ticker = null;
|
||||
let historyEntry = false;
|
||||
|
||||
const stopTicker = () => {
|
||||
if (ticker !== null) clearIntervalRef(ticker);
|
||||
ticker = null;
|
||||
};
|
||||
const render = () => {
|
||||
const pending = timer.breakSnapshot();
|
||||
if (!pending) {
|
||||
stopTicker();
|
||||
status.hidden = true;
|
||||
resumeButton.hidden = true;
|
||||
openButtons.forEach(button => { button.hidden = false; });
|
||||
return false;
|
||||
}
|
||||
status.hidden = false;
|
||||
resumeButton.hidden = false;
|
||||
openButtons.forEach(button => { button.hidden = true; });
|
||||
const remaining = Math.max(0, pending.deadline_at - now());
|
||||
if (pending.expired || remaining <= 0) {
|
||||
status.textContent = 'Break over · ready to resume';
|
||||
} else {
|
||||
const seconds = Math.ceil(remaining / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
status.textContent = 'On break · resume in ' + minutes + ':' + String(seconds % 60).padStart(2, '0');
|
||||
}
|
||||
if (ticker === null) ticker = setIntervalRef(render, 1000);
|
||||
return true;
|
||||
};
|
||||
const close = () => {
|
||||
if (sheet.open) sheet.close();
|
||||
if (historyEntry) {
|
||||
historyEntry = false;
|
||||
historyRef?.back();
|
||||
}
|
||||
};
|
||||
const start = minutes => {
|
||||
if (!timer.startBreak(Number(minutes))) return false;
|
||||
if (error) error.textContent = '';
|
||||
close();
|
||||
render();
|
||||
onChange();
|
||||
return true;
|
||||
};
|
||||
const open = () => {
|
||||
if (!timer.snapshot().identity) return false;
|
||||
if (!sheet.open) {
|
||||
sheet.showModal();
|
||||
if (historyRef) {
|
||||
historyRef.pushState({...(historyRef.state || {}), todayBreak:true}, '');
|
||||
historyEntry = true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
openButtons.forEach(button => button.addEventListener('click', open));
|
||||
cancelButton?.addEventListener('click', close);
|
||||
windowRef?.addEventListener('popstate', () => {
|
||||
historyEntry = false;
|
||||
if (sheet.open) sheet.close();
|
||||
});
|
||||
presetButtons.forEach(button => button.addEventListener('click', () =>
|
||||
start(button.dataset.todayBreakMinutes)
|
||||
));
|
||||
form?.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
if (!start(Number(minutesInput?.value))) {
|
||||
if (error) error.textContent = 'Choose a whole number from 1 to 120 minutes.';
|
||||
minutesInput?.focus();
|
||||
}
|
||||
});
|
||||
resumeButton.addEventListener('click', () => {
|
||||
const pending = timer.breakSnapshot();
|
||||
if (!timer.resumeBreak()) return;
|
||||
onResume(pending.identity);
|
||||
render();
|
||||
onChange();
|
||||
});
|
||||
render();
|
||||
|
||||
return { open, close, start, render };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayBreak;
|
||||
@@ -1,17 +1,91 @@
|
||||
function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce, advance = null }) {
|
||||
return function completeTodayItem(item, options = {}) {
|
||||
if (!item || !todayWork.remove(item)) {
|
||||
function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce, advance = null,
|
||||
completeActivation = () => globalThis.stackchainFirstTaskCompleting?.(),
|
||||
now = Date.now, ttlMs = 10000, onOffer = null, onClear = null,
|
||||
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout,
|
||||
undo = [globalThis.document?.getElementById('today-completion-undo'),
|
||||
globalThis.document?.getElementById('undo-today-completion')] }) {
|
||||
const [undoReceipt, undoControl] = undo;
|
||||
let pending = null;
|
||||
let expiryTimer = null;
|
||||
|
||||
function clear() {
|
||||
if (expiryTimer !== null) clearTimer?.(expiryTimer);
|
||||
expiryTimer = null;
|
||||
pending = null;
|
||||
if (undoReceipt) undoReceipt.hidden = true;
|
||||
onClear?.();
|
||||
}
|
||||
|
||||
function completeTodayItem(item, options = {}) {
|
||||
const identity = item && todayWork.identity(item);
|
||||
const snapshot = item && (todayWork.capture?.(item) ||
|
||||
(identity ? { id: identity, index: 0, ids: [identity], plan: { capacity_minutes: null, estimates: {} } } : null));
|
||||
if (!snapshot || !todayWork.remove(item)) {
|
||||
announce(options.failureMessage || 'Could not update Today on this device. Try again.');
|
||||
return false;
|
||||
}
|
||||
todaySync.enqueue('remove', todayWork.identity(item));
|
||||
const operations = [
|
||||
{action:'remove', item_id:identity},
|
||||
...(completeActivation() ? [{action:'activate', item_id:'first-task', activation_state:'complete'}] : []),
|
||||
];
|
||||
const admitted = todaySync.enqueueBatch ? todaySync.enqueueBatch(operations) :
|
||||
todaySync.enqueue('remove', identity);
|
||||
if (!admitted) {
|
||||
todayWork.restore?.(snapshot);
|
||||
announce(options.admissionFailureMessage ||
|
||||
'Device storage is full. Free space and try again.');
|
||||
return false;
|
||||
}
|
||||
todaySync.flush();
|
||||
refresh();
|
||||
warm();
|
||||
(advance || (() => workSession.complete()))();
|
||||
announce(options.successMessage || 'Done for Today. The Gitea item is unchanged.');
|
||||
pending = { snapshot, expires_at: now() + ttlMs };
|
||||
if (undoReceipt) undoReceipt.hidden = false;
|
||||
if (undoControl) undoControl.disabled = false;
|
||||
onOffer?.({ identity: snapshot.id, expires_at: pending.expires_at });
|
||||
expiryTimer = setTimer?.(clear, ttlMs) ?? null;
|
||||
expiryTimer?.unref?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
completeTodayItem.undo = () => {
|
||||
if (!pending) return 'missing';
|
||||
if (now() >= pending.expires_at) {
|
||||
clear();
|
||||
announce('Undo expired. Add the item back to Today from My Work.');
|
||||
return 'expired';
|
||||
}
|
||||
const snapshot = pending.snapshot;
|
||||
const result = todayWork.restore?.(snapshot) || 'unavailable';
|
||||
if (result !== 'restored') {
|
||||
const messages = {
|
||||
full: 'Today is full. Remove another item before adding this work back.',
|
||||
changed: 'Today changed on this device. Add the item back from My Work.',
|
||||
unavailable: 'Could not restore Today on this device. Add the item back from My Work.',
|
||||
};
|
||||
clear();
|
||||
announce(messages[result] || messages.unavailable);
|
||||
return result;
|
||||
}
|
||||
todaySync.enqueue('add', snapshot.id);
|
||||
for (let index = snapshot.ids.length - 1; index > snapshot.index; index -= 1) {
|
||||
todaySync.enqueue('move', snapshot.id, 'up');
|
||||
}
|
||||
todaySync.flush();
|
||||
refresh();
|
||||
warm();
|
||||
clear();
|
||||
announce('Restored to Today. Your current work session is unchanged.');
|
||||
return 'restored';
|
||||
};
|
||||
completeTodayItem.clear = clear;
|
||||
undoControl?.addEventListener?.('click', () => {
|
||||
undoControl.disabled = true;
|
||||
if (completeTodayItem.undo() !== 'restored') undoControl.disabled = false;
|
||||
});
|
||||
return completeTodayItem;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayCompletion;
|
||||
@@ -0,0 +1,154 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createTodayHandoff = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createTodayHandoff(options) {
|
||||
const prefix = 'stackchain.today-handoff.v1.';
|
||||
const storage = options.storage || null;
|
||||
const selected = new Set();
|
||||
const excluded = new Set();
|
||||
|
||||
function key() {
|
||||
const login = String(options.getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? prefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function pending() {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return [];
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(storageKey) || '[]');
|
||||
return Array.isArray(value) ? value.filter(id => typeof id === 'string' && id) : [];
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function write(ids) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const unique = [...new Set(ids.filter(id => typeof id === 'string' && id))];
|
||||
try {
|
||||
if (unique.length) storage.setItem(storageKey, JSON.stringify(unique));
|
||||
else storage.removeItem(storageKey);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function capture(ids) {
|
||||
const merged = [...pending()];
|
||||
(ids || []).forEach(id => {
|
||||
if (typeof id === 'string' && id && !merged.includes(id)) merged.push(id);
|
||||
if (typeof id === 'string' && id) {
|
||||
selected.add(id);
|
||||
excluded.delete(id);
|
||||
}
|
||||
});
|
||||
return write(merged);
|
||||
}
|
||||
|
||||
function review(items) {
|
||||
const byIdentity = new Map((items || options.items?.() || []).map(item => [options.identity(item), item]));
|
||||
return pending().flatMap(identity => {
|
||||
const item = byIdentity.get(identity);
|
||||
if (!excluded.has(identity)) selected.add(identity);
|
||||
return item ? [{ identity, item, selected:selected.has(identity) }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function choose(identity, include) {
|
||||
if (!pending().includes(identity)) return false;
|
||||
if (include) {
|
||||
selected.add(identity);
|
||||
excluded.delete(identity);
|
||||
} else {
|
||||
selected.delete(identity);
|
||||
excluded.add(identity);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function render(items, list, escapeHtml) {
|
||||
const rows = review(items);
|
||||
list.innerHTML = rows.map(row => {
|
||||
const title = String(row.item.title || row.identity).slice(0, 180);
|
||||
const context = String(row.item.key || row.item.repository || 'Work item').slice(0, 180);
|
||||
return '<div class="today-handoff-item"><span><strong>' + escapeHtml(title) + '</strong>' +
|
||||
'<span class="small muted">' + escapeHtml(context) + '</span></span><label><input type="checkbox" ' +
|
||||
'data-today-handoff="' + escapeHtml(row.identity) + '"' + (row.selected ? ' checked' : '') +
|
||||
'> Today</label></div>';
|
||||
}).join('');
|
||||
list.querySelectorAll('[data-today-handoff]').forEach(input => input.addEventListener('change', () =>
|
||||
choose(input.dataset.todayHandoff, input.checked)));
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function commit(items) {
|
||||
const rows = review(items);
|
||||
const remaining = pending();
|
||||
let scheduled = 0;
|
||||
let blocked = 0;
|
||||
for (const row of rows) {
|
||||
if (!selected.has(row.identity)) continue;
|
||||
if (options.todayWork.contains(row.item)) {
|
||||
const index = remaining.indexOf(row.identity);
|
||||
if (index >= 0) remaining.splice(index, 1);
|
||||
selected.delete(row.identity);
|
||||
continue;
|
||||
}
|
||||
const added = options.todayWork.add(row.item);
|
||||
if (added !== 'added') {
|
||||
blocked += 1;
|
||||
continue;
|
||||
}
|
||||
if (!options.todaySync.enqueue('add', row.identity)) {
|
||||
options.todayWork.remove?.(row.item);
|
||||
blocked += 1;
|
||||
continue;
|
||||
}
|
||||
const index = remaining.indexOf(row.identity);
|
||||
if (index >= 0) remaining.splice(index, 1);
|
||||
selected.delete(row.identity);
|
||||
scheduled += 1;
|
||||
}
|
||||
write(remaining);
|
||||
if (scheduled) await options.todaySync.flush();
|
||||
return { scheduled, blocked, remaining:remaining.length };
|
||||
}
|
||||
|
||||
function mount({ qs, escapeHtml, onComplete = () => {} }) {
|
||||
const list = qs('#today-handoff-items');
|
||||
let prompted = false;
|
||||
function open() {
|
||||
if (prompted) return false;
|
||||
const rows = render(null, list, escapeHtml);
|
||||
if (!rows.length) return false;
|
||||
qs('#today-handoff-status').textContent = rows.length +
|
||||
(rows.length === 1 ? ' commitment is ready to review.' : ' commitments are ready to review.');
|
||||
const dialog = qs('#today-handoff-dialog');
|
||||
if (!dialog.open) dialog.showModal();
|
||||
prompted = true;
|
||||
requestAnimationFrame(() => (list.querySelector('input') || qs('#confirm-today-handoff')).focus());
|
||||
return true;
|
||||
}
|
||||
qs('#close-today-handoff').addEventListener('click', () => qs('#today-handoff-dialog').close());
|
||||
qs('#confirm-today-handoff').addEventListener('click', async event => {
|
||||
const button = event.currentTarget;
|
||||
button.disabled = true;
|
||||
qs('#today-handoff-status').textContent = 'Saving Today’s plan…';
|
||||
try {
|
||||
const result = await commit();
|
||||
if (result.blocked) qs('#today-handoff-status').textContent =
|
||||
'Today is full. ' + result.remaining + ' commitment remains safe in My Work.';
|
||||
else qs('#today-handoff-dialog').close();
|
||||
onComplete(result);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
return { open, reset:() => { prompted = false; } };
|
||||
}
|
||||
|
||||
return { capture, choose, commit, mount, pending, render, review };
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
function createTodayLockScreen({
|
||||
storage,
|
||||
getLogin,
|
||||
serviceWorker,
|
||||
NotificationRef,
|
||||
control,
|
||||
status,
|
||||
locationRef,
|
||||
historyRef,
|
||||
randomToken = () => {
|
||||
const bytes = new Uint8Array(16);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('');
|
||||
},
|
||||
fingerprint = async (token, identity) => {
|
||||
const input = new TextEncoder().encode(token + '\0' + identity);
|
||||
const digest = await globalThis.crypto.subtle.digest('SHA-256', input);
|
||||
return Array.from(new Uint8Array(digest), value =>
|
||||
value.toString(16).padStart(2, '0')).join('');
|
||||
},
|
||||
onAction = () => {},
|
||||
}) {
|
||||
let activeIdentity = '';
|
||||
let activeActionIdentity = '';
|
||||
let activeBreakDeadline = 0;
|
||||
const preferenceKey = () => {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? 'stackchain.today-lock-screen.v1.' + encodeURIComponent(login) : '';
|
||||
};
|
||||
const supported = Boolean(serviceWorker && NotificationRef);
|
||||
const actionKey = () => {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? 'stackchain.today-lock-screen-action.v1.' + encodeURIComponent(login) : '';
|
||||
};
|
||||
const readAction = () => {
|
||||
const key = actionKey();
|
||||
if (!key) return null;
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(key) || 'null');
|
||||
return typeof value?.token === 'string' && value.token &&
|
||||
typeof value.fingerprint === 'string' && value.fingerprint ? value : null;
|
||||
} catch (_error) { return null; }
|
||||
};
|
||||
const clearAction = () => {
|
||||
const key = actionKey();
|
||||
try { if (key) storage?.removeItem(key); }
|
||||
catch (_error) { return false; }
|
||||
return true;
|
||||
};
|
||||
const actionFor = async identity => {
|
||||
const existing = readAction();
|
||||
if (existing && await fingerprint(existing.token, identity) === existing.fingerprint) return existing;
|
||||
const key = actionKey();
|
||||
if (!key) return null;
|
||||
try {
|
||||
const token = String(randomToken() || '');
|
||||
const value = { token, fingerprint:token ? await fingerprint(token, identity) : '' };
|
||||
if (!value.token) return null;
|
||||
storage?.setItem(key, JSON.stringify(value));
|
||||
return value;
|
||||
} catch (_error) { return null; }
|
||||
};
|
||||
const enabled = () => {
|
||||
const key = preferenceKey();
|
||||
return Boolean(key && storage?.getItem(key) === '1');
|
||||
};
|
||||
const setStatus = message => {
|
||||
if (status) status.textContent = message;
|
||||
};
|
||||
const post = async message => {
|
||||
const target = serviceWorker?.controller || (await serviceWorker?.ready)?.active;
|
||||
target?.postMessage?.(message);
|
||||
};
|
||||
const hide = () => post({
|
||||
type:'stackchain-today-lock-screen', active:false, running:false,
|
||||
});
|
||||
const render = () => {
|
||||
if (control) {
|
||||
control.checked = enabled();
|
||||
control.disabled = !supported;
|
||||
}
|
||||
if (!supported) setStatus('Lock-screen controls are not supported on this device.');
|
||||
else if (enabled()) setStatus('Lock-screen Today controls are on.');
|
||||
};
|
||||
const consumeAction = async (action, token = '') => {
|
||||
if (!enabled() || !['pause', 'resume', 'complete'].includes(action)) return false;
|
||||
const requiresToken = action === 'complete' || (action === 'resume' && activeBreakDeadline > 0);
|
||||
if (!requiresToken) {
|
||||
onAction(action, null);
|
||||
return true;
|
||||
}
|
||||
const pending = readAction();
|
||||
if (!pending || !token || !activeIdentity || pending.token !== token ||
|
||||
await fingerprint(token, activeActionIdentity) !== pending.fingerprint) return false;
|
||||
if (!clearAction()) return false;
|
||||
onAction(action, activeIdentity);
|
||||
return true;
|
||||
};
|
||||
const api = {
|
||||
enabled,
|
||||
async enable() {
|
||||
if (!supported) {
|
||||
render();
|
||||
return false;
|
||||
}
|
||||
const permission = NotificationRef.permission === 'granted' ?
|
||||
'granted' : await NotificationRef.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
if (control) control.checked = false;
|
||||
setStatus(permission === 'denied' ?
|
||||
'Lock-screen controls are blocked in browser settings.' :
|
||||
'Lock-screen controls were not enabled.');
|
||||
return false;
|
||||
}
|
||||
const key = preferenceKey();
|
||||
if (!key) return false;
|
||||
storage?.setItem(key, '1');
|
||||
render();
|
||||
return true;
|
||||
},
|
||||
async disable() {
|
||||
const key = preferenceKey();
|
||||
if (key) storage?.removeItem(key);
|
||||
clearAction();
|
||||
if (control) control.checked = false;
|
||||
setStatus('Lock-screen Today controls are off.');
|
||||
await hide();
|
||||
return true;
|
||||
},
|
||||
async sync(snapshot, active) {
|
||||
if (!enabled() || NotificationRef?.permission !== 'granted') return false;
|
||||
const visible = Boolean(active && snapshot?.identity);
|
||||
activeIdentity = visible ? snapshot.identity : '';
|
||||
const rawBreakDeadline = Number(snapshot?.break_deadline_at);
|
||||
activeBreakDeadline = visible && Number.isSafeInteger(rawBreakDeadline) && rawBreakDeadline > 0 ?
|
||||
rawBreakDeadline : 0;
|
||||
activeActionIdentity = activeIdentity + (activeBreakDeadline ? '\0break:' + activeBreakDeadline : '');
|
||||
const pending = visible ? await actionFor(activeActionIdentity) : null;
|
||||
if (!visible) clearAction();
|
||||
await post({
|
||||
type:'stackchain-today-lock-screen',
|
||||
active:visible,
|
||||
running:visible && Boolean(snapshot.running),
|
||||
...(activeBreakDeadline ? { breakDeadlineAt:activeBreakDeadline } : {}),
|
||||
...(pending?.token ? { actionToken:pending.token } : {}),
|
||||
});
|
||||
if (visible && !pending) setStatus('Finish current is unavailable because its one-time action could not be saved.');
|
||||
return true;
|
||||
},
|
||||
consumeAction,
|
||||
async consumeLaunchAction() {
|
||||
if (!enabled()) return false;
|
||||
await post({type:'stackchain-claim-today-action'});
|
||||
return true;
|
||||
},
|
||||
render,
|
||||
};
|
||||
control?.addEventListener?.('change', () => {
|
||||
if (control.checked) api.enable();
|
||||
else api.disable();
|
||||
});
|
||||
serviceWorker?.addEventListener?.('message', event => {
|
||||
if (event.data?.type === 'stackchain-today-timer-action') {
|
||||
consumeAction(String(event.data.action || ''), String(event.data.actionToken || ''));
|
||||
}
|
||||
});
|
||||
render();
|
||||
return api;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayLockScreen;
|
||||
@@ -0,0 +1,553 @@
|
||||
function createTodayProgress({ storage, getLogin = () => '', admit, makeId = () =>
|
||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
||||
maxLength = 2000, maxItems = 20 }) {
|
||||
const prefix = 'stackchain.today-progress.v1.';
|
||||
const login = () => String(getLogin() || '').trim().toLowerCase();
|
||||
const storageKey = () => login() ? prefix + encodeURIComponent(login()) : '';
|
||||
const validIdentity = identity => typeof identity === 'string' && identity.length > 0 && identity.length <= 500;
|
||||
const attachmentFingerprint = attachments => JSON.stringify((Array.isArray(attachments) ? attachments : []).filter(Boolean).slice(0, 5).map(value => ({
|
||||
filename:String(value.filename || ''), contentType:String(value.contentType || ''),
|
||||
note:String(value.note || '').replace(/\s+/g, ' ').trim().slice(0, 240),
|
||||
operationId:String(value.operationId || '').slice(0, 128),
|
||||
markdown:String(value.confirmed?.markdown || ''),
|
||||
})));
|
||||
const payloadFingerprint = (body, attachments) => JSON.stringify({body, attachments:attachmentFingerprint(attachments)});
|
||||
const validRecord = record => record && typeof record.body === 'string' && (record.body.length > 0 || record.has_attachments === true) &&
|
||||
record.body.length <= maxLength && typeof record.operation_id === 'string' && record.operation_id.length > 0 &&
|
||||
record.operation_id.length <= 128;
|
||||
|
||||
function read() {
|
||||
const key = storageKey();
|
||||
if (!storage || !key) return {};
|
||||
try {
|
||||
const saved = JSON.parse(storage.getItem(key) || 'null');
|
||||
if (!saved || saved.version !== 1 || !saved.drafts || typeof saved.drafts !== 'object' ||
|
||||
Array.isArray(saved.drafts)) return {};
|
||||
const entries = Object.entries(saved.drafts).filter(([identity, record]) =>
|
||||
validIdentity(identity) && validRecord(record)
|
||||
).slice(0, maxItems);
|
||||
return Object.fromEntries(entries);
|
||||
} catch (_error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function write(drafts) {
|
||||
const key = storageKey();
|
||||
if (!storage || !key) return false;
|
||||
try {
|
||||
if (Object.keys(drafts).length) storage.setItem(key, JSON.stringify({ version:1, drafts }));
|
||||
else storage.removeItem(key);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function load(identity) {
|
||||
if (!validIdentity(identity)) return '';
|
||||
return read()[identity]?.body || '';
|
||||
}
|
||||
|
||||
function save(identity, value, attachments = []) {
|
||||
if (!validIdentity(identity) || !storageKey()) return false;
|
||||
const body = String(value || '').trim();
|
||||
const hasAttachments = attachments === true || (Array.isArray(attachments) && attachments.filter(Boolean).length > 0);
|
||||
if (body.length > maxLength) return false;
|
||||
const drafts = read();
|
||||
if (!body && !hasAttachments) {
|
||||
delete drafts[identity];
|
||||
return write(drafts);
|
||||
}
|
||||
if (!drafts[identity] && Object.keys(drafts).length >= maxItems) return false;
|
||||
const previous = drafts[identity];
|
||||
const fingerprint = payloadFingerprint(body, Array.isArray(attachments) ? attachments : []);
|
||||
const previousFingerprint = previous?.payload_fingerprint ||
|
||||
(previous && !previous.has_attachments ? payloadFingerprint(previous.body, []) : '');
|
||||
drafts[identity] = {
|
||||
body,
|
||||
operation_id: previousFingerprint === fingerprint ? previous.operation_id : String(makeId()).slice(0, 128),
|
||||
payload_fingerprint:fingerprint,
|
||||
...(hasAttachments ? {has_attachments:true} : {}),
|
||||
};
|
||||
return write(drafts);
|
||||
}
|
||||
|
||||
function validTarget(target) {
|
||||
return target && ['issue', 'pull'].includes(target.kind) && validIdentity(target.identity) &&
|
||||
typeof target.repository === 'string' && target.repository.length > 0 && target.repository.length <= 200 &&
|
||||
Number.isInteger(target.number) && target.number > 0;
|
||||
}
|
||||
|
||||
async function post(target, value, attachments = [], completeEvidence) {
|
||||
if (!validTarget(target)) throw new Error('An active Today issue or pull request is required.');
|
||||
const pendingRecord = read()[target.identity];
|
||||
if (pendingRecord?.cleanup_pending === true) {
|
||||
try {
|
||||
if (typeof completeEvidence === 'function') await completeEvidence();
|
||||
} catch (_error) {
|
||||
const error = new Error('Progress update is already queued; photo cleanup is pending.');
|
||||
error.deliveryAdmitted = true;
|
||||
throw error;
|
||||
}
|
||||
const pendingDrafts = read();
|
||||
if (pendingDrafts[target.identity]?.operation_id === pendingRecord.operation_id) {
|
||||
delete pendingDrafts[target.identity];
|
||||
write(pendingDrafts);
|
||||
}
|
||||
return { background:true, alreadyAdmitted:true, cleanupRecovered:true };
|
||||
}
|
||||
const evidence = (Array.isArray(attachments) ? attachments : [attachments]).filter(Boolean).slice(0, 5);
|
||||
if ((value !== undefined || evidence.length) && !save(target.identity, value ?? load(target.identity), evidence)) {
|
||||
throw new Error('Progress update could not be saved on this device.');
|
||||
}
|
||||
const record = read()[target.identity];
|
||||
if (!record) throw new Error('Write a progress update before posting.');
|
||||
if (typeof admit !== 'function') throw new Error('Progress update delivery is unavailable.');
|
||||
const admission = await admit({
|
||||
kind:target.kind + '-comment', repository:target.repository, number:target.number,
|
||||
body:record.body, operationId:record.operation_id,
|
||||
...(evidence.length ? {attachments:evidence} : {}),
|
||||
});
|
||||
const admittedDrafts = read();
|
||||
if (admittedDrafts[target.identity]?.operation_id === record.operation_id) {
|
||||
admittedDrafts[target.identity] = { ...admittedDrafts[target.identity], cleanup_pending:true };
|
||||
write(admittedDrafts);
|
||||
}
|
||||
try {
|
||||
if (typeof completeEvidence === 'function') await completeEvidence();
|
||||
} catch (_error) {
|
||||
const error = new Error('Progress update is already queued; photo cleanup is pending.');
|
||||
error.deliveryAdmitted = true;
|
||||
throw error;
|
||||
}
|
||||
const drafts = read();
|
||||
if (drafts[target.identity]?.operation_id === record.operation_id) {
|
||||
delete drafts[target.identity];
|
||||
write(drafts);
|
||||
}
|
||||
return admission;
|
||||
}
|
||||
|
||||
async function postBlocker(target, value, attachments = [], completeEvidence, until, transition) {
|
||||
if (!validTarget(target)) throw new Error('An active Today issue or pull request is required.');
|
||||
if (typeof transition !== 'function') throw new Error('Today planning is unavailable.');
|
||||
let record = read()[target.identity];
|
||||
let admission = { background:true, alreadyAdmitted:true };
|
||||
if (record?.blocker_pending !== true) {
|
||||
const body = String(value ?? record?.body ?? '').trim();
|
||||
if (!body) throw new Error('Describe what is blocking this Today item.');
|
||||
const evidence = (Array.isArray(attachments) ? attachments : [attachments]).filter(Boolean).slice(0, 5);
|
||||
if (!save(target.identity, body, evidence)) {
|
||||
throw new Error('Blocker update could not be saved on this device.');
|
||||
}
|
||||
record = read()[target.identity];
|
||||
if (typeof admit !== 'function') throw new Error('Progress update delivery is unavailable.');
|
||||
admission = await admit({
|
||||
kind:target.kind + '-comment', repository:target.repository, number:target.number,
|
||||
body:record.body, operationId:record.operation_id,
|
||||
...(evidence.length ? {attachments:evidence} : {}),
|
||||
});
|
||||
const drafts = read();
|
||||
if (drafts[target.identity]?.operation_id === record.operation_id) {
|
||||
drafts[target.identity] = { ...drafts[target.identity], blocker_pending:true, blocker_until:String(until || '') };
|
||||
write(drafts);
|
||||
}
|
||||
} else {
|
||||
const replacementUntil = String(until || '').trim();
|
||||
until = replacementUntil || record.blocker_until;
|
||||
if (replacementUntil && replacementUntil !== record.blocker_until) {
|
||||
const drafts = read();
|
||||
if (drafts[target.identity]?.operation_id === record.operation_id) {
|
||||
drafts[target.identity] = { ...drafts[target.identity], blocker_until:replacementUntil };
|
||||
write(drafts);
|
||||
record = drafts[target.identity];
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (typeof completeEvidence === 'function') await completeEvidence();
|
||||
} catch (_error) {
|
||||
const error = new Error('Blocker is already queued; photo cleanup is pending.');
|
||||
error.deliveryAdmitted = true;
|
||||
throw error;
|
||||
}
|
||||
if (await transition(target, until) !== true) {
|
||||
const error = new Error('Blocker was posted, but Today could not move on. Retry the planning step.');
|
||||
error.deliveryAdmitted = true;
|
||||
throw error;
|
||||
}
|
||||
const drafts = read();
|
||||
if (drafts[target.identity]?.operation_id === record.operation_id) {
|
||||
delete drafts[target.identity];
|
||||
write(drafts);
|
||||
}
|
||||
return admission;
|
||||
}
|
||||
|
||||
function blockerRecovery(identity) {
|
||||
if (!validIdentity(identity)) return null;
|
||||
const record = read()[identity];
|
||||
return record?.blocker_pending === true ? { pending:true, until:String(record.blocker_until || '') } : null;
|
||||
}
|
||||
|
||||
return { load, save, discard:identity => save(identity, ''), post, postBlocker, blockerRecovery };
|
||||
}
|
||||
|
||||
function createTodayProgressActivity({ fetchJson, createPager, getActions, onActions, surfaceStatus, paint = () => {}, setStatus = () => {} }) {
|
||||
let requestToken = 0;
|
||||
let pager = null;
|
||||
let target = null;
|
||||
let actions = null;
|
||||
|
||||
const pathFor = (value, page) => {
|
||||
const repository = String(value.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
const resource = value.kind === 'pull' ? 'pulls' : 'issues';
|
||||
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(value.number) +
|
||||
'/comments?' + (Number.isInteger(page) ? 'page=' + encodeURIComponent(page) + '&' : '') + 'limit=20';
|
||||
};
|
||||
const loadPage = page => fetchJson(pathFor(target, page));
|
||||
|
||||
async function open(value) {
|
||||
const token = ++requestToken;
|
||||
target = { ...value };
|
||||
setStatus('Loading recent activity…');
|
||||
try {
|
||||
const page = await loadPage();
|
||||
if (token !== requestToken || target.identity !== value.identity) return false;
|
||||
pager = createPager({ loadPage });
|
||||
actions = typeof getActions === 'function' ? await getActions() : null;
|
||||
if (token !== requestToken || target.identity !== value.identity) return false;
|
||||
onActions?.(actions);
|
||||
paint(pager.reset(page));
|
||||
setStatus('');
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (token === requestToken) setStatus('Recent activity unavailable. Retry.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOlder() {
|
||||
if (!pager || !target) return false;
|
||||
const token = requestToken;
|
||||
setStatus('Loading older activity…');
|
||||
try {
|
||||
const page = await pager.loadOlder();
|
||||
if (token !== requestToken) return false;
|
||||
paint(page);
|
||||
setStatus('');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (token === requestToken) setStatus('Older activity unavailable. Retry.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
retry:() => target ? open(target) : Promise.resolve(false),
|
||||
loadOlder,
|
||||
actionHtml:comment => actions?.actionHtml?.(comment) || '',
|
||||
surface:() => ({
|
||||
context:{ kind:target?.kind, item:target }, pager,
|
||||
status:surfaceStatus, render:paint,
|
||||
}),
|
||||
close:() => { requestToken++; target = null; pager = null; actions = null; setStatus(''); },
|
||||
};
|
||||
}
|
||||
|
||||
function mountTodayProgressActivity(qs, fetchJson, actionSource = {}, render) {
|
||||
const options = actionSource.get && !actionSource.getActions ?
|
||||
{ getActions:actionSource.get, isOffline:() => !navigator.onLine } : actionSource;
|
||||
const escape = options.escape || (value => String(value).replace(/[&<>"']/g,
|
||||
character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[character]));
|
||||
render ||= options.render;
|
||||
const list = qs('#today-progress-activity-list');
|
||||
const optionsStatus = qs('#today-progress-activity-status');
|
||||
let actionsWired = false;
|
||||
const activity = createTodayProgressActivity({
|
||||
fetchJson,
|
||||
createPager:options.createPager || createConversationPager,
|
||||
getActions:options.getActions,
|
||||
surfaceStatus:optionsStatus,
|
||||
onActions:actions => {
|
||||
if (actionsWired || !actions?.wire) return;
|
||||
actions.wire({ root:list, getSurface:() => activity.surface(),
|
||||
isOffline:options.isOffline || (() => false), escapeHtml:escape });
|
||||
actionsWired = true;
|
||||
},
|
||||
paint:state => {
|
||||
list.innerHTML = (state.comments || []).map(comment => {
|
||||
const author = comment.author || comment.user?.login || 'Unknown author';
|
||||
const timing = comment.created_at ? ' · ' + new Date(comment.created_at).toLocaleString() : '';
|
||||
return '<article class="today-progress-activity-item issue-comment" data-comment-id="' +
|
||||
escape(String(comment.id)) + '"><div class="small muted">' +
|
||||
escape(author) + escape(timing) + '</div><div class="markdown-content">' +
|
||||
render(comment.body || 'No message body provided.') + '</div>' +
|
||||
activity.actionHtml(comment) + '</article>';
|
||||
}).join('');
|
||||
qs('#today-progress-activity-count').textContent = state.total ?
|
||||
String(state.comments.length) + ' of ' + String(state.total) + ' messages' : '';
|
||||
qs('#load-older-today-progress-activity').hidden = !Number.isInteger(state.older_page);
|
||||
},
|
||||
setStatus:message => {
|
||||
optionsStatus.textContent = message;
|
||||
qs('#retry-today-progress-activity').hidden = !message.includes('unavailable');
|
||||
},
|
||||
});
|
||||
qs('#retry-today-progress-activity').addEventListener('click', () => activity.retry());
|
||||
qs('#load-older-today-progress-activity').addEventListener('click', async () => {
|
||||
const list = qs('#today-progress-activity-list');
|
||||
const previousHeight = list.scrollHeight;
|
||||
const button = qs('#load-older-today-progress-activity');
|
||||
button.disabled = true;
|
||||
await activity.loadOlder();
|
||||
list.scrollTop += list.scrollHeight - previousHeight;
|
||||
button.disabled = false;
|
||||
});
|
||||
return activity;
|
||||
}
|
||||
|
||||
function createTodayProgressView({ progress, currentTarget, qs, photos, voice, activity, mentions, announce = () => {}, onAdmitted = () => {}, moveOn = null, now = () => new Date() }) {
|
||||
const sheet = qs('#today-progress-sheet');
|
||||
const panel = qs('.today-progress-panel');
|
||||
const title = qs('#today-progress-title');
|
||||
const body = qs('#today-progress-body');
|
||||
const bodyLabel = qs('#today-progress-body-label');
|
||||
const status = qs('#today-progress-status');
|
||||
const launcher = qs('[data-mobile-today-update]');
|
||||
const blockerLauncher = qs('[data-mobile-today-blocked]');
|
||||
const blockerButton = qs('#post-today-blocker');
|
||||
const blockerReturn = qs('#today-blocker-return-at');
|
||||
const blockerRecovery = qs('#today-progress-blocker-recovery');
|
||||
const saveButton = qs('#save-today-progress');
|
||||
const postButton = qs('#post-today-progress');
|
||||
let openedTarget = null;
|
||||
|
||||
const localDateTime = value => {
|
||||
const date = new Date(value || '');
|
||||
if (!Number.isFinite(date.getTime())) return '';
|
||||
return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
||||
};
|
||||
const showBlockerRecovery = recovery => {
|
||||
const pending = recovery?.pending === true;
|
||||
body.disabled = pending;
|
||||
saveButton.disabled = pending;
|
||||
postButton.disabled = pending;
|
||||
if (blockerButton) blockerButton.textContent = pending ? 'Finish moving on' : 'Post blocker & move on';
|
||||
if (blockerReturn) blockerReturn.value = pending ? localDateTime(recovery.until) : '';
|
||||
if (blockerRecovery) {
|
||||
blockerRecovery.hidden = !pending;
|
||||
blockerRecovery.textContent = pending ? 'Blocker queued—finish moving on. The posted update cannot be changed.' : '';
|
||||
}
|
||||
};
|
||||
|
||||
const update = () => {
|
||||
const target = currentTarget();
|
||||
launcher.hidden = !target;
|
||||
if (blockerLauncher) blockerLauncher.hidden = !target;
|
||||
if (openedTarget && target?.identity !== openedTarget.identity) mentions?.dismiss?.();
|
||||
};
|
||||
const checkpoint = async () => {
|
||||
if (!openedTarget) return false;
|
||||
try {
|
||||
await photos?.checkpoint?.();
|
||||
const checkpointAttachments = await photos?.serialize?.() || [];
|
||||
if (progress.save(openedTarget.identity, body.value, checkpointAttachments)) return true;
|
||||
status.textContent = 'Update must be 2,000 characters or fewer and device storage must be available.';
|
||||
return false;
|
||||
} catch (error) {
|
||||
status.textContent = error.message + ' Your photos remain here; retry.';
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const close = () => {
|
||||
voice?.cancel?.();
|
||||
activity?.close?.();
|
||||
mentions?.dismiss?.();
|
||||
if (sheet.open) sheet.close();
|
||||
panel?.classList?.remove('blocker-mode');
|
||||
openedTarget = null;
|
||||
};
|
||||
|
||||
const open = async blockerMode => {
|
||||
const target = currentTarget();
|
||||
if (!target) return;
|
||||
mentions?.dismiss?.();
|
||||
openedTarget = target;
|
||||
panel?.classList?.[blockerMode ? 'add' : 'remove']('blocker-mode');
|
||||
if (title) title.textContent = blockerMode ? 'Report blocker' : 'Add progress update';
|
||||
if (bodyLabel) bodyLabel.textContent = blockerMode ? 'Blocker description' : 'Update';
|
||||
qs('#today-progress-target').textContent = target.label + (target.title ? ' · ' + target.title : '');
|
||||
body.value = progress.load(target.identity);
|
||||
showBlockerRecovery(progress.blockerRecovery?.(target.identity));
|
||||
sheet.showModal();
|
||||
if (!blockerMode) {
|
||||
status.textContent = 'Restoring saved photo evidence…';
|
||||
try {
|
||||
await voice?.open?.(target.identity);
|
||||
await photos?.open?.(target);
|
||||
await activity?.open?.(target);
|
||||
status.textContent = '';
|
||||
}
|
||||
catch (error) { status.textContent = error.message + ' You can retry by reopening this update.'; }
|
||||
}
|
||||
else status.textContent = '';
|
||||
body.focus();
|
||||
};
|
||||
launcher.addEventListener('click', () => open(false));
|
||||
blockerLauncher?.addEventListener('click', () => open(true));
|
||||
|
||||
const setReturn = preset => {
|
||||
const date = new Date(now());
|
||||
if (preset === 'later-today') date.setHours(date.getHours() + 4);
|
||||
else {
|
||||
date.setDate(date.getDate() + 1);
|
||||
date.setHours(9, 0, 0, 0);
|
||||
}
|
||||
blockerReturn.value = localDateTime(date);
|
||||
};
|
||||
qs('[data-today-blocker-preset="later-today"]')?.addEventListener('click', () => setReturn('later-today'));
|
||||
qs('[data-today-blocker-preset="tomorrow"]')?.addEventListener('click', () => setReturn('tomorrow'));
|
||||
qs('[data-today-blocker-custom]')?.addEventListener('click', () => blockerReturn?.focus?.());
|
||||
qs('#cancel-today-progress').addEventListener('click', async () => {
|
||||
if (await checkpoint()) close();
|
||||
});
|
||||
sheet.addEventListener('cancel', async event => {
|
||||
event.preventDefault();
|
||||
if (await checkpoint()) close();
|
||||
});
|
||||
qs('#save-today-progress').addEventListener('click', async () => {
|
||||
if (!await checkpoint()) return;
|
||||
announce('Progress update saved privately to this Today item.');
|
||||
close();
|
||||
});
|
||||
blockerButton?.addEventListener('click', async () => {
|
||||
const target = currentTarget();
|
||||
if (!target || target.identity !== openedTarget?.identity) {
|
||||
status.textContent = 'The active Today item changed. Close and open its update again.';
|
||||
return;
|
||||
}
|
||||
const returnInput = blockerReturn;
|
||||
const wakeAt = new Date(returnInput?.value || '');
|
||||
if (!body.value.trim()) {
|
||||
status.textContent = 'Describe what is blocking this Today item.';
|
||||
body.focus();
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(wakeAt.getTime()) || wakeAt.getTime() <= Date.now()) {
|
||||
status.textContent = 'Choose a valid future return time.';
|
||||
returnInput?.focus?.();
|
||||
return;
|
||||
}
|
||||
blockerButton.disabled = true;
|
||||
status.textContent = 'Posting blocker before moving Today…';
|
||||
try {
|
||||
await photos?.checkpoint?.();
|
||||
const attachments = await photos?.serialize?.() || [];
|
||||
const admission = await progress.postBlocker(
|
||||
target, body.value, attachments, () => photos?.complete?.(), wakeAt.toISOString(), moveOn
|
||||
);
|
||||
onAdmitted(admission);
|
||||
announce('Blocker queued, deferred to Later, and Today moved on.');
|
||||
close();
|
||||
} catch (error) {
|
||||
status.textContent = error.deliveryAdmitted ? error.message : error.message + ' Your blocker remains here; retry.';
|
||||
body.focus();
|
||||
} finally {
|
||||
blockerButton.disabled = false;
|
||||
}
|
||||
});
|
||||
qs('#post-today-progress').addEventListener('click', async () => {
|
||||
const target = currentTarget();
|
||||
if (!target || target.identity !== openedTarget?.identity) {
|
||||
status.textContent = 'The active Today item changed. Close and open its update again.';
|
||||
return;
|
||||
}
|
||||
const button = qs('#post-today-progress');
|
||||
button.disabled = true;
|
||||
status.textContent = 'Saving for delivery…';
|
||||
try {
|
||||
await photos?.checkpoint?.();
|
||||
const attachments = await photos?.serialize?.() || [];
|
||||
const admission = await progress.post(target, body.value, attachments, () => photos?.complete?.());
|
||||
onAdmitted(admission);
|
||||
announce(admission.background ?
|
||||
'Progress update queued for delivery. Today is still on the same item.' :
|
||||
'Progress update saved for next launch. Today is still on the same item.');
|
||||
close();
|
||||
} catch (error) {
|
||||
status.textContent = error.deliveryAdmitted ?
|
||||
error.message + ' Retry to finish local cleanup; delivery will not be queued again.' :
|
||||
error.message + ' Your update remains on this item; retry.';
|
||||
body.focus();
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
update();
|
||||
return { update };
|
||||
}
|
||||
|
||||
function createTodayProgressPhotos({ qs, document, issueAttachment, fetchJson, createStore, createDrafts, getLogin }) {
|
||||
let target = null;
|
||||
let drafts = null;
|
||||
const controller = issueAttachment.mount({
|
||||
maxFiles:5, input:qs('#today-progress-attachment'),
|
||||
inputs:[qs('#take-today-progress-photo'), qs('#today-progress-attachment')],
|
||||
preview:qs('#today-progress-attachment-preview'), image:qs('#today-progress-attachment-image'),
|
||||
meta:qs('#today-progress-attachment-meta'), remove:qs('#remove-today-progress-attachment'),
|
||||
tray:qs('#today-progress-attachment-tray'), earlier:qs('#move-today-progress-attachment-earlier'),
|
||||
later:qs('#move-today-progress-attachment-later'), note:qs('#today-progress-attachment-note'),
|
||||
noteLabel:qs('#today-progress-attachment-note-label'), status:qs('#today-progress-status'),
|
||||
onChange:() => drafts?.checkpoint('today').catch(() => {}),
|
||||
onCheckpoint:() => drafts.checkpoint('today'),
|
||||
readyMessage:'Photo ready to post with this Today update.',
|
||||
removedMessage:'Photo removed. Your progress text is unchanged.',
|
||||
editor:{
|
||||
document, edit:qs('#edit-today-progress-attachment'), dialog:qs('#issue-evidence-editor'),
|
||||
canvas:qs('#issue-evidence-editor-canvas'), exportCanvas:qs('#issue-evidence-editor-export'),
|
||||
crop:qs('#crop-issue-evidence'), redact:qs('#redact-issue-evidence'),
|
||||
highlight:qs('#highlight-issue-evidence'), arrow:qs('#arrow-issue-evidence'),
|
||||
undo:qs('#undo-issue-evidence-edit'), reset:qs('#reset-issue-evidence-edit'),
|
||||
cancel:qs('#cancel-issue-evidence-edit'), apply:qs('#apply-issue-evidence-edit'),
|
||||
status:qs('#issue-evidence-editor-status'), appliedMessage:'Edited photo ready for this Today update.',
|
||||
},
|
||||
createObjectURL:file => URL.createObjectURL(file), revokeObjectURL:url => URL.revokeObjectURL(url),
|
||||
upload:payload => {
|
||||
if (!target || target.repository !== payload.repository || Number(target.number) !== Number(payload.number)) {
|
||||
return Promise.reject(new Error('The active Today item changed. Reopen its update before posting.'));
|
||||
}
|
||||
const repository = payload.repository.split('/').map(encodeURIComponent).join('/');
|
||||
const resource = target.kind === 'pull' ? 'pulls' : 'issues';
|
||||
return fetchJson('api/v1/repos/' + repository + '/' + resource + '/' +
|
||||
encodeURIComponent(payload.number) + '/attachments', {
|
||||
method:'POST', headers:{Accept:'application/json','Idempotency-Key':payload.operation_id},
|
||||
body:issueAttachment.multipart(payload),
|
||||
});
|
||||
},
|
||||
});
|
||||
const store = createStore({ indexedDB:globalThis.indexedDB, getOwnerLogin:getLogin, scope:'today-progress' });
|
||||
drafts = createDrafts({ store, lanes:{ today:{ controller, onError:error => {
|
||||
qs('#today-progress-status').textContent = error.message + ' Your photos remain here; retry.';
|
||||
} } } });
|
||||
return {
|
||||
has:() => Boolean(controller.state()), serialize:() => controller.serialize(),
|
||||
checkpoint:() => drafts.checkpoint('today'),
|
||||
open:async value => {
|
||||
if (drafts.hasTarget('today')) await drafts.switchTo('today', value);
|
||||
else await drafts.open('today', value);
|
||||
target = { ...value };
|
||||
},
|
||||
complete:async () => { await drafts.complete('today'); target = null; },
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createTodayProgress;
|
||||
module.exports.createView = createTodayProgressView;
|
||||
module.exports.createPhotos = createTodayProgressPhotos;
|
||||
module.exports.createActivity = createTodayProgressActivity;
|
||||
module.exports.mountActivity = mountTodayProgressActivity;
|
||||
}
|
||||
@@ -217,7 +217,7 @@ async function saveTodayRecapTime(payload) {
|
||||
}
|
||||
|
||||
function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, escapeHtml,
|
||||
describeWork = () => null, adjustPlan = () => {} }) {
|
||||
describeWork = () => null, openWrapUp = () => {} }) {
|
||||
const minutes = value => String(Math.max(0, Number(value) || 0)) + 'm';
|
||||
const render = () => {
|
||||
const draft = recap.snapshot();
|
||||
@@ -283,6 +283,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
|
||||
qs('#open-today-recaps').focus();
|
||||
};
|
||||
const saveDraft = async button => {
|
||||
const workedItems = todayRecapFeedbackRows(recap.snapshot(), describeWork);
|
||||
button.disabled = true;
|
||||
qs('#today-recap-status').textContent = 'Saving recap…';
|
||||
try {
|
||||
@@ -290,7 +291,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
|
||||
qs('#today-recap-status').textContent = 'Recap saved to your account.';
|
||||
await loadHistory(); render(); button.hidden = true;
|
||||
close();
|
||||
adjustPlan(handoff.actual_minutes);
|
||||
openWrapUp(handoff.actual_minutes, workedItems);
|
||||
} catch (error) {
|
||||
qs('#today-recap-status').textContent = error.message || 'Recap could not be saved. Your timer is unchanged.';
|
||||
} finally { button.disabled = false; }
|
||||
@@ -303,6 +304,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
|
||||
return;
|
||||
}
|
||||
const actualMinutes = Object.fromEntries(recap.snapshot().items.map(item => [item.identity, item.actual_minutes]));
|
||||
const workedItems = todayRecapFeedbackRows(recap.snapshot(), describeWork);
|
||||
button.disabled = true;
|
||||
qs('#today-recap-status').textContent = 'Logging selected time…';
|
||||
try {
|
||||
@@ -310,7 +312,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
|
||||
qs('#today-recap-status').textContent = 'Selected time logged to Gitea.';
|
||||
await loadHistory(); render(); button.hidden = true;
|
||||
close();
|
||||
adjustPlan(actualMinutes);
|
||||
openWrapUp(actualMinutes, workedItems);
|
||||
} catch (error) {
|
||||
qs('#today-recap-status').textContent = error.message || 'Time could not be logged. Your recap is ready to retry.';
|
||||
render();
|
||||
@@ -345,8 +347,8 @@ function openTodayRecapAfterSession(view, timer, timerView, workFilter, qs) {
|
||||
}
|
||||
|
||||
function setupTodayRecap(timer, timerView, todayWork, api, qs, escapeHtml, closeSheets, updateActions, getLogin,
|
||||
describeWork, adjustPlan) {
|
||||
const options = { timer, timerView, todayWork, api, qs, escapeHtml, describeWork, adjustPlan };
|
||||
describeWork, openWrapUp) {
|
||||
const options = { timer, timerView, todayWork, api, qs, escapeHtml, describeWork, openWrapUp };
|
||||
const recap = createTodayRecap({
|
||||
save:saveTodayRecap, saveAndLog:saveTodayRecapTime,
|
||||
clear:() => timer.clearRecap(), storage:localStorage, getLogin,
|
||||
|
||||
@@ -4,7 +4,13 @@ function createTodayRollover(options = {}) {
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');
|
||||
|
||||
function timeZone() {
|
||||
return options.timeZone?.() || resolvedTimeZone();
|
||||
const candidate = options.timeZone?.() || resolvedTimeZone();
|
||||
try {
|
||||
new Intl.DateTimeFormat('en', { timeZone: candidate }).format(now());
|
||||
return candidate;
|
||||
} catch (_error) {
|
||||
return 'UTC';
|
||||
}
|
||||
}
|
||||
|
||||
function localDate() {
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
function createTodaySessionSync({
|
||||
fetchJson, getDeviceId, timer, onRemote = () => {}, onTransferred = () => {}, onStatus = () => {},
|
||||
onOwnedRestore = () => {},
|
||||
setInterval = globalThis.setInterval, clearInterval = globalThis.clearInterval,
|
||||
}) {
|
||||
let current = null;
|
||||
let ownedRevision = 0;
|
||||
let pollTimer = null;
|
||||
let requestTail = Promise.resolve();
|
||||
let latestSnapshot = null;
|
||||
let publishRequested = 0;
|
||||
let publishSent = 0;
|
||||
const endpoint = 'api/v1/today/session';
|
||||
const deviceId = () => String(getDeviceId?.() || '').trim();
|
||||
|
||||
function serialize(operation) {
|
||||
const result = requestTail.then(operation, operation);
|
||||
requestTail = result.catch(() => null);
|
||||
return result;
|
||||
}
|
||||
|
||||
function adopt(session) {
|
||||
if (!session || !Number.isInteger(session.revision)) return null;
|
||||
const previousOwned = current?.device_id === deviceId() &&
|
||||
(current?.running || Number.isFinite(current?.break_deadline_at));
|
||||
current = session;
|
||||
if (session.device_id === deviceId()) {
|
||||
ownedRevision = session.revision;
|
||||
if (Number.isFinite(session.break_deadline_at) &&
|
||||
timer?.restoreBreak?.(session.identity, session.break_deadline_at)) onOwnedRestore(session);
|
||||
onRemote(null);
|
||||
} else if ((session.running || Number.isFinite(session.break_deadline_at)) && session.identity) {
|
||||
if (previousOwned) {
|
||||
timer?.pause?.();
|
||||
onTransferred(session);
|
||||
}
|
||||
onRemote(session);
|
||||
} else {
|
||||
onRemote(null);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
async function refreshNow() {
|
||||
try {
|
||||
onStatus('syncing');
|
||||
const session = await fetchJson(endpoint);
|
||||
onStatus('online');
|
||||
return adopt(session);
|
||||
} catch (error) {
|
||||
onStatus('offline', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const refresh = () => serialize(refreshNow);
|
||||
|
||||
function claim() {
|
||||
return serialize(async () => {
|
||||
if ((!current?.running && !Number.isFinite(current?.break_deadline_at)) ||
|
||||
!current.identity || current.device_id === deviceId() || !deviceId()) return null;
|
||||
try {
|
||||
onStatus('syncing');
|
||||
const body = {
|
||||
base_revision:current.revision, device_id:deviceId(), identity:current.identity,
|
||||
elapsed_ms:current.elapsed_ms, entries:current.entries, running:true,
|
||||
};
|
||||
if (Object.hasOwn(current, 'break_deadline_at')) body.break_deadline_at = null;
|
||||
const session = await fetchJson(endpoint, {
|
||||
method:'PATCH', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify(body),
|
||||
});
|
||||
adopt(session);
|
||||
timer?.adopt?.(session.identity, session.elapsed_ms, session.running, session.entries);
|
||||
return session;
|
||||
} catch (error) {
|
||||
if (error?.status === 409 || error?.code === 'session_changed') {
|
||||
onStatus('conflict', error);
|
||||
await refreshNow();
|
||||
} else {
|
||||
onStatus('offline', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function publishNow(snapshot) {
|
||||
if (!snapshot?.identity || !deviceId()) return null;
|
||||
try {
|
||||
onStatus('syncing');
|
||||
const body = {
|
||||
base_revision:ownedRevision, device_id:deviceId(), identity:snapshot.identity,
|
||||
elapsed_ms:Math.max(0, Math.floor(Number(snapshot.elapsed_ms) || 0)),
|
||||
entries:Array.isArray(snapshot.entries) ? snapshot.entries : undefined,
|
||||
running:Boolean(snapshot.running),
|
||||
};
|
||||
if (Object.hasOwn(snapshot, 'break_deadline_at') || Object.hasOwn(current || {}, 'break_deadline_at')) {
|
||||
body.break_deadline_at = Number.isFinite(snapshot.break_deadline_at) ?
|
||||
Math.floor(snapshot.break_deadline_at) : null;
|
||||
}
|
||||
const session = await fetchJson(endpoint, {
|
||||
method:'PATCH', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify(body),
|
||||
});
|
||||
adopt(session);
|
||||
return session;
|
||||
} catch (error) {
|
||||
if (error?.status === 409 || error?.code === 'session_changed') {
|
||||
onStatus('conflict', error);
|
||||
await refreshNow();
|
||||
} else {
|
||||
onStatus('offline', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function publish(snapshot = timer?.sessionSnapshot?.() || timer?.snapshot?.()) {
|
||||
snapshot = snapshot?.identity ? snapshot : latestSnapshot;
|
||||
if (!snapshot?.identity || !deviceId()) return Promise.resolve(null);
|
||||
latestSnapshot = snapshot;
|
||||
publishRequested += 1;
|
||||
return serialize(async () => {
|
||||
if (publishSent === publishRequested) return current;
|
||||
if (current?.device_id && current.device_id !== deviceId()) {
|
||||
publishSent = publishRequested;
|
||||
return current;
|
||||
}
|
||||
const version = publishRequested;
|
||||
const result = await publishNow(latestSnapshot);
|
||||
publishSent = current?.device_id && current.device_id !== deviceId()
|
||||
? publishRequested
|
||||
: version;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
const pulse = () => current?.device_id === deviceId() && current?.running
|
||||
? publish()
|
||||
: refresh();
|
||||
|
||||
return {
|
||||
refresh, claim, publish,
|
||||
session:() => current,
|
||||
start(intervalMs = 15000) {
|
||||
if (pollTimer !== null) return false;
|
||||
pulse();
|
||||
pollTimer = setInterval?.(pulse, intervalMs);
|
||||
pollTimer?.unref?.();
|
||||
return true;
|
||||
},
|
||||
stop() {
|
||||
if (pollTimer === null) return false;
|
||||
clearInterval?.(pollTimer);
|
||||
pollTimer = null;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function todaySessionHandoffSummary(session, item) {
|
||||
if (Number.isFinite(session?.break_deadline_at)) {
|
||||
const end = new Date(session.break_deadline_at).toLocaleTimeString([], {
|
||||
hour:'numeric', minute:'2-digit',
|
||||
});
|
||||
return `On break until ${end} · ready to resume here`;
|
||||
}
|
||||
const minutes = Math.max(0, Math.floor(Number(session?.elapsed_ms || 0) / 60000));
|
||||
if (Array.isArray(session?.entries) && session.entries.length > 1) {
|
||||
const total = session.entries.reduce((sum, entry) => sum + Math.max(0, Number(entry?.elapsed_ms) || 0), 0);
|
||||
return `${session.entries.length} tracked items · ${Math.floor(total / 60000)} min total`;
|
||||
}
|
||||
return `${item?.title || 'Current Today item'} · ${minutes} min elapsed`;
|
||||
}
|
||||
|
||||
function attachTodaySessionHandoff({
|
||||
fetchJson, storage, timer, qs, items, identity, selectToday, startItem, announce, renderTimer,
|
||||
}) {
|
||||
const deviceKey = 'stackchain.today-session-device.v1';
|
||||
const getDeviceId = () => {
|
||||
let value = storage.getItem(deviceKey);
|
||||
if (!value) {
|
||||
value = globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2);
|
||||
storage.setItem(deviceKey, value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
let offered = null;
|
||||
const sessionStatus = qs('#today-session-sync-status');
|
||||
const showSessionStatus = message => {
|
||||
sessionStatus.textContent = message;
|
||||
sessionStatus.hidden = !message;
|
||||
};
|
||||
const sync = createTodaySessionSync({
|
||||
fetchJson, getDeviceId, timer,
|
||||
onOwnedRestore:renderTimer,
|
||||
onStatus:state => showSessionStatus(
|
||||
state === 'syncing' ? 'Session syncing…' :
|
||||
(state === 'offline' ? 'Session offline · will retry.' : '')
|
||||
),
|
||||
onRemote:session => {
|
||||
offered = session;
|
||||
const handoff = qs('#today-session-handoff');
|
||||
handoff.hidden = !session;
|
||||
if (!session) return;
|
||||
const item = items().find(entry => identity(entry) === session.identity);
|
||||
qs('#today-session-handoff-summary').textContent = todaySessionHandoffSummary(session, item);
|
||||
qs('#continue-today-session').textContent = Number.isFinite(session.break_deadline_at) ?
|
||||
'Resume Today here' : 'Continue here';
|
||||
},
|
||||
onTransferred:() => {
|
||||
showSessionStatus('Session continued on another device.');
|
||||
announce('Today continued on another device. Timer paused here.');
|
||||
renderTimer();
|
||||
},
|
||||
});
|
||||
qs('#continue-today-session').addEventListener('click', async () => {
|
||||
const target = offered;
|
||||
const claimed = await sync.claim();
|
||||
if (!claimed || !target) return;
|
||||
selectToday();
|
||||
const item = items().find(entry => identity(entry) === claimed.identity);
|
||||
if (item) startItem(item);
|
||||
else announce('Today session moved here; refresh work to open its item.');
|
||||
renderTimer();
|
||||
});
|
||||
sync.start(5000);
|
||||
return sync;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = {
|
||||
createTodaySessionSync, attachTodaySessionHandoff, todaySessionHandoffSummary,
|
||||
};
|
||||
@@ -0,0 +1,359 @@
|
||||
function createTodaySummary({
|
||||
storage = null, getLogin = () => '', share = null, copy = null,
|
||||
resolveTarget = null, enqueueDurably = null,
|
||||
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
||||
} = {}) {
|
||||
let draft = null;
|
||||
let draftKey = '';
|
||||
let postPromise = null;
|
||||
|
||||
const storageKey = () => {
|
||||
const login = String(getLogin() || '').trim().toLowerCase();
|
||||
return login ? 'stackchain.today-summary-draft.v1.' + login : '';
|
||||
};
|
||||
const syncAccount = () => {
|
||||
const key = storageKey();
|
||||
if (key !== draftKey) {
|
||||
draft = null;
|
||||
draftKey = key;
|
||||
}
|
||||
};
|
||||
const copyRows = rows => rows.map(row => ({ ...row }));
|
||||
const snapshot = () => {
|
||||
syncAccount();
|
||||
return draft ? {
|
||||
...draft,
|
||||
worked:copyRows(draft.worked),
|
||||
tomorrow:copyRows(draft.tomorrow),
|
||||
target:draft.target ? { ...draft.target } : null,
|
||||
} : null;
|
||||
};
|
||||
const persist = () => {
|
||||
const key = storageKey();
|
||||
if (storage && key && draft) storage.setItem(key, JSON.stringify(draft));
|
||||
};
|
||||
const validRows = (rows, includeTime) => Array.isArray(rows) && rows.length <= 20 && rows.every(row =>
|
||||
row && typeof row.identity === 'string' && row.identity.length > 0 && row.identity.length <= 500 &&
|
||||
typeof row.title === 'string' && row.title.length <= 180 && typeof row.context === 'string' &&
|
||||
row.context.length <= 180 && typeof row.include === 'boolean' && (!includeTime ||
|
||||
(Number.isInteger(row.actual_minutes) && row.actual_minutes >= 0 && row.actual_minutes <= 1440))
|
||||
);
|
||||
const load = () => {
|
||||
const key = storageKey();
|
||||
if (!storage || !key) return null;
|
||||
try {
|
||||
const saved = JSON.parse(storage.getItem(key) || 'null');
|
||||
const validTarget = Boolean(saved) && (saved.target === null || saved.target === undefined || (saved.target &&
|
||||
typeof saved.target.repository === 'string' && /^[a-z0-9_.-]+\/[a-z0-9_.-]+$/.test(saved.target.repository) &&
|
||||
Number.isInteger(saved.target.number) && saved.target.number > 0 &&
|
||||
['issue', 'pull'].includes(saved.target.kind) && typeof saved.target.title === 'string' &&
|
||||
typeof saved.target.state === 'string'));
|
||||
if (!saved || !validRows(saved.worked, true) || !validRows(saved.tomorrow, false) ||
|
||||
typeof saved.include_actuals !== 'boolean' || typeof saved.note !== 'string' || saved.note.length > 1000 ||
|
||||
!['string', 'undefined'].includes(typeof saved.destination) || !validTarget ||
|
||||
!['string', 'undefined'].includes(typeof saved.operation_id)) {
|
||||
if (saved !== null) storage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
return saved;
|
||||
} catch (_error) {
|
||||
try { storage.removeItem(key); } catch (_ignored) {}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const cleanRow = (row, includeTime) => {
|
||||
const cleaned = {
|
||||
identity:String(row.identity),
|
||||
title:String(row.title || row.label || row.identity).slice(0, 180),
|
||||
context:String(row.context || '').slice(0, 180),
|
||||
};
|
||||
if (includeTime) cleaned.actual_minutes = Math.min(1440, Math.max(0, Number(row.actual_minutes) || 0));
|
||||
cleaned.include = true;
|
||||
return cleaned;
|
||||
};
|
||||
const selectedLines = (rows, showTime = false) => rows.filter(row => row.include).map(row =>
|
||||
'- ' + row.title + (showTime ? ' (' + row.actual_minutes + 'm)' : '')
|
||||
);
|
||||
const discard = () => {
|
||||
const key = storageKey();
|
||||
if (storage && key) storage.removeItem(key);
|
||||
draft = null;
|
||||
};
|
||||
|
||||
return {
|
||||
begin(worked, tomorrow) {
|
||||
draftKey = storageKey();
|
||||
draft = {
|
||||
worked:(worked || []).filter(row => row?.identity).slice(0, 20).map(row => cleanRow(row, true)),
|
||||
tomorrow:(tomorrow || []).filter(row => row?.identity).slice(0, 20).map(row => cleanRow(row, false)),
|
||||
include_actuals:false,
|
||||
note:'',
|
||||
destination:'',
|
||||
target:null,
|
||||
operation_id:'',
|
||||
};
|
||||
persist();
|
||||
return snapshot();
|
||||
},
|
||||
snapshot,
|
||||
restore() {
|
||||
draftKey = storageKey();
|
||||
draft = load();
|
||||
return Boolean(draft);
|
||||
},
|
||||
choose(section, identity, include) {
|
||||
syncAccount();
|
||||
const row = draft?.[section]?.find(candidate => candidate.identity === identity);
|
||||
if (!row || typeof include !== 'boolean') return false;
|
||||
row.include = include;
|
||||
persist();
|
||||
return true;
|
||||
},
|
||||
includeActuals(include) {
|
||||
syncAccount();
|
||||
if (!draft || typeof include !== 'boolean') return false;
|
||||
draft.include_actuals = include;
|
||||
persist();
|
||||
return true;
|
||||
},
|
||||
setNote(note) {
|
||||
syncAccount();
|
||||
if (!draft) return false;
|
||||
draft.note = String(note || '').trim().slice(0, 1000);
|
||||
persist();
|
||||
return true;
|
||||
},
|
||||
setDestination(value) {
|
||||
syncAccount();
|
||||
if (!draft) return null;
|
||||
const match = String(value || '').trim().match(/^([a-z0-9_.-]+)\s*\/\s*([a-z0-9_.-]+)\s*#\s*([1-9][0-9]*)$/i);
|
||||
draft.destination = String(value || '').trim().slice(0, 260);
|
||||
draft.target = null;
|
||||
draft.operation_id = '';
|
||||
persist();
|
||||
if (!match) return null;
|
||||
const repository = (match[1] + '/' + match[2]).toLowerCase();
|
||||
return { repository, number:Number(match[3]), label:repository + '#' + Number(match[3]) };
|
||||
},
|
||||
async validateDestination() {
|
||||
syncAccount();
|
||||
const parsed = this.setDestination(draft?.destination || '');
|
||||
if (!parsed) throw new Error('Enter a destination like owner/repo#42.');
|
||||
if (typeof resolveTarget !== 'function') throw new Error('Destination validation is unavailable.');
|
||||
const resolved = await resolveTarget({ repository:parsed.repository, number:parsed.number });
|
||||
if (!resolved || resolved.repository !== parsed.repository || Number(resolved.number) !== parsed.number ||
|
||||
!['issue', 'pull'].includes(resolved.kind)) throw new Error('Choose an exact visible issue or pull request.');
|
||||
draft.target = {
|
||||
repository:parsed.repository, number:parsed.number, kind:resolved.kind,
|
||||
title:String(resolved.title || '').slice(0, 180), state:String(resolved.state || ''),
|
||||
};
|
||||
draft.operation_id = String(createOperationId()).slice(0, 128);
|
||||
persist();
|
||||
return { ...draft.target };
|
||||
},
|
||||
postSummary() {
|
||||
if (postPromise) return postPromise;
|
||||
syncAccount();
|
||||
const body = this.text();
|
||||
if (!body) throw new Error('Select at least one summary item or add a note.');
|
||||
if (!draft?.target || !draft.operation_id) throw new Error('Validate an exact Gitea destination first.');
|
||||
if (typeof enqueueDurably !== 'function') throw new Error('Gitea posting is unavailable.');
|
||||
const target = { ...draft.target };
|
||||
const message = {
|
||||
kind:'search-reply', repository:target.repository, number:target.number,
|
||||
targetKind:target.kind, body, operationId:draft.operation_id,
|
||||
};
|
||||
postPromise = (async () => {
|
||||
try {
|
||||
const admission = await enqueueDurably(message);
|
||||
discard();
|
||||
return { status:'queued', durability:admission?.durability || 'foreground-only', target };
|
||||
} finally {
|
||||
postPromise = null;
|
||||
}
|
||||
})();
|
||||
return postPromise;
|
||||
},
|
||||
discard,
|
||||
text() {
|
||||
syncAccount();
|
||||
if (!draft) return '';
|
||||
const sections = [];
|
||||
const worked = selectedLines(draft.worked, draft.include_actuals);
|
||||
const tomorrow = selectedLines(draft.tomorrow);
|
||||
if (worked.length) sections.push(['Today', ...worked].join('\n'));
|
||||
if (tomorrow.length) sections.push(['Tomorrow', ...tomorrow].join('\n'));
|
||||
if (draft.note) sections.push('Note\n' + draft.note);
|
||||
return sections.join('\n\n');
|
||||
},
|
||||
async shareSummary() {
|
||||
const text = this.text();
|
||||
if (!text) throw new Error('Select at least one summary item or add a note.');
|
||||
if (typeof share === 'function') {
|
||||
try {
|
||||
await share({ title:'Today summary', text });
|
||||
discard();
|
||||
return { status:'shared' };
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') return { status:'canceled' };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (typeof copy !== 'function') throw new Error('Sharing is unavailable on this device.');
|
||||
await copy(text);
|
||||
discard();
|
||||
return { status:'copied' };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createTodaySummaryView({ summary, qs, escapeHtml }) {
|
||||
const rowMarkup = (section, row) => '<label class="today-summary-item"><input type="checkbox" data-summary-section="' +
|
||||
section + '" data-summary-identity="' + escapeHtml(row.identity) + '"' + (row.include ? ' checked' : '') +
|
||||
'><span><strong>' + escapeHtml(row.title) + '</strong><span class="small muted">' +
|
||||
escapeHtml(row.context) + '</span></span></label>';
|
||||
|
||||
function close() {
|
||||
qs('#today-summary-sheet').hidden = true;
|
||||
document.body.classList.remove('task-overlay-open');
|
||||
qs('#start-work-session')?.focus();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const draft = summary.snapshot();
|
||||
if (!draft) return;
|
||||
qs('#today-summary-worked').innerHTML = draft.worked.map(row => rowMarkup('worked', row)).join('') ||
|
||||
'<p class="small muted">No worked items selected.</p>';
|
||||
qs('#today-summary-tomorrow').innerHTML = draft.tomorrow.map(row => rowMarkup('tomorrow', row)).join('') ||
|
||||
'<p class="small muted">Nothing scheduled for tomorrow.</p>';
|
||||
qs('#today-summary-include-actuals').checked = draft.include_actuals;
|
||||
qs('#today-summary-note').value = draft.note;
|
||||
qs('#today-summary-preview').textContent = summary.text();
|
||||
qs('#today-summary-destination').value = draft.destination || '';
|
||||
qs('#today-summary-target').textContent = draft.target ?
|
||||
(draft.target.repository + '#' + draft.target.number + ' · ' + draft.target.kind + ' · ' + draft.target.title) : '';
|
||||
qs('#post-today-summary').disabled = !draft.target;
|
||||
qs('#today-summary-sheet').querySelectorAll('[data-summary-identity]').forEach(input => {
|
||||
input.addEventListener('change', () => {
|
||||
summary.choose(input.dataset.summarySection, input.dataset.summaryIdentity, input.checked);
|
||||
render();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function show() {
|
||||
qs('#today-summary-status').textContent = '';
|
||||
render();
|
||||
qs('#today-summary-sheet').hidden = false;
|
||||
document.body.classList.add('task-overlay-open');
|
||||
requestAnimationFrame(() => (qs('#today-summary-worked input') || qs('#share-today-summary')).focus());
|
||||
}
|
||||
|
||||
function open(worked, tomorrow) {
|
||||
summary.begin(worked, tomorrow);
|
||||
show();
|
||||
}
|
||||
|
||||
async function shareDraft(button) {
|
||||
button.disabled = true;
|
||||
qs('#today-summary-status').textContent = 'Opening share options…';
|
||||
try {
|
||||
const result = await summary.shareSummary();
|
||||
if (result.status === 'canceled') {
|
||||
qs('#today-summary-status').textContent = 'Share canceled. Your private draft is still here.';
|
||||
return;
|
||||
}
|
||||
qs('#my-work-action-status').textContent = result.status === 'shared' ?
|
||||
'Today summary shared.' : 'Today summary copied to your clipboard.';
|
||||
close();
|
||||
} catch (error) {
|
||||
qs('#today-summary-status').textContent = error.message || 'Summary could not be shared. Your draft is unchanged.';
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function validateDestination(button) {
|
||||
button.disabled = true;
|
||||
qs('#today-summary-status').textContent = 'Checking destination…';
|
||||
try {
|
||||
await summary.validateDestination();
|
||||
render();
|
||||
qs('#today-summary-status').textContent = 'Destination confirmed. Review it, then post once.';
|
||||
} catch (error) {
|
||||
qs('#today-summary-status').textContent = error.message || 'Destination could not be confirmed.';
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function postDraft(button) {
|
||||
button.disabled = true;
|
||||
qs('#today-summary-status').textContent = 'Saving comment for delivery…';
|
||||
try {
|
||||
const result = await summary.postSummary();
|
||||
qs('#my-work-action-status').textContent = 'Today summary queued for ' +
|
||||
result.target.repository + '#' + result.target.number + '.';
|
||||
close();
|
||||
} catch (error) {
|
||||
qs('#today-summary-status').textContent = error.message || 'Summary could not be queued. Your review is unchanged.';
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function bind() {
|
||||
qs('#close-today-summary').addEventListener('click', close);
|
||||
qs('#discard-today-summary').addEventListener('click', () => { summary.discard(); close(); });
|
||||
qs('#today-summary-include-actuals').addEventListener('change', event => {
|
||||
summary.includeActuals(event.currentTarget.checked); render();
|
||||
});
|
||||
qs('#today-summary-note').addEventListener('input', event => {
|
||||
summary.setNote(event.currentTarget.value);
|
||||
qs('#today-summary-preview').textContent = summary.text();
|
||||
});
|
||||
qs('#today-summary-destination').addEventListener('input', event => {
|
||||
summary.setDestination(event.currentTarget.value);
|
||||
qs('#today-summary-target').textContent = '';
|
||||
qs('#post-today-summary').disabled = true;
|
||||
});
|
||||
qs('#validate-today-summary-destination').addEventListener('click', event => validateDestination(event.currentTarget));
|
||||
qs('#post-today-summary').addEventListener('click', event => postDraft(event.currentTarget));
|
||||
qs('#share-today-summary').addEventListener('click', event => shareDraft(event.currentTarget));
|
||||
}
|
||||
|
||||
return {
|
||||
open, close, render, bind, shareDraft, validateDestination, postDraft,
|
||||
resume() { if (summary.restore()) show(); },
|
||||
};
|
||||
}
|
||||
|
||||
function setupTodaySummary({ qs, escapeHtml, getLogin, resolveTarget, enqueueDurably, fetchJson }) {
|
||||
const targetResolver = resolveTarget || (async target => {
|
||||
if (typeof fetchJson !== 'function') throw new Error('Destination validation is unavailable.');
|
||||
const item = await fetchJson('api/v1/repos/' + target.repository + '/issues/' + target.number +
|
||||
'/preview?kind=issue');
|
||||
if (String(item.repository || '').toLowerCase() !== target.repository ||
|
||||
Number(item.number) !== target.number || !['issue', 'pull'].includes(item.kind)) {
|
||||
throw new Error('That exact issue or pull request is not visible to this account.');
|
||||
}
|
||||
return { repository:target.repository, number:target.number, kind:item.kind,
|
||||
title:item.title || '', state:item.state || '' };
|
||||
});
|
||||
const summary = createTodaySummary({
|
||||
storage:localStorage,
|
||||
getLogin,
|
||||
share:typeof navigator.share === 'function' ? payload => navigator.share(payload) : null,
|
||||
copy:typeof navigator.clipboard?.writeText === 'function' ? text => navigator.clipboard.writeText(text) : null,
|
||||
resolveTarget:targetResolver,
|
||||
enqueueDurably,
|
||||
});
|
||||
const view = createTodaySummaryView({ summary, qs, escapeHtml });
|
||||
view.bind();
|
||||
return view;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
createTodaySummary.createView = createTodaySummaryView;
|
||||
createTodaySummary.setup = setupTodaySummary;
|
||||
module.exports = createTodaySummary;
|
||||
}
|
||||
+97
-25
@@ -10,6 +10,8 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
let retryTimer = null;
|
||||
let retryAttempt = 0;
|
||||
let expiredCount = 0;
|
||||
let discardedCount = 0;
|
||||
let recoveryNotice = { discarded: 0, until: 0 };
|
||||
const knownOperationKeys = new Set();
|
||||
|
||||
function cancelRetry() {
|
||||
@@ -59,12 +61,22 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
revision: plan.revision, ids: plan.ids,
|
||||
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
|
||||
};
|
||||
if (['coaching', 'complete'].includes(plan.first_task_state)) {
|
||||
snapshot.first_task_state = plan.first_task_state;
|
||||
}
|
||||
if (plan.plan_date) {
|
||||
snapshot.plan_date = plan.plan_date;
|
||||
snapshot.timezone = plan.timezone || null;
|
||||
}
|
||||
try {
|
||||
storage?.setItem(snapshotKey(), JSON.stringify(snapshot));
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
const activationKey = login && 'stackchain.first-task.v1:' + login;
|
||||
const current = activationKey && storage?.getItem(activationKey);
|
||||
const rank = {'': 0, coaching: 1, complete: 2};
|
||||
if (activationKey && rank[snapshot.first_task_state] > (rank[current] || 0)) {
|
||||
storage?.setItem(activationKey, snapshot.first_task_state);
|
||||
}
|
||||
} catch (_error) {
|
||||
// A storage quota failure must not prevent the current tab from using server truth.
|
||||
}
|
||||
@@ -108,10 +120,31 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
const candidate = storage.key?.(index);
|
||||
if (candidate?.startsWith(recordPrefix)) keys.add(candidate);
|
||||
}
|
||||
const records = [...keys].map(recordKey => {
|
||||
const record = JSON.parse(storage.getItem(recordKey) || 'null');
|
||||
return record ? { ...record, recordKey } : null;
|
||||
}).filter(record => record?.operation);
|
||||
const records = [];
|
||||
for (const recordKey of keys) {
|
||||
let record;
|
||||
try {
|
||||
record = JSON.parse(storage.getItem(recordKey) || 'null');
|
||||
} catch (_error) {
|
||||
storage.removeItem(recordKey);
|
||||
knownOperationKeys.delete(recordKey);
|
||||
discardedCount += 1;
|
||||
continue;
|
||||
}
|
||||
const operations = Array.isArray(record?.operations) ? record.operations : [record?.operation];
|
||||
const valid = operations.length && operations.every(operation => operation &&
|
||||
typeof operation.operation_id === 'string' &&
|
||||
['add', 'remove', 'move', 'configure', 'rollover', 'activate'].includes(operation.action) &&
|
||||
typeof operation.item_id === 'string') && Number.isFinite(Number(record.queued_at));
|
||||
if (valid) operations.forEach((operation, index) => {
|
||||
records.push({operation, queued_at:Number(record.queued_at) + index / 1000, recordKey});
|
||||
});
|
||||
else {
|
||||
storage.removeItem(recordKey);
|
||||
knownOperationKeys.delete(recordKey);
|
||||
discardedCount += 1;
|
||||
}
|
||||
}
|
||||
const expired = records.filter(record => Number(record.queued_at) >= 1_000_000_000_000 &&
|
||||
now() - Number(record.queued_at) > maxOfflineMs);
|
||||
expired.forEach(record => {
|
||||
@@ -131,7 +164,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
? record.operation.base_revision : Math.max(0, savedRevision()),
|
||||
}))
|
||||
.filter(operation => operation && typeof operation.operation_id === 'string' &&
|
||||
['add', 'remove', 'move', 'configure', 'rollover'].includes(operation.action) && typeof operation.item_id === 'string');
|
||||
['add', 'remove', 'move', 'configure', 'rollover', 'activate'].includes(operation.action) && typeof operation.item_id === 'string');
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
@@ -156,7 +189,25 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
function enqueue(action, itemId, direction = null) {
|
||||
function saveOperations(operations, queuedAt = now()) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const batched = operations.length > 1;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operations[0].operation_id);
|
||||
try {
|
||||
storage.setItem(recordKey, JSON.stringify(batched ?
|
||||
{operations, queued_at:queuedAt} : {operation:operations[0], queued_at:queuedAt}));
|
||||
knownOperationKeys.add(recordKey);
|
||||
coordinator?.notify('today');
|
||||
onStatus?.('pending');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onStatus?.('error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(action, itemId, direction = null, fields = {}) {
|
||||
const operations = pending();
|
||||
if (action === 'remove' && operations.some(operation =>
|
||||
operation.action === 'remove' && operation.item_id === itemId
|
||||
@@ -166,20 +217,17 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
}
|
||||
const operation = {
|
||||
operation_id: operationId(), action, item_id: itemId, direction,
|
||||
base_revision: Math.max(0, savedRevision()),
|
||||
base_revision: Math.max(0, savedRevision()), ...fields,
|
||||
};
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||
let saved = false;
|
||||
try {
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
saved = true;
|
||||
coordinator?.notify('today');
|
||||
} catch (_error) { /* Report the persistence failure below. */ }
|
||||
onStatus?.(saved ? 'pending' : 'error');
|
||||
return saved;
|
||||
return saveOperations([operation], now() + operations.length);
|
||||
}
|
||||
|
||||
function enqueueBatch(specifications) {
|
||||
const operations = specifications.map(specification => ({
|
||||
operation_id: operationId(), direction: specification.direction ?? null,
|
||||
base_revision: Math.max(0, savedRevision()), ...specification,
|
||||
}));
|
||||
return saveOperations(operations);
|
||||
}
|
||||
|
||||
function enqueueConfiguration(capacityMinutes, estimates) {
|
||||
@@ -203,6 +251,15 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueActivation(state) {
|
||||
if (!['coaching', 'complete'].includes(state)) return false;
|
||||
const rank = {coaching: 1, complete: 2};
|
||||
const queued = pending().filter(operation => operation.action === 'activate');
|
||||
if (queued.some(operation => rank[operation.activation_state] >= rank[state])) return true;
|
||||
queued.forEach(operation => removeOperation(operation.operation_id));
|
||||
return enqueue('activate', 'first-task', null, {activation_state: state});
|
||||
}
|
||||
|
||||
function enqueueRollover(proposed) {
|
||||
if (!proposed || proposed.action !== 'rollover') return false;
|
||||
const operation = {
|
||||
@@ -246,7 +303,10 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
let operations = pending();
|
||||
let plan;
|
||||
let hadConflict = false;
|
||||
if (!operations.length) plan = await fetchJson('api/v1/today');
|
||||
if (!operations.length) {
|
||||
plan = await fetchJson('api/v1/today');
|
||||
operations = pending();
|
||||
}
|
||||
while (operations.length) {
|
||||
if (key() !== ownerKey) return false;
|
||||
const batch = operations.slice(0, 50);
|
||||
@@ -272,14 +332,23 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
operations = pending();
|
||||
}
|
||||
adopt(plan);
|
||||
onStatus?.(pending().length ? 'pending' : hadConflict ? 'full' :
|
||||
expiredCount ? 'expired' : 'saved', expiredCount ? { count: expiredCount } : {});
|
||||
const stillPending = pending().length;
|
||||
if (discardedCount) recoveryNotice = { discarded: discardedCount, until: now() + 5000 };
|
||||
const recovered = recoveryNotice.until > now() ? recoveryNotice.discarded : 0;
|
||||
onStatus?.(stillPending ? 'pending' : hadConflict ? 'full' :
|
||||
expiredCount ? 'expired' : recovered ? 'recovered' : 'saved',
|
||||
expiredCount ? { count: expiredCount } : recovered ? { discarded: recovered } : {});
|
||||
if (!stillPending) discardedCount = 0;
|
||||
retryAttempt = 0;
|
||||
cancelRetry();
|
||||
return !hadConflict;
|
||||
} catch (error) {
|
||||
if (pending().length) scheduleRetry(error, ownerKey);
|
||||
else onStatus?.('error');
|
||||
const stillPending = pending().length;
|
||||
if (discardedCount) recoveryNotice = { discarded: discardedCount, until: now() + 5000 };
|
||||
const recovered = recoveryNotice.until > now() ? recoveryNotice.discarded : 0;
|
||||
if (stillPending) scheduleRetry(error, ownerKey);
|
||||
else onStatus?.(recovered ? 'recovered' : 'error', recovered ? { discarded: recovered } : {});
|
||||
discardedCount = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -294,6 +363,9 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
|
||||
function startLifecycle({ window: windowObject, document: documentObject }) {
|
||||
windowObject?.addEventListener?.('online', flush);
|
||||
windowObject?.addEventListener?.('stackchain:first-task-complete', () => {
|
||||
if (enqueueActivation('complete')) flush();
|
||||
});
|
||||
documentObject?.addEventListener?.('visibilitychange', () =>
|
||||
documentObject.hidden ? false : flush()
|
||||
);
|
||||
@@ -303,7 +375,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
if (change.queue === 'today' && pending().length) flush();
|
||||
});
|
||||
|
||||
return { enqueue, enqueueConfiguration, enqueueRollover, migrate, flush, pending, startLifecycle };
|
||||
return { enqueue, enqueueBatch, enqueueConfiguration, enqueueActivation, enqueueRollover, migrate, flush, pending, startLifecycle };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
|
||||
+403
-10
@@ -1,11 +1,11 @@
|
||||
function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||
function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange = () => {} }) {
|
||||
const key = () => {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? 'stackchain.today-timer.v1.' + encodeURIComponent(login) : '';
|
||||
};
|
||||
const empty = () => ({
|
||||
version:1, active_identity:'', entries:{}, away_at:null,
|
||||
pending_interruption:null, attention_interruption:null,
|
||||
pending_interruption:null, attention_interruption:null, capture_interruption:null, search_interruption:null, detour_interruption:null, timed_break:null,
|
||||
});
|
||||
const read = () => {
|
||||
const ownerKey = key();
|
||||
@@ -24,6 +24,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||
if (!ownerKey || !storage) return false;
|
||||
try {
|
||||
storage.setItem(ownerKey, JSON.stringify(state));
|
||||
onChange(snapshot());
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
@@ -41,6 +42,30 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||
typeof pending.resume === 'boolean' ?
|
||||
{ identity:pending.identity, resume:pending.resume } : null;
|
||||
};
|
||||
const validCapture = state => {
|
||||
const pending = state.capture_interruption;
|
||||
return pending && typeof pending.identity === 'string' && pending.identity &&
|
||||
typeof pending.resume === 'boolean' ?
|
||||
{ identity:pending.identity, resume:pending.resume } : null;
|
||||
};
|
||||
const validSearch = state => {
|
||||
const pending = state.search_interruption;
|
||||
return pending && typeof pending.identity === 'string' && pending.identity &&
|
||||
typeof pending.resume === 'boolean' ?
|
||||
{ identity:pending.identity, resume:pending.resume } : null;
|
||||
};
|
||||
const validDetour = state => {
|
||||
const pending = state.detour_interruption;
|
||||
return pending && typeof pending.identity === 'string' && pending.identity &&
|
||||
typeof pending.resume === 'boolean' && ['find', 'queues', 'insights', 'device-setup', 'security-center', 'live-data-status'].includes(pending.reason) ?
|
||||
{ identity:pending.identity, resume:pending.resume, reason:pending.reason } : null;
|
||||
};
|
||||
const validBreak = state => {
|
||||
const value = state.timed_break;
|
||||
return value && typeof value.identity === 'string' && value.identity &&
|
||||
Number.isFinite(value.deadline_at) && value.deadline_at >= 0 ?
|
||||
{ identity:value.identity, deadline_at:value.deadline_at, expired:now() >= value.deadline_at } : null;
|
||||
};
|
||||
const settle = (state, at = now()) => {
|
||||
const entry = state.entries[state.active_identity];
|
||||
if (!entry?.running) return state;
|
||||
@@ -50,20 +75,65 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||
entry.running = false;
|
||||
return state;
|
||||
};
|
||||
const snapshot = (identity = '') => {
|
||||
const snapshot = (identity = '', includeEntries = false) => {
|
||||
const state = read();
|
||||
const sampledAt = now();
|
||||
const selected = identity || state.active_identity;
|
||||
const entry = state.entries[selected];
|
||||
if (!selected || !entry) return { identity:selected, elapsed_ms:0, running:false };
|
||||
const elapsed = Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ?
|
||||
Math.max(0, now() - Number(entry.started_at ?? now())) : 0);
|
||||
return { identity:selected, elapsed_ms:elapsed, running:Boolean(entry.running) };
|
||||
const result = !selected || !entry ?
|
||||
{ identity:selected, elapsed_ms:0, running:false } :
|
||||
{
|
||||
identity:selected,
|
||||
elapsed_ms:Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ?
|
||||
Math.max(0, sampledAt - Number(entry.started_at ?? sampledAt)) : 0),
|
||||
running:Boolean(entry.running),
|
||||
};
|
||||
const timedBreak = validBreak(state);
|
||||
if (timedBreak?.identity === selected) result.break_deadline_at = timedBreak.deadline_at;
|
||||
if (includeEntries) {
|
||||
result.entries = Object.entries(state.entries).slice(0, 20).map(([entryIdentity, value]) => ({
|
||||
identity:entryIdentity,
|
||||
elapsed_ms:Math.max(0, Math.floor(Number(value.elapsed_ms) || 0) + (value.running ?
|
||||
Math.max(0, sampledAt - Number(value.started_at ?? sampledAt)) : 0)),
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
return {
|
||||
adopt(identity, elapsedMs, running, entries = null) {
|
||||
if (!key() || typeof identity !== 'string' || !identity ||
|
||||
!Number.isFinite(Number(elapsedMs)) || Number(elapsedMs) < 0) return false;
|
||||
const incoming = entries === null ? [{identity, elapsed_ms:elapsedMs}] : entries;
|
||||
if (!Array.isArray(incoming) || incoming.length > 20) return false;
|
||||
const seen = new Set();
|
||||
const ledger = {};
|
||||
for (const entry of incoming) {
|
||||
if (typeof entry?.identity !== 'string' || !entry.identity || entry.identity.length > 500 ||
|
||||
seen.has(entry.identity) || !Number.isFinite(Number(entry.elapsed_ms)) || Number(entry.elapsed_ms) < 0) return false;
|
||||
seen.add(entry.identity);
|
||||
ledger[entry.identity] = {
|
||||
elapsed_ms:Math.floor(Number(entry.elapsed_ms)), started_at:null, running:false,
|
||||
};
|
||||
}
|
||||
if (!ledger[identity] || ledger[identity].elapsed_ms !== Math.floor(Number(elapsedMs))) return false;
|
||||
const state = read();
|
||||
state.active_identity = identity;
|
||||
state.entries = ledger;
|
||||
state.entries[identity].started_at = running ? now() : null;
|
||||
state.entries[identity].running = Boolean(running);
|
||||
state.away_at = null;
|
||||
state.pending_interruption = null;
|
||||
state.attention_interruption = null;
|
||||
state.search_interruption = null;
|
||||
state.detour_interruption = null;
|
||||
if(running || validBreak(state)?.identity !== identity) state.timed_break=null;
|
||||
return write(state);
|
||||
},
|
||||
activate(identity) {
|
||||
if (!key() || typeof identity !== 'string' || !identity) return false;
|
||||
const state = read();
|
||||
if (state.active_identity === identity && state.entries[identity]?.running) return true;
|
||||
if (state.active_identity === identity &&
|
||||
(state.entries[identity]?.running || validBreak(state)?.identity === identity)) return true;
|
||||
settle(state);
|
||||
state.active_identity = identity;
|
||||
const entry = state.entries[identity] || { elapsed_ms:0, started_at:null, running:false };
|
||||
@@ -73,6 +143,9 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||
state.away_at = null;
|
||||
state.pending_interruption = null;
|
||||
state.attention_interruption = null;
|
||||
state.search_interruption = null;
|
||||
state.detour_interruption = null;
|
||||
state.timed_break = null;
|
||||
return write(state);
|
||||
},
|
||||
pause() {
|
||||
@@ -89,6 +162,41 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||
entry.started_at = now();
|
||||
entry.running = true;
|
||||
}
|
||||
state.timed_break = null;
|
||||
return write(state);
|
||||
},
|
||||
startBreak(minutes) {
|
||||
const duration = Number(minutes);
|
||||
if (!Number.isInteger(duration) || duration < 1 || duration > 120) return false;
|
||||
const state = read();
|
||||
const identity = state.active_identity;
|
||||
if (!identity || !state.entries[identity]) return false;
|
||||
settle(state);
|
||||
state.away_at = null;
|
||||
state.pending_interruption = null;
|
||||
state.timed_break = { identity, deadline_at:now() + duration * 60000 };
|
||||
return write(state) ? validBreak(state) : false;
|
||||
},
|
||||
restoreBreak(identity, deadlineAt) {
|
||||
const deadline = Number(deadlineAt);
|
||||
if (typeof identity !== 'string' || !identity || !Number.isFinite(deadline) || deadline <= now()) return false;
|
||||
const state = read();
|
||||
const entry = state.entries[identity];
|
||||
if (state.active_identity !== identity || !entry || entry.running || validBreak(state)) return false;
|
||||
state.timed_break = { identity, deadline_at:Math.floor(deadline) };
|
||||
return write(state);
|
||||
},
|
||||
breakSnapshot() {
|
||||
return validBreak(read());
|
||||
},
|
||||
resumeBreak() {
|
||||
const state = read();
|
||||
const pending = validBreak(state);
|
||||
const entry = pending && state.entries[pending.identity];
|
||||
if (!pending || !entry || state.active_identity !== pending.identity || entry.running) return false;
|
||||
state.timed_break = null;
|
||||
entry.started_at = now();
|
||||
entry.running = true;
|
||||
return write(state);
|
||||
},
|
||||
stop() {
|
||||
@@ -97,6 +205,9 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||
state.away_at = null;
|
||||
state.pending_interruption = null;
|
||||
state.attention_interruption = null;
|
||||
state.search_interruption = null;
|
||||
state.detour_interruption = null;
|
||||
state.timed_break = null;
|
||||
return write(state);
|
||||
},
|
||||
beginAttention() {
|
||||
@@ -129,6 +240,113 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||
state.away_at = null;
|
||||
return write(state) ? { identity:pending.identity, resumed:pending.resume } : null;
|
||||
},
|
||||
beginCapture() {
|
||||
const state = read();
|
||||
const existing = validCapture(state);
|
||||
if (existing) return existing;
|
||||
const identity = state.active_identity;
|
||||
const entry = state.entries[identity];
|
||||
if (!identity || !entry) return null;
|
||||
const resume = Boolean(entry.running);
|
||||
if (resume) settle(state);
|
||||
state.away_at = null;
|
||||
state.capture_interruption = { identity, resume };
|
||||
return write(state) ? { ...state.capture_interruption } : null;
|
||||
},
|
||||
captureInterruption() {
|
||||
return validCapture(read());
|
||||
},
|
||||
abandonCapture() {
|
||||
const state = read();
|
||||
const pending = validCapture(state);
|
||||
if (!pending) return null;
|
||||
state.capture_interruption = null;
|
||||
state.away_at = null;
|
||||
return write(state) ? { identity:pending.identity, resumed:false } : null;
|
||||
},
|
||||
returnFromCapture() {
|
||||
const state = read();
|
||||
const pending = validCapture(state);
|
||||
const entry = pending && state.entries[pending.identity];
|
||||
if (!pending || !entry) return null;
|
||||
const resumed = pending.resume && state.active_identity === pending.identity;
|
||||
if (resumed && !entry.running) {
|
||||
entry.started_at = now();
|
||||
entry.running = true;
|
||||
}
|
||||
state.capture_interruption = null;
|
||||
state.away_at = null;
|
||||
return write(state) ? { identity:pending.identity, resumed } : null;
|
||||
},
|
||||
beginSearch() {
|
||||
const state = read();
|
||||
const existing = validSearch(state);
|
||||
if (existing) return existing;
|
||||
const identity = state.active_identity;
|
||||
const entry = state.entries[identity];
|
||||
if (!identity || !entry?.running) return null;
|
||||
const resume = true;
|
||||
settle(state);
|
||||
state.away_at = null;
|
||||
state.search_interruption = { identity, resume };
|
||||
return write(state) ? { ...state.search_interruption } : null;
|
||||
},
|
||||
searchInterruption() {
|
||||
return validSearch(read());
|
||||
},
|
||||
abandonSearch() {
|
||||
const state = read();
|
||||
const pending = validSearch(state);
|
||||
if (!pending) return null;
|
||||
state.search_interruption = null;
|
||||
state.away_at = null;
|
||||
return write(state) ? { identity:pending.identity, resumed:false } : null;
|
||||
},
|
||||
returnFromSearch() {
|
||||
const state = read();
|
||||
const pending = validSearch(state);
|
||||
const entry = pending && state.entries[pending.identity];
|
||||
if (!pending || !entry) return null;
|
||||
const resumed = pending.resume && state.active_identity === pending.identity;
|
||||
if (resumed && !entry.running) {
|
||||
entry.started_at = now();
|
||||
entry.running = true;
|
||||
}
|
||||
state.search_interruption = null;
|
||||
state.away_at = null;
|
||||
return write(state) ? { identity:pending.identity, resumed } : null;
|
||||
},
|
||||
beginDetour(reason) {
|
||||
if (!['find', 'queues', 'insights', 'device-setup', 'security-center', 'live-data-status'].includes(reason)) return null;
|
||||
const state = read();
|
||||
const existing = validDetour(state);
|
||||
if (existing) return existing;
|
||||
const identity = state.active_identity;
|
||||
const entry = state.entries[identity];
|
||||
if (!identity || !entry?.running) return null;
|
||||
settle(state);
|
||||
state.away_at = null;
|
||||
state.detour_interruption = { identity, resume:true, reason };
|
||||
return write(state) ? { ...state.detour_interruption } : null;
|
||||
},
|
||||
detourInterruption() {
|
||||
return validDetour(read());
|
||||
},
|
||||
returnFromDetour(reason = '') {
|
||||
const state = read();
|
||||
const pending = validDetour(state);
|
||||
if (reason && pending?.reason !== reason) return null;
|
||||
const entry = pending && state.entries[pending.identity];
|
||||
if (!pending || !entry || state.active_identity !== pending.identity) return null;
|
||||
const resumed = pending.resume && !entry.running;
|
||||
if (resumed) {
|
||||
entry.started_at = now();
|
||||
entry.running = true;
|
||||
}
|
||||
state.detour_interruption = null;
|
||||
state.away_at = null;
|
||||
return write(state) ? { identity:pending.identity, resumed, reason:pending.reason } : null;
|
||||
},
|
||||
markAway() {
|
||||
const state = read();
|
||||
const entry = state.entries[state.active_identity];
|
||||
@@ -197,12 +415,32 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||
}, 0);
|
||||
},
|
||||
snapshot,
|
||||
sessionSnapshot:(identity = '') => snapshot(identity, true),
|
||||
};
|
||||
}
|
||||
|
||||
function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRunway, getItem, onReopen, onComplete }) {
|
||||
function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRunway, getItem, onReopen, onResume, onComplete, onCapture }) {
|
||||
let progress = null;
|
||||
let runway = null;
|
||||
let breakView = null;
|
||||
if (typeof createTodayBreak === 'function') {
|
||||
breakView = createTodayBreak({timer, qs:selector => queryAll(selector)[0], queryAll, onChange:() => render(), onResume:onResume || onReopen});
|
||||
}
|
||||
const captureView = createTodayCaptureInterruption({
|
||||
timer,
|
||||
banner:queryAll('#today-capture-interruption')[0],
|
||||
label:queryAll('#today-capture-interruption-label')[0],
|
||||
getItem,
|
||||
onReturn:() => render(),
|
||||
});
|
||||
const searchView = createTodaySearchInterruption({
|
||||
timer, queryAll, getItemLabel:identity => getItem?.(identity)?.title, onChange:() => render(),
|
||||
onReturn:identity => onReopen?.(identity),
|
||||
});
|
||||
const detourView = createTodayDetourInterruption({
|
||||
timer, queryAll, getItemLabel:identity => getItem?.(identity)?.title, onChange:() => render(),
|
||||
onReturn:identity => onReopen?.(identity),
|
||||
});
|
||||
queryAll('[data-mobile-today-open]').forEach(button =>
|
||||
button.addEventListener('click', () => {
|
||||
const identity = timer.snapshot().identity;
|
||||
@@ -248,6 +486,7 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
|
||||
};
|
||||
const render = () => {
|
||||
const snapshot = timer.snapshot();
|
||||
breakView?.render();
|
||||
const active = Boolean(progress && isActive() && snapshot.identity);
|
||||
queryAll('[data-mobile-today-hud]').forEach(element => { element.hidden = !active; });
|
||||
queryAll('[data-mobile-today-open]').forEach(element => {
|
||||
@@ -302,19 +541,49 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
|
||||
button.setAttribute('aria-pressed', String(!snapshot.running));
|
||||
});
|
||||
};
|
||||
return {
|
||||
const view = {
|
||||
open(identity, active) {
|
||||
const current = timer.snapshot();
|
||||
if (active && current.identity !== identity) timer.activate(identity);
|
||||
else if (!active) timer.stop();
|
||||
render();
|
||||
},
|
||||
reopen(identity) { onReopen?.(identity); },
|
||||
search(action, ...args) { return searchView[action]?.(...args); },
|
||||
beginDetour(reason) { return detourView.open(reason); },
|
||||
finishDetour(reason) { return detourView.finish(reason); },
|
||||
finish() { timer.stop(); progress = null; runway = null; render(); },
|
||||
update(nextProgress, nextRunway) { progress = nextProgress; runway = nextRunway; render(); },
|
||||
reset() { progress = null; runway = null; render(); },
|
||||
toggle() { const state = timer.snapshot(); state.running ? timer.pause() : timer.resume(); render(); },
|
||||
beginCapture() {
|
||||
const result = captureView.open();
|
||||
this.restoreCapture();
|
||||
render();
|
||||
return result;
|
||||
},
|
||||
restoreCapture() {
|
||||
const result = captureView.restore();
|
||||
const button = queryAll('#save-unfiled-issue')[0];
|
||||
if (button) button.textContent = result ? 'Save & return to Today' : 'Save to Drafts';
|
||||
return result;
|
||||
},
|
||||
finishCapture() { return captureView.finish(); },
|
||||
transferCapture() { return captureView.transfer(); },
|
||||
restore(recovery) {
|
||||
render();
|
||||
return Promise.resolve(recovery).catch(() => false).then(() => { render(); return !!timer.breakSnapshot?.(); });
|
||||
},
|
||||
render,
|
||||
};
|
||||
queryAll('#new-issue').forEach(button => button.addEventListener('click', () => {
|
||||
view.beginCapture();
|
||||
onCapture?.();
|
||||
}));
|
||||
if (timer.captureInterruption) view.restoreCapture();
|
||||
searchView.restore();
|
||||
detourView.restore();
|
||||
return view;
|
||||
}
|
||||
|
||||
function createTodayBudgetReplan({ timer, openPlan }) {
|
||||
@@ -366,9 +635,133 @@ function createTodayInterruptionPrompt({ timer, sheet, description, getItemLabel
|
||||
};
|
||||
}
|
||||
|
||||
function createTodaySearchInterruption({ timer, queryAll, getItemLabel, onChange, onReturn }) {
|
||||
const render = (pending = timer.searchInterruption?.()) => {
|
||||
queryAll('[data-search-today-interruption]').forEach(element => { element.hidden = !pending; });
|
||||
queryAll('[data-search-today-label]').forEach(element => {
|
||||
element.textContent = pending ? 'Today paused · ' +
|
||||
String(getItemLabel?.(pending.identity) || 'Current Today item') : '';
|
||||
});
|
||||
onChange?.();
|
||||
return pending;
|
||||
};
|
||||
const view = {
|
||||
open() {
|
||||
const wasRunning = timer.snapshot().running;
|
||||
const pending = timer.beginSearch?.();
|
||||
render(pending || timer.searchInterruption?.());
|
||||
if (wasRunning && !pending) {
|
||||
const status = queryAll('#cmd-search-action-status')[0];
|
||||
if (status) status.textContent = 'Search is open, but Today timing could not be paused on this device.';
|
||||
}
|
||||
return pending;
|
||||
},
|
||||
restore() { return render(); },
|
||||
finish() {
|
||||
const result = timer.returnFromSearch?.();
|
||||
render(null);
|
||||
return result;
|
||||
},
|
||||
transfer() {
|
||||
const result = timer.abandonSearch?.();
|
||||
render(null);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
queryAll('#open-palette').forEach(button => button.addEventListener('click', () => view.open()));
|
||||
queryAll('[data-return-from-search]').forEach(button => button.addEventListener('click', () => {
|
||||
const pending = timer.searchInterruption?.();
|
||||
view.finish();
|
||||
if (pending) onReturn?.(pending.identity);
|
||||
}));
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
const overlays = [...queryAll('#cmd-palette'), ...queryAll('#search-preview')];
|
||||
const observer = new MutationObserver(() => {
|
||||
if (overlays.every(element => !element.classList.contains('open'))) view.finish();
|
||||
});
|
||||
overlays.forEach(element => observer.observe(element, {attributes:true, attributeFilter:['class']}));
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
function createTodayCaptureInterruption({ timer, banner, label, getItem, getItemLabel, onReturn }) {
|
||||
banner ||= typeof document === 'undefined' ? null : document.querySelector('#today-capture-interruption');
|
||||
label ||= typeof document === 'undefined' ? null : document.querySelector('#today-capture-interruption-label');
|
||||
const render = pending => {
|
||||
if (!banner || !label) return;
|
||||
banner.hidden = !pending;
|
||||
label.textContent = pending ? 'Today paused · ' +
|
||||
String(getItem?.(pending.identity)?.title || getItemLabel?.(pending.identity) || 'Current Today item') : '';
|
||||
};
|
||||
return {
|
||||
open() {
|
||||
const pending = timer.beginCapture();
|
||||
render(pending);
|
||||
return pending;
|
||||
},
|
||||
restore() {
|
||||
const pending = timer.captureInterruption();
|
||||
render(pending);
|
||||
return pending;
|
||||
},
|
||||
finish() {
|
||||
const result = timer.returnFromCapture();
|
||||
render(null);
|
||||
if (result) onReturn?.(result);
|
||||
return result;
|
||||
},
|
||||
transfer() {
|
||||
const result = timer.abandonCapture();
|
||||
render(null);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createTodayDetourInterruption({ timer, queryAll, getItemLabel, onChange, onReturn }) {
|
||||
const render = (pending = timer.detourInterruption?.()) => {
|
||||
queryAll('[data-today-detour]').forEach(element => { element.hidden = !pending; });
|
||||
queryAll('[data-today-detour-label]').forEach(element => {
|
||||
element.textContent = pending ? 'Today paused · ' +
|
||||
String(getItemLabel?.(pending.identity) || 'Current Today item') : '';
|
||||
});
|
||||
onChange?.();
|
||||
return pending;
|
||||
};
|
||||
const view = {
|
||||
open(reason) {
|
||||
const pending = timer.beginDetour?.(reason);
|
||||
render(pending || timer.detourInterruption?.());
|
||||
return pending;
|
||||
},
|
||||
restore() { return render(); },
|
||||
finish(reason) {
|
||||
const result = timer.returnFromDetour?.(reason);
|
||||
if (result || !reason) render(null);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
queryAll('[data-return-from-detour]').forEach(button => button.addEventListener('click', () => {
|
||||
const pending = timer.detourInterruption?.();
|
||||
view.finish();
|
||||
if (pending) onReturn?.(pending.identity);
|
||||
}));
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
queryAll('#find-work-sheet').forEach(sheet => new MutationObserver(() => {
|
||||
const pending = timer.detourInterruption?.();
|
||||
if (sheet.classList.contains('open')) view.open('find');
|
||||
else if (pending?.reason === 'find') view.finish();
|
||||
}).observe(sheet, {attributes:true, attributeFilter:['class']}));
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
createTodayTimer.createView = createTodayTimerView;
|
||||
createTodayTimer.createInterruptionPrompt = createTodayInterruptionPrompt;
|
||||
createTodayTimer.createBudgetReplan = createTodayBudgetReplan;
|
||||
createTodayTimer.createSearchInterruption = createTodaySearchInterruption;
|
||||
createTodayTimer.createCaptureInterruption = createTodayCaptureInterruption;
|
||||
createTodayTimer.createDetourInterruption = createTodayDetourInterruption;
|
||||
module.exports = createTodayTimer;
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
function createTodayWeekReschedule({
|
||||
week,
|
||||
api,
|
||||
getToday,
|
||||
adoptToday = () => {},
|
||||
operationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()),
|
||||
storage = null,
|
||||
getLogin = () => '',
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
let current = null;
|
||||
let flushing = null;
|
||||
const storagePrefix = 'stackchain.today-week-reschedule.v1.';
|
||||
|
||||
function storageKey() {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? storagePrefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function validRecord(value) {
|
||||
return typeof value?.body?.operation_id === 'string' &&
|
||||
Array.isArray(value?.today?.ids) && Array.isArray(value?.week?.days);
|
||||
}
|
||||
|
||||
function readQueue() {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return [];
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(key) || 'null');
|
||||
if (Array.isArray(value?.records)) return value.records.filter(validRecord);
|
||||
return validRecord(value) ? [value] : [];
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function saveQueue(records) {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return false;
|
||||
try {
|
||||
if (!records.length) storage.removeItem(key);
|
||||
else storage.setItem(key, JSON.stringify({version:2, records}));
|
||||
return true;
|
||||
}
|
||||
catch (_error) { return false; }
|
||||
}
|
||||
|
||||
function persist(record) { return saveQueue([...readQueue(), record]); }
|
||||
|
||||
function pending() {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return null;
|
||||
return readQueue()[0] || null;
|
||||
}
|
||||
|
||||
function resume() {
|
||||
const records = readQueue();
|
||||
const record = records[records.length - 1];
|
||||
if (!record) return null;
|
||||
adoptToday(record.today);
|
||||
week.adopt(record.week);
|
||||
return {...record, sync_pending:true, pending_count:records.length};
|
||||
}
|
||||
|
||||
async function restoreConflict(error) {
|
||||
if (error?.status !== 409) throw error;
|
||||
const [today, weekState] = await Promise.all([api('api/v1/today'), api('api/v1/week')]);
|
||||
adoptToday(today);
|
||||
week.adopt(weekState);
|
||||
const conflict = new Error('Plans changed on another device. Review and retry this saved move.');
|
||||
conflict.status = 409;
|
||||
throw conflict;
|
||||
}
|
||||
|
||||
function flush(retryConflict = false) {
|
||||
if (flushing) return flushing;
|
||||
if (!pending()) return Promise.resolve(false);
|
||||
flushing = (async () => {
|
||||
let result = null;
|
||||
let conflictRetried = false;
|
||||
while (true) {
|
||||
const records = readQueue();
|
||||
const record = records[0];
|
||||
if (!record) return result ? {...result, sync_pending:false, pending_count:0} : false;
|
||||
try {
|
||||
result = await api('api/v1/week/reschedule', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(record.body),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status === 409 && retryConflict && !conflictRetried) {
|
||||
const [today, weekState] = await Promise.all([api('api/v1/today'), api('api/v1/week')]);
|
||||
adoptToday(today); week.adopt(weekState);
|
||||
const latest = readQueue();
|
||||
if (today?.ids?.includes(record.body.identity) &&
|
||||
latest[0]?.body?.operation_id === record.body.operation_id) {
|
||||
latest[0] = {...latest[0], body:{
|
||||
...latest[0].body,
|
||||
today_revision:today.revision,
|
||||
week_revision:weekState.revision,
|
||||
}};
|
||||
if (!saveQueue(latest)) throw new Error('Could not update this saved move after plans changed.');
|
||||
conflictRetried = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
await restoreConflict(error);
|
||||
}
|
||||
adoptToday(result.today);
|
||||
week.adopt(result.week);
|
||||
const latest = readQueue();
|
||||
if (latest[0]?.body?.operation_id !== record.body.operation_id) continue;
|
||||
const remaining = latest.slice(1).map((entry, index) => index ? entry : ({
|
||||
...entry,
|
||||
body:{...entry.body, today_revision:result.today.revision, week_revision:result.week.revision},
|
||||
}));
|
||||
if (!saveQueue(remaining)) throw new Error('Move synced, but its local queue could not be updated.');
|
||||
}
|
||||
})().finally(() => { flushing = null; });
|
||||
return flushing;
|
||||
}
|
||||
|
||||
function optimisticSnapshots(planDate, estimate) {
|
||||
const today = {
|
||||
...current.today,
|
||||
ids: current.today.ids.filter(id => id !== current.identity),
|
||||
estimates: {...(current.today.estimates || {})},
|
||||
};
|
||||
delete today.estimates[current.identity];
|
||||
const days = (current.week.days || []).map(day => {
|
||||
const estimates = {...(day.estimates || {})}; delete estimates[current.identity];
|
||||
return {...day, ids:(day.ids || []).filter(id => id !== current.identity), estimates};
|
||||
});
|
||||
let destination = days.find(day => day.plan_date === planDate);
|
||||
if (!destination) {
|
||||
destination = {plan_date:planDate, ids:[], capacity_minutes:null, estimates:{}};
|
||||
days.push(destination);
|
||||
}
|
||||
destination.ids.push(current.identity);
|
||||
destination.estimates[current.identity] = estimate;
|
||||
return {today, week:{revision:current.week_revision, timezone:current.week.timezone || null, days}};
|
||||
}
|
||||
|
||||
async function open(identity) {
|
||||
let today;
|
||||
const queued = readQueue().length;
|
||||
let offline = Boolean(queued);
|
||||
if (queued) {
|
||||
today = getToday?.();
|
||||
} else {
|
||||
try { today = await api('api/v1/today'); }
|
||||
catch (error) {
|
||||
if (error?.status === 401 || error?.status === 403) throw error;
|
||||
today = getToday?.(); offline = true;
|
||||
}
|
||||
}
|
||||
if (!identity || !today?.ids?.includes(identity)) {
|
||||
throw new Error('The active Today item changed. Reopen rescheduling.');
|
||||
}
|
||||
const loaded = queued ? week.state?.() : await week.load();
|
||||
if (!loaded) throw new Error('Reload Today before rescheduling another saved move.');
|
||||
if (loaded?.sync_pending) {
|
||||
throw new Error('Reconnect before rescheduling Today into Week Ahead.');
|
||||
}
|
||||
offline = offline || Boolean(loaded?.offline_snapshot);
|
||||
const estimate = Number(today.estimates?.[identity]);
|
||||
const estimateMinutes = Number.isFinite(estimate) && estimate > 0 ? estimate : null;
|
||||
const days = week.review().days.map(day => {
|
||||
const ids = (day.ids || []).filter(id => id !== identity);
|
||||
const planned = Number(day.planned_minutes) || 0;
|
||||
const existing = Number(day.estimates?.[identity]) || 0;
|
||||
return {
|
||||
...day,
|
||||
ids,
|
||||
planned_minutes: Math.max(0, planned - existing),
|
||||
load: `${Math.max(0, planned - existing)} / ${Number(day.capacity_minutes) || 0} min`,
|
||||
eligible: ids.length < 5,
|
||||
};
|
||||
});
|
||||
current = {
|
||||
identity,
|
||||
today_revision: today.revision,
|
||||
week_revision: loaded.revision,
|
||||
operation_id: operationId(),
|
||||
estimate_minutes: estimateMinutes,
|
||||
days,
|
||||
today,
|
||||
week: loaded,
|
||||
offline,
|
||||
};
|
||||
return {...current, days:days.map(day => ({...day, ids:[...day.ids]}))};
|
||||
}
|
||||
|
||||
async function confirm(planDate, estimateMinutes, {allowOverload = false} = {}) {
|
||||
if (!current) throw new Error('Open rescheduling before choosing a day.');
|
||||
const day = current.days.find(value => value.plan_date === planDate);
|
||||
if (!day) throw new Error('Choose one of the next seven days.');
|
||||
const estimate = Number(estimateMinutes);
|
||||
if (!Number.isFinite(estimate) || estimate < 5 || estimate > 1440) {
|
||||
throw new Error('Estimate must be between 5 and 1440 minutes.');
|
||||
}
|
||||
if (!day.eligible) throw new Error('That Week Ahead day already has five items.');
|
||||
const capacity = Number(day.capacity_minutes) || 0;
|
||||
if (capacity && day.planned_minutes + estimate > capacity && !allowOverload) {
|
||||
throw new Error("That move exceeds the day's capacity. Confirm overload before rescheduling.");
|
||||
}
|
||||
const body = {
|
||||
operation_id:current.operation_id,
|
||||
identity:current.identity,
|
||||
estimate_minutes:estimate,
|
||||
plan_date:planDate,
|
||||
today_revision:current.today_revision,
|
||||
week_revision:current.week_revision,
|
||||
allow_over_capacity:allowOverload,
|
||||
};
|
||||
const optimistic = optimisticSnapshots(planDate, estimate);
|
||||
const durable = Boolean(storageKey() && storage);
|
||||
const admitted = durable && persist({body, ...optimistic, queued_at:now()});
|
||||
if (durable && !admitted) {
|
||||
throw new Error('Could not save this move on this device. Nothing changed.');
|
||||
}
|
||||
if (admitted) {
|
||||
adoptToday(optimistic.today);
|
||||
week.adopt(optimistic.week);
|
||||
}
|
||||
if (current.offline) {
|
||||
current = null;
|
||||
return {...optimistic, sync_pending:true, pending_count:readQueue().length};
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
if (admitted) {
|
||||
result = await flush(true);
|
||||
} else {
|
||||
try {
|
||||
result = await api('api/v1/week/reschedule', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status !== 409) throw error;
|
||||
const [today, weekState] = await Promise.all([api('api/v1/today'), api('api/v1/week')]);
|
||||
adoptToday(today); week.adopt(weekState);
|
||||
if (!today?.ids?.includes(body.identity)) throw error;
|
||||
body.today_revision = today.revision; body.week_revision = weekState.revision;
|
||||
result = await api('api/v1/week/reschedule', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch(error) {
|
||||
if (admitted && error?.status === 409) await restoreConflict(error);
|
||||
if (admitted && error?.status !== 401 && error?.status !== 403) {
|
||||
current = null;
|
||||
return {...optimistic, sync_pending:true};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
adoptToday(result.today);
|
||||
week.adopt(result.week);
|
||||
current = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
function cancel() { current = null; }
|
||||
return {open, confirm, cancel, state:() => current, pending, resume, flush};
|
||||
}
|
||||
|
||||
function mountTodayWeekReschedule({
|
||||
qs, document=globalThis.document, window=globalThis.window, week, api, getToday, adoptToday, currentTarget, closeActions,
|
||||
refresh, warm, continueToday, announce, storage=null, getLogin=()=>'', schedule=callback=>requestAnimationFrame(callback),
|
||||
}={}) {
|
||||
const dialog=qs('#today-week-reschedule'),daysRoot=qs('#today-week-reschedule-days');
|
||||
const estimate=qs('#today-week-reschedule-estimate'),status=qs('#today-week-reschedule-status');
|
||||
const confirm=qs('#confirm-today-week-reschedule'),launcher=qs('[data-work-session-reschedule-week]');
|
||||
let selectedDate=null,allowOverload=false;
|
||||
const controller=createTodayWeekReschedule({week,api,getToday,adoptToday,storage,getLogin});
|
||||
async function flushPending() {
|
||||
if(!controller.pending())return false;
|
||||
try {
|
||||
const result=await controller.flush();
|
||||
announce('Today-to-Week move synced to your account.');
|
||||
await refresh();warm();
|
||||
return result;
|
||||
} catch(error) {
|
||||
announce(`${error.message||'Sync unavailable.'} Move saved on this device · sync pending.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function resumePending() {
|
||||
const restored=controller.resume();
|
||||
if(!restored)return false;
|
||||
announce('Today-to-Week move saved on this device · sync pending.');
|
||||
flushPending();
|
||||
return restored;
|
||||
}
|
||||
window?.addEventListener('online', flushPending);
|
||||
document?.addEventListener('visibilitychange',()=>document.hidden?false:flushPending());
|
||||
let restoreAttempts=0;
|
||||
function restoreWhenOwned(){
|
||||
if(resumePending()||getLogin()||restoreAttempts++>=20)return;
|
||||
window?.setTimeout(restoreWhenOwned,250);
|
||||
}
|
||||
restoreWhenOwned();
|
||||
function close() {
|
||||
controller.cancel();selectedDate=null;allowOverload=false;
|
||||
if(dialog.open)dialog.close();schedule(()=>{
|
||||
qs('[data-mobile-today-more]').click();launcher.focus();
|
||||
});
|
||||
}
|
||||
function render(opened) {
|
||||
daysRoot.replaceChildren(...opened.days.map(day=>{
|
||||
const button=document.createElement('button');
|
||||
button.type='button';button.dataset.planDate=day.plan_date;button.setAttribute('role','radio');
|
||||
button.setAttribute('aria-checked','false');button.disabled=!day.eligible;
|
||||
const label=document.createElement('strong');label.textContent=day.label;
|
||||
const load=document.createElement('span');load.className='small';
|
||||
load.textContent=day.eligible?day.load:'5 items · full';button.append(label,load);
|
||||
button.addEventListener('click',()=>{
|
||||
selectedDate=day.plan_date;allowOverload=false;
|
||||
daysRoot.querySelectorAll('button').forEach(choice=>choice.setAttribute(
|
||||
'aria-checked',String(choice===button)
|
||||
));
|
||||
status.textContent='';confirm.disabled=false;confirm.textContent='Move to Week Ahead & continue';
|
||||
});
|
||||
return button;
|
||||
}));
|
||||
}
|
||||
launcher.addEventListener('click',async()=>{
|
||||
const target=currentTarget();
|
||||
if(!target){announce('Start or resume a checkpointed Today item before rescheduling.');return;}
|
||||
closeActions();status.textContent='Loading Week Ahead…';confirm.disabled=true;dialog.showModal();
|
||||
try{
|
||||
const opened=await controller.open(target.identity);render(opened);
|
||||
estimate.value=opened.estimate_minutes||'';
|
||||
status.textContent=opened.estimate_minutes?'Choose a future day.':'Add an estimate, then choose a future day.';
|
||||
schedule(()=>daysRoot.querySelector('button:not(:disabled)')?.focus());
|
||||
}catch(error){status.textContent=error.message;}
|
||||
});
|
||||
qs('#cancel-today-week-reschedule').addEventListener('click',close);
|
||||
dialog.addEventListener('cancel',event=>{event.preventDefault();close();});
|
||||
confirm.addEventListener('click',async()=>{
|
||||
if(!selectedDate)return;
|
||||
confirm.disabled=true;status.textContent='Moving Today into Week Ahead…';
|
||||
try{
|
||||
const result=await controller.confirm(selectedDate,Number(estimate.value),{allowOverload});
|
||||
dialog.close();
|
||||
if(result.sync_pending){
|
||||
announce('Moved locally. Saved on this device · sync pending.');warm();
|
||||
}else{
|
||||
await refresh();warm();announce('Moved to Week Ahead. Continuing Today.');
|
||||
}
|
||||
await continueToday();
|
||||
}catch(error){
|
||||
const overload=error.message.includes('Confirm overload');allowOverload=overload;
|
||||
status.textContent=error.message;
|
||||
confirm.textContent=overload?'Confirm overload & continue':'Move to Week Ahead & continue';
|
||||
confirm.disabled=false;
|
||||
}
|
||||
});
|
||||
return {controller,close,flushPending,resumePending};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createTodayWeekReschedule;
|
||||
module.exports.mount = mountTodayWeekReschedule;
|
||||
}
|
||||
+24
-1
@@ -127,6 +127,29 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
return saved;
|
||||
}
|
||||
|
||||
function capture(item) {
|
||||
const id = identity(item);
|
||||
const ids = read();
|
||||
const index = ids.indexOf(id);
|
||||
if (index < 0) return null;
|
||||
return { id, index, ids: ids.slice(), plan: planning() };
|
||||
}
|
||||
|
||||
function restore(snapshot) {
|
||||
if (!snapshot || typeof snapshot.id !== 'string' || !Array.isArray(snapshot.ids)) return 'unavailable';
|
||||
const current = read();
|
||||
const expected = snapshot.ids.filter(id => id !== snapshot.id);
|
||||
if (current.includes(snapshot.id)) return 'changed';
|
||||
if (current.length >= limit) return 'full';
|
||||
if (current.length !== expected.length || current.some((id, index) => id !== expected[index])) return 'changed';
|
||||
if (!write(snapshot.ids)) return 'unavailable';
|
||||
if (!replacePlanning(snapshot.plan)) {
|
||||
write(current);
|
||||
return 'unavailable';
|
||||
}
|
||||
return 'restored';
|
||||
}
|
||||
|
||||
function move(item, direction) {
|
||||
const ids = read();
|
||||
const index = ids.indexOf(identity(item));
|
||||
@@ -177,7 +200,7 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
};
|
||||
}
|
||||
|
||||
return { identity, read, replace, planning, replacePlanning, runway, add, addMany, remove, move, reconcile, contains, position, limit };
|
||||
return { identity, read, replace, planning, replacePlanning, runway, add, addMany, remove, capture, restore, move, reconcile, contains, position, limit };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
|
||||
@@ -0,0 +1,159 @@
|
||||
function createTodayWrapUp({ todayWork, tomorrowPlan, todaySync, limit = 5 }) {
|
||||
let items = [];
|
||||
const selected = new Set();
|
||||
|
||||
function open(candidates) {
|
||||
const current = new Set(todayWork.read());
|
||||
const byIdentity = new Map((candidates || []).map(item => [todayWork.identity(item), item]));
|
||||
items = todayWork.read().flatMap(identity =>
|
||||
current.has(identity) && byIdentity.has(identity) ? [byIdentity.get(identity)] : []
|
||||
);
|
||||
selected.clear();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function choose(identity, schedule) {
|
||||
if (!items.some(item => todayWork.identity(item) === identity)) return false;
|
||||
if (schedule) selected.add(identity);
|
||||
else selected.delete(identity);
|
||||
return true;
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return items.map(item => ({
|
||||
item,
|
||||
identity:todayWork.identity(item),
|
||||
schedule:selected.has(todayWork.identity(item)),
|
||||
}));
|
||||
}
|
||||
|
||||
async function finish() {
|
||||
const loaded = await tomorrowPlan.load();
|
||||
const currentIds = Array.isArray(loaded?.ids) ? [...loaded.ids] : [];
|
||||
const carried = items.filter(item => selected.has(todayWork.identity(item)) && todayWork.contains(item));
|
||||
if (!carried.length) {
|
||||
return {
|
||||
scheduled:0,
|
||||
left:items.filter(item => todayWork.contains(item)).length,
|
||||
plan_count:currentIds.length,
|
||||
plan_date:loaded?.plan_date,
|
||||
sync_pending:Boolean(loaded?.sync_pending),
|
||||
};
|
||||
}
|
||||
const combinedIds = [...currentIds];
|
||||
for (const item of carried) {
|
||||
const identity = todayWork.identity(item);
|
||||
if (!combinedIds.includes(identity)) combinedIds.push(identity);
|
||||
}
|
||||
if (combinedIds.length > limit) {
|
||||
throw new Error(`Tomorrow can hold ${limit} items. Keep ${combinedIds.length - limit} in Today or edit Tomorrow first.`);
|
||||
}
|
||||
const estimates = {...(loaded?.estimates || {})};
|
||||
const capacityMinutes = loaded?.capacity_minutes ?? null;
|
||||
const plannedMinutes = combinedIds.reduce((total, identity) => total + (Number(estimates[identity]) || 0), 0);
|
||||
if (capacityMinutes !== null && plannedMinutes > capacityMinutes) {
|
||||
throw new Error('Tomorrow is over capacity. Edit the plan before finishing wrap-up.');
|
||||
}
|
||||
const staged = tomorrowPlan.stage({ids:combinedIds, capacity_minutes:capacityMinutes, estimates});
|
||||
if (!staged) throw new Error('Tomorrow could not be saved on this device. Your Today plan is unchanged.');
|
||||
let scheduled = 0;
|
||||
for (const item of carried) {
|
||||
const identity = todayWork.identity(item);
|
||||
if (!todaySync.enqueue('remove', identity)) {
|
||||
throw new Error('Today sync could not be queued. The item remains in Today.');
|
||||
}
|
||||
if (todayWork.contains(item) && !todayWork.remove(item)) {
|
||||
throw new Error('Today could not be updated. Retry wrap-up.');
|
||||
}
|
||||
scheduled += 1;
|
||||
}
|
||||
await todaySync.flush();
|
||||
return {
|
||||
scheduled,
|
||||
left:items.length - scheduled,
|
||||
plan_count:combinedIds.length,
|
||||
plan_date:staged.plan_date,
|
||||
sync_pending:Boolean(staged.sync_pending),
|
||||
};
|
||||
}
|
||||
|
||||
return { open, choose, snapshot, finish };
|
||||
}
|
||||
|
||||
function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () => {}, onClose = () => {} }) {
|
||||
let actualMinutes = {};
|
||||
let workedItems = [];
|
||||
|
||||
function close() {
|
||||
qs('#today-wrap-up-sheet').hidden = true;
|
||||
document.body.classList.remove('task-overlay-open');
|
||||
onClose();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const rows = controller.snapshot();
|
||||
qs('#today-wrap-up-items').innerHTML = rows.map(row => {
|
||||
const title = String(row.item.title || row.identity).slice(0, 180);
|
||||
const context = String(row.item.key || row.item.repository || 'Work item').slice(0, 180);
|
||||
return '<div class="today-wrap-up-item"><span><strong>' + escapeHtml(title) + '</strong>' +
|
||||
'<span class="small muted">' + escapeHtml(context) + '</span></span><label><input type="checkbox" ' +
|
||||
'data-wrap-up-identity="' + escapeHtml(row.identity) + '"' + (row.schedule ? ' checked' : '') +
|
||||
'> Carry to Tomorrow</label></div>';
|
||||
}).join('') || '<p class="small">Nothing unfinished remains in Today.</p>';
|
||||
qs('#today-wrap-up-items').querySelectorAll('[data-wrap-up-identity]').forEach(input => {
|
||||
input.addEventListener('change', () => controller.choose(input.dataset.wrapUpIdentity, input.checked));
|
||||
});
|
||||
}
|
||||
|
||||
function open(items, recapActualMinutes = {}, recapWorkedItems = []) {
|
||||
actualMinutes = { ...recapActualMinutes };
|
||||
workedItems = recapWorkedItems.map(item => ({...item}));
|
||||
controller.open(items);
|
||||
qs('#today-wrap-up-status').textContent = '';
|
||||
render();
|
||||
qs('#today-wrap-up-sheet').hidden = false;
|
||||
document.body.classList.add('task-overlay-open');
|
||||
requestAnimationFrame(() => (qs('#today-wrap-up-items input') || qs('#finish-today-wrap-up')).focus());
|
||||
}
|
||||
|
||||
async function finish(button) {
|
||||
button.disabled = true;
|
||||
qs('#today-wrap-up-status').textContent = 'Saving tomorrow’s plan…';
|
||||
try {
|
||||
const tomorrowItems = controller.snapshot().filter(row => row.schedule).map(row => ({
|
||||
identity:row.identity,
|
||||
title:String(row.item.title || row.identity).slice(0, 180),
|
||||
context:String(row.item.key || row.item.repository || 'Work item').slice(0, 180),
|
||||
}));
|
||||
const result = await controller.finish();
|
||||
qs('#my-work-action-status').textContent = result.scheduled + ' carried to Tomorrow · ' + result.left +
|
||||
' left in Today' + (result.sync_pending ? ' · sync pending.' : '.');
|
||||
close();
|
||||
onComplete(result, actualMinutes, workedItems, tomorrowItems);
|
||||
} catch (error) {
|
||||
qs('#today-wrap-up-status').textContent = error.message || 'Wrap-up could not be saved. Retry when ready.';
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function bind() {
|
||||
qs('#close-today-wrap-up').addEventListener('click', close);
|
||||
qs('#finish-today-wrap-up').addEventListener('click', event => finish(event.currentTarget));
|
||||
}
|
||||
|
||||
return { open, close, finish, render, bind };
|
||||
}
|
||||
|
||||
function setupTodayWrapUp(options) {
|
||||
const controller = createTodayWrapUp(options);
|
||||
const view = createTodayWrapUpView({ ...options, controller });
|
||||
view.bind();
|
||||
return view;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
createTodayWrapUp.createView = createTodayWrapUpView;
|
||||
createTodayWrapUp.setup = setupTodayWrapUp;
|
||||
module.exports = createTodayWrapUp;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
function createTomorrowPlan({fetchJson,localDate,timeZone,storage,getLogin}={}) {
|
||||
let plan={revision:0,ids:[],capacity_minutes:null,estimates:{}};
|
||||
let flushing=null;
|
||||
let lastConflict=null;
|
||||
const storagePrefix='stackchain.tomorrow-sync.v1.';
|
||||
const state=()=>({...plan,ids:[...plan.ids],estimates:{...plan.estimates}});
|
||||
function storageKey() {
|
||||
const login=String(getLogin?.()||'').trim().toLowerCase();
|
||||
return login?storagePrefix+encodeURIComponent(login):'';
|
||||
}
|
||||
function pending() {
|
||||
const key=storageKey();
|
||||
if(!key||!storage) return false;
|
||||
try {
|
||||
const value=JSON.parse(storage.getItem(key)||'null');
|
||||
return Number.isInteger(value?.base_revision)&&Array.isArray(value?.ids)?
|
||||
{...value,ids:[...value.ids],estimates:{...(value.estimates||{})},sync_pending:true}:false;
|
||||
} catch (_error) { return false; }
|
||||
}
|
||||
function adopt(value) {
|
||||
if (!Number.isInteger(value?.revision)||!Array.isArray(value?.ids)) return false;
|
||||
plan={revision:value.revision,ids:[...value.ids],capacity_minutes:value.capacity_minutes??null,
|
||||
estimates:{...(value.estimates||{})},...(value.plan_date?{plan_date:value.plan_date,timezone:value.timezone||null}:{})};
|
||||
return state();
|
||||
}
|
||||
function nextLocalDate() {
|
||||
const [y,m,d]=localDate().split('-').map(Number);
|
||||
return new Date(Date.UTC(y,m-1,d+1)).toISOString().slice(0,10);
|
||||
}
|
||||
async function load(){
|
||||
const queued=pending();
|
||||
return queued?(plan={...queued},state()):adopt(await fetchJson('api/v1/tomorrow'));
|
||||
}
|
||||
function stage(value) {
|
||||
const key=storageKey();
|
||||
if(!key||!storage) return false;
|
||||
const queued={base_revision:plan.revision,revision:plan.revision,ids:[...(value.ids||[])],
|
||||
capacity_minutes:value.capacity_minutes??null,estimates:{...(value.estimates||{})},
|
||||
plan_date:nextLocalDate(),timezone:timeZone(),sync_pending:true};
|
||||
try { storage.setItem(key,JSON.stringify(queued)); }
|
||||
catch (_error) { return false; }
|
||||
lastConflict=null;
|
||||
plan=queued;
|
||||
return state();
|
||||
}
|
||||
function deliveryBody(value) {
|
||||
return {base_revision:value.base_revision,ids:[...value.ids],
|
||||
capacity_minutes:value.capacity_minutes??null,estimates:{...(value.estimates||{})},
|
||||
plan_date:value.plan_date,timezone:value.timezone};
|
||||
}
|
||||
function flush() {
|
||||
if(flushing) return flushing;
|
||||
const queued=pending();
|
||||
const key=storageKey();
|
||||
if(!queued||!key) return Promise.resolve(false);
|
||||
const body=deliveryBody(queued);
|
||||
flushing=fetchJson('api/v1/tomorrow',{method:'PUT',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify(body)}).then(saved=>{
|
||||
const current=pending();
|
||||
if(current&&JSON.stringify(deliveryBody(current))===JSON.stringify(body)) storage.removeItem(key);
|
||||
if(!pending()) adopt(saved);
|
||||
return saved;
|
||||
}).catch(async error=>{
|
||||
if(error?.status===409) {
|
||||
const remote=await fetchJson('api/v1/tomorrow');
|
||||
lastConflict={key,local:pending(),remote:{...remote,ids:[...remote.ids],estimates:{...(remote.estimates||{})}}};
|
||||
}
|
||||
throw error;
|
||||
}).finally(()=>{flushing=null;});
|
||||
return flushing;
|
||||
}
|
||||
async function keepLocal() {
|
||||
if(!lastConflict||lastConflict.key!==storageKey()) return false;
|
||||
const conflictKey=lastConflict.key;
|
||||
const local={...lastConflict.local,ids:[...lastConflict.local.ids],estimates:{...lastConflict.local.estimates}};
|
||||
const body=deliveryBody({...local,base_revision:lastConflict.remote.revision});
|
||||
try {
|
||||
const saved=await fetchJson('api/v1/tomorrow',{method:'PUT',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify(body)});
|
||||
const current=pending();
|
||||
if(current&&JSON.stringify(deliveryBody(current))===JSON.stringify(deliveryBody(local))) storage.removeItem(storageKey());
|
||||
if(!pending()) adopt(saved);
|
||||
lastConflict=null;
|
||||
return saved;
|
||||
} catch(error) {
|
||||
if(error?.status===409) {
|
||||
const remote=await fetchJson('api/v1/tomorrow');
|
||||
lastConflict={key:conflictKey,local,remote:{...remote,ids:[...remote.ids],estimates:{...(remote.estimates||{})}}};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function useRemote() {
|
||||
if(!lastConflict||lastConflict.key!==storageKey()) return false;
|
||||
const remote=lastConflict.remote;
|
||||
const key=storageKey();
|
||||
if(key&&storage) storage.removeItem(key);
|
||||
const adopted=adopt(remote);
|
||||
lastConflict=null;
|
||||
return adopted;
|
||||
}
|
||||
async function save(value) {
|
||||
const body={base_revision:plan.revision,ids:[...(value.ids||[])],
|
||||
capacity_minutes:value.capacity_minutes??null,estimates:{...(value.estimates||{})},
|
||||
plan_date:nextLocalDate(),timezone:timeZone()};
|
||||
return adopt(await fetchJson('api/v1/tomorrow',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}));
|
||||
}
|
||||
async function promote(today_revision) {
|
||||
if (pending()||!plan.plan_date||plan.plan_date>localDate()||!plan.ids.length) return false;
|
||||
const promotion_id=`tomorrow-${plan.plan_date}-r${plan.revision}`;
|
||||
return fetchJson('api/v1/tomorrow/promote',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({promotion_id,tomorrow_revision:plan.revision,today_revision})});
|
||||
}
|
||||
function millisecondsUntilNextDay() {
|
||||
const now=Date.now();
|
||||
const zone=timeZone();
|
||||
let formatter;
|
||||
try {
|
||||
formatter=new Intl.DateTimeFormat('en-CA',{timeZone:zone,year:'numeric',month:'2-digit',day:'2-digit',
|
||||
hour:'2-digit',minute:'2-digit',second:'2-digit',hourCycle:'h23'});
|
||||
} catch (_error) {
|
||||
const next=new Date(now);
|
||||
next.setHours(24,0,0,250);
|
||||
return Math.max(1,next.getTime()-now);
|
||||
}
|
||||
const zoned=value=>Object.fromEntries(formatter.formatToParts(new Date(value))
|
||||
.filter(part=>part.type!=='literal').map(part=>[part.type,Number(part.value)]));
|
||||
const current=zoned(now);
|
||||
const targetWall=Date.UTC(current.year,current.month-1,current.day+1);
|
||||
let candidate=targetWall;
|
||||
for(let attempt=0;attempt<2;attempt+=1){
|
||||
const parts=zoned(candidate);
|
||||
const offset=Date.UTC(parts.year,parts.month-1,parts.day,parts.hour,parts.minute,parts.second)-candidate;
|
||||
candidate=targetWall-offset;
|
||||
}
|
||||
return Math.max(1,candidate-now+250);
|
||||
}
|
||||
function summary(value=plan) {
|
||||
const ids=Array.isArray(value?.ids)?value.ids:[];
|
||||
const suffix=value?.sync_pending?' · sync pending':'';
|
||||
if(!ids.length) return 'Nothing planned'+suffix;
|
||||
const label=`${ids.length} planned`;
|
||||
const estimates=value.estimates||{};
|
||||
const estimated=ids.reduce((total,id)=>total+(Number(estimates[id])||0),0);
|
||||
const capacity=Number(value.capacity_minutes)||0;
|
||||
return (estimated&&capacity?`${label} · ${estimated} of ${capacity} min`:label)+suffix;
|
||||
}
|
||||
function startLifecycle({windowObject,documentObject,check,setTimer=setTimeout,clearTimer=clearTimeout,
|
||||
nextDelay=millisecondsUntilNextDay}={}) {
|
||||
let flight=null;
|
||||
let timer=null;
|
||||
const schedule=()=>{
|
||||
if(timer!==null) clearTimer(timer);
|
||||
timer=setTimer(run,nextDelay());
|
||||
};
|
||||
function run(){
|
||||
if(!flight) flight=Promise.resolve().then(check).finally(()=>{flight=null;schedule();});
|
||||
return flight;
|
||||
}
|
||||
windowObject?.addEventListener?.('online',run);
|
||||
documentObject?.addEventListener?.('visibilitychange',()=>documentObject.hidden?false:run());
|
||||
run();
|
||||
return {run,stop(){if(timer!==null)clearTimer(timer);timer=null;}};
|
||||
}
|
||||
function conflict() {
|
||||
return lastConflict?.key===storageKey()?{local:{...lastConflict.local,ids:[...lastConflict.local.ids],estimates:{...lastConflict.local.estimates}},
|
||||
remote:{...lastConflict.remote,ids:[...lastConflict.remote.ids],estimates:{...lastConflict.remote.estimates}}}:null;
|
||||
}
|
||||
return {adopt,load,save,stage,pending,flush,conflict,keepLocal,useRemote,promote,state,summary,nextLocalDate,startLifecycle};
|
||||
}
|
||||
if(typeof module!=='undefined'&&module.exports)module.exports=createTomorrowPlan;
|
||||
+295
-11
@@ -1,3 +1,43 @@
|
||||
function withFilingEstimate(draft, value) {
|
||||
draft.estimateMinutes = Number(value) || null;
|
||||
return draft;
|
||||
}
|
||||
|
||||
function unfiledDraftSummary(item) {
|
||||
const plan = item.filingPlan || {};
|
||||
const ready = Boolean(plan.repository);
|
||||
const startsToday = plan.completionIntent === 'create-and-start';
|
||||
const evidenceCount = Number(item.attachmentCount || (item.hasAttachment ? 1 : 0));
|
||||
const evidence = evidenceCount ? `${evidenceCount} screenshot${evidenceCount === 1 ? '' : 's'}` : 'No screenshots';
|
||||
return {
|
||||
label:ready ? 'Ready to file' : 'Needs filing',
|
||||
repository:plan.repository || '', ready,
|
||||
action:ready ? (startsToday ? 'Review & start' : 'Review & file') : 'Choose repository',
|
||||
details:[startsToday ? 'Create & start' : 'Create issue',
|
||||
plan.estimateMinutes ? `${plan.estimateMinutes} min` : '', evidence].filter(Boolean).join(' · '),
|
||||
};
|
||||
}
|
||||
|
||||
function unfiledDraftDisplayTitle(item) {
|
||||
return String(item?.title || '').trim() || (item?.hasAttachment ? 'Untitled photo draft' : 'Untitled draft');
|
||||
}
|
||||
|
||||
function unfiledShouldFocusTitle({mobile = false, continuing = false} = {}) {
|
||||
return !mobile || continuing;
|
||||
}
|
||||
|
||||
function unfiledSavedMessage(item) {
|
||||
return item.repository ? 'Saved planned Draft. Resume on any signed-in device.' :
|
||||
'Saved to Drafts. Choose a repository when you’re ready to file it.';
|
||||
}
|
||||
|
||||
function applyIssueFilingMode(qs, enabled) {
|
||||
qs('#create-issue-filing').hidden = !enabled;
|
||||
qs('.create-issue-capture-actions').hidden = false;
|
||||
qs('#file-new-issue').hidden = enabled;
|
||||
qs('#create-issue-heading').textContent = enabled ? 'File issue' : 'Capture work';
|
||||
}
|
||||
|
||||
function createUnfiledCaptures({
|
||||
storage,
|
||||
attachmentStore = null,
|
||||
@@ -8,6 +48,8 @@ function createUnfiledCaptures({
|
||||
maxItems = 20,
|
||||
}) {
|
||||
const storageKey = 'stackchain.unfiled-issues.v1';
|
||||
const listeners = new Set();
|
||||
const onChange = (id, removed) => listeners.forEach(listener => listener(id, removed));
|
||||
|
||||
function read() {
|
||||
try {
|
||||
@@ -15,7 +57,8 @@ function createUnfiledCaptures({
|
||||
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
||||
return record.items.filter(item =>
|
||||
item && typeof item.id === 'string' && typeof item.ownerLogin === 'string' &&
|
||||
typeof item.title === 'string' && item.title.trim() && typeof item.body === 'string'
|
||||
typeof item.title === 'string' && (item.title.trim() || item.hasAttachment === true) &&
|
||||
typeof item.body === 'string'
|
||||
);
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
@@ -35,26 +78,108 @@ function createUnfiledCaptures({
|
||||
return {full:items.length >= maxItems, count:items.length, maxItems, oldest:items.at(-1) || null};
|
||||
}
|
||||
|
||||
function filingPlan(value = {}, remote = false) {
|
||||
const get = (local, wire) => value?.[remote ? wire : local];
|
||||
const repository = String(get('repository', 'repository') || '').trim();
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) return null;
|
||||
const labelIds = Array.from(new Set((Array.isArray(get('labelIds', 'label_ids')) ?
|
||||
get('labelIds', 'label_ids') : []).filter(id => Number.isInteger(id) && id > 0))).slice(0, 20);
|
||||
const plan = {repository, labelIds};
|
||||
const milestoneId = Number(get('milestoneId', 'milestone_id'));
|
||||
if (Number.isInteger(milestoneId) && milestoneId > 0) plan.milestoneId = milestoneId;
|
||||
const dueDate = String(get('dueDate', 'due_date') || '');
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(dueDate)) plan.dueDate = dueDate;
|
||||
const bounded = (local, wire, limit) => {
|
||||
const text = String(get(local, wire) || '').trim();
|
||||
if (text) plan[local] = text.slice(0, limit);
|
||||
};
|
||||
bounded('templateName', 'template_name', 80);
|
||||
bounded('templateId', 'template_id', 80);
|
||||
bounded('capturedBody', 'captured_body', 10000);
|
||||
if (get('unassigned', 'unassigned') === true) plan.unassigned = true;
|
||||
const assignee = String(get('assignee', 'assignee') || '').trim();
|
||||
if (!plan.unassigned && /^[A-Za-z0-9_.-]+$/.test(assignee)) {
|
||||
plan.assignee = assignee;
|
||||
bounded('assigneeName', 'assignee_name', 255);
|
||||
}
|
||||
const estimateMinutes = Number(get('estimateMinutes', 'estimate_minutes'));
|
||||
if (Number.isInteger(estimateMinutes) && estimateMinutes >= 5 && estimateMinutes <= 1440) {
|
||||
plan.estimateMinutes = estimateMinutes;
|
||||
}
|
||||
const completionIntent = String(get('completionIntent', 'completion_intent') || '');
|
||||
if (['create', 'create-and-start'].includes(completionIntent)) plan.completionIntent = completionIntent;
|
||||
return plan;
|
||||
}
|
||||
|
||||
function exportFilingPlan(plan) {
|
||||
if (!plan) return null;
|
||||
return {
|
||||
repository:plan.repository, label_ids:plan.labelIds,
|
||||
...(plan.milestoneId ? {milestone_id:plan.milestoneId} : {}),
|
||||
...(plan.dueDate ? {due_date:plan.dueDate} : {}),
|
||||
...(plan.templateName ? {template_name:plan.templateName} : {}),
|
||||
...(plan.templateId ? {template_id:plan.templateId} : {}),
|
||||
...(plan.capturedBody ? {captured_body:plan.capturedBody} : {}),
|
||||
...(plan.unassigned ? {unassigned:true} : {}),
|
||||
...(plan.assignee ? {assignee:plan.assignee} : {}),
|
||||
...(plan.assigneeName ? {assignee_name:plan.assigneeName} : {}),
|
||||
...(plan.estimateMinutes ? {estimate_minutes:plan.estimateMinutes} : {}),
|
||||
...(plan.completionIntent ? {completion_intent:plan.completionIntent} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function prepare(note) {
|
||||
const title = String(note?.title || '').trim().slice(0, 255);
|
||||
const body = String(note?.body || '').trim().slice(0, 10000);
|
||||
if (!title) throw new Error('Add a title before saving.');
|
||||
const ownerLogin = String(getCaptureLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
|
||||
const attachment = note?.attachment;
|
||||
const attachments = note?.attachments;
|
||||
if (Array.isArray(attachments) && attachments.length > 5) {
|
||||
throw new Error('You can attach up to 5 screenshots.');
|
||||
}
|
||||
const validAttachments = Array.isArray(attachments) && attachments.length > 0 &&
|
||||
attachments.every(value => value?.blob && value?.filename &&
|
||||
['image/png', 'image/jpeg', 'image/webp'].includes(String(value?.contentType || '')));
|
||||
if (attachments && !validAttachments) {
|
||||
throw new Error('One or more screenshots are unavailable. Choose them again before saving.');
|
||||
}
|
||||
const hasAttachment = Boolean(attachment?.blob && attachment?.filename &&
|
||||
['image/png', 'image/jpeg', 'image/webp'].includes(String(attachment?.contentType || '')));
|
||||
if (attachment && !hasAttachment) throw new Error('The screenshot is unavailable. Choose it again before saving.');
|
||||
if (hasAttachment && !attachmentStore) {
|
||||
if (!title && !hasAttachment && !validAttachments) throw new Error('Add a title or photo before saving.');
|
||||
const ownerLogin = String(getCaptureLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
|
||||
if ((hasAttachment || validAttachments) && !attachmentStore) {
|
||||
throw new Error('Screenshot storage is unavailable. Your capture is still open; retry after reloading.');
|
||||
}
|
||||
return {title, body, ownerLogin, attachment, hasAttachment};
|
||||
const seenBlockers = new Set();
|
||||
const blockers = (Array.isArray(note?.blockers) ? note.blockers : []).reduce((items, blocker) => {
|
||||
const repository = String(blocker?.repository || '').trim();
|
||||
const number = Number(blocker?.number);
|
||||
const key = repository + '#' + number;
|
||||
if (items.length >= 5 || seenBlockers.has(key) ||
|
||||
!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) ||
|
||||
!Number.isInteger(number) || number < 1) return items;
|
||||
seenBlockers.add(key);
|
||||
items.push({repository, number,
|
||||
title:String(blocker?.title || '').replace(/\s+/g, ' ').trim().slice(0, 255)});
|
||||
return items;
|
||||
}, []);
|
||||
return {
|
||||
title, body, ownerLogin, attachment, attachments, blockers,
|
||||
filingPlan:filingPlan(note),
|
||||
hasAttachment:hasAttachment || validAttachments,
|
||||
attachmentCount:validAttachments ? attachments.length : (hasAttachment ? 1 : 0),
|
||||
};
|
||||
}
|
||||
|
||||
function persist(prepared, existing, removed = null) {
|
||||
const item = {
|
||||
id:String(createId()), ownerLogin:prepared.ownerLogin, title:prepared.title, body:prepared.body,
|
||||
savedAt:Number(now()), ...(prepared.hasAttachment ? {hasAttachment:true} : {}),
|
||||
savedAt:Number(now()), ...(prepared.hasAttachment ? {
|
||||
hasAttachment:true, attachmentCount:prepared.attachmentCount,
|
||||
} : {}), ...(prepared.blockers.length ? {
|
||||
blockers:prepared.blockers, blockerCount:prepared.blockers.length,
|
||||
} : {}), ...(prepared.filingPlan ? {filingPlan:prepared.filingPlan} : {}),
|
||||
};
|
||||
const items = [item, ...existing.filter(candidate => candidate.id !== item.id)];
|
||||
const writeItems = () => {
|
||||
@@ -63,10 +188,17 @@ function createUnfiledCaptures({
|
||||
if (prepared.hasAttachment) Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
onChange(item.id, false);
|
||||
return item;
|
||||
};
|
||||
const stage = prepared.hasAttachment
|
||||
? Promise.resolve(attachmentStore.put(item.id, {
|
||||
? Promise.resolve(attachmentStore.put(item.id, prepared.attachments ? {
|
||||
attachments:prepared.attachments.map(value => ({
|
||||
filename:String(value.filename).slice(0, 255),
|
||||
contentType:String(value.contentType), blob:value.blob,
|
||||
...(String(value.note || '').trim() ? {note:String(value.note).trim().slice(0, 240)} : {}),
|
||||
})),
|
||||
} : {
|
||||
filename:String(prepared.attachment.filename).slice(0, 255),
|
||||
contentType:String(prepared.attachment.contentType), blob:prepared.attachment.blob,
|
||||
}))
|
||||
@@ -113,6 +245,7 @@ function createUnfiledCaptures({
|
||||
const removed = items.find(item => item.id === id);
|
||||
if (!removed?.hasAttachment) {
|
||||
write(remaining);
|
||||
onChange(id, true);
|
||||
return true;
|
||||
}
|
||||
return Promise.resolve(attachmentStore?.get(id)).then(removedAttachment =>
|
||||
@@ -122,6 +255,7 @@ function createUnfiledCaptures({
|
||||
if (removedAttachment) await Promise.resolve(attachmentStore?.put(id, removedAttachment)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
onChange(id, true);
|
||||
return true;
|
||||
})
|
||||
);
|
||||
@@ -133,10 +267,18 @@ function createUnfiledCaptures({
|
||||
if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) {
|
||||
throw new Error('Reconnect with the account that saved this capture.');
|
||||
}
|
||||
const draft = {repository:'', title:item.title, body:item.body, labelIds:[]};
|
||||
const draft = {...(item.filingPlan || {repository:'', labelIds:[]}), title:item.title, body:item.body,
|
||||
...(Array.isArray(item.blockers) && item.blockers.length ? {blockers:item.blockers} : {})};
|
||||
if (!item.hasAttachment) return draft;
|
||||
if (!attachmentStore) throw new Error('The saved screenshot is unavailable. Retry after reloading.');
|
||||
return Promise.resolve(attachmentStore.get(id)).then(attachment => {
|
||||
if (Array.isArray(attachment?.attachments) && attachment.attachments.length) {
|
||||
if (attachment.attachments.length > 5 || attachment.attachments.some(value =>
|
||||
!value?.blob || !value?.filename || !value?.contentType)) {
|
||||
throw new Error('The saved screenshots are unavailable. Keep this Draft and retry.');
|
||||
}
|
||||
return {...draft, attachments:attachment.attachments};
|
||||
}
|
||||
if (!attachment?.blob || !attachment?.filename || !attachment?.contentType) {
|
||||
throw new Error('The saved screenshot is unavailable. Keep this Draft and retry.');
|
||||
}
|
||||
@@ -146,7 +288,149 @@ function createUnfiledCaptures({
|
||||
|
||||
function completeResume(id) { return discard(id); }
|
||||
|
||||
return {list, capacity, save, replaceOldest, discard, resume, completeResume};
|
||||
function encodeBytes(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function decodeBytes(value, contentType) {
|
||||
const binary = atob(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return new Blob([bytes], {type:contentType});
|
||||
}
|
||||
|
||||
async function exportOwned(login) {
|
||||
const owner = String(login || '').trim();
|
||||
const exported = [];
|
||||
for (const item of read().filter(candidate => candidate.ownerLogin === owner)) {
|
||||
let evidence = [];
|
||||
if (item.hasAttachment) {
|
||||
const stored = await attachmentStore?.get(item.id);
|
||||
const attachments = Array.isArray(stored?.attachments) ? stored.attachments :
|
||||
(stored?.blob ? [stored] : []);
|
||||
if (attachments.length !== Number(item.attachmentCount || 1)) {
|
||||
throw new Error('The saved screenshots are unavailable. Keep this Draft and retry.');
|
||||
}
|
||||
evidence = await Promise.all(attachments.map(async attachment => ({
|
||||
filename:attachment.filename,
|
||||
content_type:attachment.contentType,
|
||||
...(attachment.note ? {note:attachment.note} : {}),
|
||||
data:encodeBytes(await attachment.blob.arrayBuffer()),
|
||||
})));
|
||||
}
|
||||
exported.push({
|
||||
id:item.id, title:item.title, body:item.body, saved_at:Number(item.savedAt),
|
||||
...(item.filingPlan ? {filing_plan:exportFilingPlan(item.filingPlan)} : {}),
|
||||
...(Array.isArray(item.blockers) && item.blockers.length ? {blockers:item.blockers} : {}),
|
||||
...(evidence.length ? {evidence} : {}),
|
||||
});
|
||||
}
|
||||
return exported;
|
||||
}
|
||||
|
||||
async function mergeRemote(drafts, login) {
|
||||
const ownerLogin = String(login || '').trim();
|
||||
if (!ownerLogin || !Array.isArray(drafts)) return 0;
|
||||
const existing = read();
|
||||
const known = new Set(existing.map(item => item.id));
|
||||
const imported = [];
|
||||
for (const remote of drafts) {
|
||||
if (!remote || known.has(remote.id) || imported.length + existing.length >= maxItems) continue;
|
||||
const evidence = Array.isArray(remote.evidence) ? remote.evidence : [];
|
||||
if (evidence.length && !attachmentStore) continue;
|
||||
const plan = filingPlan(remote.filing_plan, true);
|
||||
const item = {
|
||||
id:String(remote.id), ownerLogin, title:String(remote.title || ''), body:String(remote.body || ''),
|
||||
savedAt:Number(remote.saved_at),
|
||||
...(plan ? {filingPlan:plan} : {}),
|
||||
...(Array.isArray(remote.blockers) && remote.blockers.length ? {
|
||||
blockers:remote.blockers, blockerCount:remote.blockers.length,
|
||||
} : {}),
|
||||
...(evidence.length ? {hasAttachment:true, attachmentCount:evidence.length} : {}),
|
||||
};
|
||||
if (!item.id || (!item.title.trim() && !evidence.length) || !Number.isFinite(item.savedAt)) continue;
|
||||
if (evidence.length) {
|
||||
await attachmentStore.put(item.id, {attachments:evidence.map(entry => ({
|
||||
filename:String(entry.filename), contentType:String(entry.content_type),
|
||||
blob:decodeBytes(String(entry.data), String(entry.content_type)),
|
||||
...(entry.note ? {note:String(entry.note)} : {}),
|
||||
}))});
|
||||
}
|
||||
known.add(item.id);
|
||||
imported.push(item);
|
||||
}
|
||||
if (imported.length) write(imported.concat(existing));
|
||||
return imported.length;
|
||||
}
|
||||
|
||||
async function reconcileRemote(drafts, login) {
|
||||
const ownerLogin = String(login || '').trim();
|
||||
if (!ownerLogin || !Array.isArray(drafts)) return 0;
|
||||
const existing = read();
|
||||
const retained = existing.filter(item => item.ownerLogin !== ownerLogin);
|
||||
const reconciled = [];
|
||||
const stagedEvidence = new Map();
|
||||
for (const remote of drafts) {
|
||||
if (!remote || reconciled.length + retained.length >= maxItems) continue;
|
||||
const evidence = Array.isArray(remote.evidence) ? remote.evidence : [];
|
||||
if (evidence.length && !attachmentStore) {
|
||||
throw new Error('The synchronized screenshots cannot be stored on this device.');
|
||||
}
|
||||
const plan = filingPlan(remote.filing_plan, true);
|
||||
const item = {
|
||||
id:String(remote.id || ''), ownerLogin, title:String(remote.title || ''), body:String(remote.body || ''),
|
||||
savedAt:Number(remote.saved_at),
|
||||
...(plan ? {filingPlan:plan} : {}),
|
||||
...(Array.isArray(remote.blockers) && remote.blockers.length ? {
|
||||
blockers:remote.blockers, blockerCount:remote.blockers.length,
|
||||
} : {}),
|
||||
...(evidence.length ? {hasAttachment:true, attachmentCount:evidence.length} : {}),
|
||||
};
|
||||
if (!item.id || (!item.title.trim() && !evidence.length) || !Number.isFinite(item.savedAt)) continue;
|
||||
if (evidence.length) {
|
||||
stagedEvidence.set(item.id, {attachments:evidence.map(entry => ({
|
||||
filename:String(entry.filename), contentType:String(entry.content_type),
|
||||
blob:decodeBytes(String(entry.data), String(entry.content_type)),
|
||||
...(entry.note ? {note:String(entry.note)} : {}),
|
||||
}))});
|
||||
}
|
||||
reconciled.push(item);
|
||||
}
|
||||
const evidenceIds = new Set(reconciled.filter(item => item.hasAttachment).map(item => item.id));
|
||||
const affectedIds = new Set(stagedEvidence.keys());
|
||||
for (const item of existing) if (item.ownerLogin === ownerLogin && item.hasAttachment) affectedIds.add(item.id);
|
||||
const previousEvidence = new Map();
|
||||
for (const id of affectedIds) previousEvidence.set(id, await attachmentStore?.get(id));
|
||||
try {
|
||||
for (const [id, value] of stagedEvidence) await attachmentStore.put(id, value);
|
||||
for (const item of existing) {
|
||||
if (item.ownerLogin === ownerLogin && item.hasAttachment && !evidenceIds.has(item.id)) {
|
||||
await attachmentStore?.delete(item.id);
|
||||
}
|
||||
}
|
||||
write(reconciled.concat(retained));
|
||||
} catch (error) {
|
||||
for (const [id, value] of previousEvidence) {
|
||||
await Promise.resolve(value ? attachmentStore?.put(id, value) : attachmentStore?.delete(id)).catch(() => {});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return reconciled.length;
|
||||
}
|
||||
|
||||
return {list, capacity, save, replaceOldest, discard, resume, completeResume,
|
||||
exportOwned, mergeRemote, reconcileRemote, currentLogin:getCurrentLogin,
|
||||
subscribe:listener => (listeners.add(listener), () => listeners.delete(listener))};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createUnfiledCaptures;
|
||||
module.exports.summary = unfiledDraftSummary;
|
||||
module.exports.displayTitle = unfiledDraftDisplayTitle;
|
||||
module.exports.shouldFocusTitle = unfiledShouldFocusTitle;
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
(function (root, factory) {
|
||||
const createUnfiledDraftSync = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = createUnfiledDraftSync;
|
||||
if (root) root.createUnfiledDraftSync = createUnfiledDraftSync;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
function createUnfiledDraftSync(options, mountedFetch) {
|
||||
if (mountedFetch) return createUnfiledDraftSync.mount(options, mountedFetch);
|
||||
const {
|
||||
captures, fetchJson, storage, getLogin = () => '', onState = () => {}, maxConflictRetries = 2,
|
||||
createConflictId = () => 'conflict-' + (globalThis.crypto?.randomUUID?.() || Date.now().toString(36)),
|
||||
} = options;
|
||||
const key = 'stackchain.unfiled-draft-sync.v1';
|
||||
let inFlight = null;
|
||||
let rerun = false;
|
||||
let queuedOwner = '';
|
||||
|
||||
function read(login) {
|
||||
try {
|
||||
const all = JSON.parse(storage?.getItem(key) || '{}');
|
||||
const state = all?.[login];
|
||||
return {
|
||||
all:all && typeof all === 'object' ? all : {},
|
||||
known:new Set(Array.isArray(state?.known) ? state.known : []),
|
||||
deleted:new Set(Array.isArray(state?.deleted) ? state.deleted : []),
|
||||
base:state?.base && typeof state.base === 'object' ? state.base : {},
|
||||
};
|
||||
} catch (_error) {
|
||||
return {all:{}, known:new Set(), deleted:new Set(), base:{}};
|
||||
}
|
||||
}
|
||||
|
||||
function write(login, state) {
|
||||
state.all[login] = {known:[...state.known], deleted:[...state.deleted], base:state.base};
|
||||
storage?.setItem(key, JSON.stringify(state.all));
|
||||
}
|
||||
|
||||
function publish(status, message, details = {}) {
|
||||
onState({status, ...(message ? {message} : {}), ...details});
|
||||
const visibleStatus = globalThis.document?.querySelector?.('#my-work-action-status');
|
||||
if (message && visibleStatus) visibleStatus.textContent = message;
|
||||
}
|
||||
|
||||
function sameDrafts(left, right) {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
async function fingerprint(draft) {
|
||||
if (!draft) return null;
|
||||
const text = JSON.stringify(draft);
|
||||
if (globalThis.crypto?.subtle && globalThis.TextEncoder) {
|
||||
const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
||||
return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
let first = 2166136261, second = 3335557771;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
first = Math.imul(first ^ text.charCodeAt(index), 16777619);
|
||||
second = Math.imul(second ^ text.charCodeAt(index), 2246822519);
|
||||
}
|
||||
return text.length + ':' + (first >>> 0).toString(16) + (second >>> 0).toString(16);
|
||||
}
|
||||
|
||||
async function reconcile(captures, drafts, owner) {
|
||||
if (typeof captures.reconcileRemote === 'function') {
|
||||
await captures.reconcileRemote(drafts, owner);
|
||||
return;
|
||||
}
|
||||
const finalIds = new Set(drafts.map(item => item.id));
|
||||
for (const item of await captures.exportOwned(owner)) {
|
||||
if (!finalIds.has(item.id)) await captures.discard(item.id);
|
||||
}
|
||||
await captures.mergeRemote(drafts, owner);
|
||||
}
|
||||
|
||||
async function runSync(login) {
|
||||
const owner = String(login || '').trim();
|
||||
if (!owner) return false;
|
||||
publish('syncing', 'Syncing Drafts…');
|
||||
const state = read(owner);
|
||||
try {
|
||||
const remote = await fetchJson('api/v1/unfiled-drafts');
|
||||
const remoteDrafts = Array.isArray(remote?.drafts) ? remote.drafts : [];
|
||||
const local = await captures.exportOwned(owner);
|
||||
const merged = [];
|
||||
const localById = new Map(local.map(item => [item.id, item]));
|
||||
const remoteById = new Map(remoteDrafts.map(item => [item.id, item]));
|
||||
const ids = new Set([...state.known, ...localById.keys(), ...remoteById.keys()]);
|
||||
let preservedConflict = false;
|
||||
const usedIds = new Set(ids);
|
||||
for (const id of ids) {
|
||||
const localDraft = state.deleted.has(id) ? null : localById.get(id);
|
||||
const remoteDraft = remoteById.get(id);
|
||||
const localHash = await fingerprint(localDraft);
|
||||
const remoteHash = await fingerprint(remoteDraft);
|
||||
const baseHash = state.base[id] || null;
|
||||
const localChanged = localHash !== baseHash;
|
||||
const remoteChanged = remoteHash !== baseHash;
|
||||
if (localChanged && remoteChanged && localHash !== remoteHash) {
|
||||
preservedConflict = true;
|
||||
if (remoteDraft) merged.push(remoteDraft);
|
||||
if (localDraft) {
|
||||
let conflictId = String(createConflictId()).slice(0, 100);
|
||||
for (let suffix = 2; usedIds.has(conflictId); suffix += 1) {
|
||||
conflictId = (String(createConflictId()).slice(0, 95) + '-' + suffix).slice(0, 100);
|
||||
}
|
||||
usedIds.add(conflictId);
|
||||
const suffix = ' (conflict copy)';
|
||||
merged.push({...localDraft, id:conflictId,
|
||||
title:(String(localDraft.title).slice(0, 255 - suffix.length) + suffix)});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const selected = localChanged ? localDraft : (remoteChanged ? remoteDraft : (localDraft || remoteDraft));
|
||||
if (selected) merged.push(selected);
|
||||
}
|
||||
merged.sort((left, right) => Number(right.saved_at) - Number(left.saved_at));
|
||||
|
||||
let finalSnapshot = remote;
|
||||
if (!sameDrafts(merged, remoteDrafts)) {
|
||||
finalSnapshot = await fetchJson('api/v1/unfiled-drafts', {
|
||||
method:'PUT', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({revision:Number(remote.revision || 0), drafts:merged}),
|
||||
});
|
||||
}
|
||||
await reconcile(captures, finalSnapshot.drafts || [], owner);
|
||||
state.known = new Set((finalSnapshot.drafts || []).map(item => item.id));
|
||||
state.deleted.clear();
|
||||
state.base = {};
|
||||
for (const item of finalSnapshot.drafts || []) state.base[item.id] = await fingerprint(item);
|
||||
write(owner, state);
|
||||
publish(preservedConflict ? 'conflict-preserved' : 'ready', preservedConflict
|
||||
? 'Draft changed on two devices · both versions were saved.'
|
||||
: 'Drafts synced across devices.');
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error?.status !== 409) {
|
||||
publish('pending', 'Saved on this device · sync pending');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function sync(login) {
|
||||
const owner = String(login || '').trim();
|
||||
if (!owner) return Promise.resolve(false);
|
||||
if (inFlight) {
|
||||
rerun = true;
|
||||
queuedOwner = owner;
|
||||
return inFlight;
|
||||
}
|
||||
inFlight = (async () => {
|
||||
let result = false;
|
||||
let conflictRetries = 0;
|
||||
let activeOwner = owner;
|
||||
do {
|
||||
rerun = false;
|
||||
try {
|
||||
result = await runSync(activeOwner);
|
||||
} catch (error) {
|
||||
if (error?.status !== 409) throw error;
|
||||
if (conflictRetries >= maxConflictRetries) {
|
||||
publish('conflict', 'Draft sync needs attention. Retry when your devices finish saving.', {retryable:true});
|
||||
throw error;
|
||||
}
|
||||
conflictRetries += 1;
|
||||
rerun = true;
|
||||
}
|
||||
if (queuedOwner) {
|
||||
if (queuedOwner !== activeOwner) conflictRetries = 0;
|
||||
activeOwner = queuedOwner;
|
||||
queuedOwner = '';
|
||||
rerun = true;
|
||||
}
|
||||
} while (rerun);
|
||||
return result;
|
||||
})().finally(() => { inFlight = null; });
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
async function remove(id, login) {
|
||||
const owner = String(login || '').trim();
|
||||
if (!owner) return false;
|
||||
const state = read(owner);
|
||||
state.deleted.add(String(id));
|
||||
write(owner, state);
|
||||
await captures.discard(String(id));
|
||||
return sync(owner);
|
||||
}
|
||||
|
||||
function retry() {
|
||||
return sync(getLogin());
|
||||
}
|
||||
|
||||
captures.subscribe?.((id, removed) => {
|
||||
const login = getLogin();
|
||||
if (login) void (removed ? remove(id, login) : sync(login)).catch(() => {});
|
||||
});
|
||||
|
||||
return {sync, remove, retry};
|
||||
}
|
||||
createUnfiledDraftSync.mount = (captures, fetchJson) => {
|
||||
const getLogin = captures.currentLogin;
|
||||
const retryButton = globalThis.document?.querySelector?.('#retry-unfiled-draft-sync');
|
||||
const controller = createUnfiledDraftSync({
|
||||
captures, fetchJson, storage:globalThis.localStorage, getLogin,
|
||||
onState:state => { if (retryButton) retryButton.hidden = !state.retryable; },
|
||||
});
|
||||
retryButton?.addEventListener?.('click', async () => {
|
||||
retryButton.disabled = true;
|
||||
try { await controller.retry(); } catch (_error) { /* state remains actionable */ }
|
||||
finally { retryButton.disabled = false; }
|
||||
});
|
||||
const connect = () => {
|
||||
const login = getLogin();
|
||||
if (login) void controller.sync(login).catch(() => {});
|
||||
else globalThis.setTimeout(connect, 250);
|
||||
};
|
||||
globalThis.setTimeout(connect, 0);
|
||||
globalThis.addEventListener?.('online', connect);
|
||||
return controller;
|
||||
};
|
||||
return createUnfiledDraftSync;
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
function createVoiceConversationCapture({
|
||||
Recognition, elements, transcriptStore = null, getLogin = () => '',
|
||||
createEvent = name => new Event(name, {bubbles:true}),
|
||||
maxLength = 0, draftLabel = '',
|
||||
}) {
|
||||
const supported = typeof Recognition === 'function';
|
||||
const label = String(draftLabel || elements.root.dataset?.draftLabel || 'reply').trim() || 'reply';
|
||||
let recognition = null;
|
||||
let target = '';
|
||||
let generation = 0;
|
||||
elements.root.hidden = !supported;
|
||||
if (!supported) {
|
||||
elements.status.textContent = 'Voice capture is unavailable; type the ' + label + ' instead.';
|
||||
}
|
||||
|
||||
const normalize = value => String(value || '').replace(/\s+/g, ' ').trim();
|
||||
const login = () => String(getLogin() || '').trim();
|
||||
const composerLimit = Number(elements.draft.maxLength);
|
||||
const limit = Number.isInteger(maxLength) && maxLength > 0 ? maxLength :
|
||||
Number.isInteger(composerLimit) && composerLimit > 0 ? composerLimit : 10000;
|
||||
|
||||
function showReview(value, recovered = false) {
|
||||
const transcript = normalize(value).slice(0, limit);
|
||||
elements.transcript.value = transcript;
|
||||
elements.review.hidden = false;
|
||||
elements.append.textContent = elements.draft.value.trim() ? 'Append to draft' : 'Use transcript';
|
||||
elements.replace.hidden = !elements.draft.value.trim();
|
||||
elements.status.textContent = recovered ?
|
||||
'Recovered transcript. Review it before using or discarding it.' :
|
||||
'Transcript ready. Review it before using it.';
|
||||
if (!recovered && transcriptStore && login() && target) {
|
||||
void transcriptStore.save(login(), transcript, target);
|
||||
}
|
||||
}
|
||||
|
||||
async function open(nextTarget) {
|
||||
cancel();
|
||||
target = String(nextTarget || '').trim();
|
||||
const opening = ++generation;
|
||||
elements.transcript.value = '';
|
||||
elements.review.hidden = true;
|
||||
elements.status.textContent = '';
|
||||
if (!supported || !target || !transcriptStore || !login()) return;
|
||||
const recovered = normalize(await transcriptStore.load(login(), target)).slice(0, limit);
|
||||
if (opening === generation && recovered) showReview(recovered, true);
|
||||
}
|
||||
|
||||
function clearCheckpoint() {
|
||||
if (transcriptStore && login() && target) void transcriptStore.clear(login(), target);
|
||||
}
|
||||
|
||||
function commit(replace) {
|
||||
const transcript = normalize(elements.transcript.value).slice(0, limit);
|
||||
elements.draft.value = replace ? transcript :
|
||||
[elements.draft.value.trim(), transcript].filter(Boolean).join('\n\n').slice(0, limit);
|
||||
elements.draft.dispatchEvent(createEvent('input'));
|
||||
clearCheckpoint();
|
||||
elements.review.hidden = true;
|
||||
elements.status.textContent = 'Transcript added. Review the ' + label + ' before sending.';
|
||||
}
|
||||
|
||||
elements.start.addEventListener('click', () => {
|
||||
if (!supported || !target) return;
|
||||
const sessionGeneration = generation;
|
||||
const currentRecognition = new Recognition();
|
||||
recognition = currentRecognition;
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = false;
|
||||
recognition.onresult = event => {
|
||||
if (recognition !== currentRecognition || generation !== sessionGeneration) return;
|
||||
const finalText = Array.from(event.results || []).slice(event.resultIndex ?? 0)
|
||||
.filter(result => result.isFinal)
|
||||
.map(result => result[0]?.transcript || '').join(' ');
|
||||
if (normalize(finalText)) {
|
||||
showReview([elements.transcript.value, finalText].filter(Boolean).join(' '));
|
||||
}
|
||||
};
|
||||
recognition.onerror = event => {
|
||||
if (recognition !== currentRecognition || generation !== sessionGeneration) return;
|
||||
elements.start.hidden = false;
|
||||
elements.stop.hidden = true;
|
||||
elements.status.textContent = ['not-allowed', 'service-not-allowed'].includes(event.error) ?
|
||||
'Microphone permission was denied. Your draft and transcript are unchanged.' :
|
||||
'Voice capture stopped. Your draft and transcript are unchanged.';
|
||||
};
|
||||
recognition.onend = () => {
|
||||
if (recognition !== currentRecognition || generation !== sessionGeneration) return;
|
||||
recognition = null;
|
||||
elements.start.hidden = false;
|
||||
elements.stop.hidden = true;
|
||||
};
|
||||
try {
|
||||
recognition.start();
|
||||
} catch (_) {
|
||||
recognition = null;
|
||||
elements.start.hidden = false;
|
||||
elements.stop.hidden = true;
|
||||
elements.status.textContent = 'Voice capture could not start. Your draft and transcript are unchanged.';
|
||||
return;
|
||||
}
|
||||
elements.start.hidden = true;
|
||||
elements.stop.hidden = false;
|
||||
elements.status.textContent = 'Listening… Tap stop when you are finished.';
|
||||
});
|
||||
elements.stop.addEventListener('click', () => {
|
||||
recognition?.stop();
|
||||
elements.status.textContent = 'Finishing transcript…';
|
||||
});
|
||||
elements.append.addEventListener('click', () => commit(false));
|
||||
elements.replace.addEventListener('click', () => commit(true));
|
||||
elements.discard.addEventListener('click', () => {
|
||||
clearCheckpoint();
|
||||
elements.transcript.value = '';
|
||||
elements.review.hidden = true;
|
||||
elements.status.textContent = 'Transcript discarded.';
|
||||
});
|
||||
|
||||
function cancel() {
|
||||
generation += 1;
|
||||
recognition?.abort();
|
||||
recognition = null;
|
||||
elements.start.hidden = false;
|
||||
elements.stop.hidden = true;
|
||||
}
|
||||
|
||||
return {supported, open, cancel};
|
||||
}
|
||||
|
||||
function mountVoiceConversation({
|
||||
kind, draftSelector, qs, Recognition, transcriptStore, getLogin,
|
||||
createCapture = createVoiceConversationCapture,
|
||||
}) {
|
||||
return createCapture({
|
||||
Recognition, transcriptStore, getLogin,
|
||||
elements:{
|
||||
root:qs('#voice-' + kind), start:qs('#start-voice-' + kind),
|
||||
stop:qs('#stop-voice-' + kind), review:qs('#voice-' + kind + '-review'),
|
||||
transcript:qs('#voice-' + kind + '-transcript'), append:qs('#append-voice-' + kind),
|
||||
replace:qs('#replace-with-voice-' + kind), discard:qs('#discard-voice-' + kind),
|
||||
status:qs('#voice-' + kind + '-status'), draft:qs(draftSelector),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = {createVoiceConversationCapture, mountVoiceConversation};
|
||||
@@ -0,0 +1,120 @@
|
||||
function mapVoiceTranscript(value) {
|
||||
const transcript = String(value || '').replace(/\s+/g, ' ').trim();
|
||||
if (!transcript) return {title:'', body:''};
|
||||
const sentence = transcript.match(/^(.+?[.!?])(?:\s+|$)(.*)$/);
|
||||
const title = (sentence ? sentence[1] : transcript).trim().slice(0, 255);
|
||||
const body = (sentence ? sentence[2] : '').trim().slice(0, 10000);
|
||||
return {title, body};
|
||||
}
|
||||
|
||||
function createVoiceIssueCapture({
|
||||
Recognition, elements, createEvent = name => new Event(name, {bubbles:true}),
|
||||
transcriptStore = null, getLogin = () => '',
|
||||
}) {
|
||||
const supported = typeof Recognition === 'function';
|
||||
const login = String(getLogin() || '').trim();
|
||||
const ready = supported && login && transcriptStore ? Promise.resolve(transcriptStore.load(login)).then(value => {
|
||||
const recovered = String(value || '').replace(/\s+/g, ' ').trim().slice(0, 10255);
|
||||
if (recovered) showReview(recovered, {persist:false, recovered:true});
|
||||
}) : Promise.resolve();
|
||||
elements.root.hidden = !supported;
|
||||
if (!supported) {
|
||||
elements.status.textContent = 'Voice capture is unavailable; type the title and note instead.';
|
||||
return {supported:false, ready, cancel() {}};
|
||||
}
|
||||
let recognition = null;
|
||||
|
||||
function showReview(value, {persist = true, recovered = false} = {}) {
|
||||
elements.transcript.value = value;
|
||||
const activeLogin = String(getLogin() || '').trim();
|
||||
if (persist && activeLogin && transcriptStore) void transcriptStore.save(activeLogin, value);
|
||||
const hasDraft = Boolean(elements.title.value.trim() || elements.body.value.trim());
|
||||
elements.append.textContent = hasDraft ? 'Append to draft' : 'Use transcript';
|
||||
elements.replace.hidden = !hasDraft;
|
||||
elements.review.hidden = false;
|
||||
elements.status.textContent = recovered ?
|
||||
'Recovered transcript. Review it before using or discarding it.' :
|
||||
'Transcript ready. Review it before using it.';
|
||||
}
|
||||
|
||||
elements.start.addEventListener('click', () => {
|
||||
recognition = new Recognition();
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = false;
|
||||
recognition.onresult = event => {
|
||||
const finalText = Array.from(event.results || [])
|
||||
.slice(event.resultIndex ?? 0)
|
||||
.filter(result => result.isFinal)
|
||||
.map(result => result[0]?.transcript || '').join(' ').replace(/\s+/g, ' ').trim();
|
||||
if (finalText) {
|
||||
const accumulated = [elements.transcript.value.trim(), finalText]
|
||||
.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim().slice(0, 10255);
|
||||
showReview(accumulated);
|
||||
}
|
||||
};
|
||||
recognition.onerror = event => {
|
||||
elements.start.hidden = false;
|
||||
elements.stop.hidden = true;
|
||||
elements.status.textContent = event.error === 'not-allowed' || event.error === 'service-not-allowed' ?
|
||||
'Microphone permission was denied. Your draft and transcript are unchanged.' :
|
||||
'Voice capture stopped. Your draft and transcript are unchanged.';
|
||||
};
|
||||
recognition.onend = () => {
|
||||
elements.start.hidden = false;
|
||||
elements.stop.hidden = true;
|
||||
};
|
||||
try {
|
||||
recognition.start();
|
||||
} catch (_) {
|
||||
recognition = null;
|
||||
elements.start.hidden = false;
|
||||
elements.stop.hidden = true;
|
||||
elements.status.textContent = 'Voice capture could not start. Your draft and transcript are unchanged.';
|
||||
return;
|
||||
}
|
||||
elements.start.hidden = true;
|
||||
elements.stop.hidden = false;
|
||||
elements.status.textContent = 'Listening… Tap stop when you are finished.';
|
||||
});
|
||||
elements.stop.addEventListener('click', () => {
|
||||
recognition?.stop();
|
||||
elements.status.textContent = 'Finishing transcript…';
|
||||
});
|
||||
|
||||
function clearCheckpoint() {
|
||||
const activeLogin = String(getLogin() || '').trim();
|
||||
if (activeLogin && transcriptStore) void transcriptStore.clear(activeLogin);
|
||||
}
|
||||
|
||||
function commit(mode) {
|
||||
const mapped = mapVoiceTranscript(elements.transcript.value);
|
||||
if (mode === 'replace') {
|
||||
elements.title.value = mapped.title;
|
||||
elements.body.value = mapped.body;
|
||||
} else if (elements.title.value.trim()) {
|
||||
elements.body.value = [elements.body.value.trim(), mapped.title, mapped.body]
|
||||
.filter(Boolean).join('\n\n').slice(0, 10000);
|
||||
} else {
|
||||
elements.title.value = mapped.title;
|
||||
elements.body.value = [elements.body.value.trim(), mapped.body]
|
||||
.filter(Boolean).join('\n\n').slice(0, 10000);
|
||||
}
|
||||
elements.title.dispatchEvent(createEvent('input'));
|
||||
elements.body.dispatchEvent(createEvent('input'));
|
||||
clearCheckpoint();
|
||||
elements.review.hidden = true;
|
||||
elements.status.textContent = 'Transcript added. Review the title and note before saving.';
|
||||
}
|
||||
elements.append.addEventListener('click', () => commit('append'));
|
||||
elements.replace.addEventListener('click', () => commit('replace'));
|
||||
elements.discard?.addEventListener('click', () => {
|
||||
clearCheckpoint();
|
||||
elements.transcript.value = '';
|
||||
elements.review.hidden = true;
|
||||
elements.status.textContent = 'Recovered transcript discarded.';
|
||||
});
|
||||
|
||||
return {supported:true, ready, cancel() { recognition?.abort(); recognition = null; }};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = {mapVoiceTranscript, createVoiceIssueCapture};
|
||||
@@ -0,0 +1,93 @@
|
||||
(function (root, factory) {
|
||||
const createVoiceTranscriptStore = factory(root.indexedDB);
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createVoiceTranscriptStore;
|
||||
else root.createVoiceTranscriptStore = createVoiceTranscriptStore;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function buildFactory(defaultIndexedDB) {
|
||||
const dbName = 'stackchain-voice-transcripts-v1';
|
||||
const STORE_NAME = 'checkpoints';
|
||||
const MAX_TRANSCRIPT_LENGTH = 10255;
|
||||
|
||||
function requestResult(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('Private transcript storage failed.'));
|
||||
});
|
||||
}
|
||||
|
||||
function createIndexedDbTransaction(indexedDB) {
|
||||
if (!indexedDB) return null;
|
||||
let databasePromise = null;
|
||||
function openDatabase() {
|
||||
if (databasePromise) return databasePromise;
|
||||
databasePromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, 1);
|
||||
request.onupgradeneeded = () => {
|
||||
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
|
||||
request.result.createObjectStore(STORE_NAME, {keyPath:'login'});
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('Private transcript storage is unavailable.'));
|
||||
});
|
||||
return databasePromise;
|
||||
}
|
||||
return async (work, mode = 'readonly') => {
|
||||
const database = await openDatabase();
|
||||
const transaction = database.transaction(STORE_NAME, mode);
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const completed = new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error || new Error('Private transcript storage failed.'));
|
||||
transaction.onabort = () => reject(transaction.error || new Error('Private transcript storage was interrupted.'));
|
||||
});
|
||||
const result = await work({
|
||||
get:key => requestResult(store.get(key)),
|
||||
put:value => requestResult(store.put(value)),
|
||||
delete:key => requestResult(store.delete(key)),
|
||||
});
|
||||
await completed;
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLogin(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeTranscript(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, MAX_TRANSCRIPT_LENGTH);
|
||||
}
|
||||
|
||||
function storageKey(login, target = '') {
|
||||
const account = normalizeLogin(login);
|
||||
const scope = String(target || '').trim().toLowerCase();
|
||||
return account && scope ? account + '\u0000' + scope : account;
|
||||
}
|
||||
|
||||
function createVoiceTranscriptStore({transaction = createIndexedDbTransaction(defaultIndexedDB)} = {}) {
|
||||
async function load(login, target = '') {
|
||||
const key = storageKey(login, target);
|
||||
if (!key || !transaction) return '';
|
||||
const record = await transaction(store => store.get(key));
|
||||
return normalizeTranscript(record?.transcript);
|
||||
}
|
||||
|
||||
async function save(login, transcript, target = '') {
|
||||
const key = storageKey(login, target);
|
||||
const value = normalizeTranscript(transcript);
|
||||
if (!key || !value || !transaction) return;
|
||||
await transaction(store => store.put({login:key, transcript:value}), 'readwrite');
|
||||
}
|
||||
|
||||
async function clear(login, target = '') {
|
||||
const key = storageKey(login, target);
|
||||
if (!key || !transaction) return;
|
||||
await transaction(store => store.delete(key), 'readwrite');
|
||||
}
|
||||
|
||||
return {load, save, clear};
|
||||
}
|
||||
|
||||
createVoiceTranscriptStore.databaseName = dbName;
|
||||
return createVoiceTranscriptStore;
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
function createWeekCalendarImport() {
|
||||
const maxBytes=1024*1024;
|
||||
const zonedInstantFormatters=new Map(),zonePartFormatters=new Map(),supportedTimeZones=new Map();
|
||||
function clock(value) {
|
||||
const match=/^(\d{2}):(\d{2})$/.exec(String(value||''));
|
||||
if(!match)return null;
|
||||
const minutes=Number(match[1])*60+Number(match[2]);
|
||||
return Number(match[1])<24&&Number(match[2])<60?minutes:null;
|
||||
}
|
||||
function instant(value) {
|
||||
const match=/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/.exec(value||'');
|
||||
if(!match)return null;
|
||||
const parts=match.slice(1,7).map(Number);
|
||||
const milliseconds=match[7]?Date.UTC(parts[0],parts[1]-1,parts[2],parts[3],parts[4],parts[5]):
|
||||
new Date(parts[0],parts[1]-1,parts[2],parts[3],parts[4],parts[5]).getTime();
|
||||
return Number.isFinite(milliseconds)?milliseconds:null;
|
||||
}
|
||||
function zonedInstant(value,timeZone) {
|
||||
if(!timeZone)return instant(value);
|
||||
const match=/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})$/.exec(value||'');
|
||||
if(!match)return null;
|
||||
try {
|
||||
const wanted=match.slice(1).map(Number);
|
||||
if(!zonedInstantFormatters.has(timeZone))zonedInstantFormatters.set(timeZone,new Intl.DateTimeFormat('en-CA',{
|
||||
timeZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',hourCycle:'h23',
|
||||
}));
|
||||
const formatter=zonedInstantFormatters.get(timeZone);
|
||||
let result=Date.UTC(wanted[0],wanted[1]-1,wanted[2],wanted[3],wanted[4],wanted[5]);
|
||||
for(let attempt=0;attempt<2;attempt+=1) {
|
||||
const shown=Object.fromEntries(formatter.formatToParts(new Date(result)).map(part=>[part.type,part.value]));
|
||||
const represented=Date.UTC(Number(shown.year),Number(shown.month)-1,Number(shown.day),Number(shown.hour),Number(shown.minute),Number(shown.second));
|
||||
result+=Date.UTC(wanted[0],wanted[1]-1,wanted[2],wanted[3],wanted[4],wanted[5])-represented;
|
||||
}
|
||||
return result;
|
||||
} catch(error) { return null; }
|
||||
}
|
||||
function supportedTimeZone(timeZone) {
|
||||
if(!timeZone)return true;
|
||||
if(supportedTimeZones.has(timeZone))return supportedTimeZones.get(timeZone);
|
||||
try {new Intl.DateTimeFormat('en-US',{timeZone}).format(0);supportedTimeZones.set(timeZone,true);return true;}
|
||||
catch(error) {supportedTimeZones.set(timeZone,false);return false;}
|
||||
}
|
||||
function zoneParts(value,timeZone) {
|
||||
const date=new Date(value);
|
||||
if(!timeZone)return {year:date.getFullYear(),month:date.getMonth()+1,day:date.getDate(),weekday:date.getDay(),
|
||||
hour:date.getHours(),minute:date.getMinutes(),second:date.getSeconds()};
|
||||
if(!zonePartFormatters.has(timeZone))zonePartFormatters.set(timeZone,new Intl.DateTimeFormat('en-US',{
|
||||
timeZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',weekday:'short',hourCycle:'h23',
|
||||
}));
|
||||
const formatter=zonePartFormatters.get(timeZone);
|
||||
const parts=Object.fromEntries(formatter.formatToParts(date).map(part=>[part.type,part.value]));
|
||||
return {year:Number(parts.year),month:Number(parts.month),day:Number(parts.day),
|
||||
weekday:['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].indexOf(parts.weekday),hour:Number(parts.hour),
|
||||
minute:Number(parts.minute),second:Number(parts.second)};
|
||||
}
|
||||
function addCalendarDays(value,days,timeZone) {
|
||||
if(!timeZone) {const date=new Date(value);date.setDate(date.getDate()+days);return date.getTime();}
|
||||
const parts=zoneParts(value,timeZone),day=new Date(Date.UTC(parts.year,parts.month-1,parts.day+days));
|
||||
const wall=[day.getUTCFullYear(),String(day.getUTCMonth()+1).padStart(2,'0'),String(day.getUTCDate()).padStart(2,'0')].join('')+
|
||||
'T'+[parts.hour,parts.minute,parts.second].map(part=>String(part).padStart(2,'0')).join('');
|
||||
return zonedInstant(wall,timeZone);
|
||||
}
|
||||
function dateInstant(value) {
|
||||
const match=/^(\d{4})(\d{2})(\d{2})$/.exec(value||'');
|
||||
return match?new Date(Number(match[1]),Number(match[2])-1,Number(match[3])).getTime():null;
|
||||
}
|
||||
function events(source) {
|
||||
if(typeof source!=='string'||!source.includes('BEGIN:VCALENDAR'))throw new Error('Choose a valid .ics calendar file.');
|
||||
if(new TextEncoder().encode(source).length>maxBytes)throw new Error('Calendar files must be 1 MB or smaller.');
|
||||
const lines=source.replace(/\r\n[ \t]/g,'').split(/\r?\n/);
|
||||
const result=[];let current=null;
|
||||
lines.forEach(line=>{
|
||||
if(line==='BEGIN:VEVENT'){current={};return;}
|
||||
if(line==='END:VEVENT'){
|
||||
if((current?.unsupportedTimezone||(current?.start!=null&¤t?.end>current.start))&&
|
||||
current.status!=='CANCELLED'&¤t.transparency!=='TRANSPARENT')result.push(current);
|
||||
current=null;return;
|
||||
}
|
||||
if(!current)return;
|
||||
const separator=line.indexOf(':');if(separator<0)return;
|
||||
const property=line.slice(0,separator),parts=property.split(';'),name=parts[0],value=line.slice(separator+1);
|
||||
const parameters=Object.fromEntries(parts.slice(1).map(part=>part.split('=')));
|
||||
if(parameters.TZID&&!supportedTimeZone(parameters.TZID))current.unsupportedTimezone=true;
|
||||
const allDay=parameters.VALUE==='DATE',parsed=allDay?dateInstant(value):zonedInstant(value,parameters.TZID);
|
||||
if(name==='DTSTART'){current.start=parsed;current.timeZone=parameters.TZID||null;}
|
||||
if(name==='DTEND')current.end=parsed;
|
||||
if(name==='RRULE')current.rule=value;
|
||||
if(name==='RDATE')current.rdates=(current.rdates||[]).concat(value.split(',').map(item=>allDay?dateInstant(item):zonedInstant(item,parameters.TZID)).filter(value=>value!=null));
|
||||
if(name==='EXDATE')current.exdates=(current.exdates||[]).concat(value.split(',').map(item=>allDay?dateInstant(item):zonedInstant(item,parameters.TZID)).filter(value=>value!=null));
|
||||
if(name==='STATUS')current.status=value.toUpperCase();
|
||||
if(name==='TRANSP')current.transparency=value.toUpperCase();
|
||||
});
|
||||
return result;
|
||||
}
|
||||
function recurrenceRule(value) {
|
||||
return Object.fromEntries(String(value||'').split(';').filter(Boolean).map(part=>part.split('=')));
|
||||
}
|
||||
function supportedRecurrence(value) {
|
||||
const rule=recurrenceRule(value),keys=Object.keys(rule);
|
||||
if(!['DAILY','WEEKLY'].includes(rule.FREQ))return false;
|
||||
const allowed=new Set(['FREQ','COUNT','INTERVAL','UNTIL',...(rule.FREQ==='WEEKLY'?['BYDAY']:[])]);
|
||||
if(keys.some(key=>!allowed.has(key)))return false;
|
||||
if(rule.COUNT&&(!/^\d+$/.test(rule.COUNT)||Number(rule.COUNT)<1))return false;
|
||||
if(rule.INTERVAL&&(!/^\d+$/.test(rule.INTERVAL)||Number(rule.INTERVAL)<1))return false;
|
||||
if(rule.COUNT&&rule.UNTIL)return false;
|
||||
if(rule.UNTIL&&instant(rule.UNTIL)==null&&dateInstant(rule.UNTIL)==null)return false;
|
||||
return !rule.BYDAY||rule.BYDAY.split(',').every(day=>/^(MO|TU|WE|TH|FR|SA|SU)$/.test(day));
|
||||
}
|
||||
function occurrences(event,rangeStart,rangeEnd) {
|
||||
const duration=event.end-event.start,excluded=new Set(event.exdates||[]);
|
||||
if(!event.rule) {
|
||||
return [event.start,...(event.rdates||[])].filter((start,index,all)=>start<rangeEnd&&!excluded.has(start)&&all.indexOf(start)===index)
|
||||
.map(start=>({start,end:start+duration}));
|
||||
}
|
||||
const rule=recurrenceRule(event.rule);
|
||||
if(!['DAILY','WEEKLY'].includes(rule.FREQ))return [event];
|
||||
const count=Math.max(1,Math.min(Number(rule.COUNT)||10000,10000));
|
||||
const result=[];
|
||||
const weekdays={SU:0,MO:1,TU:2,WE:3,TH:4,FR:5,SA:6};
|
||||
const selected=new Set((rule.BYDAY||'').split(',').map(day=>weekdays[day]).filter(day=>day!=null));
|
||||
if(rule.FREQ==='WEEKLY'&&!selected.size)selected.add(zoneParts(event.start,event.timeZone).weekday);
|
||||
const interval=Math.max(1,Number(rule.INTERVAL)||1),origin=zoneParts(event.start,event.timeZone);
|
||||
const until=rule.UNTIL?(rule.UNTIL.endsWith('Z')?instant(rule.UNTIL):
|
||||
(zonedInstant(rule.UNTIL,event.timeZone)??dateInstant(rule.UNTIL))):null;
|
||||
const originDay=Date.UTC(origin.year,origin.month-1,origin.day);
|
||||
let start=event.start,matched=0;
|
||||
if(rule.FREQ==='DAILY'&&start+duration<=rangeStart) {
|
||||
const target=zoneParts(rangeStart-duration,event.timeZone);
|
||||
const targetDay=Date.UTC(target.year,target.month-1,target.day);
|
||||
const elapsed=Math.max(0,Math.floor((targetDay-originDay)/(24*60*60*1000)));
|
||||
const jumps=Math.floor(elapsed/interval);
|
||||
if(jumps) {start=addCalendarDays(start,jumps*interval,event.timeZone);matched=jumps;}
|
||||
while(start+duration<=rangeStart&&matched<count) {start=addCalendarDays(start,interval,event.timeZone);matched+=1;}
|
||||
}
|
||||
if(rule.FREQ==='WEEKLY'&&start+duration<=rangeStart) {
|
||||
const target=zoneParts(rangeStart-duration,event.timeZone);
|
||||
const targetDay=Date.UTC(target.year,target.month-1,target.day);
|
||||
const elapsed=Math.max(0,Math.floor((targetDay-originDay)/(24*60*60*1000)));
|
||||
const cycleDays=7*interval,cycles=Math.floor(elapsed/cycleDays),offset=elapsed-cycles*cycleDays;
|
||||
matched=cycles*selected.size;
|
||||
start=addCalendarDays(event.start,cycles*cycleDays+(offset>=7?offset:0),event.timeZone);
|
||||
while(start+duration<=rangeStart&&matched<count) {
|
||||
const cursor=zoneParts(start,event.timeZone),cursorDay=Date.UTC(cursor.year,cursor.month-1,cursor.day);
|
||||
const weeks=Math.floor((cursorDay-originDay)/(7*24*60*60*1000));
|
||||
if(weeks%interval===0&&selected.has(cursor.weekday))matched+=1;
|
||||
start=addCalendarDays(start,1,event.timeZone);
|
||||
}
|
||||
}
|
||||
while(start<rangeEnd&&matched<count&&(until==null||start<=until)) {
|
||||
const cursor=zoneParts(start,event.timeZone),cursorDay=Date.UTC(cursor.year,cursor.month-1,cursor.day);
|
||||
const weeks=Math.floor((cursorDay-originDay)/(7*24*60*60*1000));
|
||||
const matches=rule.FREQ==='DAILY'||(weeks%interval===0&&selected.has(cursor.weekday));
|
||||
if(matches) {
|
||||
matched+=1;
|
||||
if(!excluded.has(start))result.push({start,end:start+duration});
|
||||
}
|
||||
start=addCalendarDays(start,rule.FREQ==='DAILY'?interval:1,event.timeZone);
|
||||
}
|
||||
(event.rdates||[]).forEach(extra=>{
|
||||
if(extra<rangeEnd&&!excluded.has(extra)&&!result.some(item=>item.start===extra))result.push({start:extra,end:extra+duration});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
function dayBoundary(date,minutes) {
|
||||
const [year,month,day]=date.split('-').map(Number);
|
||||
return new Date(year,month-1,day,Math.floor(minutes/60),minutes%60).getTime();
|
||||
}
|
||||
function localClock(value) {
|
||||
const date=new Date(value);
|
||||
return String(date.getHours()).padStart(2,'0')+':'+String(date.getMinutes()).padStart(2,'0');
|
||||
}
|
||||
function review(source,{dates,workdayStart='09:00',workdayEnd='17:00'}={}) {
|
||||
if(!Array.isArray(dates)||dates.length!==7)throw new Error('Week Ahead must contain seven dates.');
|
||||
const startMinute=clock(workdayStart),endMinute=clock(workdayEnd);
|
||||
if(startMinute==null||endMinute==null||endMinute<=startMinute)throw new Error('Working hours must end after they start.');
|
||||
const parsedEvents=events(source),rangeStart=dayBoundary(dates[0],0),rangeEnd=dayBoundary(dates[6],24*60);
|
||||
const unsupportedRecurrence=parsedEvents.filter(event=>event.rule&&!supportedRecurrence(event.rule)&&
|
||||
event.start<rangeEnd&&(event.end>rangeStart||event.rule)).length;
|
||||
const unsupportedTimezone=parsedEvents.filter(event=>event.unsupportedTimezone).length;
|
||||
const unsupported_count=unsupportedRecurrence+unsupportedTimezone;
|
||||
const calendarEvents=parsedEvents.filter(event=>!event.unsupportedTimezone).flatMap(event=>occurrences(event,rangeStart,rangeEnd));
|
||||
const days=dates.map(plan_date=>{
|
||||
const start=dayBoundary(plan_date,startMinute),end=dayBoundary(plan_date,endMinute);
|
||||
const ranges=calendarEvents.map(event=>[Math.max(start,event.start),Math.min(end,event.end)])
|
||||
.filter(range=>range[1]>range[0]).sort((left,right)=>left[0]-right[0]);
|
||||
const merged=[];
|
||||
ranges.forEach(range=>{
|
||||
const previous=merged[merged.length-1];
|
||||
if(previous&&range[0]<=previous[1])previous[1]=Math.max(previous[1],range[1]);
|
||||
else merged.push([...range]);
|
||||
});
|
||||
const busy_minutes=Math.round(merged.reduce((total,range)=>total+range[1]-range[0],0)/60000);
|
||||
const free_windows=[];let cursor=start;
|
||||
merged.forEach(range=>{
|
||||
if(range[0]>cursor)free_windows.push({start_time:localClock(cursor),end_time:localClock(range[0])});
|
||||
cursor=Math.max(cursor,range[1]);
|
||||
});
|
||||
if(cursor<end)free_windows.push({start_time:localClock(cursor),end_time:localClock(end)});
|
||||
return {plan_date,busy_minutes,capacity_minutes:endMinute-startMinute-busy_minutes,free_windows};
|
||||
});
|
||||
days.unsupported_count=unsupported_count;
|
||||
days.unsupported_timezone_count=unsupportedTimezone;
|
||||
return days;
|
||||
}
|
||||
function createWorkflow({controller,qs,onApplied=()=>{}}={}) {
|
||||
let reviewed=null,activeAvailability=null,needsReflow=false,reflowPreview=null;
|
||||
const root=()=>qs('#week-capacity-import');
|
||||
function clear() {
|
||||
reviewed=null;needsReflow=false;reflowPreview=null;
|
||||
qs('#week-capacity-file').value='';
|
||||
qs('#week-capacity-review').hidden=true;
|
||||
qs('#week-capacity-days').innerHTML='';
|
||||
qs('#apply-week-capacities').disabled=false;
|
||||
qs('#apply-week-capacities').textContent='Apply seven capacities';
|
||||
}
|
||||
function open() {
|
||||
clear();root().hidden=false;qs('#week-capacity-status').textContent='';
|
||||
qs('#week-capacity-file').focus?.();return true;
|
||||
}
|
||||
function cancel() {clear();root().hidden=true;return true;}
|
||||
function reviewSource(source) {
|
||||
const display=controller.dates();
|
||||
reviewed=review(source,{dates:display.map(item=>item.date),workdayStart:qs('#week-capacity-start').value,
|
||||
workdayEnd:qs('#week-capacity-end').value});
|
||||
const current=controller.review?.().days||[];
|
||||
needsReflow=reviewed.some((day,index)=>Number(current[index]?.planned_minutes)>day.capacity_minutes);
|
||||
const previewValues=reviewed.map(day=>({plan_date:day.plan_date,capacity_minutes:day.capacity_minutes,
|
||||
free_windows:day.free_windows.map(window=>({...window}))}));
|
||||
reflowPreview=needsReflow?controller.previewCapacityReflow?.(previewValues):null;
|
||||
const destinations=new Map();
|
||||
(reflowPreview?.days||[]).forEach(day=>(day.ids||[]).forEach(id=>destinations.set(id,day.plan_date)));
|
||||
qs('#week-capacity-days').innerHTML=reviewed.map((day,index)=>{
|
||||
const previous=current[index]||{},planned=Number(previous.planned_minutes)||0,over=Math.max(0,planned-day.capacity_minutes);
|
||||
const moved=[...new Set((previous.ids||[]).map(id=>destinations.get(id)).filter(date=>date&&date!==day.plan_date))]
|
||||
.map(date=>display.find(item=>item.date===date)?.label||date);
|
||||
return '<article class="week-capacity-day'+(over?' is-overloaded':'')+'"><strong>'+display[index].label+'</strong><span>'+
|
||||
(Number.isInteger(previous.capacity_minutes)?previous.capacity_minutes+' → ':'')+day.capacity_minutes+' min available</span><small>'+
|
||||
planned+' min planned · '+day.busy_minutes+' min busy</small>'+(over?'<small class="week-capacity-over">'+over+
|
||||
' min over refreshed capacity</small>':'')+(moved.length?'<small>Moves to '+moved.join(', ')+'</small>':'')+'</article>';
|
||||
}).join('');
|
||||
qs('#week-capacity-review').hidden=false;
|
||||
const unsupported=reviewed.unsupported_count||0;
|
||||
const reflowBlocked=Boolean(reflowPreview?.blockers?.length);
|
||||
qs('#apply-week-capacities').disabled=unsupported>0||reflowBlocked;
|
||||
qs('#apply-week-capacities').textContent=needsReflow?'Apply capacities & reflow':'Apply seven capacities';
|
||||
const kind=reviewed.unsupported_timezone_count?'calendar event':'recurring event';
|
||||
qs('#week-capacity-status').textContent=unsupported?
|
||||
unsupported+' '+kind+(unsupported===1?'':'s')+' could not be counted. Apply is unavailable; export a simpler seven-day calendar and try again.':
|
||||
(reflowBlocked?'Reflow needs an estimate for every planned item before refreshed capacity can be applied.':
|
||||
(reflowPreview?.unscheduled?.length?reflowPreview.unscheduled.length+' planned item'+
|
||||
(reflowPreview.unscheduled.length===1?'':'s')+' will return to My Work. Review before applying.':
|
||||
'Review seven capacity totals. Calendar details stay on this device.'));
|
||||
return reviewed.map(day=>({...day}));
|
||||
}
|
||||
async function apply() {
|
||||
if(!reviewed||reviewed.unsupported_count)return false;
|
||||
const keep=Boolean(qs('#keep-week-free-times')?.checked);
|
||||
const values=reviewed.map(day=>({
|
||||
plan_date:day.plan_date,capacity_minutes:day.capacity_minutes,
|
||||
...(keep?{free_windows:day.free_windows.map(window=>({...window}))}:{}),
|
||||
}));
|
||||
const staged=needsReflow?controller.applyCapacityReflow?.(values):controller.stageCapacities(values);
|
||||
if(!staged)return false;
|
||||
qs('#apply-week-capacities').disabled=true;
|
||||
try {await controller.flush();activeAvailability=reviewed.map(day=>({plan_date:day.plan_date,
|
||||
free_windows:day.free_windows.map(window=>({...window}))}));cancel();onApplied();return true;}
|
||||
finally {qs('#apply-week-capacities').disabled=false;}
|
||||
}
|
||||
qs('#week-capacity-file')?.addEventListener('change',async event=>{
|
||||
const file=event.currentTarget.files?.[0];if(!file)return;
|
||||
try {
|
||||
if(file.size>maxBytes)throw new Error('Calendar files must be 1 MB or smaller.');
|
||||
reviewSource(await file.text());
|
||||
} catch(error) {clear();qs('#week-capacity-status').textContent=error.message||'Calendar could not be read.';}
|
||||
});
|
||||
qs('#cancel-week-capacity-import')?.addEventListener('click',cancel);
|
||||
qs('#apply-week-capacities')?.addEventListener('click',()=>apply().catch(error=>{
|
||||
qs('#week-capacity-status').textContent=(error.message||'Week Ahead sync is unavailable.')+' Capacities remain saved on this phone.';
|
||||
}));
|
||||
function availability() {
|
||||
const saved=(controller.state?.().days||[]).filter(day=>Array.isArray(day.free_windows));
|
||||
const source=activeAvailability||saved;
|
||||
return source.length?source.map(day=>({plan_date:day.plan_date,
|
||||
free_windows:day.free_windows.map(window=>({...window}))})):null;
|
||||
}
|
||||
return {open,cancel,review:reviewSource,apply,state:()=>reviewed?reviewed.map(day=>({...day})):null,
|
||||
availability};
|
||||
}
|
||||
function mount(controller,weekWorkflow,qs) {
|
||||
const workflow=createWorkflow({controller,qs,onApplied:weekWorkflow.renderReview});
|
||||
StackchainWeekCalendar.setAvailabilityProvider(workflow.availability);
|
||||
qs('#open-week-capacity-import').addEventListener('click',workflow.open);
|
||||
return workflow;
|
||||
}
|
||||
return {review,createWorkflow,mount};
|
||||
}
|
||||
const weekCalendarImport=createWeekCalendarImport();
|
||||
if(typeof module!=='undefined'&&module.exports)module.exports=weekCalendarImport;
|
||||
@@ -0,0 +1,161 @@
|
||||
(function(root){
|
||||
'use strict';
|
||||
let availabilityProvider=()=>null;
|
||||
function escapeText(value){
|
||||
return String(value||'').replace(/\\/g,'\\\\').replace(/\r?\n/g,'\\n').replace(/,/g,'\\,').replace(/;/g,'\\;');
|
||||
}
|
||||
function compact(value){return String(value||'').replace(/[-:]/g,'');}
|
||||
function addMinutes(value,minutes){
|
||||
const [hours,mins]=String(value).split(':').map(Number),total=hours*60+mins+Number(minutes);
|
||||
return String(Math.floor(total/60)%24).padStart(2,'0')+':'+String(total%60).padStart(2,'0');
|
||||
}
|
||||
function clockMinutes(value){const [hours,minutes]=String(value).split(':').map(Number);return hours*60+minutes;}
|
||||
function stableId(value){return String(value||'work').replace(/[^a-z0-9]+/gi,'-').replace(/^-|-$/g,'').toLowerCase();}
|
||||
function foldLine(line){
|
||||
const encoder=new TextEncoder(),chunks=[];let chunk='',bytes=0,limit=75;
|
||||
for(const character of String(line)){
|
||||
const width=encoder.encode(character).length;
|
||||
if(chunk&&bytes+width>limit){chunks.push(chunk);chunk=character;bytes=width;limit=74;}
|
||||
else {chunk+=character;bytes+=width;}
|
||||
}
|
||||
chunks.push(chunk);return chunks.join('\r\n ');
|
||||
}
|
||||
function buildWeekSchedule(plan,startTimes,getItem,selected,availability){
|
||||
const blocks=[],blockers=[],availableByDate=new Map((availability||[]).map(day=>[day.plan_date,day.free_windows||[]]));
|
||||
(plan?.days||[]).slice().sort((a,b)=>a.plan_date.localeCompare(b.plan_date)).forEach(day=>{
|
||||
let cursor=startTimes?.[day.plan_date]||'09:00';
|
||||
const windows=availableByDate.has(day.plan_date)?availableByDate.get(day.plan_date):(day.free_windows||null);
|
||||
const candidates=[];
|
||||
(day.ids||[]).forEach(id=>{
|
||||
if(selected&&!selected.has(id))return;
|
||||
const minutes=Number(day.estimates?.[id]),item=getItem?.(id);
|
||||
if(!item||!Number.isFinite(minutes)||minutes<=0)return;
|
||||
const exact=startTimes?.[id]||day.start_times?.[id];
|
||||
let start=exact||cursor;
|
||||
if(!exact&&windows){
|
||||
const fit=windows.find(window=>{
|
||||
const candidate=Math.max(clockMinutes(cursor),clockMinutes(window.start_time));
|
||||
if(candidate+minutes>clockMinutes(window.end_time))return false;
|
||||
start=addMinutes('00:00',candidate);return true;
|
||||
});
|
||||
if(!fit){blockers.push({id,title:item.title||'Untitled work',plan_date:day.plan_date,minutes});return;}
|
||||
}
|
||||
const startMinute=clockMinutes(start),endMinute=startMinute+minutes;
|
||||
candidates.push({item,id,minutes,start,startMinute,endMinute,exact:Boolean(exact)});
|
||||
if(!exact)cursor=addMinutes('00:00',endMinute);
|
||||
});
|
||||
const invalid=new Set();
|
||||
candidates.forEach(candidate=>{
|
||||
if(candidate.endMinute>1440||windows&&!windows.some(window=>clockMinutes(window.start_time)<=candidate.startMinute&&candidate.endMinute<=clockMinutes(window.end_time)))invalid.add(candidate.id);
|
||||
});
|
||||
candidates.forEach((left,index)=>candidates.slice(index+1).forEach(right=>{
|
||||
if(left.startMinute<right.endMinute&&left.endMinute>right.startMinute){invalid.add(left.id);invalid.add(right.id);}
|
||||
}));
|
||||
candidates.forEach(candidate=>{
|
||||
if(invalid.has(candidate.id)){
|
||||
blockers.push({id:candidate.id,title:candidate.item.title||'Untitled work',plan_date:day.plan_date,minutes:candidate.minutes,reason:'invalid-exact-time'});return;
|
||||
}
|
||||
blocks.push({...candidate.item,id:candidate.id,plan_date:day.plan_date,start_time:candidate.start,
|
||||
end_time:addMinutes('00:00',candidate.endMinute),minutes:candidate.minutes});
|
||||
});
|
||||
});
|
||||
return {blocks,blockers};
|
||||
}
|
||||
function buildWeekBlocks(plan,startTimes,getItem,selected,availability){
|
||||
return buildWeekSchedule(plan,startTimes,getItem,selected,availability).blocks;
|
||||
}
|
||||
function serializeWeekCalendar(blocks,{timezone='UTC',revision=0,generatedAt}={}){
|
||||
const stamp=generatedAt||new Date().toISOString().replace(/[-:]/g,'').replace(/\.\d{3}/,'');
|
||||
const lines=['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//Stackchain//Week Ahead//EN','CALSCALE:GREGORIAN','METHOD:PUBLISH','X-WR-CALNAME:Stackchain Week Ahead',`X-WR-TIMEZONE:${timezone}`,'BEGIN:VTIMEZONE',`TZID:${timezone}`,'END:VTIMEZONE'];
|
||||
(blocks||[]).forEach(block=>{
|
||||
const day=compact(block.plan_date),reference=block.repository+(block.number!=null?' #'+block.number:'');
|
||||
lines.push('BEGIN:VEVENT',`UID:week-${stableId(block.id)}@stackchain`,`SEQUENCE:${Math.max(0,Math.floor(Number(revision)||0))}`,`DTSTAMP:${stamp}`,
|
||||
`DTSTART;TZID=${timezone}:${day}T${compact(block.start_time)}00`,`DTEND;TZID=${timezone}:${day}T${compact(block.end_time)}00`,
|
||||
`SUMMARY:${escapeText(block.title||'Untitled work')}`,`DESCRIPTION:${escapeText(reference+' · Stackchain Week Ahead')}`,
|
||||
`URL:${String(block.url||'')}`,'TRANSP:OPAQUE','END:VEVENT');
|
||||
});
|
||||
lines.push('END:VCALENDAR');return lines.map(foldLine).join('\r\n')+'\r\n';
|
||||
}
|
||||
async function deliverWeekCalendar({text,filename,navigator,document,urlApi,FileCtor}){
|
||||
const file=new FileCtor([text],filename,{type:'text/calendar;charset=utf-8'});
|
||||
const payload={files:[file],title:'Stackchain Week Ahead',text:'Timed Week Ahead calendar blocks'};
|
||||
if(typeof navigator?.share==='function'&&typeof navigator?.canShare==='function'&&navigator.canShare(payload)){
|
||||
await navigator.share(payload);return 'shared';
|
||||
}
|
||||
const href=urlApi.createObjectURL(file);
|
||||
try{const anchor=document.createElement('a');anchor.href=href;anchor.download=filename;anchor.click();}
|
||||
finally{urlApi.revokeObjectURL(href);}
|
||||
return 'downloaded';
|
||||
}
|
||||
function mountWeekCalendarHandoff({qs,getItem,getAvailability=availabilityProvider,escapeHtml,escapeAttribute,onSave,onDone,windowObject=root,navigatorObject=root.navigator,
|
||||
documentObject=root.document,urlApi=root.URL,FileCtor=root.File}={}){
|
||||
const handoff=qs('#week-calendar-handoff'),review=qs('#week-review'),daysRoot=qs('#week-calendar-days');
|
||||
let plan=null,focusReturn=null;
|
||||
const selected=()=>new Set(Array.from(daysRoot.querySelectorAll('[data-week-calendar-item]')).filter(input=>input.checked).map(input=>input.value));
|
||||
const starts=()=>Object.fromEntries([
|
||||
...Array.from(daysRoot.querySelectorAll('[data-week-calendar-start]')).map(input=>[input.dataset.weekCalendarStart,input.value]),
|
||||
...Array.from(daysRoot.querySelectorAll('[data-week-calendar-exact]')).filter(input=>input.value).map(input=>[input.dataset.weekCalendarExact,input.value]),
|
||||
]);
|
||||
const schedule=()=>buildWeekSchedule(plan,starts(),getItem,selected(),getAvailability?.());
|
||||
const blocks=()=>schedule().blocks;
|
||||
function update(){
|
||||
const current=schedule(),byId=new Map(current.blocks.map(block=>[block.id,block])),blockedById=new Map(current.blockers.map(blocker=>[blocker.id,blocker]));
|
||||
daysRoot.querySelectorAll('[data-week-calendar-preview]').forEach(node=>{
|
||||
const id=node.dataset.weekCalendarPreview,block=byId.get(id),blocker=blockedById.get(id);
|
||||
node.textContent=block?block.start_time+'–'+block.end_time+' · '+block.minutes+' min':
|
||||
(blocker?'Does not fit imported free time':'Excluded from calendar');
|
||||
});
|
||||
qs('#share-week-calendar').disabled=!current.blocks.length||current.blockers.length>0;
|
||||
qs('#save-week-calendar-times').disabled=!current.blocks.length||current.blockers.length>0;
|
||||
if(current.blockers.length){
|
||||
const blocker=current.blockers[0];
|
||||
qs('#week-calendar-status').textContent=blocker.title+' does not fit free time on '+blocker.plan_date+'. Adjust the start, estimate, or calendar import.';
|
||||
}else qs('#week-calendar-status').textContent=(getAvailability?.()?'Planning around imported busy time · ':'Manual timing · ')+
|
||||
current.blocks.length+' calendar block'+(current.blocks.length===1?'':'s')+' selected.';
|
||||
}
|
||||
function close({back=false}={}){
|
||||
handoff.hidden=true;
|
||||
if(back){review.hidden=false;const target=focusReturn||qs('#confirm-week-plan');focusReturn=null;target.focus?.();}
|
||||
}
|
||||
function open(value,{returnFocus=null}={}){
|
||||
plan=value;if(returnFocus)focusReturn=returnFocus;review.hidden=true;handoff.hidden=false;
|
||||
const labels=new Map((value.days||[]).map(day=>[day.plan_date,new Intl.DateTimeFormat('en',{weekday:'short',month:'short',day:'numeric',timeZone:'UTC'}).format(new Date(day.plan_date+'T12:00:00Z'))]));
|
||||
daysRoot.innerHTML=(value.days||[]).filter(day=>day.ids?.length).map(day=>'<article class="week-calendar-day"><header><h3>'+escapeHtml(labels.get(day.plan_date)||day.plan_date)+
|
||||
'</h3><label>Start <input type="time" value="09:00" data-week-calendar-start="'+escapeAttribute(day.plan_date)+'"></label></header>'+
|
||||
day.ids.map(id=>{const item=getItem(id),reference=item?(item.repository+' #'+item.number):String(id).slice(0,96),saved=day.start_times?.[id]||'';
|
||||
return '<div class="week-calendar-item"><label class="week-calendar-choice"><input type="checkbox" data-week-calendar-item value="'+escapeAttribute(id)+'" checked><span><strong>'+escapeHtml(item?.title||'Work details unavailable')+
|
||||
'</strong><small>'+escapeHtml(reference)+'</small><small data-week-calendar-preview="'+escapeAttribute(id)+'"></small></span></label><label class="week-calendar-time">Start<input type="time" value="'+escapeAttribute(saved)+'" data-week-calendar-exact="'+escapeAttribute(id)+'" aria-label="Exact start for '+escapeAttribute(item?.title||'work')+'"></label></div>';}).join('')+'</article>').join('');
|
||||
daysRoot.querySelectorAll('input').forEach(input=>input.addEventListener('change',update));
|
||||
update();qs('#back-to-week-review-from-calendar').focus?.();return true;
|
||||
}
|
||||
qs('#back-to-week-review-from-calendar').addEventListener('click',()=>close({back:true}));
|
||||
qs('#save-week-calendar-times').addEventListener('click',async()=>{
|
||||
const button=qs('#save-week-calendar-times'),current=schedule();if(!current.blocks.length||current.blockers.length)return;
|
||||
button.disabled=true;qs('#week-calendar-status').textContent='Saving exact task times…';
|
||||
const byDate={};current.blocks.forEach(block=>(byDate[block.plan_date]||(byDate[block.plan_date]={}))[block.id]=block.start_time);
|
||||
try{await onSave?.(byDate);plan={...plan,days:(plan.days||[]).map(day=>({...day,start_times:{...(byDate[day.plan_date]||{})}}))};
|
||||
qs('#week-calendar-status').textContent='Exact task times saved to Week Ahead.';}
|
||||
catch(error){qs('#week-calendar-status').textContent=(error?.message||'Exact times could not be saved.')+' Retry when connected.';}
|
||||
finally{button.disabled=false;}
|
||||
});
|
||||
qs('#export-saved-week-calendar')?.addEventListener('click',event=>{focusReturn=event.currentTarget;qs('#confirm-week-plan').click();});
|
||||
qs('#share-week-calendar').addEventListener('click',async()=>{
|
||||
const button=qs('#share-week-calendar'),current=blocks();if(!current.length)return;
|
||||
button.disabled=true;qs('#week-calendar-status').textContent='Preparing calendar blocks…';
|
||||
const day=new Date().toISOString().slice(0,10);
|
||||
try{
|
||||
const text=serializeWeekCalendar(current,{timezone:plan.timezone||Intl.DateTimeFormat().resolvedOptions().timeZone,revision:plan.revision});
|
||||
const result=await deliverWeekCalendar({text,filename:'stackchain-week-ahead-'+day+'.ics',navigator:navigatorObject,
|
||||
document:documentObject,urlApi,FileCtor});
|
||||
close();onDone?.(result);
|
||||
}catch(error){
|
||||
qs('#week-calendar-status').textContent=error?.name==='AbortError'?'Share cancelled. Your Week Ahead is still ready.':'Calendar export failed. Retry without leaving Week Ahead.';
|
||||
button.disabled=false;
|
||||
}
|
||||
});
|
||||
return {open,close,blocks};
|
||||
}
|
||||
function setAvailabilityProvider(value){availabilityProvider=typeof value==='function'?value:()=>null;}
|
||||
const api={buildWeekBlocks,buildWeekSchedule,deliverWeekCalendar,escapeText,foldLine,mountWeekCalendarHandoff,serializeWeekCalendar,setAvailabilityProvider};
|
||||
if(typeof module!=='undefined'&&module.exports)module.exports=api;else root.StackchainWeekCalendar=api;
|
||||
})(typeof window!=='undefined'?window:globalThis);
|
||||
File diff suppressed because it is too large.
Load diff
+48
-12
@@ -6,7 +6,14 @@
|
||||
'use strict';
|
||||
|
||||
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
|
||||
const queueFilters = ['today', 'agenda', 'attention', 'update', 'later', 'draft'];
|
||||
const queueFilters = ['today', 'agenda', 'attention', 'filed', 'authored', 'update', 'later', 'draft', 'following'];
|
||||
const sections = {
|
||||
issue: ['overview', 'conversation', 'reply', 'actions'],
|
||||
filed: ['overview', 'conversation', 'reply', 'actions'],
|
||||
pull: ['overview', 'conversation', 'reply', 'review'],
|
||||
review: ['overview', 'files', 'feedback', 'history'],
|
||||
update: ['activity', 'conversation', 'context', 'reply'],
|
||||
};
|
||||
|
||||
function positiveInteger(value) {
|
||||
const number = Number(value);
|
||||
@@ -23,26 +30,37 @@
|
||||
if (queue === 'agenda' && parts[3] === 'protect-today' && parts.length === 4) {
|
||||
return { kind: 'queue', filter: 'agenda', action: 'protect-today' };
|
||||
}
|
||||
if (parts[2] === 'update' && parts.length === 4) {
|
||||
if (parts[2] === 'update' && [4, 5].includes(parts.length)) {
|
||||
const notificationId = positiveInteger(parts[3]);
|
||||
return notificationId ? { kind: 'update', notification_id: notificationId } : null;
|
||||
const section = parts[4];
|
||||
if (!notificationId || (section && !sections.update.includes(section))) return null;
|
||||
return { kind: 'update', notification_id: notificationId, ...(section ? { section } : {}) };
|
||||
}
|
||||
if (!['issue', 'pull', 'review'].includes(parts[2]) || parts.length !== 6) return null;
|
||||
if (!['issue', 'filed', 'pull', 'review'].includes(parts[2]) || ![6, 7].includes(parts.length)) return null;
|
||||
if (!repositoryPart.test(parts[3]) || !repositoryPart.test(parts[4])) return null;
|
||||
const number = positiveInteger(parts[5]);
|
||||
return number ? { kind: parts[2], repository: parts[3] + '/' + parts[4], number } : null;
|
||||
const section = parts[6];
|
||||
if (!number || (section && !sections[parts[2]].includes(section))) return null;
|
||||
return {
|
||||
kind: parts[2], repository: parts[3] + '/' + parts[4], number,
|
||||
...(section ? { section } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function serialize(item) {
|
||||
if (item?.kind === 'update') {
|
||||
const notificationId = positiveInteger(item.notification_id);
|
||||
return notificationId ? '#/my-work/update/' + notificationId : '';
|
||||
if (!notificationId || (item.section && !sections.update.includes(item.section))) return '';
|
||||
return '#/my-work/update/' + notificationId + (item.section ? '/' + item.section : '');
|
||||
}
|
||||
if (!['issue', 'pull', 'review'].includes(item?.kind)) return '';
|
||||
const kind = item?.is_filed && !item?.is_assigned ? 'filed' : item?.kind;
|
||||
if (!['issue', 'filed', 'pull', 'review'].includes(kind)) return '';
|
||||
const repository = String(item.repository || '').split('/');
|
||||
const number = positiveInteger(item.number);
|
||||
if (repository.length !== 2 || !repository.every(part => repositoryPart.test(part)) || !number) return '';
|
||||
return '#/my-work/' + item.kind + '/' + repository.join('/') + '/' + number;
|
||||
if (item.section && !sections[kind].includes(item.section)) return '';
|
||||
return '#/my-work/' + kind + '/' + repository.join('/') + '/' + number +
|
||||
(item.section ? '/' + item.section : '');
|
||||
}
|
||||
|
||||
function sameRoute(item, route) {
|
||||
@@ -50,14 +68,15 @@
|
||||
if (route.kind === 'update') {
|
||||
return Number(item.notification_id) === route.notification_id;
|
||||
}
|
||||
const itemKind = item.is_review ? 'review' : item.kind;
|
||||
const itemKind = item.is_review ? 'review' :
|
||||
(item.is_filed && !item.is_assigned ? 'filed' : item.kind);
|
||||
return itemKind === route.kind && item.repository === route.repository &&
|
||||
Number(item.number) === route.number;
|
||||
}
|
||||
|
||||
function createController({
|
||||
location, history, eventTarget, onOpen, onClose, onInvalid,
|
||||
onQueue = function () {},
|
||||
onQueue = function () {}, onSection = function () {},
|
||||
resolve, onResolving = function () {}, onError = function () {},
|
||||
}) {
|
||||
let items = [];
|
||||
@@ -84,7 +103,7 @@
|
||||
return;
|
||||
}
|
||||
active = fragment;
|
||||
onOpen({ ...item, kind: route.kind });
|
||||
onOpen({ ...item, kind: route.kind, ...(route.section ? { section: route.section } : {}) });
|
||||
}).catch(error => {
|
||||
if (request !== resolution || String(location.hash || '') !== fragment) return;
|
||||
resolving = '';
|
||||
@@ -126,10 +145,16 @@
|
||||
return;
|
||||
}
|
||||
if (active === fragment) return;
|
||||
const activeRoute = parse(active);
|
||||
if (activeRoute && sameRoute(item, activeRoute)) {
|
||||
active = fragment;
|
||||
onSection(route.section || sections[route.kind][0], { restore: true });
|
||||
return;
|
||||
}
|
||||
resolution += 1;
|
||||
resolving = '';
|
||||
active = fragment;
|
||||
onOpen({ ...item, kind: route.kind });
|
||||
onOpen({ ...item, kind: route.kind, ...(route.section ? { section: route.section } : {}) });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -154,6 +179,17 @@
|
||||
onOpen(item);
|
||||
return true;
|
||||
},
|
||||
section(name, options = {}) {
|
||||
const route = parse(location.hash);
|
||||
if (!route || route.kind === 'queue' || !sections[route.kind].includes(name)) return false;
|
||||
if ((route.section || sections[route.kind][0]) === name) return true;
|
||||
const fragment = serialize({ ...route, section: name });
|
||||
const method = options.replace ? 'replaceState' : 'pushState';
|
||||
history[method]({ workRoute: fragment }, '', fragment);
|
||||
active = fragment;
|
||||
onSection(name, { restore: false });
|
||||
return true;
|
||||
},
|
||||
queue(filter) {
|
||||
if (!queueFilters.includes(filter)) return false;
|
||||
const name = filter + (['update', 'draft'].includes(filter) ? 's' : '');
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
async function loadWorkspace({
|
||||
document,
|
||||
window = null,
|
||||
createLoader = createFeatureLoader,
|
||||
schedule = callback => setTimeout(callback,750),
|
||||
}) {
|
||||
let cameOnline = false;
|
||||
let replayed = false;
|
||||
let retryInFlight = null;
|
||||
const captureOnline = () => { cameOnline = true; };
|
||||
window?.addEventListener('online', captureOnline);
|
||||
const status = document.querySelector('#my-work-action-status');
|
||||
const retryButton = document.querySelector('#retry-workspace');
|
||||
const urls = Object.fromEntries(['today-timer', 'planning'].map(name => [name,
|
||||
document.querySelector(`meta[name="stackchain-feature-${name}"]`)?.content || ''
|
||||
]));
|
||||
const originalUrls = {...urls};
|
||||
const loader = createLoader({document, urls});
|
||||
|
||||
let attempts = 0;
|
||||
const load = () => {
|
||||
if (attempts++) Object.keys(urls).forEach(name => {
|
||||
urls[name] = originalUrls[name] + '?retry=' + attempts;
|
||||
});
|
||||
return Promise.all(Object.keys(urls).map(name => loader.load(name)));
|
||||
};
|
||||
const waitForRecovery = () => new Promise(resolve => {
|
||||
if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';
|
||||
if (retryButton) {
|
||||
retryButton.hidden = retryButton.disabled = false;
|
||||
}
|
||||
|
||||
const recover = () => {
|
||||
if (retryInFlight) return retryInFlight;
|
||||
if (retryButton) retryButton.disabled = true;
|
||||
retryInFlight = load().then(() => {
|
||||
window?.removeEventListener('online', recover);
|
||||
resolve();
|
||||
}).catch(() => {
|
||||
if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';
|
||||
if (retryButton) retryButton.disabled = false;
|
||||
}).finally(() => {
|
||||
retryInFlight = null;
|
||||
});
|
||||
return retryInFlight;
|
||||
};
|
||||
|
||||
window?.removeEventListener('online', captureOnline);
|
||||
window?.addEventListener('online', recover);
|
||||
retryButton?.addEventListener('click', recover);
|
||||
});
|
||||
|
||||
try {
|
||||
await load();
|
||||
} catch {
|
||||
await new Promise(resolve => schedule(resolve));
|
||||
try {
|
||||
await load();
|
||||
} catch {
|
||||
await waitForRecovery();
|
||||
}
|
||||
}
|
||||
|
||||
window?.removeEventListener('online', captureOnline);
|
||||
if (retryButton) retryButton.hidden = true;
|
||||
if (status) status.textContent = '';
|
||||
return {
|
||||
replayOnline(callback) {
|
||||
if (replayed) return;
|
||||
replayed = true;
|
||||
window?.removeEventListener('online', captureOnline);
|
||||
if (cameOnline) callback();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = loadWorkspace;
|
||||
Loaded 100 of 292 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user