Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
947a42ddd4 | ||
|
|
8e4f1380ce | ||
|
|
2351d909ca | ||
|
|
7a0a4b9780 | ||
|
|
e108feb74b | ||
|
|
7f36f30dad | ||
|
|
8ba6dcb704 | ||
|
|
4b8a34a2ae | ||
|
|
2e198a19b4 | ||
|
|
6c5b7a3f4c | ||
|
|
36d5ae023f | ||
|
|
478c08f6a7 | ||
|
|
cae90d16ba | ||
|
|
fbc07bd142 | ||
|
|
4a4d3e1755 | ||
|
|
f243a4938f | ||
|
|
9416de1497 | ||
|
|
283a5fcfd0 | ||
|
|
c88fd526e5 | ||
|
|
f0541ea5c8 | ||
|
|
8f986f1403 | ||
|
|
de9a192ba4 | ||
|
|
88d8fa56fb | ||
|
|
9044e640bc | ||
|
|
808f3c1436 | ||
|
|
d103c81df1 | ||
|
|
429f0be675 | ||
|
|
5a093f42b0 | ||
|
|
ded435df0e | ||
|
|
e52374fa24 | ||
|
|
b94647efc9 | ||
|
|
d512770c10 | ||
|
|
ea52318be6 | ||
|
|
9e5023522b | ||
|
|
4d0fdd9b11 | ||
|
|
78725c8591 | ||
|
|
f7adaad1c5 | ||
|
|
ab093cb232 | ||
|
|
2aaff02db8 | ||
|
|
f5bfd9d4d1 | ||
|
|
9e3639eb54 | ||
|
|
b4094b28bd | ||
|
|
b294839b79 | ||
|
|
14a21de37f | ||
|
|
d9239acbce | ||
|
|
a2247c0b6c | ||
|
|
c1aaec5ffc | ||
|
|
bcef9fbd61 | ||
|
|
32db59fda2 | ||
|
|
92bc68b61a | ||
|
|
a6382b2e1d | ||
|
|
571cdaf618 | ||
|
|
aa3b2c3b1b | ||
|
|
9bbf73db97 | ||
|
|
dc950e7ec2 | ||
|
|
91ca57e242 | ||
|
|
95541ef5e1 | ||
|
|
1ace129a58 | ||
|
|
af04605d7d | ||
|
|
0ec9cf4aaa | ||
|
|
a5b0664b9c | ||
|
|
c67eb4d2c0 | ||
|
|
e99ee4f774 | ||
|
|
74e6c5c960 | ||
|
|
f801da88a7 | ||
|
|
66dcd1fa34 | ||
|
|
4f9e48cc17 | ||
|
|
95702a2e53 | ||
|
|
9de6843a2e | ||
|
|
bd5cacd14a | ||
|
|
96007e98bc | ||
|
|
ae409768ac | ||
|
|
baaf306ebf | ||
|
|
7bbf103e01 | ||
|
|
49c07f397e | ||
|
|
dd91a506fa | ||
|
|
4ed8bef562 | ||
|
|
8db6544400 | ||
|
|
ca9e4d273b | ||
|
|
9324da6a2f | ||
|
|
ce3a7ee29c | ||
|
|
c9e6b774f3 | ||
|
|
9ffb2d9939 | ||
|
|
c5d4cf8f68 | ||
|
|
6b7d68d960 | ||
|
|
0cb8a810e6 | ||
|
|
40ae263248 | ||
|
|
bf97829814 | ||
|
|
b075607bac | ||
|
|
56bdd2c9a3 | ||
|
|
0f6e51ee5c | ||
|
|
1c5701fde5 | ||
|
|
6c1c909c9d | ||
|
|
26ab9c7b05 | ||
|
|
176f14df70 | ||
|
|
2d1e317c1f | ||
|
|
fcdea66a0f | ||
|
|
0e91958de5 | ||
|
|
6624b635df | ||
|
|
0612dc6f89 | ||
|
|
0769f33653 | ||
|
|
c2286f9a22 | ||
|
|
a2d8410fec | ||
|
|
a68805b55d | ||
|
|
8ca6879a1c | ||
|
|
1661f14adb | ||
|
|
7d08eb312f | ||
|
|
47340ed82a | ||
|
|
129441713b | ||
|
|
a3e9510c1d | ||
|
|
328ba9a239 | ||
|
|
3a3ff080ac | ||
|
|
e2d3e1c04f | ||
|
|
b3658bea09 | ||
|
|
1ad71f6974 | ||
|
|
9dde306c0c | ||
|
|
d7a17aab01 | ||
|
|
65adf9b9c3 | ||
|
|
aceb832645 | ||
|
|
ec2f549295 | ||
|
|
6e06c9c489 | ||
|
|
1862858c07 | ||
|
|
306c28f203 | ||
|
|
bba2c5b20c | ||
|
|
18436e91e0 | ||
|
|
77fb199cb3 | ||
|
|
272bf1d2ad | ||
|
|
ceebf34874 | ||
|
|
efd39e1f80 | ||
|
|
93c72f561b | ||
|
|
689e8f216c | ||
|
|
4e4c3cfe64 | ||
|
|
e2112e0d83 | ||
|
|
44fca91089 | ||
|
|
4ae40cbd1e | ||
|
|
ea04aec0b4 | ||
|
|
b8e2717faf | ||
|
|
333d40ed4f | ||
|
|
cb3555f5ed | ||
|
|
7a8f70db02 | ||
|
|
1cf4f520f8 | ||
|
|
e11ce5d3d0 | ||
|
|
502950709c | ||
|
|
2c3e14d84c | ||
|
|
8e71afe158 | ||
|
|
44ea98fc41 | ||
|
|
5214fb9145 | ||
|
|
41e74ba9b5 | ||
|
|
03b616622e | ||
|
|
50e1da01fe | ||
|
|
0daa620b11 | ||
|
|
f8f50b26d8 | ||
|
|
a3bc133bcb | ||
|
|
50dae22ff4 | ||
|
|
5a91154eab | ||
|
|
d5b89d512a | ||
|
|
c4e4c62072 | ||
|
|
33b54ded4f | ||
|
|
0d2f5a818b | ||
|
|
8f217e6f01 | ||
|
|
835d7767cb | ||
|
|
8892a39160 | ||
|
|
db97871945 | ||
|
|
90254b92d2 | ||
|
|
eeda2104a6 | ||
|
|
97f43f88eb | ||
|
|
793da48f5c | ||
|
|
3177af9524 | ||
|
|
f99d22738c | ||
|
|
ea7124db52 | ||
|
|
b1247e5de6 | ||
|
|
5ab962634e | ||
|
|
d99944dcff | ||
|
|
84f7286460 | ||
|
|
920bcb06dc | ||
|
|
3679cc21ab | ||
|
|
07e5626469 | ||
|
|
6bb6f87800 | ||
|
|
cb6b52ff4c | ||
|
|
03c377dff3 | ||
|
|
d9c934d3eb | ||
|
|
0bc31b0f31 | ||
|
|
5a38c8dd2f | ||
|
|
0e3f172032 | ||
|
|
fd704419d9 | ||
|
|
32ac72f592 | ||
|
|
15f20ffaeb | ||
|
|
9c77b87435 | ||
|
|
d4d9e94f44 | ||
|
|
47cef2abf0 | ||
|
|
0a5cdf380f | ||
|
|
7f0e9b3289 | ||
|
|
9da7967158 | ||
|
|
964744a9ae | ||
|
|
779304d035 | ||
|
|
f15d7445bd | ||
|
|
26d68ba3fc | ||
|
|
9effff25df | ||
|
|
958805a1fd | ||
|
|
dd74c173a5 | ||
|
|
bd020c93f5 | ||
|
|
75555f0426 | ||
|
|
4e6b14a467 | ||
|
|
233de7fc37 | ||
|
|
c6e0230e5d | ||
|
|
ca2732668e | ||
|
|
456b76ac96 | ||
|
|
a2c58bea82 | ||
|
|
7276b3d5ef | ||
|
|
1ef8315462 | ||
|
|
2f88ac419b | ||
|
|
cee7c85780 | ||
|
|
b2c5205035 | ||
|
|
2be59c3eb6 | ||
|
|
176fc896ae | ||
|
|
a943555d86 | ||
|
|
58bbfbbc42 | ||
|
|
026b644630 | ||
|
|
1483b355af | ||
|
|
6a5bb635bb | ||
|
|
18241136e3 | ||
|
|
6fb6467b07 | ||
|
|
83f0d16904 | ||
|
|
0e0aca3844 | ||
|
|
97b52792ef | ||
|
|
0680c5ff9c | ||
|
|
f1f0158238 | ||
|
|
bca3617116 | ||
|
|
55ae674e0b | ||
|
|
46eb610d26 | ||
|
|
bfc2e9c7fb | ||
|
|
ffedd9eaf9 | ||
|
|
1512718d41 | ||
|
|
e566dcd0d9 | ||
|
|
43b8f8f31b | ||
|
|
768f700719 | ||
|
|
ab1faedadf | ||
|
|
fa325bd519 | ||
|
|
568876fc4f | ||
|
|
d506604918 | ||
|
|
7c9f79f243 | ||
|
|
a77d0ce5c0 | ||
|
|
55685e6854 | ||
|
|
4731b90899 | ||
|
|
f36527995e | ||
|
|
fd5cf98072 | ||
|
|
844e32a11e | ||
|
|
82cb0143f2 | ||
|
|
51e70347e4 | ||
|
|
075bdfdc23 | ||
|
|
e84edb3e30 | ||
|
|
2bde4656ed | ||
|
|
0b2cfc45c1 | ||
|
|
c8be41ce6c | ||
|
|
ec2f710d06 | ||
|
|
71112173b9 | ||
|
|
1615c20e66 | ||
|
|
fbcea12786 | ||
|
|
047d13afd1 | ||
|
|
4c1165533c | ||
|
|
14bdcf71fe | ||
|
|
1b3f022b2d | ||
|
|
b6d93a7f09 | ||
|
|
4b2438c298 | ||
|
|
3b79c8e09f | ||
|
|
d22ac6a7a4 | ||
|
|
2f50f5ef34 | ||
|
|
d7bbf2b8e2 | ||
|
|
b8df34309f | ||
|
|
278fa3c397 | ||
|
|
6f48ee98e5 | ||
|
|
14db59318c | ||
|
|
f9b3deaff2 | ||
|
|
c265e42220 | ||
|
|
c0bdf1551d | ||
|
|
e373eeeed3 | ||
|
|
774b0c104b | ||
|
|
fbd4d9a74e | ||
|
|
6c8859863a | ||
|
|
31ecd91f7c | ||
|
|
474255c985 | ||
|
|
652f9ceb5c | ||
|
|
49668d4ba9 | ||
|
|
acddaf07db | ||
|
|
8da26ab409 | ||
|
|
3f2fefe4f7 | ||
|
|
fb8513bc9f | ||
|
|
e4ec958edb | ||
|
|
948289a038 | ||
|
|
78ad88a245 | ||
|
|
99660aeee8 | ||
|
|
ce293ed891 | ||
|
|
2738613344 | ||
|
|
5d12d934b8 | ||
|
|
19a89645a1 | ||
|
|
65a412eb59 | ||
|
|
9df9673e84 | ||
|
|
2002ed1c9c | ||
|
|
9c98c6d0c4 | ||
|
|
488a1f6070 | ||
|
|
4d6600b0a3 | ||
|
|
dac0d15e78 | ||
|
|
1e12d937aa | ||
|
|
e643a44b59 | ||
|
|
7319870289 | ||
|
|
72c1ebf66d | ||
|
|
008c4a3ab7 | ||
|
|
38d3d11571 | ||
|
|
5b45751c67 | ||
|
|
c00d937f4c | ||
|
|
d4565acfe7 | ||
|
|
16129c53bd | ||
|
|
cbf1961510 | ||
|
|
c76a4d4be7 | ||
|
|
c33fe45d4c | ||
|
|
e355c643f4 | ||
|
|
fcccb7eb38 | ||
|
|
f9209406fb | ||
|
|
facbf6f529 | ||
|
|
27af0400c0 | ||
|
|
76a80ff034 | ||
|
|
f7f333ad52 | ||
|
|
0ce45e32aa | ||
|
|
abeb11c8a6 | ||
|
|
e01af4adec | ||
|
|
3043a0bd15 | ||
|
|
94806ed45d | ||
|
|
baee94ed1d | ||
|
|
0c8358beaa | ||
|
|
232cab01f8 | ||
|
|
b301953249 | ||
|
|
bb9cb03c8a | ||
|
|
d8a4d39849 | ||
|
|
0d6966726e | ||
|
|
bb26725c67 | ||
|
|
103fe5718b | ||
|
|
a0d1bf1788 | ||
|
|
37f35281b4 | ||
|
|
306fb3cb33 | ||
|
|
24a6107171 | ||
|
|
e495fbfd99 | ||
|
|
da6fbc4995 | ||
|
|
8d673e1229 | ||
|
|
d8c7d02f5e | ||
|
|
8331c4b305 | ||
|
|
5a06f4788b | ||
|
|
827171e206 | ||
|
|
c3f3fa9876 | ||
|
|
825ef84da5 | ||
|
|
4445d46f61 | ||
|
|
c583057d45 | ||
|
|
982f3ee2a1 | ||
|
|
cd1e4e9f91 | ||
|
|
33f8e6d144 | ||
|
|
af70cc83ba | ||
|
|
8b731fd9e5 | ||
|
|
a0c697b982 | ||
|
|
21a6fe407b | ||
|
|
1c1a7b3e27 | ||
|
|
61fd5481cc | ||
|
|
d400b27545 | ||
|
|
ec9d455d18 | ||
|
|
8a75f303bb | ||
|
|
0ce706e02e | ||
|
|
3076593021 | ||
|
|
a83389bb7d | ||
|
|
ee1cde8a04 | ||
|
|
62e9c32082 | ||
|
|
a9efe88461 | ||
|
|
c8200f2972 | ||
|
|
80b43d3e0c | ||
|
|
a7e235441b | ||
|
|
bb80637e72 | ||
|
|
8fb5de4e87 | ||
|
|
1c03f0a07a | ||
|
|
b4ac96ccaf | ||
|
|
5723fbdea5 | ||
|
|
260bc15577 | ||
|
|
59bfe3e835 | ||
|
|
086fac4120 | ||
|
|
46bc36ad4a | ||
|
|
ed477e8cc6 | ||
|
|
8b878149d4 | ||
|
|
db512419ea | ||
|
|
491bc65a38 | ||
|
|
94ec286de6 | ||
|
|
108c83588c | ||
|
|
abbb4950a5 | ||
|
|
0fe0b34898 | ||
|
|
8ad7d4be0a | ||
|
|
ac7f3805d4 | ||
|
|
e524dd111e | ||
|
|
4a14709f13 | ||
|
|
85b8ee493e | ||
|
|
e546488aeb | ||
|
|
0702e0abd3 | ||
|
|
29318005b2 | ||
|
|
b4b61c94d7 | ||
|
|
f18620d890 | ||
|
|
eb86c3fb79 | ||
|
|
f89fc2ee1d | ||
|
|
495eaa0a37 | ||
|
|
b44b7b80ff | ||
|
|
c8ff5362a3 | ||
|
|
d575a0a29f | ||
|
|
2e1134fdfb | ||
|
|
ab6ae15836 | ||
|
|
a4840c7365 | ||
|
|
dbcc13a5cf | ||
|
|
8d2639b349 | ||
|
|
889b034a79 | ||
|
|
e49145023f | ||
|
|
ae1fb05607 | ||
|
|
80a957bc5b | ||
|
|
24f93d8258 | ||
|
|
01016e08d2 | ||
|
|
949fde1517 | ||
|
|
804cf9916b | ||
|
|
ef24680baf | ||
|
|
d4b1d240b9 | ||
|
|
448c795982 | ||
|
|
7f667e2d1c | ||
|
|
a1afdb0c63 | ||
|
|
ee1d7599d5 | ||
|
|
8adad18198 | ||
|
|
49a0758aae | ||
|
|
e3bdc73013 | ||
|
|
1922e1e895 | ||
|
|
7ee8e7c8d2 | ||
|
|
e5106c0704 | ||
|
|
80331aa217 | ||
|
|
84537e6884 | ||
|
|
f7062115bc | ||
|
|
24f9f20eb7 | ||
|
|
217c6f2ed0 | ||
|
|
82490ef5dd | ||
|
|
fcda017c39 | ||
|
|
0c36925783 | ||
|
|
fcb67e62c5 | ||
|
|
0651a2886e | ||
|
|
d876f4cb39 | ||
|
|
b5259d1a98 | ||
|
|
ffe053ffc5 | ||
|
|
1785af4749 | ||
|
|
98b4b7330e | ||
|
|
f68e5ae9ef | ||
|
|
b3e73ce86a | ||
|
|
02cbb5f812 | ||
|
|
1d9ae72c81 | ||
|
|
d6470ae814 | ||
|
|
a816d1a913 | ||
|
|
2c03316f86 | ||
|
|
d19214a390 | ||
|
|
03035d1a2d | ||
|
|
6886716e67 | ||
|
|
6165308c23 | ||
|
|
77a9469ff8 | ||
|
|
2dce9b4c48 | ||
|
|
74e05763f7 | ||
|
|
a4185fde07 | ||
|
|
ebb519e6d8 | ||
|
|
90fbba600f | ||
|
|
4bd4921131 | ||
|
|
44ee813c6f | ||
|
|
3e467c5021 | ||
|
|
2b0dd3ab99 | ||
|
|
2618346a32 | ||
|
|
eacbb14279 | ||
|
|
c8570d07ef | ||
|
|
7e345dd1f9 | ||
|
|
afe6ad6bfc | ||
|
|
af4c1e8514 | ||
|
|
d4fecd1dd8 | ||
|
|
79779eb721 | ||
|
|
6af666b58c | ||
|
|
42872136a0 | ||
|
|
aca7b2ac5f | ||
|
|
a646f19b3f | ||
|
|
9c5298aebf | ||
|
|
c6b07852fe | ||
|
|
40d3053df9 | ||
|
|
5e576a58e9 | ||
|
|
4d63b5dda1 | ||
|
|
c1efd3f68b | ||
|
|
2841cc67e8 | ||
|
|
becf34b0d9 | ||
|
|
1bba915aef | ||
|
|
f7239137d6 | ||
|
|
d656986413 | ||
|
|
d0c4e9d846 | ||
|
|
bacb607d90 | ||
|
|
3914e24b3d | ||
|
|
4582be1da5 | ||
|
|
c3c7f8b14f | ||
|
|
863d4f117b | ||
|
|
35038ed56b | ||
|
|
c4149f5d94 | ||
|
|
de2db71e54 | ||
|
|
2e3ed896cc | ||
|
|
f6f352e9e0 | ||
|
|
33dede8abc | ||
|
|
1e2e66ecf7 | ||
|
|
6e5140d859 | ||
|
|
84a7f90129 | ||
|
|
aff02c2def | ||
|
|
2dd5b29303 | ||
|
|
bc8f17393a | ||
|
|
72f78f1c8d | ||
|
|
201b4ca1e8 | ||
|
|
04811a0658 | ||
|
|
ee94d6aa89 | ||
|
|
3382cd6208 | ||
|
|
56f9ec1d10 | ||
|
|
1b42312d02 | ||
|
|
8a41a9f506 | ||
|
|
18bb2d7501 | ||
|
|
6f8e576c9b | ||
|
|
e5bb102ab1 | ||
|
|
13700fe2b2 | ||
|
|
3cfbe65855 | ||
|
|
ab0b11cc92 | ||
|
|
17c8071de9 | ||
|
|
1fe7f61ec8 | ||
|
|
ea3b8124fc | ||
|
|
c41cfadb19 | ||
|
|
e434fe0847 | ||
|
|
8a8aaa752e | ||
|
|
4420d1df2c | ||
|
|
d90d39933a | ||
|
|
7e38ab624a | ||
|
|
9a36187333 | ||
|
|
b0c4d70628 | ||
|
|
d5fa6424c1 | ||
|
|
07fe5b3cb1 | ||
|
|
672547cd07 | ||
|
|
28167403a4 | ||
|
|
eaf08d42dd | ||
|
|
bae95a5e07 | ||
|
|
db1ed9e257 | ||
|
|
adef35049f | ||
|
|
bd9abf2e52 | ||
|
|
aa74c00fd4 | ||
|
|
a68a907fa7 | ||
|
|
1a05d6d03a | ||
|
|
60f4183057 | ||
|
|
04c3afc850 | ||
|
|
066e512844 | ||
|
|
d60457afaf | ||
|
|
f5a788220e | ||
|
|
2fd5edc6e4 | ||
|
|
26716d0b89 | ||
|
|
3e0f57d0c7 | ||
|
|
5347717f3d | ||
|
|
6b0a569998 | ||
|
|
4476391287 | ||
|
|
16a9d8c244 | ||
|
|
bbda9a0ee8 | ||
|
|
776fdcc6ce | ||
|
|
f76e06ec57 | ||
|
|
3a9cff697b | ||
|
|
e6979c77e3 | ||
|
|
99866507f5 | ||
|
|
cf0dd07c21 | ||
|
|
b9bf8c6c96 | ||
|
|
725d0da15d | ||
|
|
37164070d2 | ||
|
|
d7bd34531f | ||
|
|
71ca1753ef | ||
|
|
c6bfbf4ac1 | ||
|
|
a27fde72ee | ||
|
|
785f6a36bf | ||
|
|
1a4c12d851 | ||
|
|
b484e32b08 | ||
|
|
303438834b | ||
|
|
70b7b7615a | ||
|
|
5c42e60faf | ||
|
|
48ef60e0eb | ||
|
|
01af97ddab | ||
|
|
038794fa1c | ||
|
|
ffb1bccb10 | ||
|
|
371eb3bee6 | ||
|
|
466d935d02 | ||
|
|
7ffca9c3f1 | ||
|
|
a5981b3106 | ||
|
|
1df080983a | ||
|
|
822b72eee4 | ||
|
|
014abdcc00 | ||
|
|
b35cc437d3 | ||
|
|
7ce87c7168 | ||
|
|
2a3f8eedf6 | ||
|
|
895c3f2f97 | ||
|
|
8860f4591b | ||
|
|
984f26807e | ||
|
|
88c58e6108 | ||
|
|
83c7c4a14a | ||
|
|
b4a4df480e | ||
|
|
9843b4d218 | ||
|
|
c1251af597 | ||
|
|
7d09b8bf1b | ||
|
|
f57bdeec12 | ||
|
|
9899f6b761 | ||
|
|
328e10b89f | ||
|
|
b37f8a8375 | ||
|
|
139937f887 | ||
|
|
b8b90451b6 | ||
|
|
c9a2aff6a6 | ||
|
|
b25b117717 | ||
|
|
e174fd731c | ||
|
|
34e6d463be | ||
|
|
808169f43c | ||
|
|
82b6531a07 | ||
|
|
917e2cabfc | ||
|
|
a981025679 | ||
|
|
f9ea2d73e1 | ||
|
|
a5728e9407 | ||
|
|
404a2acc8f | ||
|
|
7bf61ee8ed | ||
|
|
bf1cc47525 | ||
|
|
74ad27d5fb | ||
|
|
60b73aff24 | ||
|
|
4de465e544 | ||
|
|
e1161b9227 | ||
|
|
c2cdd0fa08 | ||
|
|
b365a03e71 | ||
|
|
2e629efc7f | ||
|
|
fcaa2bef6e | ||
|
|
3b2a9fedef | ||
|
|
64256f2638 | ||
|
|
5c8122d93b | ||
|
|
6ffb95779d | ||
|
|
af975d0e7f | ||
|
|
34b2b88cf3 | ||
|
|
3fea166274 | ||
|
|
233b905492 | ||
|
|
7afad765cb | ||
|
|
39663cb594 | ||
|
|
457e0539fa | ||
|
|
871ba619a3 | ||
|
|
f5427310e8 | ||
|
|
e4435252f4 | ||
|
|
a66aa74c7f | ||
|
|
3949e15f77 | ||
|
|
acaf50bf8a | ||
|
|
09eedc08c1 | ||
|
|
738bcf8bcb | ||
|
|
795caa5d9d | ||
|
|
c041197f3f | ||
|
|
0730789cb3 | ||
|
|
1eb0fbd2c9 | ||
|
|
0484122b7f | ||
|
|
7bc9c704d1 | ||
|
|
c9efdc2c16 | ||
|
|
e0dee998d4 | ||
|
|
e09ea60616 | ||
|
|
7cab07fa01 | ||
|
|
5a62a8afe9 | ||
|
|
23d6d41148 | ||
|
|
a07fbfd7b7 | ||
|
|
1411268e35 | ||
|
|
9bda88edbd | ||
|
|
16da25898f | ||
|
|
88ae2b8e83 | ||
|
|
7d1ede8c91 | ||
|
|
3805f8b156 | ||
|
|
39f40670d0 | ||
|
|
514cf2b81c | ||
|
|
4dbb5975c0 | ||
|
|
b327e59ab1 | ||
|
|
917d51e54b | ||
|
|
122d05346b | ||
|
|
645802981e | ||
|
|
0348a2475c | ||
|
|
a2b7f59dc9 | ||
|
|
c23dc93c7a | ||
|
|
d3e3667405 | ||
|
|
d4ed5afe93 | ||
|
|
790ecef1c1 | ||
|
|
edb59ffdc6 | ||
|
|
e137038586 | ||
|
|
761eb343f9 | ||
|
|
6c15c907bf | ||
|
|
06b21e3d27 | ||
|
|
263d2e6f30 | ||
|
|
c97fec66f4 | ||
|
|
d5cf881d7a | ||
|
|
050d3e6cd2 | ||
|
|
757f68c872 | ||
|
|
d27993d2c9 | ||
|
|
bfc65d51e3 | ||
|
|
3c29d38599 | ||
|
|
060609e2cb | ||
|
|
a1c48bf012 | ||
|
|
a67fb66d2e | ||
|
|
3001f4845a | ||
|
|
192c1ad669 | ||
|
|
89ae8a3607 | ||
|
|
90d718c186 | ||
|
|
3cfbe0c89b |
@@ -5,6 +5,7 @@
|
||||
# Only add necessary files to the Docker build context (Dockerfiles are always included implicitly)
|
||||
!/build/
|
||||
!/internal/
|
||||
!/pkg/
|
||||
!/go.mod
|
||||
!/go.sum
|
||||
!main.go
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
github: [glanceapp]
|
||||
patreon: glanceapp
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Bug report
|
||||
description: Let us know if something isn't working as expected
|
||||
labels: ["bug report"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> [!NOTE]
|
||||
>
|
||||
> Do not prefix your title with "[BUG]", "[Bug report]", etc., a label will be added automatically.
|
||||
|
||||
If you're unsure whether you're experiencing a bug or not, consider using the [Discussions](https://github.com/glanceapp/glance/discussions) or [Discord](https://discord.com/invite/7KQ7Xa9kJd) to ask for help.
|
||||
|
||||
Please include only the information you think is relevant to the bug:
|
||||
|
||||
* How did you install Glance? (Docker container, manual binary install, etc)
|
||||
* Which version of Glance are you using?
|
||||
* Include the relevant parts of your `glance.yml` if applicable (widget, data source, properties used, etc)
|
||||
* Include any relevant logs or screenshots if applicable
|
||||
* Is the issue specific to a certain browser or OS?
|
||||
* Steps to reliably reproduce the issue
|
||||
* Are you hosting Glance on a VPS?
|
||||
* Anything else you think might be relevant
|
||||
|
||||
**No need to copy the above list into your description, it's just a guide to help you provide the most useful information.**
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Description
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to submit a bug report.
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discussions
|
||||
url: https://github.com/glanceapp/glance/discussions
|
||||
about: For help, feedback, guides, resources and more
|
||||
- name: Discord
|
||||
url: https://discord.com/invite/7KQ7Xa9kJd
|
||||
about: Much like the discussions but more chatty
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Feature request
|
||||
description: Share your ideas for new features or improvements
|
||||
labels: ["feature request"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> [!NOTE]
|
||||
>
|
||||
> Do not prefix your title with "[REQUEST]", "[Feature request]", etc., a label will be added automatically.
|
||||
|
||||
Please provide a detailed description of what the feature would do and what it would look like:
|
||||
|
||||
* What problem would this feature solve?
|
||||
* Are there any potential downsides to this feature?
|
||||
* If applicable, what would the configuration for this feature look like?
|
||||
* Are there any existing examples of this feature in other software?
|
||||
* If applicable, include any external documentation required to implement this feature
|
||||
* Anything else you think might be relevant
|
||||
|
||||
**No need to copy the above list into your description, it's just a guide to help you provide the most useful information.**
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Description
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to submit your idea.
|
||||
@@ -1,7 +1 @@
|
||||
<!--
|
||||
|
||||
If your pull request adds new features or changes existing ones please use the latest release/* branch as the base.
|
||||
|
||||
Documentation updates (including new themes) can be submitted to the main branch.
|
||||
|
||||
-->
|
||||
<!-- If your pull request adds new features, changes existing ones or fixes any bugs, please use the dev branch as the base, otherwise use the main branch -->
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Create release
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the target Git reference
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Golang
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Set up Docker buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
args: release
|
||||
@@ -1,4 +1,7 @@
|
||||
/assets
|
||||
/build
|
||||
/playground
|
||||
glance.yml
|
||||
/.idea
|
||||
/glance*.yml
|
||||
*.yml.bak
|
||||
*.yml.bak.*
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
project_name: glanceapp/glance
|
||||
|
||||
checksum:
|
||||
disable: true
|
||||
|
||||
builds:
|
||||
- binary: glance
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- openbsd
|
||||
- freebsd
|
||||
- windows
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- arm
|
||||
- 386
|
||||
goarm:
|
||||
- 7
|
||||
ldflags:
|
||||
- -s -w -X github.com/glanceapp/glance/internal/glance.buildVersion={{ .Tag }}
|
||||
|
||||
archives:
|
||||
-
|
||||
name_template: "glance-{{ .Os }}-{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}"
|
||||
files:
|
||||
- nothing*
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
|
||||
dockers:
|
||||
- image_templates:
|
||||
- &amd64_image "{{ .ProjectName }}:{{ .Tag }}-amd64"
|
||||
build_flag_templates:
|
||||
- --platform=linux/amd64
|
||||
goarch: amd64
|
||||
use: buildx
|
||||
dockerfile: Dockerfile.goreleaser
|
||||
|
||||
- image_templates:
|
||||
- &arm64v8_image "{{ .ProjectName }}:{{ .Tag }}-arm64"
|
||||
build_flag_templates:
|
||||
- --platform=linux/arm64
|
||||
goarch: arm64
|
||||
use: buildx
|
||||
dockerfile: Dockerfile.goreleaser
|
||||
|
||||
- image_templates:
|
||||
- &armv7_image "{{ .ProjectName }}:{{ .Tag }}-armv7"
|
||||
build_flag_templates:
|
||||
- --platform=linux/arm/v7
|
||||
goarch: arm
|
||||
goarm: 7
|
||||
use: buildx
|
||||
dockerfile: Dockerfile.goreleaser
|
||||
|
||||
docker_manifests:
|
||||
- name_template: "{{ .ProjectName }}:{{ .Tag }}"
|
||||
image_templates: &multiarch_images
|
||||
- *amd64_image
|
||||
- *arm64v8_image
|
||||
- *armv7_image
|
||||
- name_template: "{{ .ProjectName }}:latest"
|
||||
skip_push: auto
|
||||
image_templates: *multiarch_images
|
||||
@@ -1,11 +1,15 @@
|
||||
FROM alpine:3.20
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
FROM golang:1.24.3-alpine3.21 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY build/glance-$TARGETOS-$TARGETARCH${TARGETVARIANT} /app/glance
|
||||
COPY . /app
|
||||
RUN CGO_ENABLED=0 go build .
|
||||
|
||||
FROM alpine:3.21
|
||||
|
||||
LABEL org.opencontainers.image.source=https://github.com/uhlwoogi/modern-glance
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/glance .
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
ENTRYPOINT ["/app/glance"]
|
||||
ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM alpine:3.21
|
||||
|
||||
WORKDIR /app
|
||||
COPY glance .
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]
|
||||
@@ -1,14 +0,0 @@
|
||||
FROM golang:1.22.3-alpine3.19 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY . /app
|
||||
RUN CGO_ENABLED=0 go build .
|
||||
|
||||
|
||||
FROM alpine:3.19
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/glance .
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
ENTRYPOINT ["/app/glance"]
|
||||
@@ -1,111 +1,123 @@
|
||||
<p align="center"><em>What if you could see everything at a...</em></p>
|
||||
<h1 align="center">Glance</h1>
|
||||
<p align="center"><a href="#installation">Install</a> • <a href="docs/configuration.md">Configuration</a> • <a href="docs/themes.md">Themes</a></p>
|
||||
<p align="center"><img src="docs/logo.png"></p>
|
||||
<h1 align="center">modern-glance</h1>
|
||||
<p align="center">
|
||||
<a href="#installation">Install</a> •
|
||||
<a href="#web-based-editor">Editor</a> •
|
||||
<a href="docs/configuration.md#configuring-glance">Configuration</a> •
|
||||
<a href="docs/themes.md">Themes</a>
|
||||
</p>
|
||||
|
||||

|
||||
<p align="center">A fork of <a href="https://github.com/glanceapp/glance">glanceapp/glance</a> that adds a web-based dashboard editor so you can manage your config without touching YAML.</p>
|
||||
|
||||
### Features
|
||||
#### Various widgets
|
||||
* RSS feeds
|
||||
* Subreddit posts
|
||||
* Weather
|
||||
* Bookmarks
|
||||
* Hacker News
|
||||
* Lobsters
|
||||
* Latest YouTube videos from specific channels
|
||||
* Clock
|
||||
* Calendar
|
||||
* Stocks
|
||||
* iframe
|
||||
* Twitch channels & top games
|
||||
* GitHub releases
|
||||
* Repository overview
|
||||
* Site monitor
|
||||
* Search box
|
||||
> **Upstream credit:** All core functionality — widgets, theming, layout engine, hot-reload — is the work of the [Glance](https://github.com/glanceapp/glance) project and its contributors. This fork adds an editing UI on top and nothing else. If you find Glance useful, consider [sponsoring the upstream project](https://github.com/sponsors/glanceapp).
|
||||
|
||||
#### Themeable
|
||||

|
||||
> **⚠️ Vibe coded:** The editing UI in this fork was built entirely with AI assistance and has not been rigorously tested. It may have bugs, edge cases, or security issues — particularly around config file handling. Use it on a trusted network, keep backups, and don't be surprised if something breaks. PRs welcome.
|
||||
|
||||
#### Optimized for mobile devices
|
||||

|
||||
---
|
||||
|
||||
#### Fast and lightweight
|
||||
* Minimal JS, no bloated frameworks
|
||||
* Very few dependencies
|
||||
* Single, easily distributed <15mb binary and just as small docker container
|
||||
* All requests are parallelized, uncached pages usually load within ~1s (depending on internet speed and number of widgets)
|
||||
## What's added
|
||||
|
||||
### Configuration
|
||||
Checkout the [configuration docs](docs/configuration.md) to learn more. A [preconfigured page](docs/configuration.md#preconfigured-page) is also available to get you started quickly.
|
||||
### In-place edit mode
|
||||
A pencil button in the dashboard header toggles edit mode. While active you can:
|
||||
- **Drag and drop** widgets between columns and reorder them
|
||||
- **Add widgets** via a form dialog (for most widget types) or a YAML editor fallback
|
||||
- **Edit widgets** inline with per-field forms — includes live lookup for weather locations, market symbols, and RSS feed validation
|
||||
- **Delete widgets**
|
||||
- **Add, delete, resize, and reorder columns**
|
||||
- **Add and manage head widgets** (the row above the columns)
|
||||
|
||||
### Installation
|
||||
> [!CAUTION]
|
||||
>
|
||||
> The project is under active development, expect things to break every once in a while.
|
||||
### Page management (`/edit`)
|
||||
- Add, delete, and reorder pages
|
||||
- Per-page settings: name, slug, width, navigation options, mobile header
|
||||
|
||||
#### Manual
|
||||
Checkout the [releases page](https://github.com/glanceapp/glance/releases) for available binaries. You can place the binary inside `/opt/glance/` and have it start with your server via a [systemd service](https://linuxhandbook.com/create-systemd-services/). To specify a different path for the config file use the `--config` option:
|
||||
### Site settings (`/edit/site-settings`)
|
||||
- App name, logo, favicon, footer — all editable without touching YAML
|
||||
|
||||
```bash
|
||||
/opt/glance/glance --config /etc/glance.yml
|
||||
```
|
||||
### Theme settings (`/edit/theme-settings`)
|
||||
- Color pickers for background, primary, positive, and negative colors (hex input, stored as HSL)
|
||||
- Contrast and text saturation multipliers
|
||||
- Light mode toggle, hide-picker toggle, custom CSS file path
|
||||
- **Preset management**: add, edit, and delete named theme presets with live preview swatches
|
||||
- **Built-in theme catalog**: 14 themes from the upstream docs (Dracula, Catppuccin variants, Gruvbox, and more) importable with one click
|
||||
|
||||
#### Docker
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> Make sure you have a valid `glance.yml` file in the same directory before running the container.
|
||||
### Multi-step undo
|
||||
`/edit` keeps a 10-step numbered backup chain (`glance.yml.bak.1` … `.bak.10`). Each save rotates the chain; any backup can be restored individually.
|
||||
|
||||
```bash
|
||||
docker run -d -p 8080:8080 \
|
||||
-v ./glance.yml:/app/glance.yml \
|
||||
-v /etc/timezone:/etc/timezone:ro \
|
||||
-v /etc/localtime:/etc/localtime:ro \
|
||||
glanceapp/glance
|
||||
```
|
||||
### Auth gate
|
||||
`/edit` returns 403 unless `auth.users` is configured **or** `admin.allow-without-auth: true` is set. See `docs/glance.yml` for examples of both.
|
||||
|
||||
Or if you prefer docker compose:
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Docker (recommended)
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
glance:
|
||||
image: glanceapp/glance
|
||||
volumes:
|
||||
- ./glance.yml:/app/glance.yml
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
image: ghcr.io/uhlwoogi/modern-glance:latest
|
||||
ports:
|
||||
- 8080:8080
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
# Config dir — glance.yml lives here and is written by /edit
|
||||
- ./config:/app/config
|
||||
# Optional: custom CSS / images (set server.assets-path: /app/assets in glance.yml)
|
||||
- ./assets:/app/assets
|
||||
restart: unless-stopped
|
||||
# environment:
|
||||
# MY_SECRET_TOKEN: abc123 # reference as ${env:MY_SECRET_TOKEN} in glance.yml
|
||||
```
|
||||
|
||||
### Building from source
|
||||
1. Create the config directory and grab the starter config:
|
||||
```bash
|
||||
mkdir -p config assets
|
||||
curl -o config/glance.yml https://raw.githubusercontent.com/uhlwoogi/modern-glance/main/docs/glance.yml
|
||||
```
|
||||
|
||||
Requirements: [Go](https://go.dev/dl/) >= v1.22
|
||||
2. Start:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
To build:
|
||||
3. Open `http://localhost:8080` — click the **pencil icon** in the header to start editing.
|
||||
|
||||
> The starter `glance.yml` has `admin.allow-without-auth: true` so `/edit` is open by default. Switch to the auth block in the file before exposing to a network you don't fully trust.
|
||||
|
||||
### Updating
|
||||
|
||||
```bash
|
||||
go build -o build/glance .
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
To run:
|
||||
---
|
||||
|
||||
## Original Glance features
|
||||
|
||||
Everything from upstream is intact:
|
||||
|
||||
- **Widgets**: RSS, Reddit, Hacker News, weather, YouTube, Twitch, markets, Docker containers, server stats, bookmarks, calendar, monitors, and [many more](docs/configuration.md#configuring-glance)
|
||||
- **Fast and lightweight**: low memory, minimal JS, single ~20 MB binary, pages load in ~1s
|
||||
- **Highly customizable**: multiple layouts, pages, themes, custom CSS
|
||||
- **Mobile optimized**
|
||||
- **Themeable**: tweak a few numbers or pick from the [theme gallery](docs/themes.md)
|
||||
|
||||
Full configuration reference: [docs/configuration.md](docs/configuration.md#configuring-glance)
|
||||
|
||||
---
|
||||
|
||||
## Building from source
|
||||
|
||||
```bash
|
||||
# Run locally
|
||||
go run .
|
||||
|
||||
# Build binary
|
||||
go build -o glance .
|
||||
|
||||
# Build and push Docker image
|
||||
docker build -t ghcr.io/uhlwoogi/modern-glance:latest .
|
||||
docker push ghcr.io/uhlwoogi/modern-glance:latest
|
||||
```
|
||||
|
||||
### Building Docker image
|
||||
|
||||
Build the image:
|
||||
|
||||
**Make sure to replace "owner" with your name or organization.**
|
||||
|
||||
```bash
|
||||
docker build -t owner/glance:latest -f Dockerfile.single-platform .
|
||||
```
|
||||
|
||||
Push the image to your registry:
|
||||
|
||||
```bash
|
||||
docker push owner/glance:latest
|
||||
```
|
||||
Requires Go ≥ 1.23.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
services:
|
||||
glance:
|
||||
image: ghcr.io/uhlwoogi/modern-glance:latest
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
# Config directory: glance.yml lives here and is written by the /edit UI.
|
||||
# Copy docs/glance.yml → ./config/glance.yml to get started.
|
||||
- ./config:/app/config
|
||||
# Assets directory: custom CSS, images, icons referenced by server.assets-path.
|
||||
# Set `server: { assets-path: /app/assets }` in glance.yml to enable.
|
||||
- ./assets:/app/assets
|
||||
restart: unless-stopped
|
||||
# Uncomment to inject secrets as environment variables.
|
||||
# Reference them in glance.yml as ${env:MY_SECRET_TOKEN}.
|
||||
# environment:
|
||||
# MY_SECRET_TOKEN: abc123
|
||||
@@ -0,0 +1,475 @@
|
||||
[Jump to function definitions](#functions)
|
||||
|
||||
## Examples
|
||||
|
||||
The best way to get an idea of how the templates work would be with a bunch examples. Here are the most common use cases:
|
||||
|
||||
JSON response:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "My Title",
|
||||
"content": "My Content",
|
||||
}
|
||||
```
|
||||
|
||||
To access the two fields in the JSON response, you would use the following:
|
||||
|
||||
```html
|
||||
<div>{{ .JSON.String "title" }}</div>
|
||||
<div>{{ .JSON.String "content" }}</div>
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>My Title</div>
|
||||
<div>My Content</div>
|
||||
```
|
||||
|
||||
<hr>
|
||||
|
||||
JSON response:
|
||||
|
||||
```json
|
||||
{
|
||||
"author": "John Doe",
|
||||
"posts": [
|
||||
{
|
||||
"title": "My Title",
|
||||
"content": "My Content"
|
||||
},
|
||||
{
|
||||
"title": "My Title 2",
|
||||
"content": "My Content 2"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
To loop through the array of posts, you would use the following:
|
||||
|
||||
```html
|
||||
{{ range .JSON.Array "posts" }}
|
||||
<div>{{ .String "title" }}</div>
|
||||
<div>{{ .String "content" }}</div>
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>My Title</div>
|
||||
<div>My Content</div>
|
||||
<div>My Title 2</div>
|
||||
<div>My Content 2</div>
|
||||
```
|
||||
|
||||
Notice the missing `.JSON` when accessing the title and content, this is because the range function sets the context to the current array element.
|
||||
|
||||
If you want to access the top-level context within the range, you can use the following:
|
||||
|
||||
```html
|
||||
{{ range .JSON.Array "posts" }}
|
||||
<div>{{ .String "title" }}</div>
|
||||
<div>{{ .String "content" }}</div>
|
||||
<div>{{ $.JSON.String "author" }}</div>
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>My Title</div>
|
||||
<div>My Content</div>
|
||||
<div>John Doe</div>
|
||||
<div>My Title 2</div>
|
||||
<div>My Content 2</div>
|
||||
<div>John Doe</div>
|
||||
```
|
||||
|
||||
<hr>
|
||||
|
||||
JSON response:
|
||||
|
||||
```json
|
||||
[
|
||||
"Apple",
|
||||
"Banana",
|
||||
"Cherry",
|
||||
"Watermelon"
|
||||
]
|
||||
```
|
||||
|
||||
Somewhat awkwardly, when the current context is a basic type that isn't an object, the way you specify its type is to use an empty string as the key. So, to loop through the array of strings, you would use the following:
|
||||
|
||||
```html
|
||||
{{ range .JSON.Array "" }}
|
||||
<div>{{ .String "" }}</div>
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>Apple</div>
|
||||
<div>Banana</div>
|
||||
<div>Cherry</div>
|
||||
<div>Watermelon</div>
|
||||
```
|
||||
|
||||
To access an item at a specific index, you could use the following:
|
||||
|
||||
```html
|
||||
<div>{{ .JSON.String "0" }}</div>
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>Apple</div>
|
||||
```
|
||||
|
||||
<hr>
|
||||
|
||||
JSON response:
|
||||
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"address": {
|
||||
"city": "New York",
|
||||
"state": "NY"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To easily access deeply nested objects, you can use the following dot notation:
|
||||
|
||||
```html
|
||||
<div>{{ .JSON.String "user.address.city" }}</div>
|
||||
<div>{{ .JSON.String "user.address.state" }}</div>
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>New York</div>
|
||||
<div>NY</div>
|
||||
```
|
||||
|
||||
Using indexes anywhere in the path is also supported:
|
||||
|
||||
```json
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"name": "John Doe"
|
||||
},
|
||||
{
|
||||
"name": "Jane Doe"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<div>{{ .JSON.String "users.0.name" }}</div>
|
||||
<div>{{ .JSON.String "users.1.name" }}</div>
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>John Doe</div>
|
||||
<div>Jane Doe</div>
|
||||
```
|
||||
|
||||
<hr>
|
||||
|
||||
JSON response:
|
||||
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"name": "John Doe",
|
||||
"age": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To check if a field exists, you can use the following:
|
||||
|
||||
```html
|
||||
{{ if .JSON.Exists "user.age" }}
|
||||
<div>{{ .JSON.Int "user.age" }}</div>
|
||||
{{ else }}
|
||||
<div>Age not provided</div>
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>30</div>
|
||||
```
|
||||
|
||||
<hr>
|
||||
|
||||
JSON response:
|
||||
|
||||
```json
|
||||
{
|
||||
"price": 100,
|
||||
"discount": 10
|
||||
}
|
||||
```
|
||||
|
||||
Calculations can be performed on either ints or floats. If both numbers are ints, an int will be returned, otherwise a float will be returned. If you try to divide by zero, 0 will be returned. If you provide non-numeric values, `NaN` will be returned.
|
||||
|
||||
```html
|
||||
<div>{{ sub (.JSON.Int "price") (.JSON.Int "discount") }}</div>
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>90</div>
|
||||
```
|
||||
|
||||
Other operations include `add`, `mul`, `div` and `mod`.
|
||||
|
||||
<hr>
|
||||
|
||||
JSON response:
|
||||
|
||||
```json
|
||||
{
|
||||
"posts": [
|
||||
{
|
||||
"title": "Exploring the Depths of Quantum Computing",
|
||||
"date": "2023-10-27T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"title": "A Beginner's Guide to Sustainable Living",
|
||||
"date": "2023-11-15T14:30:00+01:00"
|
||||
},
|
||||
{
|
||||
"title": "The Art of Baking Sourdough Bread",
|
||||
"date": "2023-12-03T08:45:22-08:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
To parse the date and display the relative time (e.g. 2h, 1d, etc), you would use the following:
|
||||
|
||||
```
|
||||
{{ range .JSON.Array "posts" }}
|
||||
<div>{{ .String "title" }}</div>
|
||||
<div {{ .String "date" | parseTime "rfc3339" | toRelativeTime }}></div>
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
The `parseTime` function takes two arguments: the layout of the date string and the date string itself. The layout can be one of the following: "RFC3339", "RFC3339Nano", "DateTime", "DateOnly", "TimeOnly" or a custom layout in Go's [date format](https://pkg.go.dev/time#pkg-constants).
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<div>Exploring the Depths of Quantum Computing</div>
|
||||
<div data-dynamic-relative-time="1698400800"></div>
|
||||
|
||||
<div>A Beginner's Guide to Sustainable Living</div>
|
||||
<div data-dynamic-relative-time="1700055000"></div>
|
||||
|
||||
<div>The Art of Baking Sourdough Bread</div>
|
||||
<div data-dynamic-relative-time="1701621922"></div>
|
||||
```
|
||||
|
||||
You don't have to worry about the internal implementation, this will then be dynamically populated by Glance on the client side to show the correct relative time.
|
||||
|
||||
The important thing to notice here is that the return value of `toRelativeTime` must be used as an attribute in an HTML tag, be it a `div`, `li`, `span`, etc.
|
||||
|
||||
<hr>
|
||||
|
||||
In some instances, you may want to know the status code of the response. This can be done using the following:
|
||||
|
||||
```html
|
||||
{{ if eq .Response.StatusCode 200 }}
|
||||
<p>Success!</p>
|
||||
{{ else }}
|
||||
<p>Failed to fetch data</p>
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
You can also access the response headers:
|
||||
|
||||
```html
|
||||
<div>{{ .Response.Header.Get "Content-Type" }}</div>
|
||||
```
|
||||
|
||||
<hr>
|
||||
|
||||
JSON response:
|
||||
|
||||
```json
|
||||
{"name": "Steve", "age": 30}
|
||||
{"name": "Alex", "age": 25}
|
||||
{"name": "John", "age": 35}
|
||||
```
|
||||
|
||||
The above format is "[ndjson](https://docs.mulesoft.com/dataweave/latest/dataweave-formats-ndjson)" or "[JSON Lines](https://jsonlines.org/)", where each line is a separate JSON object. To parse this format, you must first disable the JSON validation check in your config, since by default the response is expected to be a single valid JSON object:
|
||||
|
||||
```yaml
|
||||
- type: custom-api
|
||||
skip-json-validation: true
|
||||
```
|
||||
|
||||
Then, to iterate over each object you can use `.JSONLines`:
|
||||
|
||||
```html
|
||||
{{ range .JSONLines }}
|
||||
<p>{{ .String "name" }} is {{ .Int "age" }} years old</p>
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<p>Steve is 30 years old</p>
|
||||
<p>Alex is 25 years old</p>
|
||||
<p>John is 35 years old</p>
|
||||
```
|
||||
|
||||
For other ways of selecting data from a JSON Lines response, have a look at the docs for [tidwall/gjson](https://github.com/tidwall/gjson/tree/master?tab=readme-ov-file#json-lines). For example, to get an array of all names, you can use the following:
|
||||
|
||||
```html
|
||||
{{ range .JSON.Array "..#.name" }}
|
||||
<p>{{ .String "" }}</p>
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```html
|
||||
<p>Steve</p>
|
||||
<p>Alex</p>
|
||||
<p>John</p>
|
||||
```
|
||||
|
||||
<hr>
|
||||
|
||||
In some instances, you may need to make two consecutive API calls, where you use the result of the first call in the second call. To achieve this, you can make additional HTTP requests from within the template itself using the following syntax:
|
||||
|
||||
```yaml
|
||||
- type: custom-api
|
||||
url: https://api.example.com/get-id-of-something
|
||||
template: |
|
||||
{{ $theID := .JSON.String "id" }}
|
||||
|
||||
{{
|
||||
$something := newRequest (concat "https://api.example.com/something/" $theID)
|
||||
| withParameter "key" "value"
|
||||
| withHeader "Authorization" "Bearer token"
|
||||
| getResponse
|
||||
}}
|
||||
|
||||
{{ $something.JSON.String "title" }}
|
||||
```
|
||||
|
||||
Here, `$theID` gets retrieved from the result of the first API call and used in the second API call. The `newRequest` function creates a new request, and the `getResponse` function executes it. You can also use `withParameter` and `withHeader` to optionally add parameters and headers to the request.
|
||||
|
||||
If you need to make a request to a URL that requires dynamic parameters, you can omit the `url` property in the YAML and run the request entirely from within the template itself:
|
||||
|
||||
```yaml
|
||||
- type: custom-api
|
||||
title: Events from the last 24h
|
||||
template: |
|
||||
{{
|
||||
$events := newRequest "https://api.example.com/events"
|
||||
| withParameter "after" (offsetNow "-24h" | formatTime "rfc3339")
|
||||
| getResponse
|
||||
}}
|
||||
|
||||
{{ if eq $events.Response.StatusCode 200 }}
|
||||
{{ range $events.JSON.Array "events" }}
|
||||
<div>{{ .String "title" }}</div>
|
||||
<div {{ .String "date" | parseTime "rfc3339" | toRelativeTime }}></div>
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
<p>Failed to fetch data: {{ $events.Response.Status }}</p>
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
*Note that you need to manually check for the correct status code.*
|
||||
|
||||
## Functions
|
||||
|
||||
The following functions are available on the `JSON` object:
|
||||
|
||||
- `String(key string) string`: Returns the value of the key as a string.
|
||||
- `Int(key string) int`: Returns the value of the key as an integer.
|
||||
- `Float(key string) float`: Returns the value of the key as a float.
|
||||
- `Bool(key string) bool`: Returns the value of the key as a boolean.
|
||||
- `Array(key string) []JSON`: Returns the value of the key as an array of `JSON` objects.
|
||||
- `Exists(key string) bool`: Returns true if the key exists in the JSON object.
|
||||
|
||||
The following functions are available on the `Options` object:
|
||||
|
||||
- `StringOr(key string, default string) string`: Returns the value of the key as a string, or the default value if the key does not exist.
|
||||
- `IntOr(key string, default int) int`: Returns the value of the key as an integer, or the default value if the key does not exist.
|
||||
- `FloatOr(key string, default float) float`: Returns the value of the key as a float, or the default value if the key does not exist.
|
||||
- `BoolOr(key string, default bool) bool`: Returns the value of the key as a boolean, or the default value if the key does not exist.
|
||||
- `JSON(key string) JSON`: Returns the value of the key as a stringified `JSON` object, or throws an error if the key does not exist.
|
||||
|
||||
The following helper functions provided by Glance are available:
|
||||
|
||||
- `toFloat(i int) float`: Converts an integer to a float.
|
||||
- `toInt(f float) int`: Converts a float to an integer.
|
||||
- `toRelativeTime(t time.Time) template.HTMLAttr`: Converts Time to a relative time such as 2h, 1d, etc which dynamically updates. **NOTE:** the value of this function should be used as an attribute in an HTML tag, e.g. `<span {{ toRelativeTime .Time }}></span>`.
|
||||
- `now() time.Time`: Returns the current time.
|
||||
- `offsetNow(offset string) time.Time`: Returns the current time with an offset. The offset can be positive or negative and must be in the format "3h" "-1h" or "2h30m10s".
|
||||
- `duration(str string) time.Duration`: Parses a string such as `1h`, `24h`, `5h30m`, etc into a `time.Duration`.
|
||||
- `parseTime(layout string, s string) time.Time`: Parses a string into time.Time. The layout must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants). You can alternatively use these values instead of the literal format: "unix", "RFC3339", "RFC3339Nano", "DateTime", "DateOnly".
|
||||
- `formatTime(layout string, s string) time.Time`: Formats a `time.Time` into a string. The layout uses the same format as `parseTime`.
|
||||
- `parseLocalTime(layout string, s string) time.Time`: Same as the above, except in the absence of a timezone, it will use the local timezone instead of UTC.
|
||||
- `parseRelativeTime(layout string, s string) time.Time`: A shorthand for `{{ .String "date" | parseTime "rfc3339" | toRelativeTime }}`.
|
||||
- `add(a, b float) float`: Adds two numbers.
|
||||
- `sub(a, b float) float`: Subtracts two numbers.
|
||||
- `mul(a, b float) float`: Multiplies two numbers.
|
||||
- `div(a, b float) float`: Divides two numbers.
|
||||
- `mod(a, b int) int`: Remainder after dividing a by b (a % b).
|
||||
- `formatApproxNumber(n int) string`: Formats a number to be more human-readable, e.g. 1000 -> 1k.
|
||||
- `formatNumber(n float|int) string`: Formats a number with commas, e.g. 1000 -> 1,000.
|
||||
- `trimPrefix(prefix string, str string) string`: Trims the prefix from a string.
|
||||
- `trimSuffix(suffix string, str string) string`: Trims the suffix from a string.
|
||||
- `trimSpace(str string) string`: Trims whitespace from a string on both ends.
|
||||
- `replaceAll(old string, new string, str string) string`: Replaces all occurrences of a string in a string.
|
||||
- `replaceMatches(pattern string, replacement string, str string) string`: Replaces all occurrences of a regular expression in a string.
|
||||
- `findMatch(pattern string, str string) string`: Finds the first match of a regular expression in a string.
|
||||
- `findSubmatch(pattern string, str string) string`: Finds the first submatch of a regular expression in a string.
|
||||
- `sortByString(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a string key in either ascending or descending order.
|
||||
- `sortByInt(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by an integer key in either ascending or descending order.
|
||||
- `sortByFloat(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a float key in either ascending or descending order.
|
||||
- `sortByTime(key string, layout string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a time key in either ascending or descending order. The format must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants).
|
||||
- `concat(strings ...string) string`: Concatenates multiple strings together.
|
||||
- `unique(key string, arr []JSON) []JSON`: Returns a unique array of JSON objects based on the given key.
|
||||
- `percentChange(current float, previous float) float`: Calculates the percentage change between two numbers.
|
||||
- `startOfDay(t time.Time) time.Time`: Returns the start of the day for a given time.
|
||||
- `endOfDay(t time.Time) time.Time`: Returns the end of the day for a given time.
|
||||
|
||||
The following helper functions provided by Go's `text/template` are available:
|
||||
|
||||
- `eq(a, b any) bool`: Compares two values for equality.
|
||||
- `ne(a, b any) bool`: Compares two values for inequality.
|
||||
- `lt(a, b any) bool`: Compares two values for less than.
|
||||
- `le(a, b any) bool`: Compares two values for less than or equal to.
|
||||
- `gt(a, b any) bool`: Compares two values for greater than.
|
||||
- `ge(a, b any) bool`: Compares two values for greater than or equal to.
|
||||
- `and(args ...bool) bool`: Returns true if **all** arguments are true; accepts two or more boolean values.
|
||||
- `or(args ...bool) bool`: Returns true if **any** argument is true; accepts two or more boolean values.
|
||||
- `not(a bool) bool`: Returns the opposite of the value.
|
||||
- `index(a any, b int) any`: Returns the value at the specified index of an array.
|
||||
- `len(a any) int`: Returns the length of an array.
|
||||
- `printf(format string, a ...any) string`: Returns a formatted string.
|
||||
@@ -26,9 +26,15 @@ If you know how to setup an HTTP server and a bit of HTML and CSS you're ready t
|
||||
### `Widget-Title`
|
||||
Used to specify the title of the widget. If not provided, the widget's title will be "Extension".
|
||||
|
||||
### `Widget-Title-URL`
|
||||
Used to specify the URL that will be opened when the widget's title is clicked. If the user has specified a `title-url` in their config, it will take precedence over this header.
|
||||
|
||||
### `Widget-Content-Type`
|
||||
Used to specify the content type that will be returned by the extension. If not provided, the content will be shown as plain text.
|
||||
|
||||
### `Widget-Content-Frameless`
|
||||
When set to `true`, the widget's content will be displayed without the default background or "frame".
|
||||
|
||||
## Content Types
|
||||
|
||||
> [!NOTE]
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# --- Edit access ---
|
||||
# /edit is locked down by default. Pick ONE of the two blocks below.
|
||||
#
|
||||
# Option A (recommended): require login.
|
||||
# Generate values with:
|
||||
# go run . secret:make
|
||||
# go run . password:hash <your-password>
|
||||
#
|
||||
# auth:
|
||||
# secret-key: <paste base64 secret here>
|
||||
# users:
|
||||
# yourname:
|
||||
# password-hash: <paste bcrypt hash here>
|
||||
#
|
||||
# Option B: open /edit without authentication. ONLY on a trusted network —
|
||||
# anyone who can reach this server can rewrite your config.
|
||||
# (The config key is still named `admin` for backward compatibility.)
|
||||
#
|
||||
admin:
|
||||
allow-without-auth: true
|
||||
pages:
|
||||
- name: Home
|
||||
# Optionally, if you only have a single page you can hide the desktop navigation for a cleaner look
|
||||
# hide-desktop-navigation: true
|
||||
columns:
|
||||
- size: small
|
||||
widgets:
|
||||
- type: calendar
|
||||
first-day-of-week: monday
|
||||
- type: group
|
||||
widgets:
|
||||
- type: hacker-news
|
||||
- size: full
|
||||
widgets:
|
||||
- type: rss
|
||||
limit: 10
|
||||
collapse-after: 3
|
||||
cache: 12h
|
||||
feeds:
|
||||
- url: https://www.techmeme.com/feed.xml
|
||||
title: TechMeme
|
||||
- size: small
|
||||
widgets:
|
||||
- type: markets
|
||||
markets:
|
||||
- symbol: AMZN
|
||||
name: Amazon.com, Inc.
|
||||
- type: weather
|
||||
location: Houston, US
|
||||
units: imperial
|
||||
hour-format: 12h
|
||||
# Optionally hide the location from being displayed in the widget
|
||||
# hide-location: true
|
||||
|
||||
hide-location: true
|
||||
show-area-name: false
|
||||
- type: group
|
||||
widgets:
|
||||
- type: reddit
|
||||
subreddit: technology
|
||||
show-thumbnails: true
|
||||
- type: reddit
|
||||
subreddit: selfhosted
|
||||
show-thumbnails: true
|
||||
|
||||
# Add more pages here:
|
||||
# - name: Your page name
|
||||
# columns:
|
||||
# - size: small
|
||||
# widgets:
|
||||
# # Add widgets here
|
||||
|
||||
# - size: full
|
||||
# widgets:
|
||||
# # Add widgets here
|
||||
|
||||
# - size: small
|
||||
# widgets:
|
||||
# # Add widgets here
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 946 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 637 KiB |
|
Before Width: | Height: | Size: 261 KiB After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 362 KiB |
|
Before Width: | Height: | Size: 370 KiB After Width: | Height: | Size: 482 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 792 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 325 KiB |
|
After Width: | Height: | Size: 339 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 549 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 367 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,226 @@
|
||||
# Preconfigured pages
|
||||
|
||||
Don't want to spend time configuring pages from scratch? No problem! Simply copy the config from the ones below.
|
||||
|
||||
Pull requests with your page configurations are welcome!
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> Pages must be placed under a top level `pages:` key, you can read more about that [here](configuration.md#pages).
|
||||
|
||||
## Startpage
|
||||
|
||||

|
||||
|
||||
<details>
|
||||
<summary>View config (requires Glance <code>v0.6.0</code> or higher)</summary>
|
||||
|
||||
```yaml
|
||||
- name: Startpage
|
||||
width: slim
|
||||
hide-desktop-navigation: true
|
||||
center-vertically: true
|
||||
columns:
|
||||
- size: full
|
||||
widgets:
|
||||
- type: search
|
||||
autofocus: true
|
||||
|
||||
- type: monitor
|
||||
cache: 1m
|
||||
title: Services
|
||||
sites:
|
||||
- title: Jellyfin
|
||||
url: https://yourdomain.com/
|
||||
icon: si:jellyfin
|
||||
- title: Gitea
|
||||
url: https://yourdomain.com/
|
||||
icon: si:gitea
|
||||
- title: qBittorrent # only for Linux ISOs, of course
|
||||
url: https://yourdomain.com/
|
||||
icon: si:qbittorrent
|
||||
- title: Immich
|
||||
url: https://yourdomain.com/
|
||||
icon: si:immich
|
||||
- title: AdGuard Home
|
||||
url: https://yourdomain.com/
|
||||
icon: si:adguard
|
||||
- title: Vaultwarden
|
||||
url: https://yourdomain.com/
|
||||
icon: si:vaultwarden
|
||||
|
||||
- type: bookmarks
|
||||
groups:
|
||||
- title: General
|
||||
links:
|
||||
- title: Gmail
|
||||
url: https://mail.google.com/mail/u/0/
|
||||
- title: Amazon
|
||||
url: https://www.amazon.com/
|
||||
- title: Github
|
||||
url: https://github.com/
|
||||
- title: Entertainment
|
||||
links:
|
||||
- title: YouTube
|
||||
url: https://www.youtube.com/
|
||||
- title: Prime Video
|
||||
url: https://www.primevideo.com/
|
||||
- title: Disney+
|
||||
url: https://www.disneyplus.com/
|
||||
- title: Social
|
||||
links:
|
||||
- title: Reddit
|
||||
url: https://www.reddit.com/
|
||||
- title: Twitter
|
||||
url: https://twitter.com/
|
||||
- title: Instagram
|
||||
url: https://www.instagram.com/
|
||||
```
|
||||
</details>
|
||||
|
||||
## Markets
|
||||
|
||||

|
||||
|
||||
<details>
|
||||
<summary>View config (requires Glance <code>v0.6.0</code> or higher)</summary>
|
||||
|
||||
```yaml
|
||||
- name: Markets
|
||||
columns:
|
||||
- size: small
|
||||
widgets:
|
||||
- type: markets
|
||||
title: Indices
|
||||
markets:
|
||||
- symbol: SPY
|
||||
name: S&P 500
|
||||
- symbol: DX-Y.NYB
|
||||
name: Dollar Index
|
||||
|
||||
- type: markets
|
||||
title: Crypto
|
||||
markets:
|
||||
- symbol: BTC-USD
|
||||
name: Bitcoin
|
||||
- symbol: ETH-USD
|
||||
name: Ethereum
|
||||
|
||||
- type: markets
|
||||
title: Stocks
|
||||
sort-by: absolute-change
|
||||
markets:
|
||||
- symbol: NVDA
|
||||
name: NVIDIA
|
||||
- symbol: AAPL
|
||||
name: Apple
|
||||
- symbol: MSFT
|
||||
name: Microsoft
|
||||
- symbol: GOOGL
|
||||
name: Google
|
||||
- symbol: AMD
|
||||
name: AMD
|
||||
- symbol: RDDT
|
||||
name: Reddit
|
||||
- symbol: AMZN
|
||||
name: Amazon
|
||||
- symbol: TSLA
|
||||
name: Tesla
|
||||
- symbol: INTC
|
||||
name: Intel
|
||||
- symbol: META
|
||||
name: Meta
|
||||
|
||||
- size: full
|
||||
widgets:
|
||||
- type: rss
|
||||
title: News
|
||||
style: horizontal-cards
|
||||
feeds:
|
||||
- url: https://feeds.bloomberg.com/markets/news.rss
|
||||
title: Bloomberg
|
||||
- url: https://moxie.foxbusiness.com/google-publisher/markets.xml
|
||||
title: Fox Business
|
||||
- url: https://moxie.foxbusiness.com/google-publisher/technology.xml
|
||||
title: Fox Business
|
||||
|
||||
- type: group
|
||||
widgets:
|
||||
- type: reddit
|
||||
show-thumbnails: true
|
||||
subreddit: technology
|
||||
- type: reddit
|
||||
show-thumbnails: true
|
||||
subreddit: wallstreetbets
|
||||
|
||||
- type: videos
|
||||
style: grid-cards
|
||||
collapse-after-rows: 3
|
||||
channels:
|
||||
- UCvSXMi2LebwJEM1s4bz5IBA # New Money
|
||||
- UCV6KDgJskWaEckne5aPA0aQ # Graham Stephan
|
||||
- UCAzhpt9DmG6PnHXjmJTvRGQ # Federal Reserve
|
||||
|
||||
- size: small
|
||||
widgets:
|
||||
- type: rss
|
||||
title: News
|
||||
limit: 30
|
||||
collapse-after: 13
|
||||
feeds:
|
||||
- url: https://www.ft.com/technology?format=rss
|
||||
title: Financial Times
|
||||
- url: https://feeds.a.dj.com/rss/RSSMarketsMain.xml
|
||||
title: Wall Street Journal
|
||||
```
|
||||
</details>
|
||||
|
||||
## Gaming
|
||||
|
||||

|
||||
|
||||
<details>
|
||||
<summary>View config (requires Glance <code>v0.6.0</code> or higher)</summary>
|
||||
|
||||
```yaml
|
||||
- name: Gaming
|
||||
columns:
|
||||
- size: small
|
||||
widgets:
|
||||
- type: twitch-top-games
|
||||
limit: 20
|
||||
collapse-after: 13
|
||||
exclude:
|
||||
- just-chatting
|
||||
- pools-hot-tubs-and-beaches
|
||||
- music
|
||||
- art
|
||||
- asmr
|
||||
|
||||
- size: full
|
||||
widgets:
|
||||
- type: group
|
||||
widgets:
|
||||
- type: reddit
|
||||
show-thumbnails: true
|
||||
subreddit: pcgaming
|
||||
- type: reddit
|
||||
subreddit: games
|
||||
|
||||
- type: videos
|
||||
style: grid-cards
|
||||
collapse-after-rows: 3
|
||||
channels:
|
||||
- UCNvzD7Z-g64bPXxGzaQaa4g # gameranx
|
||||
- UCZ7AeeVbyslLM_8-nVy2B8Q # Skill Up
|
||||
- UCHDxYLv8iovIbhrfl16CNyg # GameLinked
|
||||
- UC9PBzalIcEQCsiIkq36PyUA # Digital Foundry
|
||||
|
||||
- size: small
|
||||
widgets:
|
||||
- type: reddit
|
||||
subreddit: gamingnews
|
||||
limit: 7
|
||||
style: vertical-cards
|
||||
```
|
||||
</details>
|
||||
@@ -53,6 +53,26 @@ theme:
|
||||
primary-color: 97 13 80
|
||||
```
|
||||
|
||||
### Gruvbox Dark
|
||||

|
||||
```yaml
|
||||
theme:
|
||||
background-color: 0 0 16
|
||||
primary-color: 43 59 81
|
||||
positive-color: 61 66 44
|
||||
negative-color: 6 96 59
|
||||
```
|
||||
|
||||
### Kanagawa Dark
|
||||

|
||||
```yaml
|
||||
theme:
|
||||
background-color: 240 13 14
|
||||
primary-color: 51 33 68
|
||||
negative-color: 358 100 68
|
||||
contrast-multiplier: 1.2
|
||||
```
|
||||
|
||||
### Tucan
|
||||

|
||||
```yaml
|
||||
@@ -62,6 +82,39 @@ theme:
|
||||
negative-color: 209 88 54
|
||||
```
|
||||
|
||||
### Dracula
|
||||

|
||||
```yaml
|
||||
theme:
|
||||
background-color: 231 15 21
|
||||
primary-color: 265 89 79
|
||||
contrast-multiplier: 1.2
|
||||
positive-color: 135 94 66
|
||||
negative-color: 0 100 67
|
||||
```
|
||||
|
||||
### Shades of Purple
|
||||

|
||||
```yaml
|
||||
theme:
|
||||
background-color: 243 33 25
|
||||
contrast-multiplier: 1.2
|
||||
primary-color: 50 100 49
|
||||
positive-color: 98 82 71
|
||||
negative-color: 12 77 52
|
||||
```
|
||||
|
||||
### Neon Pink
|
||||

|
||||
```yaml
|
||||
theme:
|
||||
background-color: 240 27 11
|
||||
contrast-multiplier: 1.5
|
||||
primary-color: 321 100 71
|
||||
positive-color: 165 78 51
|
||||
negative-color: 360 100 71
|
||||
```
|
||||
|
||||
## Light
|
||||
|
||||
### Catppuccin Latte
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
## Upgrading to v0.7.0 from previous versions
|
||||
|
||||
In essence, the `glance.yml` file has been moved from the root of the project to a `config/` directory and you now need to mount that directory to `/app/config` in the container.
|
||||
|
||||
### Before
|
||||
|
||||
Versions before v0.7.0 used a `docker-compose.yml` that looked like the following:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
glance:
|
||||
image: glanceapp/glance
|
||||
volumes:
|
||||
- ./glance.yml:/app/glance.yml
|
||||
ports:
|
||||
- 8080:8080
|
||||
```
|
||||
|
||||
And expected you to have the following directory structure:
|
||||
|
||||
```plaintext
|
||||
glance/
|
||||
docker-compose.yml
|
||||
glance.yml
|
||||
```
|
||||
|
||||
### After
|
||||
|
||||
With the release of v0.7.0, the recommended `docker-compose.yml` looks like the following:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
glance:
|
||||
container_name: glance
|
||||
image: glanceapp/glance
|
||||
volumes:
|
||||
- ./config:/app/config
|
||||
ports:
|
||||
- 8080:8080
|
||||
```
|
||||
|
||||
And expects you to have the following directory structure:
|
||||
|
||||
```plaintext
|
||||
glance/
|
||||
docker-compose.yml
|
||||
config/
|
||||
glance.yml
|
||||
```
|
||||
|
||||
## Why this change was necessary
|
||||
|
||||
1. Mounting a file rather than a directory is not common practice and leads to some issues, such as creating a directory if the file is not present, which has tripped up multiple people and caused unnecessary confusion
|
||||
2. v0.7.0 added automatic reloads when the configuration file changes, which based on testing didn't work when mounting a single file
|
||||
3. v0.7.0 added the ability to include config files, so you'd have to make this change anyways if you wanted to take advantage of that feature
|
||||
|
||||
Taking all of these into account, it felt like the right time to implement the change.
|
||||
@@ -1,19 +1,33 @@
|
||||
module github.com/glanceapp/glance
|
||||
|
||||
go 1.22.3
|
||||
go 1.24.3
|
||||
|
||||
require (
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/mmcdole/gofeed v1.3.0
|
||||
golang.org/x/text v0.14.0
|
||||
github.com/shirou/gopsutil/v4 v4.25.4
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
golang.org/x/crypto v0.38.0
|
||||
golang.org/x/text v0.25.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.9.1 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.10.3 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/ebitengine/purego v0.8.4 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
|
||||
github.com/mmcdole/goxpp v1.1.1 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
golang.org/x/net v0.24.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
golang.org/x/net v0.40.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI=
|
||||
github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY=
|
||||
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
|
||||
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
||||
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
|
||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
|
||||
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
|
||||
github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8=
|
||||
@@ -19,47 +31,101 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/shirou/gopsutil/v4 v4.25.4 h1:cdtFO363VEOOFrUCjZRh4XVJkb548lyF0q0uTeMqYPw=
|
||||
github.com/shirou/gopsutil/v4 v4.25.4/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
|
||||
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
var _publicFS embed.FS
|
||||
|
||||
//go:embed templates
|
||||
var _templateFS embed.FS
|
||||
|
||||
var PublicFS, _ = fs.Sub(_publicFS, "static")
|
||||
var TemplateFS, _ = fs.Sub(_templateFS, "templates")
|
||||
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"name": "Glance",
|
||||
"display": "standalone",
|
||||
"background_color": "#151519",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/app-icon.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/message"
|
||||
)
|
||||
|
||||
var (
|
||||
PageTemplate = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl")
|
||||
PageContentTemplate = compileTemplate("content.html")
|
||||
CalendarTemplate = compileTemplate("calendar.html", "widget-base.html")
|
||||
ClockTemplate = compileTemplate("clock.html", "widget-base.html")
|
||||
BookmarksTemplate = compileTemplate("bookmarks.html", "widget-base.html")
|
||||
IFrameTemplate = compileTemplate("iframe.html", "widget-base.html")
|
||||
WeatherTemplate = compileTemplate("weather.html", "widget-base.html")
|
||||
ForumPostsTemplate = compileTemplate("forum-posts.html", "widget-base.html")
|
||||
RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html")
|
||||
RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html")
|
||||
ReleasesTemplate = compileTemplate("releases.html", "widget-base.html")
|
||||
ChangeDetectionTemplate = compileTemplate("change-detection.html", "widget-base.html")
|
||||
VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html")
|
||||
VideosGridTemplate = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html")
|
||||
MarketsTemplate = compileTemplate("markets.html", "widget-base.html")
|
||||
RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html")
|
||||
RSSDetailedListTemplate = compileTemplate("rss-detailed-list.html", "widget-base.html")
|
||||
RSSHorizontalCardsTemplate = compileTemplate("rss-horizontal-cards.html", "widget-base.html")
|
||||
RSSHorizontalCards2Template = compileTemplate("rss-horizontal-cards-2.html", "widget-base.html")
|
||||
MonitorTemplate = compileTemplate("monitor.html", "widget-base.html")
|
||||
TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html")
|
||||
TwitchChannelsTemplate = compileTemplate("twitch-channels.html", "widget-base.html")
|
||||
RepositoryTemplate = compileTemplate("repository.html", "widget-base.html")
|
||||
SearchTemplate = compileTemplate("search.html", "widget-base.html")
|
||||
ExtensionTemplate = compileTemplate("extension.html", "widget-base.html")
|
||||
)
|
||||
|
||||
var globalTemplateFunctions = template.FuncMap{
|
||||
"relativeTime": relativeTimeSince,
|
||||
"formatViewerCount": formatViewerCount,
|
||||
"formatNumber": intl.Sprint,
|
||||
"absInt": func(i int) int {
|
||||
return int(math.Abs(float64(i)))
|
||||
},
|
||||
"formatPrice": func(price float64) string {
|
||||
return intl.Sprintf("%.2f", price)
|
||||
},
|
||||
"formatTime": func(t time.Time) string {
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
},
|
||||
"shouldCollapse": func(i int, collapseAfter int) bool {
|
||||
if collapseAfter < -1 {
|
||||
return false
|
||||
}
|
||||
|
||||
return i >= collapseAfter
|
||||
},
|
||||
"itemAnimationDelay": func(i int, collapseAfter int) string {
|
||||
return fmt.Sprintf("%dms", (i-collapseAfter)*30)
|
||||
},
|
||||
"dynamicRelativeTimeAttrs": func(t time.Time) template.HTMLAttr {
|
||||
return template.HTMLAttr(fmt.Sprintf(`data-dynamic-relative-time="%d"`, t.Unix()))
|
||||
},
|
||||
}
|
||||
|
||||
func compileTemplate(primary string, dependencies ...string) *template.Template {
|
||||
t, err := template.New(primary).
|
||||
Funcs(globalTemplateFunctions).
|
||||
ParseFS(TemplateFS, append([]string{primary}, dependencies...)...)
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
var intl = message.NewPrinter(language.English)
|
||||
|
||||
func formatViewerCount(count int) string {
|
||||
if count < 1_000 {
|
||||
return strconv.Itoa(count)
|
||||
}
|
||||
|
||||
if count < 10_000 {
|
||||
return fmt.Sprintf("%.1fk", float64(count)/1_000)
|
||||
}
|
||||
|
||||
if count < 1_000_000 {
|
||||
return fmt.Sprintf("%dk", count/1_000)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%.1fm", float64(count)/1_000_000)
|
||||
}
|
||||
|
||||
func relativeTimeSince(t time.Time) string {
|
||||
delta := time.Since(t)
|
||||
|
||||
if delta < time.Minute {
|
||||
return "1m"
|
||||
}
|
||||
if delta < time.Hour {
|
||||
return fmt.Sprintf("%dm", delta/time.Minute)
|
||||
}
|
||||
if delta < 24*time.Hour {
|
||||
return fmt.Sprintf("%dh", delta/time.Hour)
|
||||
}
|
||||
if delta < 30*24*time.Hour {
|
||||
return fmt.Sprintf("%dd", delta/(24*time.Hour))
|
||||
}
|
||||
if delta < 12*30*24*time.Hour {
|
||||
return fmt.Sprintf("%dmo", delta/(30*24*time.Hour))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%dy", delta/(365*24*time.Hour))
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
{{ if ne .Style "dynamic-columns-experimental" }}
|
||||
<ul class="list list-gap-24 list-with-separator">
|
||||
{{ range .Groups }}
|
||||
<li class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.AsCSSValue }}"{{ end }}>
|
||||
{{ template "group" . }}
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ else }}
|
||||
<div class="dynamic-columns">
|
||||
{{ range .Groups }}
|
||||
<div class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.AsCSSValue }}"{{ end }}>
|
||||
{{ template "group" . }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ define "group" }}
|
||||
{{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }}
|
||||
<ul class="list list-gap-2">
|
||||
{{ range .Links }}
|
||||
<li class="flex items-center gap-10">
|
||||
{{ if ne "" .Icon }}
|
||||
<div class="bookmarks-icon-container">
|
||||
<img class="bookmarks-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .Icon }}" alt="" loading="lazy">
|
||||
</div>
|
||||
{{ end }}
|
||||
<a href="{{ .URL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
@@ -1,27 +0,0 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="color-highlight size-h1">{{ .Calendar.CurrentMonthName }}</div>
|
||||
<ul class="list-horizontal-text color-highlight size-h4">
|
||||
<li>Week {{ .Calendar.CurrentWeekNumber }}</li>
|
||||
<li>{{ .Calendar.CurrentYear }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap size-h6 margin-top-10 color-subdue">
|
||||
<div class="calendar-day">Mo</div>
|
||||
<div class="calendar-day">Tu</div>
|
||||
<div class="calendar-day">We</div>
|
||||
<div class="calendar-day">Th</div>
|
||||
<div class="calendar-day">Fr</div>
|
||||
<div class="calendar-day">Sa</div>
|
||||
<div class="calendar-day">Su</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap">
|
||||
{{ range .Calendar.Days }}
|
||||
<div class="calendar-day{{ if eq . $.Calendar.CurrentDay }} calendar-day-today{{ end }}">{{ . }}</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
@@ -1,13 +0,0 @@
|
||||
{{ if .Page.ShowMobileHeader }}
|
||||
<div class="mobile-reachability-header">{{ .Page.Title }}</div>
|
||||
{{ end }}
|
||||
|
||||
<div class="page-columns">
|
||||
{{ range .Page.Columns }}
|
||||
<div class="page-column page-column-{{ .Size }}">
|
||||
{{ range .Widgets }}
|
||||
{{ .Render }}
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
@@ -1,25 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html {{ block "document-root-attrs" . }}{{ end }} lang="en" id="top">
|
||||
<head>
|
||||
{{ block "document-head-before" . }}{{ end }}
|
||||
<title>{{ block "document-title" . }}{{ end }}</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Glance">
|
||||
<meta name="theme-color" content="{{ if ne nil .App.Config.Theme.BackgroundColor }}{{ .App.Config.Theme.BackgroundColor }}{{ else }}hsl(240, 8%, 9%){{ end }}">
|
||||
<link rel="apple-touch-icon" sizes="512x512" href="/static/app-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="50x50" href="/static/favicon.png">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
||||
<link rel="stylesheet" href="/static/main.css?v={{ .App.Config.Server.StartedAt.Unix }}">
|
||||
<script async src="/static/main.js?v={{ .App.Config.Server.StartedAt.Unix }}"></script>
|
||||
{{ block "document-head-after" . }}{{ end }}
|
||||
</head>
|
||||
<body>
|
||||
{{ template "document-body" . }}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,45 +0,0 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||
{{ range .Posts }}
|
||||
<li>
|
||||
<div class="flex gap-10 row-reverse-on-mobile thumbnail-parent">
|
||||
{{ if $.ShowThumbnails }}
|
||||
{{ if ne .ThumbnailUrl "" }}
|
||||
<img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
|
||||
{{ else if .HasTargetUrl }}
|
||||
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
||||
</svg>
|
||||
{{ else }}
|
||||
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
|
||||
</svg>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
<div class="grow min-width-0">
|
||||
<a href="{{ .DiscussionUrl }}" class="size-h3 color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
|
||||
{{ if gt (len .Tags) 0 }}
|
||||
<div class="inline-block forum-post-tags-container">
|
||||
<ul class="attachments">
|
||||
{{ range .Tags }}
|
||||
<li>{{ . }}</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
{{ end }}
|
||||
<ul class="list-horizontal-text">
|
||||
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||
<li>{{ .Score | formatNumber }} points</li>
|
||||
<li>{{ .CommentCount | formatNumber }} comments</li>
|
||||
{{ if .HasTargetUrl }}
|
||||
<li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
@@ -1,39 +0,0 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
{{ if ne .Style "dynamic-columns-experimental" }}
|
||||
<ul class="list list-gap-20 list-with-separator">
|
||||
{{ range .Markets }}
|
||||
<li class="flex items-center gap-15">
|
||||
{{ template "market" . }}
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ else }}
|
||||
<div class="dynamic-columns">
|
||||
{{ range .Markets }}
|
||||
<div class="flex items-center gap-15">
|
||||
{{ template "market" . }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ define "market" }}
|
||||
<div class="min-width-0">
|
||||
<a{{ if ne "" .SymbolLink }} href="{{ .SymbolLink }}" target="_blank" rel="noreferrer"{{ end }} class="color-highlight size-h3 block text-truncate">{{ .Symbol }}</a>
|
||||
<div class="text-truncate">{{ .Name }}</div>
|
||||
</div>
|
||||
|
||||
<a class="market-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>
|
||||
<svg class="market-chart shrink-0" viewBox="0 0 100 50">
|
||||
<polyline fill="none" stroke="var(--color-text-subdue)" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<div class="market-values shrink-0">
|
||||
<div class="size-h3 text-right {{ if eq .PercentChange 0.0 }}{{ else if gt .PercentChange 0.0 }}color-positive{{ else }}color-negative{{ end }}">{{ printf "%+.2f" .PercentChange }}%</div>
|
||||
<div class="text-right">{{ .Currency }}{{ .Price | formatPrice }}</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
@@ -1,53 +0,0 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
{{ if ne .Style "dynamic-columns-experimental" }}
|
||||
<ul class="list list-gap-20 list-with-separator">
|
||||
{{ range .Sites }}
|
||||
<li class="monitor-site flex items-center gap-15">
|
||||
{{ template "site" . }}
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ else }}
|
||||
<ul class="dynamic-columns">
|
||||
{{ range .Sites }}
|
||||
<div class="flex items-center gap-15">
|
||||
{{ template "site" . }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ define "site" }}
|
||||
{{ if .IconUrl }}
|
||||
<img class="monitor-site-icon{{ if .IsSimpleIcon }} simple-icon{{ end }}" src="{{ .IconUrl }}" alt="" loading="lazy">
|
||||
{{ end }}
|
||||
<div>
|
||||
<a class="size-h3 color-highlight" href="{{ .URL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
|
||||
<ul class="list-horizontal-text">
|
||||
{{ if not .Status.Error }}
|
||||
<li title="{{ .Status.Code }}">{{ .StatusText }}</li>
|
||||
<li>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</li>
|
||||
{{ else if .Status.TimedOut }}
|
||||
<li class="color-negative">Timed Out</li>
|
||||
{{ else }}
|
||||
<li class="color-negative" title="{{ .Status.Error }}">ERROR</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
{{ if eq .StatusStyle "ok" }}
|
||||
<div class="monitor-site-status-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
|
||||
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="monitor-site-status-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-negative)">
|
||||
<path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
@@ -1,14 +0,0 @@
|
||||
<style>
|
||||
:root {
|
||||
{{ if .App.Config.Theme.BackgroundColor }}
|
||||
--bgh: {{ .App.Config.Theme.BackgroundColor.Hue }};
|
||||
--bgs: {{ .App.Config.Theme.BackgroundColor.Saturation }}%;
|
||||
--bgl: {{ .App.Config.Theme.BackgroundColor.Lightness }}%;
|
||||
{{ end }}
|
||||
{{ if ne 0.0 .App.Config.Theme.ContrastMultiplier }}--cm: {{ .App.Config.Theme.ContrastMultiplier }};{{ end }}
|
||||
{{ if ne 0.0 .App.Config.Theme.TextSaturationMultiplier }}--tsm: {{ .App.Config.Theme.TextSaturationMultiplier }};{{ end }}
|
||||
{{ if .App.Config.Theme.PrimaryColor }}--color-primary: {{ .App.Config.Theme.PrimaryColor.AsCSSValue }};{{ end }}
|
||||
{{ if .App.Config.Theme.PositiveColor }}--color-positive: {{ .App.Config.Theme.PositiveColor.AsCSSValue }};{{ end }}
|
||||
{{ if .App.Config.Theme.NegativeColor }}--color-negative: {{ .App.Config.Theme.NegativeColor.AsCSSValue }};{{ end }}
|
||||
}
|
||||
</style>
|
||||
@@ -1,67 +0,0 @@
|
||||
{{ template "document.html" . }}
|
||||
|
||||
{{ define "document-title" }}{{ .Page.Title }} - Glance{{ end }}
|
||||
|
||||
{{ define "document-head-before" }}
|
||||
<script>
|
||||
const pageData = {
|
||||
slug: "{{ .Page.Slug }}",
|
||||
};
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
{{ define "document-root-attrs" }}{{ if .App.Config.Theme.Light }}class="light-scheme"{{ end }}{{ end }}
|
||||
{{ define "document-head-after" }}
|
||||
{{ template "page-style-overrides.gotmpl" . }}
|
||||
{{ if ne "" .App.Config.Theme.CustomCSSFile }}
|
||||
<link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.Config.Server.StartedAt.Unix }}">
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ define "navigation-links" }}
|
||||
{{ range .App.Config.Pages }}
|
||||
<a href="/{{ .Slug }}" class="nav-item{{ if eq .Slug $.Page.Slug }} nav-item-current{{ end }}">{{ .Title }}</a>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ define "document-body" }}
|
||||
<div class="header-container content-bounds">
|
||||
<div class="header flex padding-inline-widget widget-content-frame">
|
||||
<!-- TODO: Replace G with actual logo, first need an actual logo -->
|
||||
<div class="logo">G</div>
|
||||
<div class="nav flex grow">
|
||||
{{ template "navigation-links" . }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mobile-navigation">
|
||||
<div class="mobile-navigation-icons">
|
||||
<a class="mobile-navigation-label" href="#top">↑</a>
|
||||
{{ range $i, $column := .Page.Columns }}
|
||||
<label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq "full" $column.Size }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
|
||||
{{ end }}
|
||||
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"><div class="hamburger-icon"></div></label>
|
||||
</div>
|
||||
<div class="mobile-navigation-page-links">
|
||||
{{ template "navigation-links" . }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-bounds">
|
||||
<div class="page" id="page">
|
||||
<div class="page-content" id="page-content"></div>
|
||||
<div class="page-loading-container">
|
||||
<!-- TODO: add a bigger/better loading indicator -->
|
||||
<div class="loading-icon"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer flex items-center flex-column">
|
||||
<div>
|
||||
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
|
||||
</div>
|
||||
<a class="color-primary block margin-top-5 size-h5" href="https://github.com/glanceapp/glance/issues" target="_blank" rel="noreferrer">Report issue</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
@@ -1,18 +0,0 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||
{{ range $i, $release := .Releases }}
|
||||
<li>
|
||||
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ $release.NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
||||
<ul class="list-horizontal-text">
|
||||
<li {{ dynamicRelativeTimeAttrs $release.TimeReleased }}></li>
|
||||
<li>{{ $release.Version }}</li>
|
||||
{{ if gt $release.Downvotes 3 }}
|
||||
<li>{{ $release.Downvotes | formatNumber }} ⚠</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
@@ -1,44 +0,0 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<a class="size-h4 color-highlight" href="https://github.com/{{ $.RepositoryDetails.Name }}" target="_blank" rel="noreferrer">{{ .RepositoryDetails.Name }}</a>
|
||||
<ul class="list-horizontal-text">
|
||||
<li>{{ .RepositoryDetails.Stars | formatNumber }} stars</li>
|
||||
<li>{{ .RepositoryDetails.Forks | formatNumber }} forks</li>
|
||||
</ul>
|
||||
|
||||
{{ if gt (len .RepositoryDetails.PullRequests) 0 }}
|
||||
<hr class="margin-block-10">
|
||||
<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/pulls" target="_blank" rel="noreferrer">Open pull requests ({{ .RepositoryDetails.OpenPullRequests | formatNumber }} total)</a>
|
||||
<div class="flex gap-7 size-h5 margin-top-3">
|
||||
<ul class="list list-gap-2">
|
||||
{{ range .RepositoryDetails.PullRequests }}
|
||||
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
<ul class="list list-gap-2 min-width-0">
|
||||
{{ range .RepositoryDetails.PullRequests }}
|
||||
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/pull/{{ .Number }}">{{ .Title }}</a></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ if gt (len .RepositoryDetails.Issues) 0 }}
|
||||
<hr class="margin-block-10">
|
||||
<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/issues" target="_blank" rel="noreferrer">Open issues ({{ .RepositoryDetails.OpenIssues | formatNumber }} total)</a>
|
||||
<div class="flex gap-7 size-h5 margin-top-3">
|
||||
<ul class="list list-gap-2">
|
||||
{{ range .RepositoryDetails.Issues }}
|
||||
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
<ul class="list list-gap-2 min-width-0">
|
||||
{{ range .RepositoryDetails.Issues }}
|
||||
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/issues/{{ .Number }}">{{ .Title }}</a></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
||||
@@ -1,29 +0,0 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<div class="size-h2 color-highlight text-center">{{ .Weather.WeatherCodeAsString }}</div>
|
||||
<div class="size-h4 text-center">Feels like {{ .Weather.ApparentTemperature }}°{{ if eq .Units "metric" }}C{{ else }}F{{ end }}</div>
|
||||
|
||||
<div class="weather-columns flex margin-top-15 justify-center">
|
||||
{{ range $i, $column := .Weather.Columns }}
|
||||
<div class="weather-column{{ if eq $i $.Weather.CurrentColumn }} weather-column-current{{ end }}">
|
||||
{{ if $column.HasPrecipitation }}
|
||||
<div class="weather-column-rain"></div>
|
||||
{{ end }}
|
||||
{{ if and (ge $i $.Weather.SunriseColumn) (le $i $.Weather.SunsetColumn ) }}
|
||||
<div class="weather-column-daylight{{ if eq $i $.Weather.SunriseColumn }} weather-column-daylight-sunrise{{ else if eq $i $.Weather.SunsetColumn }} weather-column-daylight-sunset{{ end }}"></div>
|
||||
{{ end }}
|
||||
<div class="weather-column-value{{ if lt $column.Temperature 0 }} weather-column-value-negative{{ end }}">{{ $column.Temperature | absInt }}</div>
|
||||
<div class="weather-bar" style='--weather-bar-height: {{ printf "%.2f" $column.Scale }}'></div>
|
||||
<div class="weather-column-time">{{ index $.TimeLabels $i }}</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
{{ if not .HideLocation }}
|
||||
<div class="flex items-center justify-center margin-top-15 gap-7 size-h5">
|
||||
<div class="location-icon"></div>
|
||||
<div class="text-truncate">{{ .Place.Name }},{{ if .ShowAreaName }} {{ .Place.Area }},{{ end }} {{ .Place.Country }}</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
@@ -1,21 +0,0 @@
|
||||
<div class="widget widget-type-{{ .GetType }}">
|
||||
<div class="widget-header">
|
||||
<div class="uppercase">{{ .Title }}</div>
|
||||
{{ if and .Error .ContentAvailable }}
|
||||
<div class="notice-icon notice-icon-major" title="{{ .Error }}"></div>
|
||||
{{ else if .Notice }}
|
||||
<div class="notice-icon notice-icon-minor" title="{{ .Notice }}"></div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="widget-content {{ if .ContentAvailable }}{{ block "widget-content-classes" . }}{{ end }}{{ end }}">
|
||||
{{ if .ContentAvailable }}
|
||||
{{ block "widget-content" . }}{{ end }}
|
||||
{{ else }}
|
||||
<div class="widget-error-header">
|
||||
<div class="color-negative size-h3">ERROR</div>
|
||||
<div class="widget-error-icon"></div>
|
||||
</div>
|
||||
<p class="break-all">{{ if .Error }}{{ .Error }}{{ else }}No error information provided{{ end }}</p>
|
||||
{{ end}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,53 +0,0 @@
|
||||
package feed
|
||||
|
||||
import "time"
|
||||
|
||||
// TODO: very inflexible, refactor to allow more customizability
|
||||
// TODO: allow changing first day of week
|
||||
// TODO: allow changing between showing the previous and next week and the entire month
|
||||
func NewCalendar(now time.Time) *Calendar {
|
||||
year, week := now.ISOWeek()
|
||||
weekday := now.Weekday()
|
||||
|
||||
if weekday == 0 {
|
||||
weekday = 7
|
||||
}
|
||||
|
||||
currentMonthDays := daysInMonth(now.Month(), year)
|
||||
|
||||
var previousMonthDays int
|
||||
|
||||
if previousMonthNumber := now.Month() - 1; previousMonthNumber < 1 {
|
||||
previousMonthDays = daysInMonth(12, year-1)
|
||||
} else {
|
||||
previousMonthDays = daysInMonth(previousMonthNumber, year)
|
||||
}
|
||||
|
||||
startDaysFrom := now.Day() - int(weekday+6)
|
||||
|
||||
days := make([]int, 21)
|
||||
|
||||
for i := 0; i < 21; i++ {
|
||||
day := startDaysFrom + i
|
||||
|
||||
if day < 1 {
|
||||
day = previousMonthDays + day
|
||||
} else if day > currentMonthDays {
|
||||
day = day - currentMonthDays
|
||||
}
|
||||
|
||||
days[i] = day
|
||||
}
|
||||
|
||||
return &Calendar{
|
||||
CurrentDay: now.Day(),
|
||||
CurrentWeekNumber: week,
|
||||
CurrentMonthName: now.Month().String(),
|
||||
CurrentYear: year,
|
||||
Days: days,
|
||||
}
|
||||
}
|
||||
|
||||
func daysInMonth(m time.Month, year int) int {
|
||||
return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day()
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"html/template"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type ExtensionType int
|
||||
|
||||
const (
|
||||
ExtensionContentHTML ExtensionType = iota
|
||||
ExtensionContentUnknown = iota
|
||||
)
|
||||
|
||||
var ExtensionStringToType = map[string]ExtensionType{
|
||||
"html": ExtensionContentHTML,
|
||||
}
|
||||
|
||||
const (
|
||||
ExtensionHeaderTitle = "Widget-Title"
|
||||
ExtensionHeaderContentType = "Widget-Content-Type"
|
||||
)
|
||||
|
||||
type ExtensionRequestOptions struct {
|
||||
URL string `yaml:"url"`
|
||||
Parameters map[string]string `yaml:"parameters"`
|
||||
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
|
||||
}
|
||||
|
||||
type Extension struct {
|
||||
Title string
|
||||
Content template.HTML
|
||||
}
|
||||
|
||||
func convertExtensionContent(options ExtensionRequestOptions, content []byte, contentType ExtensionType) template.HTML {
|
||||
switch contentType {
|
||||
case ExtensionContentHTML:
|
||||
if options.AllowHtml {
|
||||
return template.HTML(content)
|
||||
}
|
||||
|
||||
fallthrough
|
||||
default:
|
||||
return template.HTML(html.EscapeString(string(content)))
|
||||
}
|
||||
}
|
||||
|
||||
func FetchExtension(options ExtensionRequestOptions) (Extension, error) {
|
||||
request, _ := http.NewRequest("GET", options.URL, nil)
|
||||
|
||||
query := url.Values{}
|
||||
|
||||
for key, value := range options.Parameters {
|
||||
query.Set(key, value)
|
||||
}
|
||||
|
||||
request.URL.RawQuery = query.Encode()
|
||||
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
|
||||
if err != nil {
|
||||
slog.Error("failed fetching extension", "error", err, "url", options.URL)
|
||||
return Extension{}, fmt.Errorf("%w: request failed: %w", ErrNoContent, err)
|
||||
}
|
||||
|
||||
defer response.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(response.Body)
|
||||
|
||||
if err != nil {
|
||||
slog.Error("failed reading response body of extension", "error", err, "url", options.URL)
|
||||
return Extension{}, fmt.Errorf("%w: could not read body: %w", ErrNoContent, err)
|
||||
}
|
||||
|
||||
extension := Extension{}
|
||||
|
||||
if response.Header.Get(ExtensionHeaderTitle) == "" {
|
||||
extension.Title = "Extension"
|
||||
} else {
|
||||
extension.Title = response.Header.Get(ExtensionHeaderTitle)
|
||||
}
|
||||
|
||||
contentType, ok := ExtensionStringToType[response.Header.Get(ExtensionHeaderContentType)]
|
||||
|
||||
if !ok {
|
||||
contentType = ExtensionContentUnknown
|
||||
}
|
||||
|
||||
extension.Content = convertExtensionContent(options, body, contentType)
|
||||
|
||||
return extension, nil
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type githubReleaseResponseJson struct {
|
||||
TagName string `json:"tag_name"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
HtmlUrl string `json:"html_url"`
|
||||
Draft bool `json:"draft"`
|
||||
PreRelease bool `json:"prerelease"`
|
||||
Reactions struct {
|
||||
Downvotes int `json:"-1"`
|
||||
} `json:"reactions"`
|
||||
}
|
||||
|
||||
func parseGithubTime(t string) time.Time {
|
||||
parsedTime, err := time.Parse("2006-01-02T15:04:05Z", t)
|
||||
|
||||
if err != nil {
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
return parsedTime
|
||||
}
|
||||
|
||||
func FetchLatestReleasesFromGithub(repositories []string, token string) (AppReleases, error) {
|
||||
appReleases := make(AppReleases, 0, len(repositories))
|
||||
|
||||
if len(repositories) == 0 {
|
||||
return appReleases, nil
|
||||
}
|
||||
|
||||
requests := make([]*http.Request, len(repositories))
|
||||
|
||||
for i, repository := range repositories {
|
||||
request, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=10", repository), nil)
|
||||
|
||||
if token != "" {
|
||||
request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||
}
|
||||
|
||||
requests[i] = request
|
||||
}
|
||||
|
||||
task := decodeJsonFromRequestTask[[]githubReleaseResponseJson](defaultClient)
|
||||
job := newJob(task, requests).withWorkers(15)
|
||||
responses, errs, err := workerPoolDo(job)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var failed int
|
||||
|
||||
for i := range responses {
|
||||
if errs[i] != nil {
|
||||
failed++
|
||||
slog.Error("Failed to fetch or parse github release", "error", errs[i], "url", requests[i].URL)
|
||||
continue
|
||||
}
|
||||
|
||||
releases := responses[i]
|
||||
|
||||
if len(releases) < 1 {
|
||||
failed++
|
||||
slog.Error("No releases found", "repository", repositories[i], "url", requests[i].URL)
|
||||
continue
|
||||
}
|
||||
|
||||
var liveRelease *githubReleaseResponseJson
|
||||
|
||||
for i := range releases {
|
||||
release := &releases[i]
|
||||
|
||||
if !release.Draft && !release.PreRelease {
|
||||
liveRelease = release
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if liveRelease == nil {
|
||||
slog.Error("No live release found", "repository", repositories[i], "url", requests[i].URL)
|
||||
continue
|
||||
}
|
||||
|
||||
version := liveRelease.TagName
|
||||
|
||||
if version[0] != 'v' {
|
||||
version = "v" + version
|
||||
}
|
||||
|
||||
appReleases = append(appReleases, AppRelease{
|
||||
Name: repositories[i],
|
||||
Version: version,
|
||||
NotesUrl: liveRelease.HtmlUrl,
|
||||
TimeReleased: parseGithubTime(liveRelease.PublishedAt),
|
||||
Downvotes: liveRelease.Reactions.Downvotes,
|
||||
})
|
||||
}
|
||||
|
||||
if len(appReleases) == 0 {
|
||||
return nil, ErrNoContent
|
||||
}
|
||||
|
||||
appReleases.SortByNewest()
|
||||
|
||||
if failed > 0 {
|
||||
return appReleases, fmt.Errorf("%w: could not get %d releases", ErrPartialContent, failed)
|
||||
}
|
||||
|
||||
return appReleases, nil
|
||||
}
|
||||
|
||||
type GithubTicket struct {
|
||||
Number int
|
||||
CreatedAt time.Time
|
||||
Title string
|
||||
}
|
||||
|
||||
type RepositoryDetails struct {
|
||||
Name string
|
||||
Stars int
|
||||
Forks int
|
||||
OpenPullRequests int
|
||||
PullRequests []GithubTicket
|
||||
OpenIssues int
|
||||
Issues []GithubTicket
|
||||
}
|
||||
|
||||
type githubRepositoryDetailsResponseJson struct {
|
||||
Name string `json:"full_name"`
|
||||
Stars int `json:"stargazers_count"`
|
||||
Forks int `json:"forks_count"`
|
||||
}
|
||||
|
||||
type githubTicketResponseJson struct {
|
||||
Count int `json:"total_count"`
|
||||
Tickets []struct {
|
||||
Number int `json:"number"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Title string `json:"title"`
|
||||
} `json:"items"`
|
||||
}
|
||||
|
||||
func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs int, maxIssues int) (RepositoryDetails, error) {
|
||||
repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repository), nil)
|
||||
|
||||
if err != nil {
|
||||
return RepositoryDetails{}, fmt.Errorf("%w: could not create request with repository: %v", ErrNoContent, err)
|
||||
}
|
||||
|
||||
PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:pr+is:open+repo:%s&per_page=%d", repository, maxPRs), nil)
|
||||
issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repository, maxIssues), nil)
|
||||
|
||||
if token != "" {
|
||||
token = fmt.Sprintf("Bearer %s", token)
|
||||
repositoryRequest.Header.Add("Authorization", token)
|
||||
PRsRequest.Header.Add("Authorization", token)
|
||||
issuesRequest.Header.Add("Authorization", token)
|
||||
}
|
||||
|
||||
var detailsResponse githubRepositoryDetailsResponseJson
|
||||
var detailsErr error
|
||||
var PRsResponse githubTicketResponseJson
|
||||
var PRsErr error
|
||||
var issuesResponse githubTicketResponseJson
|
||||
var issuesErr error
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Add(1)
|
||||
go (func() {
|
||||
defer wg.Done()
|
||||
detailsResponse, detailsErr = decodeJsonFromRequest[githubRepositoryDetailsResponseJson](defaultClient, repositoryRequest)
|
||||
})()
|
||||
|
||||
if maxPRs > 0 {
|
||||
wg.Add(1)
|
||||
go (func() {
|
||||
defer wg.Done()
|
||||
PRsResponse, PRsErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, PRsRequest)
|
||||
})()
|
||||
}
|
||||
|
||||
if maxIssues > 0 {
|
||||
wg.Add(1)
|
||||
go (func() {
|
||||
defer wg.Done()
|
||||
issuesResponse, issuesErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, issuesRequest)
|
||||
})()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if detailsErr != nil {
|
||||
return RepositoryDetails{}, fmt.Errorf("%w: could not get repository details: %s", ErrNoContent, detailsErr)
|
||||
}
|
||||
|
||||
details := RepositoryDetails{
|
||||
Name: detailsResponse.Name,
|
||||
Stars: detailsResponse.Stars,
|
||||
Forks: detailsResponse.Forks,
|
||||
PullRequests: make([]GithubTicket, 0, len(PRsResponse.Tickets)),
|
||||
Issues: make([]GithubTicket, 0, len(issuesResponse.Tickets)),
|
||||
}
|
||||
|
||||
err = nil
|
||||
|
||||
if maxPRs > 0 {
|
||||
if PRsErr != nil {
|
||||
err = fmt.Errorf("%w: could not get PRs: %s", ErrPartialContent, PRsErr)
|
||||
} else {
|
||||
details.OpenPullRequests = PRsResponse.Count
|
||||
|
||||
for i := range PRsResponse.Tickets {
|
||||
details.PullRequests = append(details.PullRequests, GithubTicket{
|
||||
Number: PRsResponse.Tickets[i].Number,
|
||||
CreatedAt: parseGithubTime(PRsResponse.Tickets[i].CreatedAt),
|
||||
Title: PRsResponse.Tickets[i].Title,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if maxIssues > 0 {
|
||||
if issuesErr != nil {
|
||||
// TODO: fix, overwriting the previous error
|
||||
err = fmt.Errorf("%w: could not get issues: %s", ErrPartialContent, issuesErr)
|
||||
} else {
|
||||
details.OpenIssues = issuesResponse.Count
|
||||
|
||||
for i := range issuesResponse.Tickets {
|
||||
details.Issues = append(details.Issues, GithubTicket{
|
||||
Number: issuesResponse.Tickets[i].Number,
|
||||
CreatedAt: parseGithubTime(issuesResponse.Tickets[i].CreatedAt),
|
||||
Title: issuesResponse.Tickets[i].Title,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return details, err
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type hackerNewsPostResponseJson struct {
|
||||
Id int `json:"id"`
|
||||
Score int `json:"score"`
|
||||
Title string `json:"title"`
|
||||
TargetUrl string `json:"url,omitempty"`
|
||||
CommentCount int `json:"descendants"`
|
||||
TimePosted int64 `json:"time"`
|
||||
}
|
||||
|
||||
func getHackerNewsPostIds(sort string) ([]int, error) {
|
||||
request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/%sstories.json", sort), nil)
|
||||
response, err := decodeJsonFromRequest[[]int](defaultClient, request)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: could not fetch list of post IDs", ErrNoContent)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func getHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (ForumPosts, error) {
|
||||
requests := make([]*http.Request, len(postIds))
|
||||
|
||||
for i, id := range postIds {
|
||||
request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", id), nil)
|
||||
requests[i] = request
|
||||
}
|
||||
|
||||
task := decodeJsonFromRequestTask[hackerNewsPostResponseJson](defaultClient)
|
||||
job := newJob(task, requests).withWorkers(30)
|
||||
results, errs, err := workerPoolDo(job)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
posts := make(ForumPosts, 0, len(postIds))
|
||||
|
||||
for i := range results {
|
||||
if errs[i] != nil {
|
||||
slog.Error("Failed to fetch or parse hacker news post", "error", errs[i], "url", requests[i].URL)
|
||||
continue
|
||||
}
|
||||
|
||||
var commentsUrl string
|
||||
|
||||
if commentsUrlTemplate == "" {
|
||||
commentsUrl = "https://news.ycombinator.com/item?id=" + strconv.Itoa(results[i].Id)
|
||||
} else {
|
||||
commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{POST-ID}", strconv.Itoa(results[i].Id))
|
||||
}
|
||||
|
||||
posts = append(posts, ForumPost{
|
||||
Title: results[i].Title,
|
||||
DiscussionUrl: commentsUrl,
|
||||
TargetUrl: results[i].TargetUrl,
|
||||
TargetUrlDomain: extractDomainFromUrl(results[i].TargetUrl),
|
||||
CommentCount: results[i].CommentCount,
|
||||
Score: results[i].Score,
|
||||
TimePosted: time.Unix(results[i].TimePosted, 0),
|
||||
})
|
||||
}
|
||||
|
||||
if len(posts) == 0 {
|
||||
return nil, ErrNoContent
|
||||
}
|
||||
|
||||
if len(posts) != len(postIds) {
|
||||
return posts, fmt.Errorf("%w could not fetch some hacker news posts", ErrPartialContent)
|
||||
}
|
||||
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func FetchHackerNewsPosts(sort string, limit int, commentsUrlTemplate string) (ForumPosts, error) {
|
||||
postIds, err := getHackerNewsPostIds(sort)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(postIds) > limit {
|
||||
postIds = postIds[:limit]
|
||||
}
|
||||
|
||||
return getHackerNewsPostsFromIds(postIds, commentsUrlTemplate)
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type lobstersPostResponseJson struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Score int `json:"score"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
CommentsURL string `json:"comments_url"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type lobstersFeedResponseJson []lobstersPostResponseJson
|
||||
|
||||
func getLobstersPostsFromFeed(feedUrl string) (ForumPosts, error) {
|
||||
request, err := http.NewRequest("GET", feedUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
feed, err := decodeJsonFromRequest[lobstersFeedResponseJson](defaultClient, request)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
posts := make(ForumPosts, 0, len(feed))
|
||||
|
||||
for i := range feed {
|
||||
createdAt, _ := time.Parse(time.RFC3339, feed[i].CreatedAt)
|
||||
|
||||
posts = append(posts, ForumPost{
|
||||
Title: feed[i].Title,
|
||||
DiscussionUrl: feed[i].CommentsURL,
|
||||
TargetUrl: feed[i].URL,
|
||||
TargetUrlDomain: extractDomainFromUrl(feed[i].URL),
|
||||
CommentCount: feed[i].CommentCount,
|
||||
Score: feed[i].Score,
|
||||
TimePosted: createdAt,
|
||||
Tags: feed[i].Tags,
|
||||
})
|
||||
}
|
||||
|
||||
if len(posts) == 0 {
|
||||
return nil, ErrNoContent
|
||||
}
|
||||
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func FetchLobstersPosts(sortBy string, tags []string) (ForumPosts, error) {
|
||||
var feedUrl string
|
||||
|
||||
if sortBy == "hot" {
|
||||
sortBy = "hottest"
|
||||
} else if sortBy == "new" {
|
||||
sortBy = "newest"
|
||||
}
|
||||
|
||||
if len(tags) == 0 {
|
||||
feedUrl = "https://lobste.rs/" + sortBy + ".json"
|
||||
} else {
|
||||
tags := strings.Join(tags, ",")
|
||||
feedUrl = "https://lobste.rs/t/" + tags + ".json"
|
||||
}
|
||||
|
||||
posts, err := getLobstersPostsFromFeed(feedUrl)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return posts, nil
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SiteStatusRequest struct {
|
||||
URL string `yaml:"url"`
|
||||
AllowInsecure bool `yaml:"allow-insecure"`
|
||||
}
|
||||
|
||||
type SiteStatus struct {
|
||||
Code int
|
||||
TimedOut bool
|
||||
ResponseTime time.Duration
|
||||
Error error
|
||||
}
|
||||
|
||||
func getSiteStatusTask(statusRequest *SiteStatusRequest) (SiteStatus, error) {
|
||||
request, err := http.NewRequest(http.MethodGet, statusRequest.URL, nil)
|
||||
|
||||
if err != nil {
|
||||
return SiteStatus{
|
||||
Error: err,
|
||||
}, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
|
||||
defer cancel()
|
||||
request = request.WithContext(ctx)
|
||||
requestSentAt := time.Now()
|
||||
var response *http.Response
|
||||
|
||||
if !statusRequest.AllowInsecure {
|
||||
response, err = defaultClient.Do(request)
|
||||
} else {
|
||||
response, err = defaultInsecureClient.Do(request)
|
||||
}
|
||||
|
||||
status := SiteStatus{ResponseTime: time.Since(requestSentAt)}
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
status.TimedOut = true
|
||||
}
|
||||
|
||||
status.Error = err
|
||||
return status, nil
|
||||
}
|
||||
|
||||
defer response.Body.Close()
|
||||
|
||||
status.Code = response.StatusCode
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func FetchStatusForSites(requests []*SiteStatusRequest) ([]SiteStatus, error) {
|
||||
job := newJob(getSiteStatusTask, requests).withWorkers(20)
|
||||
results, _, err := workerPoolDo(job)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ForumPost struct {
|
||||
Title string
|
||||
DiscussionUrl string
|
||||
TargetUrl string
|
||||
TargetUrlDomain string
|
||||
ThumbnailUrl string
|
||||
CommentCount int
|
||||
Score int
|
||||
Engagement float64
|
||||
TimePosted time.Time
|
||||
Tags []string
|
||||
}
|
||||
|
||||
type ForumPosts []ForumPost
|
||||
|
||||
type Calendar struct {
|
||||
CurrentDay int
|
||||
CurrentWeekNumber int
|
||||
CurrentMonthName string
|
||||
CurrentYear int
|
||||
Days []int
|
||||
}
|
||||
|
||||
type Weather struct {
|
||||
Temperature int
|
||||
ApparentTemperature int
|
||||
WeatherCode int
|
||||
CurrentColumn int
|
||||
SunriseColumn int
|
||||
SunsetColumn int
|
||||
Columns []weatherColumn
|
||||
}
|
||||
|
||||
type AppRelease struct {
|
||||
Name string
|
||||
Version string
|
||||
NotesUrl string
|
||||
TimeReleased time.Time
|
||||
Downvotes int
|
||||
}
|
||||
|
||||
type AppReleases []AppRelease
|
||||
|
||||
type Video struct {
|
||||
ThumbnailUrl string
|
||||
Title string
|
||||
Url string
|
||||
Author string
|
||||
AuthorUrl string
|
||||
TimePosted time.Time
|
||||
}
|
||||
|
||||
type Videos []Video
|
||||
|
||||
var currencyToSymbol = map[string]string{
|
||||
"USD": "$",
|
||||
"EUR": "€",
|
||||
"JPY": "¥",
|
||||
"CAD": "C$",
|
||||
"AUD": "A$",
|
||||
"GBP": "£",
|
||||
"CHF": "Fr",
|
||||
"NZD": "N$",
|
||||
"INR": "₹",
|
||||
"BRL": "R$",
|
||||
"RUB": "₽",
|
||||
"TRY": "₺",
|
||||
"ZAR": "R",
|
||||
"CNY": "¥",
|
||||
"KRW": "₩",
|
||||
"HKD": "HK$",
|
||||
"SGD": "S$",
|
||||
"SEK": "kr",
|
||||
"NOK": "kr",
|
||||
"DKK": "kr",
|
||||
"PLN": "zł",
|
||||
"PHP": "₱",
|
||||
}
|
||||
|
||||
type MarketRequest struct {
|
||||
Name string `yaml:"name"`
|
||||
Symbol string `yaml:"symbol"`
|
||||
ChartLink string `yaml:"chart-link"`
|
||||
SymbolLink string `yaml:"symbol-link"`
|
||||
}
|
||||
|
||||
type Market struct {
|
||||
MarketRequest
|
||||
Currency string `yaml:"-"`
|
||||
Price float64 `yaml:"-"`
|
||||
PercentChange float64 `yaml:"-"`
|
||||
SvgChartPoints string `yaml:"-"`
|
||||
}
|
||||
|
||||
type Markets []Market
|
||||
|
||||
func (t Markets) SortByAbsChange() {
|
||||
sort.Slice(t, func(i, j int) bool {
|
||||
return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange)
|
||||
})
|
||||
}
|
||||
|
||||
var weatherCodeTable = map[int]string{
|
||||
0: "Clear Sky",
|
||||
1: "Mainly Clear",
|
||||
2: "Partly Cloudy",
|
||||
3: "Overcast",
|
||||
45: "Fog",
|
||||
48: "Rime Fog",
|
||||
51: "Drizzle",
|
||||
53: "Drizzle",
|
||||
55: "Drizzle",
|
||||
56: "Drizzle",
|
||||
57: "Drizzle",
|
||||
61: "Rain",
|
||||
63: "Moderate Rain",
|
||||
65: "Heavy Rain",
|
||||
66: "Freezing Rain",
|
||||
67: "Freezing Rain",
|
||||
71: "Snow",
|
||||
73: "Moderate Snow",
|
||||
75: "Heavy Snow",
|
||||
77: "Snow Grains",
|
||||
80: "Rain",
|
||||
81: "Moderate Rain",
|
||||
82: "Heavy Rain",
|
||||
85: "Snow",
|
||||
86: "Snow",
|
||||
95: "Thunderstorm",
|
||||
96: "Thunderstorm",
|
||||
99: "Thunderstorm",
|
||||
}
|
||||
|
||||
func (w *Weather) WeatherCodeAsString() string {
|
||||
if weatherCode, ok := weatherCodeTable[w.WeatherCode]; ok {
|
||||
return weatherCode
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
const depreciatePostsOlderThanHours = 7
|
||||
const maxDepreciation = 0.9
|
||||
const maxDepreciationAfterHours = 24
|
||||
|
||||
func (p ForumPosts) CalculateEngagement() {
|
||||
var totalComments int
|
||||
var totalScore int
|
||||
|
||||
for i := range p {
|
||||
totalComments += p[i].CommentCount
|
||||
totalScore += p[i].Score
|
||||
}
|
||||
|
||||
numberOfPosts := float64(len(p))
|
||||
averageComments := float64(totalComments) / numberOfPosts
|
||||
averageScore := float64(totalScore) / numberOfPosts
|
||||
|
||||
for i := range p {
|
||||
p[i].Engagement = (float64(p[i].CommentCount)/averageComments + float64(p[i].Score)/averageScore) / 2
|
||||
|
||||
elapsed := time.Since(p[i].TimePosted)
|
||||
|
||||
if elapsed < time.Hour*depreciatePostsOlderThanHours {
|
||||
continue
|
||||
}
|
||||
|
||||
p[i].Engagement *= 1.0 - (math.Max(elapsed.Hours()-depreciatePostsOlderThanHours, maxDepreciationAfterHours)/maxDepreciationAfterHours)*maxDepreciation
|
||||
}
|
||||
}
|
||||
|
||||
func (p ForumPosts) SortByEngagement() {
|
||||
sort.Slice(p, func(i, j int) bool {
|
||||
return p[i].Engagement > p[j].Engagement
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ForumPost) HasTargetUrl() bool {
|
||||
return s.TargetUrl != ""
|
||||
}
|
||||
|
||||
func (p ForumPosts) FilterPostedBefore(postedBefore time.Duration) []ForumPost {
|
||||
recent := make([]ForumPost, 0, len(p))
|
||||
|
||||
for i := range p {
|
||||
if time.Since(p[i].TimePosted) < postedBefore {
|
||||
recent = append(recent, p[i])
|
||||
}
|
||||
}
|
||||
|
||||
return recent
|
||||
}
|
||||
|
||||
func (r AppReleases) SortByNewest() AppReleases {
|
||||
sort.Slice(r, func(i, j int) bool {
|
||||
return r[i].TimeReleased.After(r[j].TimeReleased)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (v Videos) SortByNewest() Videos {
|
||||
sort.Slice(v, func(i, j int) bool {
|
||||
return v[i].TimePosted.After(v[j].TimePosted)
|
||||
})
|
||||
|
||||
return v
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type subredditResponseJson struct {
|
||||
Data struct {
|
||||
Children []struct {
|
||||
Data struct {
|
||||
Id string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Upvotes int `json:"ups"`
|
||||
Url string `json:"url"`
|
||||
Time float64 `json:"created"`
|
||||
CommentsCount int `json:"num_comments"`
|
||||
Domain string `json:"domain"`
|
||||
Permalink string `json:"permalink"`
|
||||
Stickied bool `json:"stickied"`
|
||||
Pinned bool `json:"pinned"`
|
||||
IsSelf bool `json:"is_self"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
} `json:"data"`
|
||||
} `json:"children"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string) (ForumPosts, error) {
|
||||
query := url.Values{}
|
||||
var requestUrl string
|
||||
|
||||
if search != "" {
|
||||
query.Set("q", search+" subreddit:"+subreddit)
|
||||
query.Set("sort", sort)
|
||||
}
|
||||
|
||||
if sort == "top" {
|
||||
query.Set("t", topPeriod)
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
requestUrl = fmt.Sprintf("https://www.reddit.com/search.json?%s", query.Encode())
|
||||
} else {
|
||||
requestUrl = fmt.Sprintf("https://www.reddit.com/r/%s/%s.json?%s", subreddit, sort, query.Encode())
|
||||
}
|
||||
|
||||
if requestUrlTemplate != "" {
|
||||
requestUrl = strings.ReplaceAll(requestUrlTemplate, "{REQUEST-URL}", requestUrl)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest("GET", requestUrl, nil)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Required to increase rate limit, otherwise Reddit randomly returns 429 even after just 2 requests
|
||||
addBrowserUserAgentHeader(request)
|
||||
responseJson, err := decodeJsonFromRequest[subredditResponseJson](defaultClient, request)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(responseJson.Data.Children) == 0 {
|
||||
return nil, fmt.Errorf("no posts found")
|
||||
}
|
||||
|
||||
posts := make(ForumPosts, 0, len(responseJson.Data.Children))
|
||||
|
||||
for i := range responseJson.Data.Children {
|
||||
post := &responseJson.Data.Children[i].Data
|
||||
|
||||
if post.Stickied || post.Pinned {
|
||||
continue
|
||||
}
|
||||
|
||||
var commentsUrl string
|
||||
|
||||
if commentsUrlTemplate == "" {
|
||||
commentsUrl = "https://www.reddit.com" + post.Permalink
|
||||
} else {
|
||||
commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{SUBREDDIT}", subreddit)
|
||||
commentsUrl = strings.ReplaceAll(commentsUrl, "{POST-ID}", post.Id)
|
||||
commentsUrl = strings.ReplaceAll(commentsUrl, "{POST-PATH}", strings.TrimLeft(post.Permalink, "/"))
|
||||
}
|
||||
|
||||
forumPost := ForumPost{
|
||||
Title: html.UnescapeString(post.Title),
|
||||
DiscussionUrl: commentsUrl,
|
||||
TargetUrlDomain: post.Domain,
|
||||
CommentCount: post.CommentsCount,
|
||||
Score: post.Upvotes,
|
||||
TimePosted: time.Unix(int64(post.Time), 0),
|
||||
}
|
||||
|
||||
if post.Thumbnail != "" && post.Thumbnail != "self" && post.Thumbnail != "default" {
|
||||
forumPost.ThumbnailUrl = post.Thumbnail
|
||||
}
|
||||
|
||||
if !post.IsSelf {
|
||||
forumPost.TargetUrl = post.Url
|
||||
}
|
||||
|
||||
posts = append(posts, forumPost)
|
||||
}
|
||||
|
||||
return posts, nil
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mmcdole/gofeed"
|
||||
)
|
||||
|
||||
type RSSFeedItem struct {
|
||||
ChannelName string
|
||||
ChannelURL string
|
||||
Title string
|
||||
Link string
|
||||
ImageURL string
|
||||
Categories []string
|
||||
Description string
|
||||
PublishedAt time.Time
|
||||
}
|
||||
|
||||
// doesn't cover all cases but works the vast majority of the time
|
||||
var htmlTagsWithAttributesPattern = regexp.MustCompile(`<\/?[a-zA-Z0-9-]+ *(?:[a-zA-Z-]+=(?:"|').*?(?:"|') ?)* *\/?>`)
|
||||
var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
|
||||
|
||||
func sanitizeFeedDescription(description string) string {
|
||||
if description == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
description = strings.ReplaceAll(description, "\n", " ")
|
||||
description = htmlTagsWithAttributesPattern.ReplaceAllString(description, "")
|
||||
description = sequentialWhitespacePattern.ReplaceAllString(description, " ")
|
||||
description = strings.TrimSpace(description)
|
||||
description = html.UnescapeString(description)
|
||||
|
||||
return description
|
||||
}
|
||||
|
||||
type RSSFeedRequest struct {
|
||||
Url string `yaml:"url"`
|
||||
Title string `yaml:"title"`
|
||||
HideCategories bool `yaml:"hide-categories"`
|
||||
HideDescription bool `yaml:"hide-description"`
|
||||
ItemLinkPrefix string `yaml:"item-link-prefix"`
|
||||
}
|
||||
|
||||
type RSSFeedItems []RSSFeedItem
|
||||
|
||||
func (f RSSFeedItems) SortByNewest() RSSFeedItems {
|
||||
sort.Slice(f, func(i, j int) bool {
|
||||
return f[i].PublishedAt.After(f[j].PublishedAt)
|
||||
})
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
var feedParser = gofeed.NewParser()
|
||||
|
||||
func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||
defer cancel()
|
||||
|
||||
feed, err := feedParser.ParseURLWithContext(request.Url, ctx)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make(RSSFeedItems, 0, len(feed.Items))
|
||||
|
||||
for i := range feed.Items {
|
||||
item := feed.Items[i]
|
||||
|
||||
rssItem := RSSFeedItem{
|
||||
ChannelURL: feed.Link,
|
||||
Title: item.Title,
|
||||
}
|
||||
|
||||
if request.ItemLinkPrefix != "" {
|
||||
rssItem.Link = request.ItemLinkPrefix + item.Link
|
||||
} else if strings.HasPrefix(item.Link, "http://") || strings.HasPrefix(item.Link, "https://") {
|
||||
rssItem.Link = item.Link
|
||||
} else {
|
||||
parsedUrl, err := url.Parse(feed.Link)
|
||||
|
||||
if err != nil {
|
||||
parsedUrl, err = url.Parse(request.Url)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
var link string
|
||||
|
||||
if item.Link[0] == '/' {
|
||||
link = item.Link
|
||||
} else {
|
||||
link = "/" + item.Link
|
||||
}
|
||||
|
||||
rssItem.Link = parsedUrl.Scheme + "://" + parsedUrl.Host + link
|
||||
}
|
||||
}
|
||||
|
||||
if !request.HideDescription && item.Description != "" {
|
||||
description, _ := limitStringLength(item.Description, 1000)
|
||||
description = sanitizeFeedDescription(description)
|
||||
description, limited := limitStringLength(description, 200)
|
||||
|
||||
if limited {
|
||||
description += "…"
|
||||
}
|
||||
|
||||
rssItem.Description = description
|
||||
}
|
||||
|
||||
if !request.HideCategories {
|
||||
var categories = make([]string, 0, 6)
|
||||
|
||||
for _, category := range item.Categories {
|
||||
if len(categories) == 6 {
|
||||
break
|
||||
}
|
||||
|
||||
if len(category) == 0 || len(category) > 30 {
|
||||
continue
|
||||
}
|
||||
|
||||
categories = append(categories, category)
|
||||
}
|
||||
|
||||
rssItem.Categories = categories
|
||||
}
|
||||
|
||||
if request.Title != "" {
|
||||
rssItem.ChannelName = request.Title
|
||||
} else {
|
||||
rssItem.ChannelName = feed.Title
|
||||
}
|
||||
|
||||
if item.Image != nil {
|
||||
rssItem.ImageURL = item.Image.URL
|
||||
} else if feed.Image != nil {
|
||||
rssItem.ImageURL = feed.Image.URL
|
||||
}
|
||||
|
||||
if item.PublishedParsed != nil {
|
||||
rssItem.PublishedAt = *item.PublishedParsed
|
||||
} else {
|
||||
rssItem.PublishedAt = time.Now()
|
||||
}
|
||||
|
||||
items = append(items, rssItem)
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func GetItemsFromRSSFeeds(requests []RSSFeedRequest) (RSSFeedItems, error) {
|
||||
job := newJob(getItemsFromRSSFeedTask, requests).withWorkers(10)
|
||||
feeds, errs, err := workerPoolDo(job)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
|
||||
}
|
||||
|
||||
failed := 0
|
||||
|
||||
entries := make(RSSFeedItems, 0, len(feeds)*10)
|
||||
|
||||
for i := range feeds {
|
||||
if errs[i] != nil {
|
||||
failed++
|
||||
slog.Error("failed to get rss feed", "error", errs[i], "url", requests[i].Url)
|
||||
continue
|
||||
}
|
||||
|
||||
entries = append(entries, feeds[i]...)
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
return nil, ErrNoContent
|
||||
}
|
||||
|
||||
entries.SortByNewest()
|
||||
|
||||
if failed > 0 {
|
||||
return entries, fmt.Errorf("%w: missing %d RSS feeds", ErrPartialContent, failed)
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TwitchCategory struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
AvatarUrl string `json:"avatarURL"`
|
||||
ViewersCount int `json:"viewersCount"`
|
||||
Tags []struct {
|
||||
Name string `json:"tagName"`
|
||||
} `json:"tags"`
|
||||
GameReleaseDate string `json:"originalReleaseDate"`
|
||||
IsNew bool `json:"-"`
|
||||
}
|
||||
|
||||
type TwitchChannel struct {
|
||||
Login string
|
||||
Exists bool
|
||||
Name string
|
||||
AvatarUrl string
|
||||
IsLive bool
|
||||
LiveSince time.Time
|
||||
Category string
|
||||
CategorySlug string
|
||||
ViewersCount int
|
||||
}
|
||||
|
||||
type TwitchChannels []TwitchChannel
|
||||
|
||||
func (channels TwitchChannels) SortByViewers() {
|
||||
sort.Slice(channels, func(i, j int) bool {
|
||||
return channels[i].ViewersCount > channels[j].ViewersCount
|
||||
})
|
||||
}
|
||||
|
||||
func (channels TwitchChannels) SortByLive() {
|
||||
sort.SliceStable(channels, func(i, j int) bool {
|
||||
return channels[i].IsLive && !channels[j].IsLive
|
||||
})
|
||||
}
|
||||
|
||||
type twitchOperationResponse struct {
|
||||
Data json.RawMessage
|
||||
Extensions struct {
|
||||
OperationName string `json:"operationName"`
|
||||
}
|
||||
}
|
||||
|
||||
type twitchChannelShellOperationResponse struct {
|
||||
UserOrError struct {
|
||||
Type string `json:"__typename"`
|
||||
DisplayName string `json:"displayName"`
|
||||
ProfileImageUrl string `json:"profileImageURL"`
|
||||
Stream *struct {
|
||||
ViewersCount int `json:"viewersCount"`
|
||||
}
|
||||
} `json:"userOrError"`
|
||||
}
|
||||
|
||||
type twitchStreamMetadataOperationResponse struct {
|
||||
UserOrNull *struct {
|
||||
Stream *struct {
|
||||
StartedAt string `json:"createdAt"`
|
||||
Game *struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
} `json:"game"`
|
||||
} `json:"stream"`
|
||||
} `json:"user"`
|
||||
}
|
||||
|
||||
type twitchDirectoriesOperationResponse struct {
|
||||
Data struct {
|
||||
DirectoriesWithTags struct {
|
||||
Edges []struct {
|
||||
Node TwitchCategory `json:"node"`
|
||||
} `json:"edges"`
|
||||
} `json:"directoriesWithTags"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
const twitchGqlEndpoint = "https://gql.twitch.tv/gql"
|
||||
const twitchGqlClientId = "kimne78kx3ncx6brgo4mv6wki5h1ko"
|
||||
|
||||
const twitchDirectoriesOperationRequestBody = `[{"operationName": "BrowsePage_AllDirectories","variables": {"limit": %d,"options": {"sort": "VIEWER_COUNT","tags": []}},"extensions": {"persistedQuery": {"version": 1,"sha256Hash": "2f67f71ba89f3c0ed26a141ec00da1defecb2303595f5cda4298169549783d9e"}}}]`
|
||||
|
||||
func FetchTopGamesFromTwitch(exclude []string, limit int) ([]TwitchCategory, error) {
|
||||
reader := strings.NewReader(fmt.Sprintf(twitchDirectoriesOperationRequestBody, len(exclude)+limit))
|
||||
request, _ := http.NewRequest("POST", twitchGqlEndpoint, reader)
|
||||
request.Header.Add("Client-ID", twitchGqlClientId)
|
||||
response, err := decodeJsonFromRequest[[]twitchDirectoriesOperationResponse](defaultClient, request)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(response) == 0 {
|
||||
return nil, errors.New("no categories could be retrieved")
|
||||
}
|
||||
|
||||
edges := (response)[0].Data.DirectoriesWithTags.Edges
|
||||
categories := make([]TwitchCategory, 0, len(edges))
|
||||
|
||||
for i := range edges {
|
||||
if slices.Contains(exclude, edges[i].Node.Slug) {
|
||||
continue
|
||||
}
|
||||
|
||||
category := &edges[i].Node
|
||||
category.AvatarUrl = strings.Replace(category.AvatarUrl, "285x380", "144x192", 1)
|
||||
|
||||
if len(category.Tags) > 2 {
|
||||
category.Tags = category.Tags[:2]
|
||||
}
|
||||
|
||||
gameReleasedDate, err := time.Parse("2006-01-02T15:04:05Z", category.GameReleaseDate)
|
||||
|
||||
if err == nil {
|
||||
if time.Since(gameReleasedDate) < 14*24*time.Hour {
|
||||
category.IsNew = true
|
||||
}
|
||||
}
|
||||
|
||||
categories = append(categories, *category)
|
||||
}
|
||||
|
||||
if len(categories) > limit {
|
||||
categories = categories[:limit]
|
||||
}
|
||||
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
const twitchChannelStatusOperationRequestBody = `[{"operationName":"ChannelShell","variables":{"login":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"580ab410bcd0c1ad194224957ae2241e5d252b2c5173d8e0cce9d32d5bb14efe"}}},{"operationName":"StreamMetadata","variables":{"channelLogin":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"676ee2f834ede42eb4514cdb432b3134fefc12590080c9a2c9bb44a2a4a63266"}}}]`
|
||||
|
||||
// TODO: rework
|
||||
// The operations for multiple channels can all be sent in a single request
|
||||
// rather than sending a separate request for each channel. Need to figure out
|
||||
// what the limit is for max operations per request and batch operations in
|
||||
// multiple requests if number of channels exceeds allowed limit.
|
||||
|
||||
func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) {
|
||||
result := TwitchChannel{
|
||||
Login: strings.ToLower(channel),
|
||||
}
|
||||
|
||||
reader := strings.NewReader(fmt.Sprintf(twitchChannelStatusOperationRequestBody, channel, channel))
|
||||
request, _ := http.NewRequest("POST", twitchGqlEndpoint, reader)
|
||||
request.Header.Add("Client-ID", twitchGqlClientId)
|
||||
|
||||
response, err := decodeJsonFromRequest[[]twitchOperationResponse](defaultClient, request)
|
||||
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
if len(response) != 2 {
|
||||
return result, fmt.Errorf("expected 2 operation responses, got %d", len(response))
|
||||
}
|
||||
|
||||
var channelShell twitchChannelShellOperationResponse
|
||||
var streamMetadata twitchStreamMetadataOperationResponse
|
||||
|
||||
for i := range response {
|
||||
switch response[i].Extensions.OperationName {
|
||||
case "ChannelShell":
|
||||
err = json.Unmarshal(response[i].Data, &channelShell)
|
||||
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("failed to unmarshal channel shell: %w", err)
|
||||
}
|
||||
case "StreamMetadata":
|
||||
err = json.Unmarshal(response[i].Data, &streamMetadata)
|
||||
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("failed to unmarshal stream metadata: %w", err)
|
||||
}
|
||||
default:
|
||||
return result, fmt.Errorf("unknown operation name: %s", response[i].Extensions.OperationName)
|
||||
}
|
||||
}
|
||||
|
||||
if channelShell.UserOrError.Type != "User" {
|
||||
result.Name = result.Login
|
||||
return result, nil
|
||||
}
|
||||
|
||||
result.Exists = true
|
||||
result.Name = channelShell.UserOrError.DisplayName
|
||||
result.AvatarUrl = channelShell.UserOrError.ProfileImageUrl
|
||||
|
||||
if channelShell.UserOrError.Stream != nil {
|
||||
result.IsLive = true
|
||||
result.ViewersCount = channelShell.UserOrError.Stream.ViewersCount
|
||||
|
||||
if streamMetadata.UserOrNull != nil && streamMetadata.UserOrNull.Stream != nil && streamMetadata.UserOrNull.Stream.Game != nil {
|
||||
result.Category = streamMetadata.UserOrNull.Stream.Game.Name
|
||||
result.CategorySlug = streamMetadata.UserOrNull.Stream.Game.Slug
|
||||
startedAt, err := time.Parse("2006-01-02T15:04:05Z", streamMetadata.UserOrNull.Stream.StartedAt)
|
||||
|
||||
if err == nil {
|
||||
result.LiveSince = startedAt
|
||||
} else {
|
||||
slog.Warn("failed to parse twitch stream started at", "error", err, "started_at", streamMetadata.UserOrNull.Stream.StartedAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func FetchChannelsFromTwitch(channelLogins []string) (TwitchChannels, error) {
|
||||
result := make(TwitchChannels, 0, len(channelLogins))
|
||||
|
||||
job := newJob(fetchChannelFromTwitchTask, channelLogins).withWorkers(10)
|
||||
channels, errs, err := workerPoolDo(job)
|
||||
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
var failed int
|
||||
|
||||
for i := range channels {
|
||||
if errs[i] != nil {
|
||||
failed++
|
||||
slog.Warn("failed to fetch twitch channel", "channel", channelLogins[i], "error", errs[i])
|
||||
continue
|
||||
}
|
||||
|
||||
result = append(result, channels[i])
|
||||
}
|
||||
|
||||
if failed == len(channelLogins) {
|
||||
return result, ErrNoContent
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
return result, fmt.Errorf("%w: failed to fetch %d channels", ErrPartialContent, failed)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoContent = errors.New("failed to retrieve any content")
|
||||
ErrPartialContent = errors.New("failed to retrieve some of the content")
|
||||
)
|
||||
|
||||
func percentChange(current, previous float64) float64 {
|
||||
return (current/previous - 1) * 100
|
||||
}
|
||||
|
||||
func extractDomainFromUrl(u string) string {
|
||||
if u == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(u)
|
||||
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.TrimPrefix(strings.ToLower(parsed.Host), "www.")
|
||||
}
|
||||
|
||||
func SvgPolylineCoordsFromYValues(width float64, height float64, values []float64) string {
|
||||
if len(values) < 2 {
|
||||
return ""
|
||||
}
|
||||
|
||||
verticalPadding := height * 0.02
|
||||
height -= verticalPadding * 2
|
||||
coordinates := make([]string, len(values))
|
||||
distanceBetweenPoints := width / float64(len(values)-1)
|
||||
min := slices.Min(values)
|
||||
max := slices.Max(values)
|
||||
|
||||
for i := range values {
|
||||
coordinates[i] = fmt.Sprintf(
|
||||
"%.2f,%.2f",
|
||||
float64(i)*distanceBetweenPoints,
|
||||
((max-values[i])/(max-min))*height+verticalPadding,
|
||||
)
|
||||
}
|
||||
|
||||
return strings.Join(coordinates, " ")
|
||||
}
|
||||
|
||||
func maybeCopySliceWithoutZeroValues[T int | float64](values []T) []T {
|
||||
if len(values) == 0 {
|
||||
return values
|
||||
}
|
||||
|
||||
for i := range values {
|
||||
if values[i] != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
c := make([]T, 0, len(values)-1)
|
||||
|
||||
for i := range values {
|
||||
if values[i] != 0 {
|
||||
c = append(c, values[i])
|
||||
}
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
|
||||
var urlSchemePattern = regexp.MustCompile(`^[a-z]+:\/\/`)
|
||||
|
||||
func stripURLScheme(url string) string {
|
||||
return urlSchemePattern.ReplaceAllString(url, "")
|
||||
}
|
||||
|
||||
func limitStringLength(s string, max int) (string, bool) {
|
||||
asRunes := []rune(s)
|
||||
|
||||
if len(asRunes) > max {
|
||||
return string(asRunes[:max]), true
|
||||
}
|
||||
|
||||
return s, false
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type marketResponseJson struct {
|
||||
Chart struct {
|
||||
Result []struct {
|
||||
Meta struct {
|
||||
Currency string `json:"currency"`
|
||||
Symbol string `json:"symbol"`
|
||||
RegularMarketPrice float64 `json:"regularMarketPrice"`
|
||||
ChartPreviousClose float64 `json:"chartPreviousClose"`
|
||||
} `json:"meta"`
|
||||
Indicators struct {
|
||||
Quote []struct {
|
||||
Close []float64 `json:"close,omitempty"`
|
||||
} `json:"quote"`
|
||||
} `json:"indicators"`
|
||||
} `json:"result"`
|
||||
} `json:"chart"`
|
||||
}
|
||||
|
||||
// TODO: allow changing chart time frame
|
||||
const marketChartDays = 21
|
||||
|
||||
func FetchMarketsDataFromYahoo(marketRequests []MarketRequest) (Markets, error) {
|
||||
requests := make([]*http.Request, 0, len(marketRequests))
|
||||
|
||||
for i := range marketRequests {
|
||||
request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", marketRequests[i].Symbol), nil)
|
||||
requests = append(requests, request)
|
||||
}
|
||||
|
||||
job := newJob(decodeJsonFromRequestTask[marketResponseJson](defaultClient), requests)
|
||||
responses, errs, err := workerPoolDo(job)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
|
||||
}
|
||||
|
||||
markets := make(Markets, 0, len(responses))
|
||||
var failed int
|
||||
|
||||
for i := range responses {
|
||||
if errs[i] != nil {
|
||||
failed++
|
||||
slog.Error("Failed to fetch market data", "symbol", marketRequests[i].Symbol, "error", errs[i])
|
||||
continue
|
||||
}
|
||||
|
||||
response := responses[i]
|
||||
|
||||
if len(response.Chart.Result) == 0 {
|
||||
failed++
|
||||
slog.Error("Market response contains no data", "symbol", marketRequests[i].Symbol)
|
||||
continue
|
||||
}
|
||||
|
||||
prices := response.Chart.Result[0].Indicators.Quote[0].Close
|
||||
|
||||
if len(prices) > marketChartDays {
|
||||
prices = prices[len(prices)-marketChartDays:]
|
||||
}
|
||||
|
||||
previous := response.Chart.Result[0].Meta.RegularMarketPrice
|
||||
|
||||
if len(prices) >= 2 && prices[len(prices)-2] != 0 {
|
||||
previous = prices[len(prices)-2]
|
||||
}
|
||||
|
||||
points := SvgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices))
|
||||
|
||||
currency, exists := currencyToSymbol[response.Chart.Result[0].Meta.Currency]
|
||||
|
||||
if !exists {
|
||||
currency = response.Chart.Result[0].Meta.Currency
|
||||
}
|
||||
|
||||
markets = append(markets, Market{
|
||||
MarketRequest: marketRequests[i],
|
||||
Price: response.Chart.Result[0].Meta.RegularMarketPrice,
|
||||
Currency: currency,
|
||||
PercentChange: percentChange(
|
||||
response.Chart.Result[0].Meta.RegularMarketPrice,
|
||||
previous,
|
||||
),
|
||||
SvgChartPoints: points,
|
||||
})
|
||||
}
|
||||
|
||||
if len(markets) == 0 {
|
||||
return nil, ErrNoContent
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
return markets, fmt.Errorf("%w: could not fetch data for %d market(s)", ErrPartialContent, failed)
|
||||
}
|
||||
|
||||
return markets, nil
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package feed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type youtubeFeedResponseXml struct {
|
||||
Channel string `xml:"title"`
|
||||
ChannelLink struct {
|
||||
Href string `xml:"href,attr"`
|
||||
} `xml:"link"`
|
||||
Videos []struct {
|
||||
Title string `xml:"title"`
|
||||
Published string `xml:"published"`
|
||||
Link struct {
|
||||
Href string `xml:"href,attr"`
|
||||
} `xml:"link"`
|
||||
|
||||
Group struct {
|
||||
Thumbnail struct {
|
||||
Url string `xml:"url,attr"`
|
||||
} `xml:"http://search.yahoo.com/mrss/ thumbnail"`
|
||||
} `xml:"http://search.yahoo.com/mrss/ group"`
|
||||
} `xml:"entry"`
|
||||
}
|
||||
|
||||
func parseYoutubeFeedTime(t string) time.Time {
|
||||
parsedTime, err := time.Parse("2006-01-02T15:04:05-07:00", t)
|
||||
|
||||
if err != nil {
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
return parsedTime
|
||||
}
|
||||
|
||||
func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string) (Videos, error) {
|
||||
requests := make([]*http.Request, 0, len(channelIds))
|
||||
|
||||
for i := range channelIds {
|
||||
request, _ := http.NewRequest("GET", "https://www.youtube.com/feeds/videos.xml?channel_id="+channelIds[i], nil)
|
||||
requests = append(requests, request)
|
||||
}
|
||||
|
||||
job := newJob(decodeXmlFromRequestTask[youtubeFeedResponseXml](defaultClient), requests).withWorkers(30)
|
||||
|
||||
responses, errs, err := workerPoolDo(job)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
|
||||
}
|
||||
|
||||
videos := make(Videos, 0, len(channelIds)*15)
|
||||
|
||||
var failed int
|
||||
|
||||
for i := range responses {
|
||||
if errs[i] != nil {
|
||||
failed++
|
||||
slog.Error("Failed to fetch youtube feed", "channel", channelIds[i], "error", errs[i])
|
||||
continue
|
||||
}
|
||||
|
||||
response := responses[i]
|
||||
|
||||
for j := range response.Videos {
|
||||
video := &response.Videos[j]
|
||||
|
||||
// TODO: figure out a better way of skipping shorts
|
||||
if strings.Contains(video.Title, "#shorts") {
|
||||
continue
|
||||
}
|
||||
|
||||
var videoUrl string
|
||||
|
||||
if videoUrlTemplate == "" {
|
||||
videoUrl = video.Link.Href
|
||||
} else {
|
||||
parsedUrl, err := url.Parse(video.Link.Href)
|
||||
|
||||
if err == nil {
|
||||
videoUrl = strings.ReplaceAll(videoUrlTemplate, "{VIDEO-ID}", parsedUrl.Query().Get("v"))
|
||||
} else {
|
||||
videoUrl = "#"
|
||||
}
|
||||
}
|
||||
|
||||
videos = append(videos, Video{
|
||||
ThumbnailUrl: video.Group.Thumbnail.Url,
|
||||
Title: video.Title,
|
||||
Url: videoUrl,
|
||||
Author: response.Channel,
|
||||
AuthorUrl: response.ChannelLink.Href + "/videos",
|
||||
TimePosted: parseYoutubeFeedTime(video.Published),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(videos) == 0 {
|
||||
return nil, ErrNoContent
|
||||
}
|
||||
|
||||
videos.SortByNewest()
|
||||
|
||||
if failed > 0 {
|
||||
return videos, fmt.Errorf("%w: missing videos from %d channels", ErrPartialContent, failed)
|
||||
}
|
||||
|
||||
return videos, nil
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package glance
|
||||
|
||||
// widgetFieldSchema describes one editable field on a widget for the dialog
|
||||
// form generator. Marshaled to JSON and consumed by edit-mode.js.
|
||||
type widgetFieldSchema struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Type string `json:"type"` // string | multiline | number | boolean | select | list-strings | list-objects
|
||||
Help string `json:"help,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
Items []widgetFieldSchema `json:"items,omitempty"` // for list-objects
|
||||
Validator string `json:"validator,omitempty"`
|
||||
Lookup string `json:"lookup,omitempty"`
|
||||
}
|
||||
|
||||
// widgetSchemas maps widget type to the form fields shown in the dialog
|
||||
// editor. Widgets not listed here fall back to the textarea YAML editor.
|
||||
// Keep field order — that's the order the dialog renders them.
|
||||
var widgetSchemas = map[string][]widgetFieldSchema{
|
||||
"rss": {
|
||||
{Key: "title", Label: "Custom title", Type: "string", Help: "Override the widget header. Leave blank for the default."},
|
||||
{Key: "feeds", Label: "Feeds", Type: "list-objects", Required: true, Items: []widgetFieldSchema{
|
||||
{Key: "url", Label: "Feed URL", Type: "string", Required: true, Validator: "rss-feed", Help: "Tested when you save."},
|
||||
{Key: "title", Label: "Custom title (optional)", Type: "string", Help: "Defaults to the title from the feed itself."},
|
||||
}},
|
||||
{Key: "limit", Label: "Items to show", Type: "number", Help: "Default: 25"},
|
||||
{Key: "collapse-after", Label: "Collapse after N items", Type: "number"},
|
||||
{Key: "style", Label: "Style", Type: "select", Options: []string{"vertical-list", "horizontal-cards", "horizontal-cards-2", "detailed-list"}},
|
||||
},
|
||||
|
||||
"weather": {
|
||||
{Key: "title", Label: "Custom title", Type: "string"},
|
||||
{Key: "location", Label: "Location", Type: "string", Required: true, Help: "City and country, e.g. London, GB. Start typing to search.", Lookup: "weather-location", Validator: "weather-location"},
|
||||
{Key: "units", Label: "Units", Type: "select", Options: []string{"metric", "imperial"}},
|
||||
{Key: "hour-format", Label: "Hour format", Type: "select", Options: []string{"24h", "12h"}},
|
||||
{Key: "hide-location", Label: "Hide location label", Type: "boolean"},
|
||||
{Key: "show-area-name", Label: "Show area name", Type: "boolean"},
|
||||
},
|
||||
|
||||
"markets": {
|
||||
{Key: "title", Label: "Custom title", Type: "string"},
|
||||
{Key: "markets", Label: "Symbols", Type: "list-objects", Required: true, Items: []widgetFieldSchema{
|
||||
{Key: "symbol", Label: "Ticker symbol", Type: "string", Required: true, Help: "Search a company name or type a symbol like AAPL or BTC-USD.", Lookup: "market-symbol", Validator: "market-symbol"},
|
||||
{Key: "name", Label: "Display name (optional)", Type: "string"},
|
||||
}},
|
||||
{Key: "sort-by", Label: "Sort by", Type: "select", Options: []string{"absolute-change", "relative-change"}},
|
||||
},
|
||||
|
||||
"monitor": {
|
||||
{Key: "title", Label: "Custom title", Type: "string"},
|
||||
{Key: "sites", Label: "Sites", Type: "list-objects", Required: true, Items: []widgetFieldSchema{
|
||||
{Key: "title", Label: "Display name", Type: "string", Required: true},
|
||||
{Key: "url", Label: "URL", Type: "string", Required: true},
|
||||
{Key: "icon", Label: "Icon URL (optional)", Type: "string"},
|
||||
{Key: "alt-status-codes", Label: "Other OK status codes (comma-separated)", Type: "string", Help: "e.g. 401,403 — codes that should still show OK."},
|
||||
}},
|
||||
{Key: "show-failing-only", Label: "Show failing only", Type: "boolean"},
|
||||
},
|
||||
|
||||
"reddit": {
|
||||
{Key: "title", Label: "Custom title", Type: "string"},
|
||||
{Key: "subreddit", Label: "Subreddit", Type: "string", Required: true, Help: "Just the name, no /r/"},
|
||||
{Key: "sort-by", Label: "Sort by", Type: "select", Options: []string{"hot", "new", "top", "rising"}},
|
||||
{Key: "top-period", Label: "Top period (when sort-by is 'top')", Type: "select", Options: []string{"day", "week", "month", "year", "all"}},
|
||||
{Key: "show-thumbnails", Label: "Show thumbnails", Type: "boolean"},
|
||||
{Key: "show-flairs", Label: "Show flairs", Type: "boolean"},
|
||||
{Key: "limit", Label: "Items to show", Type: "number"},
|
||||
{Key: "collapse-after", Label: "Collapse after N items", Type: "number"},
|
||||
{Key: "style", Label: "Style", Type: "select", Options: []string{"vertical-list", "horizontal-cards"}},
|
||||
},
|
||||
|
||||
"hacker-news": {
|
||||
{Key: "title", Label: "Custom title", Type: "string"},
|
||||
{Key: "sort-by", Label: "Sort by", Type: "select", Options: []string{"top", "new", "best"}},
|
||||
{Key: "limit", Label: "Items to show", Type: "number"},
|
||||
{Key: "collapse-after", Label: "Collapse after N items", Type: "number"},
|
||||
},
|
||||
|
||||
"lobsters": {
|
||||
{Key: "title", Label: "Custom title", Type: "string"},
|
||||
{Key: "sort-by", Label: "Sort by", Type: "select", Options: []string{"hot", "new"}},
|
||||
{Key: "tags", Label: "Tags (filter)", Type: "list-strings"},
|
||||
{Key: "limit", Label: "Items to show", Type: "number"},
|
||||
{Key: "collapse-after", Label: "Collapse after N items", Type: "number"},
|
||||
},
|
||||
|
||||
"videos": {
|
||||
{Key: "title", Label: "Custom title", Type: "string"},
|
||||
{Key: "channels", Label: "YouTube channel IDs", Type: "list-strings", Required: true, Help: "The UC... ID, not the @handle"},
|
||||
{Key: "limit", Label: "Videos to show", Type: "number"},
|
||||
{Key: "style", Label: "Style", Type: "select", Options: []string{"horizontal-cards", "grid-cards", "vertical-list"}},
|
||||
},
|
||||
|
||||
"twitch-channels": {
|
||||
{Key: "title", Label: "Custom title", Type: "string"},
|
||||
{Key: "channels", Label: "Twitch channel names", Type: "list-strings", Required: true},
|
||||
{Key: "collapse-after", Label: "Collapse after N items", Type: "number"},
|
||||
},
|
||||
|
||||
"repository": {
|
||||
{Key: "title", Label: "Custom title", Type: "string"},
|
||||
{Key: "repository", Label: "Repository", Type: "string", Required: true, Help: "owner/name, e.g. glanceapp/glance"},
|
||||
{Key: "token", Label: "GitHub token (optional)", Type: "string", Help: "Increases rate limits. Use ${env:VAR} to read from env."},
|
||||
{Key: "pull-requests-limit", Label: "Pull requests to show", Type: "number"},
|
||||
{Key: "issues-limit", Label: "Issues to show", Type: "number"},
|
||||
{Key: "commits-limit", Label: "Commits to show", Type: "number"},
|
||||
},
|
||||
|
||||
"releases": {
|
||||
{Key: "title", Label: "Custom title", Type: "string"},
|
||||
{Key: "repositories", Label: "Repositories", Type: "list-strings", Required: true, Help: "owner/name per line. Add prefixes like docker:, gitlab:, codeberg: for non-GitHub sources."},
|
||||
{Key: "token", Label: "GitHub token (optional)", Type: "string"},
|
||||
{Key: "show-source-icon", Label: "Show source icon", Type: "boolean"},
|
||||
{Key: "limit", Label: "Releases to show", Type: "number"},
|
||||
{Key: "collapse-after", Label: "Collapse after N items", Type: "number"},
|
||||
},
|
||||
|
||||
"bookmarks": {
|
||||
{Key: "title", Label: "Custom title", Type: "string"},
|
||||
{Key: "groups", Label: "Groups", Type: "list-objects", Required: true, Items: []widgetFieldSchema{
|
||||
{Key: "title", Label: "Group title", Type: "string", Required: true},
|
||||
{Key: "color", Label: "Color (HSL, optional)", Type: "string", Help: "e.g. 200 50 50"},
|
||||
{Key: "links", Label: "Links", Type: "list-objects", Required: true, Items: []widgetFieldSchema{
|
||||
{Key: "title", Label: "Link title", Type: "string", Required: true},
|
||||
{Key: "url", Label: "URL", Type: "string", Required: true},
|
||||
{Key: "icon", Label: "Icon URL (optional)", Type: "string"},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
|
||||
"clock": {
|
||||
{Key: "title", Label: "Custom title", Type: "string"},
|
||||
{Key: "hour-format", Label: "Hour format", Type: "select", Options: []string{"24h", "12h"}},
|
||||
{Key: "timezones", Label: "Extra timezones to show", Type: "list-objects", Items: []widgetFieldSchema{
|
||||
{Key: "timezone", Label: "Timezone (e.g. Europe/London)", Type: "string", Required: true},
|
||||
{Key: "label", Label: "Display label", Type: "string"},
|
||||
}},
|
||||
},
|
||||
|
||||
"calendar": {
|
||||
{Key: "title", Label: "Custom title", Type: "string"},
|
||||
{Key: "first-day-of-week", Label: "First day of week", Type: "select", Options: []string{"monday", "sunday"}},
|
||||
{Key: "show-week-numbers", Label: "Show week numbers", Type: "boolean"},
|
||||
},
|
||||
|
||||
"search": {
|
||||
{Key: "title", Label: "Custom title", Type: "string"},
|
||||
{Key: "search-engine", Label: "Search engine", Type: "select", Options: []string{"duckduckgo", "google", "bing", "kagi", "startpage", "perplexity"}},
|
||||
{Key: "new-tab", Label: "Open results in new tab", Type: "boolean"},
|
||||
{Key: "autofocus", Label: "Autofocus on page load", Type: "boolean"},
|
||||
},
|
||||
|
||||
"iframe": {
|
||||
{Key: "title", Label: "Custom title", Type: "string"},
|
||||
{Key: "source", Label: "URL to embed", Type: "string", Required: true},
|
||||
{Key: "height", Label: "Height (CSS)", Type: "string", Help: "e.g. 400px, 50vh"},
|
||||
},
|
||||
|
||||
"html": {
|
||||
{Key: "title", Label: "Custom title", Type: "string"},
|
||||
{Key: "source", Label: "HTML", Type: "multiline", Required: true},
|
||||
},
|
||||
|
||||
"server-stats": {
|
||||
{Key: "title", Label: "Custom title", Type: "string"},
|
||||
},
|
||||
|
||||
"to-do": {
|
||||
{Key: "title", Label: "Custom title", Type: "string"},
|
||||
},
|
||||
|
||||
"dns-stats": {
|
||||
{Key: "title", Label: "Custom title", Type: "string"},
|
||||
{Key: "service", Label: "Service", Type: "select", Required: true,
|
||||
Options: []string{"adguard", "pihole", "pihole-v6", "technitium"}},
|
||||
{Key: "url", Label: "Service URL", Type: "string", Required: true,
|
||||
Help: "Base URL of your DNS resolver, e.g. http://pi.hole or http://192.168.1.10"},
|
||||
{Key: "username", Label: "Username (AdGuard / Pi-hole v6)", Type: "string"},
|
||||
{Key: "password", Label: "Password", Type: "string",
|
||||
Help: "Use ${env:DNS_PASSWORD} to read from an env var instead of inlining."},
|
||||
{Key: "token", Label: "API token (Pi-hole v5, Technitium)", Type: "string"},
|
||||
{Key: "allow-insecure", Label: "Allow insecure TLS (self-signed certs)", Type: "boolean"},
|
||||
{Key: "hide-graph", Label: "Hide hourly graph", Type: "boolean"},
|
||||
{Key: "hide-top-domains", Label: "Hide top blocked/queried domains", Type: "boolean"},
|
||||
{Key: "hour-format", Label: "Hour format", Type: "select",
|
||||
Options: []string{"24h", "12h"}},
|
||||
},
|
||||
|
||||
"docker-containers": {
|
||||
{Key: "title", Label: "Custom title", Type: "string"},
|
||||
{Key: "sock-path", Label: "Docker socket path", Type: "string",
|
||||
Help: "Default: /var/run/docker.sock"},
|
||||
{Key: "category", Label: "Category filter", Type: "string",
|
||||
Help: "Show only containers labelled glance.category=<value>"},
|
||||
{Key: "hide-by-default", Label: "Hide containers unless labelled glance.hide=false", Type: "boolean"},
|
||||
{Key: "running-only", Label: "Show running containers only", Type: "boolean"},
|
||||
{Key: "format-container-names", Label: "Format container names", Type: "boolean",
|
||||
Help: "Title-cases names like 'home_assistant' → 'Home Assistant'"},
|
||||
},
|
||||
|
||||
"extension": {
|
||||
{Key: "title", Label: "Custom title", Type: "string"},
|
||||
{Key: "url", Label: "Extension URL", Type: "string", Required: true,
|
||||
Help: "URL of the Glance extension endpoint. See the Extensions docs."},
|
||||
{Key: "fallback-content-type", Label: "Fallback content type", Type: "select",
|
||||
Options: []string{"", "html", "iframe"},
|
||||
Help: "Used when the response has no Widget-Content-Type header."},
|
||||
{Key: "allow-potentially-dangerous-html", Label: "Allow raw HTML output", Type: "boolean",
|
||||
Help: "Only enable for extensions you trust — they can inject scripts otherwise."},
|
||||
},
|
||||
|
||||
"change-detection": {
|
||||
{Key: "title", Label: "Custom title", Type: "string"},
|
||||
{Key: "instance-url", Label: "ChangeDetection.io URL", Type: "string",
|
||||
Help: "Defaults to https://www.changedetection.io. Use your own instance for self-hosted."},
|
||||
{Key: "token", Label: "API token", Type: "string",
|
||||
Help: "From your changedetection.io profile. Use ${env:CD_TOKEN} for env-based config."},
|
||||
{Key: "watches", Label: "Watch UUIDs (optional)", Type: "list-strings",
|
||||
Help: "Filter to specific watches. Leave empty to show all."},
|
||||
{Key: "limit", Label: "Items to show", Type: "number"},
|
||||
{Key: "collapse-after", Label: "Collapse after N items", Type: "number"},
|
||||
},
|
||||
|
||||
"custom-api": {
|
||||
{Key: "title", Label: "Custom title", Type: "string"},
|
||||
{Key: "url", Label: "API URL", Type: "string", Required: true},
|
||||
{Key: "method", Label: "HTTP method", Type: "select",
|
||||
Options: []string{"GET", "POST", "PUT", "PATCH", "DELETE"}},
|
||||
{Key: "body-type", Label: "Body type (when POST/PUT/PATCH)", Type: "select",
|
||||
Options: []string{"", "json", "string"}},
|
||||
{Key: "body", Label: "Request body", Type: "multiline",
|
||||
Help: "Optional. Sent as the request body."},
|
||||
{Key: "template", Label: "Output template (Go html/template)", Type: "multiline", Required: true,
|
||||
Help: "Renders the response. See the Custom API docs for available helpers like {{ .JSON.String \"path.to.field\" }}."},
|
||||
{Key: "frameless", Label: "Hide widget frame", Type: "boolean"},
|
||||
},
|
||||
|
||||
"group": {
|
||||
{Key: "title", Label: "Custom title", Type: "string"},
|
||||
{Key: "widgets", Label: "Widgets in group", Type: "list-of-widgets", Required: true,
|
||||
Help: "Each tab inside the group. Nested groups and split-columns aren't allowed."},
|
||||
},
|
||||
|
||||
"split-column": {
|
||||
{Key: "title", Label: "Custom title", Type: "string"},
|
||||
{Key: "max-columns", Label: "Maximum columns (≥2)", Type: "number"},
|
||||
{Key: "widgets", Label: "Widgets shown in the split", Type: "list-of-widgets", Required: true},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package glance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mmcdole/gofeed"
|
||||
)
|
||||
|
||||
// admin-validate.go — server-side validation and lookup helpers used by the
|
||||
// edit-mode dialog. Each kind has both:
|
||||
// - validate(value): is this exact value usable?
|
||||
// - lookup(query): suggestions for typing-as-you-go
|
||||
// Where one of those isn't applicable (e.g. there's no "search" for an
|
||||
// arbitrary RSS URL), only the relevant function is implemented.
|
||||
|
||||
type validationSuggestion struct {
|
||||
Value string `json:"value"` // the string to put into the input
|
||||
Display string `json:"display"` // human-readable label for the dropdown
|
||||
Hint string `json:"hint,omitempty"` // small caption (e.g. "AAPL — Apple Inc.")
|
||||
Extra map[string]string `json:"extra,omitempty"` // sibling field values to auto-fill (e.g. {"name": "Apple Inc."})
|
||||
}
|
||||
|
||||
type validationResult struct {
|
||||
Valid bool `json:"valid"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Hint string `json:"hint,omitempty"` // shown next to a successful field
|
||||
Suggestions []validationSuggestion `json:"suggestions,omitempty"`
|
||||
}
|
||||
|
||||
var validateClient = &http.Client{Timeout: 8 * time.Second}
|
||||
|
||||
func ctxWithTimeout(d time.Duration) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), d)
|
||||
}
|
||||
|
||||
func httpGetJSON(rawURL string, into interface{}) error {
|
||||
ctx, cancel := ctxWithTimeout(8 * time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Yahoo's undocumented endpoints reject the default Go UA. Use a browser-y
|
||||
// string so search/quote actually return data.
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
|
||||
req.Header.Set("Accept", "application/json,text/plain,*/*")
|
||||
resp, err := validateClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(into)
|
||||
}
|
||||
|
||||
// ---------- weather-location (Open-Meteo geocoding) ----------
|
||||
|
||||
type openMeteoGeo struct {
|
||||
Results []struct {
|
||||
Name string `json:"name"`
|
||||
Country string `json:"country"`
|
||||
Admin1 string `json:"admin1"`
|
||||
CountryCode string `json:"country_code"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
func geocodeWeatherLocation(query string) ([]validationSuggestion, error) {
|
||||
q := strings.TrimSpace(query)
|
||||
if q == "" {
|
||||
return nil, nil
|
||||
}
|
||||
endpoint := "https://geocoding-api.open-meteo.com/v1/search?count=8&format=json&name=" + url.QueryEscape(q)
|
||||
var data openMeteoGeo
|
||||
if err := httpGetJSON(endpoint, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]validationSuggestion, 0, len(data.Results))
|
||||
for _, r := range data.Results {
|
||||
// Glance's weather widget accepts strings like "London, GB". We use that
|
||||
// canonical form as the suggestion value so saving works without changes.
|
||||
v := r.Name
|
||||
if r.CountryCode != "" {
|
||||
v = v + ", " + r.CountryCode
|
||||
}
|
||||
display := r.Name
|
||||
if r.Admin1 != "" {
|
||||
display += ", " + r.Admin1
|
||||
}
|
||||
if r.Country != "" {
|
||||
display += ", " + r.Country
|
||||
}
|
||||
out = append(out, validationSuggestion{
|
||||
Value: v,
|
||||
Display: display,
|
||||
Hint: fmt.Sprintf("%.2f, %.2f", r.Latitude, r.Longitude),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateWeatherLocation(value string) validationResult {
|
||||
v := strings.TrimSpace(value)
|
||||
if v == "" {
|
||||
return validationResult{Valid: false, Error: "Location is required."}
|
||||
}
|
||||
// Try to verify against Open-Meteo, but never block the save on failure
|
||||
// — the user might know better than us, and the weather widget will
|
||||
// surface a clear error at update time if it really can't geocode.
|
||||
searchQuery := v
|
||||
if comma := strings.IndexByte(v, ','); comma > 0 {
|
||||
searchQuery = strings.TrimSpace(v[:comma])
|
||||
}
|
||||
suggestions, err := geocodeWeatherLocation(searchQuery)
|
||||
if err != nil || len(suggestions) == 0 {
|
||||
return validationResult{Valid: true, Hint: "couldn't verify — will be tested when the widget updates"}
|
||||
}
|
||||
return validationResult{Valid: true, Hint: suggestions[0].Display}
|
||||
}
|
||||
|
||||
// ---------- market-symbol (Yahoo Finance) ----------
|
||||
|
||||
type yahooSearchResp struct {
|
||||
Quotes []struct {
|
||||
Symbol string `json:"symbol"`
|
||||
ShortName string `json:"shortname"`
|
||||
LongName string `json:"longname"`
|
||||
QuoteType string `json:"quoteType"`
|
||||
Exchange string `json:"exchDisp"`
|
||||
} `json:"quotes"`
|
||||
}
|
||||
|
||||
type yahooChartResp struct {
|
||||
Chart struct {
|
||||
Result []struct {
|
||||
Meta struct {
|
||||
Symbol string `json:"symbol"`
|
||||
LongName string `json:"longName"`
|
||||
ShortName string `json:"shortName"`
|
||||
ExchangeName string `json:"exchangeName"`
|
||||
FullExchangeName string `json:"fullExchangeName"`
|
||||
Currency string `json:"currency"`
|
||||
RegularMarketPrice float64 `json:"regularMarketPrice"`
|
||||
} `json:"meta"`
|
||||
} `json:"result"`
|
||||
Error *struct {
|
||||
Code string `json:"code"`
|
||||
Description string `json:"description"`
|
||||
} `json:"error"`
|
||||
} `json:"chart"`
|
||||
}
|
||||
|
||||
func lookupMarketSymbol(query string) ([]validationSuggestion, error) {
|
||||
q := strings.TrimSpace(query)
|
||||
if q == "" {
|
||||
return nil, nil
|
||||
}
|
||||
// Try query1 first; fall back to query2 since one or the other is usually up.
|
||||
var data yahooSearchResp
|
||||
q1 := "https://query1.finance.yahoo.com/v1/finance/search?quotesCount=8&newsCount=0&q=" + url.QueryEscape(q)
|
||||
if err := httpGetJSON(q1, &data); err != nil || len(data.Quotes) == 0 {
|
||||
q2 := "https://query2.finance.yahoo.com/v1/finance/search?quotesCount=8&newsCount=0&q=" + url.QueryEscape(q)
|
||||
if err2 := httpGetJSON(q2, &data); err2 != nil {
|
||||
return nil, err2
|
||||
}
|
||||
}
|
||||
out := make([]validationSuggestion, 0, len(data.Quotes))
|
||||
for _, qr := range data.Quotes {
|
||||
name := qr.LongName
|
||||
if name == "" {
|
||||
name = qr.ShortName
|
||||
}
|
||||
if qr.Symbol == "" {
|
||||
continue
|
||||
}
|
||||
display := qr.Symbol
|
||||
if name != "" {
|
||||
display = qr.Symbol + " — " + name
|
||||
}
|
||||
hint := qr.QuoteType
|
||||
if qr.Exchange != "" {
|
||||
hint = strings.TrimSpace(strings.Join([]string{qr.QuoteType, qr.Exchange}, " · "))
|
||||
}
|
||||
extra := map[string]string{}
|
||||
if name != "" {
|
||||
extra["name"] = name
|
||||
}
|
||||
out = append(out, validationSuggestion{
|
||||
Value: qr.Symbol,
|
||||
Display: display,
|
||||
Hint: hint,
|
||||
Extra: extra,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateMarketSymbol(value string) validationResult {
|
||||
sym := strings.ToUpper(strings.TrimSpace(value))
|
||||
if sym == "" {
|
||||
return validationResult{Valid: false, Error: "Symbol is required."}
|
||||
}
|
||||
// v8 chart endpoint is the most reliable Yahoo entry point — it serves
|
||||
// public data and tends to ignore the auth cookies the v7 quote API
|
||||
// started requiring. We just need to confirm the symbol resolves to a
|
||||
// real instrument; we don't care about price.
|
||||
endpoint := "https://query1.finance.yahoo.com/v8/finance/chart/" + url.PathEscape(sym) + "?range=1d&interval=1d"
|
||||
var data yahooChartResp
|
||||
if err := httpGetJSON(endpoint, &data); err != nil {
|
||||
return validationResult{Valid: true, Hint: "couldn't verify (Yahoo unreachable)"}
|
||||
}
|
||||
if data.Chart.Error != nil && data.Chart.Error.Code != "" {
|
||||
return validationResult{Valid: false, Error: "Yahoo: " + data.Chart.Error.Description}
|
||||
}
|
||||
if len(data.Chart.Result) == 0 {
|
||||
return validationResult{Valid: false, Error: "No data returned for " + sym + "."}
|
||||
}
|
||||
m := data.Chart.Result[0].Meta
|
||||
name := m.LongName
|
||||
if name == "" {
|
||||
name = m.ShortName
|
||||
}
|
||||
hint := name
|
||||
if m.FullExchangeName != "" && name != "" {
|
||||
hint = name + " · " + m.FullExchangeName
|
||||
}
|
||||
return validationResult{Valid: true, Hint: hint}
|
||||
}
|
||||
|
||||
// ---------- rss-feed (gofeed) ----------
|
||||
|
||||
func validateRSSFeed(value string) validationResult {
|
||||
u := strings.TrimSpace(value)
|
||||
if u == "" {
|
||||
return validationResult{Valid: false, Error: "URL is required."}
|
||||
}
|
||||
if _, err := url.ParseRequestURI(u); err != nil {
|
||||
return validationResult{Valid: false, Error: "Doesn't look like a URL."}
|
||||
}
|
||||
ctx, cancel := ctxWithTimeout(8 * time.Second)
|
||||
defer cancel()
|
||||
parser := gofeed.NewParser()
|
||||
parser.Client = validateClient
|
||||
feed, err := parser.ParseURLWithContext(u, ctx)
|
||||
if err != nil {
|
||||
return validationResult{Valid: false, Error: "Feed parse failed: " + err.Error()}
|
||||
}
|
||||
hint := feed.Title
|
||||
if feed.Items != nil {
|
||||
hint = fmt.Sprintf("%s · %d items", feed.Title, len(feed.Items))
|
||||
}
|
||||
return validationResult{Valid: true, Hint: hint}
|
||||
}
|
||||
|
||||
// ---------- HTTP handlers ----------
|
||||
|
||||
func (a *application) handleAdminValidate(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.adminAccessAllowed(w, r) {
|
||||
return
|
||||
}
|
||||
kind := r.PathValue("kind")
|
||||
var body struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var result validationResult
|
||||
switch kind {
|
||||
case "weather-location":
|
||||
result = validateWeatherLocation(body.Value)
|
||||
case "market-symbol":
|
||||
result = validateMarketSymbol(body.Value)
|
||||
case "rss-feed":
|
||||
result = validateRSSFeed(body.Value)
|
||||
default:
|
||||
http.Error(w, "unknown validator: "+kind, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
func (a *application) handleAdminLookup(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.adminAccessAllowed(w, r) {
|
||||
return
|
||||
}
|
||||
kind := r.PathValue("kind")
|
||||
var body struct {
|
||||
Query string `json:"query"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
suggestions []validationSuggestion
|
||||
err error
|
||||
)
|
||||
switch kind {
|
||||
case "weather-location":
|
||||
suggestions, err = geocodeWeatherLocation(body.Query)
|
||||
case "market-symbol":
|
||||
suggestions, err = lookupMarketSymbol(body.Query)
|
||||
default:
|
||||
http.Error(w, "unknown lookup: "+kind, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
// Lookup failures shouldn't be hard errors — return empty suggestions
|
||||
// so the UI just shows nothing rather than alarming the user.
|
||||
suggestions = nil
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(struct {
|
||||
Suggestions []validationSuggestion `json:"suggestions"`
|
||||
}{Suggestions: suggestions})
|
||||
}
|
||||
@@ -0,0 +1,913 @@
|
||||
package glance
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
adminIndexTemplate = mustParseTemplate("admin.html", "document.html", "footer.html")
|
||||
adminPageTemplate = mustParseTemplate("admin-page.html", "document.html", "footer.html")
|
||||
adminWidgetTemplate = mustParseTemplate("admin-widget.html", "document.html", "footer.html")
|
||||
adminPageSettingsTemplate = mustParseTemplate("admin-page-settings.html", "document.html", "footer.html")
|
||||
adminSiteSettingsTemplate = mustParseTemplate("admin-site-settings.html", "document.html", "footer.html")
|
||||
adminThemeSettingsTemplate = mustParseTemplate("admin-theme-settings.html", "document.html", "footer.html")
|
||||
adminThemePresetTemplate = mustParseTemplate("admin-theme-preset.html", "document.html", "footer.html")
|
||||
)
|
||||
|
||||
// allWidgetTypes mirrors the switch in newWidget(). Aliases ("stocks") omitted.
|
||||
var allWidgetTypes = []string{
|
||||
"bookmarks", "calendar", "change-detection", "clock", "custom-api",
|
||||
"dns-stats", "docker-containers", "extension", "group", "hacker-news",
|
||||
"html", "iframe", "lobsters", "markets", "monitor", "reddit",
|
||||
"releases", "repository", "rss", "search", "server-stats",
|
||||
"split-column", "to-do", "twitch-channels", "twitch-top-games",
|
||||
"videos", "weather",
|
||||
}
|
||||
|
||||
// widgetFieldReferences caches the marshaled YAML of an empty instance of
|
||||
// each widget type, used to show users what fields are available when
|
||||
// editing. Computed once at startup.
|
||||
var widgetFieldReferences = func() map[string]string {
|
||||
out := make(map[string]string, len(allWidgetTypes))
|
||||
for _, t := range allWidgetTypes {
|
||||
w, err := newWidget(t)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
b, err := yaml.Marshal(w)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out[t] = string(b)
|
||||
}
|
||||
return out
|
||||
}()
|
||||
|
||||
// widgetDocsAnchors maps a widget type to its anchor in upstream Glance docs.
|
||||
// Anchors are GitHub-rendered from the headings in docs/configuration.md.
|
||||
var widgetDocsAnchors = map[string]string{
|
||||
"bookmarks": "bookmarks",
|
||||
"calendar": "calendar",
|
||||
"calendar-legacy": "calendar-legacy",
|
||||
"change-detection": "changedetectionio",
|
||||
"clock": "clock",
|
||||
"custom-api": "custom-api",
|
||||
"dns-stats": "dns-stats",
|
||||
"docker-containers": "docker-containers",
|
||||
"extension": "extension",
|
||||
"group": "group",
|
||||
"hacker-news": "hacker-news",
|
||||
"lobsters": "lobsters",
|
||||
"markets": "markets",
|
||||
"monitor": "monitor",
|
||||
"reddit": "reddit",
|
||||
"releases": "releases",
|
||||
"repository": "repository",
|
||||
"rss": "rss",
|
||||
"search": "search-widget",
|
||||
"server-stats": "server-stats",
|
||||
"split-column": "split-column",
|
||||
"to-do": "todo",
|
||||
"twitch-channels": "twitch-channels",
|
||||
"twitch-top-games": "twitch-top-games",
|
||||
"videos": "videos",
|
||||
"weather": "weather",
|
||||
}
|
||||
|
||||
func widgetDocsURL(widgetType string) string {
|
||||
anchor, ok := widgetDocsAnchors[widgetType]
|
||||
if !ok {
|
||||
return "https://github.com/glanceapp/glance/blob/main/docs/configuration.md#widgets"
|
||||
}
|
||||
return "https://github.com/glanceapp/glance/blob/main/docs/configuration.md#" + anchor
|
||||
}
|
||||
|
||||
// widgetStarters provides hand-curated minimal-but-meaningful YAML for each
|
||||
// widget type, drawn from the upstream docs. Used to pre-fill the editor
|
||||
// when adding a new widget so users see a working shape they can adapt.
|
||||
var widgetStarters = map[string]string{
|
||||
"bookmarks": `type: bookmarks
|
||||
groups:
|
||||
- title: Sites
|
||||
links:
|
||||
- title: Glance
|
||||
url: https://github.com/glanceapp/glance
|
||||
`,
|
||||
"calendar": "type: calendar\n",
|
||||
"calendar-legacy": "type: calendar-legacy\n",
|
||||
"change-detection": `type: change-detection
|
||||
instance-url: https://changedetection.example.com/
|
||||
token: ${CHANGE_DETECTION_TOKEN}
|
||||
`,
|
||||
"clock": "type: clock\n",
|
||||
"custom-api": `type: custom-api
|
||||
url: https://api.example.com/data
|
||||
template: |
|
||||
<p>Replace with a Go template that renders the JSON response.</p>
|
||||
`,
|
||||
"dns-stats": `type: dns-stats
|
||||
service: pihole
|
||||
url: http://pi.hole
|
||||
allow-insecure: true
|
||||
username: admin
|
||||
password: ${DNS_PASSWORD}
|
||||
`,
|
||||
"docker-containers": "type: docker-containers\n",
|
||||
"extension": `type: extension
|
||||
url: https://example.com/extension
|
||||
`,
|
||||
"group": `type: group
|
||||
widgets:
|
||||
- type: hacker-news
|
||||
- type: lobsters
|
||||
`,
|
||||
"hacker-news": "type: hacker-news\n",
|
||||
"html": `type: html
|
||||
source: |
|
||||
<p>Hello from a custom HTML widget.</p>
|
||||
`,
|
||||
"iframe": `type: iframe
|
||||
source: https://example.com
|
||||
`,
|
||||
"lobsters": "type: lobsters\n",
|
||||
"markets": `type: markets
|
||||
markets:
|
||||
- symbol: SPY
|
||||
name: S&P 500
|
||||
- symbol: BTC-USD
|
||||
name: Bitcoin
|
||||
`,
|
||||
"monitor": `type: monitor
|
||||
title: Services
|
||||
sites:
|
||||
- title: Example
|
||||
url: https://example.com
|
||||
`,
|
||||
"reddit": `type: reddit
|
||||
subreddit: technology
|
||||
`,
|
||||
"releases": `type: releases
|
||||
repositories:
|
||||
- glanceapp/glance
|
||||
`,
|
||||
"repository": `type: repository
|
||||
repository: glanceapp/glance
|
||||
`,
|
||||
"rss": `type: rss
|
||||
feeds:
|
||||
- url: https://www.theverge.com/rss/index.xml
|
||||
title: The Verge
|
||||
`,
|
||||
"search": "type: search\n",
|
||||
"server-stats": "type: server-stats\n",
|
||||
"split-column": `type: split-column
|
||||
widgets:
|
||||
- type: clock
|
||||
- type: search
|
||||
`,
|
||||
"to-do": "type: to-do\n",
|
||||
"twitch-channels": `type: twitch-channels
|
||||
channels:
|
||||
- shroud
|
||||
`,
|
||||
"twitch-top-games": "type: twitch-top-games\n",
|
||||
"videos": `type: videos
|
||||
channels:
|
||||
- UCXuqSBlHAE6Xw-yeJA0Tunw
|
||||
`,
|
||||
"weather": `type: weather
|
||||
location: London, GB
|
||||
`,
|
||||
}
|
||||
|
||||
func widgetStarterYAML(widgetType string) string {
|
||||
if s, ok := widgetStarters[widgetType]; ok {
|
||||
return s
|
||||
}
|
||||
return "type: " + widgetType + "\n"
|
||||
}
|
||||
|
||||
type adminWidgetView struct {
|
||||
ColumnIndex int
|
||||
WidgetIndex int
|
||||
ID uint64
|
||||
Title string
|
||||
Type string
|
||||
IsFirst bool
|
||||
IsLast bool
|
||||
}
|
||||
|
||||
type adminColumnView struct {
|
||||
Index int
|
||||
Size string
|
||||
Widgets []adminWidgetView
|
||||
}
|
||||
|
||||
type adminPageSummary struct {
|
||||
Title string
|
||||
Slug string
|
||||
WidgetCount int
|
||||
IsFirst bool
|
||||
IsLast bool
|
||||
}
|
||||
|
||||
type adminBackupSummary struct {
|
||||
N int
|
||||
Time string // human-readable, e.g. "2 minutes ago"
|
||||
SizeKB int
|
||||
Present bool
|
||||
}
|
||||
|
||||
type adminPageDetail struct {
|
||||
Title string
|
||||
Slug string
|
||||
HeadWidgets []adminWidgetView
|
||||
Columns []adminColumnView
|
||||
}
|
||||
|
||||
type adminPageSettings struct {
|
||||
Name string
|
||||
Slug string
|
||||
Width string
|
||||
DesktopNavigationWidth string
|
||||
ShowMobileHeader bool
|
||||
HideDesktopNavigation bool
|
||||
CenterVertically bool
|
||||
}
|
||||
|
||||
type adminTemplateData struct {
|
||||
App *application
|
||||
Request templateRequestData
|
||||
|
||||
Pages []adminPageSummary
|
||||
Page *adminPageDetail
|
||||
Widget *adminWidgetView
|
||||
WidgetTypes []string
|
||||
WidgetYAML string
|
||||
LoadError string
|
||||
FieldReference string
|
||||
WidgetExample string
|
||||
DocsURL string
|
||||
IsNew bool
|
||||
ErrorMessage string
|
||||
|
||||
PageSettings *adminPageSettings
|
||||
IsFirstPage bool
|
||||
IsLastPage bool
|
||||
|
||||
SiteSettings *adminSiteSettings
|
||||
ThemeSettings *adminThemeSettings
|
||||
PresetForm *adminPresetFormData
|
||||
|
||||
Backups []adminBackupSummary
|
||||
}
|
||||
|
||||
type adminThemeSettings struct {
|
||||
BackgroundColorHex string
|
||||
PrimaryColorHex string
|
||||
PositiveColorHex string
|
||||
NegativeColorHex string
|
||||
Light bool
|
||||
DisablePicker bool
|
||||
ContrastMultiplier float32
|
||||
TextSaturationMultiplier float32
|
||||
CustomCSSFile string
|
||||
Presets []adminPresetItem
|
||||
CatalogThemes []catalogTheme
|
||||
}
|
||||
|
||||
type adminPresetItem struct {
|
||||
Key string
|
||||
BackgroundColorHex string
|
||||
PrimaryColorHex string
|
||||
PositiveColorHex string
|
||||
NegativeColorHex string
|
||||
Light bool
|
||||
ContrastMultiplier float32
|
||||
TextSaturationMultiplier float32
|
||||
PreviewHTML template.HTML
|
||||
}
|
||||
|
||||
type adminPresetFormData struct {
|
||||
Key string
|
||||
BackgroundColorHex string
|
||||
PrimaryColorHex string
|
||||
PositiveColorHex string
|
||||
NegativeColorHex string
|
||||
Light bool
|
||||
ContrastMultiplier float32
|
||||
TextSaturationMultiplier float32
|
||||
IsNew bool
|
||||
ErrorMessage string
|
||||
}
|
||||
|
||||
type catalogTheme struct {
|
||||
Name string
|
||||
Key string
|
||||
PreviewHTML template.HTML
|
||||
BgHex string
|
||||
PrimaryHex string
|
||||
PositiveHex string
|
||||
NegativeHex string
|
||||
Light bool
|
||||
CM float32
|
||||
TSM float32
|
||||
}
|
||||
|
||||
var builtinThemeCatalog []catalogTheme
|
||||
|
||||
func parseHSLStr(s string) *hslColorField {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
matches := hslColorFieldPattern.FindStringSubmatch(s)
|
||||
if len(matches) != 4 {
|
||||
return nil
|
||||
}
|
||||
h, _ := strconv.ParseFloat(matches[1], 64)
|
||||
sat, _ := strconv.ParseFloat(matches[2], 64)
|
||||
l, _ := strconv.ParseFloat(matches[3], 64)
|
||||
return &hslColorField{H: h, S: sat, L: l}
|
||||
}
|
||||
|
||||
func safeHex(c *hslColorField) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return c.ToHex()
|
||||
}
|
||||
|
||||
func init() {
|
||||
type rawEntry struct {
|
||||
name, key, bg, primary, positive, negative string
|
||||
cm, tsm float32
|
||||
light bool
|
||||
}
|
||||
entries := []rawEntry{
|
||||
{"Teal City", "teal-city", "225 14 15", "157 47 65", "", "", 1.1, 0, false},
|
||||
{"Catppuccin Frappe", "catppuccin-frappe", "229 19 23", "222 74 74", "96 44 68", "359 68 71", 1.2, 0, false},
|
||||
{"Catppuccin Macchiato", "catppuccin-macchiato", "232 23 18", "220 83 75", "105 48 72", "351 74 73", 1.2, 0, false},
|
||||
{"Catppuccin Mocha", "catppuccin-mocha", "240 21 15", "217 92 83", "115 54 76", "347 70 65", 1.2, 0, false},
|
||||
{"Camouflage", "camouflage", "186 21 20", "97 13 80", "", "", 1.2, 0, false},
|
||||
{"Gruvbox Dark", "gruvbox-dark", "0 0 16", "43 59 81", "61 66 44", "6 96 59", 0, 0, false},
|
||||
{"Kanagawa Dark", "kanagawa-dark", "240 13 14", "51 33 68", "", "358 100 68", 1.2, 0, false},
|
||||
{"Tucan", "tucan", "50 1 6", "24 97 58", "", "209 88 54", 0, 0, false},
|
||||
{"Dracula", "dracula", "231 15 21", "265 89 79", "135 94 66", "0 100 67", 1.2, 0, false},
|
||||
{"Shades of Purple", "shades-of-purple", "243 33 25", "50 100 49", "98 82 71", "12 77 52", 1.2, 0, false},
|
||||
{"Neon Pink", "neon-pink", "240 27 11", "321 100 71", "165 78 51", "360 100 71", 1.5, 0, false},
|
||||
{"Catppuccin Latte", "catppuccin-latte", "220 23 95", "220 91 54", "109 58 40", "347 87 44", 1.0, 0, true},
|
||||
{"Peachy", "peachy", "28 40 77", "155 100 20", "", "0 100 60", 1.1, 0.5, true},
|
||||
{"Zebra", "zebra", "0 0 95", "0 0 10", "", "0 90 50", 0, 0, true},
|
||||
}
|
||||
for _, e := range entries {
|
||||
p := themeProperties{
|
||||
BackgroundColor: parseHSLStr(e.bg),
|
||||
PrimaryColor: parseHSLStr(e.primary),
|
||||
PositiveColor: parseHSLStr(e.positive),
|
||||
NegativeColor: parseHSLStr(e.negative),
|
||||
ContrastMultiplier: e.cm,
|
||||
TextSaturationMultiplier: e.tsm,
|
||||
Light: e.light,
|
||||
Key: e.key,
|
||||
}
|
||||
_ = p.init()
|
||||
builtinThemeCatalog = append(builtinThemeCatalog, catalogTheme{
|
||||
Name: e.name,
|
||||
Key: e.key,
|
||||
PreviewHTML: p.PreviewHTML,
|
||||
BgHex: safeHex(p.BackgroundColor),
|
||||
PrimaryHex: safeHex(p.PrimaryColor),
|
||||
PositiveHex: safeHex(p.PositiveColor),
|
||||
NegativeHex: safeHex(p.NegativeColor),
|
||||
Light: e.light,
|
||||
CM: e.cm,
|
||||
TSM: e.tsm,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type adminSiteSettings struct {
|
||||
AppName string
|
||||
LogoText string
|
||||
LogoURL string
|
||||
FaviconURL string
|
||||
AppIconURL string
|
||||
AppBackgroundColor string
|
||||
HideFooter bool
|
||||
CustomFooter string
|
||||
}
|
||||
|
||||
func (a *application) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.adminAccessAllowed(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
pages := a.freshPagesFromDisk()
|
||||
last := len(pages) - 1
|
||||
summaries := make([]adminPageSummary, 0, len(pages))
|
||||
for p := range pages {
|
||||
page := &pages[p]
|
||||
count := len(page.HeadWidgets)
|
||||
for c := range page.Columns {
|
||||
count += len(page.Columns[c].Widgets)
|
||||
}
|
||||
summaries = append(summaries, adminPageSummary{
|
||||
Title: page.Title,
|
||||
Slug: page.Slug,
|
||||
WidgetCount: count,
|
||||
IsFirst: p == 0,
|
||||
IsLast: p == last,
|
||||
})
|
||||
}
|
||||
|
||||
data := adminTemplateData{App: a, Pages: summaries, Backups: a.collectBackups()}
|
||||
a.populateTemplateRequestData(&data.Request, r)
|
||||
renderAdminTemplate(w, adminIndexTemplate, data)
|
||||
}
|
||||
|
||||
func (a *application) collectBackups() []adminBackupSummary {
|
||||
out := make([]adminBackupSummary, 0, maxBackups)
|
||||
now := time.Now()
|
||||
for n := 1; n <= maxBackups; n++ {
|
||||
info, err := os.Stat(backupPath(a.ConfigPath, n))
|
||||
if err != nil {
|
||||
out = append(out, adminBackupSummary{N: n, Present: false})
|
||||
continue
|
||||
}
|
||||
out = append(out, adminBackupSummary{
|
||||
N: n,
|
||||
Time: humanizeDuration(now.Sub(info.ModTime())) + " ago",
|
||||
SizeKB: int(info.Size() / 1024),
|
||||
Present: true,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func humanizeDuration(d time.Duration) string {
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm", int(d.Minutes()))
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%dh", int(d.Hours()))
|
||||
default:
|
||||
return fmt.Sprintf("%dd", int(d.Hours()/24))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *application) handleAdminPage(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.adminAccessAllowed(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
pages := a.freshPagesFromDisk()
|
||||
page, _, exists := findPageBySlug(pages, r.PathValue("page"))
|
||||
if !exists {
|
||||
a.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
detail := adminPageDetail{Title: page.Title, Slug: page.Slug}
|
||||
|
||||
for i, widget := range page.HeadWidgets {
|
||||
detail.HeadWidgets = append(detail.HeadWidgets, adminWidgetView{
|
||||
ColumnIndex: -1,
|
||||
WidgetIndex: i,
|
||||
ID: widget.GetID(),
|
||||
Title: widget.GetTitle(),
|
||||
Type: widget.GetType(),
|
||||
})
|
||||
}
|
||||
|
||||
for c := range page.Columns {
|
||||
col := &page.Columns[c]
|
||||
view := adminColumnView{Index: c, Size: col.Size}
|
||||
last := len(col.Widgets) - 1
|
||||
for w, widget := range col.Widgets {
|
||||
view.Widgets = append(view.Widgets, adminWidgetView{
|
||||
ColumnIndex: c,
|
||||
WidgetIndex: w,
|
||||
ID: widget.GetID(),
|
||||
Title: widget.GetTitle(),
|
||||
Type: widget.GetType(),
|
||||
IsFirst: w == 0,
|
||||
IsLast: w == last,
|
||||
})
|
||||
}
|
||||
detail.Columns = append(detail.Columns, view)
|
||||
}
|
||||
|
||||
data := adminTemplateData{App: a, Page: &detail, WidgetTypes: allWidgetTypes}
|
||||
a.populateTemplateRequestData(&data.Request, r)
|
||||
renderAdminTemplate(w, adminPageTemplate, data)
|
||||
}
|
||||
|
||||
func (a *application) handleAdminWidget(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.adminAccessAllowed(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
pages := a.freshPagesFromDisk()
|
||||
page, pageIdx, exists := findPageBySlug(pages, r.PathValue("page"))
|
||||
if !exists {
|
||||
a.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
col, err := strconv.Atoi(r.PathValue("col"))
|
||||
if err != nil {
|
||||
a.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
idx, err := strconv.Atoi(r.PathValue("idx"))
|
||||
if err != nil {
|
||||
a.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
var widget widget
|
||||
if col == -1 {
|
||||
if idx < 0 || idx >= len(page.HeadWidgets) {
|
||||
a.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
widget = page.HeadWidgets[idx]
|
||||
} else {
|
||||
if col < 0 || col >= len(page.Columns) {
|
||||
a.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
column := &page.Columns[col]
|
||||
if idx < 0 || idx >= len(column.Widgets) {
|
||||
a.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
widget = column.Widgets[idx]
|
||||
}
|
||||
|
||||
yamlText, loadErr := loadWidgetYAML(a.ConfigPath, pageIdx, col, idx)
|
||||
|
||||
a.renderWidgetEditor(w, r, editorRenderInput{
|
||||
pageTitle: page.Title,
|
||||
pageSlug: page.Slug,
|
||||
col: col,
|
||||
idx: idx,
|
||||
widgetType: widget.GetType(),
|
||||
widgetTitle: widget.GetTitle(),
|
||||
yamlText: yamlText,
|
||||
loadError: loadErr,
|
||||
})
|
||||
}
|
||||
|
||||
func hexOf(c *hslColorField) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return c.ToHex()
|
||||
}
|
||||
|
||||
func (a *application) freshPresetsFromDisk() []adminPresetItem {
|
||||
contents, _, err := parseYAMLIncludes(a.ConfigPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
cfg, err := newConfigFromYAML(contents)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var keys []string
|
||||
for k := range cfg.Theme.Presets.Items() {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
items := make([]adminPresetItem, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
p, _ := cfg.Theme.Presets.Get(key)
|
||||
p.Key = key
|
||||
_ = p.init()
|
||||
items = append(items, adminPresetItem{
|
||||
Key: key,
|
||||
BackgroundColorHex: safeHex(p.BackgroundColor),
|
||||
PrimaryColorHex: safeHex(p.PrimaryColor),
|
||||
PositiveColorHex: safeHex(p.PositiveColor),
|
||||
NegativeColorHex: safeHex(p.NegativeColor),
|
||||
Light: p.Light,
|
||||
ContrastMultiplier: p.ContrastMultiplier,
|
||||
TextSaturationMultiplier: p.TextSaturationMultiplier,
|
||||
PreviewHTML: p.PreviewHTML,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (a *application) handleAdminThemeSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.adminAccessAllowed(w, r) {
|
||||
return
|
||||
}
|
||||
t := a.Config.Theme
|
||||
settings := &adminThemeSettings{
|
||||
BackgroundColorHex: hexOf(t.BackgroundColor),
|
||||
PrimaryColorHex: hexOf(t.PrimaryColor),
|
||||
PositiveColorHex: hexOf(t.PositiveColor),
|
||||
NegativeColorHex: hexOf(t.NegativeColor),
|
||||
Light: t.Light,
|
||||
DisablePicker: t.DisablePicker,
|
||||
ContrastMultiplier: t.ContrastMultiplier,
|
||||
TextSaturationMultiplier: t.TextSaturationMultiplier,
|
||||
CustomCSSFile: t.CustomCSSFile,
|
||||
Presets: a.freshPresetsFromDisk(),
|
||||
CatalogThemes: builtinThemeCatalog,
|
||||
}
|
||||
data := adminTemplateData{App: a, ThemeSettings: settings}
|
||||
a.populateTemplateRequestData(&data.Request, r)
|
||||
renderAdminTemplate(w, adminThemeSettingsTemplate, data)
|
||||
}
|
||||
|
||||
func (a *application) handleAdminThemePreset(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.adminAccessAllowed(w, r) {
|
||||
return
|
||||
}
|
||||
key := r.PathValue("key")
|
||||
var form adminPresetFormData
|
||||
|
||||
if key == "" || key == "new" {
|
||||
form.IsNew = true
|
||||
q := r.URL.Query()
|
||||
form.Key = q.Get("name")
|
||||
form.BackgroundColorHex = q.Get("bg")
|
||||
form.PrimaryColorHex = q.Get("primary")
|
||||
form.PositiveColorHex = q.Get("positive")
|
||||
form.NegativeColorHex = q.Get("negative")
|
||||
form.Light = q.Get("light") == "true"
|
||||
if cm := q.Get("cm"); cm != "" {
|
||||
if v, err := strconv.ParseFloat(cm, 32); err == nil {
|
||||
form.ContrastMultiplier = float32(v)
|
||||
}
|
||||
}
|
||||
if tsm := q.Get("tsm"); tsm != "" {
|
||||
if v, err := strconv.ParseFloat(tsm, 32); err == nil {
|
||||
form.TextSaturationMultiplier = float32(v)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
presets := a.freshPresetsFromDisk()
|
||||
found := false
|
||||
for _, p := range presets {
|
||||
if p.Key == key {
|
||||
form = adminPresetFormData{
|
||||
Key: p.Key,
|
||||
BackgroundColorHex: p.BackgroundColorHex,
|
||||
PrimaryColorHex: p.PrimaryColorHex,
|
||||
PositiveColorHex: p.PositiveColorHex,
|
||||
NegativeColorHex: p.NegativeColorHex,
|
||||
Light: p.Light,
|
||||
ContrastMultiplier: p.ContrastMultiplier,
|
||||
TextSaturationMultiplier: p.TextSaturationMultiplier,
|
||||
IsNew: false,
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
a.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
data := adminTemplateData{App: a, PresetForm: &form}
|
||||
a.populateTemplateRequestData(&data.Request, r)
|
||||
renderAdminTemplate(w, adminThemePresetTemplate, data)
|
||||
}
|
||||
|
||||
func (a *application) handleAdminSiteSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.adminAccessAllowed(w, r) {
|
||||
return
|
||||
}
|
||||
b := a.Config.Branding
|
||||
settings := &adminSiteSettings{
|
||||
AppName: b.AppName,
|
||||
LogoText: b.LogoText,
|
||||
LogoURL: b.LogoURL,
|
||||
FaviconURL: b.FaviconURL,
|
||||
AppIconURL: b.AppIconURL,
|
||||
AppBackgroundColor: b.AppBackgroundColor,
|
||||
HideFooter: b.HideFooter,
|
||||
CustomFooter: string(b.CustomFooter),
|
||||
}
|
||||
data := adminTemplateData{App: a, SiteSettings: settings}
|
||||
a.populateTemplateRequestData(&data.Request, r)
|
||||
renderAdminTemplate(w, adminSiteSettingsTemplate, data)
|
||||
}
|
||||
|
||||
func (a *application) handleAdminPageSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.adminAccessAllowed(w, r) {
|
||||
return
|
||||
}
|
||||
pages := a.freshPagesFromDisk()
|
||||
page, idx, exists := findPageBySlug(pages, r.PathValue("page"))
|
||||
if !exists {
|
||||
a.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
settings := &adminPageSettings{
|
||||
Name: page.Title,
|
||||
Slug: page.Slug,
|
||||
Width: page.Width,
|
||||
DesktopNavigationWidth: page.DesktopNavigationWidth,
|
||||
ShowMobileHeader: page.ShowMobileHeader,
|
||||
HideDesktopNavigation: page.HideDesktopNavigation,
|
||||
CenterVertically: page.CenterVertically,
|
||||
}
|
||||
data := adminTemplateData{
|
||||
App: a,
|
||||
Page: &adminPageDetail{Title: page.Title, Slug: page.Slug},
|
||||
PageSettings: settings,
|
||||
IsFirstPage: idx == 0,
|
||||
IsLastPage: idx == len(pages)-1,
|
||||
}
|
||||
a.populateTemplateRequestData(&data.Request, r)
|
||||
renderAdminTemplate(w, adminPageSettingsTemplate, data)
|
||||
}
|
||||
|
||||
func (a *application) handleAdminNewWidget(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.adminAccessAllowed(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
pages := a.freshPagesFromDisk()
|
||||
page, _, exists := findPageBySlug(pages, r.PathValue("page"))
|
||||
if !exists {
|
||||
a.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
col, err := strconv.Atoi(r.PathValue("col"))
|
||||
if err != nil || col < 0 || col >= len(page.Columns) {
|
||||
a.handleNotFound(w, r)
|
||||
return
|
||||
}
|
||||
widgetType := r.URL.Query().Get("type")
|
||||
if _, err := newWidget(widgetType); err != nil {
|
||||
adminError(w, http.StatusBadRequest, "unknown widget type: "+widgetType)
|
||||
return
|
||||
}
|
||||
|
||||
a.renderWidgetEditor(w, r, editorRenderInput{
|
||||
pageTitle: page.Title,
|
||||
pageSlug: page.Slug,
|
||||
isNew: true,
|
||||
col: col,
|
||||
widgetType: widgetType,
|
||||
yamlText: widgetStarterYAML(widgetType),
|
||||
})
|
||||
}
|
||||
|
||||
type editorRenderInput struct {
|
||||
pageTitle string
|
||||
pageSlug string
|
||||
isNew bool
|
||||
col int
|
||||
idx int
|
||||
widgetType string
|
||||
widgetTitle string
|
||||
yamlText string
|
||||
loadError string
|
||||
errorMessage string
|
||||
}
|
||||
|
||||
func (a *application) renderWidgetEditor(w http.ResponseWriter, r *http.Request, in editorRenderInput) {
|
||||
view := adminWidgetView{
|
||||
ColumnIndex: in.col,
|
||||
WidgetIndex: in.idx,
|
||||
Title: in.widgetTitle,
|
||||
Type: in.widgetType,
|
||||
}
|
||||
detail := adminPageDetail{Title: in.pageTitle, Slug: in.pageSlug}
|
||||
|
||||
data := adminTemplateData{
|
||||
App: a,
|
||||
Page: &detail,
|
||||
Widget: &view,
|
||||
IsNew: in.isNew,
|
||||
WidgetYAML: in.yamlText,
|
||||
LoadError: in.loadError,
|
||||
ErrorMessage: in.errorMessage,
|
||||
FieldReference: widgetFieldReferences[in.widgetType],
|
||||
WidgetExample: widgetStarters[in.widgetType],
|
||||
DocsURL: widgetDocsURL(in.widgetType),
|
||||
}
|
||||
a.populateTemplateRequestData(&data.Request, r)
|
||||
renderAdminTemplate(w, adminWidgetTemplate, data)
|
||||
}
|
||||
|
||||
// widgetTypeFromYAML extracts just the `type:` field from a YAML mapping.
|
||||
// Used when re-rendering the editor after a save error so docs/reference
|
||||
// match what the user is actually editing.
|
||||
func widgetTypeFromYAML(yamlText string) string {
|
||||
var probe struct {
|
||||
Type string `yaml:"type"`
|
||||
}
|
||||
_ = yaml.Unmarshal([]byte(yamlText), &probe)
|
||||
return probe.Type
|
||||
}
|
||||
|
||||
// freshPagesFromDisk reads the on-disk config and returns the parsed pages
|
||||
// with slugs auto-generated. Admin views use this instead of a.Config.Pages
|
||||
// because the fsnotify watcher debounces 500ms after a save, and during that
|
||||
// window a.Config still reflects the pre-save state. Falls back to a copy
|
||||
// of the cached config on any error.
|
||||
func (a *application) freshPagesFromDisk() []page {
|
||||
contents, _, err := parseYAMLIncludes(a.ConfigPath)
|
||||
if err != nil {
|
||||
return a.Config.Pages
|
||||
}
|
||||
cfg, err := newConfigFromYAML(contents)
|
||||
if err != nil {
|
||||
return a.Config.Pages
|
||||
}
|
||||
for p := range cfg.Pages {
|
||||
if cfg.Pages[p].Slug == "" {
|
||||
cfg.Pages[p].Slug = titleToSlug(cfg.Pages[p].Title)
|
||||
}
|
||||
}
|
||||
return cfg.Pages
|
||||
}
|
||||
|
||||
func findPageBySlug(pages []page, slug string) (*page, int, bool) {
|
||||
for i := range pages {
|
||||
if pages[i].Slug == slug {
|
||||
return &pages[i], i, true
|
||||
}
|
||||
}
|
||||
return nil, -1, false
|
||||
}
|
||||
|
||||
// freshPageIndexBySlug looks up the on-disk page index by slug. Mutating
|
||||
// handlers must use this rather than a.pageIndexBySlug because the cached
|
||||
// a.Config can lag the file by up to ~500ms after a save.
|
||||
func (a *application) freshPageIndexBySlug(slug string) (int, bool) {
|
||||
_, idx, ok := findPageBySlug(a.freshPagesFromDisk(), slug)
|
||||
return idx, ok
|
||||
}
|
||||
|
||||
// loadWidgetYAML reads the on-disk config and returns the marshaled YAML for
|
||||
// one widget. Errors are returned as a string so the template can show them
|
||||
// without preventing the page from rendering.
|
||||
func loadWidgetYAML(path string, pageIdx, col, idx int) (string, string) {
|
||||
editor, err := loadConfigEditor(path)
|
||||
if err != nil {
|
||||
return "", err.Error()
|
||||
}
|
||||
seq, pos, err := editor.widgetSlot(pageIdx, col, idx)
|
||||
if err != nil {
|
||||
return "", err.Error()
|
||||
}
|
||||
out, err := yaml.Marshal(seq.Content[pos])
|
||||
if err != nil {
|
||||
return "", err.Error()
|
||||
}
|
||||
return string(out), ""
|
||||
}
|
||||
|
||||
// adminAccessAllowed enforces that admin endpoints either require auth, or
|
||||
// have been explicitly opted into without auth via config.admin.allow-without-auth.
|
||||
// Returns false if the response has been written and the caller should bail.
|
||||
func (a *application) adminAccessAllowed(w http.ResponseWriter, r *http.Request) bool {
|
||||
if !a.RequiresAuth && !a.Config.Admin.AllowWithoutAuth {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(`<!doctype html><meta charset=utf-8><title>Editing disabled</title>` +
|
||||
`<body style="font-family:sans-serif;padding:2rem;max-width:50rem;line-height:1.5">` +
|
||||
`<h1>Editing is disabled</h1>` +
|
||||
`<p>The edit UI requires authentication. Either:</p>` +
|
||||
`<ul>` +
|
||||
`<li>Configure <code>auth.users</code> in your config (recommended), or</li>` +
|
||||
`<li>Set <code>admin.allow-without-auth: true</code> in your config if you accept that anyone reachable on the network can edit it. (The legacy <code>admin</code> key still works for this option.)</li>` +
|
||||
`</ul>` +
|
||||
`</body>`))
|
||||
return false
|
||||
}
|
||||
if a.handleUnauthorizedResponse(w, r, redirectToLogin) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func renderAdminTemplate(w http.ResponseWriter, t *template.Template, data adminTemplateData) {
|
||||
var buf bytes.Buffer
|
||||
if err := t.Execute(&buf, data); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
w.Write(buf.Bytes())
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package glance
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
mathrand "math/rand/v2"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const AUTH_SESSION_COOKIE_NAME = "session_token"
|
||||
const AUTH_RATE_LIMIT_WINDOW = 5 * time.Minute
|
||||
const AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5
|
||||
|
||||
const AUTH_TOKEN_SECRET_LENGTH = 32
|
||||
const AUTH_USERNAME_HASH_LENGTH = 32
|
||||
const AUTH_SECRET_KEY_LENGTH = AUTH_TOKEN_SECRET_LENGTH + AUTH_USERNAME_HASH_LENGTH
|
||||
const AUTH_TIMESTAMP_LENGTH = 4 // uint32
|
||||
const AUTH_TOKEN_DATA_LENGTH = AUTH_USERNAME_HASH_LENGTH + AUTH_TIMESTAMP_LENGTH
|
||||
|
||||
// How long the token will be valid for
|
||||
const AUTH_TOKEN_VALID_PERIOD = 14 * 24 * time.Hour // 14 days
|
||||
// How long the token has left before it should be regenerated
|
||||
const AUTH_TOKEN_REGEN_BEFORE = 7 * 24 * time.Hour // 7 days
|
||||
|
||||
var loginPageTemplate = mustParseTemplate("login.html", "document.html", "footer.html")
|
||||
|
||||
type doWhenUnauthorized int
|
||||
|
||||
const (
|
||||
redirectToLogin doWhenUnauthorized = iota
|
||||
showUnauthorizedJSON
|
||||
)
|
||||
|
||||
type failedAuthAttempt struct {
|
||||
attempts int
|
||||
first time.Time
|
||||
}
|
||||
|
||||
func generateSessionToken(username string, secret []byte, now time.Time) (string, error) {
|
||||
if len(secret) != AUTH_SECRET_KEY_LENGTH {
|
||||
return "", fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
|
||||
}
|
||||
|
||||
usernameHash, err := computeUsernameHash(username, secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
data := make([]byte, AUTH_TOKEN_DATA_LENGTH)
|
||||
copy(data, usernameHash)
|
||||
expires := now.Add(AUTH_TOKEN_VALID_PERIOD).Unix()
|
||||
binary.LittleEndian.PutUint32(data[AUTH_USERNAME_HASH_LENGTH:], uint32(expires))
|
||||
|
||||
h := hmac.New(sha256.New, secret[0:AUTH_TOKEN_SECRET_LENGTH])
|
||||
h.Write(data)
|
||||
|
||||
signature := h.Sum(nil)
|
||||
encodedToken := base64.StdEncoding.EncodeToString(append(data, signature...))
|
||||
// encodedToken ends up being (hashed username + expiration timestamp + signature) encoded as base64
|
||||
|
||||
return encodedToken, nil
|
||||
}
|
||||
|
||||
func computeUsernameHash(username string, secret []byte) ([]byte, error) {
|
||||
if len(secret) != AUTH_SECRET_KEY_LENGTH {
|
||||
return nil, fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
|
||||
}
|
||||
|
||||
h := hmac.New(sha256.New, secret[AUTH_TOKEN_SECRET_LENGTH:])
|
||||
h.Write([]byte(username))
|
||||
|
||||
return h.Sum(nil), nil
|
||||
}
|
||||
|
||||
func verifySessionToken(token string, secretBytes []byte, now time.Time) ([]byte, bool, error) {
|
||||
tokenBytes, err := base64.StdEncoding.DecodeString(token)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if len(tokenBytes) != AUTH_TOKEN_DATA_LENGTH+32 {
|
||||
return nil, false, fmt.Errorf("token length is invalid")
|
||||
}
|
||||
|
||||
if len(secretBytes) != AUTH_SECRET_KEY_LENGTH {
|
||||
return nil, false, fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
|
||||
}
|
||||
|
||||
usernameHashBytes := tokenBytes[0:AUTH_USERNAME_HASH_LENGTH]
|
||||
timestampBytes := tokenBytes[AUTH_USERNAME_HASH_LENGTH : AUTH_USERNAME_HASH_LENGTH+AUTH_TIMESTAMP_LENGTH]
|
||||
providedSignatureBytes := tokenBytes[AUTH_TOKEN_DATA_LENGTH:]
|
||||
|
||||
h := hmac.New(sha256.New, secretBytes[0:32])
|
||||
h.Write(tokenBytes[0:AUTH_TOKEN_DATA_LENGTH])
|
||||
expectedSignatureBytes := h.Sum(nil)
|
||||
|
||||
if !hmac.Equal(expectedSignatureBytes, providedSignatureBytes) {
|
||||
return nil, false, fmt.Errorf("signature does not match")
|
||||
}
|
||||
|
||||
expiresTimestamp := int64(binary.LittleEndian.Uint32(timestampBytes))
|
||||
if now.Unix() > expiresTimestamp {
|
||||
return nil, false, fmt.Errorf("token has expired")
|
||||
}
|
||||
|
||||
return usernameHashBytes,
|
||||
// True if the token should be regenerated
|
||||
time.Unix(expiresTimestamp, 0).Add(-AUTH_TOKEN_REGEN_BEFORE).Before(now),
|
||||
nil
|
||||
}
|
||||
|
||||
func makeAuthSecretKey(length int) (string, error) {
|
||||
key := make([]byte, length)
|
||||
_, err := rand.Read(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(key), nil
|
||||
}
|
||||
|
||||
func (a *application) handleAuthenticationAttempt(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
waitOnFailure := 1*time.Second - time.Duration(mathrand.IntN(500))*time.Millisecond
|
||||
|
||||
ip := a.addressOfRequest(r)
|
||||
|
||||
a.authAttemptsMu.Lock()
|
||||
exceededRateLimit, retryAfter := func() (bool, int) {
|
||||
attempt, exists := a.failedAuthAttempts[ip]
|
||||
if !exists {
|
||||
a.failedAuthAttempts[ip] = &failedAuthAttempt{
|
||||
attempts: 1,
|
||||
first: time.Now(),
|
||||
}
|
||||
|
||||
return false, 0
|
||||
}
|
||||
|
||||
elapsed := time.Since(attempt.first)
|
||||
if elapsed < AUTH_RATE_LIMIT_WINDOW && attempt.attempts >= AUTH_RATE_LIMIT_MAX_ATTEMPTS {
|
||||
return true, max(1, int(AUTH_RATE_LIMIT_WINDOW.Seconds()-elapsed.Seconds()))
|
||||
}
|
||||
|
||||
attempt.attempts++
|
||||
return false, 0
|
||||
}()
|
||||
|
||||
if exceededRateLimit {
|
||||
a.authAttemptsMu.Unlock()
|
||||
time.Sleep(waitOnFailure)
|
||||
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
} else {
|
||||
// Clean up old failed attempts
|
||||
for ipOfAttempt := range a.failedAuthAttempts {
|
||||
if time.Since(a.failedAuthAttempts[ipOfAttempt].first) > AUTH_RATE_LIMIT_WINDOW {
|
||||
delete(a.failedAuthAttempts, ipOfAttempt)
|
||||
}
|
||||
}
|
||||
a.authAttemptsMu.Unlock()
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var creds struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
err = json.Unmarshal(body, &creds)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
logAuthFailure := func() {
|
||||
log.Printf(
|
||||
"Failed login attempt for user '%s' from %s",
|
||||
creds.Username, ip,
|
||||
)
|
||||
}
|
||||
|
||||
if len(creds.Username) == 0 || len(creds.Password) == 0 {
|
||||
time.Sleep(waitOnFailure)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if len(creds.Username) > 50 || len(creds.Password) > 100 {
|
||||
logAuthFailure()
|
||||
time.Sleep(waitOnFailure)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
u, exists := a.Config.Auth.Users[creds.Username]
|
||||
if !exists {
|
||||
logAuthFailure()
|
||||
time.Sleep(waitOnFailure)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword(u.PasswordHash, []byte(creds.Password)); err != nil {
|
||||
logAuthFailure()
|
||||
time.Sleep(waitOnFailure)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := generateSessionToken(creds.Username, a.authSecretKey, time.Now())
|
||||
if err != nil {
|
||||
log.Printf("Could not compute session token during login attempt: %v", err)
|
||||
time.Sleep(waitOnFailure)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
a.setAuthSessionCookie(w, r, token, time.Now().Add(AUTH_TOKEN_VALID_PERIOD))
|
||||
|
||||
a.authAttemptsMu.Lock()
|
||||
delete(a.failedAuthAttempts, ip)
|
||||
a.authAttemptsMu.Unlock()
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *application) isAuthorized(w http.ResponseWriter, r *http.Request) bool {
|
||||
if !a.RequiresAuth {
|
||||
return true
|
||||
}
|
||||
|
||||
token, err := r.Cookie(AUTH_SESSION_COOKIE_NAME)
|
||||
if err != nil || token.Value == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
usernameHash, shouldRegenerate, err := verifySessionToken(token.Value, a.authSecretKey, time.Now())
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
username, exists := a.usernameHashToUsername[string(usernameHash)]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
_, exists = a.Config.Auth.Users[username]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
if shouldRegenerate {
|
||||
newToken, err := generateSessionToken(username, a.authSecretKey, time.Now())
|
||||
if err != nil {
|
||||
log.Printf("Could not compute session token during regeneration: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
a.setAuthSessionCookie(w, r, newToken, time.Now().Add(AUTH_TOKEN_VALID_PERIOD))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Handles sending the appropriate response for an unauthorized request and returns true if the request was unauthorized
|
||||
func (a *application) handleUnauthorizedResponse(w http.ResponseWriter, r *http.Request, fallback doWhenUnauthorized) bool {
|
||||
if a.isAuthorized(w, r) {
|
||||
return false
|
||||
}
|
||||
|
||||
switch fallback {
|
||||
case redirectToLogin:
|
||||
http.Redirect(w, r, a.Config.Server.BaseURL+"/login", http.StatusSeeOther)
|
||||
case showUnauthorizedJSON:
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error": "Unauthorized"}`))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Maybe this should be a POST request instead?
|
||||
func (a *application) handleLogoutRequest(w http.ResponseWriter, r *http.Request) {
|
||||
a.setAuthSessionCookie(w, r, "", time.Now().Add(-1*time.Hour))
|
||||
http.Redirect(w, r, a.Config.Server.BaseURL+"/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *application) setAuthSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: AUTH_SESSION_COOKIE_NAME,
|
||||
Value: token,
|
||||
Expires: expires,
|
||||
Secure: strings.ToLower(r.Header.Get("X-Forwarded-Proto")) == "https",
|
||||
Path: a.Config.Server.BaseURL + "/",
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
HttpOnly: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *application) handleLoginPageRequest(w http.ResponseWriter, r *http.Request) {
|
||||
if a.isAuthorized(w, r) {
|
||||
http.Redirect(w, r, a.Config.Server.BaseURL+"/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
data := &templateData{
|
||||
App: a,
|
||||
}
|
||||
a.populateTemplateRequestData(&data.Request, r)
|
||||
|
||||
var responseBytes bytes.Buffer
|
||||
err := loginPageTemplate.Execute(&responseBytes, data)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
w.Write(responseBytes.Bytes())
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package glance
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAuthTokenGenerationAndVerification(t *testing.T) {
|
||||
secret, err := makeAuthSecretKey(AUTH_SECRET_KEY_LENGTH)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate secret key: %v", err)
|
||||
}
|
||||
|
||||
secretBytes, err := base64.StdEncoding.DecodeString(secret)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decode secret key: %v", err)
|
||||
}
|
||||
|
||||
if len(secretBytes) != AUTH_SECRET_KEY_LENGTH {
|
||||
t.Fatalf("Secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
username := "admin"
|
||||
|
||||
token, err := generateSessionToken(username, secretBytes, now)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate session token: %v", err)
|
||||
}
|
||||
|
||||
usernameHashBytes, shouldRegen, err := verifySessionToken(token, secretBytes, now)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to verify session token: %v", err)
|
||||
}
|
||||
|
||||
if shouldRegen {
|
||||
t.Fatal("Token should not need to be regenerated immediately after generation")
|
||||
}
|
||||
|
||||
computedUsernameHash, err := computeUsernameHash(username, secretBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to compute username hash: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(usernameHashBytes, computedUsernameHash) {
|
||||
t.Fatal("Username hash does not match the expected value")
|
||||
}
|
||||
|
||||
// Test token regeneration
|
||||
timeRightAfterRegenPeriod := now.Add(AUTH_TOKEN_VALID_PERIOD - AUTH_TOKEN_REGEN_BEFORE + 2*time.Second)
|
||||
_, shouldRegen, err = verifySessionToken(token, secretBytes, timeRightAfterRegenPeriod)
|
||||
if err != nil {
|
||||
t.Fatalf("Token verification should not fail during regeneration period, err: %v", err)
|
||||
}
|
||||
|
||||
if !shouldRegen {
|
||||
t.Fatal("Token should have been marked for regeneration")
|
||||
}
|
||||
|
||||
// Test token expiration
|
||||
_, _, err = verifySessionToken(token, secretBytes, now.Add(AUTH_TOKEN_VALID_PERIOD+2*time.Second))
|
||||
if err == nil {
|
||||
t.Fatal("Expected token verification to fail after token expiration")
|
||||
}
|
||||
|
||||
// Test tampered token
|
||||
decodedToken, err := base64.StdEncoding.DecodeString(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decode token: %v", err)
|
||||
}
|
||||
|
||||
// If any of the bytes are off by 1, the token should be considered invalid
|
||||
for i := range len(decodedToken) {
|
||||
tampered := make([]byte, len(decodedToken))
|
||||
copy(tampered, decodedToken)
|
||||
tampered[i] += 1
|
||||
|
||||
_, _, err = verifySessionToken(base64.StdEncoding.EncodeToString(tampered), secretBytes, now)
|
||||
if err == nil {
|
||||
t.Fatalf("Expected token verification to fail for tampered token at index %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,41 +2,154 @@ package glance
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/shirou/gopsutil/v4/disk"
|
||||
"github.com/shirou/gopsutil/v4/sensors"
|
||||
)
|
||||
|
||||
type CliIntent uint8
|
||||
type cliIntent uint8
|
||||
|
||||
const (
|
||||
CliIntentServe CliIntent = iota
|
||||
CliIntentCheckConfig = iota
|
||||
cliIntentVersionPrint cliIntent = iota
|
||||
cliIntentServe
|
||||
cliIntentConfigValidate
|
||||
cliIntentConfigPrint
|
||||
cliIntentDiagnose
|
||||
cliIntentSensorsPrint
|
||||
cliIntentMountpointInfo
|
||||
cliIntentSecretMake
|
||||
cliIntentPasswordHash
|
||||
)
|
||||
|
||||
type CliOptions struct {
|
||||
Intent CliIntent
|
||||
ConfigPath string
|
||||
type cliOptions struct {
|
||||
intent cliIntent
|
||||
configPath string
|
||||
args []string
|
||||
}
|
||||
|
||||
func ParseCliOptions() (*CliOptions, error) {
|
||||
func parseCliOptions() (*cliOptions, error) {
|
||||
var args []string
|
||||
|
||||
args = os.Args[1:]
|
||||
if len(args) == 1 && (args[0] == "--version" || args[0] == "-v" || args[0] == "version") {
|
||||
return &cliOptions{
|
||||
intent: cliIntentVersionPrint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
flags := flag.NewFlagSet("", flag.ExitOnError)
|
||||
flags.Usage = func() {
|
||||
fmt.Println("Usage: glance [options] command")
|
||||
|
||||
fmt.Println("\nOptions:")
|
||||
flags.PrintDefaults()
|
||||
|
||||
fmt.Println("\nCommands:")
|
||||
fmt.Println(" config:validate Validate the config file")
|
||||
fmt.Println(" config:print Print the parsed config file with embedded includes")
|
||||
fmt.Println(" password:hash <pwd> Hash a password")
|
||||
fmt.Println(" secret:make Generate a random secret key")
|
||||
fmt.Println(" sensors:print List all sensors")
|
||||
fmt.Println(" mountpoint:info Print information about a given mountpoint path")
|
||||
fmt.Println(" diagnose Run diagnostic checks")
|
||||
}
|
||||
|
||||
checkConfig := flags.Bool("check-config", false, "Check whether the config is valid")
|
||||
configPath := flags.String("config", "glance.yml", "Set config path")
|
||||
|
||||
err := flags.Parse(os.Args[1:])
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
intent := CliIntentServe
|
||||
var intent cliIntent
|
||||
args = flags.Args()
|
||||
unknownCommandErr := fmt.Errorf("unknown command: %s", strings.Join(args, " "))
|
||||
|
||||
if *checkConfig {
|
||||
intent = CliIntentCheckConfig
|
||||
if len(args) == 0 {
|
||||
intent = cliIntentServe
|
||||
} else if len(args) == 1 {
|
||||
if args[0] == "config:validate" {
|
||||
intent = cliIntentConfigValidate
|
||||
} else if args[0] == "config:print" {
|
||||
intent = cliIntentConfigPrint
|
||||
} else if args[0] == "sensors:print" {
|
||||
intent = cliIntentSensorsPrint
|
||||
} else if args[0] == "diagnose" {
|
||||
intent = cliIntentDiagnose
|
||||
} else if args[0] == "secret:make" {
|
||||
intent = cliIntentSecretMake
|
||||
} else {
|
||||
return nil, unknownCommandErr
|
||||
}
|
||||
} else if len(args) == 2 {
|
||||
if args[0] == "password:hash" {
|
||||
intent = cliIntentPasswordHash
|
||||
} else {
|
||||
return nil, unknownCommandErr
|
||||
}
|
||||
} else if len(args) == 2 {
|
||||
if args[0] == "mountpoint:info" {
|
||||
intent = cliIntentMountpointInfo
|
||||
} else {
|
||||
return nil, unknownCommandErr
|
||||
}
|
||||
} else {
|
||||
return nil, unknownCommandErr
|
||||
}
|
||||
|
||||
return &CliOptions{
|
||||
Intent: intent,
|
||||
ConfigPath: *configPath,
|
||||
return &cliOptions{
|
||||
intent: intent,
|
||||
configPath: *configPath,
|
||||
args: args,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func cliSensorsPrint() int {
|
||||
tempSensors, err := sensors.SensorsTemperatures()
|
||||
if err != nil {
|
||||
if warns, ok := err.(*sensors.Warnings); ok {
|
||||
fmt.Printf("Could not retrieve information for some sensors (%v):\n", err)
|
||||
for _, w := range warns.List {
|
||||
fmt.Printf(" - %v\n", w)
|
||||
}
|
||||
fmt.Println()
|
||||
} else {
|
||||
fmt.Printf("Failed to retrieve sensor information: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
if len(tempSensors) == 0 {
|
||||
fmt.Println("No sensors found")
|
||||
return 0
|
||||
}
|
||||
|
||||
fmt.Println("Sensors found:")
|
||||
for _, sensor := range tempSensors {
|
||||
fmt.Printf(" %s: %.1f°C\n", sensor.SensorKey, sensor.Temperature)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func cliMountpointInfo(requestedPath string) int {
|
||||
usage, err := disk.Usage(requestedPath)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to retrieve info for path %s: %v\n", requestedPath, err)
|
||||
if warns, ok := err.(*disk.Warnings); ok {
|
||||
for _, w := range warns.List {
|
||||
fmt.Printf(" - %v\n", w)
|
||||
}
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
fmt.Println("Path:", usage.Path)
|
||||
fmt.Println("FS type:", ternary(usage.Fstype == "", "unknown", usage.Fstype))
|
||||
fmt.Printf("Used percent: %.1f%%\n", usage.UsedPercent)
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
package glance
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?([\d\.]+)(?: |,)+([\d\.]+)%?(?: |,)+([\d\.]+)%?\)?$`)
|
||||
|
||||
const (
|
||||
hslHueMax = 360
|
||||
hslSaturationMax = 100
|
||||
hslLightnessMax = 100
|
||||
)
|
||||
|
||||
type hslColorField struct {
|
||||
H float64
|
||||
S float64
|
||||
L float64
|
||||
}
|
||||
|
||||
func (c *hslColorField) String() string {
|
||||
return fmt.Sprintf("hsl(%.1f, %.1f%%, %.1f%%)", c.H, c.S, c.L)
|
||||
}
|
||||
|
||||
func (c *hslColorField) ToHex() string {
|
||||
return hslToHex(c.H, c.S, c.L)
|
||||
}
|
||||
|
||||
func (c1 *hslColorField) SameAs(c2 *hslColorField) bool {
|
||||
if c1 == nil && c2 == nil {
|
||||
return true
|
||||
}
|
||||
if c1 == nil || c2 == nil {
|
||||
return false
|
||||
}
|
||||
return c1.H == c2.H && c1.S == c2.S && c1.L == c2.L
|
||||
}
|
||||
|
||||
func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
|
||||
var value string
|
||||
|
||||
if err := node.Decode(&value); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
matches := hslColorFieldPattern.FindStringSubmatch(value)
|
||||
|
||||
if len(matches) != 4 {
|
||||
return fmt.Errorf("invalid HSL color format: %s", value)
|
||||
}
|
||||
|
||||
hue, err := strconv.ParseFloat(matches[1], 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if hue > hslHueMax {
|
||||
return fmt.Errorf("HSL hue must be between 0 and %d", hslHueMax)
|
||||
}
|
||||
|
||||
saturation, err := strconv.ParseFloat(matches[2], 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if saturation > hslSaturationMax {
|
||||
return fmt.Errorf("HSL saturation must be between 0 and %d", hslSaturationMax)
|
||||
}
|
||||
|
||||
lightness, err := strconv.ParseFloat(matches[3], 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if lightness > hslLightnessMax {
|
||||
return fmt.Errorf("HSL lightness must be between 0 and %d", hslLightnessMax)
|
||||
}
|
||||
|
||||
c.H = hue
|
||||
c.S = saturation
|
||||
c.L = lightness
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var durationFieldPattern = regexp.MustCompile(`^(\d+)(s|m|h|d)$`)
|
||||
|
||||
type durationField time.Duration
|
||||
|
||||
func (d *durationField) UnmarshalYAML(node *yaml.Node) error {
|
||||
var value string
|
||||
|
||||
if err := node.Decode(&value); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
matches := durationFieldPattern.FindStringSubmatch(value)
|
||||
|
||||
if len(matches) != 3 {
|
||||
return fmt.Errorf("invalid duration format: %s", value)
|
||||
}
|
||||
|
||||
duration, err := strconv.Atoi(matches[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch matches[2] {
|
||||
case "s":
|
||||
*d = durationField(time.Duration(duration) * time.Second)
|
||||
case "m":
|
||||
*d = durationField(time.Duration(duration) * time.Minute)
|
||||
case "h":
|
||||
*d = durationField(time.Duration(duration) * time.Hour)
|
||||
case "d":
|
||||
*d = durationField(time.Duration(duration) * 24 * time.Hour)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type customIconField struct {
|
||||
URL template.URL
|
||||
AutoInvert bool
|
||||
}
|
||||
|
||||
func newCustomIconField(value string) customIconField {
|
||||
const autoInvertPrefix = "auto-invert "
|
||||
field := customIconField{}
|
||||
|
||||
if strings.HasPrefix(value, autoInvertPrefix) {
|
||||
field.AutoInvert = true
|
||||
value = strings.TrimPrefix(value, autoInvertPrefix)
|
||||
}
|
||||
|
||||
prefix, icon, found := strings.Cut(value, ":")
|
||||
if !found {
|
||||
field.URL = template.URL(value)
|
||||
return field
|
||||
}
|
||||
|
||||
basename, ext, found := strings.Cut(icon, ".")
|
||||
if !found {
|
||||
ext = "svg"
|
||||
basename = icon
|
||||
}
|
||||
|
||||
if ext != "svg" && ext != "png" {
|
||||
ext = "svg"
|
||||
}
|
||||
|
||||
switch prefix {
|
||||
case "si":
|
||||
field.AutoInvert = true
|
||||
field.URL = template.URL("https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + basename + ".svg")
|
||||
case "di":
|
||||
field.URL = template.URL("https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/" + ext + "/" + basename + "." + ext)
|
||||
case "mdi":
|
||||
field.AutoInvert = true
|
||||
field.URL = template.URL("https://cdn.jsdelivr.net/npm/@mdi/svg@latest/svg/" + basename + ".svg")
|
||||
case "sh":
|
||||
field.URL = template.URL("https://cdn.jsdelivr.net/gh/selfhst/icons/" + ext + "/" + basename + "." + ext)
|
||||
default:
|
||||
field.URL = template.URL(value)
|
||||
}
|
||||
|
||||
return field
|
||||
}
|
||||
|
||||
func (i *customIconField) UnmarshalYAML(node *yaml.Node) error {
|
||||
var value string
|
||||
if err := node.Decode(&value); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*i = newCustomIconField(value)
|
||||
return nil
|
||||
}
|
||||
|
||||
type proxyOptionsField struct {
|
||||
URL string `yaml:"url"`
|
||||
AllowInsecure bool `yaml:"allow-insecure"`
|
||||
Timeout durationField `yaml:"timeout"`
|
||||
client *http.Client `yaml:"-"`
|
||||
}
|
||||
|
||||
func (p *proxyOptionsField) UnmarshalYAML(node *yaml.Node) error {
|
||||
type proxyOptionsFieldAlias proxyOptionsField
|
||||
alias := (*proxyOptionsFieldAlias)(p)
|
||||
var proxyURL string
|
||||
|
||||
if err := node.Decode(&proxyURL); err != nil {
|
||||
if err := node.Decode(alias); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if proxyURL == "" && p.URL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if p.URL != "" {
|
||||
proxyURL = p.URL
|
||||
}
|
||||
|
||||
parsedUrl, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing proxy URL: %v", err)
|
||||
}
|
||||
|
||||
var timeout = defaultClientTimeout
|
||||
if p.Timeout > 0 {
|
||||
timeout = time.Duration(p.Timeout)
|
||||
}
|
||||
|
||||
p.client = &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(parsedUrl),
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: p.AllowInsecure},
|
||||
},
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type queryParametersField map[string][]string
|
||||
|
||||
func (q *queryParametersField) UnmarshalYAML(node *yaml.Node) error {
|
||||
var decoded map[string]any
|
||||
|
||||
if err := node.Decode(&decoded); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*q = make(queryParametersField)
|
||||
|
||||
// TODO: refactor the duplication in the switch cases if any more types get added
|
||||
for key, value := range decoded {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
(*q)[key] = []string{v}
|
||||
case int, int8, int16, int32, int64, float32, float64:
|
||||
(*q)[key] = []string{fmt.Sprintf("%v", v)}
|
||||
case bool:
|
||||
(*q)[key] = []string{fmt.Sprintf("%t", v)}
|
||||
case []string:
|
||||
(*q)[key] = append((*q)[key], v...)
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
switch item := item.(type) {
|
||||
case string:
|
||||
(*q)[key] = append((*q)[key], item)
|
||||
case int, int8, int16, int32, int64, float32, float64:
|
||||
(*q)[key] = append((*q)[key], fmt.Sprintf("%v", item))
|
||||
case bool:
|
||||
(*q)[key] = append((*q)[key], fmt.Sprintf("%t", item))
|
||||
default:
|
||||
return fmt.Errorf("invalid query parameter value type: %T", item)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("invalid query parameter value type: %T", value)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *queryParametersField) toQueryString() string {
|
||||
query := url.Values{}
|
||||
|
||||
for key, values := range *q {
|
||||
for _, value := range values {
|
||||
query.Add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
return query.Encode()
|
||||
}
|
||||
@@ -1,79 +1,643 @@
|
||||
package glance
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"html/template"
|
||||
"iter"
|
||||
"log"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server Server `yaml:"server"`
|
||||
Theme Theme `yaml:"theme"`
|
||||
Pages []Page `yaml:"pages"`
|
||||
const CONFIG_INCLUDE_RECURSION_DEPTH_LIMIT = 20
|
||||
|
||||
const (
|
||||
configVarTypeEnv = "env"
|
||||
configVarTypeSecret = "secret"
|
||||
configVarTypeFileFromEnv = "readFileFromEnv"
|
||||
)
|
||||
|
||||
type config struct {
|
||||
Server struct {
|
||||
Host string `yaml:"host"`
|
||||
Port uint16 `yaml:"port"`
|
||||
Proxied bool `yaml:"proxied"`
|
||||
AssetsPath string `yaml:"assets-path"`
|
||||
BaseURL string `yaml:"base-url"`
|
||||
} `yaml:"server"`
|
||||
|
||||
Auth struct {
|
||||
SecretKey string `yaml:"secret-key"`
|
||||
Users map[string]*user `yaml:"users"`
|
||||
} `yaml:"auth"`
|
||||
|
||||
Document struct {
|
||||
Head template.HTML `yaml:"head"`
|
||||
} `yaml:"document"`
|
||||
|
||||
Theme struct {
|
||||
themeProperties `yaml:",inline"`
|
||||
CustomCSSFile string `yaml:"custom-css-file"`
|
||||
|
||||
DisablePicker bool `yaml:"disable-picker"`
|
||||
Presets orderedYAMLMap[string, *themeProperties] `yaml:"presets"`
|
||||
} `yaml:"theme"`
|
||||
|
||||
Branding struct {
|
||||
HideFooter bool `yaml:"hide-footer"`
|
||||
CustomFooter template.HTML `yaml:"custom-footer"`
|
||||
LogoText string `yaml:"logo-text"`
|
||||
LogoURL string `yaml:"logo-url"`
|
||||
FaviconURL string `yaml:"favicon-url"`
|
||||
FaviconType string `yaml:"-"`
|
||||
AppName string `yaml:"app-name"`
|
||||
AppIconURL string `yaml:"app-icon-url"`
|
||||
AppBackgroundColor string `yaml:"app-background-color"`
|
||||
} `yaml:"branding"`
|
||||
|
||||
Admin struct {
|
||||
// AllowWithoutAuth permits access to /admin even when auth.users is empty.
|
||||
// Off by default because admin can rewrite the config file.
|
||||
AllowWithoutAuth bool `yaml:"allow-without-auth"`
|
||||
} `yaml:"admin"`
|
||||
|
||||
Pages []page `yaml:"pages"`
|
||||
}
|
||||
|
||||
func NewConfigFromYml(contents io.Reader) (*Config, error) {
|
||||
config := NewConfig()
|
||||
type user struct {
|
||||
Password string `yaml:"password"`
|
||||
PasswordHashString string `yaml:"password-hash"`
|
||||
PasswordHash []byte `yaml:"-"`
|
||||
}
|
||||
|
||||
contentBytes, err := io.ReadAll(contents)
|
||||
type page struct {
|
||||
Title string `yaml:"name"`
|
||||
Slug string `yaml:"slug"`
|
||||
Width string `yaml:"width"`
|
||||
DesktopNavigationWidth string `yaml:"desktop-navigation-width"`
|
||||
ShowMobileHeader bool `yaml:"show-mobile-header"`
|
||||
HideDesktopNavigation bool `yaml:"hide-desktop-navigation"`
|
||||
CenterVertically bool `yaml:"center-vertically"`
|
||||
HeadWidgets widgets `yaml:"head-widgets"`
|
||||
Columns []struct {
|
||||
Size string `yaml:"size"`
|
||||
Widgets widgets `yaml:"widgets"`
|
||||
} `yaml:"columns"`
|
||||
PrimaryColumnIndex int8 `yaml:"-"`
|
||||
mu sync.Mutex `yaml:"-"`
|
||||
}
|
||||
|
||||
func newConfigFromYAML(contents []byte) (*config, error) {
|
||||
contents, err := parseConfigVariables(contents)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = yaml.Unmarshal(contentBytes, config)
|
||||
config := &config{}
|
||||
config.Server.Port = 8080
|
||||
|
||||
err = yaml.Unmarshal(contents, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = configIsValid(config); err != nil {
|
||||
if err = isConfigStateValid(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for p := range config.Pages {
|
||||
for w := range config.Pages[p].HeadWidgets {
|
||||
if err := config.Pages[p].HeadWidgets[w].initialize(); err != nil {
|
||||
return nil, formatWidgetInitError(err, config.Pages[p].HeadWidgets[w])
|
||||
}
|
||||
}
|
||||
|
||||
for c := range config.Pages[p].Columns {
|
||||
for w := range config.Pages[p].Columns[c].Widgets {
|
||||
if err := config.Pages[p].Columns[c].Widgets[w].initialize(); err != nil {
|
||||
return nil, formatWidgetInitError(err, config.Pages[p].Columns[c].Widgets[w])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func NewConfig() *Config {
|
||||
config := &Config{}
|
||||
var envVariableNamePattern = regexp.MustCompile(`^[A-Z0-9_]+$`)
|
||||
var configVariablePattern = regexp.MustCompile(`(^|.)\$\{(?:([a-zA-Z]+):)?([a-zA-Z0-9_-]+)\}`)
|
||||
|
||||
config.Server.Host = ""
|
||||
config.Server.Port = 8080
|
||||
// Parses variables defined in the config such as:
|
||||
// ${API_KEY} - gets replaced with the value of the API_KEY environment variable
|
||||
// \${API_KEY} - escaped, gets used as is without the \ in the config
|
||||
// ${secret:api_key} - value gets loaded from /run/secrets/api_key
|
||||
// ${readFileFromEnv:PATH_TO_SECRET} - value gets loaded from the file path specified in the environment variable PATH_TO_SECRET
|
||||
//
|
||||
// TODO: don't match against commented out sections, not sure exactly how since
|
||||
// variables can be placed anywhere and used to modify the YAML structure itself
|
||||
func parseConfigVariables(contents []byte) ([]byte, error) {
|
||||
var err error
|
||||
|
||||
return config
|
||||
replaced := configVariablePattern.ReplaceAllFunc(contents, func(match []byte) []byte {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
groups := configVariablePattern.FindSubmatch(match)
|
||||
if len(groups) != 4 {
|
||||
// we can't handle this match, this shouldn't happen unless the number of groups
|
||||
// in the regex has been changed without updating the below code
|
||||
return match
|
||||
}
|
||||
|
||||
prefix := string(groups[1])
|
||||
if prefix == `\` {
|
||||
if len(match) >= 2 {
|
||||
return match[1:]
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
typeAsString, variableName := string(groups[2]), string(groups[3])
|
||||
variableType := ternary(typeAsString == "", configVarTypeEnv, typeAsString)
|
||||
|
||||
parsedValue, returnOriginal, localErr := parseConfigVariableOfType(variableType, variableName)
|
||||
if localErr != nil {
|
||||
err = fmt.Errorf("parsing variable: %v", localErr)
|
||||
return nil
|
||||
}
|
||||
|
||||
if returnOriginal {
|
||||
return match
|
||||
}
|
||||
|
||||
return []byte(prefix + parsedValue)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return replaced, nil
|
||||
}
|
||||
|
||||
func configIsValid(config *Config) error {
|
||||
// When the bool return value is true, it indicates that the caller should use the original value
|
||||
func parseConfigVariableOfType(variableType, variableName string) (string, bool, error) {
|
||||
switch variableType {
|
||||
case configVarTypeEnv:
|
||||
if !envVariableNamePattern.MatchString(variableName) {
|
||||
return "", true, nil
|
||||
}
|
||||
|
||||
v, found := os.LookupEnv(variableName)
|
||||
if !found {
|
||||
return "", false, fmt.Errorf("environment variable %s not found", variableName)
|
||||
}
|
||||
|
||||
return v, false, nil
|
||||
case configVarTypeSecret:
|
||||
secretPath := filepath.Join("/run/secrets", variableName)
|
||||
secret, err := os.ReadFile(secretPath)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("reading secret file: %v", err)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(secret)), false, nil
|
||||
case configVarTypeFileFromEnv:
|
||||
if !envVariableNamePattern.MatchString(variableName) {
|
||||
return "", true, nil
|
||||
}
|
||||
|
||||
filePath, found := os.LookupEnv(variableName)
|
||||
if !found {
|
||||
return "", false, fmt.Errorf("readFileFromEnv: environment variable %s not found", variableName)
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(filePath) {
|
||||
return "", false, fmt.Errorf("readFileFromEnv: file path %s is not absolute", filePath)
|
||||
}
|
||||
|
||||
fileContents, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("readFileFromEnv: reading file from %s: %v", variableName, err)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(fileContents)), false, nil
|
||||
default:
|
||||
return "", true, nil
|
||||
}
|
||||
}
|
||||
|
||||
func formatWidgetInitError(err error, w widget) error {
|
||||
return fmt.Errorf("%s widget: %v", w.GetType(), err)
|
||||
}
|
||||
|
||||
var configIncludePattern = regexp.MustCompile(`(?m)^([ \t]*)(?:-[ \t]*)?(?:!|\$)include:[ \t]*(.+)$`)
|
||||
|
||||
func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) {
|
||||
return recursiveParseYAMLIncludes(mainFilePath, nil, 0)
|
||||
}
|
||||
|
||||
func recursiveParseYAMLIncludes(mainFilePath string, includes map[string]struct{}, depth int) ([]byte, map[string]struct{}, error) {
|
||||
if depth > CONFIG_INCLUDE_RECURSION_DEPTH_LIMIT {
|
||||
return nil, nil, fmt.Errorf("recursion depth limit of %d reached", CONFIG_INCLUDE_RECURSION_DEPTH_LIMIT)
|
||||
}
|
||||
|
||||
mainFileContents, err := os.ReadFile(mainFilePath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("reading %s: %w", mainFilePath, err)
|
||||
}
|
||||
|
||||
mainFileAbsPath, err := filepath.Abs(mainFilePath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("getting absolute path of %s: %w", mainFilePath, err)
|
||||
}
|
||||
mainFileDir := filepath.Dir(mainFileAbsPath)
|
||||
|
||||
if includes == nil {
|
||||
includes = make(map[string]struct{})
|
||||
}
|
||||
var includesLastErr error
|
||||
|
||||
mainFileContents = configIncludePattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte {
|
||||
if includesLastErr != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
matches := configIncludePattern.FindSubmatch(match)
|
||||
if len(matches) != 3 {
|
||||
includesLastErr = fmt.Errorf("invalid include match: %v", matches)
|
||||
return nil
|
||||
}
|
||||
|
||||
indent := string(matches[1])
|
||||
includeFilePath := strings.TrimSpace(string(matches[2]))
|
||||
if !filepath.IsAbs(includeFilePath) {
|
||||
includeFilePath = filepath.Join(mainFileDir, includeFilePath)
|
||||
}
|
||||
|
||||
var fileContents []byte
|
||||
var err error
|
||||
|
||||
includes[includeFilePath] = struct{}{}
|
||||
|
||||
fileContents, includes, err = recursiveParseYAMLIncludes(includeFilePath, includes, depth+1)
|
||||
if err != nil {
|
||||
includesLastErr = err
|
||||
return nil
|
||||
}
|
||||
|
||||
return []byte(prefixStringLines(indent, string(fileContents)))
|
||||
})
|
||||
|
||||
if includesLastErr != nil {
|
||||
return nil, nil, includesLastErr
|
||||
}
|
||||
|
||||
return mainFileContents, includes, nil
|
||||
}
|
||||
|
||||
func configFilesWatcher(
|
||||
mainFilePath string,
|
||||
lastContents []byte,
|
||||
lastIncludes map[string]struct{},
|
||||
onChange func(newContents []byte),
|
||||
onErr func(error),
|
||||
) (func() error, error) {
|
||||
mainFileAbsPath, err := filepath.Abs(mainFilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting absolute path of main file: %w", err)
|
||||
}
|
||||
|
||||
// TODO: refactor, flaky
|
||||
lastIncludes[mainFileAbsPath] = struct{}{}
|
||||
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating watcher: %w", err)
|
||||
}
|
||||
|
||||
updateWatchedFiles := func(previousWatched map[string]struct{}, newWatched map[string]struct{}) {
|
||||
for filePath := range previousWatched {
|
||||
if _, ok := newWatched[filePath]; !ok {
|
||||
watcher.Remove(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
for filePath := range newWatched {
|
||||
if _, ok := previousWatched[filePath]; !ok {
|
||||
if err := watcher.Add(filePath); err != nil {
|
||||
log.Printf(
|
||||
"Could not add file to watcher, changes to this file will not trigger a reload. path: %s, error: %v",
|
||||
filePath, err,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateWatchedFiles(nil, lastIncludes)
|
||||
|
||||
// needed for lastContents and lastIncludes because they get updated in multiple goroutines
|
||||
mu := sync.Mutex{}
|
||||
|
||||
parseAndCompareBeforeCallback := func() {
|
||||
currentContents, currentIncludes, err := parseYAMLIncludes(mainFilePath)
|
||||
if err != nil {
|
||||
onErr(fmt.Errorf("parsing main file contents for comparison: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: refactor, flaky
|
||||
currentIncludes[mainFileAbsPath] = struct{}{}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if !maps.Equal(currentIncludes, lastIncludes) {
|
||||
updateWatchedFiles(lastIncludes, currentIncludes)
|
||||
lastIncludes = currentIncludes
|
||||
}
|
||||
|
||||
if !bytes.Equal(lastContents, currentContents) {
|
||||
lastContents = currentContents
|
||||
onChange(currentContents)
|
||||
}
|
||||
}
|
||||
|
||||
const debounceDuration = 500 * time.Millisecond
|
||||
var debounceTimer *time.Timer
|
||||
debouncedParseAndCompareBeforeCallback := func() {
|
||||
if debounceTimer != nil {
|
||||
debounceTimer.Stop()
|
||||
debounceTimer.Reset(debounceDuration)
|
||||
} else {
|
||||
debounceTimer = time.AfterFunc(debounceDuration, parseAndCompareBeforeCallback)
|
||||
}
|
||||
}
|
||||
|
||||
deleteLastInclude := func(filePath string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
fileAbsPath, _ := filepath.Abs(filePath)
|
||||
delete(lastIncludes, fileAbsPath)
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case event, isOpen := <-watcher.Events:
|
||||
if !isOpen {
|
||||
return
|
||||
}
|
||||
if event.Has(fsnotify.Write) {
|
||||
debouncedParseAndCompareBeforeCallback()
|
||||
} else if event.Has(fsnotify.Rename) {
|
||||
// on linux the file will no longer be watched after a rename, on windows
|
||||
// it will continue to be watched with the new name but we have no access to
|
||||
// the new name in this event in order to stop watching it manually and match the
|
||||
// behavior in linux, may lead to weird unintended behaviors on windows as we're
|
||||
// only handling renames from linux's perspective
|
||||
// see https://github.com/fsnotify/fsnotify/issues/255
|
||||
|
||||
// remove the old file from our manually tracked includes, calling
|
||||
// debouncedParseAndCompareBeforeCallback will re-add it if it's still
|
||||
// required after it triggers
|
||||
deleteLastInclude(event.Name)
|
||||
|
||||
// wait for file to maybe get created again
|
||||
// see https://github.com/glanceapp/glance/pull/358
|
||||
for range 10 {
|
||||
if _, err := os.Stat(event.Name); err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
debouncedParseAndCompareBeforeCallback()
|
||||
} else if event.Has(fsnotify.Remove) {
|
||||
deleteLastInclude(event.Name)
|
||||
debouncedParseAndCompareBeforeCallback()
|
||||
}
|
||||
case err, isOpen := <-watcher.Errors:
|
||||
if !isOpen {
|
||||
return
|
||||
}
|
||||
onErr(fmt.Errorf("watcher error: %w", err))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
onChange(lastContents)
|
||||
|
||||
return func() error {
|
||||
if debounceTimer != nil {
|
||||
debounceTimer.Stop()
|
||||
}
|
||||
|
||||
return watcher.Close()
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TODO: Refactor, we currently validate in two different places, this being
|
||||
// one of them, which doesn't modify the data and only checks for logical errors
|
||||
// and then again when creating the application which does modify the data and do
|
||||
// further validation. Would be better if validation was done in a single place.
|
||||
func isConfigStateValid(config *config) error {
|
||||
if len(config.Pages) == 0 {
|
||||
return fmt.Errorf("no pages configured")
|
||||
}
|
||||
|
||||
if len(config.Auth.Users) > 0 && config.Auth.SecretKey == "" {
|
||||
return fmt.Errorf("secret-key must be set when users are configured")
|
||||
}
|
||||
|
||||
for username := range config.Auth.Users {
|
||||
if username == "" {
|
||||
return fmt.Errorf("user has no name")
|
||||
}
|
||||
|
||||
if len(username) < 3 {
|
||||
return errors.New("usernames must be at least 3 characters")
|
||||
}
|
||||
|
||||
user := config.Auth.Users[username]
|
||||
|
||||
if user.Password == "" {
|
||||
if user.PasswordHashString == "" {
|
||||
return fmt.Errorf("user %s must have a password or a password-hash set", username)
|
||||
}
|
||||
} else if len(user.Password) < 6 {
|
||||
return fmt.Errorf("the password for %s must be at least 6 characters", username)
|
||||
}
|
||||
}
|
||||
|
||||
if config.Server.AssetsPath != "" {
|
||||
if _, err := os.Stat(config.Server.AssetsPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("assets directory does not exist: %s", config.Server.AssetsPath)
|
||||
}
|
||||
}
|
||||
|
||||
for i := range config.Pages {
|
||||
if config.Pages[i].Title == "" {
|
||||
return fmt.Errorf("Page %d has no title", i+1)
|
||||
page := &config.Pages[i]
|
||||
|
||||
if page.Title == "" {
|
||||
return fmt.Errorf("page %d has no name", i+1)
|
||||
}
|
||||
|
||||
if len(config.Pages[i].Columns) == 0 {
|
||||
return fmt.Errorf("Page %d has no columns", i+1)
|
||||
if page.Width != "" && (page.Width != "wide" && page.Width != "slim" && page.Width != "default") {
|
||||
return fmt.Errorf("page %d: width can only be either wide or slim", i+1)
|
||||
}
|
||||
|
||||
if len(config.Pages[i].Columns) > 3 {
|
||||
return fmt.Errorf("Page %d has more than 3 columns: %d", i+1, len(config.Pages[i].Columns))
|
||||
if page.DesktopNavigationWidth != "" {
|
||||
if page.DesktopNavigationWidth != "wide" && page.DesktopNavigationWidth != "slim" && page.DesktopNavigationWidth != "default" {
|
||||
return fmt.Errorf("page %d: desktop-navigation-width can only be either wide or slim", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
if len(page.Columns) == 0 {
|
||||
return fmt.Errorf("page %d has no columns", i+1)
|
||||
}
|
||||
|
||||
if page.Width == "slim" {
|
||||
if len(page.Columns) > 2 {
|
||||
return fmt.Errorf("page %d is slim and cannot have more than 2 columns", i+1)
|
||||
}
|
||||
} else {
|
||||
if len(page.Columns) > 3 {
|
||||
return fmt.Errorf("page %d has more than 3 columns", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
columnSizesCount := make(map[string]int)
|
||||
|
||||
for j := range config.Pages[i].Columns {
|
||||
if config.Pages[i].Columns[j].Size != "small" && config.Pages[i].Columns[j].Size != "full" {
|
||||
return fmt.Errorf("Column %d of page %d: size can only be either small or full", j+1, i+1)
|
||||
for j := range page.Columns {
|
||||
column := &page.Columns[j]
|
||||
|
||||
if column.Size != "small" && column.Size != "full" {
|
||||
return fmt.Errorf("column %d of page %d: size can only be either small or full", j+1, i+1)
|
||||
}
|
||||
|
||||
columnSizesCount[config.Pages[i].Columns[j].Size]++
|
||||
columnSizesCount[page.Columns[j].Size]++
|
||||
}
|
||||
|
||||
full := columnSizesCount["full"]
|
||||
|
||||
if full > 2 || full == 0 {
|
||||
return fmt.Errorf("Page %d must have either 1 or 2 full width columns", i+1)
|
||||
return fmt.Errorf("page %d must have either 1 or 2 full width columns", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read-only way to store ordered maps from a YAML structure
|
||||
type orderedYAMLMap[K comparable, V any] struct {
|
||||
keys []K
|
||||
data map[K]V
|
||||
}
|
||||
|
||||
func newOrderedYAMLMap[K comparable, V any](keys []K, values []V) (*orderedYAMLMap[K, V], error) {
|
||||
if len(keys) != len(values) {
|
||||
return nil, fmt.Errorf("keys and values must have the same length")
|
||||
}
|
||||
|
||||
om := &orderedYAMLMap[K, V]{
|
||||
keys: make([]K, len(keys)),
|
||||
data: make(map[K]V, len(keys)),
|
||||
}
|
||||
|
||||
copy(om.keys, keys)
|
||||
|
||||
for i := range keys {
|
||||
om.data[keys[i]] = values[i]
|
||||
}
|
||||
|
||||
return om, nil
|
||||
}
|
||||
|
||||
func (om *orderedYAMLMap[K, V]) Items() iter.Seq2[K, V] {
|
||||
return func(yield func(K, V) bool) {
|
||||
for _, key := range om.keys {
|
||||
value, ok := om.data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !yield(key, value) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (om *orderedYAMLMap[K, V]) Get(key K) (V, bool) {
|
||||
value, ok := om.data[key]
|
||||
return value, ok
|
||||
}
|
||||
|
||||
func (self *orderedYAMLMap[K, V]) Merge(other *orderedYAMLMap[K, V]) *orderedYAMLMap[K, V] {
|
||||
merged := &orderedYAMLMap[K, V]{
|
||||
keys: make([]K, 0, len(self.keys)+len(other.keys)),
|
||||
data: make(map[K]V, len(self.data)+len(other.data)),
|
||||
}
|
||||
|
||||
merged.keys = append(merged.keys, self.keys...)
|
||||
maps.Copy(merged.data, self.data)
|
||||
|
||||
for _, key := range other.keys {
|
||||
if _, exists := self.data[key]; !exists {
|
||||
merged.keys = append(merged.keys, key)
|
||||
}
|
||||
}
|
||||
maps.Copy(merged.data, other.data)
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
func (om *orderedYAMLMap[K, V]) UnmarshalYAML(node *yaml.Node) error {
|
||||
if node.Kind != yaml.MappingNode {
|
||||
return fmt.Errorf("orderedMap: expected mapping node, got %d", node.Kind)
|
||||
}
|
||||
|
||||
if len(node.Content)%2 != 0 {
|
||||
return fmt.Errorf("orderedMap: expected even number of content items, got %d", len(node.Content))
|
||||
}
|
||||
|
||||
om.keys = make([]K, len(node.Content)/2)
|
||||
om.data = make(map[K]V, len(node.Content)/2)
|
||||
|
||||
for i := 0; i < len(node.Content); i += 2 {
|
||||
keyNode := node.Content[i]
|
||||
valueNode := node.Content[i+1]
|
||||
|
||||
var key K
|
||||
if err := keyNode.Decode(&key); err != nil {
|
||||
return fmt.Errorf("orderedMap: decoding key: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := om.data[key]; ok {
|
||||
return fmt.Errorf("orderedMap: duplicate key %v", key)
|
||||
}
|
||||
|
||||
var value V
|
||||
if err := valueNode.Decode(&value); err != nil {
|
||||
return fmt.Errorf("orderedMap: decoding value: %v", err)
|
||||
}
|
||||
|
||||
(*om).keys[i/2] = key
|
||||
(*om).data[key] = value
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||