Compare commits
625 Commits
v1.1
...
16a7f3409c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16a7f3409c | ||
|
|
0e11cec99a | ||
|
|
c158912da4 | ||
|
|
08ad04401d | ||
|
|
a851d8ac9d | ||
|
|
5a3547e32e | ||
|
|
b24152c19a | ||
|
|
9f93298cf9 | ||
|
|
ac9b635ed8 | ||
|
|
72a1930b9e | ||
|
|
e339354cc9 | ||
|
|
4b3a278679 | ||
|
|
aa06e65c59 | ||
|
|
dd57abefdd | ||
|
|
be8ba62bb1 | ||
|
|
b7895f6743 | ||
|
|
ebe7b130b8 | ||
|
|
7fe688e97c | ||
|
|
6b02a09f75 | ||
|
|
f0d2ab6493 | ||
|
|
42ee0372fe | ||
|
|
9762e09cb3 | ||
|
|
dd8b7ab27d | ||
|
|
c348593ef4 | ||
|
|
a51da7b8ec | ||
|
|
33503f7896 | ||
|
|
da569b3321 | ||
|
|
11285e4af0 | ||
|
|
9fe02931d8 | ||
|
|
e4f9dc8c72 | ||
|
|
88ed1de58b | ||
|
|
9bc89123f8 | ||
|
|
9fb3da2b4a | ||
|
|
58bb2c22c3 | ||
|
|
29d9ec6ef1 | ||
|
|
d2224399e2 | ||
|
|
67fbed7f6b | ||
|
|
c1df3f8068 | ||
|
|
0aed9b51a9 | ||
|
|
0bd7a66086 | ||
|
|
2b6823a277 | ||
|
|
dd7ed84a6c | ||
|
|
2c6a5ca971 | ||
|
|
5bf7647cba | ||
|
|
f721034ae5 | ||
|
|
a32361fab2 | ||
|
|
572e489db6 | ||
|
|
efcb6f8bf0 | ||
|
|
7e367ef537 | ||
|
|
b9a3326a98 | ||
|
|
484b155a3c | ||
|
|
9cba4e8deb | ||
|
|
749d7b682e | ||
|
|
35850d6310 | ||
|
|
15db17d834 | ||
|
|
a0d86e884a | ||
|
|
acf97c8a3b | ||
|
|
34bf9e5160 | ||
|
|
4420f3a8ae | ||
|
|
8d2ea6cf8a | ||
|
|
e244237474 | ||
|
|
ff81c9d689 | ||
|
|
11d99f106e | ||
|
|
b8afa82a81 | ||
|
|
097a2da5cb | ||
|
|
e6d32946c1 | ||
|
|
fe4eaa4b8d | ||
|
|
48a671b285 | ||
|
|
011c9c7668 | ||
|
|
f06fc1f750 | ||
|
|
0e88d4284d | ||
|
|
1615c6869f | ||
|
|
800f43b299 | ||
|
|
15bff0a0c4 | ||
|
|
e1481f4aac | ||
|
|
7ef97ee6db | ||
|
|
d785fe4c5a | ||
|
|
5254df53dc | ||
|
|
7301eab99c | ||
|
|
ad138c3017 | ||
|
|
b09c95d7ea | ||
|
|
64611a0dd3 | ||
|
|
321ad7608f | ||
|
|
2a8b6ea935 | ||
|
|
e9cbea500b | ||
|
|
223039b2c6 | ||
|
|
7402dfc4e6 | ||
|
|
6b12715506 | ||
|
|
2dc58c5c8e | ||
|
|
0cef51c6ac | ||
|
|
2a4d974965 | ||
|
|
f71792d6a5 | ||
|
|
b571042c5d | ||
|
|
349c966c63 | ||
|
|
4a42b239cc | ||
|
|
b9b3d2350c | ||
|
|
b13cd85f0b | ||
|
|
daffd721eb | ||
|
|
24232d72e9 | ||
|
|
4983e18e23 | ||
|
|
e1954e4cba | ||
|
|
58420ae52b | ||
|
|
b01f71de1a | ||
|
|
379aaed39e | ||
|
|
dc20932060 | ||
|
|
96835ebd33 | ||
|
|
c896f779b5 | ||
|
|
5f606b1c40 | ||
|
|
9d5b8d99f7 | ||
|
|
13c047fc21 | ||
|
|
55751b3eb6 | ||
|
|
b961502a17 | ||
|
|
a895145586 | ||
|
|
5aec3b4dab | ||
|
|
d787060a24 | ||
|
|
c1a29418eb | ||
|
|
17847f999c | ||
|
|
3adcddc70c | ||
|
|
c76ff26bd6 | ||
|
|
50f8648f64 | ||
|
|
5f82a9e339 | ||
|
|
3278ba4eac | ||
|
|
9fc72f8b68 | ||
|
|
b7b707bd43 | ||
|
|
7cf27e0fde | ||
|
|
66f2a973a3 | ||
|
|
7ecbbff18a | ||
|
|
850ce195a0 | ||
|
|
479aebd023 | ||
|
|
9b178d1fb3 | ||
|
|
3ab098db5c | ||
|
|
6d16e93008 | ||
|
|
98934daee4 | ||
|
|
259474cae9 | ||
|
|
1e65a7951b | ||
|
|
bed5640366 | ||
|
|
57ea83cf4f | ||
|
|
219842d723 | ||
|
|
a96fc101f2 | ||
|
|
81a77ce0a4 | ||
|
|
9ed359f964 | ||
|
|
bc18557820 | ||
|
|
7d99edab8d | ||
|
|
32ca121520 | ||
|
|
9f1a0534a3 | ||
|
|
d2678be96d | ||
|
|
95ebbb9d13 | ||
|
|
0f6d4d639d | ||
|
|
795a5d2cb4 | ||
|
|
dd5f760606 | ||
|
|
58d6a46e36 | ||
|
|
a8d7b86cdc | ||
|
|
aac3de7ca2 | ||
|
|
de24659bae | ||
|
|
632412c10e | ||
|
|
012b58bbe4 | ||
|
|
c092842ee4 | ||
|
|
e4c1d01915 | ||
|
|
ce07ddea92 | ||
|
|
bd6322e533 | ||
|
|
91da774286 | ||
|
|
e62906e63d | ||
|
|
56e5625adc | ||
|
|
1ecf4b0bb4 | ||
|
|
57d9421c7f | ||
|
|
a73188944d | ||
|
|
97904cc0f3 | ||
|
|
f28f354992 | ||
|
|
698f5d6d06 | ||
|
|
b935a1c511 | ||
|
|
10e6bfa5a0 | ||
|
|
f030a4075b | ||
|
|
c9dd977600 | ||
|
|
c1bcc0c517 | ||
|
|
2a5692d9a7 | ||
|
|
a8d160f9b1 | ||
|
|
286cbff236 | ||
|
|
fff0870d3b | ||
|
|
fe22460c07 | ||
|
|
18f2789a5d | ||
|
|
7f161a5408 | ||
|
|
cba3fbc48c | ||
|
|
5e46f1480e | ||
|
|
ead253c55f | ||
|
|
6b8da92cb3 | ||
|
|
a91f64ce9d | ||
|
|
e1a6ccf133 | ||
|
|
d2c034a850 | ||
|
|
713930decc | ||
|
|
ee2a825cf0 | ||
|
|
8e9da86f83 | ||
|
|
9eb49fd3a7 | ||
|
|
684bc25b83 | ||
|
|
8ceab03cd7 | ||
|
|
34dad4ac8f | ||
|
|
b40d930f8a | ||
|
|
d4b34e900e | ||
|
|
954b549029 | ||
|
|
fbd0b2310e | ||
|
|
be7af0ccaf | ||
|
|
18221ef12d | ||
|
|
4c0726412b | ||
|
|
d7253a60b8 | ||
|
|
2de3ddff08 | ||
|
|
830248b6ae | ||
|
|
f8db2ef7ad | ||
|
|
109caaa889 | ||
|
|
d0b83babd2 | ||
|
|
de3decbffd | ||
|
|
c92229a698 | ||
|
|
176852b662 | ||
|
|
52cc8ecbbd | ||
|
|
e3e9542f1e | ||
|
|
b78c8bf8bf | ||
|
|
bff7476b58 | ||
|
|
05f5785660 | ||
|
|
cb50aed89a | ||
|
|
df655aca5e | ||
|
|
86853a87bf | ||
|
|
e3109a4384 | ||
|
|
eee8002d69 | ||
|
|
92f11f7513 | ||
|
|
5428e6be3a | ||
|
|
1ad693f931 | ||
|
|
c2d88a7e3f | ||
|
|
3b29d737eb | ||
|
|
fe178b8fc6 | ||
|
|
cca742a1c2 | ||
|
|
c7eddff118 | ||
|
|
cf30ed249f | ||
|
|
26b87dee98 | ||
|
|
77c7f938f1 | ||
|
|
f98de9a0a5 | ||
|
|
6fa2b67024 | ||
|
|
355e5feb62 | ||
|
|
a7dd707062 | ||
|
|
4de46a7bc5 | ||
|
|
2c6fce3322 | ||
|
|
19ecfcd0bc | ||
|
|
d575acfe80 | ||
|
|
d203d38de6 | ||
|
|
9f01f63613 | ||
|
|
982c4ebbbc | ||
|
|
0c5385cef3 | ||
|
|
58f4e1f6c9 | ||
|
|
6b7f69d5c0 | ||
|
|
7aeb458ee5 | ||
|
|
7cfd3b3238 | ||
|
|
55262d38fe | ||
|
|
a45e29feb7 | ||
|
|
9f5fd3bb4d | ||
|
|
63f9d55903 | ||
|
|
8f36ae013e | ||
|
|
851aa1a136 | ||
|
|
f38dcfba3b | ||
|
|
214c7aacfc | ||
|
|
eb9bfc57e2 | ||
|
|
c072783c42 | ||
|
|
9d701678e1 | ||
|
|
37ed856d8b | ||
|
|
28f08ad42a | ||
|
|
da267a56ef | ||
|
|
16e4cad9ad | ||
|
|
d13a04898e | ||
|
|
ff39fbff70 | ||
|
|
92c6aac49e | ||
|
|
4ca81f90e9 | ||
|
|
75e828cb4c | ||
|
|
883214a740 | ||
|
|
36e359c881 | ||
|
|
87b53fb8ec | ||
|
|
2ae62855cc | ||
|
|
19889c1457 | ||
|
|
f9afbac258 | ||
|
|
e54df07a40 | ||
|
|
f8455236dc | ||
|
|
d308bb64c2 | ||
|
|
077715f6c2 | ||
|
|
bfe7bfdbd5 | ||
|
|
1013cd1122 | ||
|
|
3e57ccc999 | ||
|
|
5f23f8be89 | ||
|
|
211b1456c7 | ||
|
|
3d9c9d03cc | ||
|
|
1ea8160f7d | ||
|
|
2c12875199 | ||
|
|
bb0b575eca | ||
|
|
e51ccb723e | ||
|
|
96796702cf | ||
|
|
fd44c98cd0 | ||
|
|
c2a28bcadf | ||
|
|
30e6afb096 | ||
|
|
882be1dbf6 | ||
|
|
8764891b80 | ||
|
|
fbb0dfed47 | ||
|
|
42b36965c5 | ||
|
|
e326c7a0fb | ||
|
|
9fae33f57b | ||
|
|
a397d2013d | ||
|
|
f65aadb055 | ||
|
|
c825f8864f | ||
|
|
2edf11a36a | ||
|
|
2df2f41516 | ||
|
|
614dcc8975 | ||
|
|
6acf9af887 | ||
|
|
ecdfcb5017 | ||
|
|
144fc1606a | ||
|
|
9919d72be0 | ||
|
|
9e95f71de8 | ||
|
|
09bfc47ef0 | ||
|
|
2e5ccc3158 | ||
|
|
70481dff73 | ||
|
|
cc12f27ce3 | ||
|
|
c36d82636d | ||
|
|
b123753d65 | ||
|
|
f590c358d2 | ||
|
|
fa2fad0ff6 | ||
|
|
d8aab7acae | ||
|
|
63ad971890 | ||
|
|
0828d6782e | ||
|
|
cf5856bdf7 | ||
|
|
34edfc0727 | ||
|
|
a1b1686d3b | ||
|
|
c5abf8f9d0 | ||
|
|
9a5af089eb | ||
|
|
9edd865bf4 | ||
|
|
e50c7e1a51 | ||
|
|
8967936fb6 | ||
|
|
fa92ea16b0 | ||
|
|
c8d6363677 | ||
|
|
b082c3e048 | ||
|
|
82fdb3be6c | ||
|
|
d7ba203f28 | ||
|
|
1cba53f7fb | ||
|
|
0a0db68905 | ||
|
|
3512350a22 | ||
|
|
8b2a9d8f20 | ||
|
|
34b50d388a | ||
|
|
cd412a4ac5 | ||
|
|
7fb6271e56 | ||
|
|
2cd815d9cd | ||
|
|
0a6e621c02 | ||
|
|
10c656a3b6 | ||
|
|
0ea313d945 | ||
|
|
1f02bde5e1 | ||
|
|
3e0c784744 | ||
|
|
528df7fb4a | ||
|
|
b04e8c1e93 | ||
|
|
0b8bf50204 | ||
|
|
f43924c17b | ||
|
|
0f519b7202 | ||
|
|
c74eeff790 | ||
|
|
e7b645a68a | ||
|
|
fc0bfd29db | ||
|
|
8950181f21 | ||
|
|
70e592c979 | ||
|
|
401668e413 | ||
|
|
37ddde1765 | ||
|
|
82586dedff | ||
|
|
ac36892150 | ||
|
|
c958ee9116 | ||
|
|
e5920259b6 | ||
|
|
8c44d2fc87 | ||
|
|
332ee0e6b5 | ||
|
|
3ae17171e2 | ||
|
|
493a4262b1 | ||
|
|
485587825c | ||
|
|
a83d43a5b1 | ||
|
|
71f81a3802 | ||
|
|
6481c97645 | ||
|
|
fa40a79d50 | ||
|
|
169d579400 | ||
|
|
430f300140 | ||
|
|
a9b450db03 | ||
|
|
89ce8df0e3 | ||
|
|
aa015b78c0 | ||
|
|
3ed1b3e612 | ||
|
|
7fb0d3833e | ||
|
|
2da616d4ff | ||
|
|
0b3d7faf9f | ||
|
|
b3ba912566 | ||
|
|
36bc84d99a | ||
|
|
f126247262 | ||
|
|
b145b00f8e | ||
|
|
7dbfecdba1 | ||
|
|
ad693aaf02 | ||
|
|
fafa6286d4 | ||
|
|
cc51fe01c2 | ||
|
|
91deb41d5b | ||
|
|
2e4082df77 | ||
|
|
51cbdea31f | ||
|
|
5335863488 | ||
|
|
c2e1926741 | ||
|
|
37a679fc80 | ||
|
|
1be79d922b | ||
|
|
6685bce51c | ||
|
|
fe1a1987bd | ||
|
|
80402943a1 | ||
|
|
b40fe94147 | ||
|
|
e0e6166cdf | ||
|
|
a2bfd1682b | ||
|
|
1f393faf79 | ||
|
|
c469749eaa | ||
|
|
e0009e4267 | ||
|
|
24a06faa3c | ||
|
|
9ede816078 | ||
|
|
646519e074 | ||
|
|
454eff0155 | ||
|
|
ebd7f2929c | ||
|
|
5b36530f67 | ||
|
|
c91b439878 | ||
|
|
7d61f705bf | ||
|
|
9fa8b8440a | ||
|
|
9e8837b37d | ||
|
|
7ca9415322 | ||
|
|
e78c028d20 | ||
|
|
cbc75047b8 | ||
|
|
cc6f6d91e1 | ||
|
|
70e9e1ed3a | ||
|
|
efafdbebaa | ||
|
|
19967ce37c | ||
|
|
ce3d6fba37 | ||
|
|
3e14716fc6 | ||
|
|
43620cd9b6 | ||
|
|
e819140f36 | ||
|
|
f28f0eac1a | ||
|
|
ff4abb8dfe | ||
|
|
e2efaddfed | ||
|
|
b1db6c6fb1 | ||
|
|
279fc469ab | ||
|
|
d185fb6dd7 | ||
|
|
3a667a3809 | ||
|
|
a895775f81 | ||
|
|
0a9beddef9 | ||
|
|
29528e40b0 | ||
|
|
aaf0b702a3 | ||
|
|
9f376db0f4 | ||
|
|
391ce61362 | ||
|
|
62e2ca4c16 | ||
|
|
ea5af73901 | ||
|
|
7d7feda319 | ||
|
|
1c810f68f8 | ||
|
|
8528a80d7e | ||
|
|
dd0bf1a012 | ||
|
|
e0c4752bbf | ||
|
|
c896440525 | ||
|
|
1f042a8434 | ||
|
|
fc3383946d | ||
|
|
4abbebf5e9 | ||
|
|
eb0ad7f22e | ||
|
|
0b1c90718d | ||
|
|
47597b2b7c | ||
|
|
f3c55ba5f2 | ||
|
|
5e453e3227 | ||
|
|
9bf7f45354 | ||
|
|
c8bc511e04 | ||
|
|
85a114e591 | ||
|
|
73b7144394 | ||
|
|
e9f6a0a1d2 | ||
|
|
dfb32d4ebe | ||
|
|
682b0c1729 | ||
|
|
e79abb69eb | ||
|
|
514ed02693 | ||
|
|
ff3241bd57 | ||
|
|
76937bedc9 | ||
|
|
1e65da9aa4 | ||
|
|
0d49377879 | ||
|
|
1a490a8e7a | ||
|
|
e53265472f | ||
|
|
66fdbef90b | ||
|
|
80482e70a8 | ||
|
|
f214c3166b | ||
|
|
4a4303afef | ||
|
|
cc7bdc5b76 | ||
|
|
3539433a9d | ||
|
|
54cb821ae9 | ||
|
|
4924dcfd12 | ||
|
|
d84e76d07c | ||
|
|
eca215d044 | ||
|
|
721de3fba6 | ||
|
|
e7fa98008d | ||
|
|
923fbc54b8 | ||
|
|
ff39f90abd | ||
|
|
db30fa3c5e | ||
|
|
26c902d551 | ||
|
|
e9739f191e | ||
|
|
fa2b97242d | ||
|
|
f1332d4200 | ||
|
|
d34c6df673 | ||
|
|
77ddde1c8a | ||
|
|
d39bdd7ef2 | ||
|
|
473b38ebdf | ||
|
|
59f546804e | ||
|
|
a7f6bdc0ba | ||
|
|
7652f44b79 | ||
|
|
5b3adb2c7e | ||
|
|
ed8f2ab96f | ||
|
|
5fa27a99da | ||
|
|
fcdb97f079 | ||
|
|
f2994bc6d0 | ||
|
|
ffa0bc1733 | ||
|
|
1198005803 | ||
|
|
e3820d1c8e | ||
|
|
af2a01eea2 | ||
|
|
5ec89f4041 | ||
|
|
57efbe4ebc | ||
|
|
7b558c529b | ||
|
|
caabd069d6 | ||
|
|
28ad0345f3 | ||
|
|
d89ae3a2bc | ||
|
|
bd12096e74 | ||
|
|
1cdde4a6b8 | ||
|
|
79704d81c2 | ||
|
|
4225e06db9 | ||
|
|
fc5da31acf | ||
|
|
f2db0309ac | ||
|
|
48dd1a28f8 | ||
|
|
ec04bd99d0 | ||
|
|
899fe62b85 | ||
|
|
512245f54f | ||
|
|
e1cfb04f98 | ||
|
|
4accfad266 | ||
|
|
bf5351d551 | ||
|
|
ef44706957 | ||
|
|
5b0b47635d | ||
|
|
93eeef0131 | ||
|
|
089f0ee30b | ||
|
|
0565e74cfa | ||
|
|
2270f716f2 | ||
|
|
a850d83b33 | ||
|
|
7560b8167b | ||
|
|
5d626b3d07 | ||
|
|
0997440f32 | ||
|
|
62e8d3758a | ||
|
|
3eb1820759 | ||
|
|
bd734cb918 | ||
|
|
6a643f7ca7 | ||
|
|
adef7b76c9 | ||
|
|
1037a8de0d | ||
|
|
3fac9bb1bd | ||
|
|
d825ce9bdf | ||
|
|
25c6a151ce | ||
|
|
1a7add1890 | ||
|
|
fca4194946 | ||
|
|
121101de9d | ||
|
|
a3146926b1 | ||
|
|
81df244d41 | ||
|
|
068b4030f5 | ||
|
|
0916f1179e | ||
|
|
6a5be593df | ||
|
|
7fd1ef80c5 | ||
|
|
26313f7842 | ||
|
|
ce1914419a | ||
|
|
6a828532cb | ||
|
|
e8a002d535 | ||
|
|
4f1e029d0b | ||
|
|
9b93a959e5 | ||
|
|
68d269658f | ||
|
|
875b87b0d6 | ||
|
|
3e79cd7944 | ||
|
|
0ea8972ede | ||
|
|
a7113addd0 | ||
|
|
84df847898 | ||
|
|
333d9373dd | ||
|
|
eb90c5b9aa | ||
|
|
3371e1afff | ||
|
|
8aafb1b729 | ||
|
|
1a0db29aa6 | ||
|
|
52073e7e81 | ||
|
|
4f79c919f0 | ||
|
|
63b265fa04 | ||
|
|
6b01d9d7b9 | ||
|
|
20a0a6724a | ||
|
|
e79cb9e6e0 | ||
|
|
d7ddcc04b5 | ||
|
|
99684a4b2f | ||
|
|
6a6153ca48 | ||
|
|
23a4ff3af6 | ||
|
|
d6c2ba5812 | ||
|
|
fa0237b546 | ||
|
|
9fcaad6b2f | ||
|
|
edc7d56219 | ||
|
|
d0a2b80ecc | ||
|
|
e2d80af81d | ||
|
|
eccd383c1c | ||
|
|
db7a178a8d | ||
|
|
62e0caa950 | ||
|
|
46d8c98aff | ||
|
|
05634ebdb7 | ||
|
|
0e2da62081 | ||
|
|
94d1659ad5 | ||
|
|
0745c92e9a | ||
|
|
7c06952a7d | ||
|
|
e2d8ca3506 | ||
|
|
4f20f537c0 | ||
|
|
a0b42b27b3 | ||
|
|
288fa3979a | ||
|
|
e4cc96ef09 | ||
|
|
60a947f131 | ||
|
|
0226c8da23 | ||
|
|
b0364087ad | ||
|
|
40a9773beb | ||
|
|
790a275443 | ||
|
|
9b9addf3e6 | ||
|
|
57d2437e9c | ||
|
|
a13aea478e | ||
|
|
6def522f38 | ||
|
|
6a63d49823 | ||
|
|
b766cb4ac5 | ||
|
|
32ab1fefa9 | ||
|
|
70761c47eb | ||
|
|
6a09d52b85 | ||
|
|
fcaf23d6bc | ||
|
|
54c2a6458d | ||
|
|
05032ec428 | ||
|
|
e24b905adc | ||
|
|
f27d0c4cd7 | ||
|
|
11a2aa2b4a | ||
|
|
2eee8baa26 | ||
|
|
0949ffc027 | ||
|
|
286538c5d0 | ||
|
|
78844def40 | ||
|
|
e17ce0fb31 | ||
|
|
55a1c297be | ||
|
|
9bb7ae7902 |
25
.github/actions/prepare/action.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Build & Upload
|
||||
inputs:
|
||||
id:
|
||||
description: artifact name
|
||||
required: true
|
||||
cmd:
|
||||
description: command to run
|
||||
required: true
|
||||
out:
|
||||
description: path to output file
|
||||
required: true
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: compile
|
||||
run: ${{ inputs.cmd }}
|
||||
shell: bash
|
||||
- name: archive
|
||||
run: tar -cvf ${{ inputs.out }}.tar ${{ inputs.out }}
|
||||
shell: bash
|
||||
- name: upload
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.id }}
|
||||
path: ${{ inputs.out }}.tar
|
||||
41
.github/workflows/build-docker.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: Publish Docker Image
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: nkanaev/yarr
|
||||
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./etc/dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
201
.github/workflows/build.yml
vendored
@@ -1,126 +1,143 @@
|
||||
name: build
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [v*]
|
||||
tags:
|
||||
- v*
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build_macos:
|
||||
name: Build for MacOS
|
||||
runs-on: macos-10.15
|
||||
runs-on: macos-13
|
||||
steps:
|
||||
- {name: "Checkout", uses: actions/checkout@v2}
|
||||
- {name: "Checkout gofeed", uses: actions/checkout@v2, with: {repository: nkanaev/gofeed, path: gofeed}}
|
||||
- name: Cache Go Modules
|
||||
uses: actions/cache@v2
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
- name: "Build"
|
||||
run: make build_macos
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v2
|
||||
go-version: '^1.23'
|
||||
- name: Build arm64 gui
|
||||
uses: ./.github/actions/prepare
|
||||
with:
|
||||
name: macos
|
||||
path: _output/macos/yarr.app
|
||||
id: darwin_arm64_gui
|
||||
cmd: make darwin_arm64_gui
|
||||
out: out/darwin_arm64_gui/yarr.app
|
||||
- name: Build amd64 gui
|
||||
uses: ./.github/actions/prepare
|
||||
with:
|
||||
id: darwin_amd64_gui
|
||||
cmd: make darwin_amd64_gui
|
||||
out: out/darwin_amd64_gui/yarr.app
|
||||
- name: Build arm64 cli
|
||||
uses: ./.github/actions/prepare
|
||||
with:
|
||||
id: darwin_arm64
|
||||
cmd: make darwin_arm64
|
||||
out: out/darwin_arm64/yarr
|
||||
- name: Build amd64 cli
|
||||
uses: ./.github/actions/prepare
|
||||
with:
|
||||
id: darwin_amd64
|
||||
cmd: make darwin_amd64
|
||||
out: out/darwin_amd64/yarr
|
||||
|
||||
build_windows:
|
||||
name: Build for Windows
|
||||
runs-on: windows-2019
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- {name: "Checkout", uses: actions/checkout@v2}
|
||||
- {name: "Checkout gofeed", uses: actions/checkout@v2, with: {repository: nkanaev/gofeed, path: gofeed}}
|
||||
- name: Cache Go Modules
|
||||
uses: actions/cache@v2
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
- name: "Build"
|
||||
run: make build_windows
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v2
|
||||
go-version: '^1.23'
|
||||
- name: Build amd64 gui
|
||||
uses: ./.github/actions/prepare
|
||||
with:
|
||||
name: windows
|
||||
path: _output/windows/yarr.exe
|
||||
id: windows_amd64_gui
|
||||
cmd: make windows_amd64_gui
|
||||
out: out/windows_amd64_gui/yarr.exe
|
||||
- name: Build arm64 gui
|
||||
if: false
|
||||
uses: ./.github/actions/prepare
|
||||
with:
|
||||
id: windows_arm64_gui
|
||||
cmd: make windows_arm64_gui
|
||||
out: out/windows_arm64_gui/yarr.exe
|
||||
|
||||
build_linux:
|
||||
name: Build for Linux
|
||||
runs-on: ubuntu-18.04
|
||||
build_multi_cli:
|
||||
name: Build for Windows/MacOS/Linux CLI
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- {name: "Checkout", uses: actions/checkout@v2}
|
||||
- {name: "Checkout gofeed", uses: actions/checkout@v2, with: {repository: nkanaev/gofeed, path: gofeed}}
|
||||
- {name: "Setup Go", uses: actions/setup-go@v2, with: {go-version: '^1.14'}}
|
||||
- name: Cache Go Modules
|
||||
uses: actions/cache@v2
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
- name: "Build"
|
||||
run: make build_linux
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v2
|
||||
go-version: '^1.23'
|
||||
- name: Setup Zig
|
||||
uses: mlugg/setup-zig@v1
|
||||
with:
|
||||
name: linux
|
||||
path: _output/linux/yarr
|
||||
version: 0.14.0
|
||||
- name: Build linux/amd64
|
||||
uses: ./.github/actions/prepare
|
||||
with:
|
||||
id: linux_amd64
|
||||
cmd: make linux_amd64
|
||||
out: out/linux_amd64/yarr
|
||||
- name: Build linux/arm64
|
||||
uses: ./.github/actions/prepare
|
||||
with:
|
||||
id: linux_arm64
|
||||
cmd: make linux_arm64
|
||||
out: out/linux_arm64/yarr
|
||||
- name: Build linux/armv7
|
||||
uses: ./.github/actions/prepare
|
||||
with:
|
||||
id: linux_armv7
|
||||
cmd: make linux_armv7
|
||||
out: out/linux_armv7/yarr
|
||||
- name: Build windows/amd64
|
||||
uses: ./.github/actions/prepare
|
||||
with:
|
||||
id: windows_amd64
|
||||
cmd: make windows_amd64
|
||||
out: out/windows_amd64/yarr
|
||||
- name: Build windows/arm64
|
||||
uses: ./.github/actions/prepare
|
||||
with:
|
||||
id: windows_arm64
|
||||
cmd: make windows_arm64
|
||||
out: out/windows_arm64/yarr
|
||||
|
||||
create_release:
|
||||
name: Create Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build_macos, build_windows, build_linux]
|
||||
needs: [build_macos, build_windows, build_multi_cli]
|
||||
steps:
|
||||
- name: Create Release
|
||||
uses: actions/create-release@v1
|
||||
id: create_release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: ${{ github.ref }}
|
||||
draft: true
|
||||
prerelease: true
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@v2
|
||||
uses: actions/download-artifact@v4.1.7
|
||||
with:
|
||||
path: .
|
||||
- name: Preparation
|
||||
run: |
|
||||
set -ex
|
||||
ls -R
|
||||
chmod u+x macos/Contents/MacOS/yarr
|
||||
chmod u+x linux/yarr
|
||||
|
||||
mv macos yarr.app && zip -r yarr-macos.zip yarr.app
|
||||
mv windows/yarr.exe . && zip yarr-windows.zip yarr.exe
|
||||
mv linux/yarr . && zip yarr-linux.zip yarr
|
||||
- name: Upload MacOS
|
||||
uses: actions/upload-release-asset@v1
|
||||
for tarfile in `ls **/*.tar`; do
|
||||
tar -xvf $tarfile
|
||||
done
|
||||
for dir in out/*; do
|
||||
echo "Compressing: $dir"
|
||||
(test -d "$dir" && cd $dir && zip -r ../yarr_`basename $dir`.zip *)
|
||||
done
|
||||
ls out
|
||||
- name: Upload Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./yarr-macos.zip
|
||||
asset_name: yarr-${{ github.ref }}-macos64.zip
|
||||
asset_content_type: application/zip
|
||||
- name: Upload Windows
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./yarr-windows.zip
|
||||
asset_name: yarr-${{ github.ref }}-windows32.zip
|
||||
asset_content_type: application/zip
|
||||
- name: Upload Linux
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./yarr-linux.zip
|
||||
asset_name: yarr-${{ github.ref }}-linux32.zip
|
||||
asset_content_type: application/zip
|
||||
draft: true
|
||||
prerelease: true
|
||||
files: |
|
||||
out/*.zip
|
||||
|
||||
19
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: Test
|
||||
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '^1.18'
|
||||
|
||||
- name: Run tests
|
||||
run: make test
|
||||
6
.gitignore
vendored
@@ -1,7 +1,9 @@
|
||||
/server/assets.go
|
||||
/gofeed
|
||||
/_output
|
||||
/out
|
||||
/yarr
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.syso
|
||||
versioninfo.rc
|
||||
.DS_Store
|
||||
|
||||
|
Before Width: | Height: | Size: 727 KiB |
|
Before Width: | Height: | Size: 395 B |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-list"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>
|
||||
|
Before Width: | Height: | Size: 482 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-settings"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1011 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-trash-2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>
|
||||
|
Before Width: | Height: | Size: 448 B |
@@ -1,375 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>yarr!</title>
|
||||
<link rel="stylesheet" href="./static/stylesheets/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="./static/stylesheets/app.css">
|
||||
<link rel="icon shortcut" href="./static/graphicarts/anchor.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
</head>
|
||||
<body class="theme-light">
|
||||
<div id="app" class="d-flex" :class="{'feed-selected': feedSelected !== null, 'item-selected': itemSelected !== null}" v-cloak>
|
||||
<!-- feed list -->
|
||||
<div id="col-feed-list" class="vh-100 position-relative d-flex flex-column border-right flex-shrink-0" :style="{width: feedListWidth+'px'}">
|
||||
<drag :width="feedListWidth" @resize="resizeFeedList"></drag>
|
||||
<div class="p-2 toolbar d-flex align-items-center">
|
||||
<div class="icon mx-2">{% inline "anchor.svg" %}</div>
|
||||
<div class="flex-grow-1"></div>
|
||||
<button class="toolbar-item"
|
||||
:class="{active: filterSelected == 'unread'}"
|
||||
v-b-tooltip.hover.bottom="'Unread'"
|
||||
@click="filterSelected = 'unread'">
|
||||
<span class="icon">{% inline "circle-full.svg" %}</span>
|
||||
</button>
|
||||
<button class="toolbar-item"
|
||||
:class="{active: filterSelected == 'starred'}"
|
||||
v-b-tooltip.hover.bottom="'Starred'"
|
||||
@click="filterSelected = 'starred'">
|
||||
<span class="icon">{% inline "star-full.svg" %}</span>
|
||||
</button>
|
||||
<button class="toolbar-item"
|
||||
:class="{active: filterSelected == ''}"
|
||||
v-b-tooltip.hover.bottom="'All'"
|
||||
@click="filterSelected = ''">
|
||||
<span class="icon">{% inline "assorted.svg" %}</span>
|
||||
</button>
|
||||
<div class="flex-grow-1"></div>
|
||||
<b-dropdown
|
||||
right no-caret lazy variant="link"
|
||||
class="settings-dropdown"
|
||||
toggle-class="toolbar-item px-2"
|
||||
ref="menuDropdown">
|
||||
<template v-slot:button-content class="toolbar-item">
|
||||
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
||||
</template>
|
||||
<b-dropdown-item-button @click="showSettings('create')">
|
||||
<span class="icon mr-1">{% inline "plus.svg" %}</span>
|
||||
New Feed
|
||||
</b-dropdown-item-button>
|
||||
<b-dropdown-item-button @click.stop="showSettings('manage')">
|
||||
<span class="icon mr-1">{% inline "list.svg" %}</span>
|
||||
Manage Feeds
|
||||
</b-dropdown-item-button>
|
||||
<b-dropdown-divider></b-dropdown-divider>
|
||||
<b-dropdown-item-button @click.stop="fetchAllFeeds()">
|
||||
<span class="icon mr-1">{% inline "rotate-cw.svg" %}</span>
|
||||
Refresh Feeds
|
||||
</b-dropdown-item-button>
|
||||
<b-dropdown-divider></b-dropdown-divider>
|
||||
<b-dropdown-header>Sort by</b-dropdown-header>
|
||||
<b-dropdown-item-button @click.stop="itemSortNewestFirst=true">
|
||||
<span class="icon mr-1" :class="{invisible: !itemSortNewestFirst}">{% inline "check.svg" %}</span>
|
||||
Newest First
|
||||
</b-dropdown-item-button>
|
||||
<b-dropdown-item-button @click="itemSortNewestFirst=false">
|
||||
<span class="icon mr-1" :class="{invisible: itemSortNewestFirst}">{% inline "check.svg" %}</span>
|
||||
Oldest First
|
||||
</b-dropdown-item-button>
|
||||
<b-dropdown-divider></b-dropdown-divider>
|
||||
<b-dropdown-header>Subscriptions</b-dropdown-header>
|
||||
<b-dropdown-form id="opml-import-form" enctype="multipart/form-data">
|
||||
<input type="file"
|
||||
id="opml-import"
|
||||
@change="importOPML"
|
||||
name="opml"
|
||||
style="opacity: 0; width: 1px; height: 0; position: absolute;">
|
||||
<label class="dropdown-item mb-0 cursor-pointer" for="opml-import">
|
||||
<span class="icon mr-1">{% inline "download.svg" %}</span>
|
||||
Import
|
||||
</label>
|
||||
</b-dropdown-form>
|
||||
<b-dropdown-item href="/opml/export">
|
||||
<span class="icon mr-1">{% inline "upload.svg" %}</span>
|
||||
Export
|
||||
</b-dropdown-item>
|
||||
</b-dropdown>
|
||||
</div>
|
||||
<div class="p-2 overflow-auto border-top flex-grow-1">
|
||||
<label class="selectgroup">
|
||||
<input type="radio" name="feed" value="" v-model="feedSelected">
|
||||
<div class="selectgroup-label d-flex align-items-center w-100">
|
||||
<span class="icon mr-2">{% inline "layers.svg" %}</span>
|
||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='unread'">All Unread</span>
|
||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='starred'">All Starred</span>
|
||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected==''">All Feeds</span>
|
||||
<span class="counter text-right">{{ filteredTotalStats }}</span>
|
||||
</div>
|
||||
</label>
|
||||
<div v-for="folder in foldersWithFeeds">
|
||||
<label class="selectgroup mt-1"
|
||||
:class="{'d-none': filterSelected
|
||||
&& !filteredFolderStats[folder.id]
|
||||
&& (!itemSelected || feedsById[itemSelectedDetails.feed_id].folder_id != folder.id)}">
|
||||
<input type="radio" name="feed" :value="'folder:'+folder.id" v-model="feedSelected">
|
||||
<div class="selectgroup-label d-flex align-items-center w-100" v-if="folder.id">
|
||||
<span class="icon mr-2"
|
||||
:class="{expanded: folder.is_expanded}"
|
||||
@click.prevent="toggleFolderExpanded(folder)">
|
||||
{% inline "chevron-right.svg" %}
|
||||
</span>
|
||||
<span class="flex-fill text-left text-truncate">{{ folder.title }}</span>
|
||||
<span class="counter text-right">{{ filteredFolderStats[folder.id] || '' }}</span>
|
||||
</div>
|
||||
</label>
|
||||
<div v-show="!folder.id || folder.is_expanded" class="mt-1" :class="{'pl-3': folder.id}">
|
||||
<label class="selectgroup"
|
||||
:class="{'d-none': filterSelected
|
||||
&& !filteredFeedStats[feed.id]
|
||||
&& (!itemSelected || itemSelectedDetails.feed_id != feed.id)}"
|
||||
v-for="feed in folder.feeds">
|
||||
<input type="radio" name="feed" :value="'feed:'+feed.id" v-model="feedSelected">
|
||||
<div class="selectgroup-label d-flex align-items-center w-100">
|
||||
<span class="icon mr-2" v-if="!feed.has_icon">{% inline "rss.svg" %}</span>
|
||||
<span class="icon mr-2" v-else><img v-lazy="'/api/feeds/'+feed.id+'/icon'" alt=""></span>
|
||||
<span class="flex-fill text-left text-truncate">{{ feed.title }}</span>
|
||||
<span class="counter text-right">{{ filteredFeedStats[feed.id] || '' }}</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2 toolbar d-flex align-items-center border-top flex-shrink-0" v-if="loading.feeds">
|
||||
<span class="icon loading mx-2"></span>
|
||||
<span class="text-truncate cursor-default noselect">Refreshing ({{ loading.feeds }} left)</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- item list -->
|
||||
<div id="col-item-list" class="vh-100 position-relative d-flex flex-column border-right flex-shrink-0" :style="{width: itemListWidth+'px'}">
|
||||
<drag :width="itemListWidth" @resize="resizeItemList"></drag>
|
||||
<div class="px-2 toolbar d-flex align-items-center">
|
||||
<button class="toolbar-item mr-2 d-block d-md-none"
|
||||
@click="feedSelected = null"
|
||||
v-b-tooltip.hover.bottom="'Show Feeds'">
|
||||
<span class="icon">{% inline "chevron-left.svg" %}</span>
|
||||
</button>
|
||||
<div class="input-icon flex-grow-1">
|
||||
<span class="icon">{% inline "search.svg" %}</span>
|
||||
<input class="d-block toolbar-search" type="" v-model="itemSearch">
|
||||
</div>
|
||||
<button class="toolbar-item ml-2"
|
||||
@click="markItemsRead()"
|
||||
v-if="filterSelected == 'unread'"
|
||||
v-b-tooltip.hover.bottom="'Mark All Read'">
|
||||
<span class="icon">{% inline "check.svg" %}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-2 overflow-auto border-top flex-grow-1" v-scroll="loadMoreItems" ref="itemlist">
|
||||
<label v-for="item in items" :key="item.id"
|
||||
class="selectgroup">
|
||||
<input type="radio" name="item" :value="item.id" v-model="itemSelected">
|
||||
<div class="selectgroup-label d-flex flex-column">
|
||||
<div style="line-height: 1; opacity: .7; margin-bottom: .1rem;" class="d-flex align-items-center">
|
||||
<transition name="indicator">
|
||||
<span class="icon icon-small mr-1" v-if="item.status=='unread'">{% inline "circle-full.svg" %}</span>
|
||||
<span class="icon icon-small mr-1" v-if="item.status=='starred'">{% inline "star-full.svg" %}</span>
|
||||
</transition>
|
||||
<small class="flex-fill text-truncate mr-1">
|
||||
{{ feedsById[item.feed_id].title }}
|
||||
</small>
|
||||
<small class="flex-shrink-0"><relative-time :val="item.date"/></small>
|
||||
</div>
|
||||
<div>{{ item.title || 'untitled' }}</div>
|
||||
</div>
|
||||
</label>
|
||||
<button class="btn btn-link btn-block loading my-3" v-if="itemsPage.cur < itemsPage.num"></button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- item show -->
|
||||
<div id="col-item" class="vh-100 d-flex flex-column w-100" style="min-width: 0;">
|
||||
<div class="toolbar px-2 d-flex align-items-center" v-if="itemSelected">
|
||||
<button class="toolbar-item"
|
||||
@click="toggleItemStarred(itemSelectedDetails)"
|
||||
v-b-tooltip.hover.bottom="'Mark Starred'">
|
||||
<span class="icon" v-if="itemSelectedDetails.status=='starred'" >{% inline "star-full.svg" %}</span>
|
||||
<span class="icon" v-else-if="itemSelectedDetails.status!='starred'" >{% inline "star.svg" %}</span>
|
||||
</button>
|
||||
<button class="toolbar-item"
|
||||
:disabled="itemSelectedDetails.status=='starred'"
|
||||
v-b-tooltip.hover.bottom="'Mark Unread'"
|
||||
@click="toggleItemRead(itemSelectedDetails)">
|
||||
<span class="icon" v-if="itemSelectedDetails.status=='unread'">{% inline "circle-full.svg" %}</span>
|
||||
<span class="icon" v-if="itemSelectedDetails.status!='unread'">{% inline "circle.svg" %}</span>
|
||||
</button>
|
||||
<a class="toolbar-item" id="content-appearance" v-b-tooltip.hover.bottom="'Appearance'" tabindex="0">
|
||||
<span class="icon">{% inline "sliders.svg" %}</span>
|
||||
</a>
|
||||
<button class="toolbar-item"
|
||||
:class="{active: itemSelectedReadability}"
|
||||
@click="getReadable(itemSelectedDetails)"
|
||||
v-b-tooltip.hover.bottom="'Read Here'">
|
||||
<span class="icon" :class="{'icon-loading': loading.readability}">{% inline "book-open.svg" %}</span>
|
||||
</button>
|
||||
<a class="toolbar-item" :href="itemSelectedDetails.link" target="_blank" v-b-tooltip.hover.bottom="'Open Link'">
|
||||
<span class="icon">{% inline "external-link.svg" %}</span>
|
||||
</a>
|
||||
<b-popover target="content-appearance" triggers="focus" placement="bottom">
|
||||
<div class="p-1" style="width: 200px;">
|
||||
<div class="d-flex">
|
||||
<label class="themepicker">
|
||||
<input type="radio" name="settingsTheme" value="light" v-model="theme.name">
|
||||
<div class="themepicker-label appearance-option"></div>
|
||||
</label>
|
||||
<label class="themepicker">
|
||||
<input type="radio" name="settingsTheme" value="sepia" v-model="theme.name">
|
||||
<div class="themepicker-label appearance-option"></div>
|
||||
</label>
|
||||
<label class="themepicker">
|
||||
<input type="radio" name="settingsTheme" value="night" v-model="theme.name">
|
||||
<div class="themepicker-label appearance-option"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label class="selectgroup">
|
||||
<input type="radio" name="font" value="" v-model="theme.font">
|
||||
<div class="selectgroup-label appearance-option">
|
||||
System Default
|
||||
</div>
|
||||
</label>
|
||||
<label class="selectgroup" v-for="f in fonts" :key="f">
|
||||
<input type="radio" name="font" :value="f" v-model="theme.font">
|
||||
<div class="selectgroup-label appearance-option":style="{'font-family': f}">
|
||||
{{ f }}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="btn-group d-flex mt-2">
|
||||
<button class="btn btn-outline appearance-option"
|
||||
style="font-size: 0.8rem" @click="incrFont(-1)">A</button>
|
||||
<button class="btn btn-outline appearance-option"
|
||||
style="font-size: 1.2rem" @click="incrFont(1)">A</button>
|
||||
</div>
|
||||
</div>
|
||||
</b-popover>
|
||||
<div class="flex-grow-1"></div>
|
||||
<button class="toolbar-item" @click="itemSelected=null" v-b-tooltip.hover.bottom="'Close Article'">
|
||||
<span class="icon">{% inline "x.svg" %}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="itemSelected"
|
||||
ref="content"
|
||||
class="content px-4 pt-3 pb-5 border-top overflow-auto"
|
||||
:style="{'font-family': theme.font, 'font-size': theme.size + 'rem'}">
|
||||
<h1><b>{{ itemSelectedDetails.title }}</b></h1>
|
||||
<div class="text-muted">
|
||||
<div>{{ feedsById[itemSelectedDetails.feed_id].title }}</div>
|
||||
<time>{{ formatDate(itemSelectedDetails.date) }}</time>
|
||||
</div>
|
||||
<hr>
|
||||
<div v-html="itemSelectedContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
<b-modal id="settings-modal" hide-header hide-footer lazy>
|
||||
<button class="btn btn-link outline-none float-right p-2 mr-n2 mt-n2" style="line-height: 1" @click="$bvModal.hide('settings-modal')">
|
||||
<span class="icon">{% inline "x.svg" %}</span>
|
||||
</button>
|
||||
<div v-if="settings=='create'">
|
||||
<p class="cursor-default"><b>New Feed</b></p>
|
||||
<form action="" @submit.prevent="createFeed(event)" class="mt-4">
|
||||
<label for="feed-url">URL</label>
|
||||
<input id="feed-url" name="url" type="url" class="form-control" required autocomplete="off" :readonly="feedNewChoice.length > 0">
|
||||
<label for="feed-folder" class="mt-3 d-block">
|
||||
Folder
|
||||
<a href="#" class="float-right text-decoration-none" @click.prevent="createNewFeedFolder()">new folder</a>
|
||||
</label>
|
||||
<select class="form-control" id="feed-folder" name="folder_id" ref="newFeedFolder">
|
||||
<option value="">---</option>
|
||||
<option :value="folder.id" v-for="folder in folders">{{ folder.title }}</option>
|
||||
</select>
|
||||
<div class="mt-4" v-if="feedNewChoice.length">
|
||||
<p class="mb-2">
|
||||
Multiple feeds found. Choose one below:
|
||||
<a href="#" class="float-right text-decoration-none" @click.prevent="resetFeedChoice()">cancel</a>
|
||||
</p>
|
||||
<label class="selectgroup" v-for="choice in feedNewChoice">
|
||||
<input type="radio" name="feedToAdd" :value="choice.url" v-model="feedNewChoiceSelected">
|
||||
<div class="selectgroup-label">
|
||||
<div class="text-truncate">{{ choice.title }}</div>
|
||||
<div class="text-truncate" :class="{light: choice.title}">{{ choice.url }}</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<button class="btn btn-block btn-default mt-3" :class="{loading: loading.newfeed}" type="submit">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
<div v-else-if="settings=='manage'">
|
||||
<p class="cursor-default"><b>Manage Feeds</b></p>
|
||||
<div v-for="folder in foldersWithFeeds" class="mt-4" :key="folder.id">
|
||||
<div class="list-row d-flex align-items-center">
|
||||
<div class="w-100 text-truncate" v-if="folder.id">
|
||||
<span class="icon mr-2">{% inline "folder.svg" %}</span>
|
||||
{{ folder.title }}
|
||||
</div>
|
||||
<div class="flex-shrink-0" v-if="folder.id">
|
||||
<b-dropdown right no-caret lazy variant="link" class="settings-dropdown" toggle-class="text-decoration-none">
|
||||
<template v-slot:button-content>
|
||||
<span class="icon">{% inline "more-vertical.svg" %}</span>
|
||||
</template>
|
||||
<b-dropdown-header>{{ folder.title }}</b-dropdown-header>
|
||||
<b-dropdown-item @click.prevent="renameFolder(folder)">Rename</b-dropdown-item>
|
||||
<b-dropdown-divider></b-dropdown-divider>
|
||||
<b-dropdown-item class="dropdown-danger"
|
||||
@click.prevent="deleteFolder(folder)">
|
||||
Delete
|
||||
</b-dropdown-item>
|
||||
</b-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="feed in folder.feeds" class="list-row d-flex align-items-center" :key="feed.id">
|
||||
<div class="w-100 text-truncate">
|
||||
<span class="icon mr-2" v-if="!feed.has_icon">{% inline "rss.svg" %}</span>
|
||||
<span class="icon mr-2" v-else><img v-lazy="'/api/feeds/'+feed.id+'/icon'" alt=""></span>
|
||||
{{ feed.title }}
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<b-dropdown right no-caret lazy variant="link" class="settings-dropdown" toggle-class="text-decoration-none">
|
||||
<template v-slot:button-content>
|
||||
<span class="icon">{% inline "more-vertical.svg" %}</span>
|
||||
</template>
|
||||
<b-dropdown-header>{{ feed.title }}</b-dropdown-header>
|
||||
<b-dropdown-item :href="feed.link" target="_blank" v-if="feed.link">Visit Website</b-dropdown-item>
|
||||
<b-dropdown-divider v-if="feed.link"></b-dropdown-divider>
|
||||
<b-dropdown-item @click.prevent="renameFeed(feed)">Rename</b-dropdown-item>
|
||||
<b-dropdown-divider v-if="folders.length"></b-dropdown-divider>
|
||||
<b-dropdown-header v-if="folders.length">Move to...</b-dropdown-header>
|
||||
<b-dropdown-item @click="moveFeed(feed, null)" v-if="feed.folder_id">
|
||||
---
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item-button
|
||||
v-if="folder.id != feed.folder_id"
|
||||
v-for="folder in folders"
|
||||
@click="moveFeed(feed, folder)">
|
||||
<span class="icon mr-1">{% inline "folder.svg" %}</span>
|
||||
{{ folder.title }}
|
||||
</b-dropdown-item-button>
|
||||
<b-dropdown-item-button @click="moveFeedToNewFolder(feed)">
|
||||
<span class="text-muted icon mr-1">{% inline "plus.svg" %}</span>
|
||||
<span class="text-muted">New Folder</span>
|
||||
</b-dropdown-item-button>
|
||||
<b-dropdown-divider></b-dropdown-divider>
|
||||
<b-dropdown-item class="dropdown-danger"
|
||||
@click.prevent="deleteFeed(feed)">
|
||||
Delete
|
||||
</b-dropdown-item>
|
||||
</b-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- polyfill -->
|
||||
<script src="./static/javascripts/fetch.umd.js"></script>
|
||||
<script src="./static/javascripts/url-polyfill.min.js"></script>
|
||||
<!-- external -->
|
||||
<script src="./static/javascripts/vue.min.js"></script>
|
||||
<script src="./static/javascripts/vue-lazyload.js"></script>
|
||||
<script src="./static/javascripts/popper.min.js"></script>
|
||||
<script src="./static/javascripts/bootstrap-vue.min.js"></script>
|
||||
<script src="./static/javascripts/Readability.js"></script>
|
||||
<script src="./static/javascripts/purify.min.js"></script>
|
||||
<!-- internal -->
|
||||
<script src="./static/javascripts/api.js"></script>
|
||||
<script src="./static/javascripts/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,95 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
(function() {
|
||||
var api = function(method, endpoint, data) {
|
||||
return fetch(endpoint, {
|
||||
method: method,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
var json = function(res) {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
var param = function(query) {
|
||||
if (!query) return ''
|
||||
return '?' + Object.keys(query).map(function(key) {
|
||||
return encodeURIComponent(key) + '=' + encodeURIComponent(query[key])
|
||||
}).join('&')
|
||||
}
|
||||
|
||||
window.api = {
|
||||
feeds: {
|
||||
list: function() {
|
||||
return api('get', '/api/feeds').then(json)
|
||||
},
|
||||
create: function(data) {
|
||||
return api('post', '/api/feeds', data).then(json)
|
||||
},
|
||||
update: function(id, data) {
|
||||
return api('put', '/api/feeds/' + id, data)
|
||||
},
|
||||
delete: function(id) {
|
||||
return api('delete', '/api/feeds/' + id)
|
||||
},
|
||||
list_items: function(id) {
|
||||
return api('get', '/api/feeds/' + id + '/items').then(json)
|
||||
},
|
||||
refresh: function() {
|
||||
return api('post', '/api/feeds/refresh')
|
||||
},
|
||||
},
|
||||
folders: {
|
||||
list: function() {
|
||||
return api('get', '/api/folders').then(json)
|
||||
},
|
||||
create: function(data) {
|
||||
return api('post', '/api/folders', data).then(json)
|
||||
},
|
||||
update: function(id, data) {
|
||||
return api('put', '/api/folders/' + id, data)
|
||||
},
|
||||
delete: function(id) {
|
||||
return api('delete', '/api/folders/' + id)
|
||||
},
|
||||
list_items: function(id) {
|
||||
return api('get', '/api/folders/' + id + '/items').then(json)
|
||||
}
|
||||
},
|
||||
items: {
|
||||
list: function(query) {
|
||||
return api('get', '/api/items' + param(query)).then(json)
|
||||
},
|
||||
update: function(id, data) {
|
||||
return api('put', '/api/items/' + id, data)
|
||||
},
|
||||
mark_read: function(query) {
|
||||
return api('put', '/api/items' + param(query))
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
get: function() {
|
||||
return api('get', '/api/settings').then(json)
|
||||
},
|
||||
update: function(data) {
|
||||
return api('put', '/api/settings', data)
|
||||
},
|
||||
},
|
||||
status: function() {
|
||||
return api('get', '/api/status').then(json)
|
||||
},
|
||||
upload_opml: function(form) {
|
||||
return fetch('/opml/import', {
|
||||
method: 'post',
|
||||
body: new FormData(form),
|
||||
})
|
||||
},
|
||||
crawl: function(url) {
|
||||
return fetch('/page?url=' + url).then(function(res) {
|
||||
return res.text()
|
||||
})
|
||||
}
|
||||
}
|
||||
})()
|
||||
@@ -1,579 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
var TITLE = document.title
|
||||
|
||||
var FONTS = [
|
||||
"Arial",
|
||||
"Courier New",
|
||||
"Georgia",
|
||||
"Times New Roman",
|
||||
"Verdana",
|
||||
]
|
||||
|
||||
var debounce = function(callback, wait) {
|
||||
var timeout
|
||||
return function() {
|
||||
var ctx = this, args = arguments
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(function() {
|
||||
callback.apply(ctx, args)
|
||||
}, wait)
|
||||
}
|
||||
}
|
||||
|
||||
var sanitize = function(content, base) {
|
||||
// WILD: `item.link` may be a relative link (or some nonsense)
|
||||
try { new URL(base) } catch(err) { base = null }
|
||||
|
||||
var sanitizer = new DOMPurify
|
||||
sanitizer.addHook('afterSanitizeAttributes', function(node) {
|
||||
// set all elements owning target to target=_blank
|
||||
if ('target' in node)
|
||||
node.setAttribute('target', '_blank')
|
||||
// set non-HTML/MathML links to xlink:show=new
|
||||
if (!node.hasAttribute('target') && (node.hasAttribute('xlink:href') || node.hasAttribute('href')))
|
||||
node.setAttribute('xlink:show', 'new')
|
||||
|
||||
// set absolute urls
|
||||
if (base && node.attributes.href && node.attributes.href.value)
|
||||
node.href = new URL(node.attributes.href.value, base).toString()
|
||||
if (base && node.attributes.src && node.attributes.src.value)
|
||||
node.src = new URL(node.attributes.src.value, base).toString()
|
||||
})
|
||||
return sanitizer.sanitize(content, {FORBID_TAGS: ['style'], FORBID_ATTR: ['style', 'class']})
|
||||
}
|
||||
|
||||
Vue.use(VueLazyload)
|
||||
|
||||
Vue.directive('scroll', {
|
||||
inserted: function(el, binding) {
|
||||
el.addEventListener('scroll', debounce(function(event) {
|
||||
binding.value(event, el)
|
||||
}, 200))
|
||||
},
|
||||
})
|
||||
|
||||
Vue.component('drag', {
|
||||
props: ['width'],
|
||||
template: '<div class="drag"></div>',
|
||||
mounted: function() {
|
||||
var self = this
|
||||
var startX = undefined
|
||||
var initW = undefined
|
||||
var onMouseMove = function(e) {
|
||||
var offset = e.clientX - startX
|
||||
var newWidth = initW + offset
|
||||
self.$emit('resize', newWidth)
|
||||
}
|
||||
var onMouseUp = function(e) {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
this.$el.addEventListener('mousedown', function(e) {
|
||||
startX = e.clientX
|
||||
initW = self.width
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function dateRepr(d) {
|
||||
var sec = (new Date().getTime() - d.getTime()) / 1000
|
||||
if (sec < 2700) // less than 45 minutes
|
||||
return Math.round(sec / 60) + 'm'
|
||||
else if (sec < 86400) // less than 24 hours
|
||||
return Math.round(sec / 3600) + 'h'
|
||||
else if (sec < 604800) // less than a week
|
||||
return Math.round(sec / 86400) + 'd'
|
||||
else
|
||||
return d.toLocaleDateString(undefined, {year: "numeric", month: "long", day: "numeric"})
|
||||
}
|
||||
|
||||
Vue.component('relative-time', {
|
||||
props: ['val'],
|
||||
data: function() {
|
||||
var d = new Date(this.val)
|
||||
return {
|
||||
'date': d,
|
||||
'formatted': dateRepr(d),
|
||||
'interval': null,
|
||||
}
|
||||
},
|
||||
template: '<time :datetime="val">{{ formatted }}</time>',
|
||||
mounted: function() {
|
||||
this.interval = setInterval(function() {
|
||||
this.formatted = dateRepr(this.date)
|
||||
}.bind(this), 600000) // every 10 minutes
|
||||
},
|
||||
destroyed: function() {
|
||||
clearInterval(this.interval)
|
||||
},
|
||||
})
|
||||
|
||||
var vm = new Vue({
|
||||
created: function() {
|
||||
this.refreshFeeds()
|
||||
this.refreshStats()
|
||||
},
|
||||
mounted: function() {
|
||||
this.$root.$on('bv::modal::hidden', function(bvEvent, modalId) {
|
||||
if (vm.settings == 'create') {
|
||||
vm.feedNewChoice = []
|
||||
vm.feedNewChoiceSelected = ''
|
||||
}
|
||||
})
|
||||
},
|
||||
data: function() {
|
||||
return {
|
||||
'filterSelected': undefined,
|
||||
'folders': [],
|
||||
'feeds': [],
|
||||
'feedSelected': undefined,
|
||||
'feedListWidth': undefined,
|
||||
'feedNewChoice': [],
|
||||
'feedNewChoiceSelected': '',
|
||||
'items': [],
|
||||
'itemsPage': {
|
||||
'cur': 1,
|
||||
'num': 1,
|
||||
},
|
||||
'itemSelected': null,
|
||||
'itemSelectedDetails': {},
|
||||
'itemSelectedReadability': '',
|
||||
'itemSearch': '',
|
||||
'itemSortNewestFirst': undefined,
|
||||
'itemListWidth': undefined,
|
||||
|
||||
'filteredFeedStats': {},
|
||||
'filteredFolderStats': {},
|
||||
'filteredTotalStats': null,
|
||||
|
||||
'settings': 'create',
|
||||
'loading': {
|
||||
'feeds': 0,
|
||||
'newfeed': false,
|
||||
'items': false,
|
||||
'readability': false,
|
||||
},
|
||||
'fonts': FONTS,
|
||||
'feedStats': {},
|
||||
'theme': {
|
||||
'name': 'light',
|
||||
'font': '',
|
||||
'size': 1,
|
||||
},
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
foldersWithFeeds: function() {
|
||||
var feedsByFolders = this.feeds.reduce(function(folders, feed) {
|
||||
if (!folders[feed.folder_id])
|
||||
folders[feed.folder_id] = [feed]
|
||||
else
|
||||
folders[feed.folder_id].push(feed)
|
||||
return folders
|
||||
}, {})
|
||||
var folders = this.folders.slice().map(function(folder) {
|
||||
folder.feeds = feedsByFolders[folder.id]
|
||||
return folder
|
||||
})
|
||||
folders.push({id: null, feeds: feedsByFolders[null]})
|
||||
return folders
|
||||
},
|
||||
feedsById: function() {
|
||||
return this.feeds.reduce(function(acc, feed) { acc[feed.id] = feed; return acc }, {})
|
||||
},
|
||||
itemsById: function() {
|
||||
return this.items.reduce(function(acc, item) { acc[item.id] = item; return acc }, {})
|
||||
},
|
||||
itemSelectedContent: function() {
|
||||
if (!this.itemSelected) return ''
|
||||
|
||||
if (this.itemSelectedReadability)
|
||||
return this.itemSelectedReadability
|
||||
|
||||
var content = ''
|
||||
if (this.itemSelectedDetails.content)
|
||||
content = this.itemSelectedDetails.content
|
||||
else if (this.itemSelectedDetails.description)
|
||||
content = this.itemSelectedDetails.description
|
||||
|
||||
return sanitize(content, this.itemSelectedDetails.link)
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'theme': {
|
||||
deep: true,
|
||||
handler: function(theme) {
|
||||
document.body.classList.value = 'theme-' + theme.name
|
||||
api.settings.update({
|
||||
theme_name: theme.name,
|
||||
theme_font: theme.font,
|
||||
theme_size: theme.size,
|
||||
})
|
||||
},
|
||||
},
|
||||
'feedStats': {
|
||||
deep: true,
|
||||
handler: debounce(function() {
|
||||
var title = TITLE
|
||||
var unreadCount = Object.values(this.feedStats).reduce(function(acc, stat) {
|
||||
return acc + stat.unread
|
||||
}, 0)
|
||||
if (unreadCount) {
|
||||
title += ' ('+unreadCount+')'
|
||||
}
|
||||
document.title = title
|
||||
this.computeStats()
|
||||
}, 500),
|
||||
},
|
||||
'filterSelected': function(newVal, oldVal) {
|
||||
if (oldVal === undefined) return // do nothing, initial setup
|
||||
api.settings.update({filter: newVal}).then(this.refreshItems.bind(this))
|
||||
this.itemSelected = null
|
||||
this.computeStats()
|
||||
},
|
||||
'feedSelected': function(newVal, oldVal) {
|
||||
if (oldVal === undefined) return // do nothing, initial setup
|
||||
api.settings.update({feed: newVal}).then(this.refreshItems.bind(this))
|
||||
this.itemSelected = null
|
||||
if (this.$refs.itemlist) this.$refs.itemlist.scrollTop = 0
|
||||
},
|
||||
'itemSelected': function(newVal, oldVal) {
|
||||
this.itemSelectedReadability = ''
|
||||
if (newVal === null) {
|
||||
this.itemSelectedDetails = null
|
||||
return
|
||||
}
|
||||
if (this.$refs.content) this.$refs.content.scrollTop = 0
|
||||
|
||||
this.itemSelectedDetails = this.itemsById[newVal]
|
||||
if (this.itemSelectedDetails.status == 'unread') {
|
||||
this.itemSelectedDetails.status = 'read'
|
||||
this.feedStats[this.itemSelectedDetails.feed_id].unread -= 1
|
||||
api.items.update(this.itemSelectedDetails.id, {status: this.itemSelectedDetails.status})
|
||||
}
|
||||
},
|
||||
'itemSearch': debounce(function(newVal) {
|
||||
this.refreshItems()
|
||||
}, 500),
|
||||
'itemSortNewestFirst': function(newVal, oldVal) {
|
||||
if (oldVal === undefined) return // do nothing, initial setup
|
||||
api.settings.update({sort_newest_first: newVal}).then(this.refreshItems.bind(this))
|
||||
},
|
||||
'feedListWidth': debounce(function(newVal, oldVal) {
|
||||
if (oldVal === undefined) return // do nothing, initial setup
|
||||
api.settings.update({feed_list_width: newVal})
|
||||
}, 1000),
|
||||
'itemListWidth': debounce(function(newVal, oldVal) {
|
||||
if (oldVal === undefined) return // do nothing, initial setup
|
||||
api.settings.update({item_list_width: newVal})
|
||||
}, 1000),
|
||||
},
|
||||
methods: {
|
||||
refreshStats: function(loopMode) {
|
||||
api.status().then(function(data) {
|
||||
if (loopMode && !vm.itemSelected) vm.refreshItems()
|
||||
|
||||
vm.loading.feeds = data.running
|
||||
if (data.running) {
|
||||
setTimeout(vm.refreshStats.bind(vm, true), 500)
|
||||
}
|
||||
vm.feedStats = data.stats.reduce(function(acc, stat) {
|
||||
acc[stat.feed_id] = stat
|
||||
return acc
|
||||
}, {})
|
||||
})
|
||||
},
|
||||
getItemsQuery: function() {
|
||||
var query = {}
|
||||
if (this.feedSelected) {
|
||||
var parts = this.feedSelected.split(':', 2)
|
||||
var type = parts[0]
|
||||
var guid = parts[1]
|
||||
if (type == 'feed') {
|
||||
query.feed_id = guid
|
||||
} else if (type == 'folder') {
|
||||
query.folder_id = guid
|
||||
}
|
||||
}
|
||||
if (this.filterSelected) {
|
||||
query.status = this.filterSelected
|
||||
}
|
||||
if (this.itemSearch) {
|
||||
query.search = this.itemSearch
|
||||
}
|
||||
if (!this.itemSortNewestFirst) {
|
||||
query.oldest_first = true
|
||||
}
|
||||
return query
|
||||
},
|
||||
refreshFeeds: function() {
|
||||
return Promise
|
||||
.all([api.folders.list(), api.feeds.list()])
|
||||
.then(function(values) {
|
||||
vm.folders = values[0]
|
||||
vm.feeds = values[1]
|
||||
})
|
||||
},
|
||||
refreshItems: function() {
|
||||
if (this.feedSelected === null) {
|
||||
vm.items = []
|
||||
vm.itemsPage = {'cur': 1, 'num': 1}
|
||||
return
|
||||
}
|
||||
var query = this.getItemsQuery()
|
||||
this.loading.items = true
|
||||
return api.items.list(query).then(function(data) {
|
||||
vm.items = data.list
|
||||
vm.itemsPage = data.page
|
||||
vm.loading.items = false
|
||||
})
|
||||
},
|
||||
loadMoreItems: function(event, el) {
|
||||
if (this.itemsPage.cur >= this.itemsPage.num) return
|
||||
if (this.loading.items) return
|
||||
var closeToBottom = (el.scrollHeight - el.scrollTop - el.offsetHeight) < 50
|
||||
if (closeToBottom) {
|
||||
this.loading.moreitems = true
|
||||
var query = this.getItemsQuery()
|
||||
query.page = this.itemsPage.cur + 1
|
||||
api.items.list(query).then(function(data) {
|
||||
vm.items = vm.items.concat(data.list)
|
||||
vm.itemsPage = data.page
|
||||
vm.loading.items = false
|
||||
})
|
||||
}
|
||||
},
|
||||
markItemsRead: function() {
|
||||
var query = this.getItemsQuery()
|
||||
api.items.mark_read(query).then(function() {
|
||||
vm.items = []
|
||||
vm.itemsPage = {'cur': 1, 'num': 1}
|
||||
vm.itemSelected = null
|
||||
vm.refreshStats()
|
||||
})
|
||||
},
|
||||
toggleFolderExpanded: function(folder) {
|
||||
folder.is_expanded = !folder.is_expanded
|
||||
api.folders.update(folder.id, {is_expanded: folder.is_expanded})
|
||||
},
|
||||
formatDate: function(datestr) {
|
||||
var options = {
|
||||
year: "numeric", month: "long", day: "numeric",
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
}
|
||||
return new Date(datestr).toLocaleDateString(undefined, options)
|
||||
},
|
||||
moveFeed: function(feed, folder) {
|
||||
var folder_id = folder ? folder.id : null
|
||||
api.feeds.update(feed.id, {folder_id: folder_id}).then(function() {
|
||||
feed.folder_id = folder_id
|
||||
})
|
||||
},
|
||||
moveFeedToNewFolder: function(feed) {
|
||||
var title = prompt('Enter folder name:')
|
||||
if (!title) return
|
||||
api.folders.create({'title': title}).then(function(folder) {
|
||||
api.feeds.update(feed.id, {folder_id: folder.id}).then(function() {
|
||||
vm.refreshFeeds()
|
||||
})
|
||||
})
|
||||
},
|
||||
createNewFeedFolder: function() {
|
||||
var title = prompt('Enter folder name:')
|
||||
if (!title) return
|
||||
api.folders.create({'title': title}).then(function(result) {
|
||||
vm.refreshFeeds().then(function() {
|
||||
vm.$nextTick(function() {
|
||||
if (vm.$refs.newFeedFolder) {
|
||||
vm.$refs.newFeedFolder.value = result.id
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
renameFolder: function(folder) {
|
||||
var newTitle = prompt('Enter new title', folder.title)
|
||||
if (newTitle) {
|
||||
api.folders.update(folder.id, {title: newTitle}).then(function() {
|
||||
folder.title = newTitle
|
||||
})
|
||||
}
|
||||
},
|
||||
deleteFolder: function(folder) {
|
||||
if (confirm('Are you sure you want to delete ' + folder.title + '?')) {
|
||||
api.folders.delete(folder.id).then(function() {
|
||||
if (vm.feedSelected === 'folder:'+folder.id) {
|
||||
vm.items = []
|
||||
vm.feedSelected = ''
|
||||
}
|
||||
vm.refreshStats()
|
||||
vm.refreshFeeds()
|
||||
})
|
||||
}
|
||||
},
|
||||
renameFeed: function(feed) {
|
||||
var newTitle = prompt('Enter new title', feed.title)
|
||||
if (newTitle) {
|
||||
api.feeds.update(feed.id, {title: newTitle}).then(function() {
|
||||
feed.title = newTitle
|
||||
})
|
||||
}
|
||||
},
|
||||
deleteFeed: function(feed) {
|
||||
if (confirm('Are you sure you want to delete ' + feed.title + '?')) {
|
||||
api.feeds.delete(feed.id).then(function() {
|
||||
// note: if item list contains delete feed's entries, refresh it first.
|
||||
for (var i = 0; i < vm.items.length; i++) {
|
||||
if (vm.items[i].feed_id == feed.id) {
|
||||
vm.refreshItems().then(function() {
|
||||
vm.refreshStats()
|
||||
vm.refreshFeeds()
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
vm.refreshStats()
|
||||
vm.refreshFeeds()
|
||||
})
|
||||
}
|
||||
},
|
||||
createFeed: function(event) {
|
||||
var form = event.target
|
||||
var data = {
|
||||
url: form.querySelector('input[name=url]').value,
|
||||
folder_id: parseInt(form.querySelector('select[name=folder_id]').value) || null,
|
||||
}
|
||||
if (this.feedNewChoiceSelected) {
|
||||
data.url = this.feedNewChoiceSelected
|
||||
}
|
||||
this.loading.newfeed = true
|
||||
api.feeds.create(data).then(function(result) {
|
||||
if (result.status === 'success') {
|
||||
vm.refreshFeeds()
|
||||
vm.refreshStats()
|
||||
vm.$bvModal.hide('settings-modal')
|
||||
} else if (result.status === 'multiple') {
|
||||
vm.feedNewChoice = result.choice
|
||||
vm.feedNewChoiceSelected = result.choice[0].url
|
||||
} else {
|
||||
alert('No feeds found at the given url.')
|
||||
}
|
||||
vm.loading.newfeed = false
|
||||
})
|
||||
},
|
||||
toggleItemStarred: function(item) {
|
||||
if (item.status == 'starred') {
|
||||
item.status = 'read'
|
||||
this.feedStats[item.feed_id].starred -= 1
|
||||
} else if (item.status != 'starred') {
|
||||
item.status = 'starred'
|
||||
this.feedStats[item.feed_id].starred += 1
|
||||
}
|
||||
api.items.update(item.id, {status: item.status})
|
||||
},
|
||||
toggleItemRead: function(item) {
|
||||
if (item.status == 'unread') {
|
||||
item.status = 'read'
|
||||
this.feedStats[item.feed_id].unread -= 1
|
||||
} else if (item.status == 'read') {
|
||||
item.status = 'unread'
|
||||
this.feedStats[item.feed_id].unread += 1
|
||||
}
|
||||
api.items.update(item.id, {status: item.status})
|
||||
},
|
||||
importOPML: function(event) {
|
||||
var input = event.target
|
||||
var form = document.querySelector('#opml-import-form')
|
||||
this.$refs.menuDropdown.hide()
|
||||
api.upload_opml(form).then(function() {
|
||||
input.value = ''
|
||||
vm.refreshFeeds()
|
||||
vm.refreshStats()
|
||||
})
|
||||
},
|
||||
getReadable: function(item) {
|
||||
if (this.itemSelectedReadability) {
|
||||
this.itemSelectedReadability = null
|
||||
return
|
||||
}
|
||||
if (item.link) {
|
||||
this.loading.readability = true
|
||||
api.crawl(item.link).then(function(body) {
|
||||
vm.loading.readability = false
|
||||
if (!body.length) return
|
||||
var bodyClean = sanitize(body, item.link)
|
||||
var doc = new DOMParser().parseFromString(bodyClean, 'text/html')
|
||||
var parsed = new Readability(doc).parse()
|
||||
if (parsed && parsed.content) {
|
||||
vm.itemSelectedReadability = parsed.content
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
showSettings: function(settings) {
|
||||
this.settings = settings
|
||||
this.$bvModal.show('settings-modal')
|
||||
},
|
||||
resizeFeedList: function(width) {
|
||||
this.feedListWidth = Math.min(Math.max(200, width), 700)
|
||||
},
|
||||
resizeItemList: function(width) {
|
||||
this.itemListWidth = Math.min(Math.max(200, width), 700)
|
||||
},
|
||||
resetFeedChoice: function() {
|
||||
this.feedNewChoice = []
|
||||
this.feedNewChoiceSelected = ''
|
||||
},
|
||||
incrFont: function(x) {
|
||||
this.theme.size = +(this.theme.size + (0.1 * x)).toFixed(1)
|
||||
},
|
||||
fetchAllFeeds: function() {
|
||||
api.feeds.refresh().then(this.refreshStats.bind(this))
|
||||
},
|
||||
computeStats: function() {
|
||||
var filter = this.filterSelected
|
||||
if (!filter) {
|
||||
this.filteredFeedStats = {}
|
||||
this.filteredFolderStats = {}
|
||||
this.filteredTotalStats = null
|
||||
return
|
||||
}
|
||||
|
||||
var statsFeeds = {}, statsFolders = {}, statsTotal = 0
|
||||
|
||||
for (var i = 0; i < this.feeds.length; i++) {
|
||||
var feed = this.feeds[i]
|
||||
if (!this.feedStats[feed.id]) continue
|
||||
|
||||
var n = vm.feedStats[feed.id][filter] || 0
|
||||
|
||||
if (!statsFolders[feed.folder_id]) statsFolders[feed.folder_id] = 0
|
||||
|
||||
statsFeeds[feed.id] = n
|
||||
statsFolders[feed.folder_id] += n
|
||||
statsTotal += n
|
||||
}
|
||||
|
||||
this.filteredFeedStats = statsFeeds
|
||||
this.filteredFolderStats = statsFolders
|
||||
this.filteredTotalStats = statsTotal
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
api.settings.get().then(function(data) {
|
||||
vm.feedSelected = data.feed
|
||||
vm.filterSelected = data.filter
|
||||
vm.itemSortNewestFirst = data.sort_newest_first
|
||||
vm.feedListWidth = data.feed_list_width || 300
|
||||
vm.itemListWidth = data.item_list_width || 300
|
||||
vm.theme.name = data.theme_name
|
||||
vm.theme.font = data.theme_font
|
||||
vm.theme.size = data.theme_size
|
||||
vm.refreshItems()
|
||||
vm.$mount('#app')
|
||||
})
|
||||
11
assets/javascripts/bootstrap-vue.min.js
vendored
@@ -1,609 +0,0 @@
|
||||
(function (global, factory) {
|
||||
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
|
||||
typeof define === 'function' && define.amd ? define(['exports'], factory) :
|
||||
(factory((global.WHATWGFetch = {})));
|
||||
}(this, (function (exports) { 'use strict';
|
||||
|
||||
var global = (typeof self !== 'undefined' && self) || (typeof global !== 'undefined' && global);
|
||||
|
||||
var support = {
|
||||
searchParams: 'URLSearchParams' in global,
|
||||
iterable: 'Symbol' in global && 'iterator' in Symbol,
|
||||
blob:
|
||||
'FileReader' in global &&
|
||||
'Blob' in global &&
|
||||
(function() {
|
||||
try {
|
||||
new Blob();
|
||||
return true
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
})(),
|
||||
formData: 'FormData' in global,
|
||||
arrayBuffer: 'ArrayBuffer' in global
|
||||
};
|
||||
|
||||
function isDataView(obj) {
|
||||
return obj && DataView.prototype.isPrototypeOf(obj)
|
||||
}
|
||||
|
||||
if (support.arrayBuffer) {
|
||||
var viewClasses = [
|
||||
'[object Int8Array]',
|
||||
'[object Uint8Array]',
|
||||
'[object Uint8ClampedArray]',
|
||||
'[object Int16Array]',
|
||||
'[object Uint16Array]',
|
||||
'[object Int32Array]',
|
||||
'[object Uint32Array]',
|
||||
'[object Float32Array]',
|
||||
'[object Float64Array]'
|
||||
];
|
||||
|
||||
var isArrayBufferView =
|
||||
ArrayBuffer.isView ||
|
||||
function(obj) {
|
||||
return obj && viewClasses.indexOf(Object.prototype.toString.call(obj)) > -1
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeName(name) {
|
||||
if (typeof name !== 'string') {
|
||||
name = String(name);
|
||||
}
|
||||
if (/[^a-z0-9\-#$%&'*+.^_`|~!]/i.test(name) || name === '') {
|
||||
throw new TypeError('Invalid character in header field name')
|
||||
}
|
||||
return name.toLowerCase()
|
||||
}
|
||||
|
||||
function normalizeValue(value) {
|
||||
if (typeof value !== 'string') {
|
||||
value = String(value);
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// Build a destructive iterator for the value list
|
||||
function iteratorFor(items) {
|
||||
var iterator = {
|
||||
next: function() {
|
||||
var value = items.shift();
|
||||
return {done: value === undefined, value: value}
|
||||
}
|
||||
};
|
||||
|
||||
if (support.iterable) {
|
||||
iterator[Symbol.iterator] = function() {
|
||||
return iterator
|
||||
};
|
||||
}
|
||||
|
||||
return iterator
|
||||
}
|
||||
|
||||
function Headers(headers) {
|
||||
this.map = {};
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
headers.forEach(function(value, name) {
|
||||
this.append(name, value);
|
||||
}, this);
|
||||
} else if (Array.isArray(headers)) {
|
||||
headers.forEach(function(header) {
|
||||
this.append(header[0], header[1]);
|
||||
}, this);
|
||||
} else if (headers) {
|
||||
Object.getOwnPropertyNames(headers).forEach(function(name) {
|
||||
this.append(name, headers[name]);
|
||||
}, this);
|
||||
}
|
||||
}
|
||||
|
||||
Headers.prototype.append = function(name, value) {
|
||||
name = normalizeName(name);
|
||||
value = normalizeValue(value);
|
||||
var oldValue = this.map[name];
|
||||
this.map[name] = oldValue ? oldValue + ', ' + value : value;
|
||||
};
|
||||
|
||||
Headers.prototype['delete'] = function(name) {
|
||||
delete this.map[normalizeName(name)];
|
||||
};
|
||||
|
||||
Headers.prototype.get = function(name) {
|
||||
name = normalizeName(name);
|
||||
return this.has(name) ? this.map[name] : null
|
||||
};
|
||||
|
||||
Headers.prototype.has = function(name) {
|
||||
return this.map.hasOwnProperty(normalizeName(name))
|
||||
};
|
||||
|
||||
Headers.prototype.set = function(name, value) {
|
||||
this.map[normalizeName(name)] = normalizeValue(value);
|
||||
};
|
||||
|
||||
Headers.prototype.forEach = function(callback, thisArg) {
|
||||
for (var name in this.map) {
|
||||
if (this.map.hasOwnProperty(name)) {
|
||||
callback.call(thisArg, this.map[name], name, this);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Headers.prototype.keys = function() {
|
||||
var items = [];
|
||||
this.forEach(function(value, name) {
|
||||
items.push(name);
|
||||
});
|
||||
return iteratorFor(items)
|
||||
};
|
||||
|
||||
Headers.prototype.values = function() {
|
||||
var items = [];
|
||||
this.forEach(function(value) {
|
||||
items.push(value);
|
||||
});
|
||||
return iteratorFor(items)
|
||||
};
|
||||
|
||||
Headers.prototype.entries = function() {
|
||||
var items = [];
|
||||
this.forEach(function(value, name) {
|
||||
items.push([name, value]);
|
||||
});
|
||||
return iteratorFor(items)
|
||||
};
|
||||
|
||||
if (support.iterable) {
|
||||
Headers.prototype[Symbol.iterator] = Headers.prototype.entries;
|
||||
}
|
||||
|
||||
function consumed(body) {
|
||||
if (body.bodyUsed) {
|
||||
return Promise.reject(new TypeError('Already read'))
|
||||
}
|
||||
body.bodyUsed = true;
|
||||
}
|
||||
|
||||
function fileReaderReady(reader) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
reader.onload = function() {
|
||||
resolve(reader.result);
|
||||
};
|
||||
reader.onerror = function() {
|
||||
reject(reader.error);
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
function readBlobAsArrayBuffer(blob) {
|
||||
var reader = new FileReader();
|
||||
var promise = fileReaderReady(reader);
|
||||
reader.readAsArrayBuffer(blob);
|
||||
return promise
|
||||
}
|
||||
|
||||
function readBlobAsText(blob) {
|
||||
var reader = new FileReader();
|
||||
var promise = fileReaderReady(reader);
|
||||
reader.readAsText(blob);
|
||||
return promise
|
||||
}
|
||||
|
||||
function readArrayBufferAsText(buf) {
|
||||
var view = new Uint8Array(buf);
|
||||
var chars = new Array(view.length);
|
||||
|
||||
for (var i = 0; i < view.length; i++) {
|
||||
chars[i] = String.fromCharCode(view[i]);
|
||||
}
|
||||
return chars.join('')
|
||||
}
|
||||
|
||||
function bufferClone(buf) {
|
||||
if (buf.slice) {
|
||||
return buf.slice(0)
|
||||
} else {
|
||||
var view = new Uint8Array(buf.byteLength);
|
||||
view.set(new Uint8Array(buf));
|
||||
return view.buffer
|
||||
}
|
||||
}
|
||||
|
||||
function Body() {
|
||||
this.bodyUsed = false;
|
||||
|
||||
this._initBody = function(body) {
|
||||
/*
|
||||
fetch-mock wraps the Response object in an ES6 Proxy to
|
||||
provide useful test harness features such as flush. However, on
|
||||
ES5 browsers without fetch or Proxy support pollyfills must be used;
|
||||
the proxy-pollyfill is unable to proxy an attribute unless it exists
|
||||
on the object before the Proxy is created. This change ensures
|
||||
Response.bodyUsed exists on the instance, while maintaining the
|
||||
semantic of setting Request.bodyUsed in the constructor before
|
||||
_initBody is called.
|
||||
*/
|
||||
this.bodyUsed = this.bodyUsed;
|
||||
this._bodyInit = body;
|
||||
if (!body) {
|
||||
this._bodyText = '';
|
||||
} else if (typeof body === 'string') {
|
||||
this._bodyText = body;
|
||||
} else if (support.blob && Blob.prototype.isPrototypeOf(body)) {
|
||||
this._bodyBlob = body;
|
||||
} else if (support.formData && FormData.prototype.isPrototypeOf(body)) {
|
||||
this._bodyFormData = body;
|
||||
} else if (support.searchParams && URLSearchParams.prototype.isPrototypeOf(body)) {
|
||||
this._bodyText = body.toString();
|
||||
} else if (support.arrayBuffer && support.blob && isDataView(body)) {
|
||||
this._bodyArrayBuffer = bufferClone(body.buffer);
|
||||
// IE 10-11 can't handle a DataView body.
|
||||
this._bodyInit = new Blob([this._bodyArrayBuffer]);
|
||||
} else if (support.arrayBuffer && (ArrayBuffer.prototype.isPrototypeOf(body) || isArrayBufferView(body))) {
|
||||
this._bodyArrayBuffer = bufferClone(body);
|
||||
} else {
|
||||
this._bodyText = body = Object.prototype.toString.call(body);
|
||||
}
|
||||
|
||||
if (!this.headers.get('content-type')) {
|
||||
if (typeof body === 'string') {
|
||||
this.headers.set('content-type', 'text/plain;charset=UTF-8');
|
||||
} else if (this._bodyBlob && this._bodyBlob.type) {
|
||||
this.headers.set('content-type', this._bodyBlob.type);
|
||||
} else if (support.searchParams && URLSearchParams.prototype.isPrototypeOf(body)) {
|
||||
this.headers.set('content-type', 'application/x-www-form-urlencoded;charset=UTF-8');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (support.blob) {
|
||||
this.blob = function() {
|
||||
var rejected = consumed(this);
|
||||
if (rejected) {
|
||||
return rejected
|
||||
}
|
||||
|
||||
if (this._bodyBlob) {
|
||||
return Promise.resolve(this._bodyBlob)
|
||||
} else if (this._bodyArrayBuffer) {
|
||||
return Promise.resolve(new Blob([this._bodyArrayBuffer]))
|
||||
} else if (this._bodyFormData) {
|
||||
throw new Error('could not read FormData body as blob')
|
||||
} else {
|
||||
return Promise.resolve(new Blob([this._bodyText]))
|
||||
}
|
||||
};
|
||||
|
||||
this.arrayBuffer = function() {
|
||||
if (this._bodyArrayBuffer) {
|
||||
var isConsumed = consumed(this);
|
||||
if (isConsumed) {
|
||||
return isConsumed
|
||||
}
|
||||
if (ArrayBuffer.isView(this._bodyArrayBuffer)) {
|
||||
return Promise.resolve(
|
||||
this._bodyArrayBuffer.buffer.slice(
|
||||
this._bodyArrayBuffer.byteOffset,
|
||||
this._bodyArrayBuffer.byteOffset + this._bodyArrayBuffer.byteLength
|
||||
)
|
||||
)
|
||||
} else {
|
||||
return Promise.resolve(this._bodyArrayBuffer)
|
||||
}
|
||||
} else {
|
||||
return this.blob().then(readBlobAsArrayBuffer)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
this.text = function() {
|
||||
var rejected = consumed(this);
|
||||
if (rejected) {
|
||||
return rejected
|
||||
}
|
||||
|
||||
if (this._bodyBlob) {
|
||||
return readBlobAsText(this._bodyBlob)
|
||||
} else if (this._bodyArrayBuffer) {
|
||||
return Promise.resolve(readArrayBufferAsText(this._bodyArrayBuffer))
|
||||
} else if (this._bodyFormData) {
|
||||
throw new Error('could not read FormData body as text')
|
||||
} else {
|
||||
return Promise.resolve(this._bodyText)
|
||||
}
|
||||
};
|
||||
|
||||
if (support.formData) {
|
||||
this.formData = function() {
|
||||
return this.text().then(decode)
|
||||
};
|
||||
}
|
||||
|
||||
this.json = function() {
|
||||
return this.text().then(JSON.parse)
|
||||
};
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
// HTTP methods whose capitalization should be normalized
|
||||
var methods = ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'POST', 'PUT'];
|
||||
|
||||
function normalizeMethod(method) {
|
||||
var upcased = method.toUpperCase();
|
||||
return methods.indexOf(upcased) > -1 ? upcased : method
|
||||
}
|
||||
|
||||
function Request(input, options) {
|
||||
if (!(this instanceof Request)) {
|
||||
throw new TypeError('Please use the "new" operator, this DOM object constructor cannot be called as a function.')
|
||||
}
|
||||
|
||||
options = options || {};
|
||||
var body = options.body;
|
||||
|
||||
if (input instanceof Request) {
|
||||
if (input.bodyUsed) {
|
||||
throw new TypeError('Already read')
|
||||
}
|
||||
this.url = input.url;
|
||||
this.credentials = input.credentials;
|
||||
if (!options.headers) {
|
||||
this.headers = new Headers(input.headers);
|
||||
}
|
||||
this.method = input.method;
|
||||
this.mode = input.mode;
|
||||
this.signal = input.signal;
|
||||
if (!body && input._bodyInit != null) {
|
||||
body = input._bodyInit;
|
||||
input.bodyUsed = true;
|
||||
}
|
||||
} else {
|
||||
this.url = String(input);
|
||||
}
|
||||
|
||||
this.credentials = options.credentials || this.credentials || 'same-origin';
|
||||
if (options.headers || !this.headers) {
|
||||
this.headers = new Headers(options.headers);
|
||||
}
|
||||
this.method = normalizeMethod(options.method || this.method || 'GET');
|
||||
this.mode = options.mode || this.mode || null;
|
||||
this.signal = options.signal || this.signal;
|
||||
this.referrer = null;
|
||||
|
||||
if ((this.method === 'GET' || this.method === 'HEAD') && body) {
|
||||
throw new TypeError('Body not allowed for GET or HEAD requests')
|
||||
}
|
||||
this._initBody(body);
|
||||
|
||||
if (this.method === 'GET' || this.method === 'HEAD') {
|
||||
if (options.cache === 'no-store' || options.cache === 'no-cache') {
|
||||
// Search for a '_' parameter in the query string
|
||||
var reParamSearch = /([?&])_=[^&]*/;
|
||||
if (reParamSearch.test(this.url)) {
|
||||
// If it already exists then set the value with the current time
|
||||
this.url = this.url.replace(reParamSearch, '$1_=' + new Date().getTime());
|
||||
} else {
|
||||
// Otherwise add a new '_' parameter to the end with the current time
|
||||
var reQueryString = /\?/;
|
||||
this.url += (reQueryString.test(this.url) ? '&' : '?') + '_=' + new Date().getTime();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Request.prototype.clone = function() {
|
||||
return new Request(this, {body: this._bodyInit})
|
||||
};
|
||||
|
||||
function decode(body) {
|
||||
var form = new FormData();
|
||||
body
|
||||
.trim()
|
||||
.split('&')
|
||||
.forEach(function(bytes) {
|
||||
if (bytes) {
|
||||
var split = bytes.split('=');
|
||||
var name = split.shift().replace(/\+/g, ' ');
|
||||
var value = split.join('=').replace(/\+/g, ' ');
|
||||
form.append(decodeURIComponent(name), decodeURIComponent(value));
|
||||
}
|
||||
});
|
||||
return form
|
||||
}
|
||||
|
||||
function parseHeaders(rawHeaders) {
|
||||
var headers = new Headers();
|
||||
// Replace instances of \r\n and \n followed by at least one space or horizontal tab with a space
|
||||
// https://tools.ietf.org/html/rfc7230#section-3.2
|
||||
var preProcessedHeaders = rawHeaders.replace(/\r?\n[\t ]+/g, ' ');
|
||||
preProcessedHeaders.split(/\r?\n/).forEach(function(line) {
|
||||
var parts = line.split(':');
|
||||
var key = parts.shift().trim();
|
||||
if (key) {
|
||||
var value = parts.join(':').trim();
|
||||
headers.append(key, value);
|
||||
}
|
||||
});
|
||||
return headers
|
||||
}
|
||||
|
||||
Body.call(Request.prototype);
|
||||
|
||||
function Response(bodyInit, options) {
|
||||
if (!(this instanceof Response)) {
|
||||
throw new TypeError('Please use the "new" operator, this DOM object constructor cannot be called as a function.')
|
||||
}
|
||||
if (!options) {
|
||||
options = {};
|
||||
}
|
||||
|
||||
this.type = 'default';
|
||||
this.status = options.status === undefined ? 200 : options.status;
|
||||
this.ok = this.status >= 200 && this.status < 300;
|
||||
this.statusText = 'statusText' in options ? options.statusText : '';
|
||||
this.headers = new Headers(options.headers);
|
||||
this.url = options.url || '';
|
||||
this._initBody(bodyInit);
|
||||
}
|
||||
|
||||
Body.call(Response.prototype);
|
||||
|
||||
Response.prototype.clone = function() {
|
||||
return new Response(this._bodyInit, {
|
||||
status: this.status,
|
||||
statusText: this.statusText,
|
||||
headers: new Headers(this.headers),
|
||||
url: this.url
|
||||
})
|
||||
};
|
||||
|
||||
Response.error = function() {
|
||||
var response = new Response(null, {status: 0, statusText: ''});
|
||||
response.type = 'error';
|
||||
return response
|
||||
};
|
||||
|
||||
var redirectStatuses = [301, 302, 303, 307, 308];
|
||||
|
||||
Response.redirect = function(url, status) {
|
||||
if (redirectStatuses.indexOf(status) === -1) {
|
||||
throw new RangeError('Invalid status code')
|
||||
}
|
||||
|
||||
return new Response(null, {status: status, headers: {location: url}})
|
||||
};
|
||||
|
||||
exports.DOMException = global.DOMException;
|
||||
try {
|
||||
new exports.DOMException();
|
||||
} catch (err) {
|
||||
exports.DOMException = function(message, name) {
|
||||
this.message = message;
|
||||
this.name = name;
|
||||
var error = Error(message);
|
||||
this.stack = error.stack;
|
||||
};
|
||||
exports.DOMException.prototype = Object.create(Error.prototype);
|
||||
exports.DOMException.prototype.constructor = exports.DOMException;
|
||||
}
|
||||
|
||||
function fetch(input, init) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var request = new Request(input, init);
|
||||
|
||||
if (request.signal && request.signal.aborted) {
|
||||
return reject(new exports.DOMException('Aborted', 'AbortError'))
|
||||
}
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
|
||||
function abortXhr() {
|
||||
xhr.abort();
|
||||
}
|
||||
|
||||
xhr.onload = function() {
|
||||
var options = {
|
||||
status: xhr.status,
|
||||
statusText: xhr.statusText,
|
||||
headers: parseHeaders(xhr.getAllResponseHeaders() || '')
|
||||
};
|
||||
options.url = 'responseURL' in xhr ? xhr.responseURL : options.headers.get('X-Request-URL');
|
||||
var body = 'response' in xhr ? xhr.response : xhr.responseText;
|
||||
setTimeout(function() {
|
||||
resolve(new Response(body, options));
|
||||
}, 0);
|
||||
};
|
||||
|
||||
xhr.onerror = function() {
|
||||
setTimeout(function() {
|
||||
reject(new TypeError('Network request failed'));
|
||||
}, 0);
|
||||
};
|
||||
|
||||
xhr.ontimeout = function() {
|
||||
setTimeout(function() {
|
||||
reject(new TypeError('Network request failed'));
|
||||
}, 0);
|
||||
};
|
||||
|
||||
xhr.onabort = function() {
|
||||
setTimeout(function() {
|
||||
reject(new exports.DOMException('Aborted', 'AbortError'));
|
||||
}, 0);
|
||||
};
|
||||
|
||||
function fixUrl(url) {
|
||||
try {
|
||||
return url === '' && global.location.href ? global.location.href : url
|
||||
} catch (e) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
xhr.open(request.method, fixUrl(request.url), true);
|
||||
|
||||
if (request.credentials === 'include') {
|
||||
xhr.withCredentials = true;
|
||||
} else if (request.credentials === 'omit') {
|
||||
xhr.withCredentials = false;
|
||||
}
|
||||
|
||||
if ('responseType' in xhr) {
|
||||
if (support.blob) {
|
||||
xhr.responseType = 'blob';
|
||||
} else if (
|
||||
support.arrayBuffer &&
|
||||
request.headers.get('Content-Type') &&
|
||||
request.headers.get('Content-Type').indexOf('application/octet-stream') !== -1
|
||||
) {
|
||||
xhr.responseType = 'arraybuffer';
|
||||
}
|
||||
}
|
||||
|
||||
if (init && typeof init.headers === 'object' && !(init.headers instanceof Headers)) {
|
||||
Object.getOwnPropertyNames(init.headers).forEach(function(name) {
|
||||
xhr.setRequestHeader(name, normalizeValue(init.headers[name]));
|
||||
});
|
||||
} else {
|
||||
request.headers.forEach(function(value, name) {
|
||||
xhr.setRequestHeader(name, value);
|
||||
});
|
||||
}
|
||||
|
||||
if (request.signal) {
|
||||
request.signal.addEventListener('abort', abortXhr);
|
||||
|
||||
xhr.onreadystatechange = function() {
|
||||
// DONE (success or failure)
|
||||
if (xhr.readyState === 4) {
|
||||
request.signal.removeEventListener('abort', abortXhr);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
xhr.send(typeof request._bodyInit === 'undefined' ? null : request._bodyInit);
|
||||
})
|
||||
}
|
||||
|
||||
fetch.polyfill = true;
|
||||
|
||||
if (!global.fetch) {
|
||||
global.fetch = fetch;
|
||||
global.Headers = Headers;
|
||||
global.Request = Request;
|
||||
global.Response = Response;
|
||||
}
|
||||
|
||||
exports.Headers = Headers;
|
||||
exports.Request = Request;
|
||||
exports.Response = Response;
|
||||
exports.fetch = fetch;
|
||||
|
||||
Object.defineProperty(exports, '__esModule', { value: true });
|
||||
|
||||
})));
|
||||
6
assets/javascripts/popper.min.js
vendored
3
assets/javascripts/purify.min.js
vendored
1
assets/javascripts/url-polyfill.min.js
vendored
45
cmd/feed2json/main.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/nkanaev/yarr/src/parser"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 2 {
|
||||
fmt.Println("usage: <script> [url|filepath]")
|
||||
return
|
||||
}
|
||||
url := os.Args[1]
|
||||
var r io.Reader
|
||||
|
||||
if strings.HasPrefix(url, "http") {
|
||||
res, err := http.Get(url)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to get url %s: %s", url, err)
|
||||
}
|
||||
r = res.Body
|
||||
} else {
|
||||
var err error
|
||||
r, err = os.Open(url)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to open file: %s", err)
|
||||
}
|
||||
}
|
||||
feed, err := parser.Parse(r)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to parse feed: %s", err)
|
||||
}
|
||||
body, err := json.MarshalIndent(feed, "", " ")
|
||||
if err != nil {
|
||||
log.Fatalf("failed to marshall feed: %s", err)
|
||||
}
|
||||
fmt.Println(string(body))
|
||||
}
|
||||
41
cmd/readability/main.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/nkanaev/yarr/src/content/readability"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 2 {
|
||||
fmt.Println("usage: <script> [url]")
|
||||
return
|
||||
}
|
||||
url := os.Args[1]
|
||||
var r io.Reader
|
||||
|
||||
if strings.HasPrefix(url, "http") {
|
||||
res, err := http.Get(url)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to get url %s: %s", url, err)
|
||||
}
|
||||
r = res.Body
|
||||
} else {
|
||||
var err error
|
||||
r, err = os.Open(url)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to open file: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
content, err := readability.ExtractContent(r)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to extract content: %s", err)
|
||||
}
|
||||
fmt.Println(content)
|
||||
}
|
||||
162
cmd/yarr/main.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/nkanaev/yarr/src/platform"
|
||||
"github.com/nkanaev/yarr/src/server"
|
||||
"github.com/nkanaev/yarr/src/storage"
|
||||
"github.com/nkanaev/yarr/src/worker"
|
||||
)
|
||||
|
||||
var Version string = "0.0"
|
||||
var GitHash string = "unknown"
|
||||
|
||||
var OptList = make([]string, 0)
|
||||
|
||||
func opt(envVar, defaultValue string) string {
|
||||
OptList = append(OptList, envVar)
|
||||
value := os.Getenv(envVar)
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func parseAuthfile(authfile io.Reader) (username, password string, err error) {
|
||||
scanner := bufio.NewScanner(authfile)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", "", fmt.Errorf("wrong syntax (expected `username:password`)")
|
||||
}
|
||||
username = parts[0]
|
||||
password = parts[1]
|
||||
break
|
||||
}
|
||||
return username, password, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
platform.FixConsoleIfNeeded()
|
||||
|
||||
var addr, db, authfile, auth, certfile, keyfile, basepath, logfile string
|
||||
var ver, open bool
|
||||
|
||||
flag.CommandLine.SetOutput(os.Stdout)
|
||||
|
||||
flag.Usage = func() {
|
||||
out := flag.CommandLine.Output()
|
||||
fmt.Fprintf(out, "Usage of %s:\n", os.Args[0])
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprintln(out, "\nThe environmental variables, if present, will be used to provide\nthe default values for the params above:")
|
||||
fmt.Fprintln(out, " ", strings.Join(OptList, ", "))
|
||||
}
|
||||
|
||||
flag.StringVar(&addr, "addr", opt("YARR_ADDR", "127.0.0.1:7070"), "address to run server on")
|
||||
flag.StringVar(&basepath, "base", opt("YARR_BASE", ""), "base path of the service url")
|
||||
flag.StringVar(&authfile, "auth-file", opt("YARR_AUTHFILE", ""), "`path` to a file containing username:password. Takes precedence over --auth (or YARR_AUTH)")
|
||||
flag.StringVar(&auth, "auth", opt("YARR_AUTH", ""), "string with username and password in the format `username:password`")
|
||||
flag.StringVar(&certfile, "cert-file", opt("YARR_CERTFILE", ""), "`path` to cert file for https")
|
||||
flag.StringVar(&keyfile, "key-file", opt("YARR_KEYFILE", ""), "`path` to key file for https")
|
||||
flag.StringVar(&db, "db", opt("YARR_DB", ""), "storage file `path`")
|
||||
flag.StringVar(&logfile, "log-file", opt("YARR_LOGFILE", ""), "`path` to log file to use instead of stdout")
|
||||
flag.BoolVar(&ver, "version", false, "print application version")
|
||||
flag.BoolVar(&open, "open", false, "open the server in browser")
|
||||
flag.Parse()
|
||||
|
||||
if ver {
|
||||
fmt.Printf("v%s (%s)\n", Version, GitHash)
|
||||
return
|
||||
}
|
||||
|
||||
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
|
||||
if logfile != "" {
|
||||
file, err := os.OpenFile(logfile, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to setup log file: ", err)
|
||||
}
|
||||
defer file.Close()
|
||||
log.SetOutput(file)
|
||||
} else {
|
||||
log.SetOutput(os.Stdout)
|
||||
}
|
||||
|
||||
if open && strings.HasPrefix(addr, "unix:") {
|
||||
log.Fatal("Cannot open ", addr, " in browser")
|
||||
}
|
||||
|
||||
if db == "" {
|
||||
configPath, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to get config dir: ", err)
|
||||
}
|
||||
|
||||
storagePath := filepath.Join(configPath, "yarr")
|
||||
if err := os.MkdirAll(storagePath, 0755); err != nil {
|
||||
log.Fatal("Failed to create app config dir: ", err)
|
||||
}
|
||||
db = filepath.Join(storagePath, "storage.db")
|
||||
}
|
||||
|
||||
log.Printf("using db file %s", db)
|
||||
|
||||
var username, password string
|
||||
var err error
|
||||
if authfile != "" {
|
||||
f, err := os.Open(authfile)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to open auth file: ", err)
|
||||
}
|
||||
defer f.Close()
|
||||
username, password, err = parseAuthfile(f)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to parse auth file: ", err)
|
||||
}
|
||||
} else if auth != "" {
|
||||
username, password, err = parseAuthfile(strings.NewReader(auth))
|
||||
if err != nil {
|
||||
log.Fatal("Failed to parse auth literal: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
if (certfile != "" || keyfile != "") && (certfile == "" || keyfile == "") {
|
||||
log.Fatalf("Both cert & key files are required")
|
||||
}
|
||||
|
||||
store, err := storage.New(db)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to initialise database: ", err)
|
||||
}
|
||||
|
||||
worker.SetVersion(Version)
|
||||
srv := server.NewServer(store, addr)
|
||||
|
||||
if basepath != "" {
|
||||
srv.BasePath = "/" + strings.Trim(basepath, "/")
|
||||
}
|
||||
|
||||
if certfile != "" && keyfile != "" {
|
||||
srv.CertFile = certfile
|
||||
srv.KeyFile = keyfile
|
||||
}
|
||||
|
||||
if username != "" && password != "" {
|
||||
srv.Username = username
|
||||
srv.Password = password
|
||||
}
|
||||
|
||||
log.Printf("starting server at %s", srv.GetAddr())
|
||||
if open {
|
||||
platform.Open(srv.GetAddr())
|
||||
}
|
||||
platform.Start(srv)
|
||||
}
|
||||
47
cmd/yarr/main_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPasswordFromAuthfile(t *testing.T) {
|
||||
for _, tc := range [...]struct {
|
||||
authfile string
|
||||
expectedUsername string
|
||||
expectedPassword string
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
authfile: "username:password",
|
||||
expectedUsername: "username",
|
||||
expectedPassword: "password",
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
authfile: "username-and-no-password",
|
||||
expectedError: true,
|
||||
},
|
||||
{
|
||||
authfile: "username:password:with:columns",
|
||||
expectedUsername: "username",
|
||||
expectedPassword: "password:with:columns",
|
||||
expectedError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.authfile, func(t *testing.T) {
|
||||
username, password, err := parseAuthfile(strings.NewReader(tc.authfile))
|
||||
if tc.expectedUsername != username {
|
||||
t.Errorf("expected username %q, got %q", tc.expectedUsername, username)
|
||||
}
|
||||
if tc.expectedPassword != password {
|
||||
t.Errorf("expected password %q, got %q", tc.expectedPassword, password)
|
||||
}
|
||||
if tc.expectedError && err == nil {
|
||||
t.Errorf("expected error, got nil")
|
||||
} else if !tc.expectedError && err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
56
doc/build.md
Normal file
@@ -0,0 +1,56 @@
|
||||
## Compilation
|
||||
|
||||
Prerequisies:
|
||||
|
||||
* Go >= 1.23
|
||||
* C Compiler (GCC / Clang / ...)
|
||||
* Zig >= 0.14.0 (optional, for cross-compiling CLI versions)
|
||||
* binutils (optional, for building Windows GUI version)
|
||||
|
||||
Get the source code:
|
||||
|
||||
git clone https://github.com/nkanaev/yarr.git
|
||||
|
||||
Compile:
|
||||
|
||||
# create cli for the host OS/architecture
|
||||
make host # out/yarr
|
||||
|
||||
# create GUI, works only in the target OS
|
||||
make windows_amd64_gui # out/windows_amd64_gui/yarr.exe
|
||||
make windows_arm64_gui # out/windows_arm64_gui/yarr.exe
|
||||
make darwin_arm64_gui # out/darwin_arm64_gui/yarr.app
|
||||
make darwin_amd64_gui # out/darwin_amd64_gui/yarr.app
|
||||
|
||||
# create cli, cross-compiles within any OS/architecture
|
||||
make linux_amd64
|
||||
make linux_arm64
|
||||
make linux_armv7
|
||||
make windows_amd64
|
||||
make windows_arm64
|
||||
|
||||
# ... or build a docker image
|
||||
docker build -t yarr -f etc/dockerfile .
|
||||
|
||||
## ARM compilation
|
||||
|
||||
The instructions below are to cross-compile *yarr* to `Linux/ARM*`.
|
||||
|
||||
Build:
|
||||
|
||||
docker build -t yarr.arm -f etc/dockerfile.arm .
|
||||
|
||||
Test:
|
||||
|
||||
# inside host
|
||||
docker run -it --rm yarr.arm
|
||||
|
||||
# then, inside container
|
||||
cd /root/out
|
||||
qemu-aarch64 -L /usr/aarch64-linux-gnu/ yarr.arm64
|
||||
|
||||
Extract files from images:
|
||||
|
||||
CID=$(docker create yarr.arm)
|
||||
docker cp -a "$CID:/root/out" .
|
||||
docker rm "$CID"
|
||||
125
doc/changelog.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# upcoming
|
||||
|
||||
- (new) serve on unix socket (thanks to @rvighne)
|
||||
- (new) more auto-refresh options: 12h & 24h (thanks to @aswerkljh for suggestion)
|
||||
- (fix) smooth scrolling on iOS (thanks to gatheraled)
|
||||
- (fix) displaying youtube shorts in "Read Here" (thanks to @Dean-Corso for the report)
|
||||
- (etc) theme-color support (thanks to @asimpson)
|
||||
- (etc) cookie security measures (thanks to Tom Fitzhenry)
|
||||
- (etc) restrict access to internal IPs for page crawler (thanks to Omar Kurt)
|
||||
|
||||
# v2.5 (2025-03-26)
|
||||
|
||||
- (new) Fever API support (thanks to @icefed)
|
||||
- (new) editable feed link (thanks to @adaszko)
|
||||
- (new) switch to feed by clicking the title in the article page (thanks to @tarasglek for suggestion)
|
||||
- (new) support multiple media links
|
||||
- (new) next/prev article navigation buttons (thanks to @tillcash)
|
||||
- (fix) duplicate articles caused by the same feed addition (thanks to @adaszko)
|
||||
- (fix) relative article links (thanks to @adazsko for the report)
|
||||
- (fix) atom article links stored in id element (thanks to @adazsko for the report)
|
||||
- (fix) parsing atom feed titles (thanks to @wnh)
|
||||
- (fix) sorting same-day batch articles (thanks to @lamescholar for the report)
|
||||
- (fix) showing login page in the selected theme (thanks to @feddiriko for the report)
|
||||
- (fix) parsing atom feeds with html elements (thanks to @tillcash & @toBeOfUse for the report, @krkk for the fix)
|
||||
- (fix) parsing feeds with missing guids (thanks to @hoyii for the report)
|
||||
- (fix) sending actual client version to servers (thanks to @aidanholm)
|
||||
- (fix) error caused by missing config dir (thanks to @timster)
|
||||
- (etc) load external images with no-referrer policy (thanks to @tillcash for the report)
|
||||
- (etc) open external links with no-referrer policy (thanks to @donovanglover)
|
||||
- (etc) show article content in the list if title is missing (thanks to @asimpson for suggestion)
|
||||
- (etc) accessibility improvements (thanks to @tseykovets)
|
||||
|
||||
# v2.4 (2023-08-15)
|
||||
|
||||
- (new) ARM build support (thanks to @tillcash & @fenuks)
|
||||
- (new) auth configuration via param or env variable (thanks to @pierreprinetti)
|
||||
- (new) web app manifest for an app-like experience on mobile (thanks to @qbit)
|
||||
- (fix) concurrency issue crashing the app (thanks to @quoing)
|
||||
- (fix) favicon visibility in dark mode (thanks to @caycaycarly for the report)
|
||||
- (fix) autoloading more articles not working in certain edge cases (thanks to @fenuks for the report)
|
||||
- (fix) handle Google URL redirects in "Read Here" (thanks to @cubbei for discovery)
|
||||
- (fix) handle failures to extract content in "Read Here" (thanks to @grigio for the report)
|
||||
- (fix) article view width for high resolution screens (thanks to @whaler-ragweed for the report)
|
||||
- (fix) make newly added feed searchable (thanks to @BMorearty for the report)
|
||||
- (fix) feed/article selection accessibility via arrow keys (thanks to @grigio and @tillcash)
|
||||
- (fix) keyboard shortcuts in Firefox (thanks to @kaloyan13)
|
||||
- (fix) keyboard shortcuts in non-English layouts (thanks to @kaloyan13)
|
||||
- (fix) sorting articles with timezone information (thanks to @x2cf)
|
||||
- (fix) handling links set in guid only for certain feeds (thanks to @adaszko for the report)
|
||||
- (fix) crashes caused by feed icon endpoint (thanks to @adaszko)
|
||||
|
||||
# v2.3 (2022-05-03)
|
||||
|
||||
- (fix) handling encodings (thanks to @f100024 & @fserb)
|
||||
- (fix) parsing xml feeds with illegal characters (thanks to @stepelu for the report)
|
||||
- (fix) old articles reappearing as unread (thanks to @adaszko for the report)
|
||||
- (fix) item list scrolling issue on large screens (thanks to @bielej for the report)
|
||||
- (fix) keyboard shortcuts color in dark mode (thanks to @John09f9 for the report)
|
||||
- (etc) autofocus when adding a new feed (thanks to @lakuapik)
|
||||
|
||||
# v2.2 (2021-11-20)
|
||||
|
||||
- (fix) windows console support (thanks to @dufferzafar for the report)
|
||||
- (fix) remove html tags from article titles (thanks to Alex Went for the report)
|
||||
- (etc) autoselect current folder when adding a new feed (thanks to @krkk)
|
||||
- (etc) folder/feed settings menu available across all filters
|
||||
|
||||
# v2.1 (2021-08-16)
|
||||
|
||||
- (new) configuration via env variables
|
||||
- (fix) missing `content-type` headers (thanks to @verahawk for the report)
|
||||
- (fix) handle opml files not following the spec (thanks to @huangnauh for the report)
|
||||
- (fix) pagination in unread/starred feeds (thanks to @Farow for the report)
|
||||
- (fix) handling feeds with non-utf8 encodings (thanks to @fserb for the report)
|
||||
- (fix) errors caused by empty feeds (thanks to @decke)
|
||||
- (fix) recognize all audio mime types as podcasts (thanks to @krkk)
|
||||
- (fix) ui tweaks (thanks to @Farow)
|
||||
|
||||
# v2.0 (2021-04-18)
|
||||
|
||||
- (new) user interface tweaks
|
||||
- (new) feed parser fully rewritten
|
||||
- (new) show youtube/vimeo iframes in "read here"
|
||||
- (new) keyboard shortcuts for article scrolling & toggling "read here"
|
||||
- (new) more options for auto-refresh intervals
|
||||
- (fix) `-base` not serving static files (thanks to @vfaronov)
|
||||
- (etc) 3rd-party dependencies reduced to the bare minimum
|
||||
|
||||
special thanks to @tillcash for feedback & suggestions.
|
||||
|
||||
# v1.4 (2021-03-11)
|
||||
|
||||
- (new) keyboard shortcuts (thanks to @Duarte-Dias)
|
||||
- (new) show podcast audio
|
||||
- (fix) deleting feeds
|
||||
- (etc) minor ui tweaks & changes
|
||||
|
||||
# v1.3 (2021-02-18)
|
||||
|
||||
- (fix) log out functionality if authentication is set
|
||||
- (fix) import opml if authentication is set
|
||||
- (fix) login page if authentication is set (thanks to @einschmidt)
|
||||
|
||||
# v1.2 (2021-02-11)
|
||||
|
||||
- (new) autorefresh rate
|
||||
- (new) reduced bandwidth usage via stateful http headers `last-modified/etag`
|
||||
- (new) show feed errors in feed management modal
|
||||
- (new) `-open` flag for automatically opening the server url
|
||||
- (new) `-base` flag for serving urls under non-root path (thanks to @hcl)
|
||||
- (new) `-auth-file` flag for authentication
|
||||
- (new) `-cert-file` & `-key-file` flags for TLS
|
||||
- (fix) wrapping long words in the ui to prevent vertical scroll
|
||||
- (fix) increased toolbar height in mobile/tablet layout (thanks to @einschmidt)
|
||||
|
||||
# v1.1 (2020-10-05)
|
||||
|
||||
- (new) responsive design
|
||||
- (fix) server crash on favicon fetch timeout (reported by @minioin)
|
||||
- (fix) handling byte order marks in feeds (reported by @ilaer)
|
||||
- (fix) deleting a feed raises exception in the ui if the feed's items are shown.
|
||||
|
||||
# v1.0 (2020-09-24)
|
||||
|
||||
Initial Release
|
||||
19
doc/fever.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Fever API support
|
||||
|
||||
Fever API is a kind of RSS HTTP API interface, because the Fever API definition is not very clear, so the implementation of Fever server and Client may have some compatibility problems.
|
||||
|
||||
The Fever API implemented by Yarr is based on the Fever API spec: https://github.com/DigitalDJ/tinytinyrss-fever-plugin/blob/master/fever-api.md.
|
||||
|
||||
Here are some Apps that have been tested to work with yarr. Feel free to test other Clients/Apps and update the list here.
|
||||
|
||||
> Different apps support different URL/Address formats. Please note whether the URL entered has `http://` scheme and `/` suffix.
|
||||
|
||||
| App | Platforms | Config Server URL |
|
||||
|:------------------------------------------------------------------------- | ---------------- |:--------------------------------------------------- |
|
||||
| [Reeder](https://reederapp.com/) | MacOS<br>iOS | 127.0.0.1:7070/fever<br>http://127.0.0.1:7070/fever |
|
||||
| [ReadKit](https://readkit.app/) | MacOS<br>iOS | http://127.0.0.1:7070/fever |
|
||||
| [Fluent Reader](https://github.com/yang991178/fluent-reader) | MacOS<br>Windows | http://127.0.0.1:7070/fever/ |
|
||||
| [Unread](https://apps.apple.com/us/app/unread-an-rss-reader/id1363637349) | iOS | http://127.0.0.1:7070/fever |
|
||||
| [Fiery Feeds](https://voidstern.net/fiery-feeds) | MacOS<br>iOS | http://127.0.0.1:7070/fever |
|
||||
|
||||
If you are having trouble using Fever, please open an issue and @icefed, thanks.
|
||||
171
doc/formats.txt
Normal file
@@ -0,0 +1,171 @@
|
||||
# model
|
||||
|
||||
- feed:
|
||||
- title
|
||||
|
||||
rdf>channel>title (rss 0.90)
|
||||
rdf>channel>title (rss 1.0)
|
||||
rss>channel>title (rss 0.91 netscape)
|
||||
rss>channel>title (rss 0.91 userland)
|
||||
rss>channel>title (rss 2.0)
|
||||
feed>title (atom 1.0)
|
||||
|
||||
- site_url
|
||||
|
||||
rdf>channel>link (rss 0.90)
|
||||
rdf>channel>link (rss 1.0)
|
||||
rss>channel>link (rss 0.91 netscape)
|
||||
rss>channel>link (rss 0.91 userland)
|
||||
rss>channel>link (rss 2.0)
|
||||
feed>link (atom 1.0)
|
||||
|
||||
- item:
|
||||
- guid
|
||||
|
||||
rss>channel>guid (rss 2.0)
|
||||
feed>entry>id (atom 1.0)
|
||||
|
||||
- date
|
||||
|
||||
rdf>item>dc:date (rss 1.0)
|
||||
rss>channel>pubDate (rss 2.0)
|
||||
feed>entry>updated (atom 1.0)
|
||||
feed>entry>published (atom 1.0)
|
||||
|
||||
- url
|
||||
|
||||
rdf>item>link (rss 0.90)
|
||||
rdf>item>link (rss 1.0)
|
||||
rss>channel>item>link (rss 0.91 netscape)
|
||||
rss>channel>item>link (rss 0.91 userland)
|
||||
rss>channel>item>link (rss 2.0)
|
||||
feed>entry>link[rel=alternate] (atom 1.0)
|
||||
|
||||
- title
|
||||
|
||||
rdf>item>title (rss 0.90)
|
||||
rdf>item>title (rss 1.0)
|
||||
rss>channel>item>title (rss 0.91 netscape)
|
||||
rss>channel>item>title (rss 0.91 userland)
|
||||
rss>channel>item>title (rss 2.0)
|
||||
feed>entry>title (atom 1.0)
|
||||
|
||||
- content
|
||||
|
||||
rss>channel>item>description (rss 0.91 netscape)
|
||||
rss>channel>item>description (rss 0.91 userland)
|
||||
rss>channel>item>description (rss 2.0)
|
||||
rdf>item>description (rss 1.0)
|
||||
rdf>item>content:encoded (rss 1.0)
|
||||
feed>entry>content (atom 1.0)
|
||||
|
||||
- image_url
|
||||
|
||||
rss>item>media:thumbnail:url (rss 2.0 media)
|
||||
feed>entry>enclosure[rel='image/*'] (atom 1.0) ???
|
||||
|
||||
- audio_url
|
||||
|
||||
rss>item>enclosure:url (audio/*) (rss 2.0)
|
||||
feed>entry>enclosure (audio/*') (atom 1.0) ???
|
||||
|
||||
# specs
|
||||
|
||||
- rss
|
||||
https://en.wikipedia.org/wiki/RSS
|
||||
- 0.90:
|
||||
https://www.rssboard.org/rss-0-9-0
|
||||
https://web.archive.org/web/20001208063100/http://my.netscape.com/publish/help/quickstart.html
|
||||
- 0.91 (netscape)
|
||||
https://www.rssboard.org/rss-0-9-1-netscape
|
||||
- 0.91 (userland)
|
||||
https://www.rssboard.org/rss-0-9-1
|
||||
- 0.92
|
||||
https://www.rssboard.org/rss-0-9-2
|
||||
by userland, no significant changes from 0.91
|
||||
- 0.93 (withdrawn)
|
||||
http://backend.userland.com/rss093
|
||||
- 0.94 (withdrawn)
|
||||
- 1.0
|
||||
https://web.resource.org/rss/1.0/
|
||||
https://web.archive.org/web/20021014094554/https://web.resource.org/rss/1.0/spec
|
||||
reintroduced rdf from 0.90, added dublincore namespaces etc
|
||||
namespaces:
|
||||
content: http://purl.org/rss/1.0/modules/content/
|
||||
dc: http://purl.org/dc/elements/1.1/
|
||||
- 2.0
|
||||
https://cyber.harvard.edu/rss/rss.html
|
||||
https://www.rssboard.org/rss-2-0
|
||||
|
||||
- atom
|
||||
https://en.wikipedia.org/wiki/Atom_(Web_standard)
|
||||
- 0.3
|
||||
https://support.google.com/merchants/answer/160598?hl=en
|
||||
http://web.archive.org/web/20060811235523/http://www.mnot.net/drafts/draft-nottingham-atom-format-02.html
|
||||
- 1.0
|
||||
https://tools.ietf.org/html/rfc4287
|
||||
https://validator.w3.org/feed/docs/atom.html
|
||||
|
||||
- json
|
||||
https://en.wikipedia.org/wiki/JSON_Feed
|
||||
- 1.0
|
||||
https://jsonfeed.org/version/1
|
||||
- 1.1
|
||||
https://jsonfeed.org/version/1.1
|
||||
|
||||
- media
|
||||
https://www.rssboard.org/media-rss
|
||||
xml namespace for:
|
||||
- rss 2.0
|
||||
- atom 1.0
|
||||
|
||||
# extensions
|
||||
|
||||
- feedburner
|
||||
https://en.wikipedia.org/wiki/FeedBurner
|
||||
|
||||
- media
|
||||
https://www.rssboard.org/media-rss
|
||||
initially for rss 2.0, used in atom 1.0 as well (youtube)
|
||||
|
||||
- itunes podcasts
|
||||
https://help.apple.com/itc/podcasts_connect/#/itcb54353390
|
||||
https://github.com/simplepie/simplepie-ng/wiki/Spec:-iTunes-Podcast-RSS
|
||||
|
||||
- google podcasts
|
||||
https://support.google.com/podcast-publishers/answer/9889544?visit_id=637523492443301715-1225759684&rd=1
|
||||
|
||||
# parsers
|
||||
|
||||
https://github.com/kurtmckee/feedparser
|
||||
https://github.com/mmcdole/gofeed
|
||||
https://github.com/miniflux/v2/tree/2.0.28/reader/
|
||||
https://github.com/Ranchero-Software/RSParser
|
||||
https://github.com/feederco/feeder-parser
|
||||
|
||||
https://github.com/mmcdole/gofeed/commit/9665eb31016cef3d15ab85574bc6fdbe890cd252
|
||||
|
||||
# platforms
|
||||
|
||||
A list of centralized content providers worth keeping track of.
|
||||
The parser should be reasonably handle content provided by them.
|
||||
Delete any from the list in case they drop support of web feeds.
|
||||
|
||||
- blogger
|
||||
- cnblogs
|
||||
- flickr
|
||||
- hatenablog
|
||||
- livejournal
|
||||
- medium
|
||||
- posthaven
|
||||
- reddit
|
||||
- substack
|
||||
- tumblr
|
||||
- vimeo
|
||||
- wordpress
|
||||
- youtube
|
||||
|
||||
# links
|
||||
|
||||
https://indieweb.org/feed#Criticism
|
||||
https://inessential.com/2013/03/18/brians_stupid_feed_tricks
|
||||
70
doc/platform.txt
Normal file
@@ -0,0 +1,70 @@
|
||||
Incomplete & inaccurate platform-specific notes.
|
||||
|
||||
# MacOS Icon
|
||||
|
||||
The format for desktop apps is [.icns][icns].
|
||||
AFAIK, the format is not open (even though it had been [reverse-engineered][icns-re]),
|
||||
and I couldn't find any 3rd party tool that'd fully support it.
|
||||
|
||||
The easiest way for creating icon file is either via `Xcode`,
|
||||
or by using built-in `iconutil` command that ships with MacOS.
|
||||
|
||||
The steps are provided below:
|
||||
|
||||
$ sips -s format png --resampleWidth 1024 source.png --out /path/to/icons/icon_512x512@2x.png
|
||||
$ sips -s format png --resampleWidth 512 source.png --out /path/to/icons/icon_512x512.png
|
||||
$ sips -s format png --resampleWidth 256 source.png --out /path/to/icons/icon_256x256.png
|
||||
$ sips -s format png --resampleWidth 128 source.png --out /path/to/icons/icon_128x128.png
|
||||
$ sips -s format png --resampleWidth 64 source.png --out /path/to/icons/icon_32x32@2x.png
|
||||
$ sips -s format png --resampleWidth 32 source.png --out /path/to/icons/icon_32x32.png
|
||||
$ sips -s format png --resampleWidth 16 source.png --out /path/to/icons/icon_16x16.png
|
||||
$ iconutil -c icns /path/to/icons -o icon.icns
|
||||
|
||||
[icns]: https://en.wikipedia.org/wiki/Apple_Icon_Image_format
|
||||
[icns-re]: https://www.macdisk.com/maciconen.php#RLE
|
||||
|
||||
# Windows Icon
|
||||
|
||||
Terminology:
|
||||
|
||||
- coff: precursor to pe format (portable executable). pe is an extension of coff.
|
||||
- manifest: xml file with platform requirements needed during runtime
|
||||
- https://docs.microsoft.com/en-us/windows/win32/sbscs/application-manifests
|
||||
- https://www.samlogic.net/articles/manifest.htm
|
||||
- rc: dsl file that describes the application metadata & resources
|
||||
- https://docs.microsoft.com/en-gb/windows/win32/menurc/about-resource-files
|
||||
- https://github.com/josephspurrier/goversioninfo/blob/master/testdata/rc/versioninfo.rc (sample rc)
|
||||
|
||||
Windows Icons are directly embedded to the binary.
|
||||
To do so one needs to provide `.syso` file prior to compiling Go code,
|
||||
which will be passed to the linker. So, basically `.syso` is any
|
||||
[object file][obj-file] that the linker understands.
|
||||
|
||||
More info here: [ticket][syso-ticket] & [commit][syso-commit].
|
||||
|
||||
Note to self: running `go build main.go` [won't embed][syso-quirk]
|
||||
.syso file if it isn't located in a package directory.
|
||||
|
||||
Tools to create `.syso` files:
|
||||
|
||||
- [windres][windres]: ships with mingw (gnu tools for windows)
|
||||
- [rsrc][rsrc]: written in Go, wasn't considered at the time
|
||||
due to the critical bug with icon alignment
|
||||
- [goversioninfo][goversioninfo]: rsrc wrapper
|
||||
with manifest file creation via json
|
||||
|
||||
[obj-file]: https://en.wikipedia.org/wiki/Object_file
|
||||
[syso-linker]: https://github.com/golang/go/issues/23278#issuecomment-354567634
|
||||
[syso-ticket]: https://github.com/golang/go/issues/1552
|
||||
[syso-commit]: https://github.com/golang/go/commit/b0996334
|
||||
[syso-quirk]: https://github.com/golang/go/issues/16090
|
||||
[mingw]: https://en.wikipedia.org/wiki/MinGW
|
||||
[coff]: https://en.wikipedia.org/wiki/COFF
|
||||
[windres]: https://sourceware.org/binutils/docs/binutils/windres.html
|
||||
[rsrs]: https://github.com/akavel/rsrc
|
||||
[rsrc-bug]: https://github.com/akavel/rsrc/issues/12
|
||||
[goversioninfo]: github.com/josephspurrier/goversioninfo
|
||||
|
||||
[winicon-guide]: https://docs.microsoft.com/en-us/windows/win32/uxguide/vis-icons#size-requirements
|
||||
[res-vs-coff]: http://www.mingw.org/wiki/MS_resource_compiler
|
||||
[versioninfo-resource]: https://docs.microsoft.com/en-us/windows/win32/menurc/versioninfo-resource
|
||||
62
doc/rationale.txt
Normal file
@@ -0,0 +1,62 @@
|
||||
# goal
|
||||
|
||||
The goal is to ship a (subjectively ergonomic) software for reading feeds.
|
||||
*yarr* is not designed/intended to be used as an archiving tool.
|
||||
|
||||
The initial goal was to serve the author's needs:
|
||||
a desktop application accessible via web browser
|
||||
(because keeping 2 apps running, feed reader & browser, was annoying).
|
||||
|
||||
# interface
|
||||
|
||||
The UI aesthetics were inspired by the works of:
|
||||
|
||||
- Antoine Plu
|
||||
https://dribbble.com/antoineplu
|
||||
- Pawel Kuna
|
||||
https://github.com/codecalm
|
||||
https://github.com/tabler/tabler
|
||||
- Pawel Kadysz
|
||||
https://dribbble.com/pawelkadysz
|
||||
- Palantir
|
||||
https://github.com/palantir/blueprint
|
||||
- Yan Zhu
|
||||
https://github.com/picturepan2/spectre
|
||||
|
||||
The 3-column layout (feeds + items + read) & certain UI/navigation
|
||||
elements were based on & largely inspired by `Reeder 3`, `NetNewsWire` & `Feedbin`.
|
||||
|
||||
Alternative layouts *might* be introduced in the future, but are not guaranteed.
|
||||
|
||||
Ideas for 1-column layout:
|
||||
- stringer
|
||||
https://github.com/swanson/stringer
|
||||
- headline
|
||||
https://github.com/zserge/headline
|
||||
- miniflux
|
||||
https://miniflux.app/
|
||||
|
||||
Ideas for 2-column layout:
|
||||
- feedly
|
||||
https://feedly.com/
|
||||
- vienna (classic `|-` shaped layout)
|
||||
https://github.com/ViennaRSS/vienna-rss
|
||||
|
||||
# frontend
|
||||
|
||||
ES5 is preferred over ES6 until js transpilers (babeljs)
|
||||
become a thing of the past.
|
||||
|
||||
The project won't introduce node/npm ecosystem,
|
||||
3rd party js code is directly included into the project.
|
||||
|
||||
# backend
|
||||
|
||||
The reasons for Go:
|
||||
- single binary compilation
|
||||
- availability of 3rd party libraries
|
||||
- the author's excuse to learn go
|
||||
|
||||
The reasons for SQLite:
|
||||
- lack of need for db setup (huge plus for desktop)
|
||||
- SQL is boring & practical
|
||||
68
doc/samples.yml
Normal file
@@ -0,0 +1,68 @@
|
||||
- site: https://vimeo.com/channels/staffpicks/videos
|
||||
feed: https://vimeo.com/channels/staffpicks/videos/rss
|
||||
tags: [vimeo, image]
|
||||
|
||||
- site: https://www.youtube.com/@everyframeapainting/videos
|
||||
feed: https://www.youtube.com/feeds/videos.xml?channel_id=UCjFqcJQXGZ6T6sxyFB-5i6A"
|
||||
tags: [youtube, image]
|
||||
|
||||
- site: https://iwdrm.tumblr.com/
|
||||
feed: https://iwdrm.tumblr.com/rss
|
||||
tags: [tumblr, image]
|
||||
|
||||
- site: https://falseknees.tumblr.com/
|
||||
feed: https://falseknees.tumblr.com/rss
|
||||
tags: [tumblr, image]
|
||||
|
||||
- site: https://accidentallyquadratic.tumblr.com/
|
||||
feed: https://accidentallyquadratic.tumblr.com/rss
|
||||
info: text blog with code sections
|
||||
tags: [tumblr, text, code]
|
||||
|
||||
- site: https://www.flickr.com/photos/maratsafin/
|
||||
feed: https://www.flickr.com/services/feeds/photos_public.gne?id=59021497@N07&lang=en-us&format=atom
|
||||
tags: [flickr, image]
|
||||
|
||||
- site: https://www.reddit.com/r/comics
|
||||
feed: https://www.reddit.com/r/comics.rss
|
||||
tags: [reddit, image]
|
||||
|
||||
- site: https://www.reddit.com/r/AITAH
|
||||
feed: https://www.reddit.com/r/AITAH.rss
|
||||
tags: [reddit, text]
|
||||
|
||||
- site: https://idothei.wordpress.com/
|
||||
feed: https://idothei.wordpress.com/feed/
|
||||
tags: [wordpress, text]
|
||||
|
||||
- site: https://www.vidarholen.net/contents/blog/
|
||||
feed: https://www.vidarholen.net/contents/blog/?feed=rss2
|
||||
tags: [wordpress, text]
|
||||
|
||||
- site: https://blog.posthaven.com/
|
||||
feed: https://blog.posthaven.com/posts.atom
|
||||
tags: [posthaven, text]
|
||||
|
||||
- site: https://medium.com/@dailynewsletter
|
||||
feed: https://medium.com/feed/@dailynewsletter
|
||||
tags: [medium, text]
|
||||
|
||||
- site: https://thereveal.substack.com/
|
||||
feed: https://thereveal.substack.com/feed
|
||||
tags: [substack, text]
|
||||
|
||||
- site: https://tema.livejournal.com/
|
||||
feed: https://tema.livejournal.com/data/rss
|
||||
tags: [livejournal, text]
|
||||
|
||||
- site: https://mametter.hatenablog.com/
|
||||
feed: https://mametter.hatenablog.com/feed
|
||||
tags: [hatena, text]
|
||||
|
||||
- site: https://juliepowell.blogspot.com/
|
||||
feed: https://juliepowell.blogspot.com/feeds/posts/default
|
||||
tags: [blogger, text]
|
||||
|
||||
- site: https://micro.blog/val
|
||||
feed: https://micro.blog/posts/val
|
||||
tags: [json, microblog]
|
||||
27
doc/thirdparty.txt
Normal file
@@ -0,0 +1,27 @@
|
||||
Below is the list of 3-rd party code directly included & rewritten
|
||||
to suit the author's needs & preferences.
|
||||
The licenses are included, and the authorship comments are left intact.
|
||||
|
||||
- readability
|
||||
https://github.com/miniflux/v2 (commit:31435ef) Apache 2.0
|
||||
|
||||
removed goquery dependency
|
||||
removed assisting utility structs (need another way to debug node scores)
|
||||
|
||||
- sanitizer
|
||||
https://github.com/miniflux/v2 (commit:3cb04b2) Apache 2.0
|
||||
|
||||
whitelist changed to the ones from https://github.com/cure53/DOMPurify:
|
||||
- allowed tags
|
||||
- allowed uri schemes
|
||||
- added svg whitelist
|
||||
|
||||
- systray
|
||||
https://github.com/getlantern/systray (commit:2c0986d) Apache 2.0
|
||||
|
||||
removed golog dependency
|
||||
|
||||
- fixconsole
|
||||
https://github.com/apenwarr/fixconsole (commit:5a9f648) Apache 2.0
|
||||
|
||||
removed `w32` dependency
|
||||
0
doc/todo.txt
Normal file
@@ -1,9 +0,0 @@
|
||||
FROM golang:1.15 AS build
|
||||
RUN apt install gcc -y
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN make build_linux
|
||||
|
||||
FROM ubuntu:20.04
|
||||
COPY --from=build /src/_output/linux/yarr /usr/bin/yarr
|
||||
ENTRYPOINT ["/usr/bin/yarr", "-addr", "0.0.0.0:7070"]
|
||||
14
etc/dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM golang:alpine3.21 AS build
|
||||
RUN apk add build-base git
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/root/go/pkg \
|
||||
make host
|
||||
|
||||
FROM alpine:latest
|
||||
RUN apk add --no-cache ca-certificates && update-ca-certificates
|
||||
COPY --from=build /src/out/yarr /usr/local/bin/yarr
|
||||
EXPOSE 7070
|
||||
ENTRYPOINT ["/usr/local/bin/yarr"]
|
||||
CMD ["-addr", "0.0.0.0:7070", "-db", "/data/yarr.db"]
|
||||
38
etc/dockerfile.arm
Normal file
@@ -0,0 +1,38 @@
|
||||
FROM ubuntu:20.04
|
||||
|
||||
# Install GCC
|
||||
RUN apt update
|
||||
RUN apt install -y \
|
||||
wget build-essential \
|
||||
gcc-aarch64-linux-gnu \
|
||||
binutils-aarch64-linux-gnu binutils-aarch64-linux-gnu-dbg \
|
||||
gcc-arm-linux-gnueabihf \
|
||||
binutils-arm-linux-gnueabihf binutils-arm-linux-gnueabihf-dbg
|
||||
RUN env DEBIAN_FRONTEND=noninteractive \
|
||||
apt install -y qemu-user qemu-user-static
|
||||
|
||||
# Install Golang
|
||||
RUN wget --quiet https://go.dev/dl/go1.24.1.linux-amd64.tar.gz && \
|
||||
rm -rf /usr/local/go && \
|
||||
tar -C /usr/local -xzf go1.24.1.linux-amd64.tar.gz
|
||||
ENV PATH=$PATH:/usr/local/go/bin
|
||||
|
||||
# Copy source code
|
||||
WORKDIR /root/src
|
||||
RUN mkdir /root/out
|
||||
COPY . .
|
||||
|
||||
# Build ARM64
|
||||
RUN env \
|
||||
CC=aarch64-linux-gnu-gcc \
|
||||
CGO_ENABLED=1 \
|
||||
GOOS=linux GOARCH=arm64 \
|
||||
make host && mv out/yarr /root/out/yarr.arm64
|
||||
|
||||
RUN env \
|
||||
CC=arm-linux-gnueabihf-gcc \
|
||||
CGO_ENABLED=1 \
|
||||
GOOS=linux GOARCH=arm GOARM=7 \
|
||||
make host && mv out/yarr /root/out/yarr.armv7
|
||||
|
||||
CMD ["/bin/bash"]
|
||||
BIN
etc/icon.icns
Normal file
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
31
etc/install-linux.sh
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [[ ! -d "$HOME/.local/share/applications" ]]; then
|
||||
mkdir -p "$HOME/.local/share/applications"
|
||||
fi
|
||||
|
||||
cat >"$HOME/.local/share/applications/yarr.desktop" <<END
|
||||
[Desktop Entry]
|
||||
Name=yarr
|
||||
Exec=$HOME/.local/bin/yarr -open
|
||||
Icon=yarr
|
||||
Type=Application
|
||||
Categories=Internet;Network;News;Feed;
|
||||
END
|
||||
|
||||
if [[ ! -d "$HOME/.local/share/icons" ]]; then
|
||||
mkdir -p "$HOME/.local/share/icons"
|
||||
fi
|
||||
|
||||
cat >"$HOME/.local/share/icons/yarr.svg" <<END
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-anchor-favicon">
|
||||
<circle cx="12" cy="5" r="3" stroke-width="4" stroke="#ffffff"></circle>
|
||||
<line x1="12" y1="22" x2="12" y2="8" stroke-width="4" stroke="#ffffff"></line>
|
||||
<path d="M5 12H2a10 10 0 0 0 20 0h-3" stroke-width="4" stroke="#ffffff"></path>
|
||||
|
||||
<circle cx="12" cy="5" r="3"></circle>
|
||||
<line x1="12" y1="22" x2="12" y2="8"></line>
|
||||
<path d="M5 12H2a10 10 0 0 0 20 0h-3"></path>
|
||||
</svg>
|
||||
END
|
||||
62
etc/macos_package.sh
Executable file
@@ -0,0 +1,62 @@
|
||||
#/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
usage() {
|
||||
echo "usage: $0 VERSION path/to/icon.icns path/to/binary output/dir"
|
||||
}
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
usage
|
||||
exit
|
||||
fi
|
||||
|
||||
VERSION=$1
|
||||
ICNFILE=$2
|
||||
BINFILE=$3
|
||||
OUTPATH=$4
|
||||
|
||||
mkdir -p $OUTPATH/yarr.app/Contents/MacOS
|
||||
mkdir -p $OUTPATH/yarr.app/Contents/Resources
|
||||
|
||||
mv $BINFILE $OUTPATH/yarr.app/Contents/MacOS/yarr
|
||||
cp $ICNFILE $OUTPATH/yarr.app/Contents/Resources/icon.icns
|
||||
|
||||
chmod u+x $OUTPATH/yarr.app/Contents/MacOS/yarr
|
||||
|
||||
echo -n 'APPL????' >$OUTPATH/yarr.app/Contents/PkgInfo
|
||||
cat <<EOF >$OUTPATH/yarr.app/Contents/Info.plist
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>yarr</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>yarr</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>nkanaev.yarr</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$VERSION</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>yarr</string>
|
||||
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icon</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.news</string>
|
||||
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>True</string>
|
||||
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2020 nkanaev. All rights reserved.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
BIN
etc/promo.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
89
etc/windows_versioninfo.sh
Executable file
@@ -0,0 +1,89 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
# Function to display usage information
|
||||
usage() {
|
||||
echo "Usage: $0 [-version VERSION] [-outfile FILENAME]"
|
||||
echo " -version VERSION Set the version number (default: 0.0)"
|
||||
echo " -outfile FILENAME Set the output file name (default: versioninfo.rc)"
|
||||
echo ""
|
||||
echo "This script generates a Windows resource file with version information."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Default values
|
||||
version="0.0"
|
||||
outfile="versioninfo.rc"
|
||||
|
||||
# Check if help is requested
|
||||
if [[ "$1" == "-h" || "$1" == "--help" ]]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
# Parse command-line options
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-version)
|
||||
if [[ -z "$2" || "$2" == -* ]]; then
|
||||
echo "Error: Missing value for -version parameter"
|
||||
usage
|
||||
fi
|
||||
version="$2"
|
||||
shift 2
|
||||
;;
|
||||
-outfile)
|
||||
if [[ -z "$2" || "$2" == -* ]]; then
|
||||
echo "Error: Missing value for -outfile parameter"
|
||||
usage
|
||||
fi
|
||||
outfile="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "Error: Unknown parameter: $1"
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Replace dots with commas for version_comma
|
||||
version_comma="${version//./,}"
|
||||
|
||||
# Use a here document for the template with ENDFILE delimiter
|
||||
cat <<ENDFILE > "$outfile"
|
||||
1 VERSIONINFO
|
||||
FILEVERSION $version_comma,0,0
|
||||
PRODUCTVERSION $version_comma,0,0
|
||||
BEGIN
|
||||
BLOCK "StringFileInfo"
|
||||
BEGIN
|
||||
BLOCK "080904E4"
|
||||
BEGIN
|
||||
VALUE "CompanyName", "Old MacDonald's Farm"
|
||||
VALUE "FileDescription", "Yet another RSS reader"
|
||||
VALUE "FileVersion", "$version"
|
||||
VALUE "InternalName", "yarr"
|
||||
VALUE "LegalCopyright", "nkanaev"
|
||||
VALUE "OriginalFilename", "yarr.exe"
|
||||
VALUE "ProductName", "yarr"
|
||||
VALUE "ProductVersion", "$version"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
BEGIN
|
||||
VALUE "Translation", 0x809, 1252
|
||||
END
|
||||
END
|
||||
|
||||
1 ICON "icon.ico"
|
||||
ENDFILE
|
||||
|
||||
# Set the correct permissions
|
||||
chmod 644 "$outfile"
|
||||
|
||||
echo "Generated $outfile with version $version"
|
||||
15
go.mod
@@ -1,14 +1,13 @@
|
||||
module github.com/nkanaev/yarr
|
||||
|
||||
go 1.14
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.23.5
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.5.1
|
||||
github.com/getlantern/systray v1.0.4
|
||||
github.com/mattn/go-sqlite3 v1.14.0
|
||||
github.com/mmcdole/gofeed v1.0.0
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
golang.org/x/net v0.38.0
|
||||
golang.org/x/sys v0.31.0
|
||||
)
|
||||
|
||||
replace github.com/mmcdole/gofeed => ./gofeed
|
||||
require golang.org/x/text v0.23.0 // indirect
|
||||
|
||||
81
go.sum
@@ -1,73 +1,8 @@
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg=
|
||||
github.com/PuerkitoBio/goquery v1.5.1 h1:PSPBGne8NIUWw+/7vFBV+kG2J/5MOjbzc7154OaKCSE=
|
||||
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
|
||||
github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
|
||||
github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo=
|
||||
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
|
||||
github.com/codegangsta/cli v1.20.0/go.mod h1:/qJNoX69yVSKu5o4jLyXAENLRyk1uhi7zkbQ3slBdOA=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
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/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
|
||||
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
|
||||
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
|
||||
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
|
||||
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk=
|
||||
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
|
||||
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
|
||||
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
|
||||
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc=
|
||||
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
|
||||
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA=
|
||||
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
|
||||
github.com/getlantern/systray v1.0.4 h1:qJ/bOlYhn5nsj2FejutWWVFMbhOkYhsChoy26OjgZgU=
|
||||
github.com/getlantern/systray v1.0.4/go.mod h1:AecygODWIsBquJCJFop8MEQcJbWFfw/1yWbVabNgpCM=
|
||||
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68=
|
||||
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/mattn/go-sqlite3 v1.14.0 h1:mLyGNKR8+Vv9CAU7PphKa2hkEqxxhn8i32J6FPj1/QA=
|
||||
github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
|
||||
github.com/mmcdole/gofeed v1.0.0 h1:PHqwr8fsEm8xarj9s53XeEAFYhRM3E9Ib7Ie766/LTE=
|
||||
github.com/mmcdole/gofeed v1.0.0/go.mod h1:tkVcyzS3qVMlQrQxJoEH1hkTiuo9a8emDzkMi7TZBu0=
|
||||
github.com/mmcdole/goxpp v0.0.0-20181012175147-0068e33feabf h1:sWGE2v+hO0Nd4yFU/S/mDBM5plIU8v/Qhfz41hkDIAI=
|
||||
github.com/mmcdole/goxpp v0.0.0-20181012175147-0068e33feabf/go.mod h1:pasqhqstspkosTneA62Nc+2p9SOBBYAPbnmRRWPQ0V8=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
|
||||
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
|
||||
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/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/urfave/cli v1.22.3/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e h1:3G+cUijn7XD+S4eJFddp53Pv7+slrESplyjG25HgL+k=
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9 h1:YTzHMGlqJu67/uEo1lBv0n3wBXhXNeUbB1XfN2vmTm0=
|
||||
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
|
||||
37
hacking.md
@@ -1,37 +0,0 @@
|
||||
# hacking
|
||||
|
||||
If you have any questions/suggestions/proposals,
|
||||
you can reach out the author via e-mail (nkanaev@live.com)
|
||||
or mastodon (https://fosstodon.org/@nkanaev).
|
||||
|
||||
## build
|
||||
|
||||
Install `Go >= 1.14` and `gcc`. Get the source code:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/nkanaev/yarr.git
|
||||
git clone https://github.com/nkanaev/gofeed.git
|
||||
mv gofeed yarr
|
||||
cd yarr
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```sh
|
||||
# create a binary for the host os
|
||||
make build_macos # -> _output/macos/yarr.app
|
||||
make build_linux # -> _output/linux/yarr
|
||||
make build_windows # -> _output/windows/yarr.exe
|
||||
|
||||
# ... or run locally (for testing & hacking)
|
||||
go run main.go # starts a server at http://localhost:7070
|
||||
```
|
||||
|
||||
## plans
|
||||
|
||||
- feeds health checker
|
||||
- Fever API support
|
||||
|
||||
## code of conduct
|
||||
|
||||
Be excellent to each other. Party on, dudes!
|
||||
53
main.go
@@ -1,53 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/nkanaev/yarr/server"
|
||||
"github.com/nkanaev/yarr/storage"
|
||||
"github.com/nkanaev/yarr/platform"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
var Version string = "0.0"
|
||||
var GitHash string = "unknown"
|
||||
|
||||
func main() {
|
||||
var addr, storageFile string
|
||||
var ver bool
|
||||
flag.StringVar(&addr, "addr", "127.0.0.1:7070", "address to run server on")
|
||||
flag.StringVar(&storageFile, "db", "", "storage file path")
|
||||
flag.BoolVar(&ver, "version", false, "print application version")
|
||||
flag.Parse()
|
||||
|
||||
if ver {
|
||||
fmt.Printf("v%s (%s)\n", Version, GitHash)
|
||||
return
|
||||
}
|
||||
|
||||
logger := log.New(os.Stdout, "", log.Ldate|log.Ltime|log.Lshortfile)
|
||||
|
||||
configPath, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
logger.Fatal("Failed to get config dir: ", err)
|
||||
}
|
||||
|
||||
if storageFile == "" {
|
||||
storagePath := filepath.Join(configPath, "yarr")
|
||||
if err := os.MkdirAll(storagePath, 0755); err != nil {
|
||||
logger.Fatal("Failed to create app config dir: ", err)
|
||||
}
|
||||
storageFile = filepath.Join(storagePath, "storage.db")
|
||||
}
|
||||
|
||||
db, err := storage.New(storageFile, logger)
|
||||
if err != nil {
|
||||
logger.Fatal("Failed to initialise database: ", err)
|
||||
}
|
||||
|
||||
srv := server.New(db, logger, addr)
|
||||
logger.Printf("starting server at http://%s", addr)
|
||||
platform.Start(srv)
|
||||
}
|
||||
111
makefile
@@ -1,41 +1,90 @@
|
||||
VERSION=1.1
|
||||
VERSION=2.5
|
||||
GITHASH=$(shell git rev-parse --short=8 HEAD)
|
||||
|
||||
ASSETS = assets/javascripts/* assets/stylesheets/* assets/graphicarts/* assets/index.html
|
||||
CGO_ENABLED=1
|
||||
GO_TAGS = sqlite_foreign_keys sqlite_json
|
||||
GO_LDFLAGS = -s -w -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)'
|
||||
|
||||
GO_LDFLAGS = -s -w
|
||||
GO_LDFLAGS := $(GO_LDFLAGS) -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)'
|
||||
GO_FLAGS = -tags "$(GO_TAGS)" -ldflags="$(GO_LDFLAGS)"
|
||||
GO_FLAGS_DEBUG = -tags "$(GO_TAGS) debug"
|
||||
GO_FLAGS_GUI = -tags "$(GO_TAGS) gui" -ldflags="$(GO_LDFLAGS)"
|
||||
GO_FLAGS_GUI_WIN = -tags "$(GO_TAGS) gui" -ldflags="$(GO_LDFLAGS) -H windowsgui"
|
||||
|
||||
default: bundle
|
||||
export CGO_ENABLED=1
|
||||
|
||||
server/assets.go: $(ASSETS)
|
||||
go run scripts/bundle_assets.go >/dev/null
|
||||
default: test host
|
||||
|
||||
bundle: server/assets.go
|
||||
# platform-specific files
|
||||
|
||||
build_default: bundle
|
||||
mkdir -p _output
|
||||
go build -tags "sqlite_foreign_keys release" -ldflags="$(GO_LDFLAGS)" -o _output/yarr main.go
|
||||
etc/icon.icns: etc/icon_macos.png
|
||||
mkdir -p etc/icon.iconset
|
||||
sips -s format png --resampleWidth 1024 etc/icon_macos.png --out etc/icon.iconset/icon_512x512@2x.png
|
||||
sips -s format png --resampleWidth 512 etc/icon_macos.png --out etc/icon.iconset/icon_512x512.png
|
||||
sips -s format png --resampleWidth 256 etc/icon_macos.png --out etc/icon.iconset/icon_256x256.png
|
||||
sips -s format png --resampleWidth 128 etc/icon_macos.png --out etc/icon.iconset/icon_128x128.png
|
||||
sips -s format png --resampleWidth 64 etc/icon_macos.png --out etc/icon.iconset/icon_32x32@2x.png
|
||||
sips -s format png --resampleWidth 32 etc/icon_macos.png --out etc/icon.iconset/icon_32x32.png
|
||||
sips -s format png --resampleWidth 16 etc/icon_macos.png --out etc/icon.iconset/icon_16x16.png
|
||||
iconutil -c icns etc/icon.iconset -o etc/icon.icns
|
||||
|
||||
build_macos: bundle
|
||||
set GOOS=darwin
|
||||
set GOARCH=amd64
|
||||
mkdir -p _output/macos
|
||||
go build -tags "sqlite_foreign_keys release macos" -ldflags="$(GO_LDFLAGS)" -o _output/macos/yarr main.go
|
||||
cp artwork/icon.png _output/macos/icon.png
|
||||
go run scripts/package_macos.go -outdir _output/macos -version "$(VERSION)"
|
||||
src/platform/versioninfo.rc:
|
||||
./etc/windows_versioninfo.sh -version "$(VERSION)" -outfile src/platform/versioninfo.rc
|
||||
windres -i src/platform/versioninfo.rc -O coff -o src/platform/versioninfo.syso
|
||||
|
||||
build_linux: bundle
|
||||
set GOOS=linux
|
||||
set GOARCH=386
|
||||
mkdir -p _output/linux
|
||||
go build -tags "sqlite_foreign_keys release linux" -ldflags="$(GO_LDFLAGS)" -o _output/linux/yarr main.go
|
||||
# build targets
|
||||
|
||||
build_windows: bundle
|
||||
set GOOS=windows
|
||||
set GOARCH=386
|
||||
mkdir -p _output/windows
|
||||
go run scripts/generate_versioninfo.go -version "$(VERSION)" -outfile artwork/versioninfo.rc
|
||||
windres -i artwork/versioninfo.rc -O coff -o platform/versioninfo.syso
|
||||
go build -tags "sqlite_foreign_keys release windows" -ldflags="$(GO_LDFLAGS) -H windowsgui" -o _output/windows/yarr.exe main.go
|
||||
host:
|
||||
go build $(GO_FLAGS) -o out/yarr ./cmd/yarr
|
||||
|
||||
darwin_amd64:
|
||||
# cross-compilation not supported: CC="zig cc -target x86_64-macos-none"
|
||||
GOOS=darwin GOARCH=arm64 go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
|
||||
|
||||
darwin_arm64:
|
||||
# cross-compilation not supported: CC="zig cc -target aarch64-macos-none"
|
||||
GOOS=darwin GOARCH=arm64 go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
|
||||
|
||||
linux_amd64:
|
||||
CC="zig cc -target x86_64-linux-musl -O2 -g0" CGO_CFLAGS="-D_LARGEFILE64_SOURCE" GOOS=linux GOARCH=amd64 \
|
||||
go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
|
||||
|
||||
linux_arm64:
|
||||
CC="zig cc -target aarch64-linux-musl -O2 -g0" CGO_CFLAGS="-D_LARGEFILE64_SOURCE" GOOS=linux GOARCH=arm64 \
|
||||
go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
|
||||
|
||||
linux_armv7:
|
||||
CC="zig cc -target arm-linux-musleabihf -O2 -g0" CGO_CFLAGS="-D_LARGEFILE64_SOURCE" GOOS=linux GOARCH=arm GOARM=7 \
|
||||
go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
|
||||
|
||||
windows_amd64:
|
||||
CC="zig cc -target x86_64-windows-gnu" GOOS=windows GOARCH=amd64 go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
|
||||
|
||||
windows_arm64:
|
||||
CC="zig cc -target aarch64-windows-gnu" GOOS=windows GOARCH=arm64 go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
|
||||
|
||||
darwin_arm64_gui: etc/icon.icns
|
||||
GOOS=darwin GOARCH=arm64 go build $(GO_FLAGS_GUI) -o out/$@/yarr ./cmd/yarr
|
||||
./etc/macos_package.sh $(VERSION) etc/icon.icns out/$@/yarr out/$@
|
||||
|
||||
darwin_amd64_gui: etc/icon.icns
|
||||
GOOS=darwin GOARCH=amd64 go build $(GO_FLAGS_GUI) -o out/$@/yarr ./cmd/yarr
|
||||
./etc/macos_package.sh $(VERSION) etc/icon.icns out/$@/yarr out/$@
|
||||
|
||||
windows_amd64_gui: src/platform/versioninfo.rc
|
||||
GOOS=windows GOARCH=amd64 go build $(GO_FLAGS_GUI_WIN) -o out/$@/yarr.exe ./cmd/yarr
|
||||
|
||||
windows_arm64_gui: src/platform/versioninfo.rc
|
||||
GOOS=windows GOARCH=arm64 go build $(GO_FLAGS_GUI_WIN) -o out/$@/yarr.exe ./cmd/yarr
|
||||
|
||||
serve:
|
||||
go run $(GO_FLAGS_DEBUG) ./cmd/yarr -db local.db
|
||||
|
||||
test:
|
||||
go test $(GO_FLAGS) ./...
|
||||
|
||||
.PHONY: \
|
||||
host \
|
||||
darwin_amd64 darwin_amd64_gui \
|
||||
darwin_arm64 darwin_arm64_gui \
|
||||
windows_amd64 windows_amd64_gui \
|
||||
windows_arm64 windows_arm64_gui \
|
||||
serve test
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
// +build !windows,!macos
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"github.com/nkanaev/yarr/server"
|
||||
)
|
||||
|
||||
func Start(s *server.Handler) {
|
||||
s.Start()
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
// +build macos
|
||||
|
||||
// File generated by 2goarray v0.1.0 (http://github.com/cratonica/2goarray)
|
||||
|
||||
package platform
|
||||
|
||||
var Icon []byte = []byte{
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
|
||||
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x45, 0x00, 0x00, 0x00, 0x45,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1c, 0x8d, 0x2b, 0x29, 0x00, 0x00, 0x00,
|
||||
0x09, 0x70, 0x48, 0x59, 0x73, 0x00, 0x00, 0x2e, 0x23, 0x00, 0x00, 0x2e,
|
||||
0x23, 0x01, 0x78, 0xa5, 0x3f, 0x76, 0x00, 0x00, 0x00, 0x19, 0x74, 0x45,
|
||||
0x58, 0x74, 0x53, 0x6f, 0x66, 0x74, 0x77, 0x61, 0x72, 0x65, 0x00, 0x77,
|
||||
0x77, 0x77, 0x2e, 0x69, 0x6e, 0x6b, 0x73, 0x63, 0x61, 0x70, 0x65, 0x2e,
|
||||
0x6f, 0x72, 0x67, 0x9b, 0xee, 0x3c, 0x1a, 0x00, 0x00, 0x08, 0x55, 0x49,
|
||||
0x44, 0x41, 0x54, 0x78, 0x9c, 0xed, 0x9c, 0x5d, 0x6c, 0x1c, 0x57, 0x15,
|
||||
0xc7, 0xff, 0x67, 0x76, 0xd6, 0x93, 0xc4, 0x4e, 0xd6, 0x4d, 0x9b, 0x88,
|
||||
0x8f, 0x38, 0xa1, 0x84, 0x7e, 0xe1, 0xc4, 0x08, 0xb6, 0xd8, 0xbe, 0x77,
|
||||
0x97, 0xb0, 0x6d, 0xd3, 0x22, 0x2b, 0xa0, 0x16, 0x42, 0x1d, 0xf5, 0x21,
|
||||
0xa8, 0x25, 0x2f, 0x85, 0x92, 0x87, 0x20, 0x54, 0x5e, 0x90, 0x50, 0x85,
|
||||
0x10, 0x1f, 0xad, 0xa0, 0xa4, 0x91, 0x22, 0x54, 0xa0, 0x5f, 0x2f, 0x4d,
|
||||
0x53, 0x19, 0x22, 0x54, 0xb9, 0x24, 0x4a, 0xb2, 0x75, 0x3c, 0x77, 0x64,
|
||||
0x8c, 0x23, 0x30, 0x98, 0xd4, 0x11, 0x0d, 0x42, 0x76, 0x2b, 0x25, 0x51,
|
||||
0xdd, 0x6e, 0x9c, 0x34, 0xde, 0x31, 0xde, 0xc3, 0xc3, 0xce, 0x46, 0x8e,
|
||||
0x3d, 0x77, 0xf6, 0x6b, 0x66, 0x9d, 0xa2, 0xfc, 0x5e, 0x1c, 0xcf, 0x3d,
|
||||
0xf7, 0xcc, 0x7f, 0x4e, 0xe6, 0xe3, 0xde, 0x73, 0xcf, 0x35, 0x61, 0x09,
|
||||
0xc8, 0x64, 0x32, 0xcb, 0x5c, 0xd7, 0xbd, 0x07, 0xc0, 0xfd, 0x00, 0x3e,
|
||||
0x07, 0xe0, 0x63, 0x00, 0x6e, 0x04, 0xf0, 0x2e, 0x80, 0x77, 0x00, 0x9c,
|
||||
0x04, 0x70, 0xa8, 0xa9, 0xa9, 0xe9, 0x68, 0x36, 0x9b, 0x9d, 0x69, 0xb4,
|
||||
0x3e, 0x6a, 0xe4, 0xc9, 0x92, 0xc9, 0x64, 0xdc, 0xb2, 0xac, 0x47, 0x88,
|
||||
0xe8, 0x09, 0x66, 0xfe, 0x48, 0x05, 0x5d, 0xde, 0x05, 0xf0, 0x64, 0x22,
|
||||
0x91, 0x78, 0xba, 0xbf, 0xbf, 0x3f, 0x1f, 0xb5, 0xbe, 0x12, 0x0d, 0x0b,
|
||||
0x4a, 0x57, 0x57, 0xd7, 0x2d, 0xb1, 0x58, 0xec, 0x10, 0x80, 0x3b, 0x6a,
|
||||
0xe8, 0x3e, 0xc6, 0xcc, 0x0f, 0x38, 0x8e, 0xf3, 0xaf, 0xb0, 0x75, 0xf9,
|
||||
0xd1, 0x90, 0xa0, 0x08, 0x21, 0xbe, 0x48, 0x44, 0x7d, 0x00, 0x56, 0xd7,
|
||||
0xe1, 0x66, 0x8a, 0x99, 0xbf, 0xe6, 0x38, 0xce, 0x1b, 0x61, 0xe9, 0xd2,
|
||||
0x11, 0x79, 0x50, 0x84, 0x10, 0xb7, 0x13, 0x91, 0x03, 0xa0, 0x35, 0x04,
|
||||
0x77, 0x17, 0x88, 0x48, 0xda, 0xb6, 0x3d, 0x16, 0x82, 0x2f, 0x2d, 0x46,
|
||||
0x94, 0xce, 0x3b, 0x3a, 0x3a, 0x9a, 0x89, 0xe8, 0x8f, 0x08, 0x27, 0x20,
|
||||
0x00, 0xb0, 0x8a, 0x99, 0xfb, 0x92, 0xc9, 0xe4, 0x8a, 0x90, 0xfc, 0xf9,
|
||||
0x62, 0x46, 0xe9, 0xbc, 0xb9, 0xb9, 0xf9, 0xbb, 0x00, 0x3e, 0x55, 0xc6,
|
||||
0x6c, 0x02, 0x40, 0x16, 0xc0, 0x79, 0x00, 0x6b, 0x01, 0x64, 0x00, 0xac,
|
||||
0x0b, 0xb0, 0xbf, 0xd5, 0xb2, 0xac, 0x3d, 0x00, 0x7e, 0x1c, 0x82, 0x44,
|
||||
0x5f, 0x22, 0x7b, 0x7c, 0x3a, 0x3b, 0x3b, 0x6f, 0x34, 0x4d, 0xf3, 0x0c,
|
||||
0x80, 0x55, 0x1a, 0x93, 0xf3, 0x44, 0xb4, 0xdb, 0xb6, 0xed, 0x57, 0x00,
|
||||
0xf0, 0x7c, 0x4d, 0x52, 0xca, 0x1d, 0x00, 0x9e, 0x01, 0x70, 0x93, 0xa6,
|
||||
0xef, 0x05, 0x66, 0xbe, 0xd9, 0x71, 0x9c, 0xa9, 0x10, 0x25, 0x5f, 0x21,
|
||||
0xb2, 0xc7, 0x27, 0x1e, 0x8f, 0x7f, 0x15, 0xfa, 0x80, 0x9c, 0x05, 0x20,
|
||||
0x6c, 0xdb, 0x3e, 0x80, 0xab, 0x03, 0x02, 0x00, 0xac, 0x94, 0x7a, 0x19,
|
||||
0x40, 0x37, 0x80, 0x73, 0x9a, 0xfe, 0xab, 0x88, 0xe8, 0xfe, 0x70, 0x94,
|
||||
0x2e, 0x26, 0xb2, 0xa0, 0x30, 0xb3, 0x56, 0x34, 0x33, 0x3f, 0xac, 0x94,
|
||||
0x7a, 0x2b, 0xa8, 0xbf, 0xd7, 0xfe, 0xcd, 0x00, 0x93, 0x07, 0x6a, 0xd5,
|
||||
0x56, 0x8e, 0x28, 0x5f, 0xb4, 0xdd, 0x9a, 0xe3, 0x27, 0x1d, 0xc7, 0x79,
|
||||
0xbd, 0x12, 0x07, 0x4a, 0xa9, 0xd7, 0x00, 0xfc, 0xb5, 0x4a, 0xff, 0x75,
|
||||
0x13, 0x49, 0x50, 0x7a, 0x7a, 0x7a, 0x2c, 0x14, 0x87, 0xed, 0x7e, 0x54,
|
||||
0x14, 0x90, 0x12, 0x44, 0xa4, 0xb3, 0x5f, 0xd3, 0xde, 0xde, 0xde, 0x54,
|
||||
0x95, 0xb0, 0x0a, 0x89, 0x24, 0x28, 0x53, 0x53, 0x53, 0x6b, 0xa0, 0x79,
|
||||
0x89, 0x33, 0xf3, 0x3b, 0x55, 0xba, 0x7b, 0x5b, 0x73, 0x9c, 0x5a, 0x5a,
|
||||
0x5a, 0xd6, 0x56, 0xe9, 0xab, 0x22, 0x22, 0x09, 0x8a, 0x69, 0x9a, 0x39,
|
||||
0x5d, 0x1b, 0x11, 0x55, 0x35, 0x66, 0x61, 0x66, 0xad, 0xfd, 0xf2, 0xe5,
|
||||
0xcb, 0xdf, 0xaf, 0xc6, 0x57, 0xa5, 0x44, 0x12, 0x14, 0xdb, 0xb6, 0xa7,
|
||||
0x01, 0x4c, 0xfb, 0xb5, 0x31, 0x73, 0xaa, 0x4a, 0x77, 0x5f, 0xd0, 0x1c,
|
||||
0xcf, 0x65, 0xb3, 0xd9, 0x8b, 0x55, 0xfa, 0xaa, 0x88, 0x28, 0x5f, 0xb4,
|
||||
0xbe, 0x43, 0x71, 0x22, 0xba, 0x37, 0x95, 0x4a, 0xdd, 0x56, 0x89, 0x83,
|
||||
0xee, 0xee, 0xee, 0x3b, 0x00, 0xdc, 0x5d, 0x8d, 0xff, 0x30, 0x88, 0xf2,
|
||||
0x93, 0x7c, 0x48, 0xd3, 0x64, 0x32, 0xf3, 0x73, 0x99, 0x4c, 0x66, 0x59,
|
||||
0x50, 0xff, 0x4c, 0x26, 0xb3, 0x8c, 0x88, 0x9e, 0x83, 0x7e, 0xd4, 0xad,
|
||||
0xf3, 0x5f, 0x37, 0x91, 0x05, 0x25, 0x16, 0x8b, 0xf5, 0x01, 0x28, 0x68,
|
||||
0x9a, 0x85, 0xeb, 0xba, 0x87, 0xb7, 0x6c, 0xd9, 0xd2, 0xe6, 0xd7, 0x98,
|
||||
0x4e, 0xa7, 0xd7, 0xbb, 0xae, 0x7b, 0x84, 0x88, 0xba, 0x34, 0xfd, 0xe7,
|
||||
0x98, 0xb9, 0x2f, 0x14, 0xa1, 0x3e, 0x44, 0x3a, 0x4b, 0x4e, 0xa5, 0x52,
|
||||
0x2f, 0x30, 0xf3, 0x37, 0x02, 0x4c, 0x2e, 0x33, 0xf3, 0x2b, 0x44, 0x34,
|
||||
0x02, 0x60, 0x39, 0x80, 0xcb, 0x44, 0x74, 0x27, 0x33, 0xf7, 0x02, 0x08,
|
||||
0xba, 0x93, 0x9e, 0x57, 0x4a, 0x3d, 0x12, 0xaa, 0xd8, 0x79, 0x44, 0x1a,
|
||||
0x14, 0x29, 0xe5, 0x06, 0x66, 0x1e, 0x23, 0xa2, 0xe6, 0x10, 0xdd, 0x5e,
|
||||
0x34, 0x4d, 0xf3, 0xd3, 0x03, 0x03, 0x03, 0x13, 0x21, 0xfa, 0xbc, 0x8a,
|
||||
0x48, 0x53, 0x07, 0x4a, 0xa9, 0xff, 0x00, 0xd8, 0x09, 0xfd, 0x63, 0x54,
|
||||
0x2d, 0x4c, 0x44, 0xbb, 0xa2, 0x0c, 0x08, 0x00, 0xc4, 0xa2, 0x74, 0x0e,
|
||||
0x00, 0x93, 0x93, 0x93, 0x6f, 0xb6, 0xb5, 0xb5, 0xcd, 0x00, 0xd8, 0x8a,
|
||||
0xfa, 0xee, 0x4c, 0x26, 0xa2, 0xef, 0xdb, 0xb6, 0xfd, 0x6c, 0x48, 0xd2,
|
||||
0xb4, 0x44, 0x1e, 0x14, 0x00, 0x98, 0x98, 0x98, 0xb0, 0xd7, 0xaf, 0x5f,
|
||||
0x7f, 0x0a, 0xc0, 0x36, 0x00, 0xf1, 0x1a, 0x5c, 0xcc, 0x00, 0xd8, 0xa5,
|
||||
0x94, 0xda, 0x1f, 0xae, 0x32, 0x7f, 0x1a, 0x12, 0x14, 0x00, 0x98, 0x98,
|
||||
0x98, 0x18, 0x5b, 0xb7, 0x6e, 0x5d, 0x1f, 0x11, 0xb5, 0x01, 0xb8, 0xbd,
|
||||
0x8a, 0xae, 0x7f, 0x20, 0xa2, 0xaf, 0x2b, 0xa5, 0x8e, 0x45, 0xa5, 0x6d,
|
||||
0x21, 0x0d, 0x5d, 0xe2, 0x28, 0x21, 0x84, 0xe8, 0x34, 0x0c, 0xe3, 0x21,
|
||||
0x66, 0x7e, 0x14, 0xfe, 0x5f, 0x99, 0x19, 0x66, 0xde, 0x0f, 0xe0, 0x65,
|
||||
0xc7, 0x71, 0xfe, 0xdc, 0x60, 0x79, 0x4b, 0x13, 0x94, 0x12, 0x52, 0xca,
|
||||
0x13, 0x00, 0xd2, 0x3e, 0x4d, 0x83, 0x4a, 0x29, 0xdd, 0xf0, 0x3e, 0x72,
|
||||
0x22, 0xfd, 0xfa, 0x7c, 0x58, 0xb9, 0x1e, 0x14, 0x1f, 0xae, 0x07, 0xc5,
|
||||
0x87, 0xeb, 0x41, 0xf1, 0xe1, 0x7a, 0x50, 0x7c, 0x30, 0xa5, 0x94, 0x7f,
|
||||
0x22, 0xa2, 0xaa, 0x82, 0xc3, 0xcc, 0x13, 0xcc, 0xfc, 0x4b, 0xc7, 0x71,
|
||||
0xfe, 0x1e, 0x95, 0xb0, 0x7a, 0x11, 0x42, 0x6c, 0x26, 0xa2, 0x3d, 0xde,
|
||||
0xb8, 0xa8, 0x62, 0x98, 0xb9, 0x60, 0x02, 0xd8, 0xc2, 0xcc, 0x81, 0xb9,
|
||||
0x0d, 0x3f, 0x88, 0x68, 0x87, 0x94, 0x32, 0xa3, 0x94, 0x1a, 0xae, 0xb6,
|
||||
0x6f, 0xd4, 0x48, 0x29, 0x3f, 0x8f, 0xe2, 0xaa, 0xe3, 0x0a, 0xe6, 0x85,
|
||||
0xcb, 0x4a, 0x65, 0xb9, 0x6c, 0x00, 0xa8, 0xb5, 0xee, 0x63, 0x05, 0x33,
|
||||
0x3f, 0x51, 0x63, 0xdf, 0x48, 0xf1, 0x74, 0xd5, 0xba, 0xde, 0x9c, 0x37,
|
||||
0x00, 0xb8, 0xb5, 0x9e, 0x9c, 0x88, 0x6a, 0xa9, 0x35, 0x89, 0x9c, 0x3a,
|
||||
0x75, 0xe5, 0xeb, 0xb9, 0x53, 0xc0, 0xcc, 0xa7, 0xea, 0x38, 0x79, 0x64,
|
||||
0xd4, 0xa9, 0xcb, 0xad, 0xe7, 0x4e, 0xf9, 0x80, 0x88, 0x7e, 0x58, 0xc7,
|
||||
0xc9, 0x23, 0xc3, 0xd3, 0xf5, 0x41, 0x8d, 0xdd, 0xf3, 0x26, 0xf4, 0x77,
|
||||
0xca, 0x7b, 0x5e, 0x9a, 0x70, 0x11, 0xd7, 0xfa, 0xd7, 0x47, 0x29, 0x35,
|
||||
0x2c, 0x84, 0xe8, 0x0e, 0xfa, 0xfa, 0x30, 0x73, 0x12, 0xc0, 0x0d, 0x3e,
|
||||
0x4d, 0x79, 0x13, 0xc5, 0xba, 0x10, 0xbf, 0x67, 0xf0, 0xac, 0x6d, 0xdb,
|
||||
0xf7, 0x86, 0xa8, 0xb5, 0xa1, 0x78, 0xff, 0x61, 0xda, 0x05, 0x7a, 0x29,
|
||||
0xe5, 0x69, 0xf8, 0x07, 0xe5, 0x9c, 0x01, 0x60, 0x5c, 0xd3, 0x6f, 0x63,
|
||||
0x32, 0x99, 0xac, 0x25, 0x21, 0x74, 0xcd, 0xe3, 0x5d, 0xd7, 0x27, 0xfc,
|
||||
0xda, 0x98, 0x79, 0xdc, 0x00, 0xf0, 0xa6, 0xa6, 0x6f, 0xdc, 0xb2, 0xac,
|
||||
0xf6, 0xa8, 0x84, 0x2d, 0x25, 0xf1, 0x78, 0x7c, 0x33, 0xf4, 0x19, 0xc0,
|
||||
0x71, 0x83, 0x99, 0xb5, 0x2b, 0x6d, 0x44, 0x74, 0x57, 0x34, 0xb2, 0x96,
|
||||
0x16, 0x22, 0xd2, 0xad, 0x3a, 0xc2, 0x30, 0x8c, 0x31, 0xc3, 0x30, 0x0c,
|
||||
0x05, 0x60, 0xd6, 0xcf, 0x80, 0x99, 0xb5, 0x9d, 0x3f, 0xe4, 0xe8, 0xae,
|
||||
0xcb, 0x9d, 0x9e, 0x9e, 0x56, 0x86, 0xb7, 0x18, 0xfe, 0x17, 0x8d, 0xd1,
|
||||
0xd6, 0x4c, 0x26, 0x13, 0x56, 0x65, 0xe3, 0x35, 0x81, 0x10, 0x62, 0x75,
|
||||
0xc0, 0x9d, 0x32, 0x34, 0x3a, 0x3a, 0x7a, 0xa9, 0x34, 0x11, 0x3c, 0xaa,
|
||||
0x31, 0x5a, 0xe6, 0xba, 0x6e, 0x6f, 0x04, 0xda, 0x96, 0x0c, 0xc3, 0x30,
|
||||
0x7a, 0x01, 0x58, 0x9a, 0xe6, 0x63, 0x80, 0x97, 0x3a, 0x98, 0x9b, 0x9b,
|
||||
0x3b, 0x18, 0xe0, 0x67, 0x57, 0xc8, 0xba, 0x96, 0x14, 0x66, 0xd6, 0x5e,
|
||||
0x0f, 0x11, 0x1d, 0x04, 0xbc, 0xa0, 0x0c, 0x0d, 0x0d, 0x8d, 0x02, 0xf8,
|
||||
0x9b, 0xc6, 0xb6, 0x53, 0x4a, 0xf9, 0x7f, 0xf1, 0x6e, 0x91, 0x52, 0xde,
|
||||
0x07, 0xe0, 0x4e, 0x4d, 0xf3, 0xc9, 0x52, 0x25, 0xf7, 0xfc, 0x3c, 0xca,
|
||||
0x8b, 0x01, 0xfe, 0x7e, 0x10, 0x96, 0xb0, 0x25, 0x46, 0x7b, 0x1d, 0xcc,
|
||||
0xfc, 0x52, 0xe9, 0xdf, 0x57, 0x82, 0x92, 0xcf, 0xe7, 0x7f, 0x0b, 0x40,
|
||||
0x57, 0x96, 0x75, 0x97, 0x94, 0x32, 0xb2, 0xba, 0xd5, 0x46, 0x20, 0xa5,
|
||||
0xdc, 0x0e, 0x7d, 0x55, 0xd4, 0x85, 0x58, 0x2c, 0xf6, 0x42, 0xe9, 0x97,
|
||||
0x2b, 0x41, 0x19, 0x19, 0x19, 0xc9, 0x01, 0xf8, 0x75, 0x80, 0xdf, 0xbd,
|
||||
0x1d, 0x1d, 0x1d, 0x61, 0x56, 0x0f, 0x34, 0x0c, 0xaf, 0x96, 0xff, 0xa9,
|
||||
0x00, 0x93, 0x67, 0x06, 0x07, 0x07, 0xdf, 0x2b, 0xfd, 0x72, 0x55, 0x1a,
|
||||
0x72, 0x6e, 0x6e, 0xee, 0x17, 0x00, 0x2e, 0x6b, 0x3a, 0xae, 0x5f, 0xb9,
|
||||
0x72, 0xe5, 0xcf, 0xea, 0x97, 0xd8, 0x78, 0x2c, 0xcb, 0x7a, 0x0a, 0xfa,
|
||||
0x61, 0xfd, 0xa5, 0x58, 0x2c, 0xf6, 0xab, 0xf9, 0xc7, 0xae, 0x0a, 0xca,
|
||||
0xd0, 0xd0, 0xd0, 0x59, 0x00, 0x3f, 0xd7, 0x39, 0x67, 0xe6, 0xc7, 0x84,
|
||||
0x10, 0x0f, 0x85, 0xa0, 0xb3, 0x61, 0x08, 0x21, 0x1e, 0x04, 0xf0, 0x2d,
|
||||
0x5d, 0x3b, 0x11, 0xfd, 0xe4, 0xc4, 0x89, 0x13, 0xe7, 0xe7, 0x1f, 0x5b,
|
||||
0x94, 0xb0, 0x6e, 0x6a, 0x6a, 0xfa, 0x29, 0x00, 0x6d, 0x89, 0x38, 0x11,
|
||||
0xed, 0x97, 0x52, 0x6e, 0xaa, 0x47, 0x68, 0xa3, 0xf0, 0x92, 0xd7, 0xbf,
|
||||
0x09, 0x30, 0x39, 0x9d, 0x48, 0x24, 0x16, 0x3d, 0x56, 0x8b, 0x82, 0x92,
|
||||
0xcd, 0x66, 0x67, 0x88, 0x68, 0x77, 0x80, 0xa3, 0x04, 0x80, 0xd7, 0xa5,
|
||||
0x94, 0x1b, 0x6a, 0xd0, 0xd9, 0x30, 0xa4, 0x94, 0x1b, 0xbc, 0x6a, 0x6d,
|
||||
0xdd, 0xa6, 0x09, 0x26, 0xa2, 0xef, 0xf8, 0xed, 0x4d, 0xf4, 0x5d, 0xda,
|
||||
0xb0, 0x6d, 0xbb, 0x9f, 0x99, 0xf7, 0x05, 0x9c, 0xf3, 0xe3, 0x00, 0x8e,
|
||||
0x5c, 0xab, 0x81, 0xf1, 0x74, 0x1d, 0x41, 0x71, 0x17, 0xab, 0x8e, 0xbd,
|
||||
0xb6, 0x6d, 0x1f, 0xf1, 0x6b, 0xd0, 0xae, 0xf7, 0xb4, 0xb6, 0xb6, 0x7e,
|
||||
0x0f, 0x80, 0x6f, 0xe6, 0xcd, 0xe3, 0x16, 0x00, 0x4e, 0x3a, 0x9d, 0xfe,
|
||||
0x4c, 0x25, 0x42, 0x1b, 0x45, 0x2a, 0x95, 0x6a, 0x07, 0x30, 0x88, 0xa2,
|
||||
0x3e, 0x1d, 0xc3, 0xb9, 0x5c, 0xee, 0x71, 0x5d, 0xa3, 0x36, 0x28, 0xfd,
|
||||
0xfd, 0xfd, 0x79, 0x6f, 0x9e, 0x70, 0x5e, 0x67, 0x03, 0xe0, 0xa3, 0x85,
|
||||
0x42, 0xe1, 0x0d, 0x6f, 0x0c, 0xb0, 0xe4, 0xa4, 0x52, 0xa9, 0x5e, 0x66,
|
||||
0x56, 0x08, 0xde, 0x59, 0x76, 0xce, 0x34, 0xcd, 0x1d, 0x63, 0x63, 0x63,
|
||||
0xda, 0xdc, 0x74, 0xe0, 0xca, 0xe0, 0xe0, 0xe0, 0xe0, 0x19, 0x14, 0x4b,
|
||||
0xb2, 0x82, 0xca, 0xbd, 0x13, 0x00, 0x5e, 0x4d, 0xa5, 0x52, 0xfb, 0xa2,
|
||||
0xde, 0xdb, 0xa7, 0xa3, 0xa3, 0xa3, 0xa3, 0x59, 0x08, 0xb1, 0x9f, 0x99,
|
||||
0x0f, 0x40, 0xff, 0x0e, 0x01, 0x80, 0x8b, 0x44, 0xb4, 0x6d, 0x60, 0x60,
|
||||
0xe0, 0xdf, 0x41, 0xfe, 0xca, 0x2e, 0x97, 0x7a, 0x2b, 0x80, 0xdb, 0x51,
|
||||
0x26, 0xeb, 0xcf, 0xcc, 0x8f, 0x59, 0x96, 0xf5, 0x4f, 0x21, 0x44, 0x64,
|
||||
0x9b, 0x93, 0xfc, 0x90, 0x52, 0x6e, 0x6f, 0x69, 0x69, 0x39, 0x45, 0x44,
|
||||
0x8f, 0x96, 0x31, 0x75, 0x01, 0x6c, 0xb7, 0x6d, 0x5b, 0x97, 0x26, 0xb9,
|
||||
0x42, 0x45, 0x6b, 0xc8, 0x4a, 0xa9, 0xc3, 0x00, 0xbe, 0x82, 0xe0, 0x3b,
|
||||
0x06, 0x00, 0x36, 0x10, 0xd1, 0xef, 0x85, 0x10, 0xc7, 0xbb, 0xbb, 0xbb,
|
||||
0xb7, 0x56, 0xe2, 0xbb, 0x56, 0xa4, 0x94, 0xf7, 0x49, 0x29, 0x07, 0x00,
|
||||
0xbc, 0x0a, 0xa0, 0xdc, 0x7a, 0xf1, 0x45, 0x22, 0xfa, 0xb2, 0x77, 0x1d,
|
||||
0x65, 0xa9, 0x78, 0xb7, 0xa9, 0x52, 0xea, 0xb0, 0x37, 0x5b, 0x7e, 0x0d,
|
||||
0xc0, 0x9a, 0x20, 0x5b, 0x22, 0xca, 0x10, 0x51, 0x46, 0x4a, 0x39, 0x4c,
|
||||
0x44, 0xbf, 0x23, 0xa2, 0x03, 0xf3, 0x87, 0xd1, 0xb5, 0x22, 0x84, 0x58,
|
||||
0x6d, 0x18, 0x46, 0xaf, 0x37, 0xfd, 0xd7, 0xcd, 0x76, 0x17, 0x72, 0x8e,
|
||||
0x88, 0xb6, 0x55, 0x72, 0x87, 0x94, 0xa8, 0xba, 0xe6, 0x2d, 0x9d, 0x4e,
|
||||
0x7f, 0xb2, 0x50, 0x28, 0x1c, 0xa8, 0x42, 0x14, 0x00, 0xe4, 0x99, 0xf9,
|
||||
0x18, 0x11, 0x1d, 0x65, 0xe6, 0x63, 0xae, 0xeb, 0xfe, 0x63, 0x64, 0x64,
|
||||
0x64, 0xb6, 0x5c, 0xcd, 0x5b, 0x32, 0x99, 0x8c, 0xc7, 0xe3, 0xf1, 0xcd,
|
||||
0x5e, 0xa6, 0xec, 0x6e, 0xef, 0xa7, 0x2e, 0x41, 0xe4, 0xc7, 0xb0, 0x69,
|
||||
0x9a, 0x3b, 0xca, 0xbd, 0x43, 0x16, 0x52, 0x53, 0x21, 0x60, 0x4f, 0x4f,
|
||||
0x8f, 0x95, 0xcb, 0xe5, 0x9e, 0x04, 0x10, 0x34, 0xc8, 0x0b, 0xe2, 0xbf,
|
||||
0x00, 0xce, 0xa0, 0xb8, 0xc5, 0xd6, 0x6f, 0xab, 0xff, 0x14, 0x8a, 0x7f,
|
||||
0xfc, 0xe1, 0x66, 0xd4, 0xb6, 0x77, 0x9a, 0x01, 0xec, 0xcd, 0xe5, 0x72,
|
||||
0x8f, 0x07, 0x7d, 0x65, 0x74, 0xd4, 0x55, 0x1d, 0x29, 0x84, 0xf8, 0x12,
|
||||
0x11, 0xed, 0x43, 0xf9, 0x0d, 0xd9, 0x8d, 0xe4, 0x34, 0x80, 0xdd, 0x95,
|
||||
0xbe, 0x3f, 0xfc, 0xa8, 0xab, 0xb8, 0x78, 0x72, 0x72, 0xf2, 0xad, 0x8d,
|
||||
0x1b, 0x37, 0x3e, 0x5b, 0x28, 0x14, 0x66, 0x01, 0x74, 0xa1, 0xb6, 0x6a,
|
||||
0xea, 0x50, 0x60, 0xe6, 0x4b, 0x44, 0xf4, 0xa3, 0x44, 0x22, 0xb1, 0xf3,
|
||||
0xf8, 0xf1, 0xe3, 0xa7, 0xeb, 0xf1, 0x15, 0x5a, 0x1d, 0xad, 0x94, 0x72,
|
||||
0x2d, 0x11, 0xed, 0x61, 0xe6, 0x6f, 0x23, 0x78, 0xac, 0x10, 0x36, 0x17,
|
||||
0x00, 0xec, 0x8b, 0xc5, 0x62, 0x4f, 0x2f, 0x9c, 0xed, 0xd6, 0x4a, 0xe8,
|
||||
0xc5, 0xc5, 0xe9, 0x74, 0xfa, 0x06, 0x66, 0x7e, 0x98, 0x99, 0x77, 0x02,
|
||||
0xf8, 0x6c, 0xd8, 0xfe, 0xe7, 0x71, 0x92, 0x99, 0x5f, 0xb2, 0x2c, 0xeb,
|
||||
0xf9, 0x6c, 0x36, 0x1b, 0xea, 0x06, 0xcb, 0xa8, 0xf7, 0xfb, 0x6c, 0x62,
|
||||
0xe6, 0x07, 0x0d, 0xc3, 0xb8, 0x87, 0x99, 0x3b, 0x51, 0xdf, 0xe3, 0x35,
|
||||
0x0b, 0x60, 0x08, 0xc0, 0x51, 0x22, 0x3a, 0x18, 0xe5, 0x9f, 0x0b, 0x69,
|
||||
0x58, 0x19, 0x7a, 0x26, 0x93, 0x69, 0x99, 0x9d, 0x9d, 0x15, 0x00, 0x36,
|
||||
0x01, 0xb8, 0x8d, 0x99, 0x6f, 0x45, 0x71, 0x16, 0x7b, 0x13, 0x11, 0xb5,
|
||||
0x32, 0x73, 0x8c, 0x88, 0xe6, 0x98, 0xf9, 0x7d, 0x14, 0xbf, 0x3c, 0x6f,
|
||||
0x33, 0xf3, 0x38, 0x80, 0x71, 0xc3, 0x30, 0xc6, 0xa6, 0xa7, 0xa7, 0xd5,
|
||||
0xe8, 0xe8, 0xe8, 0xa5, 0x46, 0x68, 0xfd, 0x1f, 0x32, 0x61, 0xc5, 0x44,
|
||||
0x9c, 0xe8, 0x1b, 0xc4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44,
|
||||
0xae, 0x42, 0x60, 0x82,
|
||||
}
|
||||
|
||||
8828
platform/icon_win.go
36
readme.md
@@ -1,16 +1,38 @@
|
||||
# yarr
|
||||
|
||||
yet another rss reader.
|
||||
**yarr** (yet another rss reader) is a web-based feed aggregator which can be used both
|
||||
as a desktop application and a personal self-hosted server.
|
||||
|
||||

|
||||
The app is a single binary with an embedded database (SQLite).
|
||||
|
||||
*yarr* is a server written in Go with the frontend in Vue.js. The storage is backed by SQLite.
|
||||

|
||||
|
||||
The goal of the project is to provide a desktop application accessible via web browser.
|
||||
Longer-term plans include a self-hosted solution for individuals.
|
||||
Support for 3rd-party applications (via Fever API) is being considered.
|
||||
## usage
|
||||
|
||||
[download](https://github.com/nkanaev/yarr/releases/latest)
|
||||
The latest prebuilt binaries for Linux/MacOS/Windows are available
|
||||
[here](https://github.com/nkanaev/yarr/releases/latest).
|
||||
The archives follow the naming convention `yarr_{OS}_{ARCH}[_gui].zip`, where:
|
||||
|
||||
* `OS` is the target operating system
|
||||
* `ARCH` is the CPU architecture (`arm64` for AArch64, `amd64` for X86-64)
|
||||
* `-gui` indicates that the binary ships with the GUI (tray icon), and is a command line application if omitted
|
||||
|
||||
Usage instructions:
|
||||
|
||||
* MacOS: place `yarr.app` in `/Applications` folder, [open the app][macos-open], click the anchor menu bar icon, select "Open".
|
||||
|
||||
* Windows: open `yarr.exe`, click the anchor system tray icon, select "Open".
|
||||
|
||||
* Linux: place `yarr` in `$HOME/.local/bin` and run [the script](etc/install-linux.sh).
|
||||
|
||||
[macos-open]: https://support.apple.com/en-gb/guide/mac-help/mh40616/mac
|
||||
|
||||
For self-hosting, see `yarr -h` for auth, tls & server configuration flags.
|
||||
|
||||
See more:
|
||||
|
||||
* [Building from source code](doc/build.md)
|
||||
* [Fever API support](doc/fever.md)
|
||||
|
||||
## credits
|
||||
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
htemplate "html/template"
|
||||
)
|
||||
|
||||
var code_template = `// +build release
|
||||
|
||||
// autogenerated. do not edit!
|
||||
|
||||
package server
|
||||
|
||||
var assets_bundle = map[string]asset{
|
||||
{{- range .}}
|
||||
"{{.Name}}": {etag: "{{.Etag}}", body: "{{.Body}}"},
|
||||
{{- end }}
|
||||
}
|
||||
|
||||
func init() {
|
||||
assets = assets_bundle
|
||||
}
|
||||
`
|
||||
|
||||
type asset struct {
|
||||
Name, Etag, Body string
|
||||
}
|
||||
|
||||
func shasum(b []byte) string {
|
||||
h := sha256.New()
|
||||
h.Write(b)
|
||||
return fmt.Sprintf("%x", h.Sum(nil))[:16]
|
||||
}
|
||||
|
||||
func encode(b []byte) string {
|
||||
var buf bytes.Buffer
|
||||
zw := gzip.NewWriter(&buf)
|
||||
zw.Write(b)
|
||||
zw.Close()
|
||||
return base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
}
|
||||
|
||||
func main() {
|
||||
assets := make([]asset, 0)
|
||||
filepatterns := []string{
|
||||
"assets/graphicarts/*.svg",
|
||||
"assets/graphicarts/*.png",
|
||||
"assets/javascripts/*.js",
|
||||
"assets/stylesheets/*.css",
|
||||
"assets/stylesheets/*.map",
|
||||
}
|
||||
fmt.Printf("%8s %8s %s\n", "original", "encoded", "filename")
|
||||
for _, pattern := range filepatterns {
|
||||
filenames, _ := filepath.Glob(pattern)
|
||||
for _, filename := range filenames {
|
||||
content, _ := ioutil.ReadFile(filename)
|
||||
assets = append(assets, asset{
|
||||
Name: strings.TrimPrefix(strings.ReplaceAll(filename, "\\", "/"), "assets/"),
|
||||
Etag: shasum(content),
|
||||
Body: encode(content),
|
||||
})
|
||||
fmt.Printf(
|
||||
"%8d %8d %s\n",
|
||||
len(content),
|
||||
len(assets[len(assets)-1].Body),
|
||||
filename,
|
||||
)
|
||||
}
|
||||
}
|
||||
var indexbuf bytes.Buffer
|
||||
htemplate.Must(htemplate.New("index.html").Delims("{%", "%}").Funcs(htemplate.FuncMap{
|
||||
"inline": func(svg string) htemplate.HTML {
|
||||
content, _ := ioutil.ReadFile("assets/graphicarts/" + svg)
|
||||
return htemplate.HTML(content)
|
||||
},
|
||||
}).ParseFiles("assets/index.html")).Execute(&indexbuf, nil)
|
||||
indexcontent := indexbuf.Bytes()
|
||||
assets = append(assets, asset{
|
||||
Name: "index.html",
|
||||
Etag: shasum(indexcontent),
|
||||
Body: encode(indexcontent),
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
template := template.Must(template.New("code").Parse(code_template))
|
||||
template.Execute(&buf, assets)
|
||||
ioutil.WriteFile("server/assets.go", buf.Bytes(), 0644)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"flag"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var rsrc = `1 VERSIONINFO
|
||||
FILEVERSION {VERSION_COMMA},0,0
|
||||
PRODUCTVERSION {VERSION_COMMA},0,0
|
||||
BEGIN
|
||||
BLOCK "StringFileInfo"
|
||||
BEGIN
|
||||
BLOCK "080904E4"
|
||||
BEGIN
|
||||
VALUE "CompanyName", "Old MacDonald's Farm"
|
||||
VALUE "FileDescription", "Yet another RSS reader"
|
||||
VALUE "FileVersion", "{VERSION}"
|
||||
VALUE "InternalName", "yarr"
|
||||
VALUE "LegalCopyright", "nkanaev"
|
||||
VALUE "OriginalFilename", "yarr.exe"
|
||||
VALUE "ProductName", "yarr"
|
||||
VALUE "ProductVersion", "{VERSION}"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
BEGIN
|
||||
VALUE "Translation", 0x809, 1252
|
||||
END
|
||||
END
|
||||
|
||||
1 ICON "icon.ico"
|
||||
`
|
||||
|
||||
func main() {
|
||||
var version, outfile string
|
||||
flag.StringVar(&version, "version", "0.0", "")
|
||||
flag.StringVar(&outfile, "outfile", "versioninfo.rc", "")
|
||||
flag.Parse()
|
||||
|
||||
version_comma := strings.ReplaceAll(version, ".", ",")
|
||||
|
||||
out := strings.ReplaceAll(rsrc, "{VERSION}", version)
|
||||
out = strings.ReplaceAll(out, "{VERSION_COMMA}", version_comma)
|
||||
|
||||
ioutil.WriteFile(outfile, []byte(out), 0644)
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var plist = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>yarr</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>yarr</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>nkanaev.yarr</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>VERSION</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>yarr</string>
|
||||
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icon</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.news</string>
|
||||
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>True</string>
|
||||
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2020 nkanaev. All rights reserved.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
`
|
||||
|
||||
func run(cmd ...string) {
|
||||
fmt.Println(cmd)
|
||||
err := exec.Command(cmd[0], cmd[1:]...).Run()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
var version, outdir string
|
||||
flag.StringVar(&version, "version", "0.0", "")
|
||||
flag.StringVar(&outdir, "outdir", "", "")
|
||||
flag.Parse()
|
||||
|
||||
outfile := "yarr"
|
||||
|
||||
binDir := path.Join(outdir, "yarr.app", "Contents/MacOS")
|
||||
resDir := path.Join(outdir, "yarr.app", "Contents/Resources")
|
||||
|
||||
plistFile := path.Join(outdir, "yarr.app", "Contents/Info.plist")
|
||||
pkginfoFile := path.Join(outdir, "yarr.app", "Contents/PkgInfo")
|
||||
|
||||
os.MkdirAll(binDir, 0700)
|
||||
os.MkdirAll(resDir, 0700)
|
||||
|
||||
f, _ := ioutil.ReadFile(path.Join(outdir, outfile))
|
||||
ioutil.WriteFile(path.Join(binDir, outfile), f, 0755)
|
||||
|
||||
ioutil.WriteFile(plistFile, []byte(strings.Replace(plist, "VERSION", version, 1)), 0644)
|
||||
ioutil.WriteFile(pkginfoFile, []byte("APPL????"), 0644)
|
||||
|
||||
iconFile := path.Join(outdir, "icon.png")
|
||||
iconsetDir := path.Join(outdir, "icon.iconset")
|
||||
os.Mkdir(iconsetDir, 0700)
|
||||
|
||||
for _, res := range []int{1024, 512, 256, 128, 64, 32, 16} {
|
||||
outfile := fmt.Sprintf("icon_%dx%d.png", res, res)
|
||||
if res == 1024 || res == 64 {
|
||||
outfile = fmt.Sprintf("icon_%dx%d@2x.png", res / 2, res / 2)
|
||||
}
|
||||
cmd := []string {
|
||||
"sips", "-s", "format", "png", "--resampleWidth", strconv.Itoa(res),
|
||||
iconFile, "--out", path.Join(iconsetDir, outfile),
|
||||
}
|
||||
run(cmd...)
|
||||
}
|
||||
|
||||
icnsFile := path.Join(resDir, "icon.icns")
|
||||
run("iconutil", "-c", "icns", iconsetDir, "-o", icnsFile)
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/mmcdole/gofeed"
|
||||
"github.com/nkanaev/yarr/storage"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FeedSource struct {
|
||||
Title string `json:"title"`
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
const feedLinks = `
|
||||
link[type='application/rss+xml'],
|
||||
link[type='application/atom+xml'],
|
||||
a[href$="/feed"],
|
||||
a[href$="/feed/"],
|
||||
a[href$="feed.xml"],
|
||||
a[href$="atom.xml"],
|
||||
a[href$="rss.xml"],
|
||||
a:contains("rss"),
|
||||
a:contains("RSS"),
|
||||
a:contains("feed"),
|
||||
a:contains("FEED")
|
||||
`
|
||||
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
userAgent string
|
||||
}
|
||||
|
||||
func (c *Client) get(url string) (*http.Response, error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", c.userAgent)
|
||||
return c.httpClient.Do(req)
|
||||
}
|
||||
|
||||
var defaultClient *Client
|
||||
|
||||
func searchFeedLinks(html []byte, siteurl string) ([]FeedSource, error) {
|
||||
sources := make([]FeedSource, 0, 0)
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(html))
|
||||
if err != nil {
|
||||
return sources, err
|
||||
}
|
||||
base, err := url.Parse(siteurl)
|
||||
if err != nil {
|
||||
return sources, err
|
||||
}
|
||||
|
||||
// feed {url: title} map
|
||||
feeds := make(map[string]string)
|
||||
|
||||
doc.Find(feedLinks).Each(func(i int, s *goquery.Selection) {
|
||||
// Unlikely to happen, but don't get more than N links
|
||||
if len(feeds) > 10 {
|
||||
return
|
||||
}
|
||||
if href, ok := s.Attr("href"); ok {
|
||||
feedUrl, err := url.Parse(href)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
title := s.AttrOr("title", "")
|
||||
url := base.ResolveReference(feedUrl).String()
|
||||
|
||||
if _, alreadyExists := feeds[url]; alreadyExists {
|
||||
if feeds[url] == "" {
|
||||
feeds[url] = title
|
||||
}
|
||||
} else {
|
||||
feeds[url] = title
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for url, title := range feeds {
|
||||
sources = append(sources, FeedSource{Title: title, Url: url})
|
||||
}
|
||||
return sources, nil
|
||||
}
|
||||
|
||||
func discoverFeed(candidateUrl string) (*gofeed.Feed, *[]FeedSource, error) {
|
||||
// Query URL
|
||||
res, err := defaultClient.get(candidateUrl)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != 200 {
|
||||
errmsg := fmt.Sprintf("Failed to fetch feed %s (status: %d)", candidateUrl, res.StatusCode)
|
||||
return nil, nil, errors.New(errmsg)
|
||||
}
|
||||
content, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Try to feed into parser
|
||||
feedparser := gofeed.NewParser()
|
||||
feed, err := feedparser.Parse(bytes.NewReader(content))
|
||||
if err == nil {
|
||||
// WILD: feeds may not always have link to themselves
|
||||
if len(feed.FeedLink) == 0 {
|
||||
feed.FeedLink = candidateUrl
|
||||
}
|
||||
|
||||
// WILD: resolve relative links (path, without host)
|
||||
base, _ := url.Parse(candidateUrl)
|
||||
if link, err := url.Parse(feed.Link); err == nil && link.Host == "" {
|
||||
feed.Link = base.ResolveReference(link).String()
|
||||
}
|
||||
if link, err := url.Parse(feed.FeedLink); err == nil && link.Host == "" {
|
||||
feed.FeedLink = base.ResolveReference(link).String()
|
||||
}
|
||||
|
||||
return feed, nil, nil
|
||||
}
|
||||
|
||||
// Possibly an html link. Search for feed links
|
||||
sources, err := searchFeedLinks(content, candidateUrl)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
} else if len(sources) == 0 {
|
||||
return nil, nil, errors.New("No feeds found at the given url")
|
||||
} else if len(sources) == 1 {
|
||||
if sources[0].Url == candidateUrl {
|
||||
return nil, nil, errors.New("Recursion!")
|
||||
}
|
||||
return discoverFeed(sources[0].Url)
|
||||
}
|
||||
return nil, &sources, nil
|
||||
}
|
||||
|
||||
func findFavicon(websiteUrl, feedUrl string) (*[]byte, error) {
|
||||
candidateUrls := make([]string, 0)
|
||||
|
||||
favicon := func(link string) string {
|
||||
u, err := url.Parse(link)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s://%s/favicon.ico", u.Scheme, u.Host)
|
||||
}
|
||||
|
||||
if len(websiteUrl) != 0 {
|
||||
base, err := url.Parse(websiteUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := defaultClient.get(websiteUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
doc, err := goquery.NewDocumentFromReader(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc.Find(`link[rel=icon]`).EachWithBreak(func(i int, s *goquery.Selection) bool {
|
||||
if href, ok := s.Attr("href"); ok {
|
||||
if hrefUrl, err := url.Parse(href); err == nil {
|
||||
faviconUrl := base.ResolveReference(hrefUrl).String()
|
||||
candidateUrls = append(candidateUrls, faviconUrl)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if c := favicon(websiteUrl); len(c) != 0 {
|
||||
candidateUrls = append(candidateUrls, c)
|
||||
}
|
||||
}
|
||||
if c := favicon(feedUrl); len(c) != 0 {
|
||||
candidateUrls = append(candidateUrls, c)
|
||||
}
|
||||
|
||||
imageTypes := [4]string{
|
||||
"image/x-icon",
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
}
|
||||
for _, url := range candidateUrls {
|
||||
res, err := defaultClient.get(url)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode == 200 {
|
||||
if content, err := ioutil.ReadAll(res.Body); err == nil {
|
||||
ctype := http.DetectContentType(content)
|
||||
for _, itype := range imageTypes {
|
||||
if ctype == itype {
|
||||
return &content, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func convertItems(items []*gofeed.Item, feed storage.Feed) []storage.Item {
|
||||
result := make([]storage.Item, len(items))
|
||||
for i, item := range items {
|
||||
imageURL := ""
|
||||
if item.Image != nil {
|
||||
imageURL = item.Image.URL
|
||||
}
|
||||
author := ""
|
||||
if item.Author != nil {
|
||||
author = item.Author.Name
|
||||
}
|
||||
result[i] = storage.Item{
|
||||
GUID: item.GUID,
|
||||
FeedId: feed.Id,
|
||||
Title: item.Title,
|
||||
Link: item.Link,
|
||||
Description: item.Description,
|
||||
Content: item.Content,
|
||||
Author: author,
|
||||
Date: item.PublishedParsed,
|
||||
DateUpdated: item.UpdatedParsed,
|
||||
Status: storage.UNREAD,
|
||||
Image: imageURL,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func listItems(f storage.Feed) ([]storage.Item, error) {
|
||||
res, err := defaultClient.get(f.FeedLink)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode == 404 {
|
||||
errmsg := fmt.Sprintf("Failed to list feed items for %s (status: 404)", f.FeedLink)
|
||||
return nil, errors.New(errmsg)
|
||||
}
|
||||
feedparser := gofeed.NewParser()
|
||||
feed, err := feedparser.Parse(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return convertItems(feed.Items, f), nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
transport := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
}).DialContext,
|
||||
DisableKeepAlives: true,
|
||||
TLSHandshakeTimeout: time.Second * 10,
|
||||
}
|
||||
httpClient := &http.Client{
|
||||
Timeout: time.Second * 30,
|
||||
Transport: transport,
|
||||
}
|
||||
defaultClient = &Client{
|
||||
httpClient: httpClient,
|
||||
userAgent: "Yarr/1.0",
|
||||
}
|
||||
}
|
||||
@@ -1,482 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/base64"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"github.com/nkanaev/yarr/storage"
|
||||
"html"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"math"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var routes []Route = []Route{
|
||||
p("/", IndexHandler),
|
||||
p("/static/*path", StaticHandler),
|
||||
p("/api/status", StatusHandler),
|
||||
p("/api/folders", FolderListHandler),
|
||||
p("/api/folders/:id", FolderHandler),
|
||||
p("/api/feeds", FeedListHandler),
|
||||
p("/api/feeds/find", FeedHandler),
|
||||
p("/api/feeds/refresh", FeedRefreshHandler),
|
||||
p("/api/feeds/:id/icon", FeedIconHandler),
|
||||
p("/api/feeds/:id", FeedHandler),
|
||||
p("/api/items", ItemListHandler),
|
||||
p("/api/items/:id", ItemHandler),
|
||||
p("/api/settings", SettingsHandler),
|
||||
p("/opml/import", OPMLImportHandler),
|
||||
p("/opml/export", OPMLExportHandler),
|
||||
p("/page", PageCrawlHandler),
|
||||
}
|
||||
|
||||
type asset struct {
|
||||
etag string
|
||||
body string // base64(gzip(content))
|
||||
gzipped *[]byte
|
||||
decoded *string
|
||||
}
|
||||
|
||||
func (a *asset) gzip() *[]byte {
|
||||
if a.gzipped == nil {
|
||||
gzipped, _ := base64.StdEncoding.DecodeString(a.body)
|
||||
a.gzipped = &gzipped
|
||||
}
|
||||
return a.gzipped
|
||||
}
|
||||
|
||||
func (a *asset) text() *string {
|
||||
if a.decoded == nil {
|
||||
gzipped, _ := base64.StdEncoding.DecodeString(a.body)
|
||||
reader, _ := gzip.NewReader(bytes.NewBuffer(gzipped))
|
||||
decoded, _ := ioutil.ReadAll(reader)
|
||||
reader.Close()
|
||||
|
||||
decoded_string := string(decoded)
|
||||
a.decoded = &decoded_string
|
||||
}
|
||||
return a.decoded
|
||||
}
|
||||
|
||||
var assets map[string]asset
|
||||
|
||||
type FolderCreateForm struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type FolderUpdateForm struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
IsExpanded *bool `json:"is_expanded,omitempty"`
|
||||
}
|
||||
|
||||
type FeedCreateForm struct {
|
||||
Url string `json:"url"`
|
||||
FolderID *int64 `json:"folder_id,omitempty"`
|
||||
}
|
||||
|
||||
type ItemUpdateForm struct {
|
||||
Status *storage.ItemStatus `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
func IndexHandler(rw http.ResponseWriter, req *http.Request) {
|
||||
if assets != nil {
|
||||
asset := assets["index.html"]
|
||||
|
||||
rw.Header().Set("Content-Type", "text/html")
|
||||
rw.Header().Set("Content-Encoding", "gzip")
|
||||
rw.Write(*asset.gzip())
|
||||
} else {
|
||||
t := template.Must(template.New("index.html").Delims("{%", "%}").Funcs(template.FuncMap{
|
||||
"inline": func(svg string) template.HTML {
|
||||
content, _ := ioutil.ReadFile("assets/graphicarts/" + svg)
|
||||
return template.HTML(content)
|
||||
},
|
||||
}).ParseFiles("assets/index.html"))
|
||||
rw.Header().Set("Content-Type", "text/html")
|
||||
t.Execute(rw, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func StaticHandler(rw http.ResponseWriter, req *http.Request) {
|
||||
path := Vars(req)["path"]
|
||||
ctype := mime.TypeByExtension(filepath.Ext(path))
|
||||
|
||||
if assets != nil {
|
||||
if asset, ok := assets[path]; ok {
|
||||
if req.Header.Get("if-none-match") == asset.etag {
|
||||
rw.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
rw.Header().Set("Content-Type", ctype)
|
||||
rw.Header().Set("Content-Encoding", "gzip")
|
||||
rw.Header().Set("Etag", asset.etag)
|
||||
rw.Write(*asset.gzip())
|
||||
}
|
||||
}
|
||||
|
||||
f, err := os.Open("assets/" + path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
rw.Header().Set("Content-Type", ctype)
|
||||
io.Copy(rw, f)
|
||||
}
|
||||
|
||||
func StatusHandler(rw http.ResponseWriter, req *http.Request) {
|
||||
writeJSON(rw, map[string]interface{}{
|
||||
"running": *handler(req).queueSize,
|
||||
"stats": db(req).FeedStats(),
|
||||
})
|
||||
}
|
||||
|
||||
func FolderListHandler(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method == "GET" {
|
||||
list := db(req).ListFolders()
|
||||
writeJSON(rw, list)
|
||||
} else if req.Method == "POST" {
|
||||
var body FolderCreateForm
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
handler(req).log.Print(err)
|
||||
rw.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(body.Title) == 0 {
|
||||
rw.WriteHeader(http.StatusBadRequest)
|
||||
writeJSON(rw, map[string]string{"error": "Folder title missing."})
|
||||
return
|
||||
}
|
||||
folder := db(req).CreateFolder(body.Title)
|
||||
rw.WriteHeader(http.StatusCreated)
|
||||
writeJSON(rw, folder)
|
||||
} else {
|
||||
rw.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func FolderHandler(rw http.ResponseWriter, req *http.Request) {
|
||||
id, err := strconv.ParseInt(Vars(req)["id"], 10, 64)
|
||||
if err != nil {
|
||||
rw.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Method == "PUT" {
|
||||
var body FolderUpdateForm
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
handler(req).log.Print(err)
|
||||
rw.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Title != nil {
|
||||
db(req).RenameFolder(id, *body.Title)
|
||||
}
|
||||
if body.IsExpanded != nil {
|
||||
db(req).ToggleFolderExpanded(id, *body.IsExpanded)
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
} else if req.Method == "DELETE" {
|
||||
db(req).DeleteFolder(id)
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func FeedRefreshHandler(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method == "POST" {
|
||||
handler(req).fetchAllFeeds()
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
rw.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func FeedIconHandler(rw http.ResponseWriter, req *http.Request) {
|
||||
id, err := strconv.ParseInt(Vars(req)["id"], 10, 64)
|
||||
if err != nil {
|
||||
rw.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
feed := db(req).GetFeed(id)
|
||||
if feed != nil && feed.Icon != nil {
|
||||
rw.Header().Set("Content-Type", http.DetectContentType(*feed.Icon))
|
||||
rw.Header().Set("Content-Length", strconv.Itoa(len(*feed.Icon)))
|
||||
rw.Write(*feed.Icon)
|
||||
} else {
|
||||
rw.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func FeedListHandler(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method == "GET" {
|
||||
list := db(req).ListFeeds()
|
||||
writeJSON(rw, list)
|
||||
} else if req.Method == "POST" {
|
||||
var form FeedCreateForm
|
||||
if err := json.NewDecoder(req.Body).Decode(&form); err != nil {
|
||||
handler(req).log.Print(err)
|
||||
rw.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
feed, sources, err := discoverFeed(form.Url)
|
||||
if err != nil {
|
||||
handler(req).log.Print(err)
|
||||
writeJSON(rw, map[string]string{"status": "notfound"})
|
||||
return
|
||||
}
|
||||
|
||||
if feed != nil {
|
||||
storedFeed := db(req).CreateFeed(
|
||||
feed.Title,
|
||||
feed.Description,
|
||||
feed.Link,
|
||||
feed.FeedLink,
|
||||
form.FolderID,
|
||||
)
|
||||
db(req).CreateItems(convertItems(feed.Items, *storedFeed))
|
||||
writeJSON(rw, map[string]string{"status": "success"})
|
||||
} else if sources != nil {
|
||||
writeJSON(rw, map[string]interface{}{"status": "multiple", "choice": sources})
|
||||
} else {
|
||||
writeJSON(rw, map[string]string{"status": "notfound"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func FeedHandler(rw http.ResponseWriter, req *http.Request) {
|
||||
id, err := strconv.ParseInt(Vars(req)["id"], 10, 64)
|
||||
if err != nil {
|
||||
rw.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Method == "PUT" {
|
||||
feed := db(req).GetFeed(id)
|
||||
if feed == nil {
|
||||
rw.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
handler(req).log.Print(err)
|
||||
rw.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if title, ok := body["title"]; ok {
|
||||
if reflect.TypeOf(title).Kind() == reflect.String {
|
||||
db(req).RenameFeed(id, title.(string))
|
||||
}
|
||||
}
|
||||
if f_id, ok := body["folder_id"]; ok {
|
||||
if f_id == nil {
|
||||
db(req).UpdateFeedFolder(id, nil)
|
||||
} else if reflect.TypeOf(f_id).Kind() == reflect.Float64 {
|
||||
folderId := int64(f_id.(float64))
|
||||
db(req).UpdateFeedFolder(id, &folderId)
|
||||
}
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
} else if req.Method == "DELETE" {
|
||||
db(req).DeleteFeed(id)
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
} else {
|
||||
rw.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func ItemHandler(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method == "PUT" {
|
||||
id, err := strconv.ParseInt(Vars(req)["id"], 10, 64)
|
||||
if err != nil {
|
||||
rw.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var body ItemUpdateForm
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
handler(req).log.Print(err)
|
||||
rw.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Status != nil {
|
||||
db(req).UpdateItemStatus(id, *body.Status)
|
||||
}
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
rw.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func ItemListHandler(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method == "GET" {
|
||||
perPage := 20
|
||||
curPage := 1
|
||||
query := req.URL.Query()
|
||||
if page, err := strconv.ParseInt(query.Get("page"), 10, 64); err == nil {
|
||||
curPage = int(page)
|
||||
}
|
||||
filter := storage.ItemFilter{}
|
||||
if folderID, err := strconv.ParseInt(query.Get("folder_id"), 10, 64); err == nil {
|
||||
filter.FolderID = &folderID
|
||||
}
|
||||
if feedID, err := strconv.ParseInt(query.Get("feed_id"), 10, 64); err == nil {
|
||||
filter.FeedID = &feedID
|
||||
}
|
||||
if status := query.Get("status"); len(status) != 0 {
|
||||
statusValue := storage.StatusValues[status]
|
||||
filter.Status = &statusValue
|
||||
}
|
||||
if search := query.Get("search"); len(search) != 0 {
|
||||
filter.Search = &search
|
||||
}
|
||||
newestFirst := query.Get("oldest_first") != "true"
|
||||
items := db(req).ListItems(filter, (curPage-1)*perPage, perPage, newestFirst)
|
||||
count := db(req).CountItems(filter)
|
||||
writeJSON(rw, map[string]interface{}{
|
||||
"page": map[string]int{
|
||||
"cur": curPage,
|
||||
"num": int(math.Ceil(float64(count) / float64(perPage))),
|
||||
},
|
||||
"list": items,
|
||||
})
|
||||
} else if req.Method == "PUT" {
|
||||
query := req.URL.Query()
|
||||
filter := storage.ItemFilter{}
|
||||
if folderID, err := strconv.ParseInt(query.Get("folder_id"), 10, 64); err == nil {
|
||||
filter.FolderID = &folderID
|
||||
}
|
||||
if feedID, err := strconv.ParseInt(query.Get("feed_id"), 10, 64); err == nil {
|
||||
filter.FeedID = &feedID
|
||||
}
|
||||
db(req).MarkItemsRead(filter)
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
rw.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func SettingsHandler(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method == "GET" {
|
||||
writeJSON(rw, db(req).GetSettings())
|
||||
} else if req.Method == "PUT" {
|
||||
settings := make(map[string]interface{})
|
||||
if err := json.NewDecoder(req.Body).Decode(&settings); err != nil {
|
||||
rw.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if db(req).UpdateSettings(settings) {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
rw.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func OPMLImportHandler(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method == "POST" {
|
||||
file, _, err := req.FormFile("opml")
|
||||
if err != nil {
|
||||
handler(req).log.Print(err)
|
||||
return
|
||||
}
|
||||
doc, err := parseOPML(file)
|
||||
if err != nil {
|
||||
handler(req).log.Print(err)
|
||||
return
|
||||
}
|
||||
for _, outline := range doc.Outlines {
|
||||
if outline.Type == "rss" {
|
||||
db(req).CreateFeed(outline.Title, outline.Description, outline.SiteURL, outline.FeedURL, nil)
|
||||
} else {
|
||||
folder := db(req).CreateFolder(outline.Title)
|
||||
for _, o := range outline.AllFeeds() {
|
||||
db(req).CreateFeed(o.Title, o.Description, o.SiteURL, o.FeedURL, &folder.Id)
|
||||
}
|
||||
}
|
||||
}
|
||||
handler(req).fetchAllFeeds()
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
rw.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func OPMLExportHandler(rw http.ResponseWriter, req *http.Request) {
|
||||
if req.Method == "GET" {
|
||||
rw.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
rw.Header().Set("Content-Disposition", `attachment; filename="subscriptions.opml"`)
|
||||
|
||||
builder := strings.Builder{}
|
||||
|
||||
line := func(s string, args ...string) {
|
||||
if len(args) > 0 {
|
||||
escapedargs := make([]interface{}, len(args))
|
||||
for idx, arg := range args {
|
||||
escapedargs[idx] = html.EscapeString(arg)
|
||||
}
|
||||
s = fmt.Sprintf(s, escapedargs...)
|
||||
}
|
||||
builder.WriteString(s)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
|
||||
feedline := func(feed storage.Feed, indent int) {
|
||||
line(
|
||||
strings.Repeat(" ", indent)+
|
||||
`<outline type="rss" text="%s" description="%s" xmlUrl="%s" htmlUrl="%s"/>`,
|
||||
feed.Title, feed.Description,
|
||||
feed.FeedLink, feed.Link,
|
||||
)
|
||||
}
|
||||
line(`<?xml version="1.0" encoding="UTF-8"?>`)
|
||||
line(`<opml version="1.1">`)
|
||||
line(`<head>`)
|
||||
line(` <title>subscriptions.opml</title>`)
|
||||
line(`</head>`)
|
||||
line(`<body>`)
|
||||
feedsByFolderID := make(map[int64][]storage.Feed)
|
||||
for _, feed := range db(req).ListFeeds() {
|
||||
var folderId = int64(0)
|
||||
if feed.FolderId != nil {
|
||||
folderId = *feed.FolderId
|
||||
}
|
||||
if feedsByFolderID[folderId] == nil {
|
||||
feedsByFolderID[folderId] = make([]storage.Feed, 0)
|
||||
}
|
||||
feedsByFolderID[folderId] = append(feedsByFolderID[folderId], feed)
|
||||
}
|
||||
for _, folder := range db(req).ListFolders() {
|
||||
line(` <outline text="%s">`, folder.Title)
|
||||
for _, feed := range feedsByFolderID[folder.Id] {
|
||||
feedline(feed, 4)
|
||||
}
|
||||
line(` </outline>`)
|
||||
}
|
||||
for _, feed := range feedsByFolderID[0] {
|
||||
feedline(feed, 2)
|
||||
}
|
||||
line(`</body>`)
|
||||
line(`</opml>`)
|
||||
rw.Write([]byte(builder.String()))
|
||||
}
|
||||
}
|
||||
|
||||
func PageCrawlHandler(rw http.ResponseWriter, req *http.Request) {
|
||||
query := req.URL.Query()
|
||||
if url := query.Get("url"); len(url) > 0 {
|
||||
res, err := http.Get(url)
|
||||
if err == nil {
|
||||
body, err := ioutil.ReadAll(res.Body)
|
||||
if err == nil {
|
||||
rw.Write(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"io"
|
||||
)
|
||||
|
||||
type opml struct {
|
||||
XMLName xml.Name `xml:"opml"`
|
||||
Version string `xml:"version,attr"`
|
||||
Outlines []outline `xml:"body>outline"`
|
||||
}
|
||||
|
||||
type outline struct {
|
||||
Type string `xml:"type,attr,omitempty"`
|
||||
Title string `xml:"text,attr"`
|
||||
FeedURL string `xml:"xmlUrl,attr,omitempty"`
|
||||
SiteURL string `xml:"htmlUrl,attr,omitempty"`
|
||||
Description string `xml:"description,attr,omitempty"`
|
||||
Outlines []outline `xml:"outline,omitempty"`
|
||||
}
|
||||
|
||||
func (o outline) AllFeeds() []outline {
|
||||
result := make([]outline, 0)
|
||||
for _, sub := range o.Outlines {
|
||||
if sub.Type == "rss" {
|
||||
result = append(result, sub)
|
||||
} else {
|
||||
result = append(result, sub.AllFeeds()...)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseOPML(r io.Reader) (*opml, error) {
|
||||
feeds := new(opml)
|
||||
decoder := xml.NewDecoder(r)
|
||||
decoder.Entity = xml.HTMLEntity
|
||||
decoder.Strict = false
|
||||
err := decoder.Decode(&feeds)
|
||||
return feeds, err
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"log"
|
||||
)
|
||||
|
||||
func writeJSON(rw http.ResponseWriter, data interface{}) {
|
||||
rw.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
reply, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
rw.Write(reply)
|
||||
rw.Write([]byte("\n"))
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
type Route struct {
|
||||
url string
|
||||
urlRegex *regexp.Regexp
|
||||
handler func(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
func p(path string, handler func(http.ResponseWriter, *http.Request)) Route {
|
||||
var urlRegexp string
|
||||
urlRegexp = regexp.MustCompile(`[\*\:]\w+`).ReplaceAllStringFunc(path, func(m string) string {
|
||||
if m[0:1] == `*` {
|
||||
return "(?P<" + m[1:] + ">.+)"
|
||||
}
|
||||
return "(?P<" + m[1:] + ">[^/]+)"
|
||||
})
|
||||
urlRegexp = "^" + urlRegexp + "$"
|
||||
return Route{
|
||||
url: path,
|
||||
urlRegex: regexp.MustCompile(urlRegexp),
|
||||
handler: handler,
|
||||
}
|
||||
}
|
||||
|
||||
func getRoute(req *http.Request) (*Route, map[string]string) {
|
||||
vars := make(map[string]string)
|
||||
for _, route := range routes {
|
||||
if route.urlRegex.MatchString(req.URL.Path) {
|
||||
matches := route.urlRegex.FindStringSubmatchIndex(req.URL.Path)
|
||||
for i, key := range route.urlRegex.SubexpNames()[1:] {
|
||||
vars[key] = req.URL.Path[matches[i*2+2]:matches[i*2+3]]
|
||||
}
|
||||
return &route, vars
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
141
server/server.go
@@ -1,141 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/nkanaev/yarr/storage"
|
||||
"log"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
Addr string
|
||||
db *storage.Storage
|
||||
log *log.Logger
|
||||
feedQueue chan storage.Feed
|
||||
queueSize *int32
|
||||
}
|
||||
|
||||
func New(db *storage.Storage, logger *log.Logger, addr string) *Handler {
|
||||
queueSize := int32(0)
|
||||
return &Handler{
|
||||
db: db,
|
||||
log: logger,
|
||||
feedQueue: make(chan storage.Feed, 3000),
|
||||
queueSize: &queueSize,
|
||||
Addr: addr,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Start() {
|
||||
h.startJobs()
|
||||
s := &http.Server{Addr: h.Addr, Handler: h}
|
||||
err := s.ListenAndServe()
|
||||
if err != http.ErrServerClosed {
|
||||
h.log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h Handler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
route, vars := getRoute(req)
|
||||
if route == nil {
|
||||
rw.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(req.Context(), ctxHandler, &h)
|
||||
ctx = context.WithValue(ctx, ctxVars, vars)
|
||||
route.handler(rw, req.WithContext(ctx))
|
||||
}
|
||||
|
||||
func (h *Handler) startJobs() {
|
||||
delTicker := time.NewTicker(time.Hour * 24)
|
||||
|
||||
syncSearchChannel := make(chan bool, 10)
|
||||
var syncSearchTimer *time.Timer // TODO: should this be atomic?
|
||||
|
||||
syncSearch := func() {
|
||||
if syncSearchTimer == nil {
|
||||
syncSearchTimer = time.AfterFunc(time.Second * 2, func() {
|
||||
syncSearchChannel <- true
|
||||
})
|
||||
} else {
|
||||
syncSearchTimer.Reset(time.Second * 2)
|
||||
}
|
||||
}
|
||||
|
||||
worker := func() {
|
||||
for {
|
||||
select {
|
||||
case feed := <-h.feedQueue:
|
||||
items, err := listItems(feed)
|
||||
atomic.AddInt32(h.queueSize, -1)
|
||||
if err != nil {
|
||||
h.log.Printf("Failed to fetch %s (%d): %s", feed.FeedLink, feed.Id, err)
|
||||
continue
|
||||
}
|
||||
h.db.CreateItems(items)
|
||||
syncSearch()
|
||||
if !feed.HasIcon {
|
||||
icon, err := findFavicon(feed.Link, feed.FeedLink)
|
||||
if icon != nil {
|
||||
h.db.UpdateFeedIcon(feed.Id, icon)
|
||||
}
|
||||
if err != nil {
|
||||
h.log.Printf("Failed to search favicon for %s (%s): %s", feed.Link, feed.FeedLink, err)
|
||||
}
|
||||
}
|
||||
case <- delTicker.C:
|
||||
h.db.DeleteOldItems()
|
||||
case <- syncSearchChannel:
|
||||
h.db.SyncSearch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
num := runtime.NumCPU() - 1
|
||||
if num < 1 {
|
||||
num = 1
|
||||
}
|
||||
for i := 0; i < num; i++ {
|
||||
go worker()
|
||||
}
|
||||
go h.db.DeleteOldItems()
|
||||
go h.db.SyncSearch()
|
||||
//h.fetchAllFeeds()
|
||||
}
|
||||
|
||||
func (h *Handler) fetchAllFeeds() {
|
||||
for _, feed := range h.db.ListFeeds() {
|
||||
h.fetchFeed(feed)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) fetchFeed(feed storage.Feed) {
|
||||
atomic.AddInt32(h.queueSize, 1)
|
||||
h.feedQueue <- feed
|
||||
}
|
||||
|
||||
func Vars(req *http.Request) map[string]string {
|
||||
if rv := req.Context().Value(ctxVars); rv != nil {
|
||||
return rv.(map[string]string)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func db(req *http.Request) *storage.Storage {
|
||||
if h := handler(req); h != nil {
|
||||
return h.db
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handler(req *http.Request) *Handler {
|
||||
return req.Context().Value(ctxHandler).(*Handler)
|
||||
}
|
||||
|
||||
const (
|
||||
ctxVars = 2
|
||||
ctxHandler = 3
|
||||
)
|
||||
62
src/assets/assets.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
type assetsfs struct {
|
||||
embedded *embed.FS
|
||||
templates map[string]*template.Template
|
||||
}
|
||||
|
||||
var FS assetsfs
|
||||
|
||||
func (afs assetsfs) Open(name string) (fs.File, error) {
|
||||
if afs.embedded != nil {
|
||||
return afs.embedded.Open(name)
|
||||
}
|
||||
return os.DirFS("src/assets").Open(name)
|
||||
}
|
||||
|
||||
func Template(path string) *template.Template {
|
||||
var tmpl *template.Template
|
||||
tmpl, found := FS.templates[path]
|
||||
if !found {
|
||||
tmpl = template.Must(template.New(path).Delims("{%", "%}").Funcs(template.FuncMap{
|
||||
"inline": func(svg string) template.HTML {
|
||||
svgfile, err := FS.Open("graphicarts/" + svg)
|
||||
// should never happen
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer svgfile.Close()
|
||||
|
||||
content, err := ioutil.ReadAll(svgfile)
|
||||
// should never happen
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return template.HTML(content)
|
||||
},
|
||||
}).ParseFS(FS, path))
|
||||
if FS.embedded != nil {
|
||||
FS.templates[path] = tmpl
|
||||
}
|
||||
}
|
||||
return tmpl
|
||||
}
|
||||
|
||||
func Render(path string, writer io.Writer, data interface{}) {
|
||||
tmpl := Template(path)
|
||||
tmpl.Execute(writer, data)
|
||||
}
|
||||
|
||||
func init() {
|
||||
FS.templates = make(map[string]*template.Template)
|
||||
}
|
||||
15
src/assets/assetsfs.go
Normal file
@@ -0,0 +1,15 @@
|
||||
//go:build !debug
|
||||
|
||||
package assets
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed *.html
|
||||
//go:embed graphicarts
|
||||
//go:embed javascripts
|
||||
//go:embed stylesheets
|
||||
var embedded embed.FS
|
||||
|
||||
func init() {
|
||||
FS.embedded = &embedded
|
||||
}
|
||||
1
src/assets/graphicarts/alert-circle.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-alert-circle"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
|
||||
|
After Width: | Height: | Size: 356 B |
|
Before Width: | Height: | Size: 345 B After Width: | Height: | Size: 345 B |
|
Before Width: | Height: | Size: 353 B After Width: | Height: | Size: 353 B |
|
Before Width: | Height: | Size: 339 B After Width: | Height: | Size: 339 B |
|
Before Width: | Height: | Size: 262 B After Width: | Height: | Size: 262 B |
|
Before Width: | Height: | Size: 269 B After Width: | Height: | Size: 269 B |
|
Before Width: | Height: | Size: 270 B After Width: | Height: | Size: 270 B |
|
Before Width: | Height: | Size: 270 B After Width: | Height: | Size: 270 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-more-vertical"><circle cx="12" cy="12" r="1"></circle><circle cx="12" cy="5" r="1"></circle><circle cx="12" cy="19" r="1"></circle></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-up"><polyline points="18 15 12 9 6 15"></polyline></svg>
|
||||
|
Before Width: | Height: | Size: 341 B After Width: | Height: | Size: 268 B |
|
Before Width: | Height: | Size: 267 B After Width: | Height: | Size: 267 B |
|
Before Width: | Height: | Size: 258 B After Width: | Height: | Size: 258 B |
|
Before Width: | Height: | Size: 370 B After Width: | Height: | Size: 370 B |
|
Before Width: | Height: | Size: 365 B After Width: | Height: | Size: 365 B |
|
Before Width: | Height: | Size: 388 B After Width: | Height: | Size: 388 B |
BIN
src/assets/graphicarts/favicon.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
9
src/assets/graphicarts/favicon.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-anchor-favicon">
|
||||
<circle cx="12" cy="5" r="3" stroke-width="4" stroke="#ffffff"></circle>
|
||||
<line x1="12" y1="22" x2="12" y2="8" stroke-width="4" stroke="#ffffff"></line>
|
||||
<path d="M5 12H2a10 10 0 0 0 20 0h-3" stroke-width="4" stroke="#ffffff"></path>
|
||||
|
||||
<circle cx="12" cy="5" r="3"></circle>
|
||||
<line x1="12" y1="22" x2="12" y2="8"></line>
|
||||
<path d="M5 12H2a10 10 0 0 0 20 0h-3"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 603 B |
1
src/assets/graphicarts/folder-minus.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-folder-minus"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path><line x1="9" y1="14" x2="15" y2="14"></line></svg>
|
||||
|
After Width: | Height: | Size: 361 B |
1
src/assets/graphicarts/folder-plus.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-folder-plus"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path><line x1="12" y1="11" x2="12" y2="17"></line><line x1="9" y1="14" x2="15" y2="14"></line></svg>
|
||||
|
After Width: | Height: | Size: 405 B |
|
Before Width: | Height: | Size: 311 B After Width: | Height: | Size: 311 B |
1
src/assets/graphicarts/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-globe"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>
|
||||
|
After Width: | Height: | Size: 409 B |
1
src/assets/graphicarts/help-circle.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-help-circle"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
|
||||
|
After Width: | Height: | Size: 365 B |
|
Before Width: | Height: | Size: 365 B After Width: | Height: | Size: 365 B |
1
src/assets/graphicarts/log-out.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-log-out"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
|
||||
|
After Width: | Height: | Size: 367 B |
|
Before Width: | Height: | Size: 343 B After Width: | Height: | Size: 343 B |
|
Before Width: | Height: | Size: 304 B After Width: | Height: | Size: 304 B |
|
Before Width: | Height: | Size: 321 B After Width: | Height: | Size: 321 B |
|
Before Width: | Height: | Size: 330 B After Width: | Height: | Size: 330 B |
|
Before Width: | Height: | Size: 308 B After Width: | Height: | Size: 308 B |
|
Before Width: | Height: | Size: 611 B After Width: | Height: | Size: 611 B |
|
Before Width: | Height: | Size: 348 B After Width: | Height: | Size: 348 B |
|
Before Width: | Height: | Size: 339 B After Width: | Height: | Size: 339 B |
1
src/assets/graphicarts/trash.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-trash"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
|
||||
|
After Width: | Height: | Size: 356 B |
|
Before Width: | Height: | Size: 365 B After Width: | Height: | Size: 365 B |
|
Before Width: | Height: | Size: 299 B After Width: | Height: | Size: 299 B |