Compare commits
456 Commits
v1.2
...
4983e18e23
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
38
.github/workflows/build-docker-image
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
name: Publish Docker Image
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: [ 'v*.*.*', 'v*.*', 'v*', 'latest' ]
|
||||||
|
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@v3
|
||||||
|
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./etc/dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
44
.github/workflows/build.yml
vendored
@@ -2,15 +2,21 @@ name: build
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: [v*]
|
tags: ['v*', 'test*']
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build_macos:
|
build_macos:
|
||||||
name: Build for MacOS
|
name: Build for MacOS
|
||||||
runs-on: macos-10.15
|
runs-on: macos-13
|
||||||
steps:
|
steps:
|
||||||
- {name: "Checkout", uses: actions/checkout@v2}
|
- name: "Checkout"
|
||||||
- {name: "Checkout gofeed", uses: actions/checkout@v2, with: {repository: nkanaev/gofeed, path: gofeed}}
|
uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
submodules: 'recursive'
|
||||||
|
- name: "Setup Go"
|
||||||
|
uses: actions/setup-go@v2
|
||||||
|
with:
|
||||||
|
go-version: '^1.17'
|
||||||
- name: Cache Go Modules
|
- name: Cache Go Modules
|
||||||
uses: actions/cache@v2
|
uses: actions/cache@v2
|
||||||
with:
|
with:
|
||||||
@@ -28,10 +34,16 @@ jobs:
|
|||||||
|
|
||||||
build_windows:
|
build_windows:
|
||||||
name: Build for Windows
|
name: Build for Windows
|
||||||
runs-on: windows-2019
|
runs-on: windows-2022
|
||||||
steps:
|
steps:
|
||||||
- {name: "Checkout", uses: actions/checkout@v2}
|
- name: "Checkout"
|
||||||
- {name: "Checkout gofeed", uses: actions/checkout@v2, with: {repository: nkanaev/gofeed, path: gofeed}}
|
uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
submodules: 'recursive'
|
||||||
|
- name: "Setup Go"
|
||||||
|
uses: actions/setup-go@v2
|
||||||
|
with:
|
||||||
|
go-version: '^1.17'
|
||||||
- name: Cache Go Modules
|
- name: Cache Go Modules
|
||||||
uses: actions/cache@v2
|
uses: actions/cache@v2
|
||||||
with:
|
with:
|
||||||
@@ -49,11 +61,16 @@ jobs:
|
|||||||
|
|
||||||
build_linux:
|
build_linux:
|
||||||
name: Build for Linux
|
name: Build for Linux
|
||||||
runs-on: ubuntu-18.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- {name: "Checkout", uses: actions/checkout@v2}
|
- name: "Checkout"
|
||||||
- {name: "Checkout gofeed", uses: actions/checkout@v2, with: {repository: nkanaev/gofeed, path: gofeed}}
|
uses: actions/checkout@v2
|
||||||
- {name: "Setup Go", uses: actions/setup-go@v2, with: {go-version: '^1.14'}}
|
with:
|
||||||
|
submodules: 'recursive'
|
||||||
|
- name: "Setup Go"
|
||||||
|
uses: actions/setup-go@v2
|
||||||
|
with:
|
||||||
|
go-version: '^1.17'
|
||||||
- name: Cache Go Modules
|
- name: Cache Go Modules
|
||||||
uses: actions/cache@v2
|
uses: actions/cache@v2
|
||||||
with:
|
with:
|
||||||
@@ -72,6 +89,7 @@ jobs:
|
|||||||
create_release:
|
create_release:
|
||||||
name: Create Release
|
name: Create Release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ !contains(github.ref, 'test') }}
|
||||||
needs: [build_macos, build_windows, build_linux]
|
needs: [build_macos, build_windows, build_linux]
|
||||||
steps:
|
steps:
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
@@ -113,7 +131,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||||
asset_path: ./yarr-windows.zip
|
asset_path: ./yarr-windows.zip
|
||||||
asset_name: yarr-${{ github.ref }}-windows32.zip
|
asset_name: yarr-${{ github.ref }}-windows64.zip
|
||||||
asset_content_type: application/zip
|
asset_content_type: application/zip
|
||||||
- name: Upload Linux
|
- name: Upload Linux
|
||||||
uses: actions/upload-release-asset@v1
|
uses: actions/upload-release-asset@v1
|
||||||
@@ -122,5 +140,5 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||||
asset_path: ./yarr-linux.zip
|
asset_path: ./yarr-linux.zip
|
||||||
asset_name: yarr-${{ github.ref }}-linux32.zip
|
asset_name: yarr-${{ github.ref }}-linux64.zip
|
||||||
asset_content_type: application/zip
|
asset_content_type: application/zip
|
||||||
|
|||||||
2
.gitignore
vendored
@@ -1,5 +1,3 @@
|
|||||||
/server/assets.go
|
|
||||||
/gofeed
|
|
||||||
/_output
|
/_output
|
||||||
/yarr
|
/yarr
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
|
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,396 +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>Refresh</b-dropdown-header>
|
|
||||||
<b-dropdown-item-button @click.stop="refreshRate = min" v-for="min in [0, 60]">
|
|
||||||
<span class="icon mr-1" :class="{invisible: refreshRate != min}">{% inline "check.svg" %}</span>
|
|
||||||
<span v-if="min == 0">Manually</span>
|
|
||||||
<span v-if="min == 60">Every hour</span>
|
|
||||||
</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; z-index: -1;">
|
|
||||||
<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-divider v-if="authenticated"></b-dropdown-divider>
|
|
||||||
<b-dropdown-item-button v-if="authenticated" @click="logout()">
|
|
||||||
<span class="icon mr-1">{% inline "log-out.svg" %}</span>
|
|
||||||
Log out
|
|
||||||
</b-dropdown-item-button>
|
|
||||||
</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>
|
|
||||||
<span class="icon flex-shrink-0 mx-2"
|
|
||||||
v-b-tooltip.hover.top="feed_errors[feed.id]"
|
|
||||||
v-if="feed_errors[feed.id]">
|
|
||||||
{% inline "alert-circle.svg" %}
|
|
||||||
</span>
|
|
||||||
<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>
|
|
||||||
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))
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/ioutil"
|
|
||||||
"flag"
|
"flag"
|
||||||
|
"io/ioutil"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var rsrc = `1 VERSIONINFO
|
var rsrc = `1 VERSIONINFO
|
||||||
FILEVERSION {VERSION_COMMA},0,0
|
FILEVERSION {VERSION_COMMA},0,0
|
||||||
PRODUCTVERSION {VERSION_COMMA},0,0
|
PRODUCTVERSION {VERSION_COMMA},0,0
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -85,9 +85,9 @@ func main() {
|
|||||||
for _, res := range []int{1024, 512, 256, 128, 64, 32, 16} {
|
for _, res := range []int{1024, 512, 256, 128, 64, 32, 16} {
|
||||||
outfile := fmt.Sprintf("icon_%dx%d.png", res, res)
|
outfile := fmt.Sprintf("icon_%dx%d.png", res, res)
|
||||||
if res == 1024 || res == 64 {
|
if res == 1024 || res == 64 {
|
||||||
outfile = fmt.Sprintf("icon_%dx%d@2x.png", res / 2, res / 2)
|
outfile = fmt.Sprintf("icon_%dx%d@2x.png", res/2, res/2)
|
||||||
}
|
}
|
||||||
cmd := []string {
|
cmd := []string{
|
||||||
"sips", "-s", "format", "png", "--resampleWidth", strconv.Itoa(res),
|
"sips", "-s", "format", "png", "--resampleWidth", strconv.Itoa(res),
|
||||||
iconFile, "--out", path.Join(iconsetDir, outfile),
|
iconFile, "--out", path.Join(iconsetDir, outfile),
|
||||||
}
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
155
cmd/yarr/main.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Failed to get config dir: ", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if db == "" {
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
44
doc/build.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
## Compilation
|
||||||
|
|
||||||
|
Install `Go >= 1.17` and `GCC`. Get the source code:
|
||||||
|
|
||||||
|
git clone https://github.com/nkanaev/yarr.git
|
||||||
|
|
||||||
|
Then run one of the corresponding commands:
|
||||||
|
|
||||||
|
# create an executable for the host os
|
||||||
|
make build_macos # -> _output/macos/yarr.app
|
||||||
|
make build_linux # -> _output/linux/yarr
|
||||||
|
make build_windows # -> _output/windows/yarr.exe
|
||||||
|
|
||||||
|
# host-specific cli version (no gui)
|
||||||
|
make build_default # -> _output/yarr
|
||||||
|
|
||||||
|
# ... or start a dev server locally
|
||||||
|
make serve # starts a server at http://localhost:7070
|
||||||
|
|
||||||
|
# ... 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"
|
||||||
@@ -1,5 +1,87 @@
|
|||||||
# upcoming
|
# upcoming
|
||||||
|
|
||||||
|
- (new) Fever API support (thanks to @icefed)
|
||||||
|
- (new) editable feed link (thanks to @adaszko)
|
||||||
|
- (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)
|
||||||
|
|
||||||
|
# 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) autorefresh rate
|
||||||
- (new) reduced bandwidth usage via stateful http headers `last-modified/etag`
|
- (new) reduced bandwidth usage via stateful http headers `last-modified/etag`
|
||||||
- (new) show feed errors in feed management modal
|
- (new) show feed errors in feed management modal
|
||||||
@@ -8,6 +90,7 @@
|
|||||||
- (new) `-auth-file` flag for authentication
|
- (new) `-auth-file` flag for authentication
|
||||||
- (new) `-cert-file` & `-key-file` flags for TLS
|
- (new) `-cert-file` & `-key-file` flags for TLS
|
||||||
- (fix) wrapping long words in the ui to prevent vertical scroll
|
- (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)
|
# v1.1 (2020-10-05)
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# hacking
|
|
||||||
|
|
||||||
## 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
|
|
||||||
```
|
|
||||||
|
|
||||||
## code of conduct
|
|
||||||
|
|
||||||
Be excellent to each other. Party on, dudes!
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Linux desktop
|
|
||||||
|
|
||||||
Grab the latest linux binary, then run:
|
|
||||||
|
|
||||||
```
|
|
||||||
$ sudo mv /path/to/yarr /usr/local/bin
|
|
||||||
$ sudo tee /usr/local/share/applications/yarr.desktop >/dev/null <<EOF
|
|
||||||
[Desktop Entry]
|
|
||||||
Name=yarr
|
|
||||||
Exec=yarr -open
|
|
||||||
Icon=rss
|
|
||||||
Type=Application
|
|
||||||
Categories=Internet;
|
|
||||||
EOF
|
|
||||||
```
|
|
||||||
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
|
||||||
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
|
||||||
11
doc/todo.txt
@@ -1,11 +0,0 @@
|
|||||||
- add: switch to `embed` once 1.16 is out
|
|
||||||
https://tip.golang.org/pkg/embed/
|
|
||||||
- add: enclosures (podcasts etc)
|
|
||||||
https://github.com/shagr4th/yarr/commits/master
|
|
||||||
- fix: moving newly added feed makes it disappear
|
|
||||||
- fix: broken base link in https://applieddivinitystudies.com/
|
|
||||||
- fix: migrate to cascade delete
|
|
||||||
https://sqlite.org/foreignkeys.html#fk_actions
|
|
||||||
- fix: loading items (by scrolling down) is glitching while feeds are refreshing
|
|
||||||
- doc: self-hosting instructions (including certificates)
|
|
||||||
- etc: test gofeed against real-world feeds, compare results with https://pypi.org/project/feedparser/
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:alpine AS build
|
FROM golang:alpine3.18 AS build
|
||||||
RUN apk add build-base git
|
RUN apk add build-base git
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -9,4 +9,5 @@ RUN apk add --no-cache ca-certificates && \
|
|||||||
update-ca-certificates
|
update-ca-certificates
|
||||||
COPY --from=build /src/_output/linux/yarr /usr/local/bin/yarr
|
COPY --from=build /src/_output/linux/yarr /usr/local/bin/yarr
|
||||||
EXPOSE 7070
|
EXPOSE 7070
|
||||||
CMD ["/usr/local/bin/yarr", "-addr", "0.0.0.0:7070", "-db", "/data/yarr.db"]
|
ENTRYPOINT ["/usr/local/bin/yarr"]
|
||||||
|
CMD ["-addr", "0.0.0.0:7070", "-db", "/data/yarr.db"]
|
||||||
44
etc/dockerfile.arm
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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.18.2.linux-amd64.tar.gz && \
|
||||||
|
rm -rf /usr/local/go && \
|
||||||
|
tar -C /usr/local -xzf go1.18.2.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 \
|
||||||
|
go build \
|
||||||
|
-tags "sqlite_foreign_keys linux" \
|
||||||
|
-ldflags="-s -w" \
|
||||||
|
-o /root/out/yarr.arm64 ./cmd/yarr
|
||||||
|
|
||||||
|
RUN env \
|
||||||
|
CC=arm-linux-gnueabihf-gcc \
|
||||||
|
CGO_ENABLED=1 \
|
||||||
|
GOOS=linux GOARCH=arm GOARM=7 \
|
||||||
|
go build \
|
||||||
|
-tags "sqlite_foreign_keys linux" \
|
||||||
|
-ldflags="-s -w" \
|
||||||
|
-o /root/out/yarr.arm7 ./cmd/yarr
|
||||||
|
|
||||||
|
CMD ["/bin/bash"]
|
||||||
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;
|
||||||
|
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
|
||||||
BIN
etc/promo.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
13
go.mod
@@ -1,14 +1,11 @@
|
|||||||
module github.com/nkanaev/yarr
|
module github.com/nkanaev/yarr
|
||||||
|
|
||||||
go 1.14
|
go 1.17
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/PuerkitoBio/goquery v1.5.1
|
github.com/mattn/go-sqlite3 v1.14.7
|
||||||
github.com/getlantern/systray v1.0.4
|
golang.org/x/net v0.17.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.0
|
golang.org/x/sys v0.13.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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
replace github.com/mmcdole/gofeed => ./gofeed
|
require golang.org/x/text v0.13.0 // indirect
|
||||||
|
|||||||
110
go.sum
@@ -1,73 +1,45 @@
|
|||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/mattn/go-sqlite3 v1.14.7 h1:fxWBnXkxfM6sRiuH3bqJ4CfzZojMOLVc0UTsTglEghA=
|
||||||
github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg=
|
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||||
github.com/PuerkitoBio/goquery v1.5.1 h1:PSPBGne8NIUWw+/7vFBV+kG2J/5MOjbzc7154OaKCSE=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
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/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/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e h1:3G+cUijn7XD+S4eJFddp53Pv7+slrESplyjG25HgL+k=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
|
||||||
|
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||||
|
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||||
|
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9 h1:YTzHMGlqJu67/uEo1lBv0n3wBXhXNeUbB1XfN2vmTm0=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||||
|
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
|
||||||
|
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
|
||||||
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
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=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
|||||||
110
main.go
@@ -1,110 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/nkanaev/yarr/platform"
|
|
||||||
"github.com/nkanaev/yarr/server"
|
|
||||||
"github.com/nkanaev/yarr/storage"
|
|
||||||
sdopen "github.com/skratchdot/open-golang/open"
|
|
||||||
)
|
|
||||||
|
|
||||||
var Version string = "0.0"
|
|
||||||
var GitHash string = "unknown"
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
var addr, db, authfile, certfile, keyfile string
|
|
||||||
var ver, open bool
|
|
||||||
flag.StringVar(&addr, "addr", "127.0.0.1:7070", "address to run server on")
|
|
||||||
flag.StringVar(&authfile, "auth-file", "", "path to a file containing username:password")
|
|
||||||
flag.StringVar(&server.BasePath, "base", "", "base path of the service url")
|
|
||||||
flag.StringVar(&certfile, "cert-file", "", "path to cert file for https")
|
|
||||||
flag.StringVar(&keyfile, "key-file", "", "path to key file for https")
|
|
||||||
flag.StringVar(&db, "db", "", "storage file path")
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
if server.BasePath != "" && !strings.HasPrefix(server.BasePath, "/") {
|
|
||||||
server.BasePath = "/" + server.BasePath
|
|
||||||
}
|
|
||||||
|
|
||||||
if server.BasePath != "" && strings.HasSuffix(server.BasePath, "/") {
|
|
||||||
server.BasePath = strings.TrimSuffix(server.BasePath, "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
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 db == "" {
|
|
||||||
storagePath := filepath.Join(configPath, "yarr")
|
|
||||||
if err := os.MkdirAll(storagePath, 0755); err != nil {
|
|
||||||
logger.Fatal("Failed to create app config dir: ", err)
|
|
||||||
}
|
|
||||||
db = filepath.Join(storagePath, "storage.db")
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Printf("using db file %s", db)
|
|
||||||
|
|
||||||
var username, password string
|
|
||||||
if authfile != "" {
|
|
||||||
f, err := os.Open(authfile)
|
|
||||||
if err != nil {
|
|
||||||
logger.Fatal("Failed to open auth file: ", err)
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
scanner := bufio.NewScanner(f)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
parts := strings.Split(line, ":")
|
|
||||||
if len(parts) != 2 {
|
|
||||||
logger.Fatalf("Invalid auth: %v (expected `username:password`)", line)
|
|
||||||
}
|
|
||||||
username = parts[0]
|
|
||||||
password = parts[1]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (certfile != "" || keyfile != "") && (certfile == "" || keyfile == "") {
|
|
||||||
logger.Fatalf("Both cert & key files are required")
|
|
||||||
}
|
|
||||||
|
|
||||||
store, err := storage.New(db, logger)
|
|
||||||
if err != nil {
|
|
||||||
logger.Fatal("Failed to initialise database: ", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
srv := server.New(store, logger, addr)
|
|
||||||
|
|
||||||
if certfile != "" && keyfile != "" {
|
|
||||||
srv.CertFile = certfile
|
|
||||||
srv.KeyFile = keyfile
|
|
||||||
}
|
|
||||||
|
|
||||||
if username != "" && password != "" {
|
|
||||||
srv.Username = username
|
|
||||||
srv.Password = password
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Printf("starting server at %s", srv.GetAddr())
|
|
||||||
if open {
|
|
||||||
sdopen.Run(srv.GetAddr())
|
|
||||||
}
|
|
||||||
platform.Start(srv)
|
|
||||||
}
|
|
||||||
46
makefile
@@ -1,41 +1,33 @@
|
|||||||
VERSION=1.2
|
VERSION=2.4
|
||||||
GITHASH=$(shell git rev-parse --short=8 HEAD)
|
GITHASH=$(shell git rev-parse --short=8 HEAD)
|
||||||
|
|
||||||
ASSETS = assets/javascripts/* assets/stylesheets/* assets/graphicarts/* assets/index.html
|
|
||||||
CGO_ENABLED=1
|
CGO_ENABLED=1
|
||||||
|
|
||||||
GO_LDFLAGS = -s -w
|
GO_LDFLAGS = -s -w
|
||||||
GO_LDFLAGS := $(GO_LDFLAGS) -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)'
|
GO_LDFLAGS := $(GO_LDFLAGS) -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)'
|
||||||
|
|
||||||
default: bundle
|
build_default:
|
||||||
|
|
||||||
server/assets.go: $(ASSETS)
|
|
||||||
go run scripts/bundle_assets.go >/dev/null
|
|
||||||
|
|
||||||
bundle: server/assets.go
|
|
||||||
|
|
||||||
build_default: bundle
|
|
||||||
mkdir -p _output
|
mkdir -p _output
|
||||||
go build -tags "sqlite_foreign_keys release" -ldflags="$(GO_LDFLAGS)" -o _output/yarr main.go
|
go build -tags "sqlite_foreign_keys" -ldflags="$(GO_LDFLAGS)" -o _output/yarr ./cmd/yarr
|
||||||
|
|
||||||
build_macos: bundle
|
build_macos:
|
||||||
set GOOS=darwin
|
|
||||||
set GOARCH=amd64
|
|
||||||
mkdir -p _output/macos
|
mkdir -p _output/macos
|
||||||
go build -tags "sqlite_foreign_keys release macos" -ldflags="$(GO_LDFLAGS)" -o _output/macos/yarr main.go
|
GOOS=darwin GOARCH=amd64 go build -tags "sqlite_foreign_keys macos" -ldflags="$(GO_LDFLAGS)" -o _output/macos/yarr ./cmd/yarr
|
||||||
cp artwork/icon.png _output/macos/icon.png
|
cp src/platform/icon.png _output/macos/icon.png
|
||||||
go run scripts/package_macos.go -outdir _output/macos -version "$(VERSION)"
|
go run ./cmd/package_macos -outdir _output/macos -version "$(VERSION)"
|
||||||
|
|
||||||
build_linux: bundle
|
build_linux:
|
||||||
set GOOS=linux
|
|
||||||
set GOARCH=386
|
|
||||||
mkdir -p _output/linux
|
mkdir -p _output/linux
|
||||||
go build -tags "sqlite_foreign_keys release linux" -ldflags="$(GO_LDFLAGS)" -o _output/linux/yarr main.go
|
GOOS=linux GOARCH=amd64 go build -tags "sqlite_foreign_keys linux" -ldflags="$(GO_LDFLAGS)" -o _output/linux/yarr ./cmd/yarr
|
||||||
|
|
||||||
build_windows: bundle
|
build_windows:
|
||||||
set GOOS=windows
|
|
||||||
set GOARCH=386
|
|
||||||
mkdir -p _output/windows
|
mkdir -p _output/windows
|
||||||
go run scripts/generate_versioninfo.go -version "$(VERSION)" -outfile artwork/versioninfo.rc
|
go run ./cmd/generate_versioninfo -version "$(VERSION)" -outfile src/platform/versioninfo.rc
|
||||||
windres -i artwork/versioninfo.rc -O coff -o platform/versioninfo.syso
|
windres -i src/platform/versioninfo.rc -O coff -o src/platform/versioninfo.syso
|
||||||
go build -tags "sqlite_foreign_keys release windows" -ldflags="$(GO_LDFLAGS) -H windowsgui" -o _output/windows/yarr.exe main.go
|
GOOS=windows GOARCH=amd64 go build -tags "sqlite_foreign_keys windows" -ldflags="$(GO_LDFLAGS) -H windowsgui" -o _output/windows/yarr.exe ./cmd/yarr
|
||||||
|
|
||||||
|
serve:
|
||||||
|
go run -tags "sqlite_foreign_keys" ./cmd/yarr -db local.db
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test -tags "sqlite_foreign_keys" ./...
|
||||||
|
|||||||
@@ -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
35
readme.md
@@ -1,15 +1,38 @@
|
|||||||
# yarr
|
# 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.
|
## usage
|
||||||
Longer-term plans include a self-hosted solution for individuals.
|
|
||||||
|
|
||||||
[download](https://github.com/nkanaev/yarr/releases/latest)
|
The latest prebuilt binaries for Linux/MacOS/Windows AMD64 are available
|
||||||
|
[here](https://github.com/nkanaev/yarr/releases/latest). Installation instructions:
|
||||||
|
|
||||||
|
* MacOS
|
||||||
|
|
||||||
|
Download `yarr-*-macos64.zip`, unzip it, place `yarr.app` in `/Applications` folder, [open the app][macos-open], click the anchor menu bar icon, select "Open".
|
||||||
|
|
||||||
|
* Windows
|
||||||
|
|
||||||
|
Download `yarr-*-windows64.zip`, unzip it, open `yarr.exe`, click the anchor system tray icon, select "Open".
|
||||||
|
|
||||||
|
* Linux
|
||||||
|
|
||||||
|
Download `yarr-*-linux64.zip`, unzip it, 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
|
## 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,51 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/subtle"
|
|
||||||
"encoding/hex"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func userIsAuthenticated(req *http.Request, username, password string) bool {
|
|
||||||
cookie, _ := req.Cookie("auth")
|
|
||||||
if cookie == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
parts := strings.Split(cookie.Value, ":")
|
|
||||||
if len(parts) != 2 || !stringsEqual(parts[0], username) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return stringsEqual(parts[1], secret(username, password))
|
|
||||||
}
|
|
||||||
|
|
||||||
func userAuthenticate(rw http.ResponseWriter, username, password string) {
|
|
||||||
expires := time.Now().Add(time.Hour * 24 * 7) // 1 week
|
|
||||||
var cookiePath string
|
|
||||||
if BasePath != "" {
|
|
||||||
cookiePath = BasePath
|
|
||||||
} else {
|
|
||||||
cookiePath = "/"
|
|
||||||
}
|
|
||||||
cookie := http.Cookie{
|
|
||||||
Name: "auth",
|
|
||||||
Value: username + ":" + secret(username, password),
|
|
||||||
Expires: expires,
|
|
||||||
Path: cookiePath,
|
|
||||||
}
|
|
||||||
http.SetCookie(rw, &cookie)
|
|
||||||
}
|
|
||||||
|
|
||||||
func stringsEqual(p1, p2 string) bool {
|
|
||||||
return subtle.ConstantTimeCompare([]byte(p1), []byte(p2)) == 1
|
|
||||||
}
|
|
||||||
|
|
||||||
func secret(msg, key string) string {
|
|
||||||
mac := hmac.New(sha256.New, []byte(key))
|
|
||||||
mac.Write([]byte(msg))
|
|
||||||
src := mac.Sum(nil)
|
|
||||||
return hex.EncodeToString(src)
|
|
||||||
}
|
|
||||||
@@ -1,313 +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)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) getConditional(url, lastModified, etag string) (*http.Response, error) {
|
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", c.userAgent)
|
|
||||||
req.Header.Set("If-Modified-Since", lastModified)
|
|
||||||
req.Header.Set("If-None-Match", etag)
|
|
||||||
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, db *storage.Storage) ([]storage.Item, error) {
|
|
||||||
var res *http.Response
|
|
||||||
var err error
|
|
||||||
|
|
||||||
httpState := db.GetHTTPState(f.Id)
|
|
||||||
if httpState != nil {
|
|
||||||
res, err = defaultClient.getConditional(f.FeedLink, httpState.LastModified, httpState.Etag)
|
|
||||||
} else {
|
|
||||||
res, err = defaultClient.get(f.FeedLink)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer res.Body.Close()
|
|
||||||
|
|
||||||
if res.StatusCode/100 == 4 || res.StatusCode/100 == 5 {
|
|
||||||
errmsg := fmt.Sprintf("Failed to list feed items for %s (status: %d)", f.FeedLink, res.StatusCode)
|
|
||||||
return nil, errors.New(errmsg)
|
|
||||||
}
|
|
||||||
|
|
||||||
if res.StatusCode == 304 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
lastModified := res.Header.Get("Last-Modified")
|
|
||||||
etag := res.Header.Get("Etag")
|
|
||||||
if lastModified != "" || etag != "" {
|
|
||||||
db.SetHTTPState(f.Id, lastModified, etag)
|
|
||||||
}
|
|
||||||
|
|
||||||
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,530 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"compress/gzip"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"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).ManualAuth(),
|
|
||||||
p("/static/*path", StaticHandler).ManualAuth(),
|
|
||||||
|
|
||||||
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/errors", FeedErrorsHandler),
|
|
||||||
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) {
|
|
||||||
h := handler(req)
|
|
||||||
if h.requiresAuth() && !userIsAuthenticated(req, h.Username, h.Password) {
|
|
||||||
if req.Method == "POST" {
|
|
||||||
username := req.FormValue("username")
|
|
||||||
password := req.FormValue("password")
|
|
||||||
if stringsEqual(username, h.Username) && stringsEqual(password, h.Password) {
|
|
||||||
userAuthenticate(rw, username, password)
|
|
||||||
http.Redirect(rw, req, req.URL.Path, http.StatusFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if assets != nil {
|
|
||||||
asset := assets["login.html"]
|
|
||||||
rw.Header().Set("Content-Type", "text/html")
|
|
||||||
rw.Header().Set("Content-Encoding", "gzip")
|
|
||||||
rw.Write(*asset.gzip())
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
f, err := os.Open("assets/login.html")
|
|
||||||
if err != nil {
|
|
||||||
handler(req).log.Print(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
io.Copy(rw, f)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 FeedErrorsHandler(rw http.ResponseWriter, req *http.Request) {
|
|
||||||
errors := db(req).GetFeedErrors()
|
|
||||||
writeJSON(rw, errors)
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
|
|
||||||
icon, err := findFavicon(storedFeed.Link, storedFeed.FeedLink)
|
|
||||||
if icon != nil {
|
|
||||||
db(req).UpdateFeedIcon(storedFeed.Id, icon)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
handler(req).log.Printf("Failed to find favicon for %s (%d): %s", storedFeed.FeedLink, storedFeed.Id, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
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.MarkFilter{}
|
|
||||||
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) {
|
|
||||||
if _, ok := settings["refresh_rate"]; ok {
|
|
||||||
handler(req).refreshRate <- db(req).GetSettingsValueInt64("refresh_rate")
|
|
||||||
}
|
|
||||||
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"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
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,50 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"regexp"
|
|
||||||
)
|
|
||||||
|
|
||||||
var BasePath string = ""
|
|
||||||
|
|
||||||
type Route struct {
|
|
||||||
url string
|
|
||||||
urlRegex *regexp.Regexp
|
|
||||||
handler func(http.ResponseWriter, *http.Request)
|
|
||||||
manualAuth bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r Route) ManualAuth() Route {
|
|
||||||
r.manualAuth = true
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
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(reqPath string) (*Route, map[string]string) {
|
|
||||||
vars := make(map[string]string)
|
|
||||||
for _, route := range routes {
|
|
||||||
if route.urlRegex.MatchString(reqPath) {
|
|
||||||
matches := route.urlRegex.FindStringSubmatchIndex(reqPath)
|
|
||||||
for i, key := range route.urlRegex.SubexpNames()[1:] {
|
|
||||||
vars[key] = reqPath[matches[i*2+2]:matches[i*2+3]]
|
|
||||||
}
|
|
||||||
return &route, vars
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
226
server/server.go
@@ -1,226 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/nkanaev/yarr/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Handler struct {
|
|
||||||
Addr string
|
|
||||||
db *storage.Storage
|
|
||||||
log *log.Logger
|
|
||||||
feedQueue chan storage.Feed
|
|
||||||
queueSize *int32
|
|
||||||
refreshRate chan int64
|
|
||||||
// auth
|
|
||||||
Username string
|
|
||||||
Password string
|
|
||||||
// https
|
|
||||||
CertFile string
|
|
||||||
KeyFile string
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
refreshRate: make(chan int64),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) GetAddr() string {
|
|
||||||
proto := "http"
|
|
||||||
if h.CertFile != "" && h.KeyFile != "" {
|
|
||||||
proto = "https"
|
|
||||||
}
|
|
||||||
return proto + "://" + h.Addr + BasePath
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) Start() {
|
|
||||||
h.startJobs()
|
|
||||||
s := &http.Server{Addr: h.Addr, Handler: h}
|
|
||||||
|
|
||||||
var err error
|
|
||||||
if h.CertFile != "" && h.KeyFile != "" {
|
|
||||||
err = s.ListenAndServeTLS(h.CertFile, h.KeyFile)
|
|
||||||
} else {
|
|
||||||
err = s.ListenAndServe()
|
|
||||||
}
|
|
||||||
if err != http.ErrServerClosed {
|
|
||||||
h.log.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func unsafeMethod(method string) bool {
|
|
||||||
return method == "POST" || method == "PUT" || method == "DELETE"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h Handler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|
||||||
reqPath := req.URL.Path
|
|
||||||
if BasePath != "" {
|
|
||||||
if !strings.HasPrefix(reqPath, BasePath) {
|
|
||||||
rw.WriteHeader(http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
reqPath = strings.TrimPrefix(req.URL.Path, BasePath)
|
|
||||||
if reqPath == "" {
|
|
||||||
http.Redirect(rw, req, BasePath+"/", http.StatusFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
route, vars := getRoute(reqPath)
|
|
||||||
if route == nil {
|
|
||||||
rw.WriteHeader(http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.requiresAuth() && !route.manualAuth {
|
|
||||||
if unsafeMethod(req.Method) && req.Header.Get("X-Requested-By") != "yarr" {
|
|
||||||
rw.WriteHeader(http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !userIsAuthenticated(req, h.Username, h.Password) {
|
|
||||||
rw.WriteHeader(http.StatusUnauthorized)
|
|
||||||
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, h.db)
|
|
||||||
atomic.AddInt32(h.queueSize, -1)
|
|
||||||
if err != nil {
|
|
||||||
h.log.Printf("Failed to fetch %s (%d): %s", feed.FeedLink, feed.Id, err)
|
|
||||||
h.db.SetFeedError(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()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
var refreshTicker *time.Ticker
|
|
||||||
refreshTick := make(<-chan time.Time)
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-refreshTick:
|
|
||||||
h.fetchAllFeeds()
|
|
||||||
case val := <-h.refreshRate:
|
|
||||||
if refreshTicker != nil {
|
|
||||||
refreshTicker.Stop()
|
|
||||||
if val == 0 {
|
|
||||||
refreshTick = make(<-chan time.Time)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if val > 0 {
|
|
||||||
refreshTicker = time.NewTicker(time.Duration(val) * time.Minute)
|
|
||||||
refreshTick = refreshTicker.C
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
refreshRate := h.db.GetSettingsValueInt64("refresh_rate")
|
|
||||||
h.refreshRate <- refreshRate
|
|
||||||
if refreshRate > 0 {
|
|
||||||
h.fetchAllFeeds()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h Handler) requiresAuth() bool {
|
|
||||||
return h.Username != "" && h.Password != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) fetchAllFeeds() {
|
|
||||||
h.log.Print("Refreshing all feeds")
|
|
||||||
h.db.ResetFeedErrors()
|
|
||||||
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)
|
||||||
|
}
|
||||||
13
src/assets/assetsfs.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 356 B 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: 270 B After Width: | Height: | Size: 270 B |
|
Before Width: | Height: | Size: 270 B After Width: | Height: | Size: 270 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 +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-chevron-down"><polyline points="6 9 12 15 18 9"></polyline></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-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>
|
||||||
|
Before Width: | Height: | Size: 269 B 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 |
|
Before Width: | Height: | Size: 367 B 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 +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-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>
|
||||||
|
Before Width: | Height: | Size: 341 B 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 |
425
src/assets/index.html
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
<!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" href="./static/graphicarts/favicon.svg" type="image/svg+xml">
|
||||||
|
<link rel="alternate icon" href="./static/graphicarts/favicon.png" type="image/png">
|
||||||
|
<link rel="manifest" href="./manifest.json" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<script>
|
||||||
|
window.app = window.app || {}
|
||||||
|
window.app.settings = {% .settings %}
|
||||||
|
window.app.authenticated = {% .authenticated %}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="theme-{% .settings.theme_name %}">
|
||||||
|
<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'}"
|
||||||
|
title="Unread"
|
||||||
|
@click="filterSelected = 'unread'">
|
||||||
|
<span class="icon">{% inline "circle-full.svg" %}</span>
|
||||||
|
</button>
|
||||||
|
<button class="toolbar-item"
|
||||||
|
:class="{active: filterSelected == 'starred'}"
|
||||||
|
title="Starred"
|
||||||
|
@click="filterSelected = 'starred'">
|
||||||
|
<span class="icon">{% inline "star-full.svg" %}</span>
|
||||||
|
</button>
|
||||||
|
<button class="toolbar-item"
|
||||||
|
:class="{active: filterSelected == ''}"
|
||||||
|
title="All"
|
||||||
|
@click="filterSelected = ''">
|
||||||
|
<span class="icon">{% inline "assorted.svg" %}</span>
|
||||||
|
</button>
|
||||||
|
<div class="flex-grow-1"></div>
|
||||||
|
<dropdown class="settings-dropdown" toggle-class="btn btn-link toolbar-item px-2" ref="menuDropdown" drop="right" title="Settings">
|
||||||
|
<template v-slot:button>
|
||||||
|
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<button class="dropdown-item" @click="showSettings('create')">
|
||||||
|
<span class="icon mr-1">{% inline "plus.svg" %}</span>
|
||||||
|
New Feed
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<button class="dropdown-item" @click="fetchAllFeeds()">
|
||||||
|
<span class="icon mr-1">{% inline "rotate-cw.svg" %}</span>
|
||||||
|
Refresh Feeds
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
|
||||||
|
<header class="dropdown-header">Theme</header>
|
||||||
|
<div class="row text-center m-0">
|
||||||
|
<button class="btn btn-link col-4 px-0 rounded-0"
|
||||||
|
:class="'theme-'+t"
|
||||||
|
@click.stop="theme.name = t"
|
||||||
|
v-for="t in ['light', 'sepia', 'night']">
|
||||||
|
<span class="icon" v-if="theme.name == t">{% inline "check.svg" %}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
|
||||||
|
<header class="dropdown-header">Auto Refresh</header>
|
||||||
|
<div class="row text-center m-0">
|
||||||
|
<button class="dropdown-item col-4 px-0" :class="{active: !refreshRate}" @click.stop="refreshRate = 0">0</button>
|
||||||
|
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 10}" @click.stop="refreshRate = 10">10m</button>
|
||||||
|
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 30}" @click.stop="refreshRate = 30">30m</button>
|
||||||
|
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 60}" @click.stop="refreshRate = 60">1h</button>
|
||||||
|
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 120}" @click.stop="refreshRate = 120">2h</button>
|
||||||
|
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 240}" @click.stop="refreshRate = 240">4h</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
|
||||||
|
<header class="dropdown-header">Show first</header>
|
||||||
|
<div class="d-flex text-center">
|
||||||
|
<button class="dropdown-item px-0" :class="{active: itemSortNewestFirst}" @click.stop="itemSortNewestFirst=true">New</button>
|
||||||
|
<button class="dropdown-item px-0" :class="{active: !itemSortNewestFirst}" @click.stop="itemSortNewestFirst=false">Old</button>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<header class="dropdown-header">Subscriptions</header>
|
||||||
|
<form id="opml-import-form" enctype="multipart/form-data" tabindex="-1">
|
||||||
|
<input type="file"
|
||||||
|
id="opml-import"
|
||||||
|
@change="importOPML"
|
||||||
|
name="opml"
|
||||||
|
style="opacity: 0; width: 1px; height: 0; position: absolute; z-index: -1;">
|
||||||
|
<label class="dropdown-item mb-0 cursor-pointer" for="opml-import" @click.stop="">
|
||||||
|
<span class="icon mr-1">{% inline "download.svg" %}</span>
|
||||||
|
Import
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
<a class="dropdown-item" href="./opml/export">
|
||||||
|
<span class="icon mr-1">{% inline "upload.svg" %}</span>
|
||||||
|
Export
|
||||||
|
</a>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<button class="dropdown-item" @click="showSettings('shortcuts')">
|
||||||
|
<span class="icon mr-1">{% inline "help-circle.svg" %}</span>
|
||||||
|
Shortcuts
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-divider" v-if="authenticated"></div>
|
||||||
|
<button class="dropdown-item" v-if="authenticated" @click="logout()">
|
||||||
|
<span class="icon mr-1">{% inline "log-out.svg" %}</span>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</dropdown>
|
||||||
|
</div>
|
||||||
|
<div id="feed-list-scroll" 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
|
||||||
|
&& !(current.folder.id == folder.id || current.feed.folder_id == folder.id)
|
||||||
|
&& !filteredFolderStats[folder.id]
|
||||||
|
&& (!itemSelectedDetails || (feedsById[itemSelectedDetails.feed_id] || {}).folder_id != folder.id)}">
|
||||||
|
<input type="radio" name="feed" :value="'folder:'+folder.id" v-model="feedSelected" v-if="folder.id">
|
||||||
|
<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
|
||||||
|
&& !(current.feed.id == feed.id)
|
||||||
|
&& !filteredFeedStats[feed.id]
|
||||||
|
&& (!itemSelectedDetails || 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 :src="'./api/feeds/'+feed.id+'/icon'" alt="" loading="lazy"></span>
|
||||||
|
<span class="flex-fill text-left text-truncate">{{ feed.title }}</span>
|
||||||
|
<span class="counter text-right">{{ filteredFeedStats[feed.id] || '' }}</span>
|
||||||
|
<span class="icon flex-shrink-0 mx-2"
|
||||||
|
:title="feed_errors[feed.id]"
|
||||||
|
v-if="!filterSelected && feed_errors[feed.id]">
|
||||||
|
{% inline "alert-circle.svg" %}
|
||||||
|
</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"
|
||||||
|
title="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>
|
||||||
|
<!-- id used by keybindings -->
|
||||||
|
<input id="searchbar" type="" class="d-block toolbar-search" v-model="itemSearch" @keydown.enter="$event.target.blur()">
|
||||||
|
</div>
|
||||||
|
<button class="toolbar-item ml-2"
|
||||||
|
@click="markItemsRead()"
|
||||||
|
v-if="filterSelected == 'unread'"
|
||||||
|
title="Mark All Read">
|
||||||
|
<span class="icon">{% inline "check.svg" %}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
|
||||||
|
<button class="btn btn-link toolbar-item px-2 ml-2" v-if="!current.type" disabled>
|
||||||
|
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
||||||
|
</button>
|
||||||
|
<dropdown class="settings-dropdown"
|
||||||
|
toggle-class="btn btn-link toolbar-item px-2 ml-2"
|
||||||
|
drop="right"
|
||||||
|
title="Feed Settings"
|
||||||
|
v-if="current.type == 'feed'">
|
||||||
|
<template v-slot:button>
|
||||||
|
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
||||||
|
</template>
|
||||||
|
<header class="dropdown-header">{{ current.feed.title }}</header>
|
||||||
|
<a class="dropdown-item" :href="current.feed.link" target="_blank" v-if="current.feed.link">
|
||||||
|
<span class="icon mr-1">{% inline "globe.svg" %}</span>
|
||||||
|
Website
|
||||||
|
</a>
|
||||||
|
<a class="dropdown-item" :href="current.feed.feed_link" target="_blank" v-if="current.feed.feed_link">
|
||||||
|
<span class="icon mr-1">{% inline "rss.svg" %}</span>
|
||||||
|
Feed Link
|
||||||
|
</a>
|
||||||
|
<div class="dropdown-divider" v-if="current.feed.link || current.feed.feed_link"></div>
|
||||||
|
<button class="dropdown-item" @click="renameFeed(current.feed)">
|
||||||
|
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
<button class="dropdown-item" @click="updateFeedLink(current.feed)" v-if="current.feed.feed_link">
|
||||||
|
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
||||||
|
Change Link
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<header class="dropdown-header">Move to...</header>
|
||||||
|
<button class="dropdown-item"
|
||||||
|
v-if="folder.id != current.feed.folder_id"
|
||||||
|
v-for="folder in folders"
|
||||||
|
@click="moveFeed(current.feed, folder)">
|
||||||
|
<span class="icon mr-1">{% inline "folder.svg" %}</span>
|
||||||
|
{{ folder.title }}
|
||||||
|
</button>
|
||||||
|
<button class="dropdown-item text-muted" @click="moveFeed(current.feed, null)" v-if="current.feed.folder_id">
|
||||||
|
<span class="icon mr-1">{% inline "folder-minus.svg" %}</span>
|
||||||
|
──
|
||||||
|
</button>
|
||||||
|
<button class="dropdown-item text-muted" @click="moveFeedToNewFolder(current.feed)">
|
||||||
|
<span class="icon mr-1">{% inline "folder-plus.svg" %}</span>
|
||||||
|
new folder
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<button class="dropdown-item text-danger" @click.prevent="deleteFeed(current.feed)">
|
||||||
|
<span class="icon mr-1">{% inline "trash.svg" %}</span>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</dropdown>
|
||||||
|
<dropdown class="settings-dropdown"
|
||||||
|
toggle-class="btn btn-link toolbar-item px-2 ml-2"
|
||||||
|
title="Folder Settings"
|
||||||
|
drop="right"
|
||||||
|
v-if="current.type == 'folder'">
|
||||||
|
<template v-slot:button>
|
||||||
|
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
||||||
|
</template>
|
||||||
|
<header class="dropdown-header">{{ current.folder.title }}</header>
|
||||||
|
<button class="dropdown-item" @click="renameFolder(current.folder)">
|
||||||
|
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<button class="dropdown-item text-danger" @click="deleteFolder(current.folder)">
|
||||||
|
<span class="icon mr-1">{% inline "trash.svg" %}</span>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</dropdown>
|
||||||
|
</div>
|
||||||
|
<div id="item-list-scroll" 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 v-bind:title="formatDate(item.date)" :val="item.date"/></small>
|
||||||
|
</div>
|
||||||
|
<div>{{ item.title || 'untitled' }}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-link btn-block loading my-3" v-if="itemsHasMore"></button>
|
||||||
|
</div>
|
||||||
|
<div class="px-3 py-2 border-top text-danger text-break" v-if="feed_errors[current.feed.id]">
|
||||||
|
{{ feed_errors[current.feed.id] }}
|
||||||
|
</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="itemSelectedDetails">
|
||||||
|
<button class="toolbar-item"
|
||||||
|
@click="toggleItemStarred(itemSelectedDetails)"
|
||||||
|
title="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"
|
||||||
|
title="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>
|
||||||
|
<dropdown class="settings-dropdown" toggle-class="toolbar-item px-2" drop="center" title="Appearance">
|
||||||
|
<template v-slot:button>
|
||||||
|
<span class="icon">{% inline "sliders.svg" %}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<button class="dropdown-item" :class="{active: !theme.font}" @click.stop="theme.font = ''">sans-serif</button>
|
||||||
|
<button class="dropdown-item font-serif" :class="{active: theme.font == 'serif'}" @click.stop="theme.font = 'serif'">serif</button>
|
||||||
|
<button class="dropdown-item font-monospace" :class="{active: theme.font == 'monospace'}" @click.stop="theme.font = 'monospace'">monospace</button>
|
||||||
|
|
||||||
|
<div class="d-flex text-center">
|
||||||
|
<button class="dropdown-item" style="font-size: 0.8rem" @click.stop="incrFont(-1)">A</button>
|
||||||
|
<button class="dropdown-item" style="font-size: 1.2rem" @click.stop="incrFont(1)">A</button>
|
||||||
|
</div>
|
||||||
|
</dropdown>
|
||||||
|
<button class="toolbar-item"
|
||||||
|
:class="{active: itemSelectedReadability}"
|
||||||
|
@click="toggleReadability()"
|
||||||
|
title="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" title="Open Link">
|
||||||
|
<span class="icon">{% inline "external-link.svg" %}</span>
|
||||||
|
</a>
|
||||||
|
<div class="flex-grow-1"></div>
|
||||||
|
<button class="toolbar-item" @click="itemSelected=null" title="Close Article">
|
||||||
|
<span class="icon">{% inline "x.svg" %}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="itemSelectedDetails"
|
||||||
|
ref="content"
|
||||||
|
class="content px-4 pt-3 pb-5 border-top overflow-auto"
|
||||||
|
:class="{'font-serif': theme.font == 'serif', 'font-monospace': theme.font == 'monospace'}"
|
||||||
|
:style="{'font-size': theme.size + 'rem'}">
|
||||||
|
<div class="content-wrapper">
|
||||||
|
<h1><b>{{ itemSelectedDetails.title || 'untitled' }}</b></h1>
|
||||||
|
<div class="text-muted">
|
||||||
|
<div>
|
||||||
|
<span class="cursor-pointer" @click="feedSelected = 'feed:'+(feedsById[itemSelectedDetails.feed_id] || {}).id">
|
||||||
|
{{ (feedsById[itemSelectedDetails.feed_id] || {}).title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<time>{{ formatDate(itemSelectedDetails.date) }}</time>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div v-if="!itemSelectedReadability">
|
||||||
|
<img :src="itemSelectedDetails.image" v-if="itemSelectedDetails.image" class="mb-3">
|
||||||
|
<audio class="w-100" controls v-if="itemSelectedDetails.podcast_url" :src="itemSelectedDetails.podcast_url"></audio>
|
||||||
|
</div>
|
||||||
|
<div v-html="itemSelectedContent"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<modal :open="!!settings" @hide="settings = ''">
|
||||||
|
<button class="btn btn-link outline-none float-right p-2 mr-n2 mt-n2" style="line-height: 1" @click="settings = ''">
|
||||||
|
<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" placeholder="https://example.com/feed" v-focus>
|
||||||
|
<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" :selected="folder.id === current.feed.folder_id || folder.id === current.folder.id">{{ 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=='shortcuts'">
|
||||||
|
<p class="cursor-default"><b>Keyboard Shortcuts</b></p>
|
||||||
|
|
||||||
|
<table class="table table-borderless table-sm table-compact m-0">
|
||||||
|
<tr><td><kbd>1</kbd> <kbd>2</kbd> <kbd>3</kbd></td>
|
||||||
|
<td>show unread / starred / all feeds</td></tr>
|
||||||
|
<tr><td><kbd>/</kbd></td> <td>focus the search bar</td></tr>
|
||||||
|
|
||||||
|
<tr><td colspan=2> </td></tr>
|
||||||
|
<tr><td><kbd>j</kbd> <kbd>k</kbd></td> <td>next / prev article</td></tr>
|
||||||
|
<tr><td><kbd>l</kbd> <kbd>h</kbd></td> <td>next / prev feed</td></tr>
|
||||||
|
|
||||||
|
<tr><td colspan=2> </td></tr>
|
||||||
|
<tr><td><kbd>R</kbd></td> <td>mark all read</td></tr>
|
||||||
|
<tr><td><kbd>r</kbd></td> <td>mark read / unread</td></tr>
|
||||||
|
<tr><td><kbd>s</kbd></td> <td>mark starred / unstarred</td></tr>
|
||||||
|
<tr><td><kbd>o</kbd></td> <td>open link</td></tr>
|
||||||
|
<tr><td><kbd>i</kbd></td> <td>read here</td> </tr>
|
||||||
|
<tr><td><kbd>f</kbd> <kbd>b</kbd></td> <td>scroll content forward / backward</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</modal>
|
||||||
|
</div>
|
||||||
|
<!-- external -->
|
||||||
|
<script src="./static/javascripts/vue.min.js"></script>
|
||||||
|
<!-- internal -->
|
||||||
|
<script src="./static/javascripts/api.js"></script>
|
||||||
|
<script src="./static/javascripts/app.js"></script>
|
||||||
|
<script src="./static/javascripts/key.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
|
var xfetch = function(resource, init) {
|
||||||
|
init = init || {}
|
||||||
|
if (['post', 'put', 'delete'].indexOf(init.method) !== -1) {
|
||||||
|
init['headers'] = init['headers'] || {}
|
||||||
|
init['headers']['x-requested-by'] = 'yarr'
|
||||||
|
}
|
||||||
|
return fetch(resource, init)
|
||||||
|
}
|
||||||
var api = function(method, endpoint, data) {
|
var api = function(method, endpoint, data) {
|
||||||
var headers = {'Content-Type': 'application/json'}
|
var headers = {'Content-Type': 'application/json'}
|
||||||
if (['post', 'put', 'delete'].indexOf(method) !== -1)
|
return xfetch(endpoint, {
|
||||||
headers['x-requested-by'] = 'yarr'
|
|
||||||
return fetch(endpoint, {
|
|
||||||
method: method,
|
method: method,
|
||||||
headers: headers,
|
headers: headers,
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
@@ -65,6 +71,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
items: {
|
items: {
|
||||||
|
get: function(id) {
|
||||||
|
return api('get', './api/items/' + id).then(json)
|
||||||
|
},
|
||||||
list: function(query) {
|
list: function(query) {
|
||||||
return api('get', './api/items' + param(query)).then(json)
|
return api('get', './api/items' + param(query)).then(json)
|
||||||
},
|
},
|
||||||
@@ -87,15 +96,16 @@
|
|||||||
return api('get', './api/status').then(json)
|
return api('get', './api/status').then(json)
|
||||||
},
|
},
|
||||||
upload_opml: function(form) {
|
upload_opml: function(form) {
|
||||||
return fetch('./opml/import', {
|
return xfetch('./opml/import', {
|
||||||
method: 'post',
|
method: 'post',
|
||||||
body: new FormData(form),
|
body: new FormData(form),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
logout: function() {
|
||||||
|
return api('post', './logout')
|
||||||
|
},
|
||||||
crawl: function(url) {
|
crawl: function(url) {
|
||||||
return fetch('./page?url=' + url).then(function(res) {
|
return api('get', './page?url=' + encodeURIComponent(url)).then(json)
|
||||||
return res.text()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
@@ -2,19 +2,6 @@
|
|||||||
|
|
||||||
var TITLE = document.title
|
var TITLE = document.title
|
||||||
|
|
||||||
function authenticated() {
|
|
||||||
return /auth=.+/g.test(document.cookie)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
var FONTS = [
|
|
||||||
"Arial",
|
|
||||||
"Courier New",
|
|
||||||
"Georgia",
|
|
||||||
"Times New Roman",
|
|
||||||
"Verdana",
|
|
||||||
]
|
|
||||||
|
|
||||||
var debounce = function(callback, wait) {
|
var debounce = function(callback, wait) {
|
||||||
var timeout
|
var timeout
|
||||||
return function() {
|
return function() {
|
||||||
@@ -26,30 +13,6 @@ var debounce = function(callback, 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', {
|
Vue.directive('scroll', {
|
||||||
inserted: function(el, binding) {
|
inserted: function(el, binding) {
|
||||||
el.addEventListener('scroll', debounce(function(event) {
|
el.addEventListener('scroll', debounce(function(event) {
|
||||||
@@ -58,6 +21,12 @@ Vue.directive('scroll', {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Vue.directive('focus', {
|
||||||
|
inserted: function(el) {
|
||||||
|
el.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
Vue.component('drag', {
|
Vue.component('drag', {
|
||||||
props: ['width'],
|
props: ['width'],
|
||||||
template: '<div class="drag"></div>',
|
template: '<div class="drag"></div>',
|
||||||
@@ -83,6 +52,96 @@ Vue.component('drag', {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Vue.component('dropdown', {
|
||||||
|
props: ['class', 'toggle-class', 'ref', 'drop', 'title'],
|
||||||
|
data: function() {
|
||||||
|
return {open: false}
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div class="dropdown" :class="$attrs.class">
|
||||||
|
<button ref="btn" @click="toggle" :class="btnToggleClass" :title="$props.title"><slot name="button"></slot></button>
|
||||||
|
<div ref="menu" class="dropdown-menu" :class="{show: open}"><slot v-if="open"></slot></div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
computed: {
|
||||||
|
btnToggleClass: function() {
|
||||||
|
var c = this.$props.toggleClass || ''
|
||||||
|
c += ' dropdown-toggle dropdown-toggle-no-caret'
|
||||||
|
c += this.open ? ' show' : ''
|
||||||
|
return c.trim()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
toggle: function(e) {
|
||||||
|
this.open ? this.hide() : this.show()
|
||||||
|
},
|
||||||
|
show: function(e) {
|
||||||
|
this.open = true
|
||||||
|
this.$refs.menu.style.top = this.$refs.btn.offsetHeight + 'px'
|
||||||
|
var drop = this.$props.drop
|
||||||
|
|
||||||
|
if (drop === 'right') {
|
||||||
|
this.$refs.menu.style.left = 'auto'
|
||||||
|
this.$refs.menu.style.right = '0'
|
||||||
|
} else if (drop === 'center') {
|
||||||
|
this.$nextTick(function() {
|
||||||
|
var btnWidth = this.$refs.btn.getBoundingClientRect().width
|
||||||
|
var menuWidth = this.$refs.menu.getBoundingClientRect().width
|
||||||
|
this.$refs.menu.style.left = '-' + ((menuWidth - btnWidth) / 2) + 'px'
|
||||||
|
}.bind(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', this.clickHandler)
|
||||||
|
},
|
||||||
|
hide: function() {
|
||||||
|
this.open = false
|
||||||
|
document.removeEventListener('click', this.clickHandler)
|
||||||
|
},
|
||||||
|
clickHandler: function(e) {
|
||||||
|
var dropdown = e.target.closest('.dropdown')
|
||||||
|
if (dropdown == null || dropdown != this.$el) return this.hide()
|
||||||
|
if (e.target.closest('.dropdown-item') != null) return this.hide()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
Vue.component('modal', {
|
||||||
|
props: ['open'],
|
||||||
|
template: `
|
||||||
|
<div class="modal custom-modal" tabindex="-1" v-if="$props.open">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content" ref="content">
|
||||||
|
<div class="modal-body">
|
||||||
|
<slot v-if="$props.open"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
data: function() {
|
||||||
|
return {opening: false}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
'open': function(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.opening = true
|
||||||
|
document.addEventListener('click', this.handleClick)
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('click', this.handleClick)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
handleClick: function(e) {
|
||||||
|
if (this.opening) {
|
||||||
|
this.opening = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.target.closest('.modal-content') == null) this.$emit('hide')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
function dateRepr(d) {
|
function dateRepr(d) {
|
||||||
var sec = (new Date().getTime() - d.getTime()) / 1000
|
var sec = (new Date().getTime() - d.getTime()) / 1000
|
||||||
var neg = sec < 0
|
var neg = sec < 0
|
||||||
@@ -125,58 +184,53 @@ Vue.component('relative-time', {
|
|||||||
|
|
||||||
var vm = new Vue({
|
var vm = new Vue({
|
||||||
created: function() {
|
created: function() {
|
||||||
this.refreshFeeds()
|
|
||||||
this.refreshStats()
|
this.refreshStats()
|
||||||
},
|
.then(this.refreshFeeds.bind(this))
|
||||||
mounted: function() {
|
.then(this.refreshItems.bind(this, false))
|
||||||
this.$root.$on('bv::modal::hidden', function(bvEvent, modalId) {
|
|
||||||
if (vm.settings == 'create') {
|
api.feeds.list_errors().then(function(errors) {
|
||||||
vm.feedNewChoice = []
|
vm.feed_errors = errors
|
||||||
vm.feedNewChoiceSelected = ''
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
data: function() {
|
data: function() {
|
||||||
|
var s = app.settings
|
||||||
return {
|
return {
|
||||||
'filterSelected': undefined,
|
'filterSelected': s.filter,
|
||||||
'folders': [],
|
'folders': [],
|
||||||
'feeds': [],
|
'feeds': [],
|
||||||
'feedSelected': undefined,
|
'feedSelected': s.feed,
|
||||||
'feedListWidth': undefined,
|
'feedListWidth': s.feed_list_width || 300,
|
||||||
'feedNewChoice': [],
|
'feedNewChoice': [],
|
||||||
'feedNewChoiceSelected': '',
|
'feedNewChoiceSelected': '',
|
||||||
'items': [],
|
'items': [],
|
||||||
'itemsPage': {
|
'itemsHasMore': true,
|
||||||
'cur': 1,
|
|
||||||
'num': 1,
|
|
||||||
},
|
|
||||||
'itemSelected': null,
|
'itemSelected': null,
|
||||||
'itemSelectedDetails': {},
|
'itemSelectedDetails': null,
|
||||||
'itemSelectedReadability': '',
|
'itemSelectedReadability': '',
|
||||||
'itemSearch': '',
|
'itemSearch': '',
|
||||||
'itemSortNewestFirst': undefined,
|
'itemSortNewestFirst': s.sort_newest_first,
|
||||||
'itemListWidth': undefined,
|
'itemListWidth': s.item_list_width || 300,
|
||||||
|
|
||||||
'filteredFeedStats': {},
|
'filteredFeedStats': {},
|
||||||
'filteredFolderStats': {},
|
'filteredFolderStats': {},
|
||||||
'filteredTotalStats': null,
|
'filteredTotalStats': null,
|
||||||
|
|
||||||
'settings': 'create',
|
'settings': '',
|
||||||
'loading': {
|
'loading': {
|
||||||
'feeds': 0,
|
'feeds': 0,
|
||||||
'newfeed': false,
|
'newfeed': false,
|
||||||
'items': false,
|
'items': false,
|
||||||
'readability': false,
|
'readability': false,
|
||||||
},
|
},
|
||||||
'fonts': FONTS,
|
'fonts': ['', 'serif', 'monospace'],
|
||||||
'feedStats': {},
|
'feedStats': {},
|
||||||
'theme': {
|
'theme': {
|
||||||
'name': 'light',
|
'name': s.theme_name,
|
||||||
'font': '',
|
'font': s.theme_font,
|
||||||
'size': 1,
|
'size': s.theme_size,
|
||||||
},
|
},
|
||||||
'refreshRate': undefined,
|
'refreshRate': s.refresh_rate,
|
||||||
'authenticated': authenticated(),
|
'authenticated': app.authenticated,
|
||||||
'feed_errors': {},
|
'feed_errors': {},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -197,10 +251,24 @@ var vm = new Vue({
|
|||||||
return folders
|
return folders
|
||||||
},
|
},
|
||||||
feedsById: function() {
|
feedsById: function() {
|
||||||
return this.feeds.reduce(function(acc, feed) { acc[feed.id] = feed; return acc }, {})
|
return this.feeds.reduce(function(acc, f) { acc[f.id] = f; return acc }, {})
|
||||||
},
|
},
|
||||||
itemsById: function() {
|
foldersById: function() {
|
||||||
return this.items.reduce(function(acc, item) { acc[item.id] = item; return acc }, {})
|
return this.folders.reduce(function(acc, f) { acc[f.id] = f; return acc }, {})
|
||||||
|
},
|
||||||
|
current: function() {
|
||||||
|
var parts = (this.feedSelected || '').split(':', 2)
|
||||||
|
var type = parts[0]
|
||||||
|
var guid = parts[1]
|
||||||
|
|
||||||
|
var folder = {}, feed = {}
|
||||||
|
|
||||||
|
if (type == 'feed')
|
||||||
|
feed = this.feedsById[guid] || {}
|
||||||
|
if (type == 'folder')
|
||||||
|
folder = this.foldersById[guid] || {}
|
||||||
|
|
||||||
|
return {type: type, feed: feed, folder: folder}
|
||||||
},
|
},
|
||||||
itemSelectedContent: function() {
|
itemSelectedContent: function() {
|
||||||
if (!this.itemSelected) return ''
|
if (!this.itemSelected) return ''
|
||||||
@@ -208,13 +276,7 @@ var vm = new Vue({
|
|||||||
if (this.itemSelectedReadability)
|
if (this.itemSelectedReadability)
|
||||||
return this.itemSelectedReadability
|
return this.itemSelectedReadability
|
||||||
|
|
||||||
var content = ''
|
return this.itemSelectedDetails.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: {
|
watch: {
|
||||||
@@ -245,13 +307,13 @@ var vm = new Vue({
|
|||||||
},
|
},
|
||||||
'filterSelected': function(newVal, oldVal) {
|
'filterSelected': function(newVal, oldVal) {
|
||||||
if (oldVal === undefined) return // do nothing, initial setup
|
if (oldVal === undefined) return // do nothing, initial setup
|
||||||
api.settings.update({filter: newVal}).then(this.refreshItems.bind(this))
|
api.settings.update({filter: newVal}).then(this.refreshItems.bind(this, false))
|
||||||
this.itemSelected = null
|
this.itemSelected = null
|
||||||
this.computeStats()
|
this.computeStats()
|
||||||
},
|
},
|
||||||
'feedSelected': function(newVal, oldVal) {
|
'feedSelected': function(newVal, oldVal) {
|
||||||
if (oldVal === undefined) return // do nothing, initial setup
|
if (oldVal === undefined) return // do nothing, initial setup
|
||||||
api.settings.update({feed: newVal}).then(this.refreshItems.bind(this))
|
api.settings.update({feed: newVal}).then(this.refreshItems.bind(this, false))
|
||||||
this.itemSelected = null
|
this.itemSelected = null
|
||||||
if (this.$refs.itemlist) this.$refs.itemlist.scrollTop = 0
|
if (this.$refs.itemlist) this.$refs.itemlist.scrollTop = 0
|
||||||
},
|
},
|
||||||
@@ -263,19 +325,24 @@ var vm = new Vue({
|
|||||||
}
|
}
|
||||||
if (this.$refs.content) this.$refs.content.scrollTop = 0
|
if (this.$refs.content) this.$refs.content.scrollTop = 0
|
||||||
|
|
||||||
this.itemSelectedDetails = this.itemsById[newVal]
|
api.items.get(newVal).then(function(item) {
|
||||||
if (this.itemSelectedDetails.status == 'unread') {
|
this.itemSelectedDetails = item
|
||||||
this.itemSelectedDetails.status = 'read'
|
if (this.itemSelectedDetails.status == 'unread') {
|
||||||
this.feedStats[this.itemSelectedDetails.feed_id].unread -= 1
|
api.items.update(this.itemSelectedDetails.id, {status: 'read'}).then(function() {
|
||||||
api.items.update(this.itemSelectedDetails.id, {status: this.itemSelectedDetails.status})
|
this.feedStats[this.itemSelectedDetails.feed_id].unread -= 1
|
||||||
}
|
var itemInList = this.items.find(function(i) { return i.id == item.id })
|
||||||
|
if (itemInList) itemInList.status = 'read'
|
||||||
|
this.itemSelectedDetails.status = 'read'
|
||||||
|
}.bind(this))
|
||||||
|
}
|
||||||
|
}.bind(this))
|
||||||
},
|
},
|
||||||
'itemSearch': debounce(function(newVal) {
|
'itemSearch': debounce(function(newVal) {
|
||||||
this.refreshItems()
|
this.refreshItems()
|
||||||
}, 500),
|
}, 500),
|
||||||
'itemSortNewestFirst': function(newVal, oldVal) {
|
'itemSortNewestFirst': function(newVal, oldVal) {
|
||||||
if (oldVal === undefined) return // do nothing, initial setup
|
if (oldVal === undefined) return // do nothing, initial setup
|
||||||
api.settings.update({sort_newest_first: newVal}).then(this.refreshItems.bind(this))
|
api.settings.update({sort_newest_first: newVal}).then(vm.refreshItems.bind(this, false))
|
||||||
},
|
},
|
||||||
'feedListWidth': debounce(function(newVal, oldVal) {
|
'feedListWidth': debounce(function(newVal, oldVal) {
|
||||||
if (oldVal === undefined) return // do nothing, initial setup
|
if (oldVal === undefined) return // do nothing, initial setup
|
||||||
@@ -292,7 +359,7 @@ var vm = new Vue({
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
refreshStats: function(loopMode) {
|
refreshStats: function(loopMode) {
|
||||||
api.status().then(function(data) {
|
return api.status().then(function(data) {
|
||||||
if (loopMode && !vm.itemSelected) vm.refreshItems()
|
if (loopMode && !vm.itemSelected) vm.refreshItems()
|
||||||
|
|
||||||
vm.loading.feeds = data.running
|
vm.loading.feeds = data.running
|
||||||
@@ -303,6 +370,10 @@ var vm = new Vue({
|
|||||||
acc[stat.feed_id] = stat
|
acc[stat.feed_id] = stat
|
||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
|
|
||||||
|
api.feeds.list_errors().then(function(errors) {
|
||||||
|
vm.feed_errors = errors
|
||||||
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
getItemsQuery: function() {
|
getItemsQuery: function() {
|
||||||
@@ -336,34 +407,49 @@ var vm = new Vue({
|
|||||||
vm.feeds = values[1]
|
vm.feeds = values[1]
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
refreshItems: function() {
|
refreshItems: function(loadMore) {
|
||||||
if (this.feedSelected === null) {
|
if (this.feedSelected === null) {
|
||||||
vm.items = []
|
vm.items = []
|
||||||
vm.itemsPage = {'cur': 1, 'num': 1}
|
vm.itemsHasMore = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var query = this.getItemsQuery()
|
var query = this.getItemsQuery()
|
||||||
|
if (loadMore) {
|
||||||
|
query.after = vm.items[vm.items.length-1].id
|
||||||
|
}
|
||||||
|
|
||||||
this.loading.items = true
|
this.loading.items = true
|
||||||
return api.items.list(query).then(function(data) {
|
api.items.list(query).then(function(data) {
|
||||||
vm.items = data.list
|
if (loadMore) {
|
||||||
vm.itemsPage = data.page
|
vm.items = vm.items.concat(data.list)
|
||||||
|
} else {
|
||||||
|
vm.items = data.list
|
||||||
|
}
|
||||||
|
vm.itemsHasMore = data.has_more
|
||||||
vm.loading.items = false
|
vm.loading.items = false
|
||||||
|
|
||||||
|
// load more if there's some space left at the bottom of the item list.
|
||||||
|
vm.$nextTick(function() {
|
||||||
|
if (vm.itemsHasMore && !vm.loading.items && vm.itemListCloseToBottom()) {
|
||||||
|
vm.refreshItems(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
itemListCloseToBottom: function() {
|
||||||
|
// approx. vertical space at the bottom of the list (loading el & paddings) when 1rem = 16px
|
||||||
|
var bottomSpace = 70
|
||||||
|
var scale = (parseFloat(getComputedStyle(document.documentElement).fontSize) || 16) / 16
|
||||||
|
|
||||||
|
var el = this.$refs.itemlist
|
||||||
|
var closeToBottom = (el.scrollHeight - el.scrollTop - el.offsetHeight) < bottomSpace * scale
|
||||||
|
return closeToBottom
|
||||||
|
},
|
||||||
loadMoreItems: function(event, el) {
|
loadMoreItems: function(event, el) {
|
||||||
if (this.itemsPage.cur >= this.itemsPage.num) return
|
if (!this.itemsHasMore) return
|
||||||
if (this.loading.items) return
|
if (this.loading.items) return
|
||||||
var closeToBottom = (el.scrollHeight - el.scrollTop - el.offsetHeight) < 50
|
if (this.itemListCloseToBottom()) this.refreshItems(true)
|
||||||
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() {
|
markItemsRead: function() {
|
||||||
var query = this.getItemsQuery()
|
var query = this.getItemsQuery()
|
||||||
@@ -371,6 +457,7 @@ var vm = new Vue({
|
|||||||
vm.items = []
|
vm.items = []
|
||||||
vm.itemsPage = {'cur': 1, 'num': 1}
|
vm.itemsPage = {'cur': 1, 'num': 1}
|
||||||
vm.itemSelected = null
|
vm.itemSelected = null
|
||||||
|
vm.itemsHasMore = false
|
||||||
vm.refreshStats()
|
vm.refreshStats()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -389,6 +476,7 @@ var vm = new Vue({
|
|||||||
var folder_id = folder ? folder.id : null
|
var folder_id = folder ? folder.id : null
|
||||||
api.feeds.update(feed.id, {folder_id: folder_id}).then(function() {
|
api.feeds.update(feed.id, {folder_id: folder_id}).then(function() {
|
||||||
feed.folder_id = folder_id
|
feed.folder_id = folder_id
|
||||||
|
vm.refreshStats()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
moveFeedToNewFolder: function(feed) {
|
moveFeedToNewFolder: function(feed) {
|
||||||
@@ -396,7 +484,9 @@ var vm = new Vue({
|
|||||||
if (!title) return
|
if (!title) return
|
||||||
api.folders.create({'title': title}).then(function(folder) {
|
api.folders.create({'title': title}).then(function(folder) {
|
||||||
api.feeds.update(feed.id, {folder_id: folder.id}).then(function() {
|
api.feeds.update(feed.id, {folder_id: folder.id}).then(function() {
|
||||||
vm.refreshFeeds()
|
vm.refreshFeeds().then(function() {
|
||||||
|
vm.refreshStats()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -418,21 +508,29 @@ var vm = new Vue({
|
|||||||
if (newTitle) {
|
if (newTitle) {
|
||||||
api.folders.update(folder.id, {title: newTitle}).then(function() {
|
api.folders.update(folder.id, {title: newTitle}).then(function() {
|
||||||
folder.title = newTitle
|
folder.title = newTitle
|
||||||
})
|
this.folders.sort(function(a, b) {
|
||||||
|
return a.title.localeCompare(b.title)
|
||||||
|
})
|
||||||
|
}.bind(this))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
deleteFolder: function(folder) {
|
deleteFolder: function(folder) {
|
||||||
if (confirm('Are you sure you want to delete ' + folder.title + '?')) {
|
if (confirm('Are you sure you want to delete ' + folder.title + '?')) {
|
||||||
api.folders.delete(folder.id).then(function() {
|
api.folders.delete(folder.id).then(function() {
|
||||||
if (vm.feedSelected === 'folder:'+folder.id) {
|
vm.feedSelected = null
|
||||||
vm.items = []
|
|
||||||
vm.feedSelected = ''
|
|
||||||
}
|
|
||||||
vm.refreshStats()
|
vm.refreshStats()
|
||||||
vm.refreshFeeds()
|
vm.refreshFeeds()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
updateFeedLink: function(feed) {
|
||||||
|
var newLink = prompt('Enter feed link', feed.feed_link)
|
||||||
|
if (newLink) {
|
||||||
|
api.feeds.update(feed.id, {feed_link: newLink}).then(function() {
|
||||||
|
feed.feed_link = newLink
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
renameFeed: function(feed) {
|
renameFeed: function(feed) {
|
||||||
var newTitle = prompt('Enter new title', feed.title)
|
var newTitle = prompt('Enter new title', feed.title)
|
||||||
if (newTitle) {
|
if (newTitle) {
|
||||||
@@ -444,12 +542,7 @@ var vm = new Vue({
|
|||||||
deleteFeed: function(feed) {
|
deleteFeed: function(feed) {
|
||||||
if (confirm('Are you sure you want to delete ' + feed.title + '?')) {
|
if (confirm('Are you sure you want to delete ' + feed.title + '?')) {
|
||||||
api.feeds.delete(feed.id).then(function() {
|
api.feeds.delete(feed.id).then(function() {
|
||||||
// unselect feed to prevent reading properties of null in template
|
vm.feedSelected = null
|
||||||
var isSelected = !vm.feedSelected
|
|
||||||
|| (vm.feedSelected === 'feed:'+feed.id
|
|
||||||
|| (feed.folder_id && vm.feedSelected === 'folder:'+feed.folder_id));
|
|
||||||
if (isSelected) vm.feedSelected = null
|
|
||||||
|
|
||||||
vm.refreshStats()
|
vm.refreshStats()
|
||||||
vm.refreshFeeds()
|
vm.refreshFeeds()
|
||||||
})
|
})
|
||||||
@@ -469,7 +562,8 @@ var vm = new Vue({
|
|||||||
if (result.status === 'success') {
|
if (result.status === 'success') {
|
||||||
vm.refreshFeeds()
|
vm.refreshFeeds()
|
||||||
vm.refreshStats()
|
vm.refreshStats()
|
||||||
vm.$bvModal.hide('settings-modal')
|
vm.settings = ''
|
||||||
|
vm.feedSelected = 'feed:' + result.feed.id
|
||||||
} else if (result.status === 'multiple') {
|
} else if (result.status === 'multiple') {
|
||||||
vm.feedNewChoice = result.choice
|
vm.feedNewChoice = result.choice
|
||||||
vm.feedNewChoiceSelected = result.choice[0].url
|
vm.feedNewChoiceSelected = result.choice[0].url
|
||||||
@@ -479,25 +573,30 @@ var vm = new Vue({
|
|||||||
vm.loading.newfeed = false
|
vm.loading.newfeed = false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
toggleItemStatus: function(item, targetstatus, fallbackstatus) {
|
||||||
|
var oldstatus = item.status
|
||||||
|
var newstatus = item.status !== targetstatus ? targetstatus : fallbackstatus
|
||||||
|
|
||||||
|
var updateStats = function(status, incr) {
|
||||||
|
if ((status == 'unread') || (status == 'starred')) {
|
||||||
|
this.feedStats[item.feed_id][status] += incr
|
||||||
|
}
|
||||||
|
}.bind(this)
|
||||||
|
|
||||||
|
api.items.update(item.id, {status: newstatus}).then(function() {
|
||||||
|
updateStats(oldstatus, -1)
|
||||||
|
updateStats(newstatus, +1)
|
||||||
|
|
||||||
|
var itemInList = this.items.find(function(i) { return i.id == item.id })
|
||||||
|
if (itemInList) itemInList.status = newstatus
|
||||||
|
item.status = newstatus
|
||||||
|
}.bind(this))
|
||||||
|
},
|
||||||
toggleItemStarred: function(item) {
|
toggleItemStarred: function(item) {
|
||||||
if (item.status == 'starred') {
|
this.toggleItemStatus(item, 'starred', 'read')
|
||||||
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) {
|
toggleItemRead: function(item) {
|
||||||
if (item.status == 'unread') {
|
this.toggleItemStatus(item, 'unread', 'read')
|
||||||
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) {
|
importOPML: function(event) {
|
||||||
var input = event.target
|
var input = event.target
|
||||||
@@ -509,33 +608,32 @@ var vm = new Vue({
|
|||||||
vm.refreshStats()
|
vm.refreshStats()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
getReadable: function(item) {
|
logout: function() {
|
||||||
|
api.logout().then(function() {
|
||||||
|
document.location.reload()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
toggleReadability: function() {
|
||||||
if (this.itemSelectedReadability) {
|
if (this.itemSelectedReadability) {
|
||||||
this.itemSelectedReadability = null
|
this.itemSelectedReadability = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
var item = this.itemSelectedDetails
|
||||||
|
if (!item) return
|
||||||
if (item.link) {
|
if (item.link) {
|
||||||
this.loading.readability = true
|
this.loading.readability = true
|
||||||
api.crawl(item.link).then(function(body) {
|
api.crawl(item.link).then(function(data) {
|
||||||
|
vm.itemSelectedReadability = data && data.content
|
||||||
vm.loading.readability = false
|
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) {
|
showSettings: function(settings) {
|
||||||
this.settings = settings
|
this.settings = settings
|
||||||
this.$bvModal.show('settings-modal')
|
|
||||||
|
|
||||||
if (settings === 'manage') {
|
if (settings === 'create') {
|
||||||
api.feeds.list_errors().then(function(errors) {
|
vm.feedNewChoice = []
|
||||||
vm.feed_errors = errors
|
vm.feedNewChoiceSelected = ''
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
resizeFeedList: function(width) {
|
resizeFeedList: function(width) {
|
||||||
@@ -552,7 +650,10 @@ var vm = new Vue({
|
|||||||
this.theme.size = +(this.theme.size + (0.1 * x)).toFixed(1)
|
this.theme.size = +(this.theme.size + (0.1 * x)).toFixed(1)
|
||||||
},
|
},
|
||||||
fetchAllFeeds: function() {
|
fetchAllFeeds: function() {
|
||||||
api.feeds.refresh().then(this.refreshStats.bind(this))
|
if (this.loading.feeds) return
|
||||||
|
api.feeds.refresh().then(function() {
|
||||||
|
vm.refreshStats()
|
||||||
|
})
|
||||||
},
|
},
|
||||||
computeStats: function() {
|
computeStats: function() {
|
||||||
var filter = this.filterSelected
|
var filter = this.filterSelected
|
||||||
@@ -585,16 +686,4 @@ var vm = new Vue({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
api.settings.get().then(function(data) {
|
vm.$mount('#app')
|
||||||
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.refreshRate = data.refresh_rate
|
|
||||||
vm.refreshItems()
|
|
||||||
vm.$mount('#app')
|
|
||||||
})
|
|
||||||
208
src/assets/javascripts/key.js
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
function scrollto(target, scroll) {
|
||||||
|
var padding = 10
|
||||||
|
var targetRect = target.getBoundingClientRect()
|
||||||
|
var scrollRect = scroll.getBoundingClientRect()
|
||||||
|
|
||||||
|
// target
|
||||||
|
var relativeOffset = targetRect.y - scrollRect.y
|
||||||
|
var absoluteOffset = relativeOffset + scroll.scrollTop
|
||||||
|
|
||||||
|
if (padding <= relativeOffset && relativeOffset + targetRect.height <= scrollRect.height - padding) return
|
||||||
|
|
||||||
|
var newPos = scroll.scrollTop
|
||||||
|
if (relativeOffset < padding) {
|
||||||
|
newPos = absoluteOffset - padding
|
||||||
|
} else {
|
||||||
|
newPos = absoluteOffset - scrollRect.height + targetRect.height + padding
|
||||||
|
}
|
||||||
|
scroll.scrollTop = Math.round(newPos)
|
||||||
|
}
|
||||||
|
|
||||||
|
var helperFunctions = {
|
||||||
|
// navigation helper, navigate relative to selected item
|
||||||
|
navigateToItem: function(relativePosition) {
|
||||||
|
if (vm.itemSelected == null) {
|
||||||
|
// if no item is selected, select first
|
||||||
|
if (vm.items.length !== 0) vm.itemSelected = vm.items[0].id
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemPosition = vm.items.findIndex(function(x) { return x.id === vm.itemSelected })
|
||||||
|
if (itemPosition === -1) {
|
||||||
|
if (vm.items.length !== 0) vm.itemSelected = vm.items[0].id
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var newPosition = itemPosition + relativePosition
|
||||||
|
if (newPosition < 0 || newPosition >= vm.items.length) return
|
||||||
|
|
||||||
|
vm.itemSelected = vm.items[newPosition].id
|
||||||
|
|
||||||
|
vm.$nextTick(function() {
|
||||||
|
var scroll = document.querySelector('#item-list-scroll')
|
||||||
|
|
||||||
|
var handle = scroll.querySelector('input[type=radio]:checked')
|
||||||
|
var target = handle && handle.parentElement
|
||||||
|
|
||||||
|
if (target && scroll) scrollto(target, scroll)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
// navigation helper, navigate relative to selected feed
|
||||||
|
navigateToFeed: function(relativePosition) {
|
||||||
|
var navigationList = Array.from(document.querySelectorAll('#col-feed-list input[name=feed]'))
|
||||||
|
.filter(function(r) { return r.offsetParent !== null && r.value !== 'folder:null' })
|
||||||
|
.map(function(r) { return r.value })
|
||||||
|
|
||||||
|
var currentFeedPosition = navigationList.indexOf(vm.feedSelected)
|
||||||
|
|
||||||
|
if (currentFeedPosition == -1) {
|
||||||
|
vm.feedSelected = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var newPosition = currentFeedPosition+relativePosition
|
||||||
|
if (newPosition < 0 || newPosition >= navigationList.length) return
|
||||||
|
|
||||||
|
vm.feedSelected = navigationList[newPosition]
|
||||||
|
|
||||||
|
vm.$nextTick(function() {
|
||||||
|
var scroll = document.querySelector('#feed-list-scroll')
|
||||||
|
|
||||||
|
var handle = scroll.querySelector('input[type=radio]:checked')
|
||||||
|
var target = handle && handle.parentElement
|
||||||
|
|
||||||
|
if (target && scroll) scrollto(target, scroll)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
scrollContent: function(direction) {
|
||||||
|
var padding = 40
|
||||||
|
var scroll = document.querySelector('.content')
|
||||||
|
if (!scroll) return
|
||||||
|
|
||||||
|
var height = scroll.getBoundingClientRect().height
|
||||||
|
var newpos = scroll.scrollTop + (height - padding) * direction
|
||||||
|
|
||||||
|
if (typeof scroll.scrollTo == 'function') {
|
||||||
|
scroll.scrollTo({top: newpos, left: 0, behavior: 'smooth'})
|
||||||
|
} else {
|
||||||
|
scroll.scrollTop = newpos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var shortcutFunctions = {
|
||||||
|
openItemLink: function() {
|
||||||
|
if (vm.itemSelectedDetails && vm.itemSelectedDetails.link) {
|
||||||
|
window.open(vm.itemSelectedDetails.link, '_blank')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toggleReadability: function() {
|
||||||
|
vm.toggleReadability()
|
||||||
|
},
|
||||||
|
toggleItemRead: function() {
|
||||||
|
if (vm.itemSelected != null) {
|
||||||
|
vm.toggleItemRead(vm.itemSelectedDetails)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
markAllRead: function() {
|
||||||
|
// same condition as 'Mark all read button'
|
||||||
|
if (vm.filterSelected == 'unread'){
|
||||||
|
vm.markItemsRead()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toggleItemStarred: function() {
|
||||||
|
if (vm.itemSelected != null) {
|
||||||
|
vm.toggleItemStarred(vm.itemSelectedDetails)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
focusSearch: function() {
|
||||||
|
document.getElementById("searchbar").focus()
|
||||||
|
},
|
||||||
|
nextItem(){
|
||||||
|
helperFunctions.navigateToItem(+1)
|
||||||
|
},
|
||||||
|
previousItem() {
|
||||||
|
helperFunctions.navigateToItem(-1)
|
||||||
|
},
|
||||||
|
nextFeed(){
|
||||||
|
helperFunctions.navigateToFeed(+1)
|
||||||
|
},
|
||||||
|
previousFeed() {
|
||||||
|
helperFunctions.navigateToFeed(-1)
|
||||||
|
},
|
||||||
|
scrollForward: function() {
|
||||||
|
helperFunctions.scrollContent(+1)
|
||||||
|
},
|
||||||
|
scrollBackward: function() {
|
||||||
|
helperFunctions.scrollContent(-1)
|
||||||
|
},
|
||||||
|
showAll() {
|
||||||
|
vm.filterSelected = ''
|
||||||
|
},
|
||||||
|
showUnread() {
|
||||||
|
vm.filterSelected = 'unread'
|
||||||
|
},
|
||||||
|
showStarred() {
|
||||||
|
vm.filterSelected = 'starred'
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// If you edit, make sure you update the help modal
|
||||||
|
var keybindings = {
|
||||||
|
"o": shortcutFunctions.openItemLink,
|
||||||
|
"i": shortcutFunctions.toggleReadability,
|
||||||
|
"r": shortcutFunctions.toggleItemRead,
|
||||||
|
"R": shortcutFunctions.markAllRead,
|
||||||
|
"s": shortcutFunctions.toggleItemStarred,
|
||||||
|
"/": shortcutFunctions.focusSearch,
|
||||||
|
"j": shortcutFunctions.nextItem,
|
||||||
|
"k": shortcutFunctions.previousItem,
|
||||||
|
"l": shortcutFunctions.nextFeed,
|
||||||
|
"h": shortcutFunctions.previousFeed,
|
||||||
|
"f": shortcutFunctions.scrollForward,
|
||||||
|
"b": shortcutFunctions.scrollBackward,
|
||||||
|
"1": shortcutFunctions.showUnread,
|
||||||
|
"2": shortcutFunctions.showStarred,
|
||||||
|
"3": shortcutFunctions.showAll,
|
||||||
|
}
|
||||||
|
|
||||||
|
var codebindings = {
|
||||||
|
"KeyO": shortcutFunctions.openItemLink,
|
||||||
|
"KeyI": shortcutFunctions.toggleReadability,
|
||||||
|
//"r": shortcutFunctions.toggleItemRead,
|
||||||
|
//"KeyR": shortcutFunctions.markAllRead,
|
||||||
|
"KeyS": shortcutFunctions.toggleItemStarred,
|
||||||
|
"Slash": shortcutFunctions.focusSearch,
|
||||||
|
"KeyJ": shortcutFunctions.nextItem,
|
||||||
|
"KeyK": shortcutFunctions.previousItem,
|
||||||
|
"KeyL": shortcutFunctions.nextFeed,
|
||||||
|
"KeyH": shortcutFunctions.previousFeed,
|
||||||
|
"KeyF": shortcutFunctions.scrollForward,
|
||||||
|
"KeyB": shortcutFunctions.scrollBackward,
|
||||||
|
"Digit1": shortcutFunctions.showUnread,
|
||||||
|
"Digit2": shortcutFunctions.showStarred,
|
||||||
|
"Digit3": shortcutFunctions.showAll,
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTextBox(element) {
|
||||||
|
var tagName = element.tagName.toLowerCase()
|
||||||
|
// Input elements that aren't text
|
||||||
|
var inputBlocklist = ['button','checkbox','color','file','hidden','image','radio','range','reset','search','submit']
|
||||||
|
|
||||||
|
return tagName === 'textarea' ||
|
||||||
|
( tagName === 'input'
|
||||||
|
&& inputBlocklist.indexOf(element.getAttribute('type').toLowerCase()) == -1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown',function(event) {
|
||||||
|
// Ignore while focused on text or
|
||||||
|
// when using modifier keys (to not clash with browser behaviour)
|
||||||
|
if (isTextBox(event.target) || event.metaKey || event.ctrlKey || event.altKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var keybindFunction = keybindings[event.key] || codebindings[event.code]
|
||||||
|
if (keybindFunction) {
|
||||||
|
event.preventDefault()
|
||||||
|
keybindFunction()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -5,7 +5,8 @@
|
|||||||
<title>yarr!</title>
|
<title>yarr!</title>
|
||||||
<link rel="stylesheet" href="./static/stylesheets/bootstrap.min.css">
|
<link rel="stylesheet" href="./static/stylesheets/bootstrap.min.css">
|
||||||
<link rel="stylesheet" href="./static/stylesheets/app.css">
|
<link rel="stylesheet" href="./static/stylesheets/app.css">
|
||||||
<link rel="icon shortcut" href="./static/graphicarts/anchor.png">
|
<link rel="icon" href="./static/graphicarts/favicon.svg" type="image/svg+xml">
|
||||||
|
<link rel="alternate icon" href="./static/graphicarts/favicon.png" type="image/png">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<style>
|
<style>
|
||||||
form {
|
form {
|
||||||
@@ -21,16 +22,20 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="theme-{% .settings.theme_name %}">
|
||||||
<form action="" method="post">
|
<form action="" method="post">
|
||||||
<img src="./static/graphicarts/anchor.svg" alt="">
|
<img src="./static/graphicarts/anchor.svg" alt="">
|
||||||
|
{% if .error %}
|
||||||
|
<div class="text-danger text-center my-3">{% .error %}</div>
|
||||||
|
{% end %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="username">Username</label>
|
<label for="username">Username</label>
|
||||||
<input name="username" class="form-control" id="username" autocomplete="off">
|
<input name="username" class="form-control" id="username" autocomplete="off"
|
||||||
|
value="{% if .username %}{% .username %}{% end %}" required autofocus>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input name="password" class="form-control" id="password" type="password">
|
<input name="password" class="form-control" id="password" type="password" required>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-block btn-default" type="submit">Login</button>
|
<button class="btn btn-block btn-default" type="submit">Login</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -2,18 +2,22 @@
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
html {
|
||||||
font-size: 15px !important;
|
font-size: 15px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* bootstrap customizations */
|
/* bootstrap customizations */
|
||||||
|
|
||||||
.btn-link {
|
.btn-link {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control {
|
.form-control, .form-control:focus {
|
||||||
box-shadow: inset 0px 1px 1px rgba(0, 0, 0, 0.07);
|
box-shadow: inset 0px 1px 1px rgba(0, 0, 0, 0.07) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
select.form-control {
|
select.form-control {
|
||||||
@@ -22,12 +26,11 @@ select.form-control {
|
|||||||
|
|
||||||
select.form-control:not([multiple]):not([size]) {
|
select.form-control:not([multiple]):not([size]) {
|
||||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||||
background: #fff url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%204%205'%3E%3Cpath%20fill='%23667189'%20d='M2%200L0%202h4zm0%205L0%203h4z'/%3E%3C/svg%3E") no-repeat right .35rem center/.6rem .6rem;
|
|
||||||
padding-right: 1.2rem;
|
padding-right: 1.2rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control:focus, .btn:focus {
|
.btn:focus {
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,26 +46,25 @@ select.form-control:not([multiple]):not([size]) {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#settings-modal {
|
|
||||||
color: #212529 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-dropdown .dropdown-toggle {
|
.settings-dropdown .dropdown-toggle {
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-menu {
|
.settings-dropdown .dropdown-menu {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
box-shadow: 0px 1px 5px rgba(0, 0, 0, 0.07);
|
box-shadow: 0px 1px 5px rgba(0, 0, 0, 0.07);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
max-height: calc(100vh - 3rem);
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-item, .dropdown-header {
|
.settings-dropdown .dropdown-item,
|
||||||
|
.settings-dropdown .dropdown-header {
|
||||||
padding: .375rem 1rem;
|
padding: .375rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-divider {
|
.settings-dropdown .dropdown-divider {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,28 +72,15 @@ select.form-control:not([multiple]):not([size]) {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-dropdown .dropdown-item {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-dropdown .dropdown-item:focus {
|
.settings-dropdown .dropdown-item:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-dropdown.large .dropdown-item {
|
.settings-dropdown form:focus {
|
||||||
padding: .5rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-danger .dropdown-item {
|
|
||||||
color: #dc3545!important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-backdrop {
|
|
||||||
background-color: rgba(0, 0, 0, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal.fade .modal-dialog {
|
|
||||||
transition: none !important;
|
|
||||||
transform: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.b-dropdown-form:focus {
|
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,17 +88,28 @@ select.form-control:not([multiple]):not([size]) {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.b-tooltip {
|
.table-compact {
|
||||||
opacity: 1;
|
color: unset !important;
|
||||||
font-size: .7rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.b-tooltip:focus {
|
.table-compact tr td:first-child {
|
||||||
outline: none;
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-compact tr td:last-child {
|
||||||
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* custom elements */
|
/* custom elements */
|
||||||
|
|
||||||
|
.font-serif {
|
||||||
|
font-family: Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-monospace {
|
||||||
|
font-family: SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
height: 1rem;
|
height: 1rem;
|
||||||
width: 1rem;
|
width: 1rem;
|
||||||
@@ -167,7 +167,9 @@ select.form-control:not([multiple]):not([size]) {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
top: 0; left: 0;
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selectgroup + .selectgroup {
|
.selectgroup + .selectgroup {
|
||||||
@@ -184,7 +186,6 @@ select.form-control:not([multiple]):not([size]) {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-row:hover,
|
|
||||||
.toolbar-item:hover,
|
.toolbar-item:hover,
|
||||||
.toolbar-search:hover,
|
.toolbar-search:hover,
|
||||||
.selectgroup-label:hover,
|
.selectgroup-label:hover,
|
||||||
@@ -240,7 +241,6 @@ select.form-control:not([multiple]):not([size]) {
|
|||||||
border: 1px solid #ced4da;
|
border: 1px solid #ced4da;
|
||||||
border-radius: .25rem;
|
border-radius: .25rem;
|
||||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||||
background: linear-gradient(#fff, #f5f7f9);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-default:active {
|
.btn-default:active {
|
||||||
@@ -257,16 +257,6 @@ select.form-control:not([multiple]):not([size]) {
|
|||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-row {
|
|
||||||
padding-left: .5rem;
|
|
||||||
padding-right: .5rem;
|
|
||||||
margin-left: -.5rem;
|
|
||||||
margin-right: -.5rem;
|
|
||||||
border-radius: 3px;
|
|
||||||
user-select: none;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
min-height: 2rem !important;
|
min-height: 2rem !important;
|
||||||
max-height: 2rem !important;
|
max-height: 2rem !important;
|
||||||
@@ -349,57 +339,18 @@ select.form-control:not([multiple]):not([size]) {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.themepicker {
|
|
||||||
position: relative;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.themepicker input {
|
|
||||||
opacity: 0;
|
|
||||||
position: absolute;
|
|
||||||
z-index: -1;
|
|
||||||
top: 0; left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.themepicker-label {
|
|
||||||
height: 1.75rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.themepicker input[value=light] + .themepicker-label {
|
|
||||||
box-shadow: inset 0 0 0px 1px #dee2e6;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.themepicker + .themepicker {
|
|
||||||
margin-left: .5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.themepicker-label:hover {
|
|
||||||
box-shadow: inset 0 0 0 2px rgb(1, 123, 254, .6) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.themepicker input:checked + .themepicker-label {
|
|
||||||
box-shadow: inset 0 0 0px 2px #017bfe !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.appearance-option {
|
|
||||||
height: 2rem;
|
|
||||||
padding-top: 0 !important;
|
|
||||||
padding-bottom: 0 !important;
|
|
||||||
line-height: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#opml-import-form input[type="file"]::-webkit-file-upload-button {
|
#opml-import-form input[type="file"]::-webkit-file-upload-button {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -999px;
|
top: -999px;
|
||||||
left: -999px;
|
left: -999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.custom-modal {
|
||||||
|
background-color: rgba(0, 0, 0, 0.9);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* content */
|
/* content */
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
@@ -407,14 +358,51 @@ select.form-control:not([multiple]):not([size]) {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content-wrapper {
|
||||||
|
max-width: 60rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.content img, .content video {
|
.content img, .content video {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content iframe {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .video-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .video-wrapper::before {
|
||||||
|
display: block;
|
||||||
|
padding-top: 56.25%; /* 16x9 aspect ratio */
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.content .video-wrapper iframe {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.content pre {
|
.content pre {
|
||||||
|
overflow-x: auto;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
overflow-x: scroll;
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-left: -0.5rem;
|
||||||
|
margin-right: -0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content a {
|
.content a {
|
||||||
@@ -442,14 +430,23 @@ select.form-control:not([multiple]):not([size]) {
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content p {
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* theme: light */
|
/* theme: light */
|
||||||
|
|
||||||
|
button.theme-light {
|
||||||
|
background-color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
a,
|
a,
|
||||||
.btn-link:hover,
|
.btn-link:hover {
|
||||||
.toolbar-item.active {
|
|
||||||
color: #0080d4;
|
color: #0080d4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar-item.active,
|
||||||
.dropdown-item.active,
|
.dropdown-item.active,
|
||||||
.dropdown-item:active,
|
.dropdown-item:active,
|
||||||
.selectgroup input:checked + .selectgroup-label {
|
.selectgroup input:checked + .selectgroup-label {
|
||||||
@@ -464,45 +461,60 @@ a,
|
|||||||
|
|
||||||
/* theme: sepia */
|
/* theme: sepia */
|
||||||
|
|
||||||
.themepicker input[value=sepia] + .themepicker-label,
|
|
||||||
.theme-sepia,
|
.theme-sepia,
|
||||||
|
.theme-sepia .btn-default,
|
||||||
|
.theme-sepia .dropdown-menu,
|
||||||
|
.theme-sepia .form-control,
|
||||||
|
.theme-sepia .modal-content,
|
||||||
.theme-sepia .toolbar-search {
|
.theme-sepia .toolbar-search {
|
||||||
background-color: #f4f0e5;
|
background-color: #f4f0e5 !important;
|
||||||
}
|
}
|
||||||
.theme-sepia .content hr,
|
.theme-sepia .content hr,
|
||||||
|
.theme-sepia .content pre,
|
||||||
.theme-sepia .border-right,
|
.theme-sepia .border-right,
|
||||||
.theme-sepia .border-top {
|
.theme-sepia .border-top {
|
||||||
border-color: #e0d6ba !important;
|
border-color: #e0d6ba !important;
|
||||||
}
|
}
|
||||||
.theme-sepia .selectgroup-label:not(.appearance-option):hover,
|
.theme-sepia .selectgroup-label:hover,
|
||||||
.theme-sepia .toolbar-item:hover,
|
.theme-sepia .toolbar-item:hover,
|
||||||
.theme-sepia .toolbar-search:hover,
|
.theme-sepia .toolbar-search:hover,
|
||||||
|
.theme-sepia .dropdown-item:hover,
|
||||||
.theme-sepia .toolbar-search:focus {
|
.theme-sepia .toolbar-search:focus {
|
||||||
background-color: #e0d6ba;
|
background-color: #e0d6ba;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* theme: night */
|
/* theme: night */
|
||||||
|
|
||||||
.themepicker input[value=night] + .themepicker-label,
|
|
||||||
.theme-night,
|
.theme-night,
|
||||||
|
.theme-night .btn-default,
|
||||||
|
.theme-night .dropdown-menu,
|
||||||
|
.theme-night .dropdown-item,
|
||||||
|
.theme-night .form-control,
|
||||||
|
.theme-night .modal-content,
|
||||||
.theme-night .toolbar-search {
|
.theme-night .toolbar-search {
|
||||||
color: #d1d1d1;
|
color: #d1d1d1;
|
||||||
background-color: #0e0e0e;
|
background-color: #0e0e0e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-night .content hr,
|
.theme-night .content hr,
|
||||||
|
.theme-night .content pre,
|
||||||
.theme-night .border-right,
|
.theme-night .border-right,
|
||||||
.theme-night .border-top {
|
.theme-night .border-top,
|
||||||
|
.theme-night .dropdown-divider {
|
||||||
border-color: #1a1a1a !important;
|
border-color: #1a1a1a !important;
|
||||||
}
|
}
|
||||||
|
.theme-night .selectgroup-label:hover,
|
||||||
.theme-night .selectgroup-label:not(.appearance-option):hover,
|
.theme-night .dropdown-item:hover,
|
||||||
.theme-night .toolbar-item:hover,
|
.theme-night .toolbar-item:hover,
|
||||||
.theme-night .toolbar-search:hover,
|
.theme-night .toolbar-search:hover,
|
||||||
.theme-night .toolbar-search:focus {
|
.theme-night .toolbar-search:focus {
|
||||||
background-color: #1a1a1a;
|
background-color: #1a1a1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-night .dropdown-menu,
|
||||||
|
.theme-night .modal-content {
|
||||||
|
border-color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
/* animation */
|
/* animation */
|
||||||
.indicator-enter-active, .indicator-leave-active {
|
.indicator-enter-active, .indicator-leave-active {
|
||||||
transition: all .3s;
|
transition: all .3s;
|
||||||
86
src/content/htmlutil/query.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package htmlutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/net/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
var nodeNameRegex = regexp.MustCompile(`\w+|\*`)
|
||||||
|
|
||||||
|
func FindNodes(node *html.Node, match func(*html.Node) bool) []*html.Node {
|
||||||
|
nodes := make([]*html.Node, 0)
|
||||||
|
|
||||||
|
queue := make([]*html.Node, 0)
|
||||||
|
queue = append(queue, node)
|
||||||
|
for len(queue) > 0 {
|
||||||
|
var n *html.Node
|
||||||
|
n, queue = queue[0], queue[1:]
|
||||||
|
if match(n) {
|
||||||
|
nodes = append(nodes, n)
|
||||||
|
}
|
||||||
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||||
|
queue = append(queue, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
func Query(node *html.Node, sel string) []*html.Node {
|
||||||
|
matcher := NewMatcher(sel)
|
||||||
|
return FindNodes(node, matcher.Match)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Closest(node *html.Node, sel string) *html.Node {
|
||||||
|
matcher := NewMatcher(sel)
|
||||||
|
for cur := node; cur != nil; cur = cur.Parent {
|
||||||
|
if matcher.Match(cur) {
|
||||||
|
return cur
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMatcher(sel string) Matcher {
|
||||||
|
multi := MultiMatch{}
|
||||||
|
parts := strings.Split(sel, ",")
|
||||||
|
for _, part := range parts {
|
||||||
|
part := strings.TrimSpace(part)
|
||||||
|
if nodeNameRegex.MatchString(part) {
|
||||||
|
multi.Add(ElementMatch{Name: part})
|
||||||
|
} else {
|
||||||
|
panic("unsupported selector: " + part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return multi
|
||||||
|
}
|
||||||
|
|
||||||
|
type Matcher interface {
|
||||||
|
Match(*html.Node) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type ElementMatch struct {
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m ElementMatch) Match(n *html.Node) bool {
|
||||||
|
return n.Type == html.ElementNode && (n.Data == m.Name || m.Name == "*")
|
||||||
|
}
|
||||||
|
|
||||||
|
type MultiMatch struct {
|
||||||
|
matchers []Matcher
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MultiMatch) Add(matcher Matcher) {
|
||||||
|
m.matchers = append(m.matchers, matcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MultiMatch) Match(n *html.Node) bool {
|
||||||
|
for _, matcher := range m.matchers {
|
||||||
|
if matcher.Match(n) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
87
src/content/htmlutil/query_test.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package htmlutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"golang.org/x/net/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestQuery(t *testing.T) {
|
||||||
|
node, _ := html.Parse(strings.NewReader(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title></title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div>
|
||||||
|
<p>test</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`))
|
||||||
|
nodes := Query(node, "p")
|
||||||
|
match := (len(nodes) == 1 &&
|
||||||
|
nodes[0].Type == html.ElementNode &&
|
||||||
|
nodes[0].Data == "p")
|
||||||
|
if !match {
|
||||||
|
t.Fatalf("incorrect match: %#v", nodes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryMulti(t *testing.T) {
|
||||||
|
node, _ := html.Parse(strings.NewReader(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title></title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>foo</p>
|
||||||
|
<div>
|
||||||
|
<p>bar</p>
|
||||||
|
<span>baz</span>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`))
|
||||||
|
nodes := Query(node, "p , span")
|
||||||
|
match := (len(nodes) == 3 &&
|
||||||
|
nodes[0].Type == html.ElementNode && nodes[0].Data == "p" &&
|
||||||
|
nodes[1].Type == html.ElementNode && nodes[1].Data == "p" &&
|
||||||
|
nodes[2].Type == html.ElementNode && nodes[2].Data == "span")
|
||||||
|
if !match {
|
||||||
|
for i, n := range nodes {
|
||||||
|
t.Logf("%d: %s", i, HTML(n))
|
||||||
|
}
|
||||||
|
t.Fatal("incorrect match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClosest(t *testing.T) {
|
||||||
|
html, _ := html.Parse(strings.NewReader(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title></title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="foo">
|
||||||
|
<p><a class="bar" href=""></a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`))
|
||||||
|
link := Query(html, "a")
|
||||||
|
if link == nil || Attr(link[0], "class") != "bar" {
|
||||||
|
t.FailNow()
|
||||||
|
}
|
||||||
|
wrap := Closest(link[0], "div")
|
||||||
|
if wrap == nil || Attr(wrap, "class") != "foo" {
|
||||||
|
t.FailNow()
|
||||||
|
}
|
||||||
|
}
|
||||||