Compare commits
411 Commits
v1.4
...
eb125784f0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb125784f0 | ||
|
|
8a67d9f644 | ||
|
|
d41f1f782d | ||
|
|
10012ced93 | ||
|
|
5254df53dc | ||
|
|
7301eab99c | ||
|
|
ad138c3017 | ||
|
|
b09c95d7ea | ||
|
|
64611a0dd3 | ||
|
|
321ad7608f | ||
|
|
2a8b6ea935 | ||
|
|
e9cbea500b | ||
|
|
223039b2c6 | ||
|
|
7402dfc4e6 | ||
|
|
6b12715506 | ||
|
|
2dc58c5c8e | ||
|
|
0cef51c6ac | ||
|
|
2a4d974965 | ||
|
|
f71792d6a5 | ||
|
|
b571042c5d | ||
|
|
349c966c63 | ||
|
|
4a42b239cc | ||
|
|
b9b3d2350c | ||
|
|
b13cd85f0b | ||
|
|
daffd721eb | ||
|
|
24232d72e9 | ||
|
|
4983e18e23 | ||
|
|
e1954e4cba | ||
|
|
58420ae52b | ||
|
|
b01f71de1a | ||
|
|
379aaed39e | ||
|
|
dc20932060 | ||
|
|
96835ebd33 | ||
|
|
c896f779b5 | ||
|
|
5f606b1c40 | ||
|
|
9d5b8d99f7 | ||
|
|
13c047fc21 | ||
|
|
55751b3eb6 | ||
|
|
b961502a17 | ||
|
|
a895145586 | ||
|
|
5aec3b4dab | ||
|
|
d787060a24 | ||
|
|
c1a29418eb | ||
|
|
17847f999c | ||
|
|
3adcddc70c | ||
|
|
c76ff26bd6 | ||
|
|
50f8648f64 | ||
|
|
5f82a9e339 | ||
|
|
3278ba4eac | ||
|
|
9fc72f8b68 | ||
|
|
b7b707bd43 | ||
|
|
7cf27e0fde | ||
|
|
66f2a973a3 | ||
|
|
7ecbbff18a | ||
|
|
850ce195a0 | ||
|
|
479aebd023 | ||
|
|
9b178d1fb3 | ||
|
|
3ab098db5c | ||
|
|
6d16e93008 | ||
|
|
98934daee4 | ||
|
|
259474cae9 | ||
|
|
1e65a7951b | ||
|
|
bed5640366 | ||
|
|
57ea83cf4f | ||
|
|
219842d723 | ||
|
|
a96fc101f2 | ||
|
|
81a77ce0a4 | ||
|
|
9ed359f964 | ||
|
|
bc18557820 | ||
|
|
7d99edab8d | ||
|
|
32ca121520 | ||
|
|
9f1a0534a3 | ||
|
|
d2678be96d | ||
|
|
95ebbb9d13 | ||
|
|
0f6d4d639d | ||
|
|
795a5d2cb4 | ||
|
|
dd5f760606 | ||
|
|
58d6a46e36 | ||
|
|
a8d7b86cdc | ||
|
|
aac3de7ca2 | ||
|
|
de24659bae | ||
|
|
632412c10e | ||
|
|
012b58bbe4 | ||
|
|
c092842ee4 | ||
|
|
e4c1d01915 | ||
|
|
ce07ddea92 | ||
|
|
bd6322e533 | ||
|
|
91da774286 | ||
|
|
e62906e63d | ||
|
|
56e5625adc | ||
|
|
1ecf4b0bb4 | ||
|
|
57d9421c7f | ||
|
|
a73188944d | ||
|
|
97904cc0f3 | ||
|
|
f28f354992 | ||
|
|
698f5d6d06 | ||
|
|
b935a1c511 | ||
|
|
10e6bfa5a0 | ||
|
|
f030a4075b | ||
|
|
c9dd977600 | ||
|
|
c1bcc0c517 | ||
|
|
2a5692d9a7 | ||
|
|
a8d160f9b1 | ||
|
|
286cbff236 | ||
|
|
fff0870d3b | ||
|
|
fe22460c07 | ||
|
|
18f2789a5d | ||
|
|
7f161a5408 | ||
|
|
cba3fbc48c | ||
|
|
5e46f1480e | ||
|
|
ead253c55f | ||
|
|
6b8da92cb3 | ||
|
|
a91f64ce9d | ||
|
|
e1a6ccf133 | ||
|
|
d2c034a850 | ||
|
|
713930decc | ||
|
|
ee2a825cf0 | ||
|
|
8e9da86f83 | ||
|
|
9eb49fd3a7 | ||
|
|
684bc25b83 | ||
|
|
8ceab03cd7 | ||
|
|
34dad4ac8f | ||
|
|
b40d930f8a | ||
|
|
d4b34e900e | ||
|
|
954b549029 | ||
|
|
fbd0b2310e | ||
|
|
be7af0ccaf | ||
|
|
18221ef12d | ||
|
|
4c0726412b | ||
|
|
d7253a60b8 | ||
|
|
2de3ddff08 | ||
|
|
830248b6ae | ||
|
|
f8db2ef7ad | ||
|
|
109caaa889 | ||
|
|
d0b83babd2 | ||
|
|
de3decbffd | ||
|
|
c92229a698 | ||
|
|
176852b662 | ||
|
|
52cc8ecbbd | ||
|
|
e3e9542f1e | ||
|
|
b78c8bf8bf | ||
|
|
bff7476b58 | ||
|
|
05f5785660 | ||
|
|
cb50aed89a | ||
|
|
df655aca5e | ||
|
|
86853a87bf | ||
|
|
e3109a4384 | ||
|
|
eee8002d69 | ||
|
|
92f11f7513 | ||
|
|
5428e6be3a | ||
|
|
1ad693f931 | ||
|
|
c2d88a7e3f | ||
|
|
3b29d737eb | ||
|
|
fe178b8fc6 | ||
|
|
cca742a1c2 | ||
|
|
c7eddff118 | ||
|
|
cf30ed249f | ||
|
|
26b87dee98 | ||
|
|
77c7f938f1 | ||
|
|
f98de9a0a5 | ||
|
|
6fa2b67024 | ||
|
|
355e5feb62 | ||
|
|
a7dd707062 | ||
|
|
4de46a7bc5 | ||
|
|
2c6fce3322 | ||
|
|
19ecfcd0bc | ||
|
|
d575acfe80 | ||
|
|
d203d38de6 | ||
|
|
9f01f63613 | ||
|
|
982c4ebbbc | ||
|
|
0c5385cef3 | ||
|
|
58f4e1f6c9 | ||
|
|
6b7f69d5c0 | ||
|
|
7aeb458ee5 | ||
|
|
7cfd3b3238 | ||
|
|
55262d38fe | ||
|
|
a45e29feb7 | ||
|
|
9f5fd3bb4d | ||
|
|
63f9d55903 | ||
|
|
8f36ae013e | ||
|
|
851aa1a136 | ||
|
|
f38dcfba3b | ||
|
|
214c7aacfc | ||
|
|
eb9bfc57e2 | ||
|
|
c072783c42 | ||
|
|
9d701678e1 | ||
|
|
37ed856d8b | ||
|
|
28f08ad42a | ||
|
|
da267a56ef | ||
|
|
16e4cad9ad | ||
|
|
d13a04898e | ||
|
|
ff39fbff70 | ||
|
|
92c6aac49e | ||
|
|
4ca81f90e9 | ||
|
|
75e828cb4c | ||
|
|
883214a740 | ||
|
|
36e359c881 | ||
|
|
87b53fb8ec | ||
|
|
2ae62855cc | ||
|
|
19889c1457 | ||
|
|
f9afbac258 | ||
|
|
e54df07a40 | ||
|
|
f8455236dc | ||
|
|
d308bb64c2 | ||
|
|
077715f6c2 | ||
|
|
bfe7bfdbd5 | ||
|
|
1013cd1122 | ||
|
|
3e57ccc999 | ||
|
|
5f23f8be89 | ||
|
|
211b1456c7 | ||
|
|
3d9c9d03cc | ||
|
|
1ea8160f7d | ||
|
|
2c12875199 | ||
|
|
bb0b575eca | ||
|
|
e51ccb723e | ||
|
|
96796702cf | ||
|
|
fd44c98cd0 | ||
|
|
c2a28bcadf | ||
|
|
30e6afb096 | ||
|
|
882be1dbf6 | ||
|
|
8764891b80 | ||
|
|
fbb0dfed47 | ||
|
|
42b36965c5 | ||
|
|
e326c7a0fb | ||
|
|
9fae33f57b | ||
|
|
a397d2013d | ||
|
|
f65aadb055 | ||
|
|
c825f8864f | ||
|
|
2edf11a36a | ||
|
|
2df2f41516 | ||
|
|
614dcc8975 | ||
|
|
6acf9af887 | ||
|
|
ecdfcb5017 | ||
|
|
144fc1606a | ||
|
|
9919d72be0 | ||
|
|
9e95f71de8 | ||
|
|
09bfc47ef0 | ||
|
|
2e5ccc3158 | ||
|
|
70481dff73 | ||
|
|
cc12f27ce3 | ||
|
|
c36d82636d | ||
|
|
b123753d65 | ||
|
|
f590c358d2 | ||
|
|
fa2fad0ff6 | ||
|
|
d8aab7acae | ||
|
|
63ad971890 | ||
|
|
0828d6782e | ||
|
|
cf5856bdf7 | ||
|
|
34edfc0727 | ||
|
|
a1b1686d3b | ||
|
|
c5abf8f9d0 | ||
|
|
9a5af089eb | ||
|
|
9edd865bf4 | ||
|
|
e50c7e1a51 | ||
|
|
8967936fb6 | ||
|
|
fa92ea16b0 | ||
|
|
c8d6363677 | ||
|
|
b082c3e048 | ||
|
|
82fdb3be6c | ||
|
|
d7ba203f28 | ||
|
|
1cba53f7fb | ||
|
|
0a0db68905 | ||
|
|
3512350a22 | ||
|
|
8b2a9d8f20 | ||
|
|
34b50d388a | ||
|
|
cd412a4ac5 | ||
|
|
7fb6271e56 | ||
|
|
2cd815d9cd | ||
|
|
0a6e621c02 | ||
|
|
10c656a3b6 | ||
|
|
0ea313d945 | ||
|
|
1f02bde5e1 | ||
|
|
3e0c784744 | ||
|
|
528df7fb4a | ||
|
|
b04e8c1e93 | ||
|
|
0b8bf50204 | ||
|
|
f43924c17b | ||
|
|
0f519b7202 | ||
|
|
c74eeff790 | ||
|
|
e7b645a68a | ||
|
|
fc0bfd29db | ||
|
|
8950181f21 | ||
|
|
70e592c979 | ||
|
|
401668e413 | ||
|
|
37ddde1765 | ||
|
|
82586dedff | ||
|
|
ac36892150 | ||
|
|
c958ee9116 | ||
|
|
e5920259b6 | ||
|
|
8c44d2fc87 | ||
|
|
332ee0e6b5 | ||
|
|
3ae17171e2 | ||
|
|
493a4262b1 | ||
|
|
485587825c | ||
|
|
a83d43a5b1 | ||
|
|
71f81a3802 | ||
|
|
6481c97645 | ||
|
|
fa40a79d50 | ||
|
|
169d579400 | ||
|
|
430f300140 | ||
|
|
a9b450db03 | ||
|
|
89ce8df0e3 | ||
|
|
aa015b78c0 | ||
|
|
3ed1b3e612 | ||
|
|
7fb0d3833e | ||
|
|
2da616d4ff | ||
|
|
0b3d7faf9f | ||
|
|
b3ba912566 | ||
|
|
36bc84d99a | ||
|
|
f126247262 | ||
|
|
b145b00f8e | ||
|
|
7dbfecdba1 | ||
|
|
ad693aaf02 | ||
|
|
fafa6286d4 | ||
|
|
cc51fe01c2 | ||
|
|
91deb41d5b | ||
|
|
2e4082df77 | ||
|
|
51cbdea31f | ||
|
|
5335863488 | ||
|
|
c2e1926741 | ||
|
|
37a679fc80 | ||
|
|
1be79d922b | ||
|
|
6685bce51c | ||
|
|
fe1a1987bd | ||
|
|
80402943a1 | ||
|
|
b40fe94147 | ||
|
|
e0e6166cdf | ||
|
|
a2bfd1682b | ||
|
|
1f393faf79 | ||
|
|
c469749eaa | ||
|
|
e0009e4267 | ||
|
|
24a06faa3c | ||
|
|
9ede816078 | ||
|
|
646519e074 | ||
|
|
454eff0155 | ||
|
|
ebd7f2929c | ||
|
|
5b36530f67 | ||
|
|
c91b439878 | ||
|
|
7d61f705bf | ||
|
|
9fa8b8440a | ||
|
|
9e8837b37d | ||
|
|
7ca9415322 | ||
|
|
e78c028d20 | ||
|
|
cbc75047b8 | ||
|
|
cc6f6d91e1 | ||
|
|
70e9e1ed3a | ||
|
|
efafdbebaa | ||
|
|
19967ce37c | ||
|
|
ce3d6fba37 | ||
|
|
3e14716fc6 | ||
|
|
43620cd9b6 | ||
|
|
e819140f36 | ||
|
|
f28f0eac1a | ||
|
|
ff4abb8dfe | ||
|
|
e2efaddfed | ||
|
|
b1db6c6fb1 | ||
|
|
279fc469ab | ||
|
|
d185fb6dd7 | ||
|
|
3a667a3809 | ||
|
|
a895775f81 | ||
|
|
0a9beddef9 | ||
|
|
29528e40b0 | ||
|
|
aaf0b702a3 | ||
|
|
9f376db0f4 | ||
|
|
391ce61362 | ||
|
|
62e2ca4c16 | ||
|
|
ea5af73901 | ||
|
|
7d7feda319 | ||
|
|
1c810f68f8 | ||
|
|
8528a80d7e | ||
|
|
dd0bf1a012 | ||
|
|
e0c4752bbf | ||
|
|
c896440525 | ||
|
|
1f042a8434 | ||
|
|
fc3383946d | ||
|
|
4abbebf5e9 | ||
|
|
eb0ad7f22e | ||
|
|
0b1c90718d | ||
|
|
47597b2b7c | ||
|
|
f3c55ba5f2 | ||
|
|
5e453e3227 | ||
|
|
9bf7f45354 | ||
|
|
c8bc511e04 | ||
|
|
85a114e591 | ||
|
|
73b7144394 | ||
|
|
e9f6a0a1d2 | ||
|
|
dfb32d4ebe | ||
|
|
682b0c1729 | ||
|
|
e79abb69eb | ||
|
|
514ed02693 | ||
|
|
ff3241bd57 | ||
|
|
76937bedc9 | ||
|
|
1e65da9aa4 | ||
|
|
0d49377879 | ||
|
|
1a490a8e7a | ||
|
|
e53265472f | ||
|
|
66fdbef90b | ||
|
|
80482e70a8 | ||
|
|
f214c3166b | ||
|
|
4a4303afef | ||
|
|
cc7bdc5b76 | ||
|
|
3539433a9d | ||
|
|
54cb821ae9 | ||
|
|
4924dcfd12 | ||
|
|
d84e76d07c | ||
|
|
eca215d044 | ||
|
|
721de3fba6 | ||
|
|
e7fa98008d | ||
|
|
923fbc54b8 | ||
|
|
ff39f90abd | ||
|
|
db30fa3c5e |
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 }}
|
||||
163
.github/workflows/build.yml
vendored
@@ -1,13 +1,11 @@
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*', 'test*']
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
build_macos:
|
||||
name: Build for MacOS
|
||||
runs-on: macos-10.15
|
||||
runs-on: macos-13
|
||||
steps:
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@v2
|
||||
@@ -16,9 +14,9 @@ jobs:
|
||||
- name: "Setup Go"
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: '^1.16'
|
||||
go-version: '^1.17'
|
||||
- name: Cache Go Modules
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
@@ -27,14 +25,14 @@ jobs:
|
||||
- name: "Build"
|
||||
run: make build_macos
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos
|
||||
path: _output/macos/yarr.app
|
||||
|
||||
build_windows:
|
||||
name: Build for Windows
|
||||
runs-on: windows-2019
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@v2
|
||||
@@ -43,9 +41,9 @@ jobs:
|
||||
- name: "Setup Go"
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: '^1.16'
|
||||
go-version: '^1.17'
|
||||
- name: Cache Go Modules
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
@@ -54,14 +52,14 @@ jobs:
|
||||
- name: "Build"
|
||||
run: make build_windows
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows
|
||||
path: _output/windows/yarr.exe
|
||||
|
||||
build_linux:
|
||||
name: Build for Linux
|
||||
runs-on: ubuntu-18.04
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@v2
|
||||
@@ -70,40 +68,101 @@ jobs:
|
||||
- name: "Setup Go"
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: '^1.16'
|
||||
go-version: '^1.17'
|
||||
- name: Cache Go Modules
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
key: ${{ runner.os }}-amd64-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
${{ runner.os }}-go-amd64
|
||||
- name: "Build"
|
||||
run: make build_linux
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux
|
||||
path: _output/linux/yarr
|
||||
|
||||
build_linux-arm:
|
||||
name: Build for Linux ARM
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
run: >
|
||||
sudo apt-get install -y
|
||||
gcc-arm-linux-gnueabihf
|
||||
libc6-dev-armhf-cross
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
- name: "Setup Go"
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: '^1.17'
|
||||
- name: Cache Go Modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-armv7-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-armv7
|
||||
- name: "Build"
|
||||
env:
|
||||
CC: arm-linux-gnueabihf-gcc
|
||||
GOARCH: arm
|
||||
GOARM: 7
|
||||
run: make build_linux
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux_arm
|
||||
path: _output/linux/yarr
|
||||
|
||||
build_linux-arm64:
|
||||
name: Build for Linux ARM64
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
run: >
|
||||
sudo apt-get install -y
|
||||
gcc-aarch64-linux-gnu
|
||||
libc6-dev-arm64-cross
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
- name: "Setup Go"
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: '^1.17'
|
||||
- name: Cache Go Modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-arm64-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-arm64
|
||||
- name: "Build"
|
||||
env:
|
||||
CC: aarch64-linux-gnu-gcc
|
||||
GOARCH: arm64
|
||||
run: make build_linux
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux_arm64
|
||||
path: _output/linux/yarr
|
||||
|
||||
create_release:
|
||||
name: Create Release
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !contains(github.ref, 'test') }}
|
||||
needs: [build_macos, build_windows, build_linux]
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') && !contains(github.ref, 'test') }}
|
||||
needs: [build_macos, build_windows, build_linux, build_linux-arm, build_linux-arm64]
|
||||
steps:
|
||||
- name: Create Release
|
||||
uses: actions/create-release@v1
|
||||
id: create_release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: ${{ github.ref }}
|
||||
draft: true
|
||||
prerelease: true
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@v2
|
||||
uses: actions/download-artifact@v4.1.7
|
||||
with:
|
||||
path: .
|
||||
- name: Preparation
|
||||
@@ -111,34 +170,24 @@ jobs:
|
||||
ls -R
|
||||
chmod u+x macos/Contents/MacOS/yarr
|
||||
chmod u+x linux/yarr
|
||||
chmod u+x linux_arm/yarr
|
||||
chmod u+x linux_arm64/yarr
|
||||
|
||||
mv macos yarr.app && zip -r yarr-macos.zip yarr.app
|
||||
mv windows/yarr.exe . && zip yarr-windows.zip yarr.exe
|
||||
mv linux/yarr . && zip yarr-linux.zip yarr
|
||||
- name: Upload MacOS
|
||||
uses: actions/upload-release-asset@v1
|
||||
mv macos yarr.app && zip -r yarr-${GITHUB_REF_NAME}-macos64.zip yarr.app
|
||||
( cd windows && zip ../yarr-${GITHUB_REF_NAME}-windows64.zip yarr.exe )
|
||||
( cd linux && zip ../yarr-${GITHUB_REF_NAME}-linux64.zip yarr )
|
||||
( cd linux_arm && zip ../yarr-${GITHUB_REF_NAME}-linux_arm.zip yarr )
|
||||
( cd linux_arm64 && zip ../yarr-${GITHUB_REF_NAME}-linux_arm64.zip yarr )
|
||||
- name: Upload Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./yarr-macos.zip
|
||||
asset_name: yarr-${{ github.ref }}-macos64.zip
|
||||
asset_content_type: application/zip
|
||||
- name: Upload Windows
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./yarr-windows.zip
|
||||
asset_name: yarr-${{ github.ref }}-windows32.zip
|
||||
asset_content_type: application/zip
|
||||
- name: Upload Linux
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./yarr-linux.zip
|
||||
asset_name: yarr-${{ github.ref }}-linux32.zip
|
||||
asset_content_type: application/zip
|
||||
draft: true
|
||||
prerelease: true
|
||||
files: |
|
||||
yarr-${{ github.ref_name }}-macos64.zip
|
||||
yarr-${{ github.ref_name }}-windows64.zip
|
||||
yarr-${{ github.ref_name }}-linux64.zip
|
||||
yarr-${{ github.ref_name }}-linux_arm.zip
|
||||
yarr-${{ github.ref_name }}-linux_arm64.zip
|
||||
|
||||
4
.gitignore
vendored
@@ -1,7 +1,7 @@
|
||||
/server/assets.go
|
||||
/gofeed
|
||||
/_output
|
||||
/yarr
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.syso
|
||||
versioninfo.rc
|
||||
|
||||
3
.gitmodules
vendored
@@ -1,3 +0,0 @@
|
||||
[submodule "src/gofeed"]
|
||||
path = src/gofeed
|
||||
url = http://github.com/mmcdole/gofeed
|
||||
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
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"flag"
|
||||
"io/ioutil"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var rsrc = `1 VERSIONINFO
|
||||
var rsrc = `1 VERSIONINFO
|
||||
FILEVERSION {VERSION_COMMA},0,0
|
||||
PRODUCTVERSION {VERSION_COMMA},0,0
|
||||
BEGIN
|
||||
@@ -85,9 +85,9 @@ func main() {
|
||||
for _, res := range []int{1024, 512, 256, 128, 64, 32, 16} {
|
||||
outfile := fmt.Sprintf("icon_%dx%d.png", res, res)
|
||||
if res == 1024 || res == 64 {
|
||||
outfile = fmt.Sprintf("icon_%dx%d@2x.png", res / 2, res / 2)
|
||||
outfile = fmt.Sprintf("icon_%dx%d@2x.png", res/2, res/2)
|
||||
}
|
||||
cmd := []string {
|
||||
cmd := []string{
|
||||
"sips", "-s", "format", "png", "--resampleWidth", strconv.Itoa(res),
|
||||
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)
|
||||
}
|
||||
158
cmd/yarr/main.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/nkanaev/yarr/src/platform"
|
||||
"github.com/nkanaev/yarr/src/server"
|
||||
"github.com/nkanaev/yarr/src/storage"
|
||||
"github.com/nkanaev/yarr/src/worker"
|
||||
)
|
||||
|
||||
var Version string = "0.0"
|
||||
var GitHash string = "unknown"
|
||||
|
||||
var OptList = make([]string, 0)
|
||||
|
||||
func opt(envVar, defaultValue string) string {
|
||||
OptList = append(OptList, envVar)
|
||||
value := os.Getenv(envVar)
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func parseAuthfile(authfile io.Reader) (username, password string, err error) {
|
||||
scanner := bufio.NewScanner(authfile)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", "", fmt.Errorf("wrong syntax (expected `username:password`)")
|
||||
}
|
||||
username = parts[0]
|
||||
password = parts[1]
|
||||
break
|
||||
}
|
||||
return username, password, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
platform.FixConsoleIfNeeded()
|
||||
|
||||
var addr, db, authfile, auth, certfile, keyfile, basepath, logfile string
|
||||
var ver, open bool
|
||||
|
||||
flag.CommandLine.SetOutput(os.Stdout)
|
||||
|
||||
flag.Usage = func() {
|
||||
out := flag.CommandLine.Output()
|
||||
fmt.Fprintf(out, "Usage of %s:\n", os.Args[0])
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprintln(out, "\nThe environmental variables, if present, will be used to provide\nthe default values for the params above:")
|
||||
fmt.Fprintln(out, " ", strings.Join(OptList, ", "))
|
||||
}
|
||||
|
||||
flag.StringVar(&addr, "addr", opt("YARR_ADDR", "127.0.0.1:7070"), "address to run server on")
|
||||
flag.StringVar(&basepath, "base", opt("YARR_BASE", ""), "base path of the service url")
|
||||
flag.StringVar(&authfile, "auth-file", opt("YARR_AUTHFILE", ""), "`path` to a file containing username:password. Takes precedence over --auth (or YARR_AUTH)")
|
||||
flag.StringVar(&auth, "auth", opt("YARR_AUTH", ""), "string with username and password in the format `username:password`")
|
||||
flag.StringVar(&certfile, "cert-file", opt("YARR_CERTFILE", ""), "`path` to cert file for https")
|
||||
flag.StringVar(&keyfile, "key-file", opt("YARR_KEYFILE", ""), "`path` to key file for https")
|
||||
flag.StringVar(&db, "db", opt("YARR_DB", ""), "storage file `path`")
|
||||
flag.StringVar(&logfile, "log-file", opt("YARR_LOGFILE", ""), "`path` to log file to use instead of stdout")
|
||||
flag.BoolVar(&ver, "version", false, "print application version")
|
||||
flag.BoolVar(&open, "open", false, "open the server in browser")
|
||||
flag.Parse()
|
||||
|
||||
if ver {
|
||||
fmt.Printf("v%s (%s)\n", Version, GitHash)
|
||||
return
|
||||
}
|
||||
|
||||
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
|
||||
if logfile != "" {
|
||||
file, err := os.OpenFile(logfile, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to setup log file: ", err)
|
||||
}
|
||||
defer file.Close()
|
||||
log.SetOutput(file)
|
||||
} else {
|
||||
log.SetOutput(os.Stdout)
|
||||
}
|
||||
|
||||
if db == "" {
|
||||
configPath, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to get config dir: ", err)
|
||||
}
|
||||
|
||||
storagePath := filepath.Join(configPath, "yarr")
|
||||
if err := os.MkdirAll(storagePath, 0755); err != nil {
|
||||
log.Fatal("Failed to create app config dir: ", err)
|
||||
}
|
||||
db = filepath.Join(storagePath, "storage.db")
|
||||
}
|
||||
|
||||
log.Printf("using db file %s", db)
|
||||
|
||||
var username, password string
|
||||
var err error
|
||||
if authfile != "" {
|
||||
f, err := os.Open(authfile)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to open auth file: ", err)
|
||||
}
|
||||
defer f.Close()
|
||||
username, password, err = parseAuthfile(f)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to parse auth file: ", err)
|
||||
}
|
||||
} else if auth != "" {
|
||||
username, password, err = parseAuthfile(strings.NewReader(auth))
|
||||
if err != nil {
|
||||
log.Fatal("Failed to parse auth literal: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
if (certfile != "" || keyfile != "") && (certfile == "" || keyfile == "") {
|
||||
log.Fatalf("Both cert & key files are required")
|
||||
}
|
||||
|
||||
store, err := storage.New(db)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to initialise database: ", err)
|
||||
}
|
||||
|
||||
worker.SetVersion(Version)
|
||||
srv := server.NewServer(store, addr)
|
||||
|
||||
if basepath != "" {
|
||||
srv.BasePath = "/" + strings.Trim(basepath, "/")
|
||||
}
|
||||
|
||||
if certfile != "" && keyfile != "" {
|
||||
srv.CertFile = certfile
|
||||
srv.KeyFile = keyfile
|
||||
}
|
||||
|
||||
if username != "" && password != "" {
|
||||
srv.Username = username
|
||||
srv.Password = password
|
||||
}
|
||||
|
||||
log.Printf("starting server at %s", srv.GetAddr())
|
||||
if open {
|
||||
platform.Open(srv.GetAddr())
|
||||
}
|
||||
platform.Start(srv)
|
||||
}
|
||||
47
cmd/yarr/main_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPasswordFromAuthfile(t *testing.T) {
|
||||
for _, tc := range [...]struct {
|
||||
authfile string
|
||||
expectedUsername string
|
||||
expectedPassword string
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
authfile: "username:password",
|
||||
expectedUsername: "username",
|
||||
expectedPassword: "password",
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
authfile: "username-and-no-password",
|
||||
expectedError: true,
|
||||
},
|
||||
{
|
||||
authfile: "username:password:with:columns",
|
||||
expectedUsername: "username",
|
||||
expectedPassword: "password:with:columns",
|
||||
expectedError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.authfile, func(t *testing.T) {
|
||||
username, password, err := parseAuthfile(strings.NewReader(tc.authfile))
|
||||
if tc.expectedUsername != username {
|
||||
t.Errorf("expected username %q, got %q", tc.expectedUsername, username)
|
||||
}
|
||||
if tc.expectedPassword != password {
|
||||
t.Errorf("expected password %q, got %q", tc.expectedPassword, password)
|
||||
}
|
||||
if tc.expectedError && err == nil {
|
||||
t.Errorf("expected error, got nil")
|
||||
} else if !tc.expectedError && err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
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,35 +0,0 @@
|
||||
# v1.4 (2021-03-11)
|
||||
|
||||
- (new) keyboard shortcuts (thanks to @Duarte-Dias)
|
||||
- (new) show podcast audio
|
||||
- (fix) deleting feeds
|
||||
- (etc) minor ui tweaks & changes
|
||||
|
||||
# v1.3 (2021-02-18)
|
||||
|
||||
- (fix) log out functionality if authentication is set
|
||||
- (fix) import opml if authentication is set
|
||||
- (fix) login page if authentication is set (thanks to @einschmidt)
|
||||
|
||||
# v1.2 (2021-02-11)
|
||||
|
||||
- (new) autorefresh rate
|
||||
- (new) reduced bandwidth usage via stateful http headers `last-modified/etag`
|
||||
- (new) show feed errors in feed management modal
|
||||
- (new) `-open` flag for automatically opening the server url
|
||||
- (new) `-base` flag for serving urls under non-root path (thanks to @hcl)
|
||||
- (new) `-auth-file` flag for authentication
|
||||
- (new) `-cert-file` & `-key-file` flags for TLS
|
||||
- (fix) wrapping long words in the ui to prevent vertical scroll
|
||||
- (fix) increased toolbar height in mobile/tablet layout (thanks to @einschmidt)
|
||||
|
||||
# v1.1 (2020-10-05)
|
||||
|
||||
- (new) responsive design
|
||||
- (fix) server crash on favicon fetch timeout (reported by @minioin)
|
||||
- (fix) handling byte order marks in feeds (reported by @ilaer)
|
||||
- (fix) deleting a feed raises exception in the ui if the feed's items are shown.
|
||||
|
||||
# v1.0 (2020-09-24)
|
||||
|
||||
Initial Release
|
||||
112
doc/changelog.txt
Normal file
@@ -0,0 +1,112 @@
|
||||
# upcoming
|
||||
|
||||
- (new) Fever API support (thanks to @icefed)
|
||||
- (new) editable feed link (thanks to @adaszko)
|
||||
- (new) switch to feed by clicking the title in the article page (thanks to @tarasglek for suggestion)
|
||||
- (fix) duplicate articles caused by the same feed addition (thanks to @adaszko)
|
||||
- (fix) relative article links (thanks to @adazsko for the report)
|
||||
- (fix) atom article links stored in id element (thanks to @adazsko for the report)
|
||||
- (fix) parsing atom feed titles (thanks to @wnh)
|
||||
- (fix) sorting same-day batch articles (thanks to @lamescholar for the report)
|
||||
- (fix) showing login page in the selected theme (thanks to @feddiriko for the report)
|
||||
- (fix) parsing atom feeds with html elements (thanks to @tillcash & @toBeOfUse for the report, @krkk for the fix)
|
||||
- (fix) parsing feeds with missing guids (thanks to @hoyii for the report)
|
||||
- (fix) sending actual client version to servers (thanks to @aidanholm)
|
||||
- (fix) error caused by missing config dir (thanks to @timster)
|
||||
- (etc) load external images with no-referrer policy (thanks to @tillcash for the report)
|
||||
- (etc) open external links with no-referrer policy (thanks to @donovanglover)
|
||||
- (etc) show article content in the list if title is missing (thanks to @asimpson for suggestion)
|
||||
|
||||
# v2.4 (2023-08-15)
|
||||
|
||||
- (new) ARM build support (thanks to @tillcash & @fenuks)
|
||||
- (new) auth configuration via param or env variable (thanks to @pierreprinetti)
|
||||
- (new) web app manifest for an app-like experience on mobile (thanks to @qbit)
|
||||
- (fix) concurrency issue crashing the app (thanks to @quoing)
|
||||
- (fix) favicon visibility in dark mode (thanks to @caycaycarly for the report)
|
||||
- (fix) autoloading more articles not working in certain edge cases (thanks to @fenuks for the report)
|
||||
- (fix) handle Google URL redirects in "Read Here" (thanks to @cubbei for discovery)
|
||||
- (fix) handle failures to extract content in "Read Here" (thanks to @grigio for the report)
|
||||
- (fix) article view width for high resolution screens (thanks to @whaler-ragweed for the report)
|
||||
- (fix) make newly added feed searchable (thanks to @BMorearty for the report)
|
||||
- (fix) feed/article selection accessibility via arrow keys (thanks to @grigio and @tillcash)
|
||||
- (fix) keyboard shortcuts in Firefox (thanks to @kaloyan13)
|
||||
- (fix) keyboard shortcuts in non-English layouts (thanks to @kaloyan13)
|
||||
- (fix) sorting articles with timezone information (thanks to @x2cf)
|
||||
- (fix) handling links set in guid only for certain feeds (thanks to @adaszko for the report)
|
||||
- (fix) crashes caused by feed icon endpoint (thanks to @adaszko)
|
||||
|
||||
# v2.3 (2022-05-03)
|
||||
|
||||
- (fix) handling encodings (thanks to @f100024 & @fserb)
|
||||
- (fix) parsing xml feeds with illegal characters (thanks to @stepelu for the report)
|
||||
- (fix) old articles reappearing as unread (thanks to @adaszko for the report)
|
||||
- (fix) item list scrolling issue on large screens (thanks to @bielej for the report)
|
||||
- (fix) keyboard shortcuts color in dark mode (thanks to @John09f9 for the report)
|
||||
- (etc) autofocus when adding a new feed (thanks to @lakuapik)
|
||||
|
||||
# v2.2 (2021-11-20)
|
||||
|
||||
- (fix) windows console support (thanks to @dufferzafar for the report)
|
||||
- (fix) remove html tags from article titles (thanks to Alex Went for the report)
|
||||
- (etc) autoselect current folder when adding a new feed (thanks to @krkk)
|
||||
- (etc) folder/feed settings menu available across all filters
|
||||
|
||||
# v2.1 (2021-08-16)
|
||||
|
||||
- (new) configuration via env variables
|
||||
- (fix) missing `content-type` headers (thanks to @verahawk for the report)
|
||||
- (fix) handle opml files not following the spec (thanks to @huangnauh for the report)
|
||||
- (fix) pagination in unread/starred feeds (thanks to @Farow for the report)
|
||||
- (fix) handling feeds with non-utf8 encodings (thanks to @fserb for the report)
|
||||
- (fix) errors caused by empty feeds (thanks to @decke)
|
||||
- (fix) recognize all audio mime types as podcasts (thanks to @krkk)
|
||||
- (fix) ui tweaks (thanks to @Farow)
|
||||
|
||||
# v2.0 (2021-04-18)
|
||||
|
||||
- (new) user interface tweaks
|
||||
- (new) feed parser fully rewritten
|
||||
- (new) show youtube/vimeo iframes in "read here"
|
||||
- (new) keyboard shortcuts for article scrolling & toggling "read here"
|
||||
- (new) more options for auto-refresh intervals
|
||||
- (fix) `-base` not serving static files (thanks to @vfaronov)
|
||||
- (etc) 3rd-party dependencies reduced to the bare minimum
|
||||
|
||||
special thanks to @tillcash for feedback & suggestions.
|
||||
|
||||
# v1.4 (2021-03-11)
|
||||
|
||||
- (new) keyboard shortcuts (thanks to @Duarte-Dias)
|
||||
- (new) show podcast audio
|
||||
- (fix) deleting feeds
|
||||
- (etc) minor ui tweaks & changes
|
||||
|
||||
# v1.3 (2021-02-18)
|
||||
|
||||
- (fix) log out functionality if authentication is set
|
||||
- (fix) import opml if authentication is set
|
||||
- (fix) login page if authentication is set (thanks to @einschmidt)
|
||||
|
||||
# v1.2 (2021-02-11)
|
||||
|
||||
- (new) autorefresh rate
|
||||
- (new) reduced bandwidth usage via stateful http headers `last-modified/etag`
|
||||
- (new) show feed errors in feed management modal
|
||||
- (new) `-open` flag for automatically opening the server url
|
||||
- (new) `-base` flag for serving urls under non-root path (thanks to @hcl)
|
||||
- (new) `-auth-file` flag for authentication
|
||||
- (new) `-cert-file` & `-key-file` flags for TLS
|
||||
- (fix) wrapping long words in the ui to prevent vertical scroll
|
||||
- (fix) increased toolbar height in mobile/tablet layout (thanks to @einschmidt)
|
||||
|
||||
# v1.1 (2020-10-05)
|
||||
|
||||
- (new) responsive design
|
||||
- (fix) server crash on favicon fetch timeout (reported by @minioin)
|
||||
- (fix) handling byte order marks in feeds (reported by @ilaer)
|
||||
- (fix) deleting a feed raises exception in the ui if the feed's items are shown.
|
||||
|
||||
# v1.0 (2020-09-24)
|
||||
|
||||
Initial Release
|
||||
19
doc/fever.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Fever API support
|
||||
|
||||
Fever API is a kind of RSS HTTP API interface, because the Fever API definition is not very clear, so the implementation of Fever server and Client may have some compatibility problems.
|
||||
|
||||
The Fever API implemented by Yarr is based on the Fever API spec: https://github.com/DigitalDJ/tinytinyrss-fever-plugin/blob/master/fever-api.md.
|
||||
|
||||
Here are some Apps that have been tested to work with yarr. Feel free to test other Clients/Apps and update the list here.
|
||||
|
||||
> Different apps support different URL/Address formats. Please note whether the URL entered has `http://` scheme and `/` suffix.
|
||||
|
||||
| App | Platforms | Config Server URL |
|
||||
|:------------------------------------------------------------------------- | ---------------- |:--------------------------------------------------- |
|
||||
| [Reeder](https://reederapp.com/) | MacOS<br>iOS | 127.0.0.1:7070/fever<br>http://127.0.0.1:7070/fever |
|
||||
| [ReadKit](https://readkit.app/) | MacOS<br>iOS | http://127.0.0.1:7070/fever |
|
||||
| [Fluent Reader](https://github.com/yang991178/fluent-reader) | MacOS<br>Windows | http://127.0.0.1:7070/fever/ |
|
||||
| [Unread](https://apps.apple.com/us/app/unread-an-rss-reader/id1363637349) | iOS | http://127.0.0.1:7070/fever |
|
||||
| [Fiery Feeds](https://voidstern.net/fiery-feeds) | MacOS<br>iOS | http://127.0.0.1:7070/fever |
|
||||
|
||||
If you are having trouble using Fever, please open an issue and @icefed, thanks.
|
||||
171
doc/formats.txt
Normal file
@@ -0,0 +1,171 @@
|
||||
# model
|
||||
|
||||
- feed:
|
||||
- title
|
||||
|
||||
rdf>channel>title (rss 0.90)
|
||||
rdf>channel>title (rss 1.0)
|
||||
rss>channel>title (rss 0.91 netscape)
|
||||
rss>channel>title (rss 0.91 userland)
|
||||
rss>channel>title (rss 2.0)
|
||||
feed>title (atom 1.0)
|
||||
|
||||
- site_url
|
||||
|
||||
rdf>channel>link (rss 0.90)
|
||||
rdf>channel>link (rss 1.0)
|
||||
rss>channel>link (rss 0.91 netscape)
|
||||
rss>channel>link (rss 0.91 userland)
|
||||
rss>channel>link (rss 2.0)
|
||||
feed>link (atom 1.0)
|
||||
|
||||
- item:
|
||||
- guid
|
||||
|
||||
rss>channel>guid (rss 2.0)
|
||||
feed>entry>id (atom 1.0)
|
||||
|
||||
- date
|
||||
|
||||
rdf>item>dc:date (rss 1.0)
|
||||
rss>channel>pubDate (rss 2.0)
|
||||
feed>entry>updated (atom 1.0)
|
||||
feed>entry>published (atom 1.0)
|
||||
|
||||
- url
|
||||
|
||||
rdf>item>link (rss 0.90)
|
||||
rdf>item>link (rss 1.0)
|
||||
rss>channel>item>link (rss 0.91 netscape)
|
||||
rss>channel>item>link (rss 0.91 userland)
|
||||
rss>channel>item>link (rss 2.0)
|
||||
feed>entry>link[rel=alternate] (atom 1.0)
|
||||
|
||||
- title
|
||||
|
||||
rdf>item>title (rss 0.90)
|
||||
rdf>item>title (rss 1.0)
|
||||
rss>channel>item>title (rss 0.91 netscape)
|
||||
rss>channel>item>title (rss 0.91 userland)
|
||||
rss>channel>item>title (rss 2.0)
|
||||
feed>entry>title (atom 1.0)
|
||||
|
||||
- content
|
||||
|
||||
rss>channel>item>description (rss 0.91 netscape)
|
||||
rss>channel>item>description (rss 0.91 userland)
|
||||
rss>channel>item>description (rss 2.0)
|
||||
rdf>item>description (rss 1.0)
|
||||
rdf>item>content:encoded (rss 1.0)
|
||||
feed>entry>content (atom 1.0)
|
||||
|
||||
- image_url
|
||||
|
||||
rss>item>media:thumbnail:url (rss 2.0 media)
|
||||
feed>entry>enclosure[rel='image/*'] (atom 1.0) ???
|
||||
|
||||
- audio_url
|
||||
|
||||
rss>item>enclosure:url (audio/*) (rss 2.0)
|
||||
feed>entry>enclosure (audio/*') (atom 1.0) ???
|
||||
|
||||
# specs
|
||||
|
||||
- rss
|
||||
https://en.wikipedia.org/wiki/RSS
|
||||
- 0.90:
|
||||
https://www.rssboard.org/rss-0-9-0
|
||||
https://web.archive.org/web/20001208063100/http://my.netscape.com/publish/help/quickstart.html
|
||||
- 0.91 (netscape)
|
||||
https://www.rssboard.org/rss-0-9-1-netscape
|
||||
- 0.91 (userland)
|
||||
https://www.rssboard.org/rss-0-9-1
|
||||
- 0.92
|
||||
https://www.rssboard.org/rss-0-9-2
|
||||
by userland, no significant changes from 0.91
|
||||
- 0.93 (withdrawn)
|
||||
http://backend.userland.com/rss093
|
||||
- 0.94 (withdrawn)
|
||||
- 1.0
|
||||
https://web.resource.org/rss/1.0/
|
||||
https://web.archive.org/web/20021014094554/https://web.resource.org/rss/1.0/spec
|
||||
reintroduced rdf from 0.90, added dublincore namespaces etc
|
||||
namespaces:
|
||||
content: http://purl.org/rss/1.0/modules/content/
|
||||
dc: http://purl.org/dc/elements/1.1/
|
||||
- 2.0
|
||||
https://cyber.harvard.edu/rss/rss.html
|
||||
https://www.rssboard.org/rss-2-0
|
||||
|
||||
- atom
|
||||
https://en.wikipedia.org/wiki/Atom_(Web_standard)
|
||||
- 0.3
|
||||
https://support.google.com/merchants/answer/160598?hl=en
|
||||
http://web.archive.org/web/20060811235523/http://www.mnot.net/drafts/draft-nottingham-atom-format-02.html
|
||||
- 1.0
|
||||
https://tools.ietf.org/html/rfc4287
|
||||
https://validator.w3.org/feed/docs/atom.html
|
||||
|
||||
- json
|
||||
https://en.wikipedia.org/wiki/JSON_Feed
|
||||
- 1.0
|
||||
https://jsonfeed.org/version/1
|
||||
- 1.1
|
||||
https://jsonfeed.org/version/1.1
|
||||
|
||||
- media
|
||||
https://www.rssboard.org/media-rss
|
||||
xml namespace for:
|
||||
- rss 2.0
|
||||
- atom 1.0
|
||||
|
||||
# extensions
|
||||
|
||||
- feedburner
|
||||
https://en.wikipedia.org/wiki/FeedBurner
|
||||
|
||||
- media
|
||||
https://www.rssboard.org/media-rss
|
||||
initially for rss 2.0, used in atom 1.0 as well (youtube)
|
||||
|
||||
- itunes podcasts
|
||||
https://help.apple.com/itc/podcasts_connect/#/itcb54353390
|
||||
https://github.com/simplepie/simplepie-ng/wiki/Spec:-iTunes-Podcast-RSS
|
||||
|
||||
- google podcasts
|
||||
https://support.google.com/podcast-publishers/answer/9889544?visit_id=637523492443301715-1225759684&rd=1
|
||||
|
||||
# parsers
|
||||
|
||||
https://github.com/kurtmckee/feedparser
|
||||
https://github.com/mmcdole/gofeed
|
||||
https://github.com/miniflux/v2/tree/2.0.28/reader/
|
||||
https://github.com/Ranchero-Software/RSParser
|
||||
https://github.com/feederco/feeder-parser
|
||||
|
||||
https://github.com/mmcdole/gofeed/commit/9665eb31016cef3d15ab85574bc6fdbe890cd252
|
||||
|
||||
# platforms
|
||||
|
||||
A list of centralized content providers worth keeping track of.
|
||||
The parser should be reasonably handle content provided by them.
|
||||
Delete any from the list in case they drop support of web feeds.
|
||||
|
||||
- blogger
|
||||
- cnblogs
|
||||
- flickr
|
||||
- hatenablog
|
||||
- livejournal
|
||||
- medium
|
||||
- posthaven
|
||||
- reddit
|
||||
- substack
|
||||
- tumblr
|
||||
- vimeo
|
||||
- wordpress
|
||||
- youtube
|
||||
|
||||
# links
|
||||
|
||||
https://indieweb.org/feed#Criticism
|
||||
https://inessential.com/2013/03/18/brians_stupid_feed_tricks
|
||||
68
doc/samples.yml
Normal file
@@ -0,0 +1,68 @@
|
||||
- site: https://vimeo.com/channels/staffpicks/videos
|
||||
feed: https://vimeo.com/channels/staffpicks/videos/rss
|
||||
tags: [vimeo, image]
|
||||
|
||||
- site: https://www.youtube.com/@everyframeapainting/videos
|
||||
feed: https://www.youtube.com/feeds/videos.xml?channel_id=UCjFqcJQXGZ6T6sxyFB-5i6A"
|
||||
tags: [youtube, image]
|
||||
|
||||
- site: https://iwdrm.tumblr.com/
|
||||
feed: https://iwdrm.tumblr.com/rss
|
||||
tags: [tumblr, image]
|
||||
|
||||
- site: https://falseknees.tumblr.com/
|
||||
feed: https://falseknees.tumblr.com/rss
|
||||
tags: [tumblr, image]
|
||||
|
||||
- site: https://accidentallyquadratic.tumblr.com/
|
||||
feed: https://accidentallyquadratic.tumblr.com/rss
|
||||
info: text blog with code sections
|
||||
tags: [tumblr, text, code]
|
||||
|
||||
- site: https://www.flickr.com/photos/maratsafin/
|
||||
feed: https://www.flickr.com/services/feeds/photos_public.gne?id=59021497@N07&lang=en-us&format=atom
|
||||
tags: [flickr, image]
|
||||
|
||||
- site: https://www.reddit.com/r/comics
|
||||
feed: https://www.reddit.com/r/comics.rss
|
||||
tags: [reddit, image]
|
||||
|
||||
- site: https://www.reddit.com/r/AITAH
|
||||
feed: https://www.reddit.com/r/AITAH.rss
|
||||
tags: [reddit, text]
|
||||
|
||||
- site: https://idothei.wordpress.com/
|
||||
feed: https://idothei.wordpress.com/feed/
|
||||
tags: [wordpress, text]
|
||||
|
||||
- site: https://www.vidarholen.net/contents/blog/
|
||||
feed: https://www.vidarholen.net/contents/blog/?feed=rss2
|
||||
tags: [wordpress, text]
|
||||
|
||||
- site: https://blog.posthaven.com/
|
||||
feed: https://blog.posthaven.com/posts.atom
|
||||
tags: [posthaven, text]
|
||||
|
||||
- site: https://medium.com/@dailynewsletter
|
||||
feed: https://medium.com/feed/@dailynewsletter
|
||||
tags: [medium, text]
|
||||
|
||||
- site: https://thereveal.substack.com/
|
||||
feed: https://thereveal.substack.com/feed
|
||||
tags: [substack, text]
|
||||
|
||||
- site: https://tema.livejournal.com/
|
||||
feed: https://tema.livejournal.com/data/rss
|
||||
tags: [livejournal, text]
|
||||
|
||||
- site: https://mametter.hatenablog.com/
|
||||
feed: https://mametter.hatenablog.com/feed
|
||||
tags: [hatena, text]
|
||||
|
||||
- site: https://juliepowell.blogspot.com/
|
||||
feed: https://juliepowell.blogspot.com/feeds/posts/default
|
||||
tags: [blogger, text]
|
||||
|
||||
- site: https://micro.blog/val
|
||||
feed: https://micro.blog/posts/val
|
||||
tags: [json, microblog]
|
||||
27
doc/thirdparty.txt
Normal file
@@ -0,0 +1,27 @@
|
||||
Below is the list of 3-rd party code directly included & rewritten
|
||||
to suit the author's needs & preferences.
|
||||
The licenses are included, and the authorship comments are left intact.
|
||||
|
||||
- readability
|
||||
https://github.com/miniflux/v2 (commit:31435ef) Apache 2.0
|
||||
|
||||
removed goquery dependency
|
||||
removed assisting utility structs (need another way to debug node scores)
|
||||
|
||||
- sanitizer
|
||||
https://github.com/miniflux/v2 (commit:3cb04b2) Apache 2.0
|
||||
|
||||
whitelist changed to the ones from https://github.com/cure53/DOMPurify:
|
||||
- allowed tags
|
||||
- allowed uri schemes
|
||||
- added svg whitelist
|
||||
|
||||
- systray
|
||||
https://github.com/getlantern/systray (commit:2c0986d) Apache 2.0
|
||||
|
||||
removed golog dependency
|
||||
|
||||
- fixconsole
|
||||
https://github.com/apenwarr/fixconsole (commit:5a9f648) Apache 2.0
|
||||
|
||||
removed `w32` dependency
|
||||
@@ -1,4 +0,0 @@
|
||||
- fix: loading items (by scrolling down) is glitching while feeds are refreshing
|
||||
- ref: switch to the standard logger
|
||||
- ref: drop goquery, switch to cascadia
|
||||
- ref: organize "server" package using KonMari method
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:alpine AS build
|
||||
FROM golang:alpine3.18 AS build
|
||||
RUN apk add build-base git
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
@@ -9,4 +9,5 @@ RUN apk add --no-cache ca-certificates && \
|
||||
update-ca-certificates
|
||||
COPY --from=build /src/_output/linux/yarr /usr/local/bin/yarr
|
||||
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
|
Before Width: | Height: | Size: 727 KiB After Width: | Height: | Size: 173 KiB |
12
go.mod
@@ -1,13 +1,11 @@
|
||||
module github.com/nkanaev/yarr
|
||||
|
||||
go 1.16
|
||||
go 1.17
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.5.1
|
||||
github.com/getlantern/systray v1.0.4
|
||||
github.com/mattn/go-sqlite3 v1.14.0
|
||||
github.com/mmcdole/gofeed v1.0.0
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e
|
||||
github.com/mattn/go-sqlite3 v1.14.7
|
||||
golang.org/x/net v0.23.0
|
||||
golang.org/x/sys v0.18.0
|
||||
)
|
||||
|
||||
replace github.com/mmcdole/gofeed => ./src/gofeed
|
||||
require golang.org/x/text v0.14.0 // indirect
|
||||
|
||||
102
go.sum
@@ -1,62 +1,48 @@
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/PuerkitoBio/goquery v1.5.1 h1:PSPBGne8NIUWw+/7vFBV+kG2J/5MOjbzc7154OaKCSE=
|
||||
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
|
||||
github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo=
|
||||
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
|
||||
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/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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/urfave/cli v1.22.3/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
|
||||
github.com/mattn/go-sqlite3 v1.14.7 h1:fxWBnXkxfM6sRiuH3bqJ4CfzZojMOLVc0UTsTglEghA=
|
||||
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e h1:3G+cUijn7XD+S4eJFddp53Pv7+slrESplyjG25HgL+k=
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
|
||||
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
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-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9 h1:YTzHMGlqJu67/uEo1lBv0n3wBXhXNeUbB1XfN2vmTm0=
|
||||
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
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.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
31
makefile
@@ -1,36 +1,33 @@
|
||||
VERSION=1.4
|
||||
VERSION=2.4
|
||||
GITHASH=$(shell git rev-parse --short=8 HEAD)
|
||||
|
||||
CGO_ENABLED=1
|
||||
GO_LDFLAGS = -s -w -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)'
|
||||
|
||||
GO_LDFLAGS = -s -w
|
||||
GO_LDFLAGS := $(GO_LDFLAGS) -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)'
|
||||
export GOARCH ?= amd64
|
||||
export CGO_ENABLED = 1
|
||||
|
||||
build_default:
|
||||
mkdir -p _output
|
||||
go build -tags "sqlite_foreign_keys release" -ldflags="$(GO_LDFLAGS)" -o _output/yarr src/main.go
|
||||
go build -tags "sqlite_foreign_keys" -ldflags="$(GO_LDFLAGS)" -o _output/yarr ./cmd/yarr
|
||||
|
||||
build_macos:
|
||||
set GOOS=darwin
|
||||
set GOARCH=amd64
|
||||
mkdir -p _output/macos
|
||||
go build -tags "sqlite_foreign_keys release macos" -ldflags="$(GO_LDFLAGS)" -o _output/macos/yarr src/main.go
|
||||
GOOS=darwin go build -tags "sqlite_foreign_keys macos" -ldflags="$(GO_LDFLAGS)" -o _output/macos/yarr ./cmd/yarr
|
||||
cp src/platform/icon.png _output/macos/icon.png
|
||||
go run bin/package_macos.go -outdir _output/macos -version "$(VERSION)"
|
||||
go run ./cmd/package_macos -outdir _output/macos -version "$(VERSION)"
|
||||
|
||||
build_linux:
|
||||
set GOOS=linux
|
||||
set GOARCH=386
|
||||
mkdir -p _output/linux
|
||||
go build -tags "sqlite_foreign_keys release linux" -ldflags="$(GO_LDFLAGS)" -o _output/linux/yarr src/main.go
|
||||
GOOS=linux go build -tags "sqlite_foreign_keys linux" -ldflags="$(GO_LDFLAGS)" -o _output/linux/yarr ./cmd/yarr
|
||||
|
||||
build_windows:
|
||||
set GOOS=windows
|
||||
set GOARCH=386
|
||||
mkdir -p _output/windows
|
||||
go run bin/generate_versioninfo.go -version "$(VERSION)" -outfile src/platform/versioninfo.rc
|
||||
go run ./cmd/generate_versioninfo -version "$(VERSION)" -outfile src/platform/versioninfo.rc
|
||||
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 src/main.go
|
||||
GOOS=windows 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" src/main.go -db local.db
|
||||
go run -tags "sqlite_foreign_keys" ./cmd/yarr -db local.db
|
||||
|
||||
test:
|
||||
go test -tags "sqlite_foreign_keys" ./...
|
||||
|
||||
64
readme.md
@@ -3,72 +3,36 @@
|
||||
**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.
|
||||
|
||||
It is written in Go with the frontend in Vue.js. The storage is backed by SQLite.
|
||||
The app is a single binary with an embedded database (SQLite).
|
||||
|
||||

|
||||
|
||||
## usage
|
||||
|
||||
The latest prebuilt binaries for Linux/MacOS/Windows are available
|
||||
[here](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
|
||||
* MacOS
|
||||
|
||||
Download `yarr-*-macos64.zip`, unzip it, place `yarr.app` in `/Applications` folder.
|
||||
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".
|
||||
|
||||
The binaries are not signed, because the author doesn't want to buy a certificate.
|
||||
Apple hates cheapskate developers, therefore the OS will refuse to run the application.
|
||||
To bypass these measures, you can run the command:
|
||||
* Windows
|
||||
|
||||
xattr -d com.apple.quarantine /Applications/yarr.app
|
||||
Download `yarr-*-windows64.zip`, unzip it, open `yarr.exe`, click the anchor system tray icon, select "Open".
|
||||
|
||||
### windows
|
||||
* Linux
|
||||
|
||||
Download `yarr-*-windows32.zip`, unzip it, place wherever you'd like to
|
||||
(`C:\Program Files` or Recycle Bin). Create a shortcut manually if you'd like to.
|
||||
Download `yarr-*-linux64.zip`, unzip it, place `yarr` in `$HOME/.local/bin`
|
||||
and run [the script](etc/install-linux.sh).
|
||||
|
||||
Microsoft doesn't like cheapskate developers too,
|
||||
but might only gently warn you about that, which you can safely ignore.
|
||||
|
||||
### linux
|
||||
|
||||
The Linux version doesn't come with the desktop environment integration.
|
||||
For easy access on DE it is recommended to create a desktop menu entry by
|
||||
by following the steps below:
|
||||
|
||||
unzip -x yarr*.zip
|
||||
sudo mv yarr /usr/local/bin/yarr
|
||||
sudo nano /usr/local/share/applications/yarr.desktop
|
||||
|
||||
and pasting the content:
|
||||
|
||||
[Desktop Entry]
|
||||
Name=yarr
|
||||
Exec=/usr/local/bin/yarr -open
|
||||
Icon=rss
|
||||
Type=Application
|
||||
Categories=Internet;
|
||||
[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.
|
||||
|
||||
## build
|
||||
See more:
|
||||
|
||||
Install `Go >= 1.16` and `gcc`. Get the source code:
|
||||
|
||||
git clone --recurse-submodules 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
|
||||
|
||||
# ... or start a dev server locally
|
||||
make serve # starts a server at http://localhost:7070
|
||||
|
||||
# ... or build a docker image
|
||||
docker build -t yarr .
|
||||
* [Building from source code](doc/build.md)
|
||||
* [Fever API support](doc/fever.md)
|
||||
|
||||
## credits
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@ import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"io/fs"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
@@ -23,15 +24,24 @@ func (afs assetsfs) Open(name string) (fs.File, error) {
|
||||
return os.DirFS("src/assets").Open(name)
|
||||
}
|
||||
|
||||
func Render(path string, writer io.Writer, data interface{}) {
|
||||
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, _ := FS.Open("graphicarts/" + svg)
|
||||
content, _ := ioutil.ReadAll(svgfile)
|
||||
svgfile.Close()
|
||||
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))
|
||||
@@ -39,6 +49,11 @@ func Render(path string, writer io.Writer, data interface{}) {
|
||||
FS.templates[path] = tmpl
|
||||
}
|
||||
}
|
||||
return tmpl
|
||||
}
|
||||
|
||||
func Render(path string, writer io.Writer, data interface{}) {
|
||||
tmpl := Template(path)
|
||||
tmpl.Execute(writer, data)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// +build release
|
||||
|
||||
package assets
|
||||
|
||||
import "embed"
|
||||
@@ -11,5 +9,5 @@ import "embed"
|
||||
var embedded embed.FS
|
||||
|
||||
func init() {
|
||||
FS.embedded = &embedded
|
||||
FS.embedded = &embedded
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 395 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-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-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: 341 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 |
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 |
|
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 +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-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: 269 B After Width: | Height: | Size: 356 B |
@@ -5,10 +5,17 @@
|
||||
<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">
|
||||
<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-light">
|
||||
<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'}">
|
||||
@@ -18,93 +25,97 @@
|
||||
<div class="flex-grow-1"></div>
|
||||
<button class="toolbar-item"
|
||||
:class="{active: filterSelected == 'unread'}"
|
||||
v-b-tooltip.hover.bottom="'Unread'"
|
||||
title="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'"
|
||||
title="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'"
|
||||
title="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">
|
||||
<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>
|
||||
<b-dropdown-item-button @click="showSettings('create')">
|
||||
|
||||
<button class="dropdown-item" @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()">
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item" @click="fetchAllFeeds()">
|
||||
<span class="icon mr-1">{% inline "rotate-cw.svg" %}</span>
|
||||
Refresh Feeds
|
||||
</b-dropdown-item-button>
|
||||
</button>
|
||||
|
||||
<b-dropdown-divider></b-dropdown-divider>
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<b-dropdown-divider></b-dropdown-divider>
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<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">
|
||||
<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">
|
||||
<label class="dropdown-item mb-0 cursor-pointer" for="opml-import" @click.stop="">
|
||||
<span class="icon mr-1">{% inline "download.svg" %}</span>
|
||||
Import
|
||||
</label>
|
||||
</b-dropdown-form>
|
||||
<b-dropdown-item href="./opml/export">
|
||||
</form>
|
||||
<a class="dropdown-item" href="./opml/export">
|
||||
<span class="icon mr-1">{% inline "upload.svg" %}</span>
|
||||
Export
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-divider></b-dropdown-divider>
|
||||
<b-dropdown-item-button @click="showSettings('shortcuts')">
|
||||
</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item" @click="showSettings('shortcuts')">
|
||||
<span class="icon mr-1">{% inline "help-circle.svg" %}</span>
|
||||
Shortcuts
|
||||
</b-dropdown-item-button>
|
||||
<b-dropdown-divider v-if="authenticated"></b-dropdown-divider>
|
||||
<b-dropdown-item-button v-if="authenticated" @click="logout()">
|
||||
</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
|
||||
</b-dropdown-item-button>
|
||||
</b-dropdown>
|
||||
</button>
|
||||
</dropdown>
|
||||
</div>
|
||||
<div id="feed-list-scroll" class="p-2 overflow-auto border-top flex-grow-1">
|
||||
<label class="selectgroup">
|
||||
@@ -120,9 +131,10 @@
|
||||
<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]
|
||||
&& (!itemSelected || feedsById[itemSelectedDetails.feed_id].folder_id != folder.id)}">
|
||||
<input type="radio" name="feed" :value="'folder:'+folder.id" v-model="feedSelected">
|
||||
&& (!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}"
|
||||
@@ -136,15 +148,21 @@
|
||||
<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]
|
||||
&& (!itemSelected || itemSelectedDetails.feed_id != 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 v-lazy="'./api/feeds/'+feed.id+'/icon'" alt=""></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>
|
||||
@@ -161,20 +179,93 @@
|
||||
<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'">
|
||||
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" class="d-block toolbar-search" type="" v-model="itemSearch">
|
||||
<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'"
|
||||
v-b-tooltip.hover.bottom="'Mark All Read'">
|
||||
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" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" 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" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" 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"
|
||||
@@ -187,117 +278,108 @@
|
||||
<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 }}
|
||||
{{ (feedsById[item.feed_id] || {}).title }}
|
||||
</small>
|
||||
<small class="flex-shrink-0"><relative-time :val="item.date"/></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="itemsPage.cur < itemsPage.num"></button>
|
||||
<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="itemSelected">
|
||||
<div class="toolbar px-2 d-flex align-items-center" v-if="itemSelectedDetails">
|
||||
<button class="toolbar-item"
|
||||
@click="toggleItemStarred(itemSelectedDetails)"
|
||||
v-b-tooltip.hover.bottom="'Mark Starred'">
|
||||
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"
|
||||
:disabled="itemSelectedDetails.status=='starred'"
|
||||
v-b-tooltip.hover.bottom="'Mark Unread'"
|
||||
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>
|
||||
<a class="toolbar-item" id="content-appearance" v-b-tooltip.hover.bottom="'Appearance'" tabindex="0">
|
||||
<span class="icon">{% inline "sliders.svg" %}</span>
|
||||
</a>
|
||||
<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="getReadable(itemSelectedDetails)"
|
||||
v-b-tooltip.hover.bottom="'Read Here'">
|
||||
@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" v-b-tooltip.hover.bottom="'Open Link'">
|
||||
<a class="toolbar-item" :href="itemSelectedDetails.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" title="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'">
|
||||
<button class="toolbar-item" @click="navigateToItem(-1)" title="Previous Article" :disabled="itemSelected == items[0].id">
|
||||
<span class="icon">{% inline "chevron-left.svg" %}</span>
|
||||
</button>
|
||||
<button class="toolbar-item" @click="navigateToItem(+1)" title="Next Article" :disabled="itemSelected == items[items.length - 1].id">
|
||||
<span class="icon">{% inline "chevron-right.svg" %}</span>
|
||||
</button>
|
||||
<button class="toolbar-item" @click="itemSelected=null" title="Close Article">
|
||||
<span class="icon">{% inline "x.svg" %}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="itemSelected"
|
||||
<div v-if="itemSelectedDetails"
|
||||
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>
|
||||
: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>
|
||||
<hr>
|
||||
<audio class="w-100" controls v-if="itemSelectedDetails.podcast_url" :src="itemSelectedDetails.podcast_url"></audio>
|
||||
<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')">
|
||||
<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">
|
||||
<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">{{ folder.title }}</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">
|
||||
@@ -315,144 +397,35 @@
|
||||
<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 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><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><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 articles as read</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<kbd>r</kbd>
|
||||
</td>
|
||||
<td>toggle an article as read / unread</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>s</kbd></td>
|
||||
<td>toggle an article as starred / unstarred</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>o</kbd></td>
|
||||
<td>open an article's link</td>
|
||||
<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>
|
||||
</div>
|
||||
</modal>
|
||||
</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.min.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>
|
||||
<script src="./static/javascripts/keybindings.js"></script>
|
||||
<script src="./static/javascripts/key.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
2
src/assets/javascripts/Readability.min.js
vendored
@@ -71,6 +71,9 @@
|
||||
}
|
||||
},
|
||||
items: {
|
||||
get: function(id) {
|
||||
return api('get', './api/items/' + id).then(json)
|
||||
},
|
||||
list: function(query) {
|
||||
return api('get', './api/items' + param(query)).then(json)
|
||||
},
|
||||
@@ -102,9 +105,7 @@
|
||||
return api('post', './logout')
|
||||
},
|
||||
crawl: function(url) {
|
||||
return xfetch('./page?url=' + url).then(function(res) {
|
||||
return res.text()
|
||||
})
|
||||
return api('get', './page?url=' + encodeURIComponent(url)).then(json)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -2,19 +2,26 @@
|
||||
|
||||
var TITLE = document.title
|
||||
|
||||
function authenticated() {
|
||||
return /auth=.+/g.test(document.cookie)
|
||||
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 FONTS = [
|
||||
"Arial",
|
||||
"Courier New",
|
||||
"Georgia",
|
||||
"Times New Roman",
|
||||
"Verdana",
|
||||
]
|
||||
|
||||
var debounce = function(callback, wait) {
|
||||
var timeout
|
||||
return function() {
|
||||
@@ -26,30 +33,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', {
|
||||
inserted: function(el, binding) {
|
||||
el.addEventListener('scroll', debounce(function(event) {
|
||||
@@ -58,6 +41,12 @@ Vue.directive('scroll', {
|
||||
},
|
||||
})
|
||||
|
||||
Vue.directive('focus', {
|
||||
inserted: function(el) {
|
||||
el.focus()
|
||||
}
|
||||
})
|
||||
|
||||
Vue.component('drag', {
|
||||
props: ['width'],
|
||||
template: '<div class="drag"></div>',
|
||||
@@ -83,6 +72,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) {
|
||||
var sec = (new Date().getTime() - d.getTime()) / 1000
|
||||
var neg = sec < 0
|
||||
@@ -125,58 +204,53 @@ Vue.component('relative-time', {
|
||||
|
||||
var vm = new Vue({
|
||||
created: function() {
|
||||
this.refreshFeeds()
|
||||
this.refreshStats()
|
||||
},
|
||||
mounted: function() {
|
||||
this.$root.$on('bv::modal::hidden', function(bvEvent, modalId) {
|
||||
if (vm.settings == 'create') {
|
||||
vm.feedNewChoice = []
|
||||
vm.feedNewChoiceSelected = ''
|
||||
}
|
||||
.then(this.refreshFeeds.bind(this))
|
||||
.then(this.refreshItems.bind(this, false))
|
||||
|
||||
api.feeds.list_errors().then(function(errors) {
|
||||
vm.feed_errors = errors
|
||||
})
|
||||
},
|
||||
data: function() {
|
||||
var s = app.settings
|
||||
return {
|
||||
'filterSelected': undefined,
|
||||
'filterSelected': s.filter,
|
||||
'folders': [],
|
||||
'feeds': [],
|
||||
'feedSelected': undefined,
|
||||
'feedListWidth': undefined,
|
||||
'feedSelected': s.feed,
|
||||
'feedListWidth': s.feed_list_width || 300,
|
||||
'feedNewChoice': [],
|
||||
'feedNewChoiceSelected': '',
|
||||
'items': [],
|
||||
'itemsPage': {
|
||||
'cur': 1,
|
||||
'num': 1,
|
||||
},
|
||||
'itemsHasMore': true,
|
||||
'itemSelected': null,
|
||||
'itemSelectedDetails': {},
|
||||
'itemSelectedDetails': null,
|
||||
'itemSelectedReadability': '',
|
||||
'itemSearch': '',
|
||||
'itemSortNewestFirst': undefined,
|
||||
'itemListWidth': undefined,
|
||||
'itemSortNewestFirst': s.sort_newest_first,
|
||||
'itemListWidth': s.item_list_width || 300,
|
||||
|
||||
'filteredFeedStats': {},
|
||||
'filteredFolderStats': {},
|
||||
'filteredTotalStats': null,
|
||||
|
||||
'settings': 'create',
|
||||
'settings': '',
|
||||
'loading': {
|
||||
'feeds': 0,
|
||||
'newfeed': false,
|
||||
'items': false,
|
||||
'readability': false,
|
||||
},
|
||||
'fonts': FONTS,
|
||||
'fonts': ['', 'serif', 'monospace'],
|
||||
'feedStats': {},
|
||||
'theme': {
|
||||
'name': 'light',
|
||||
'font': '',
|
||||
'size': 1,
|
||||
'name': s.theme_name,
|
||||
'font': s.theme_font,
|
||||
'size': s.theme_size,
|
||||
},
|
||||
'refreshRate': undefined,
|
||||
'authenticated': authenticated(),
|
||||
'refreshRate': s.refresh_rate,
|
||||
'authenticated': app.authenticated,
|
||||
'feed_errors': {},
|
||||
}
|
||||
},
|
||||
@@ -197,10 +271,24 @@ var vm = new Vue({
|
||||
return folders
|
||||
},
|
||||
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() {
|
||||
return this.items.reduce(function(acc, item) { acc[item.id] = item; return acc }, {})
|
||||
foldersById: function() {
|
||||
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() {
|
||||
if (!this.itemSelected) return ''
|
||||
@@ -208,13 +296,7 @@ var vm = new Vue({
|
||||
if (this.itemSelectedReadability)
|
||||
return this.itemSelectedReadability
|
||||
|
||||
var content = ''
|
||||
if (this.itemSelectedDetails.content)
|
||||
content = this.itemSelectedDetails.content
|
||||
else if (this.itemSelectedDetails.description)
|
||||
content = this.itemSelectedDetails.description
|
||||
|
||||
return sanitize(content, this.itemSelectedDetails.link)
|
||||
return this.itemSelectedDetails.content || ''
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
@@ -245,13 +327,13 @@ var vm = new Vue({
|
||||
},
|
||||
'filterSelected': function(newVal, oldVal) {
|
||||
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.computeStats()
|
||||
},
|
||||
'feedSelected': function(newVal, oldVal) {
|
||||
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
|
||||
if (this.$refs.itemlist) this.$refs.itemlist.scrollTop = 0
|
||||
},
|
||||
@@ -263,19 +345,24 @@ var vm = new Vue({
|
||||
}
|
||||
if (this.$refs.content) this.$refs.content.scrollTop = 0
|
||||
|
||||
this.itemSelectedDetails = this.itemsById[newVal]
|
||||
if (this.itemSelectedDetails.status == 'unread') {
|
||||
this.itemSelectedDetails.status = 'read'
|
||||
this.feedStats[this.itemSelectedDetails.feed_id].unread -= 1
|
||||
api.items.update(this.itemSelectedDetails.id, {status: this.itemSelectedDetails.status})
|
||||
}
|
||||
api.items.get(newVal).then(function(item) {
|
||||
this.itemSelectedDetails = item
|
||||
if (this.itemSelectedDetails.status == 'unread') {
|
||||
api.items.update(this.itemSelectedDetails.id, {status: 'read'}).then(function() {
|
||||
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) {
|
||||
this.refreshItems()
|
||||
}, 500),
|
||||
'itemSortNewestFirst': function(newVal, oldVal) {
|
||||
if (oldVal === undefined) return // do nothing, initial setup
|
||||
api.settings.update({sort_newest_first: newVal}).then(this.refreshItems.bind(this))
|
||||
api.settings.update({sort_newest_first: newVal}).then(vm.refreshItems.bind(this, false))
|
||||
},
|
||||
'feedListWidth': debounce(function(newVal, oldVal) {
|
||||
if (oldVal === undefined) return // do nothing, initial setup
|
||||
@@ -292,7 +379,7 @@ var vm = new Vue({
|
||||
},
|
||||
methods: {
|
||||
refreshStats: function(loopMode) {
|
||||
api.status().then(function(data) {
|
||||
return api.status().then(function(data) {
|
||||
if (loopMode && !vm.itemSelected) vm.refreshItems()
|
||||
|
||||
vm.loading.feeds = data.running
|
||||
@@ -303,6 +390,10 @@ var vm = new Vue({
|
||||
acc[stat.feed_id] = stat
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
api.feeds.list_errors().then(function(errors) {
|
||||
vm.feed_errors = errors
|
||||
})
|
||||
})
|
||||
},
|
||||
getItemsQuery: function() {
|
||||
@@ -336,34 +427,53 @@ var vm = new Vue({
|
||||
vm.feeds = values[1]
|
||||
})
|
||||
},
|
||||
refreshItems: function() {
|
||||
refreshItems: function(loadMore = false) {
|
||||
if (this.feedSelected === null) {
|
||||
vm.items = []
|
||||
vm.itemsPage = {'cur': 1, 'num': 1}
|
||||
vm.itemsHasMore = false
|
||||
return
|
||||
}
|
||||
|
||||
var query = this.getItemsQuery()
|
||||
if (loadMore) {
|
||||
query.after = vm.items[vm.items.length-1].id
|
||||
}
|
||||
|
||||
this.loading.items = true
|
||||
return api.items.list(query).then(function(data) {
|
||||
vm.items = data.list
|
||||
vm.itemsPage = data.page
|
||||
if (loadMore) {
|
||||
vm.items = vm.items.concat(data.list)
|
||||
} else {
|
||||
vm.items = data.list
|
||||
}
|
||||
vm.itemsHasMore = data.has_more
|
||||
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
|
||||
|
||||
if (el.scrollHeight === 0) return false // element is invisible (responsive design)
|
||||
|
||||
var closeToBottom = (el.scrollHeight - el.scrollTop - el.offsetHeight) < bottomSpace * scale
|
||||
return closeToBottom
|
||||
},
|
||||
loadMoreItems: function(event, el) {
|
||||
if (this.itemsPage.cur >= this.itemsPage.num) return
|
||||
if (!this.itemsHasMore) return
|
||||
if (this.loading.items) return
|
||||
var closeToBottom = (el.scrollHeight - el.scrollTop - el.offsetHeight) < 50
|
||||
if (closeToBottom) {
|
||||
this.loading.moreitems = true
|
||||
var query = this.getItemsQuery()
|
||||
query.page = this.itemsPage.cur + 1
|
||||
api.items.list(query).then(function(data) {
|
||||
vm.items = vm.items.concat(data.list)
|
||||
vm.itemsPage = data.page
|
||||
vm.loading.items = false
|
||||
})
|
||||
}
|
||||
if (this.itemListCloseToBottom()) return this.refreshItems(true)
|
||||
if (this.itemSelected && this.itemSelected === this.items[this.items.length - 1].id) return this.refreshItems(true)
|
||||
},
|
||||
markItemsRead: function() {
|
||||
var query = this.getItemsQuery()
|
||||
@@ -371,6 +481,7 @@ var vm = new Vue({
|
||||
vm.items = []
|
||||
vm.itemsPage = {'cur': 1, 'num': 1}
|
||||
vm.itemSelected = null
|
||||
vm.itemsHasMore = false
|
||||
vm.refreshStats()
|
||||
})
|
||||
},
|
||||
@@ -421,21 +532,29 @@ var vm = new Vue({
|
||||
if (newTitle) {
|
||||
api.folders.update(folder.id, {title: newTitle}).then(function() {
|
||||
folder.title = newTitle
|
||||
})
|
||||
this.folders.sort(function(a, b) {
|
||||
return a.title.localeCompare(b.title)
|
||||
})
|
||||
}.bind(this))
|
||||
}
|
||||
},
|
||||
deleteFolder: function(folder) {
|
||||
if (confirm('Are you sure you want to delete ' + folder.title + '?')) {
|
||||
api.folders.delete(folder.id).then(function() {
|
||||
if (vm.feedSelected === 'folder:'+folder.id) {
|
||||
vm.items = []
|
||||
vm.feedSelected = ''
|
||||
}
|
||||
vm.feedSelected = null
|
||||
vm.refreshStats()
|
||||
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) {
|
||||
var newTitle = prompt('Enter new title', feed.title)
|
||||
if (newTitle) {
|
||||
@@ -447,12 +566,7 @@ var vm = new Vue({
|
||||
deleteFeed: function(feed) {
|
||||
if (confirm('Are you sure you want to delete ' + feed.title + '?')) {
|
||||
api.feeds.delete(feed.id).then(function() {
|
||||
// unselect feed to prevent reading properties of null in template
|
||||
var isSelected = !vm.feedSelected
|
||||
|| (vm.feedSelected === 'feed:'+feed.id
|
||||
|| (feed.folder_id && vm.feedSelected === 'folder:'+feed.folder_id));
|
||||
if (isSelected) vm.feedSelected = null
|
||||
|
||||
vm.feedSelected = null
|
||||
vm.refreshStats()
|
||||
vm.refreshFeeds()
|
||||
})
|
||||
@@ -472,7 +586,8 @@ var vm = new Vue({
|
||||
if (result.status === 'success') {
|
||||
vm.refreshFeeds()
|
||||
vm.refreshStats()
|
||||
vm.$bvModal.hide('settings-modal')
|
||||
vm.settings = ''
|
||||
vm.feedSelected = 'feed:' + result.feed.id
|
||||
} else if (result.status === 'multiple') {
|
||||
vm.feedNewChoice = result.choice
|
||||
vm.feedNewChoiceSelected = result.choice[0].url
|
||||
@@ -482,25 +597,30 @@ var vm = new Vue({
|
||||
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) {
|
||||
if (item.status == 'starred') {
|
||||
item.status = 'read'
|
||||
this.feedStats[item.feed_id].starred -= 1
|
||||
} else if (item.status != 'starred') {
|
||||
item.status = 'starred'
|
||||
this.feedStats[item.feed_id].starred += 1
|
||||
}
|
||||
api.items.update(item.id, {status: item.status})
|
||||
this.toggleItemStatus(item, 'starred', 'read')
|
||||
},
|
||||
toggleItemRead: function(item) {
|
||||
if (item.status == 'unread') {
|
||||
item.status = 'read'
|
||||
this.feedStats[item.feed_id].unread -= 1
|
||||
} else if (item.status == 'read') {
|
||||
item.status = 'unread'
|
||||
this.feedStats[item.feed_id].unread += 1
|
||||
}
|
||||
api.items.update(item.id, {status: item.status})
|
||||
this.toggleItemStatus(item, 'unread', 'read')
|
||||
},
|
||||
importOPML: function(event) {
|
||||
var input = event.target
|
||||
@@ -517,33 +637,27 @@ var vm = new Vue({
|
||||
document.location.reload()
|
||||
})
|
||||
},
|
||||
getReadable: function(item) {
|
||||
toggleReadability: function() {
|
||||
if (this.itemSelectedReadability) {
|
||||
this.itemSelectedReadability = null
|
||||
return
|
||||
}
|
||||
var item = this.itemSelectedDetails
|
||||
if (!item) return
|
||||
if (item.link) {
|
||||
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
|
||||
if (!body.length) return
|
||||
var bodyClean = sanitize(body, item.link)
|
||||
var doc = new DOMParser().parseFromString(bodyClean, 'text/html')
|
||||
var parsed = new Readability(doc).parse()
|
||||
if (parsed && parsed.content) {
|
||||
vm.itemSelectedReadability = parsed.content
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
showSettings: function(settings) {
|
||||
this.settings = settings
|
||||
this.$bvModal.show('settings-modal')
|
||||
|
||||
if (settings === 'manage') {
|
||||
api.feeds.list_errors().then(function(errors) {
|
||||
vm.feed_errors = errors
|
||||
})
|
||||
if (settings === 'create') {
|
||||
vm.feedNewChoice = []
|
||||
vm.feedNewChoiceSelected = ''
|
||||
}
|
||||
},
|
||||
resizeFeedList: function(width) {
|
||||
@@ -560,7 +674,10 @@ var vm = new Vue({
|
||||
this.theme.size = +(this.theme.size + (0.1 * x)).toFixed(1)
|
||||
},
|
||||
fetchAllFeeds: function() {
|
||||
api.feeds.refresh().then(this.refreshStats.bind(this))
|
||||
if (this.loading.feeds) return
|
||||
api.feeds.refresh().then(function() {
|
||||
vm.refreshStats()
|
||||
})
|
||||
},
|
||||
computeStats: function() {
|
||||
var filter = this.filterSelected
|
||||
@@ -590,19 +707,66 @@ var vm = new Vue({
|
||||
this.filteredFolderStats = statsFolders
|
||||
this.filteredTotalStats = statsTotal
|
||||
},
|
||||
// navigation helper, navigate relative to selected item
|
||||
navigateToItem: function(relativePosition) {
|
||||
let vm = this
|
||||
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)
|
||||
|
||||
vm.loadMoreItems()
|
||||
})
|
||||
},
|
||||
// navigation helper, navigate relative to selected feed
|
||||
navigateToFeed: function(relativePosition) {
|
||||
let vm = this
|
||||
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)
|
||||
})
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
api.settings.get().then(function(data) {
|
||||
vm.feedSelected = data.feed
|
||||
vm.filterSelected = data.filter
|
||||
vm.itemSortNewestFirst = data.sort_newest_first
|
||||
vm.feedListWidth = data.feed_list_width || 300
|
||||
vm.itemListWidth = data.item_list_width || 300
|
||||
vm.theme.name = data.theme_name
|
||||
vm.theme.font = data.theme_font
|
||||
vm.theme.size = data.theme_size
|
||||
vm.refreshRate = data.refresh_rate
|
||||
vm.refreshItems()
|
||||
vm.$mount('#app')
|
||||
})
|
||||
vm.$mount('#app')
|
||||
|
||||
11
src/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 });
|
||||
|
||||
})));
|
||||
133
src/assets/javascripts/key.js
Normal file
@@ -0,0 +1,133 @@
|
||||
var helperFunctions = {
|
||||
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', 'noopener,noreferrer')
|
||||
}
|
||||
},
|
||||
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(){
|
||||
vm.navigateToItem(+1)
|
||||
},
|
||||
previousItem() {
|
||||
vm.navigateToItem(-1)
|
||||
},
|
||||
nextFeed(){
|
||||
vm.navigateToFeed(+1)
|
||||
},
|
||||
previousFeed() {
|
||||
vm.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()
|
||||
}
|
||||
})
|
||||
@@ -1,164 +0,0 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
var shortcutFunctions = {
|
||||
openItemLink: function() {
|
||||
if (vm.itemSelectedDetails && vm.itemSelectedDetails.link) {
|
||||
window.open(vm.itemSelectedDetails.link, '_blank')
|
||||
}
|
||||
},
|
||||
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)
|
||||
},
|
||||
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,
|
||||
"r": shortcutFunctions.toggleItemRead,
|
||||
"R": shortcutFunctions.markAllRead,
|
||||
"s": shortcutFunctions.toggleItemStarred,
|
||||
"/": shortcutFunctions.focusSearch,
|
||||
"j": shortcutFunctions.nextItem,
|
||||
"k": shortcutFunctions.previousItem,
|
||||
"l": shortcutFunctions.nextFeed,
|
||||
"h": shortcutFunctions.previousFeed,
|
||||
"1": shortcutFunctions.showUnread,
|
||||
"2": shortcutFunctions.showStarred,
|
||||
"3": 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) {
|
||||
return
|
||||
}
|
||||
var keybindFunction = keybindings[event.key]
|
||||
if (keybindFunction) {
|
||||
event.preventDefault()
|
||||
keybindFunction()
|
||||
}
|
||||
})
|
||||
6
src/assets/javascripts/popper.min.js
vendored
3
src/assets/javascripts/purify.min.js
vendored
1
src/assets/javascripts/url-polyfill.min.js
vendored
@@ -5,7 +5,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">
|
||||
<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">
|
||||
<style>
|
||||
form {
|
||||
@@ -21,16 +22,20 @@
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<body class="theme-{% .settings.theme_name %}">
|
||||
<form action="" method="post">
|
||||
<img src="./static/graphicarts/anchor.svg" alt="">
|
||||
{% if .error %}
|
||||
<div class="text-danger text-center my-3">{% .error %}</div>
|
||||
{% end %}
|
||||
<div class="form-group">
|
||||
<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 class="form-group">
|
||||
<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>
|
||||
<button class="btn btn-block btn-default" type="submit">Login</button>
|
||||
</form>
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body {
|
||||
html {
|
||||
font-size: 15px !important;
|
||||
}
|
||||
|
||||
body {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
@@ -13,8 +16,8 @@ body {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
box-shadow: inset 0px 1px 1px rgba(0, 0, 0, 0.07);
|
||||
.form-control, .form-control:focus {
|
||||
box-shadow: inset 0px 1px 1px rgba(0, 0, 0, 0.07) !important;
|
||||
}
|
||||
|
||||
select.form-control {
|
||||
@@ -23,12 +26,11 @@ select.form-control {
|
||||
|
||||
select.form-control:not([multiple]):not([size]) {
|
||||
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;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-control:focus, .btn:focus {
|
||||
.btn:focus {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
@@ -44,26 +46,25 @@ select.form-control:not([multiple]):not([size]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#settings-modal {
|
||||
color: #212529 !important;
|
||||
}
|
||||
|
||||
.settings-dropdown .dropdown-toggle {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
.settings-dropdown .dropdown-menu {
|
||||
padding: 0;
|
||||
box-shadow: 0px 1px 5px rgba(0, 0, 0, 0.07);
|
||||
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;
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
.settings-dropdown .dropdown-divider {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -71,28 +72,15 @@ select.form-control:not([multiple]):not([size]) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.settings-dropdown .dropdown-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-dropdown .dropdown-item:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.settings-dropdown.large .dropdown-item {
|
||||
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 {
|
||||
.settings-dropdown form:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -100,13 +88,8 @@ select.form-control:not([multiple]):not([size]) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.b-tooltip {
|
||||
opacity: 1;
|
||||
font-size: .7rem;
|
||||
}
|
||||
|
||||
.b-tooltip:focus {
|
||||
outline: none;
|
||||
.table-compact {
|
||||
color: unset !important;
|
||||
}
|
||||
|
||||
.table-compact tr td:first-child {
|
||||
@@ -119,6 +102,14 @@ select.form-control:not([multiple]):not([size]) {
|
||||
|
||||
/* custom elements */
|
||||
|
||||
.font-serif {
|
||||
font-family: Georgia, serif;
|
||||
}
|
||||
|
||||
.font-monospace {
|
||||
font-family: SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.icon {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
@@ -176,7 +167,9 @@ select.form-control:not([multiple]):not([size]) {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
top: 0; left: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.selectgroup + .selectgroup {
|
||||
@@ -193,7 +186,6 @@ select.form-control:not([multiple]):not([size]) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list-row:hover,
|
||||
.toolbar-item:hover,
|
||||
.toolbar-search:hover,
|
||||
.selectgroup-label:hover,
|
||||
@@ -249,7 +241,6 @@ select.form-control:not([multiple]):not([size]) {
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: .25rem;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||
background: linear-gradient(#fff, #f5f7f9);
|
||||
}
|
||||
|
||||
.btn-default:active {
|
||||
@@ -266,16 +257,6 @@ select.form-control:not([multiple]):not([size]) {
|
||||
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 {
|
||||
min-height: 2rem !important;
|
||||
max-height: 2rem !important;
|
||||
@@ -358,57 +339,18 @@ select.form-control:not([multiple]):not([size]) {
|
||||
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 {
|
||||
position: absolute;
|
||||
top: -999px;
|
||||
left: -999px;
|
||||
}
|
||||
|
||||
.custom-modal {
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
overflow-y: auto;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
/* content */
|
||||
|
||||
.content {
|
||||
@@ -416,11 +358,43 @@ select.form-control:not([multiple]):not([size]) {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: 60rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.content img, .content video {
|
||||
max-width: 100%;
|
||||
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 {
|
||||
overflow-x: auto;
|
||||
color: inherit;
|
||||
@@ -456,14 +430,23 @@ select.form-control:not([multiple]):not([size]) {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* theme: light */
|
||||
|
||||
button.theme-light {
|
||||
background-color: #fff !important;
|
||||
}
|
||||
|
||||
a,
|
||||
.btn-link:hover,
|
||||
.toolbar-item.active {
|
||||
.btn-link:hover {
|
||||
color: #0080d4;
|
||||
}
|
||||
|
||||
.toolbar-item.active,
|
||||
.dropdown-item.active,
|
||||
.dropdown-item:active,
|
||||
.selectgroup input:checked + .selectgroup-label {
|
||||
@@ -478,10 +461,13 @@ a,
|
||||
|
||||
/* theme: sepia */
|
||||
|
||||
.themepicker input[value=sepia] + .themepicker-label,
|
||||
.theme-sepia,
|
||||
.theme-sepia .btn-default,
|
||||
.theme-sepia .dropdown-menu,
|
||||
.theme-sepia .form-control,
|
||||
.theme-sepia .modal-content,
|
||||
.theme-sepia .toolbar-search {
|
||||
background-color: #f4f0e5;
|
||||
background-color: #f4f0e5 !important;
|
||||
}
|
||||
.theme-sepia .content hr,
|
||||
.theme-sepia .content pre,
|
||||
@@ -489,36 +475,46 @@ a,
|
||||
.theme-sepia .border-top {
|
||||
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-search:hover,
|
||||
.theme-sepia .dropdown-item:hover,
|
||||
.theme-sepia .toolbar-search:focus {
|
||||
background-color: #e0d6ba;
|
||||
}
|
||||
|
||||
/* theme: night */
|
||||
|
||||
.themepicker input[value=night] + .themepicker-label,
|
||||
.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 {
|
||||
color: #d1d1d1;
|
||||
background-color: #0e0e0e;
|
||||
}
|
||||
|
||||
.theme-night .content hr,
|
||||
.theme-night .content pre,
|
||||
.theme-night .border-right,
|
||||
.theme-night .border-top {
|
||||
.theme-night .border-top,
|
||||
.theme-night .dropdown-divider {
|
||||
border-color: #1a1a1a !important;
|
||||
}
|
||||
|
||||
.theme-night .selectgroup-label:not(.appearance-option):hover,
|
||||
.theme-night .selectgroup-label:hover,
|
||||
.theme-night .dropdown-item:hover,
|
||||
.theme-night .toolbar-item:hover,
|
||||
.theme-night .toolbar-search:hover,
|
||||
.theme-night .toolbar-search:focus {
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
|
||||
.theme-night .dropdown-menu,
|
||||
.theme-night .modal-content {
|
||||
border-color: #1a1a1a;
|
||||
}
|
||||
|
||||
/* animation */
|
||||
.indicator-enter-active, .indicator-leave-active {
|
||||
transition: all .3s;
|
||||
|
||||
1
src/assets/stylesheets/bootstrap.min.css
vendored
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()
|
||||
}
|
||||
}
|
||||
38
src/content/htmlutil/urlutils.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package htmlutil
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Any(els []string, el string, match func(string, string) bool) bool {
|
||||
for _, x := range els {
|
||||
if match(x, el) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func AbsoluteUrl(href, base string) string {
|
||||
baseUrl, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
hrefUrl, err := url.Parse(href)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return baseUrl.ResolveReference(hrefUrl).String()
|
||||
}
|
||||
|
||||
func URLDomain(val string) string {
|
||||
if u, err := url.Parse(val); err == nil {
|
||||
return u.Host
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
func IsAPossibleLink(val string) bool {
|
||||
return strings.HasPrefix(val, "http://") || strings.HasPrefix(val, "https://")
|
||||
}
|
||||
77
src/content/htmlutil/utils.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package htmlutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
var whitespaceRegex = regexp.MustCompile(`[\s]+`)
|
||||
|
||||
func HTML(node *html.Node) string {
|
||||
writer := strings.Builder{}
|
||||
html.Render(&writer, node)
|
||||
return writer.String()
|
||||
}
|
||||
|
||||
func InnerHTML(node *html.Node) string {
|
||||
writer := strings.Builder{}
|
||||
for c := node.FirstChild; c != nil; c = c.NextSibling {
|
||||
html.Render(&writer, c)
|
||||
}
|
||||
return writer.String()
|
||||
}
|
||||
|
||||
func Attr(node *html.Node, key string) string {
|
||||
for _, a := range node.Attr {
|
||||
if strings.EqualFold(a.Key, key) {
|
||||
return a.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func Text(node *html.Node) string {
|
||||
text := make([]string, 0)
|
||||
isTextNode := func(n *html.Node) bool {
|
||||
return n.Type == html.TextNode
|
||||
}
|
||||
for _, n := range FindNodes(node, isTextNode) {
|
||||
text = append(text, strings.TrimSpace(n.Data))
|
||||
}
|
||||
return strings.Join(text, " ")
|
||||
}
|
||||
|
||||
func ExtractText(content string) string {
|
||||
tokenizer := html.NewTokenizer(strings.NewReader(content))
|
||||
buffer := bytes.Buffer{}
|
||||
for {
|
||||
token := tokenizer.Next()
|
||||
if token == html.ErrorToken {
|
||||
break
|
||||
}
|
||||
if token == html.TextToken {
|
||||
buffer.WriteString(html.UnescapeString(string(tokenizer.Text())))
|
||||
}
|
||||
}
|
||||
text := buffer.String()
|
||||
text = strings.TrimSpace(text)
|
||||
text = whitespaceRegex.ReplaceAllLiteralString(text, " ")
|
||||
return text
|
||||
}
|
||||
|
||||
func TruncateText(input string, size int) string {
|
||||
runes := []rune(input)
|
||||
if len(runes) <= size {
|
||||
return input
|
||||
}
|
||||
for i := size - 1; i > 0; i-- {
|
||||
if unicode.IsSpace(runes[i]) {
|
||||
return string(runes[:i]) + " ..."
|
||||
}
|
||||
}
|
||||
return input
|
||||
}
|
||||
44
src/content/htmlutil/utils_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package htmlutil
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractText(t *testing.T) {
|
||||
testcases := [][2]string{
|
||||
{"hello", "<div>hello</div>"},
|
||||
{"hello world", "<div>hello</div> world"},
|
||||
{"helloworld", "<div>hello</div>world"},
|
||||
{"hello world", "hello <div>world</div>"},
|
||||
{"helloworld", "hello<div>world</div>"},
|
||||
{"hello world!", "hello <div>world</div>!"},
|
||||
{"hello world !", "hello <div> world\r\n </div>!"},
|
||||
}
|
||||
for _, testcase := range testcases {
|
||||
want := testcase[0]
|
||||
base := testcase[1]
|
||||
have := ExtractText(base)
|
||||
if want != have {
|
||||
t.Logf("base: %#v\n", base)
|
||||
t.Logf("want: %#v\n", want)
|
||||
t.Logf("have: %#v\n", have)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateText(t *testing.T) {
|
||||
input := "Lorem ipsum — классический текст-«рыба»"
|
||||
|
||||
size := 30
|
||||
want := "Lorem ipsum — классический ..."
|
||||
have := TruncateText(input, size)
|
||||
if want != have {
|
||||
t.Errorf("\nsize: %d\nwant: %#v\nhave: %#v", size, want, have)
|
||||
}
|
||||
|
||||
size = 1000
|
||||
want = input
|
||||
have = TruncateText(input, size)
|
||||
if want != have {
|
||||
t.Errorf("\nsize: %d\nwant: %#v\nhave: %#v", size, want, have)
|
||||
}
|
||||
}
|
||||
177
src/content/readability/LICENSE
Normal file
@@ -0,0 +1,177 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
280
src/content/readability/readability.go
Normal file
@@ -0,0 +1,280 @@
|
||||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package readability
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/nkanaev/yarr/src/content/htmlutil"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTagsToScore = "section,h2,h3,h4,h5,h6,p,td,pre,div"
|
||||
)
|
||||
|
||||
var (
|
||||
divToPElementsRegexp = regexp.MustCompile(`(?i)<(a|blockquote|dl|div|img|ol|p|pre|table|ul)`)
|
||||
sentenceRegexp = regexp.MustCompile(`\.( |$)`)
|
||||
|
||||
blacklistCandidatesRegexp = regexp.MustCompile(`(?i)popupbody|-ad|g-plus`)
|
||||
okMaybeItsACandidateRegexp = regexp.MustCompile(`(?i)and|article|body|column|main|shadow`)
|
||||
unlikelyCandidatesRegexp = regexp.MustCompile(`(?i)banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|foot|header|legends|menu|modal|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote`)
|
||||
|
||||
negativeRegexp = regexp.MustCompile(`(?i)hidden|^hid$|hid$|hid|^hid |banner|combx|comment|com-|contact|foot|footer|footnote|masthead|media|meta|modal|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|tool|widget|byline|author|dateline|writtenby|p-author`)
|
||||
positiveRegexp = regexp.MustCompile(`(?i)article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story`)
|
||||
)
|
||||
|
||||
type nodeScores map[*html.Node]float32
|
||||
|
||||
// ExtractContent returns relevant content.
|
||||
func ExtractContent(page io.Reader) (string, error) {
|
||||
root, err := html.Parse(page)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, trash := range htmlutil.Query(root, "script,style") {
|
||||
if trash.Parent != nil {
|
||||
trash.Parent.RemoveChild(trash)
|
||||
}
|
||||
}
|
||||
|
||||
transformMisusedDivsIntoParagraphs(root)
|
||||
removeUnlikelyCandidates(root)
|
||||
|
||||
scores := getCandidates(root)
|
||||
//log.Printf("[Readability] Candidates: %v", candidates)
|
||||
|
||||
best := getTopCandidate(scores)
|
||||
if best == nil {
|
||||
for _, body := range htmlutil.Query(root, "body") {
|
||||
best = body
|
||||
break
|
||||
}
|
||||
if best == nil {
|
||||
return "", errors.New("failed to extract content")
|
||||
}
|
||||
}
|
||||
//log.Printf("[Readability] TopCandidate: %v", topCandidate)
|
||||
|
||||
output := getArticle(best, scores)
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// Now that we have the top candidate, look through its siblings for content that might also be related.
|
||||
// Things like preambles, content split by ads that we removed, etc.
|
||||
func getArticle(best *html.Node, scores nodeScores) string {
|
||||
output := bytes.NewBufferString("<div>")
|
||||
siblingScoreThreshold := float32(math.Max(10, float64(scores[best]*.2)))
|
||||
|
||||
nodelist := make([]*html.Node, 0)
|
||||
nodelist = append(nodelist, best)
|
||||
|
||||
// Get the candidate's siblings
|
||||
for n := best.NextSibling; n != nil; n = n.NextSibling {
|
||||
nodelist = append(nodelist, n)
|
||||
}
|
||||
for n := best.PrevSibling; n != nil; n = n.PrevSibling {
|
||||
nodelist = append(nodelist, n)
|
||||
}
|
||||
|
||||
for _, node := range nodelist {
|
||||
append := false
|
||||
isP := node.Data == "p"
|
||||
|
||||
if node == best {
|
||||
append = true
|
||||
} else if scores[node] >= siblingScoreThreshold {
|
||||
append = true
|
||||
} else {
|
||||
if isP {
|
||||
linkDensity := getLinkDensity(node)
|
||||
content := htmlutil.Text(node)
|
||||
contentLength := len(content)
|
||||
|
||||
if contentLength >= 80 && linkDensity < .25 {
|
||||
append = true
|
||||
} else if contentLength < 80 && linkDensity == 0 && sentenceRegexp.MatchString(content) {
|
||||
append = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if append {
|
||||
tag := "div"
|
||||
if isP {
|
||||
tag = "p"
|
||||
}
|
||||
fmt.Fprintf(output, "<%s>%s</%s>", tag, htmlutil.InnerHTML(node), tag)
|
||||
}
|
||||
}
|
||||
|
||||
output.Write([]byte("</div>"))
|
||||
return output.String()
|
||||
}
|
||||
|
||||
func removeUnlikelyCandidates(root *html.Node) {
|
||||
body := htmlutil.Query(root, "body")
|
||||
if len(body) == 0 {
|
||||
return
|
||||
}
|
||||
for _, node := range htmlutil.Query(body[0], "*") {
|
||||
str := htmlutil.Attr(node, "class") + htmlutil.Attr(node, "id")
|
||||
|
||||
if htmlutil.Closest(node, "table,code") != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
blacklisted := (blacklistCandidatesRegexp.MatchString(str) ||
|
||||
(unlikelyCandidatesRegexp.MatchString(str) &&
|
||||
!okMaybeItsACandidateRegexp.MatchString(str)))
|
||||
if blacklisted && node.Parent != nil {
|
||||
node.Parent.RemoveChild(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getTopCandidate(scores nodeScores) *html.Node {
|
||||
var top *html.Node
|
||||
var max float32
|
||||
|
||||
for node, score := range scores {
|
||||
if score > max {
|
||||
top = node
|
||||
max = score
|
||||
}
|
||||
}
|
||||
|
||||
return top
|
||||
}
|
||||
|
||||
// Loop through all paragraphs, and assign a score to them based on how content-y they look.
|
||||
// Then add their score to their parent node.
|
||||
// A score is determined by things like number of commas, class names, etc.
|
||||
// Maybe eventually link density.
|
||||
func getCandidates(root *html.Node) nodeScores {
|
||||
scores := make(nodeScores)
|
||||
for _, node := range htmlutil.Query(root, defaultTagsToScore) {
|
||||
text := htmlutil.Text(node)
|
||||
|
||||
// If this paragraph is less than 25 characters, don't even count it.
|
||||
if len(text) < 25 {
|
||||
continue
|
||||
}
|
||||
|
||||
parentNode := node.Parent
|
||||
grandParentNode := parentNode.Parent
|
||||
|
||||
if _, found := scores[parentNode]; !found {
|
||||
scores[parentNode] = scoreNode(parentNode)
|
||||
}
|
||||
|
||||
if grandParentNode != nil {
|
||||
if _, found := scores[grandParentNode]; !found {
|
||||
scores[grandParentNode] = scoreNode(grandParentNode)
|
||||
}
|
||||
}
|
||||
|
||||
// Add a point for the paragraph itself as a base.
|
||||
contentScore := float32(1.0)
|
||||
|
||||
// Add points for any commas within this paragraph.
|
||||
contentScore += float32(strings.Count(text, ",") + 1)
|
||||
|
||||
// For every 100 characters in this paragraph, add another point. Up to 3 points.
|
||||
contentScore += float32(math.Min(float64(int(len(text)/100.0)), 3))
|
||||
|
||||
scores[parentNode] += contentScore
|
||||
if grandParentNode != nil {
|
||||
scores[grandParentNode] += contentScore / 2.0
|
||||
}
|
||||
}
|
||||
|
||||
// Scale the final candidates score based on link density. Good content
|
||||
// should have a relatively small link density (5% or less) and be mostly
|
||||
// unaffected by this operation
|
||||
for node := range scores {
|
||||
scores[node] *= (1 - getLinkDensity(node))
|
||||
}
|
||||
|
||||
return scores
|
||||
}
|
||||
|
||||
func scoreNode(node *html.Node) float32 {
|
||||
var score float32
|
||||
|
||||
switch node.Data {
|
||||
case "div":
|
||||
score += 5
|
||||
case "pre", "td", "blockquote", "img":
|
||||
score += 3
|
||||
case "address", "ol", "ul", "dl", "dd", "dt", "li", "form":
|
||||
score -= 3
|
||||
case "h1", "h2", "h3", "h4", "h5", "h6", "th":
|
||||
score -= 5
|
||||
}
|
||||
|
||||
return score + getClassWeight(node)
|
||||
}
|
||||
|
||||
// Get the density of links as a percentage of the content
|
||||
// This is the amount of text that is inside a link divided by the total text in the node.
|
||||
func getLinkDensity(n *html.Node) float32 {
|
||||
textLength := len(htmlutil.Text(n))
|
||||
if textLength == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
linkLength := 0.0
|
||||
for _, a := range htmlutil.Query(n, "a") {
|
||||
linkLength += float64(len(htmlutil.Text(a)))
|
||||
}
|
||||
|
||||
return float32(linkLength) / float32(textLength)
|
||||
}
|
||||
|
||||
// Get an elements class/id weight. Uses regular expressions to tell if this
|
||||
// element looks good or bad.
|
||||
func getClassWeight(node *html.Node) float32 {
|
||||
weight := 0
|
||||
class := htmlutil.Attr(node, "class")
|
||||
id := htmlutil.Attr(node, "id")
|
||||
|
||||
if class != "" {
|
||||
if negativeRegexp.MatchString(class) {
|
||||
weight -= 25
|
||||
}
|
||||
|
||||
if positiveRegexp.MatchString(class) {
|
||||
weight += 25
|
||||
}
|
||||
}
|
||||
|
||||
if id != "" {
|
||||
if negativeRegexp.MatchString(id) {
|
||||
weight -= 25
|
||||
}
|
||||
|
||||
if positiveRegexp.MatchString(id) {
|
||||
weight += 25
|
||||
}
|
||||
}
|
||||
|
||||
return float32(weight)
|
||||
}
|
||||
|
||||
func transformMisusedDivsIntoParagraphs(root *html.Node) {
|
||||
for _, node := range htmlutil.Query(root, "div") {
|
||||
if !divToPElementsRegexp.MatchString(htmlutil.InnerHTML(node)) {
|
||||
node.Data = "p"
|
||||
}
|
||||
}
|
||||
}
|
||||
453
src/content/sanitizer/sanitizer.go
Normal file
@@ -0,0 +1,453 @@
|
||||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package sanitizer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/nkanaev/yarr/src/content/htmlutil"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
var splitSrcsetRegex = regexp.MustCompile(`,\s+`)
|
||||
|
||||
// Sanitize returns safe HTML.
|
||||
func Sanitize(baseURL, input string) string {
|
||||
var buffer bytes.Buffer
|
||||
var tagStack []string
|
||||
var parentTag string
|
||||
blacklistedTagDepth := 0
|
||||
|
||||
tokenizer := html.NewTokenizer(bytes.NewBufferString(input))
|
||||
for {
|
||||
if tokenizer.Next() == html.ErrorToken {
|
||||
err := tokenizer.Err()
|
||||
if err == io.EOF {
|
||||
return buffer.String()
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
token := tokenizer.Token()
|
||||
switch token.Type {
|
||||
case html.TextToken:
|
||||
if blacklistedTagDepth > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// An iframe element never has fallback content.
|
||||
// See https://www.w3.org/TR/2010/WD-html5-20101019/the-iframe-element.html#the-iframe-element
|
||||
if parentTag == "iframe" {
|
||||
continue
|
||||
}
|
||||
|
||||
buffer.WriteString(html.EscapeString(token.Data))
|
||||
case html.StartTagToken:
|
||||
tagName := token.Data
|
||||
parentTag = tagName
|
||||
|
||||
if isValidTag(tagName) {
|
||||
attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
|
||||
|
||||
if hasRequiredAttributes(tagName, attrNames) {
|
||||
wrap := isVideoIframe(token)
|
||||
if wrap {
|
||||
buffer.WriteString(`<div class="video-wrapper">`)
|
||||
}
|
||||
|
||||
if len(attrNames) > 0 {
|
||||
buffer.WriteString("<" + tagName + " " + htmlAttributes + ">")
|
||||
} else {
|
||||
buffer.WriteString("<" + tagName + ">")
|
||||
}
|
||||
|
||||
if tagName == "iframe" {
|
||||
// autoclose iframes
|
||||
buffer.WriteString("</iframe>")
|
||||
if wrap {
|
||||
buffer.WriteString("</div>")
|
||||
}
|
||||
} else {
|
||||
tagStack = append(tagStack, tagName)
|
||||
}
|
||||
}
|
||||
} else if isBlockedTag(tagName) {
|
||||
blacklistedTagDepth++
|
||||
}
|
||||
case html.EndTagToken:
|
||||
tagName := token.Data
|
||||
// iframes are autoclosed. see above
|
||||
if tagName == "iframe" {
|
||||
continue
|
||||
}
|
||||
if isValidTag(tagName) && inList(tagName, tagStack) {
|
||||
buffer.WriteString(fmt.Sprintf("</%s>", tagName))
|
||||
} else if isBlockedTag(tagName) {
|
||||
blacklistedTagDepth--
|
||||
}
|
||||
case html.SelfClosingTagToken:
|
||||
tagName := token.Data
|
||||
if isValidTag(tagName) {
|
||||
attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
|
||||
|
||||
if hasRequiredAttributes(tagName, attrNames) {
|
||||
if len(attrNames) > 0 {
|
||||
buffer.WriteString("<" + tagName + " " + htmlAttributes + "/>")
|
||||
} else {
|
||||
buffer.WriteString("<" + tagName + "/>")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([]string, string) {
|
||||
var htmlAttrs, attrNames []string
|
||||
|
||||
for _, attribute := range attributes {
|
||||
value := attribute.Val
|
||||
|
||||
if !isValidAttribute(tagName, attribute.Key) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (tagName == "img" || tagName == "source") && attribute.Key == "srcset" {
|
||||
value = sanitizeSrcsetAttr(baseURL, value)
|
||||
}
|
||||
|
||||
if isExternalResourceAttribute(attribute.Key) {
|
||||
if tagName == "iframe" {
|
||||
if isValidIframeSource(baseURL, attribute.Val) {
|
||||
value = attribute.Val
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
} else if tagName == "img" && attribute.Key == "src" && isValidDataAttribute(attribute.Val) {
|
||||
value = attribute.Val
|
||||
} else {
|
||||
value = htmlutil.AbsoluteUrl(value, baseURL)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if !hasValidURIScheme(value) || isBlockedResource(value) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
attrNames = append(attrNames, attribute.Key)
|
||||
htmlAttrs = append(htmlAttrs, fmt.Sprintf(`%s="%s"`, attribute.Key, html.EscapeString(value)))
|
||||
}
|
||||
|
||||
extraAttrNames, extraHTMLAttributes := getExtraAttributes(tagName)
|
||||
if len(extraAttrNames) > 0 {
|
||||
attrNames = append(attrNames, extraAttrNames...)
|
||||
htmlAttrs = append(htmlAttrs, extraHTMLAttributes...)
|
||||
}
|
||||
|
||||
return attrNames, strings.Join(htmlAttrs, " ")
|
||||
}
|
||||
|
||||
func getExtraAttributes(tagName string) ([]string, []string) {
|
||||
switch tagName {
|
||||
case "a":
|
||||
return []string{"rel", "target", "referrerpolicy"}, []string{`rel="noopener noreferrer"`, `target="_blank"`, `referrerpolicy="no-referrer"`}
|
||||
case "video", "audio":
|
||||
return []string{"controls"}, []string{"controls"}
|
||||
case "iframe":
|
||||
return []string{"sandbox", "loading"}, []string{`sandbox="allow-scripts allow-same-origin allow-popups"`, `loading="lazy"`}
|
||||
case "img":
|
||||
return []string{"loading"}, []string{`loading="lazy"`, `referrerpolicy="no-referrer"`}
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func isValidTag(tagName string) bool {
|
||||
x := allowedTags.has(tagName) || allowedSvgTags.has(tagName) || allowedSvgFilters.has(tagName)
|
||||
//fmt.Println(tagName, x)
|
||||
return x
|
||||
}
|
||||
|
||||
func isValidAttribute(tagName, attributeName string) bool {
|
||||
if attrs, ok := allowedAttrs[tagName]; ok {
|
||||
return attrs.has(attributeName)
|
||||
}
|
||||
if allowedSvgTags.has(tagName) {
|
||||
return allowedSvgAttrs.has(attributeName)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isExternalResourceAttribute(attribute string) bool {
|
||||
switch attribute {
|
||||
case "src", "href", "poster", "cite":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func hasRequiredAttributes(tagName string, attributes []string) bool {
|
||||
elements := make(map[string][]string)
|
||||
elements["a"] = []string{"href"}
|
||||
elements["iframe"] = []string{"src"}
|
||||
elements["img"] = []string{"src"}
|
||||
elements["source"] = []string{"src", "srcset"}
|
||||
|
||||
for element, attrs := range elements {
|
||||
if tagName == element {
|
||||
for _, attribute := range attributes {
|
||||
for _, attr := range attrs {
|
||||
if attr == attribute {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// See https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml
|
||||
func hasValidURIScheme(src string) bool {
|
||||
scheme := strings.SplitN(src, ":", 2)[0]
|
||||
return allowedURISchemes.has(scheme)
|
||||
}
|
||||
|
||||
func isBlockedResource(src string) bool {
|
||||
blacklist := []string{
|
||||
"feedsportal.com",
|
||||
"api.flattr.com",
|
||||
"stats.wordpress.com",
|
||||
"plus.google.com/share",
|
||||
"twitter.com/share",
|
||||
"feeds.feedburner.com",
|
||||
}
|
||||
|
||||
for _, element := range blacklist {
|
||||
if strings.Contains(src, element) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isValidIframeSource(baseURL, src string) bool {
|
||||
whitelist := []string{
|
||||
"bandcamp.com",
|
||||
"cdn.embedly.com",
|
||||
"invidio.us",
|
||||
"player.bilibili.com",
|
||||
"player.vimeo.com",
|
||||
"soundcloud.com",
|
||||
"vk.com",
|
||||
"w.soundcloud.com",
|
||||
"www.dailymotion.com",
|
||||
"www.youtube-nocookie.com",
|
||||
"www.youtube.com",
|
||||
}
|
||||
|
||||
domain := htmlutil.URLDomain(src)
|
||||
// allow iframe from same origin
|
||||
if htmlutil.URLDomain(baseURL) == domain {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, safeDomain := range whitelist {
|
||||
if safeDomain == domain {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func getTagAllowList() map[string][]string {
|
||||
whitelist := make(map[string][]string)
|
||||
whitelist["img"] = []string{"alt", "title", "src", "srcset", "sizes"}
|
||||
whitelist["picture"] = []string{}
|
||||
whitelist["audio"] = []string{"src"}
|
||||
whitelist["video"] = []string{"poster", "height", "width", "src"}
|
||||
whitelist["source"] = []string{"src", "type", "srcset", "sizes", "media"}
|
||||
whitelist["dt"] = []string{}
|
||||
whitelist["dd"] = []string{}
|
||||
whitelist["dl"] = []string{}
|
||||
whitelist["table"] = []string{}
|
||||
whitelist["caption"] = []string{}
|
||||
whitelist["thead"] = []string{}
|
||||
whitelist["tfooter"] = []string{}
|
||||
whitelist["tr"] = []string{}
|
||||
whitelist["td"] = []string{"rowspan", "colspan"}
|
||||
whitelist["th"] = []string{"rowspan", "colspan"}
|
||||
whitelist["h1"] = []string{}
|
||||
whitelist["h2"] = []string{}
|
||||
whitelist["h3"] = []string{}
|
||||
whitelist["h4"] = []string{}
|
||||
whitelist["h5"] = []string{}
|
||||
whitelist["h6"] = []string{}
|
||||
whitelist["strong"] = []string{}
|
||||
whitelist["em"] = []string{}
|
||||
whitelist["code"] = []string{}
|
||||
whitelist["pre"] = []string{}
|
||||
whitelist["blockquote"] = []string{}
|
||||
whitelist["q"] = []string{"cite"}
|
||||
whitelist["p"] = []string{}
|
||||
whitelist["ul"] = []string{}
|
||||
whitelist["li"] = []string{}
|
||||
whitelist["ol"] = []string{}
|
||||
whitelist["br"] = []string{}
|
||||
whitelist["del"] = []string{}
|
||||
whitelist["a"] = []string{"href", "title"}
|
||||
whitelist["figure"] = []string{}
|
||||
whitelist["figcaption"] = []string{}
|
||||
whitelist["cite"] = []string{}
|
||||
whitelist["time"] = []string{"datetime"}
|
||||
whitelist["abbr"] = []string{"title"}
|
||||
whitelist["acronym"] = []string{"title"}
|
||||
whitelist["wbr"] = []string{}
|
||||
whitelist["dfn"] = []string{}
|
||||
whitelist["sub"] = []string{}
|
||||
whitelist["sup"] = []string{}
|
||||
whitelist["var"] = []string{}
|
||||
whitelist["samp"] = []string{}
|
||||
whitelist["s"] = []string{}
|
||||
whitelist["del"] = []string{}
|
||||
whitelist["ins"] = []string{}
|
||||
whitelist["kbd"] = []string{}
|
||||
whitelist["rp"] = []string{}
|
||||
whitelist["rt"] = []string{}
|
||||
whitelist["rtc"] = []string{}
|
||||
whitelist["ruby"] = []string{}
|
||||
whitelist["iframe"] = []string{"width", "height", "frameborder", "src", "allowfullscreen"}
|
||||
return whitelist
|
||||
}
|
||||
|
||||
func inList(needle string, haystack []string) bool {
|
||||
for _, element := range haystack {
|
||||
if element == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isBlockedTag(tagName string) bool {
|
||||
blacklist := []string{
|
||||
"noscript",
|
||||
"script",
|
||||
"style",
|
||||
}
|
||||
|
||||
for _, element := range blacklist {
|
||||
if element == tagName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/*
|
||||
One or more strings separated by commas, indicating possible image sources for the user agent to use.
|
||||
|
||||
Each string is composed of:
|
||||
- A URL to an image
|
||||
- Optionally, whitespace followed by one of:
|
||||
- A width descriptor (a positive integer directly followed by w). The width descriptor is divided by the source size given in the sizes attribute to calculate the effective pixel density.
|
||||
- A pixel density descriptor (a positive floating point number directly followed by x).
|
||||
*/
|
||||
func sanitizeSrcsetAttr(baseURL, value string) string {
|
||||
var sanitizedSources []string
|
||||
rawSources := splitSrcsetRegex.Split(value, -1)
|
||||
for _, rawSource := range rawSources {
|
||||
parts := strings.Split(strings.TrimSpace(rawSource), " ")
|
||||
nbParts := len(parts)
|
||||
|
||||
if nbParts > 0 {
|
||||
sanitizedSource := parts[0]
|
||||
if !strings.HasPrefix(parts[0], "data:") {
|
||||
sanitizedSource = htmlutil.AbsoluteUrl(parts[0], baseURL)
|
||||
if sanitizedSource == "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if nbParts == 2 && isValidWidthOrDensityDescriptor(parts[1]) {
|
||||
sanitizedSource += " " + parts[1]
|
||||
}
|
||||
|
||||
sanitizedSources = append(sanitizedSources, sanitizedSource)
|
||||
}
|
||||
}
|
||||
return strings.Join(sanitizedSources, ", ")
|
||||
}
|
||||
|
||||
func isValidWidthOrDensityDescriptor(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
lastChar := value[len(value)-1:]
|
||||
if lastChar != "w" && lastChar != "x" {
|
||||
return false
|
||||
}
|
||||
|
||||
_, err := strconv.ParseFloat(value[0:len(value)-1], 32)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func isValidDataAttribute(value string) bool {
|
||||
var dataAttributeAllowList = []string{
|
||||
"data:image/avif",
|
||||
"data:image/apng",
|
||||
"data:image/png",
|
||||
"data:image/svg",
|
||||
"data:image/svg+xml",
|
||||
"data:image/jpg",
|
||||
"data:image/jpeg",
|
||||
"data:image/gif",
|
||||
"data:image/webp",
|
||||
}
|
||||
|
||||
for _, prefix := range dataAttributeAllowList {
|
||||
if strings.HasPrefix(value, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isVideoIframe(token html.Token) bool {
|
||||
videoWhitelist := map[string]bool{
|
||||
"player.bilibili.com": true,
|
||||
"player.vimeo.com": true,
|
||||
"www.dailymotion.com": true,
|
||||
"www.youtube-nocookie.com": true,
|
||||
"www.youtube.com": true,
|
||||
}
|
||||
if token.Data == "iframe" {
|
||||
for _, attr := range token.Attr {
|
||||
if attr.Key == "src" {
|
||||
domain := htmlutil.URLDomain(attr.Val)
|
||||
return videoWhitelist[domain]
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
314
src/content/sanitizer/sanitizer_test.go
Normal file
@@ -0,0 +1,314 @@
|
||||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package sanitizer
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidInput(t *testing.T) {
|
||||
input := `<p>This is a <strong>text</strong> with an image: <img src="http://example.org/" alt="Test" loading="lazy">.</p>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if input != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, input, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImgWithTextDataURL(t *testing.T) {
|
||||
input := `<img src="data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==" alt="Example">`
|
||||
expected := ``
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if output != expected {
|
||||
t.Errorf(`Wrong output: %s`, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImgWithDataURL(t *testing.T) {
|
||||
input := `<img src="data:image/gif;base64,test" alt="Example">`
|
||||
expected := `<img src="data:image/gif;base64,test" alt="Example" loading="lazy">`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if output != expected {
|
||||
t.Errorf(`Wrong output: %s`, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImgWithSrcset(t *testing.T) {
|
||||
input := `<img srcset="example-320w.jpg, example-480w.jpg 1.5x, example-640w.jpg 2x, example-640w.jpg 640w" src="example-640w.jpg" alt="Example">`
|
||||
expected := `<img srcset="http://example.org/example-320w.jpg, http://example.org/example-480w.jpg 1.5x, http://example.org/example-640w.jpg 2x, http://example.org/example-640w.jpg 640w" src="http://example.org/example-640w.jpg" alt="Example" loading="lazy">`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if output != expected {
|
||||
t.Errorf(`Wrong output: %s`, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImgWithSrcsetAndDataURL(t *testing.T) {
|
||||
input := `<img srcset="data:image/gif;base64,test" src="http://example.org/example-320w.jpg" alt="Example">`
|
||||
expected := `<img srcset="data:image/gif;base64,test" src="http://example.org/example-320w.jpg" alt="Example" loading="lazy">`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if output != expected {
|
||||
t.Errorf(`Wrong output: %s`, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceWithSrcsetAndMedia(t *testing.T) {
|
||||
input := `<picture><source media="(min-width: 800px)" srcset="elva-800w.jpg"></picture>`
|
||||
expected := `<picture><source media="(min-width: 800px)" srcset="http://example.org/elva-800w.jpg"></picture>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if output != expected {
|
||||
t.Errorf(`Wrong output: %s`, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediumImgWithSrcset(t *testing.T) {
|
||||
input := `<img alt="Image for post" class="t u v ef aj" src="https://miro.medium.com/max/5460/1*aJ9JibWDqO81qMfNtqgqrw.jpeg" srcset="https://miro.medium.com/max/552/1*aJ9JibWDqO81qMfNtqgqrw.jpeg 276w, https://miro.medium.com/max/1000/1*aJ9JibWDqO81qMfNtqgqrw.jpeg 500w" sizes="500px" width="2730" height="3407">`
|
||||
expected := `<img alt="Image for post" src="https://miro.medium.com/max/5460/1*aJ9JibWDqO81qMfNtqgqrw.jpeg" srcset="https://miro.medium.com/max/552/1*aJ9JibWDqO81qMfNtqgqrw.jpeg 276w, https://miro.medium.com/max/1000/1*aJ9JibWDqO81qMfNtqgqrw.jpeg 500w" sizes="500px" loading="lazy">`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if output != expected {
|
||||
t.Errorf(`Wrong output: %s`, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfClosingTags(t *testing.T) {
|
||||
input := `<p>This <br> is a <strong>text</strong> <br/>with an image: <img src="http://example.org/" alt="Test" loading="lazy"/>.</p>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if input != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, input, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTable(t *testing.T) {
|
||||
input := `<table><tr><th>A</th><th colspan="2">B</th></tr><tr><td>C</td><td>D</td><td>E</td></tr></table>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if input != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, input, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeURL(t *testing.T) {
|
||||
input := `This <a href="/test.html">link is relative</a> and this image: <img src="../folder/image.png"/>`
|
||||
expected := `This <a href="http://example.org/test.html" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer">link is relative</a> and this image: <img src="http://example.org/folder/image.png" loading="lazy"/>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtocolRelativeURL(t *testing.T) {
|
||||
input := `This <a href="//static.example.org/index.html">link is relative</a>.`
|
||||
expected := `This <a href="http://static.example.org/index.html" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer">link is relative</a>.`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidTag(t *testing.T) {
|
||||
input := `<p>My invalid <wtf>tag</wtf>.</p>`
|
||||
expected := `<p>My invalid tag.</p>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoTag(t *testing.T) {
|
||||
input := `<p>My valid <video src="videofile.webm" autoplay poster="posterimage.jpg">fallback</video>.</p>`
|
||||
expected := `<p>My valid <video src="http://example.org/videofile.webm" poster="http://example.org/posterimage.jpg" controls>fallback</video>.</p>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudioAndSourceTag(t *testing.T) {
|
||||
input := `<p>My music <audio controls="controls"><source src="foo.wav" type="audio/wav"></audio>.</p>`
|
||||
expected := `<p>My music <audio controls><source src="http://example.org/foo.wav" type="audio/wav"></audio>.</p>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownTag(t *testing.T) {
|
||||
input := `<p>My invalid <unknown>tag</unknown>.</p>`
|
||||
expected := `<p>My invalid tag.</p>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidNestedTag(t *testing.T) {
|
||||
input := `<p>My invalid <wtf>tag with some <em>valid</em> tag</wtf>.</p>`
|
||||
expected := `<p>My invalid tag with some <em>valid</em> tag.</p>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidIFrame(t *testing.T) {
|
||||
input := `<iframe src="http://example.org/"></iframe>`
|
||||
expected := `<iframe src="http://example.org/" sandbox="allow-scripts allow-same-origin allow-popups" loading="lazy"></iframe>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf("Wrong output:\nwant: %s\nhave: %s", expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidIFrame(t *testing.T) {
|
||||
input := `<iframe src="http://example.org/"></iframe>`
|
||||
expected := ``
|
||||
output := Sanitize("http://example.com/", input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIFrameWithChildElements(t *testing.T) {
|
||||
input := `<iframe src="https://www.youtube.com/"><p>test</p></iframe>`
|
||||
expected := `<div class="video-wrapper"><iframe src="https://www.youtube.com/" sandbox="allow-scripts allow-same-origin allow-popups" loading="lazy"></iframe></div>`
|
||||
output := Sanitize("http://example.com/", input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidURLScheme(t *testing.T) {
|
||||
input := `<p>This link is <a src="file:///etc/passwd">not valid</a></p>`
|
||||
expected := `<p>This link is not valid</p>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailtoURIScheme(t *testing.T) {
|
||||
input := `<p>This link is <a href="mailto:jsmith@example.com?subject=A%20Test&body=My%20idea%20is%3A%20%0A">valid</a></p>`
|
||||
expected := `<p>This link is <a href="mailto:jsmith@example.com?subject=A%20Test&body=My%20idea%20is%3A%20%0A" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer">valid</a></p>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelURIScheme(t *testing.T) {
|
||||
input := `<p>This link is <a href="tel:+1-201-555-0123">valid</a></p>`
|
||||
expected := `<p>This link is <a href="tel:+1-201-555-0123" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer">valid</a></p>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXMPPURIScheme(t *testing.T) {
|
||||
input := `<p>This link is <a href="xmpp:user@host?subscribe&type=subscribed">valid</a></p>`
|
||||
expected := `<p>This link is <a href="xmpp:user@host?subscribe&type=subscribed" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer">valid</a></p>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlacklistedLink(t *testing.T) {
|
||||
input := `<p>This image is not valid <img src="https://stats.wordpress.com/some-tracker"></p>`
|
||||
expected := `<p>This image is not valid </p>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXmlEntities(t *testing.T) {
|
||||
input := `<pre>echo "test" > /etc/hosts</pre>`
|
||||
expected := `<pre>echo "test" > /etc/hosts</pre>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEspaceAttributes(t *testing.T) {
|
||||
input := `<td rowspan="<b>test</b>">test</td>`
|
||||
expected := `<td rowspan="<b>test</b>">test</td>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceIframeURL(t *testing.T) {
|
||||
input := `<iframe src="https://player.vimeo.com/video/123456?title=0&byline=0"></iframe>`
|
||||
expected := `<div class="video-wrapper"><iframe src="https://player.vimeo.com/video/123456?title=0&byline=0" sandbox="allow-scripts allow-same-origin allow-popups" loading="lazy"></iframe></div>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceNoScript(t *testing.T) {
|
||||
input := `<p>Before paragraph.</p><noscript>Inside <code>noscript</code> tag with an image: <img src="http://example.org/" alt="Test" loading="lazy"></noscript><p>After paragraph.</p>`
|
||||
expected := `<p>Before paragraph.</p><p>After paragraph.</p>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceScript(t *testing.T) {
|
||||
input := `<p>Before paragraph.</p><script type="text/javascript">alert("1");</script><p>After paragraph.</p>`
|
||||
expected := `<p>Before paragraph.</p><p>After paragraph.</p>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceStyle(t *testing.T) {
|
||||
input := `<p>Before paragraph.</p><style>body { background-color: #ff0000; }</style><p>After paragraph.</p>`
|
||||
expected := `<p>Before paragraph.</p><p>After paragraph.</p>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapYoutubeIFrames(t *testing.T) {
|
||||
input := `<iframe src="https://www.youtube.com/embed/foobar"></iframe>`
|
||||
expected := `<div class="video-wrapper"><iframe src="https://www.youtube.com/embed/foobar" sandbox="allow-scripts allow-same-origin allow-popups" loading="lazy"></iframe></div>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf("Wrong output:\nwant: %v\nhave: %v", expected, output)
|
||||
}
|
||||
}
|
||||
422
src/content/sanitizer/whitelist.go
Normal file
@@ -0,0 +1,422 @@
|
||||
package sanitizer
|
||||
|
||||
type set struct {
|
||||
m map[string]bool
|
||||
}
|
||||
|
||||
func sset(vals []string) set {
|
||||
m := make(map[string]bool)
|
||||
for _, val := range vals {
|
||||
m[val] = true
|
||||
}
|
||||
return set{m: m}
|
||||
}
|
||||
|
||||
func (s *set) has(val string) bool {
|
||||
_, ok := s.m[val]
|
||||
return ok
|
||||
}
|
||||
|
||||
// taken from: https://github.com/cure53/DOMPurify/blob/e1c19cf6/src/tags.js
|
||||
var allowedTags = sset([]string{
|
||||
"a",
|
||||
"abbr",
|
||||
"acronym",
|
||||
"address",
|
||||
"area",
|
||||
"article",
|
||||
"aside",
|
||||
"audio",
|
||||
"b",
|
||||
"bdi",
|
||||
"bdo",
|
||||
"big",
|
||||
"blink",
|
||||
"blockquote",
|
||||
"body",
|
||||
"br",
|
||||
"button",
|
||||
"canvas",
|
||||
"caption",
|
||||
"center",
|
||||
"cite",
|
||||
"code",
|
||||
"col",
|
||||
"colgroup",
|
||||
"content",
|
||||
"data",
|
||||
"datalist",
|
||||
"dd",
|
||||
"decorator",
|
||||
"del",
|
||||
"details",
|
||||
"dfn",
|
||||
"dialog",
|
||||
"dir",
|
||||
"div",
|
||||
"dl",
|
||||
"dt",
|
||||
"element",
|
||||
"em",
|
||||
"fieldset",
|
||||
"figcaption",
|
||||
"figure",
|
||||
"font",
|
||||
"footer",
|
||||
"form",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"head",
|
||||
"header",
|
||||
"hgroup",
|
||||
"hr",
|
||||
"html",
|
||||
"i",
|
||||
"iframe",
|
||||
"img",
|
||||
"input",
|
||||
"ins",
|
||||
"kbd",
|
||||
"label",
|
||||
"legend",
|
||||
"li",
|
||||
"main",
|
||||
"map",
|
||||
"mark",
|
||||
"marquee",
|
||||
"menu",
|
||||
"menuitem",
|
||||
"meter",
|
||||
"nav",
|
||||
"nobr",
|
||||
"ol",
|
||||
"optgroup",
|
||||
"option",
|
||||
"output",
|
||||
"p",
|
||||
"picture",
|
||||
"pre",
|
||||
"progress",
|
||||
"q",
|
||||
"rp",
|
||||
"rt",
|
||||
"ruby",
|
||||
"s",
|
||||
"samp",
|
||||
"section",
|
||||
"select",
|
||||
"shadow",
|
||||
"small",
|
||||
"source",
|
||||
"spacer",
|
||||
"span",
|
||||
"strike",
|
||||
"strong",
|
||||
"sub",
|
||||
"summary",
|
||||
"sup",
|
||||
"table",
|
||||
"tbody",
|
||||
"td",
|
||||
"template",
|
||||
"textarea",
|
||||
"tfoot",
|
||||
"th",
|
||||
"thead",
|
||||
"time",
|
||||
"tr",
|
||||
"track",
|
||||
"tt",
|
||||
"u",
|
||||
"ul",
|
||||
"var",
|
||||
"video",
|
||||
"wbr",
|
||||
})
|
||||
|
||||
var allowedSvgTags = sset([]string{
|
||||
"svg",
|
||||
"a",
|
||||
"altglyph",
|
||||
"altglyphdef",
|
||||
"altglyphitem",
|
||||
"animatecolor",
|
||||
"animatemotion",
|
||||
"animatetransform",
|
||||
"circle",
|
||||
"clippath",
|
||||
"defs",
|
||||
"desc",
|
||||
"ellipse",
|
||||
"filter",
|
||||
"font",
|
||||
"g",
|
||||
"glyph",
|
||||
"glyphref",
|
||||
"hkern",
|
||||
"image",
|
||||
"line",
|
||||
"lineargradient",
|
||||
"marker",
|
||||
"mask",
|
||||
"metadata",
|
||||
"mpath",
|
||||
"path",
|
||||
"pattern",
|
||||
"polygon",
|
||||
"polyline",
|
||||
"radialgradient",
|
||||
"rect",
|
||||
"stop",
|
||||
//"style",
|
||||
"switch",
|
||||
"symbol",
|
||||
"text",
|
||||
"textpath",
|
||||
"title",
|
||||
"tref",
|
||||
"tspan",
|
||||
"view",
|
||||
"vkern",
|
||||
})
|
||||
|
||||
var allowedSvgFilters = sset([]string{
|
||||
"feBlend",
|
||||
"feColorMatrix",
|
||||
"feComponentTransfer",
|
||||
"feComposite",
|
||||
"feConvolveMatrix",
|
||||
"feDiffuseLighting",
|
||||
"feDisplacementMap",
|
||||
"feDistantLight",
|
||||
"feFlood",
|
||||
"feFuncA",
|
||||
"feFuncB",
|
||||
"feFuncG",
|
||||
"feFuncR",
|
||||
"feGaussianBlur",
|
||||
"feMerge",
|
||||
"feMergeNode",
|
||||
"feMorphology",
|
||||
"feOffset",
|
||||
"fePointLight",
|
||||
"feSpecularLighting",
|
||||
"feSpotLight",
|
||||
"feTile",
|
||||
"feTurbulence",
|
||||
})
|
||||
|
||||
var allowedAttrs = map[string]set{
|
||||
"img": sset([]string{"alt", "title", "src", "srcset", "sizes"}),
|
||||
"audio": sset([]string{"src"}),
|
||||
"video": sset([]string{"poster", "height", "width", "src"}),
|
||||
"source": sset([]string{"src", "type", "srcset", "sizes", "media"}),
|
||||
"td": sset([]string{"rowspan", "colspan"}),
|
||||
"th": sset([]string{"rowspan", "colspan"}),
|
||||
"q": sset([]string{"cite"}),
|
||||
"a": sset([]string{"href", "title"}),
|
||||
"time": sset([]string{"datetime"}),
|
||||
"abbr": sset([]string{"title"}),
|
||||
"acronym": sset([]string{"title"}),
|
||||
"iframe": sset([]string{"width", "height", "frameborder", "src", "allowfullscreen"}),
|
||||
}
|
||||
|
||||
var allowedSvgAttrs = sset([]string{
|
||||
"accent-height",
|
||||
"accumulate",
|
||||
"additive",
|
||||
"alignment-baseline",
|
||||
"ascent",
|
||||
"attributename",
|
||||
"attributetype",
|
||||
"azimuth",
|
||||
"basefrequency",
|
||||
"baseline-shift",
|
||||
"begin",
|
||||
"bias",
|
||||
"by",
|
||||
"class",
|
||||
"clip",
|
||||
"clippathunits",
|
||||
"clip-path",
|
||||
"clip-rule",
|
||||
"color",
|
||||
"color-interpolation",
|
||||
"color-interpolation-filters",
|
||||
"color-profile",
|
||||
"color-rendering",
|
||||
"cx",
|
||||
"cy",
|
||||
"d",
|
||||
"dx",
|
||||
"dy",
|
||||
"diffuseconstant",
|
||||
"direction",
|
||||
"display",
|
||||
"divisor",
|
||||
"dur",
|
||||
"edgemode",
|
||||
"elevation",
|
||||
"end",
|
||||
"fill",
|
||||
"fill-opacity",
|
||||
"fill-rule",
|
||||
"filter",
|
||||
"filterunits",
|
||||
"flood-color",
|
||||
"flood-opacity",
|
||||
"font-family",
|
||||
"font-size",
|
||||
"font-size-adjust",
|
||||
"font-stretch",
|
||||
"font-style",
|
||||
"font-variant",
|
||||
"font-weight",
|
||||
"fx",
|
||||
"fy",
|
||||
"g1",
|
||||
"g2",
|
||||
"glyph-name",
|
||||
"glyphref",
|
||||
"gradientunits",
|
||||
"gradienttransform",
|
||||
"height",
|
||||
"href",
|
||||
"id",
|
||||
"image-rendering",
|
||||
"in",
|
||||
"in2",
|
||||
"k",
|
||||
"k1",
|
||||
"k2",
|
||||
"k3",
|
||||
"k4",
|
||||
"kerning",
|
||||
"keypoints",
|
||||
"keysplines",
|
||||
"keytimes",
|
||||
"lang",
|
||||
"lengthadjust",
|
||||
"letter-spacing",
|
||||
"kernelmatrix",
|
||||
"kernelunitlength",
|
||||
"lighting-color",
|
||||
"local",
|
||||
"marker-end",
|
||||
"marker-mid",
|
||||
"marker-start",
|
||||
"markerheight",
|
||||
"markerunits",
|
||||
"markerwidth",
|
||||
"maskcontentunits",
|
||||
"maskunits",
|
||||
"max",
|
||||
"mask",
|
||||
"media",
|
||||
"method",
|
||||
"mode",
|
||||
"min",
|
||||
"name",
|
||||
"numoctaves",
|
||||
"offset",
|
||||
"operator",
|
||||
"opacity",
|
||||
"order",
|
||||
"orient",
|
||||
"orientation",
|
||||
"origin",
|
||||
"overflow",
|
||||
"paint-order",
|
||||
"path",
|
||||
"pathlength",
|
||||
"patterncontentunits",
|
||||
"patterntransform",
|
||||
"patternunits",
|
||||
"points",
|
||||
"preservealpha",
|
||||
"preserveaspectratio",
|
||||
"primitiveunits",
|
||||
"r",
|
||||
"rx",
|
||||
"ry",
|
||||
"radius",
|
||||
"refx",
|
||||
"refy",
|
||||
"repeatcount",
|
||||
"repeatdur",
|
||||
"restart",
|
||||
"result",
|
||||
"rotate",
|
||||
"scale",
|
||||
"seed",
|
||||
"shape-rendering",
|
||||
"specularconstant",
|
||||
"specularexponent",
|
||||
"spreadmethod",
|
||||
"startoffset",
|
||||
"stddeviation",
|
||||
"stitchtiles",
|
||||
"stop-color",
|
||||
"stop-opacity",
|
||||
"stroke-dasharray",
|
||||
"stroke-dashoffset",
|
||||
"stroke-linecap",
|
||||
"stroke-linejoin",
|
||||
"stroke-miterlimit",
|
||||
"stroke-opacity",
|
||||
"stroke",
|
||||
"stroke-width",
|
||||
//"style",
|
||||
"surfacescale",
|
||||
"systemlanguage",
|
||||
"tabindex",
|
||||
"targetx",
|
||||
"targety",
|
||||
"transform",
|
||||
"text-anchor",
|
||||
"text-decoration",
|
||||
"text-rendering",
|
||||
"textlength",
|
||||
"type",
|
||||
"u1",
|
||||
"u2",
|
||||
"unicode",
|
||||
"values",
|
||||
"viewbox",
|
||||
"visibility",
|
||||
"version",
|
||||
"vert-adv-y",
|
||||
"vert-origin-x",
|
||||
"vert-origin-y",
|
||||
"width",
|
||||
"word-spacing",
|
||||
"wrap",
|
||||
"writing-mode",
|
||||
"xchannelselector",
|
||||
"ychannelselector",
|
||||
"x",
|
||||
"x1",
|
||||
"x2",
|
||||
"xmlns",
|
||||
"y",
|
||||
"y1",
|
||||
"y2",
|
||||
"z",
|
||||
"zoomandpan",
|
||||
})
|
||||
|
||||
var allowedURISchemes = sset([]string{
|
||||
"http",
|
||||
"https",
|
||||
"ftp",
|
||||
"ftps",
|
||||
"tel",
|
||||
"mailto",
|
||||
"callto",
|
||||
"cid",
|
||||
"xmpp",
|
||||
})
|
||||
97
src/content/scraper/finder.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package scraper
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/nkanaev/yarr/src/content/htmlutil"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
func FindFeeds(body string, base string) map[string]string {
|
||||
candidates := make(map[string]string)
|
||||
|
||||
doc, err := html.Parse(strings.NewReader(body))
|
||||
if err != nil {
|
||||
return candidates
|
||||
}
|
||||
|
||||
// find direct links
|
||||
// css: link[type=application/atom+xml]
|
||||
linkTypes := []string{"application/atom+xml", "application/rss+xml", "application/json"}
|
||||
isFeedLink := func(n *html.Node) bool {
|
||||
if n.Type == html.ElementNode && n.Data == "link" {
|
||||
t := htmlutil.Attr(n, "type")
|
||||
for _, tt := range linkTypes {
|
||||
if tt == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
for _, node := range htmlutil.FindNodes(doc, isFeedLink) {
|
||||
href := htmlutil.Attr(node, "href")
|
||||
name := htmlutil.Attr(node, "title")
|
||||
link := htmlutil.AbsoluteUrl(href, base)
|
||||
if link != "" {
|
||||
candidates[link] = name
|
||||
}
|
||||
}
|
||||
|
||||
// guess by hyperlink properties
|
||||
if len(candidates) == 0 {
|
||||
// css: a[href="feed"]
|
||||
// css: a:contains("rss")
|
||||
feedHrefs := []string{"feed", "feed.xml", "rss.xml", "atom.xml"}
|
||||
feedTexts := []string{"rss", "feed"}
|
||||
isFeedHyperLink := func(n *html.Node) bool {
|
||||
if n.Type == html.ElementNode && n.Data == "a" {
|
||||
href := strings.Trim(htmlutil.Attr(n, "href"), "/")
|
||||
for _, feedHref := range feedHrefs {
|
||||
if strings.HasSuffix(href, feedHref) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
text := htmlutil.Text(n)
|
||||
for _, feedText := range feedTexts {
|
||||
if strings.EqualFold(text, feedText) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
for _, node := range htmlutil.FindNodes(doc, isFeedHyperLink) {
|
||||
href := htmlutil.Attr(node, "href")
|
||||
link := htmlutil.AbsoluteUrl(href, base)
|
||||
if link != "" {
|
||||
candidates[link] = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
func FindIcons(body string, base string) []string {
|
||||
icons := make([]string, 0)
|
||||
|
||||
doc, err := html.Parse(strings.NewReader(body))
|
||||
if err != nil {
|
||||
return icons
|
||||
}
|
||||
|
||||
// css: link[rel=icon]
|
||||
isLink := func(n *html.Node) bool {
|
||||
return n.Type == html.ElementNode && n.Data == "link"
|
||||
}
|
||||
for _, node := range htmlutil.FindNodes(doc, isLink) {
|
||||
rels := strings.Split(htmlutil.Attr(node, "rel"), " ")
|
||||
for _, rel := range rels {
|
||||
if strings.EqualFold(rel, "icon") {
|
||||
icons = append(icons, htmlutil.AbsoluteUrl(htmlutil.Attr(node, "href"), base))
|
||||
}
|
||||
}
|
||||
}
|
||||
return icons
|
||||
}
|
||||
97
src/content/scraper/finder_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package scraper
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const base = "http://example.com"
|
||||
|
||||
func TestFindFeedsInvalidHTML(t *testing.T) {
|
||||
x := `some nonsense`
|
||||
r := FindFeeds(x, base)
|
||||
if len(r) != 0 {
|
||||
t.Fatal("not expecting results")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindFeedsLinks(t *testing.T) {
|
||||
x := `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title></title>
|
||||
<link rel="alternate" href="/feed.xml" type="application/rss+xml" title="rss with title">
|
||||
<link rel="alternate" href="/atom.xml" type="application/atom+xml">
|
||||
<link rel="alternate" href="/feed.json" type="application/json">
|
||||
</head>
|
||||
<body>
|
||||
<a href="/feed.xml">rss</a>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
have := FindFeeds(x, base)
|
||||
|
||||
want := map[string]string{
|
||||
base + "/feed.xml": "rss with title",
|
||||
base + "/atom.xml": "",
|
||||
base + "/feed.json": "",
|
||||
}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fatal("invalid result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindFeedsGuess(t *testing.T) {
|
||||
body := `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<body>
|
||||
<!-- negative -->
|
||||
<a href="/about">what is rss?</a>
|
||||
<a href="/feed/cows">moo</a>
|
||||
|
||||
<!-- positive -->
|
||||
<a href="/feed.xml">subscribe</a>
|
||||
<a href="/news">rss</a>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
have := FindFeeds(body, base)
|
||||
want := map[string]string{
|
||||
base + "/feed.xml": "",
|
||||
base + "/news": "",
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fatal("invalid result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindIcons(t *testing.T) {
|
||||
body := `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title></title>
|
||||
<link rel="icon favicon" href="/favicon.ico">
|
||||
<link rel="icon macicon" href="path/to/favicon.png">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
have := FindIcons(body, base)
|
||||
want := []string{base + "/favicon.ico", base + "/path/to/favicon.png"}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fatal("invalid result")
|
||||
}
|
||||
}
|
||||
38
src/content/silo/iframe.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package silo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
youtubeFrame = `<iframe src="https://www.youtube.com/embed/%s" width="560" height="315" frameborder="0" allowfullscreen></iframe>`
|
||||
vimeoFrame = `<iframe src="https://player.vimeo.com/video/%s" width="640" height="360" frameborder="0" allowfullscreen></iframe>`
|
||||
vimeoRegex = regexp.MustCompile(`\/(\d+)$`)
|
||||
)
|
||||
|
||||
func VideoIFrame(link string) string {
|
||||
l, err := url.Parse(link)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
youtubeID := ""
|
||||
if l.Host == "www.youtube.com" && l.Path == "/watch" {
|
||||
youtubeID = l.Query().Get("v")
|
||||
} else if l.Host == "youtu.be" {
|
||||
youtubeID = strings.TrimLeft(l.Path, "/")
|
||||
}
|
||||
if youtubeID != "" {
|
||||
return fmt.Sprintf(youtubeFrame, youtubeID)
|
||||
}
|
||||
|
||||
if l.Host == "vimeo.com" {
|
||||
if matches := vimeoRegex.FindStringSubmatch(l.Path); len(matches) > 0 {
|
||||
return fmt.Sprintf(vimeoFrame, matches[1])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
36
src/content/silo/iframe_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package silo
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestYoutubeIframe(t *testing.T) {
|
||||
links := []string{
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"https://youtu.be/dQw4w9WgXcQ",
|
||||
"https://youtu.be/dQw4w9WgXcQ",
|
||||
}
|
||||
for _, link := range links {
|
||||
have := VideoIFrame(link)
|
||||
want := `<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="560" height="315" frameborder="0" allowfullscreen></iframe>`
|
||||
if have != want {
|
||||
t.Logf("want: %s", want)
|
||||
t.Logf("have: %s", have)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVimeoIframe(t *testing.T) {
|
||||
links := []string{
|
||||
"https://vimeo.com/channels/staffpicks/526381128",
|
||||
"https://vimeo.com/526381128",
|
||||
}
|
||||
for _, link := range links {
|
||||
have := VideoIFrame(link)
|
||||
want := `<iframe src="https://player.vimeo.com/video/526381128" width="640" height="360" frameborder="0" allowfullscreen></iframe>`
|
||||
if have != want {
|
||||
t.Logf("want: %s", want)
|
||||
t.Logf("have: %s", have)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/content/silo/url.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package silo
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func RedirectURL(link string) string {
|
||||
if strings.HasPrefix(link, "https://www.google.com/url?") {
|
||||
if u, err := url.Parse(link); err == nil {
|
||||
if u2 := u.Query().Get("url"); u2 != "" {
|
||||
return u2
|
||||
}
|
||||
}
|
||||
}
|
||||
return link
|
||||
}
|
||||
24
src/content/silo/url_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package silo
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRedirectURL(t *testing.T) {
|
||||
link := "https://www.google.com/url?rct=j&sa=t&url=https://www.cryptoglobe.com/latest/2022/08/investment-strategist-lyn-alden-explains-why-she-is-still-bullish-on-bitcoin-long-term/&ct=ga&cd=CAIyGjlkMjI1NjUyODE3ODFjMDQ6Y29tOmVuOlVT&usg=AOvVaw16C2fJtw6m8QVEbto2HCKK"
|
||||
want := "https://www.cryptoglobe.com/latest/2022/08/investment-strategist-lyn-alden-explains-why-she-is-still-bullish-on-bitcoin-long-term/"
|
||||
have := RedirectURL(link)
|
||||
if have != want {
|
||||
t.Logf("want: %s", want)
|
||||
t.Logf("have: %s", have)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
link = "https://example.com"
|
||||
if RedirectURL(link) != link {
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
link = "https://example.com/url?url=test.com"
|
||||
if RedirectURL(link) != link {
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
109
src/main.go
@@ -1,109 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"flag"
|
||||
"fmt"
|
||||
"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"
|
||||
|
||||
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 {
|
||||
platform.Open(srv.GetAddr())
|
||||
}
|
||||
platform.Start(srv)
|
||||
}
|
||||
104
src/parser/atom.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// Atom 1.0 parser
|
||||
package parser
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/nkanaev/yarr/src/content/htmlutil"
|
||||
)
|
||||
|
||||
type atomFeed struct {
|
||||
XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
|
||||
ID string `xml:"id"`
|
||||
Title atomText `xml:"title"`
|
||||
Links atomLinks `xml:"link"`
|
||||
Entries []atomEntry `xml:"entry"`
|
||||
}
|
||||
|
||||
type atomEntry struct {
|
||||
ID string `xml:"id"`
|
||||
Title atomText `xml:"title"`
|
||||
Summary atomText `xml:"summary"`
|
||||
Published string `xml:"published"`
|
||||
Updated string `xml:"updated"`
|
||||
Links atomLinks `xml:"link"`
|
||||
Content atomText `xml:"http://www.w3.org/2005/Atom content"`
|
||||
OrigLink string `xml:"http://rssnamespace.org/feedburner/ext/1.0 origLink"`
|
||||
|
||||
media
|
||||
}
|
||||
|
||||
type atomText struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Data string `xml:",chardata"`
|
||||
XML string `xml:",innerxml"`
|
||||
}
|
||||
|
||||
type atomLink struct {
|
||||
Href string `xml:"href,attr"`
|
||||
Rel string `xml:"rel,attr"`
|
||||
}
|
||||
|
||||
type atomLinks []atomLink
|
||||
|
||||
func (a *atomText) Text() string {
|
||||
if a.Type == "html" {
|
||||
return htmlutil.ExtractText(a.Data)
|
||||
} else if a.Type == "xhtml" {
|
||||
return htmlutil.ExtractText(a.XML)
|
||||
}
|
||||
return a.Data
|
||||
}
|
||||
|
||||
func (a *atomText) String() string {
|
||||
data := a.Data
|
||||
if a.Type == "xhtml" {
|
||||
data = a.XML
|
||||
}
|
||||
return strings.TrimSpace(data)
|
||||
}
|
||||
|
||||
func (links atomLinks) First(rel string) string {
|
||||
for _, l := range links {
|
||||
if l.Rel == rel {
|
||||
return l.Href
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func ParseAtom(r io.Reader) (*Feed, error) {
|
||||
srcfeed := atomFeed{}
|
||||
|
||||
decoder := xmlDecoder(r)
|
||||
if err := decoder.Decode(&srcfeed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dstfeed := &Feed{
|
||||
Title: srcfeed.Title.String(),
|
||||
SiteURL: firstNonEmpty(srcfeed.Links.First("alternate"), srcfeed.Links.First("")),
|
||||
}
|
||||
for _, srcitem := range srcfeed.Entries {
|
||||
linkFromID := ""
|
||||
guidFromID := ""
|
||||
if htmlutil.IsAPossibleLink(srcitem.ID) {
|
||||
linkFromID = srcitem.ID
|
||||
guidFromID = srcitem.ID + "::" + srcitem.Updated
|
||||
}
|
||||
|
||||
link := firstNonEmpty(srcitem.OrigLink, srcitem.Links.First("alternate"), srcitem.Links.First(""), linkFromID)
|
||||
dstfeed.Items = append(dstfeed.Items, Item{
|
||||
GUID: firstNonEmpty(guidFromID, srcitem.ID, link),
|
||||
Date: dateParse(firstNonEmpty(srcitem.Published, srcitem.Updated)),
|
||||
URL: link,
|
||||
Title: srcitem.Title.Text(),
|
||||
Content: firstNonEmpty(srcitem.Content.String(), srcitem.Summary.String(), srcitem.firstMediaDescription()),
|
||||
ImageURL: srcitem.firstMediaThumbnail(),
|
||||
AudioURL: "",
|
||||
})
|
||||
}
|
||||
return dstfeed, nil
|
||||
}
|
||||
232
src/parser/atom_test.go
Normal file
@@ -0,0 +1,232 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAtom(t *testing.T) {
|
||||
have, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>Example Feed</title>
|
||||
<subtitle>A subtitle.</subtitle>
|
||||
<link href="http://example.org/feed/" rel="self" />
|
||||
<link href="http://example.org/" />
|
||||
<id>urn:uuid:60a76c80-d399-11d9-b91C-0003939e0af6</id>
|
||||
<updated>2003-12-13T18:30:02Z</updated>
|
||||
<entry>
|
||||
<title>Atom-Powered Robots Run Amok</title>
|
||||
<link href="http://example.org/2003/12/13/atom03" />
|
||||
<link rel="alternate" type="text/html" href="http://example.org/2003/12/13/atom03.html"/>
|
||||
<link rel="edit" href="http://example.org/2003/12/13/atom03/edit"/>
|
||||
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
|
||||
<updated>2003-12-13T18:30:02Z</updated>
|
||||
<summary>Some text.</summary>
|
||||
<content type="xhtml">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml"><p>This is the entry content.</p></div>
|
||||
</content>
|
||||
<author>
|
||||
<name>John Doe</name>
|
||||
<email>johndoe@example.com</email>
|
||||
</author>
|
||||
</entry>
|
||||
</feed>
|
||||
`))
|
||||
want := &Feed{
|
||||
Title: "Example Feed",
|
||||
SiteURL: "http://example.org/",
|
||||
Items: []Item{
|
||||
{
|
||||
GUID: "urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a",
|
||||
Date: time.Unix(1071340202, 0).UTC(),
|
||||
URL: "http://example.org/2003/12/13/atom03.html",
|
||||
Title: "Atom-Powered Robots Run Amok",
|
||||
Content: `<div xmlns="http://www.w3.org/1999/xhtml"><p>This is the entry content.</p></div>`,
|
||||
ImageURL: "",
|
||||
AudioURL: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fatal("invalid atom")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomClashingNamespaces(t *testing.T) {
|
||||
have, err := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<entry>
|
||||
<content>atom content</content>
|
||||
<media:content xmlns:media="http://search.yahoo.com/mrss/" />
|
||||
</entry>
|
||||
</feed>
|
||||
`))
|
||||
want := &Feed{Items: []Item{{Content: "atom content"}}}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomHTMLTitle(t *testing.T) {
|
||||
feed, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<entry><title type="html">say <code>what</code>?</entry>
|
||||
</feed>
|
||||
`))
|
||||
have := feed.Items[0].Title
|
||||
want := "say what?"
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomXHTMLTitle(t *testing.T) {
|
||||
feed, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<entry><title type="xhtml">say <code>what</code>?</entry>
|
||||
</feed>
|
||||
`))
|
||||
have := feed.Items[0].Title
|
||||
want := "say what?"
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomXHTMLNestedTitle(t *testing.T) {
|
||||
feed, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<entry>
|
||||
<title type="xhtml">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">
|
||||
<a href="https://example.com">Link to Example</a>
|
||||
</div>
|
||||
</title>
|
||||
</entry>
|
||||
</feed>
|
||||
`))
|
||||
have := feed.Items[0].Title
|
||||
want := "Link to Example"
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomImageLink(t *testing.T) {
|
||||
feed, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
|
||||
<entry>
|
||||
<media:thumbnail url="https://example.com/image.png?width=100&height=100" />
|
||||
</entry>
|
||||
</feed>
|
||||
`))
|
||||
have := feed.Items[0].ImageURL
|
||||
want := `https://example.com/image.png?width=100&height=100`
|
||||
if want != have {
|
||||
t.Fatalf("item.image_url doesn't match\nwant: %#v\nhave: %#v\n", want, have)
|
||||
}
|
||||
}
|
||||
|
||||
// found in: https://www.reddit.com/r/funny.rss
|
||||
// items come with thumbnail urls which are also present in the content
|
||||
func TestAtomImageLinkDuplicated(t *testing.T) {
|
||||
feed, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
|
||||
<entry>
|
||||
<content type="html"><img src="https://example.com/image.png?width=100&height=100"></content>
|
||||
<media:thumbnail url="https://example.com/image.png?width=100&height=100" />
|
||||
</entry>
|
||||
</feed>
|
||||
`))
|
||||
have := feed.Items[0].Content
|
||||
want := `<img src="https://example.com/image.png?width=100&height=100">`
|
||||
if want != have {
|
||||
t.Fatalf("want: %#v\nhave: %#v\n", want, have)
|
||||
}
|
||||
if feed.Items[0].ImageURL != "" {
|
||||
t.Fatal("item.image_url must be unset if present in the content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomLinkInID(t *testing.T) {
|
||||
feed, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
|
||||
<entry>
|
||||
<title>one updated</title>
|
||||
<id>https://example.com/posts/1</id>
|
||||
<updated>2003-12-13T09:17:51</updated>
|
||||
</entry>
|
||||
<entry>
|
||||
<title>two</title>
|
||||
<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
|
||||
</entry>
|
||||
<entry>
|
||||
<title>one</title>
|
||||
<id>https://example.com/posts/1</id>
|
||||
</entry>
|
||||
</feed>
|
||||
`))
|
||||
have := feed.Items
|
||||
want := []Item{
|
||||
Item{
|
||||
GUID: "https://example.com/posts/1::2003-12-13T09:17:51",
|
||||
Date: time.Date(2003, time.December, 13, 9, 17, 51, 0, time.UTC),
|
||||
URL: "https://example.com/posts/1",
|
||||
Title: "one updated",
|
||||
},
|
||||
Item{
|
||||
GUID: "urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6",
|
||||
Date: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC), URL: "",
|
||||
Title: "two",
|
||||
},
|
||||
Item{
|
||||
GUID: "https://example.com/posts/1::",
|
||||
Date: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
|
||||
URL: "https://example.com/posts/1",
|
||||
Title: "one",
|
||||
Content: "",
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Fatalf("\nwant: %#v\nhave: %#v\n", want, have)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomDoesntEscapeHTMLTags(t *testing.T) {
|
||||
feed, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<entry><summary type="html">&lt;script&gt;alert(1);&lt;/script&gt;</summary></entry>
|
||||
</feed>
|
||||
`))
|
||||
have := feed.Items[0].Content
|
||||
want := "<script>alert(1);</script>"
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
218
src/parser/date.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package parser
|
||||
|
||||
import "time"
|
||||
|
||||
// taken from github.com/mjibson/goread
|
||||
var dateFormats = []string{
|
||||
time.RFC822, // RSS
|
||||
time.RFC822Z, // RSS
|
||||
time.RFC3339, // Atom
|
||||
time.UnixDate,
|
||||
time.RubyDate,
|
||||
time.RFC850,
|
||||
time.RFC1123Z,
|
||||
time.RFC1123,
|
||||
time.ANSIC,
|
||||
"Mon, 02 Jan 2006 15:04:05 MST -07:00",
|
||||
"Mon, January 2, 2006, 3:04 PM MST",
|
||||
"Mon, January 2 2006 15:04:05 -0700",
|
||||
"Mon, January 02, 2006, 15:04:05 MST",
|
||||
"Mon, January 02, 2006 15:04:05 MST",
|
||||
"Mon, Jan 2, 2006 15:04 MST",
|
||||
"Mon, Jan 2 2006 15:04 MST",
|
||||
"Mon, Jan 2 2006 15:04:05 MST",
|
||||
"Mon, Jan 2, 2006 15:04:05 MST",
|
||||
"Mon, Jan 2 2006 15:04:05 -700",
|
||||
"Mon, Jan 2 2006 15:04:05 -0700",
|
||||
"Mon Jan 2 15:04 2006",
|
||||
"Mon Jan 2 15:04:05 2006 MST",
|
||||
"Mon Jan 02, 2006 3:04 pm",
|
||||
"Mon, Jan 02,2006 15:04:05 MST",
|
||||
"Mon Jan 02 2006 15:04:05 -0700",
|
||||
"Mon, 02/01/2006",
|
||||
"Monday, 2. January 2006 - 15:04",
|
||||
"Monday 02 January 2006",
|
||||
"Monday, January 2, 2006 15:04:05 MST",
|
||||
"Monday, January 2, 2006 03:04 PM",
|
||||
"Monday, January 2, 2006",
|
||||
"Monday, January 02, 2006",
|
||||
"Monday, 2 January 2006 15:04:05 MST",
|
||||
"Monday, 2 January 2006 15:04:05 -0700",
|
||||
"Monday, 2 Jan 2006 15:04:05 MST",
|
||||
"Monday, 2 Jan 2006 15:04:05 -0700",
|
||||
"Monday, 02 January 2006 15:04:05 MST",
|
||||
"Monday, 02 January 2006 15:04:05 -0700",
|
||||
"Monday, 02 January 2006 15:04:05",
|
||||
"Monday, January 02, 2006 - 3:04pm",
|
||||
"Monday, January 2, 2006 - 3:04pm",
|
||||
"Mon, 01/02/2006 - 15:04",
|
||||
"Mon, 2 January 2006 15:04 MST",
|
||||
"Mon, 2 January 2006, 15:04 -0700",
|
||||
"Mon, 2 January 2006, 15:04:05 MST",
|
||||
"Mon, 2 January 2006 15:04:05 MST",
|
||||
"Mon, 2 January 2006 15:04:05 -0700",
|
||||
"Mon, 2 January 2006",
|
||||
"nilMon, 2 Jan 2006 3:04:05 PM -0700",
|
||||
"Mon, 2 Jan 2006 15:4:5 MST",
|
||||
"Mon, 2 Jan 2006 15:4:5 -0700 GMT",
|
||||
"Mon, 2, Jan 2006 15:4",
|
||||
"Mon, 2 Jan 2006 15:04 MST",
|
||||
"Mon, 2 Jan 2006, 15:04 -0700",
|
||||
"Mon, 2 Jan 2006 15:04 -0700",
|
||||
"Mon, 2 Jan 2006 15:04:05 UT",
|
||||
"Mon, 2 Jan 2006 15:04:05MST",
|
||||
"Mon, 2 Jan 2006 15:04:05 MST",
|
||||
"Mon 2 Jan 2006 15:04:05 MST",
|
||||
"mon,2 Jan 2006 15:04:05 MST",
|
||||
"Mon, 2 Jan 2006 15:04:05 -0700 MST",
|
||||
"Mon, 2 Jan 2006 15:04:05-0700",
|
||||
"Mon, 2 Jan 2006 15:04:05 -0700",
|
||||
"Mon, 2 Jan 2006 15:04:05",
|
||||
"Mon, 2 Jan 2006 15:04",
|
||||
"Mon, 02 Jan 2006, 15:04",
|
||||
"Mon, 2 Jan 2006, 15:04",
|
||||
"Mon,2 Jan 2006",
|
||||
"Mon, 2 Jan 2006",
|
||||
"Mon, 2 Jan 15:04:05 MST",
|
||||
"Mon, 2 Jan 06 15:04:05 MST",
|
||||
"Mon, 2 Jan 06 15:04:05 -0700",
|
||||
"Mon, 2006-01-02 15:04",
|
||||
"Mon,02 January 2006 14:04:05 MST",
|
||||
"Mon, 02 January 2006",
|
||||
"Mon, 02 Jan 2006 3:04:05 PM MST",
|
||||
"Mon, 02 Jan 2006 15 -0700",
|
||||
"Mon,02 Jan 2006 15:04 MST",
|
||||
"Mon, 02 Jan 2006 15:04 MST",
|
||||
"Mon, 02 Jan 2006 15:04 -0700",
|
||||
"Mon, 02 Jan 2006 15:04:05 Z",
|
||||
"Mon, 02 Jan 2006 15:04:05 UT",
|
||||
"Mon, 02 Jan 2006 15:04:05 MST-07:00",
|
||||
"Mon, 02 Jan 2006 15:04:05 MST -0700",
|
||||
"Mon, 02 Jan 2006, 15:04:05 MST",
|
||||
"Mon, 02 Jan 2006 15:04:05MST",
|
||||
"Mon, 02 Jan 2006 15:04:05 MST",
|
||||
"Mon , 02 Jan 2006 15:04:05 MST",
|
||||
"Mon, 02 Jan 2006 15:04:05 GMT-0700",
|
||||
"Mon,02 Jan 2006 15:04:05 -0700",
|
||||
"Mon, 02 Jan 2006 15:04:05 -0700",
|
||||
"Mon, 02 Jan 2006 15:04:05 -07:00",
|
||||
"Mon, 02 Jan 2006 15:04:05 --0700",
|
||||
"Mon 02 Jan 2006 15:04:05 -0700",
|
||||
"Mon 02 Jan 2006, 15:04:05 MST",
|
||||
"Mon, 02 Jan 2006 15:04:05 MST",
|
||||
"Mon, 02 Jan 2006 15:04:05 -07",
|
||||
"Mon, 02 Jan 2006 15:04:05 00",
|
||||
"Mon, 02 Jan 2006 15:04:05",
|
||||
"Mon, 02 Jan 2006",
|
||||
"Mon, 02 Jan 06 15:04:05 MST",
|
||||
"Mon, 02 Jan 2006 3:04 PM MST",
|
||||
"Mon Jan 02 2006 15:04:05 MST",
|
||||
"Mon, 01 02 2006 15:04:05 -0700",
|
||||
"Mon, 2th Jan 2006 15:05:05 MST",
|
||||
"Jan. 2, 2006, 3:04 a.m.",
|
||||
"fri, 02 jan 2006 15:04:05 -0700",
|
||||
"January 02 2006 03:04:05 PM",
|
||||
"January 2, 2006 3:04 PM",
|
||||
"January 2, 2006, 3:04 p.m.",
|
||||
"January 2, 2006 15:04:05 MST",
|
||||
"January 2, 2006 15:04:05",
|
||||
"January 2, 2006 03:04 PM",
|
||||
"January 2, 2006",
|
||||
"January 02, 2006 15:04:05 MST",
|
||||
"January 02, 2006 15:04",
|
||||
"January 02, 2006 03:04 PM",
|
||||
"January 02, 2006",
|
||||
"Jan 2, 2006 3:04:05 PM MST",
|
||||
"Jan 2, 2006 3:04:05 PM",
|
||||
"Jan 2, 2006 15:04:05 MST",
|
||||
"Jan 2, 2006",
|
||||
"Jan 02 2006 03:04:05PM",
|
||||
"Jan 02, 2006",
|
||||
"6/1/2 15:04",
|
||||
"6-1-2 15:04",
|
||||
"2 January 2006 15:04:05 MST",
|
||||
"2 January 2006 15:04:05 -0700",
|
||||
"2 January 2006",
|
||||
"2 Jan 2006 15:04:05 Z",
|
||||
"2 Jan 2006 15:04:05 MST",
|
||||
"2 Jan 2006 15:04:05 -0700",
|
||||
"2 Jan 2006",
|
||||
"2 Jan 2006 15:04 MST",
|
||||
"2.1.2006 15:04:05",
|
||||
"2/1/2006",
|
||||
"2-1-2006",
|
||||
"2006 January 02",
|
||||
"2006-1-2T15:04:05Z",
|
||||
"2006-1-2 15:04:05",
|
||||
"2006-1-2",
|
||||
"2006-01-02T15:04:05-07:00Z",
|
||||
"2006-1-02T15:04:05Z",
|
||||
"2006-01-02T15:04Z",
|
||||
"2006-01-02T15:04-07:00",
|
||||
"2006-01-02T15:04:05Z",
|
||||
"2006-01-02T15:04:05-07:00:00",
|
||||
"2006-01-02T15:04:05:-0700",
|
||||
"2006-01-02T15:04:05-0700",
|
||||
"2006-01-02T15:04:05-07:00",
|
||||
"2006-01-02T15:04:05 -0700",
|
||||
"2006-01-02T15:04:05:00",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02T15:04",
|
||||
"2006-01-02 at 15:04:05",
|
||||
"2006-01-02 15:04:05Z",
|
||||
"2006-01-02 15:04:05 MST",
|
||||
"2006-01-02 15:04:05-0700",
|
||||
"2006-01-02 15:04:05-07:00",
|
||||
"2006-01-02 15:04:05 -0700",
|
||||
"2006-01-02 15:04",
|
||||
"2006-01-02 00:00:00.0 15:04:05.0 -0700",
|
||||
"2006/01/02",
|
||||
"2006-01-02",
|
||||
"15:04 02.01.2006 -0700",
|
||||
"1/2/2006 3:04 PM MST",
|
||||
"1/2/2006 3:04:05 PM MST",
|
||||
"1/2/2006 3:04:05 PM",
|
||||
"1/2/2006 15:04:05 MST",
|
||||
"1/2/2006",
|
||||
"06/1/2 15:04",
|
||||
"06-1-2 15:04",
|
||||
"02 Monday, Jan 2006 15:04",
|
||||
"02 Jan 2006 15:04 MST",
|
||||
"02 Jan 2006 15:04:05 UT",
|
||||
"02 Jan 2006 15:04:05 MST",
|
||||
"02 Jan 2006 15:04:05 -0700",
|
||||
"02 Jan 2006 15:04:05",
|
||||
"02 Jan 2006",
|
||||
"02/01/2006 15:04 MST",
|
||||
"02-01-2006 15:04:05 MST",
|
||||
"02.01.2006 15:04:05",
|
||||
"02/01/2006 15:04:05",
|
||||
"02.01.2006 15:04",
|
||||
"02/01/2006 - 15:04",
|
||||
"02.01.2006 -0700",
|
||||
"02/01/2006",
|
||||
"02-01-2006",
|
||||
"01/02/2006 3:04 PM",
|
||||
"01/02/2006 15:04:05 MST",
|
||||
"01/02/2006 - 15:04",
|
||||
"01/02/2006",
|
||||
"01-02-2006",
|
||||
"Jan. 2006",
|
||||
"Jan. 2, 2006, 03:04 p.m.",
|
||||
"2006-01-02 15:04:05 -07:00",
|
||||
"2 January, 2006",
|
||||
}
|
||||
|
||||
var defaultTime = time.Time{}
|
||||
|
||||
func dateParse(line string) time.Time {
|
||||
if line == "" {
|
||||
return defaultTime
|
||||
}
|
||||
for _, layout := range dateFormats {
|
||||
if t, err := time.Parse(layout, line); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return defaultTime
|
||||
}
|
||||
181
src/parser/feed.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nkanaev/yarr/src/content/htmlutil"
|
||||
"golang.org/x/net/html/charset"
|
||||
)
|
||||
|
||||
var UnknownFormat = errors.New("unknown feed format")
|
||||
|
||||
type feedProbe struct {
|
||||
feedType string
|
||||
callback func(r io.Reader) (*Feed, error)
|
||||
encoding string
|
||||
}
|
||||
|
||||
func sniff(lookup string) (out feedProbe) {
|
||||
lookup = strings.TrimSpace(lookup)
|
||||
lookup = strings.TrimLeft(lookup, "\x00\xEF\xBB\xBF\xFE\xFF")
|
||||
|
||||
if len(lookup) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
switch lookup[0] {
|
||||
case '<':
|
||||
decoder := xmlDecoder(strings.NewReader(lookup))
|
||||
for {
|
||||
token, _ := decoder.Token()
|
||||
if token == nil {
|
||||
break
|
||||
}
|
||||
|
||||
// check <?xml encoding="ENCODING" ?>
|
||||
if el, ok := token.(xml.ProcInst); ok && el.Target == "xml" {
|
||||
out.encoding = strings.ToLower(procInst("encoding", string(el.Inst)))
|
||||
}
|
||||
|
||||
if el, ok := token.(xml.StartElement); ok {
|
||||
switch el.Name.Local {
|
||||
case "rss":
|
||||
out.feedType = "rss"
|
||||
out.callback = ParseRSS
|
||||
return
|
||||
case "RDF":
|
||||
out.feedType = "rdf"
|
||||
out.callback = ParseRDF
|
||||
return
|
||||
case "feed":
|
||||
out.feedType = "atom"
|
||||
out.callback = ParseAtom
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
case '{':
|
||||
out.feedType = "json"
|
||||
out.callback = ParseJSON
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func Parse(r io.Reader) (*Feed, error) {
|
||||
return ParseWithEncoding(r, "")
|
||||
}
|
||||
|
||||
func ParseWithEncoding(r io.Reader, fallbackEncoding string) (*Feed, error) {
|
||||
lookup := make([]byte, 2048)
|
||||
n, err := io.ReadFull(r, lookup)
|
||||
switch {
|
||||
case err == io.ErrUnexpectedEOF:
|
||||
lookup = lookup[:n]
|
||||
r = bytes.NewReader(lookup)
|
||||
case err != nil:
|
||||
return nil, err
|
||||
default:
|
||||
r = io.MultiReader(bytes.NewReader(lookup), r)
|
||||
}
|
||||
|
||||
out := sniff(string(lookup))
|
||||
if out.feedType == "" {
|
||||
return nil, UnknownFormat
|
||||
}
|
||||
|
||||
if out.encoding == "" && fallbackEncoding != "" {
|
||||
r, err = charset.NewReaderLabel(fallbackEncoding, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if (out.feedType != "json") && (out.encoding == "" || out.encoding == "utf-8") {
|
||||
// XML decoder will not rely on custom CharsetReader (see `xmlDecoder`)
|
||||
// to handle invalid xml characters.
|
||||
// Assume input is already UTF-8 and do the cleanup here.
|
||||
r = NewSafeXMLReader(r)
|
||||
}
|
||||
|
||||
feed, err := out.callback(r)
|
||||
if feed != nil {
|
||||
feed.cleanup()
|
||||
}
|
||||
return feed, err
|
||||
}
|
||||
|
||||
func ParseAndFix(r io.Reader, baseURL, fallbackEncoding string) (*Feed, error) {
|
||||
feed, err := ParseWithEncoding(r, fallbackEncoding)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
feed.TranslateURLs(baseURL)
|
||||
feed.SetMissingDatesTo(time.Now())
|
||||
feed.SetMissingGUIDs()
|
||||
return feed, nil
|
||||
}
|
||||
|
||||
func (feed *Feed) cleanup() {
|
||||
feed.Title = strings.TrimSpace(feed.Title)
|
||||
feed.SiteURL = strings.TrimSpace(feed.SiteURL)
|
||||
|
||||
for i, item := range feed.Items {
|
||||
feed.Items[i].GUID = strings.TrimSpace(item.GUID)
|
||||
feed.Items[i].URL = strings.TrimSpace(item.URL)
|
||||
feed.Items[i].Title = strings.TrimSpace(htmlutil.ExtractText(item.Title))
|
||||
feed.Items[i].Content = strings.TrimSpace(item.Content)
|
||||
|
||||
if item.ImageURL != "" && strings.Contains(item.Content, item.ImageURL) {
|
||||
feed.Items[i].ImageURL = ""
|
||||
}
|
||||
if item.AudioURL != "" && strings.Contains(item.Content, item.AudioURL) {
|
||||
feed.Items[i].AudioURL = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (feed *Feed) SetMissingDatesTo(newdate time.Time) {
|
||||
for i, item := range feed.Items {
|
||||
if item.Date.IsZero() {
|
||||
feed.Items[i].Date = newdate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (feed *Feed) TranslateURLs(base string) error {
|
||||
baseUrl, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse base url: %#v", base)
|
||||
}
|
||||
siteUrl, err := url.Parse(feed.SiteURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse feed url: %#v", feed.SiteURL)
|
||||
}
|
||||
feed.SiteURL = baseUrl.ResolveReference(siteUrl).String()
|
||||
for _, item := range feed.Items {
|
||||
itemUrl, err := url.Parse(item.URL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse item url: %#v", item.URL)
|
||||
}
|
||||
item.URL = siteUrl.ResolveReference(itemUrl).String()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (feed *Feed) SetMissingGUIDs() {
|
||||
for i, item := range feed.Items {
|
||||
if item.GUID == "" {
|
||||
id := strings.Join([]string{item.Title, item.Date.Format(time.RFC3339), item.URL}, ";;")
|
||||
feed.Items[i].GUID = fmt.Sprintf("%x", sha256.Sum256([]byte(id)))
|
||||
}
|
||||
}
|
||||
}
|
||||
181
src/parser/feed_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSniff(t *testing.T) {
|
||||
testcases := []struct {
|
||||
input string
|
||||
want feedProbe
|
||||
}{
|
||||
{
|
||||
`<?xml version="1.0"?><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"></rdf:RDF>`,
|
||||
feedProbe{feedType: "rdf", callback: ParseRDF},
|
||||
},
|
||||
{
|
||||
`<?xml version="1.0" encoding="ISO-8859-1"?><rss version="2.0"><channel></channel></rss>`,
|
||||
feedProbe{feedType: "rss", callback: ParseRSS, encoding: "iso-8859-1"},
|
||||
},
|
||||
{
|
||||
`<?xml version="1.0"?><rss version="2.0"><channel></channel></rss>`,
|
||||
feedProbe{feedType: "rss", callback: ParseRSS},
|
||||
},
|
||||
{
|
||||
`<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>`,
|
||||
feedProbe{feedType: "atom", callback: ParseAtom, encoding: "utf-8"},
|
||||
},
|
||||
{
|
||||
`{}`,
|
||||
feedProbe{feedType: "json", callback: ParseJSON},
|
||||
},
|
||||
{
|
||||
`<!DOCTYPE html><html><head><title></title></head><body></body></html>`,
|
||||
feedProbe{},
|
||||
},
|
||||
}
|
||||
for _, testcase := range testcases {
|
||||
want := testcase.want
|
||||
have := sniff(testcase.input)
|
||||
if want.encoding != have.encoding || want.feedType != have.feedType {
|
||||
t.Errorf("Invalid output\n---\n%s\n---\n\nwant=%#v\nhave=%#v", testcase.input, want, have)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
have, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>
|
||||
Title
|
||||
</title>
|
||||
<item>
|
||||
<title>
|
||||
Item 1
|
||||
</title>
|
||||
<description>
|
||||
<![CDATA[<div>content</div>]]>
|
||||
</description>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`))
|
||||
want := &Feed{
|
||||
Title: "Title",
|
||||
Items: []Item{
|
||||
{
|
||||
Title: "Item 1",
|
||||
Content: "<div>content</div>",
|
||||
},
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fatal("invalid content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseShortFeed(t *testing.T) {
|
||||
have, err := Parse(strings.NewReader(
|
||||
`<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>`,
|
||||
))
|
||||
want := &Feed{}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFeedWithBOM(t *testing.T) {
|
||||
have, err := Parse(strings.NewReader(
|
||||
"\xEF\xBB\xBF" + `<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>`,
|
||||
))
|
||||
want := &Feed{}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCleanIllegalCharsInUTF8(t *testing.T) {
|
||||
data := `
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
||||
<channel>
|
||||
<item>
|
||||
<title>` + "\a" + `title</title>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`
|
||||
feed, err := Parse(strings.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(feed.Items) != 1 || feed.Items[0].Title != "title" {
|
||||
t.Fatalf("invalid feed, got: %v", feed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCleanIllegalCharsInNonUTF8(t *testing.T) {
|
||||
// echo привет | iconv -f utf8 -t cp1251 | hexdump -C
|
||||
data := `
|
||||
<?xml version="1.0" encoding="windows-1251"?>
|
||||
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
||||
<channel>
|
||||
<item>
|
||||
<title>` + "\a \xef\xf0\xe8\xe2\xe5\xf2\x0a \a" + `</title>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`
|
||||
feed, err := Parse(strings.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(feed.Items) != 1 || feed.Items[0].Title != "привет" {
|
||||
t.Fatalf("invalid feed, got: %v", feed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMissingGUID(t *testing.T) {
|
||||
data := `
|
||||
<?xml version="1.0" encoding="windows-1251"?>
|
||||
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
||||
<channel>
|
||||
<item>
|
||||
<title>foo</title>
|
||||
</item>
|
||||
<item>
|
||||
<title>bar</title>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`
|
||||
feed, err := ParseAndFix(strings.NewReader(data), "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(feed.Items) != 2 {
|
||||
t.Fatalf("expected 2 items, got %d", len(feed.Items))
|
||||
}
|
||||
if feed.Items[0].GUID == "" || feed.Items[1].GUID == "" {
|
||||
t.Fatalf("item GUIDs are missing, got %#v", feed.Items)
|
||||
}
|
||||
if feed.Items[0].GUID == feed.Items[1].GUID {
|
||||
t.Fatalf("item GUIDs are not unique, got %#v", feed.Items)
|
||||
}
|
||||
}
|
||||
57
src/parser/json.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// JSON 1.0 parser
|
||||
package parser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
)
|
||||
|
||||
type jsonFeed struct {
|
||||
Version string `json:"version"`
|
||||
Title string `json:"title"`
|
||||
SiteURL string `json:"home_page_url"`
|
||||
Items []jsonItem `json:"items"`
|
||||
}
|
||||
|
||||
type jsonItem struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Text string `json:"content_text"`
|
||||
HTML string `json:"content_html"`
|
||||
DatePublished string `json:"date_published"`
|
||||
DateModified string `json:"date_modified"`
|
||||
Attachments []jsonAttachment `json:"attachments"`
|
||||
}
|
||||
|
||||
type jsonAttachment struct {
|
||||
URL string `json:"url"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Title string `json:"title"`
|
||||
Size int64 `json:"size_in_bytes"`
|
||||
Duration int `json:"duration_in_seconds"`
|
||||
}
|
||||
|
||||
func ParseJSON(data io.Reader) (*Feed, error) {
|
||||
srcfeed := new(jsonFeed)
|
||||
decoder := json.NewDecoder(data)
|
||||
if err := decoder.Decode(&srcfeed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dstfeed := &Feed{
|
||||
Title: srcfeed.Title,
|
||||
SiteURL: srcfeed.SiteURL,
|
||||
}
|
||||
for _, srcitem := range srcfeed.Items {
|
||||
dstfeed.Items = append(dstfeed.Items, Item{
|
||||
GUID: firstNonEmpty(srcitem.ID, srcitem.URL),
|
||||
Date: dateParse(firstNonEmpty(srcitem.DatePublished, srcitem.DateModified)),
|
||||
URL: srcitem.URL,
|
||||
Title: srcitem.Title,
|
||||
Content: firstNonEmpty(srcitem.HTML, srcitem.Text, srcitem.Summary),
|
||||
})
|
||||
}
|
||||
return dstfeed, nil
|
||||
}
|
||||
42
src/parser/json_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestJSONFeed(t *testing.T) {
|
||||
have, _ := Parse(strings.NewReader(`{
|
||||
"version": "https://jsonfeed.org/version/1",
|
||||
"title": "My Example Feed",
|
||||
"home_page_url": "https://example.org/",
|
||||
"feed_url": "https://example.org/feed.json",
|
||||
"items": [
|
||||
{
|
||||
"id": "2",
|
||||
"content_text": "This is a second item.",
|
||||
"url": "https://example.org/second-item"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"content_html": "<p>Hello, world!</p>",
|
||||
"url": "https://example.org/initial-post"
|
||||
}
|
||||
]
|
||||
}`))
|
||||
want := &Feed{
|
||||
Title: "My Example Feed",
|
||||
SiteURL: "https://example.org/",
|
||||
Items: []Item{
|
||||
{GUID: "2", Content: "This is a second item.", URL: "https://example.org/second-item"},
|
||||
{GUID: "1", Content: "<p>Hello, world!</p>", URL: "https://example.org/initial-post"},
|
||||
},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fatal("invalid json")
|
||||
}
|
||||
}
|
||||
55
src/parser/media.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package parser
|
||||
|
||||
type media struct {
|
||||
MediaGroups []mediaGroup `xml:"http://search.yahoo.com/mrss/ group"`
|
||||
MediaContents []mediaContent `xml:"http://search.yahoo.com/mrss/ content"`
|
||||
MediaThumbnails []mediaThumbnail `xml:"http://search.yahoo.com/mrss/ thumbnail"`
|
||||
MediaDescriptions []mediaDescription `xml:"http://search.yahoo.com/mrss/ description"`
|
||||
}
|
||||
|
||||
type mediaGroup struct {
|
||||
MediaThumbnails []mediaThumbnail `xml:"http://search.yahoo.com/mrss/ thumbnail"`
|
||||
MediaDescriptions []mediaDescription `xml:"http://search.yahoo.com/mrss/ description"`
|
||||
}
|
||||
|
||||
type mediaContent struct {
|
||||
MediaThumbnails []mediaThumbnail `xml:"http://search.yahoo.com/mrss/ thumbnail"`
|
||||
}
|
||||
|
||||
type mediaThumbnail struct {
|
||||
URL string `xml:"url,attr"`
|
||||
}
|
||||
|
||||
type mediaDescription struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Description string `xml:",chardata"`
|
||||
}
|
||||
|
||||
func (m *media) firstMediaThumbnail() string {
|
||||
for _, c := range m.MediaContents {
|
||||
for _, t := range c.MediaThumbnails {
|
||||
return t.URL
|
||||
}
|
||||
}
|
||||
for _, t := range m.MediaThumbnails {
|
||||
return t.URL
|
||||
}
|
||||
for _, g := range m.MediaGroups {
|
||||
for _, t := range g.MediaThumbnails {
|
||||
return t.URL
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *media) firstMediaDescription() string {
|
||||
for _, d := range m.MediaDescriptions {
|
||||
return plain2html(d.Description)
|
||||
}
|
||||
for _, g := range m.MediaGroups {
|
||||
for _, d := range g.MediaDescriptions {
|
||||
return plain2html(d.Description)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
20
src/parser/models.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package parser
|
||||
|
||||
import "time"
|
||||
|
||||
type Feed struct {
|
||||
Title string
|
||||
SiteURL string
|
||||
Items []Item
|
||||
}
|
||||
|
||||
type Item struct {
|
||||
GUID string
|
||||
Date time.Time
|
||||
URL string
|
||||
Title string
|
||||
|
||||
Content string
|
||||
ImageURL string
|
||||
AudioURL string
|
||||
}
|
||||
49
src/parser/rdf.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Parser for RSS versions:
|
||||
// - 0.90
|
||||
// - 1.0
|
||||
package parser
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"io"
|
||||
)
|
||||
|
||||
type rdfFeed struct {
|
||||
XMLName xml.Name `xml:"RDF"`
|
||||
Title string `xml:"channel>title"`
|
||||
Link string `xml:"channel>link"`
|
||||
Items []rdfItem `xml:"item"`
|
||||
}
|
||||
|
||||
type rdfItem struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
Description string `xml:"description"`
|
||||
|
||||
DublinCoreDate string `xml:"http://purl.org/dc/elements/1.1/ date"`
|
||||
ContentEncoded string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
|
||||
}
|
||||
|
||||
func ParseRDF(r io.Reader) (*Feed, error) {
|
||||
srcfeed := rdfFeed{}
|
||||
|
||||
decoder := xmlDecoder(r)
|
||||
if err := decoder.Decode(&srcfeed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dstfeed := &Feed{
|
||||
Title: srcfeed.Title,
|
||||
SiteURL: srcfeed.Link,
|
||||
}
|
||||
for _, srcitem := range srcfeed.Items {
|
||||
dstfeed.Items = append(dstfeed.Items, Item{
|
||||
GUID: srcitem.Link,
|
||||
URL: srcitem.Link,
|
||||
Date: dateParse(srcitem.DublinCoreDate),
|
||||
Title: srcitem.Title,
|
||||
Content: firstNonEmpty(srcitem.ContentEncoded, srcitem.Description),
|
||||
})
|
||||
}
|
||||
return dstfeed, nil
|
||||
}
|
||||
81
src/parser/rdf_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRDFFeed(t *testing.T) {
|
||||
have, _ := Parse(strings.NewReader(`<?xml version="1.0"?>
|
||||
<rdf:RDF
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns="http://channel.netscape.com/rdf/simple/0.9/">
|
||||
|
||||
<channel>
|
||||
<title>Mozilla Dot Org</title>
|
||||
<link>http://www.mozilla.org</link>
|
||||
<description>the Mozilla Organization
|
||||
web site</description>
|
||||
</channel>
|
||||
|
||||
<image>
|
||||
<title>Mozilla</title>
|
||||
<url>http://www.mozilla.org/images/moz.gif</url>
|
||||
<link>http://www.mozilla.org</link>
|
||||
</image>
|
||||
|
||||
<item>
|
||||
<title>New Status Updates</title>
|
||||
<link>http://www.mozilla.org/status/</link>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<title>Bugzilla Reorganized</title>
|
||||
<link>http://www.mozilla.org/bugs/</link>
|
||||
</item>
|
||||
|
||||
</rdf:RDF>
|
||||
`))
|
||||
want := &Feed{
|
||||
Title: "Mozilla Dot Org",
|
||||
SiteURL: "http://www.mozilla.org",
|
||||
Items: []Item{
|
||||
{GUID: "http://www.mozilla.org/status/", URL: "http://www.mozilla.org/status/", Title: "New Status Updates"},
|
||||
{GUID: "http://www.mozilla.org/bugs/", URL: "http://www.mozilla.org/bugs/", Title: "Bugzilla Reorganized"},
|
||||
},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fatal("invalid rdf")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRDFExtensions(t *testing.T) {
|
||||
have, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<rdf:RDF xmlns="http://purl.org/rss/1.0/"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
||||
<item>
|
||||
<dc:date>2006-01-02T15:04:05-07:00</dc:date>
|
||||
<content:encoded><![CDATA[test]]></content:encoded>
|
||||
</item>
|
||||
</rdf:RDF>
|
||||
`))
|
||||
date, _ := time.Parse(time.RFC1123Z, time.RFC1123Z)
|
||||
want := &Feed{
|
||||
Items: []Item{
|
||||
{Content: "test", Date: date},
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
105
src/parser/rss.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// Parser for RSS versions:
|
||||
// - 0.91 netscape
|
||||
// - 0.91 userland
|
||||
// - 2.0
|
||||
package parser
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type rssFeed struct {
|
||||
XMLName xml.Name `xml:"rss"`
|
||||
Version string `xml:"version,attr"`
|
||||
Title string `xml:"channel>title"`
|
||||
Link string `xml:"channel>link"`
|
||||
Items []rssItem `xml:"channel>item"`
|
||||
}
|
||||
|
||||
type rssItem struct {
|
||||
GUID rssGuid `xml:"guid"`
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"rss link"`
|
||||
Description string `xml:"rss description"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
Enclosures []rssEnclosure `xml:"enclosure"`
|
||||
|
||||
DublinCoreDate string `xml:"http://purl.org/dc/elements/1.1/ date"`
|
||||
ContentEncoded string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
|
||||
|
||||
OrigLink string `xml:"http://rssnamespace.org/feedburner/ext/1.0 origLink"`
|
||||
OrigEnclosureLink string `xml:"http://rssnamespace.org/feedburner/ext/1.0 origEnclosureLink"`
|
||||
|
||||
media
|
||||
}
|
||||
|
||||
type rssGuid struct {
|
||||
GUID string `xml:",chardata"`
|
||||
IsPermaLink string `xml:"isPermaLink,attr"`
|
||||
}
|
||||
|
||||
type rssLink struct {
|
||||
XMLName xml.Name
|
||||
Data string `xml:",chardata"`
|
||||
Href string `xml:"href,attr"`
|
||||
Rel string `xml:"rel,attr"`
|
||||
}
|
||||
|
||||
type rssTitle struct {
|
||||
XMLName xml.Name
|
||||
Data string `xml:",chardata"`
|
||||
Inner string `xml:",innerxml"`
|
||||
}
|
||||
|
||||
type rssEnclosure struct {
|
||||
URL string `xml:"url,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
Length string `xml:"length,attr"`
|
||||
}
|
||||
|
||||
func ParseRSS(r io.Reader) (*Feed, error) {
|
||||
srcfeed := rssFeed{}
|
||||
|
||||
decoder := xmlDecoder(r)
|
||||
decoder.DefaultSpace = "rss"
|
||||
if err := decoder.Decode(&srcfeed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dstfeed := &Feed{
|
||||
Title: srcfeed.Title,
|
||||
SiteURL: srcfeed.Link,
|
||||
}
|
||||
for _, srcitem := range srcfeed.Items {
|
||||
podcastURL := ""
|
||||
for _, e := range srcitem.Enclosures {
|
||||
if strings.HasPrefix(e.Type, "audio/") {
|
||||
podcastURL = e.URL
|
||||
|
||||
if srcitem.OrigEnclosureLink != "" && strings.Contains(podcastURL, path.Base(srcitem.OrigEnclosureLink)) {
|
||||
podcastURL = srcitem.OrigEnclosureLink
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
permalink := ""
|
||||
if srcitem.GUID.IsPermaLink == "true" {
|
||||
permalink = srcitem.GUID.GUID
|
||||
}
|
||||
|
||||
dstfeed.Items = append(dstfeed.Items, Item{
|
||||
GUID: firstNonEmpty(srcitem.GUID.GUID, srcitem.Link),
|
||||
Date: dateParse(firstNonEmpty(srcitem.DublinCoreDate, srcitem.PubDate)),
|
||||
URL: firstNonEmpty(srcitem.OrigLink, srcitem.Link, permalink),
|
||||
Title: srcitem.Title,
|
||||
Content: firstNonEmpty(srcitem.ContentEncoded, srcitem.Description),
|
||||
AudioURL: podcastURL,
|
||||
ImageURL: srcitem.firstMediaThumbnail(),
|
||||
})
|
||||
}
|
||||
return dstfeed, nil
|
||||
}
|
||||
230
src/parser/rss_test.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRSSFeed(t *testing.T) {
|
||||
have, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0"?>
|
||||
<!DOCTYPE rss SYSTEM "http://my.netscape.com/publish/formats/rss-0.91.dtd">
|
||||
<rss version="0.91">
|
||||
<channel>
|
||||
<language>en</language>
|
||||
<description>???</description>
|
||||
<link>http://www.scripting.com/</link>
|
||||
<title>Scripting News</title>
|
||||
<item>
|
||||
<title>Title 1</title>
|
||||
<link>http://www.scripting.com/one/</link>
|
||||
<description>Description 1</description>
|
||||
</item>
|
||||
<item>
|
||||
<title>Title 2</title>
|
||||
<link>http://www.scripting.com/two/</link>
|
||||
<description>Description 2</description>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`))
|
||||
want := &Feed{
|
||||
Title: "Scripting News",
|
||||
SiteURL: "http://www.scripting.com/",
|
||||
Items: []Item{
|
||||
{
|
||||
GUID: "http://www.scripting.com/one/",
|
||||
URL: "http://www.scripting.com/one/",
|
||||
Title: "Title 1",
|
||||
Content: "Description 1",
|
||||
},
|
||||
{
|
||||
GUID: "http://www.scripting.com/two/",
|
||||
URL: "http://www.scripting.com/two/",
|
||||
Title: "Title 2",
|
||||
Content: "Description 2",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fatal("invalid rss")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRSSMediaContentThumbnail(t *testing.T) {
|
||||
// see: https://vimeo.com/channels/staffpicks/videos/rss
|
||||
feed, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:atom="http://www.w3.org/2005/Atom"
|
||||
xmlns:media="http://search.yahoo.com/mrss/" xml:lang="en-US">
|
||||
<channel>
|
||||
<item>
|
||||
<title></title>
|
||||
<media:content>
|
||||
<media:player url="https://player.vimeo.com/video/527877676"/>
|
||||
<media:credit role="author" scheme="urn:ebu"></media:credit>
|
||||
<media:thumbnail height="540" width="960" url="https://i.vimeocdn.com/video/1092705247_960.jpg"/>
|
||||
<media:title></media:title>
|
||||
</media:content>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`))
|
||||
have := feed.Items[0].ImageURL
|
||||
want := "https://i.vimeocdn.com/video/1092705247_960.jpg"
|
||||
if have != want {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
func TestRSSWithLotsOfSpaces(t *testing.T) {
|
||||
// https://pxlnv.com/: https://feedpress.me/pxlnv
|
||||
feed, err := Parse(strings.NewReader(strings.ReplaceAll(`
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<?xml-stylesheet type="text/xsl" media="screen" href="/~files/feed-premium.xsl"?>
|
||||
<lotsofspaces>
|
||||
<rss xmlns:content="http://purl.org/rss/1.0/modules/content/"
|
||||
xmlns:wfw="http://wellformedweb.org/CommentAPI/"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:atom="http://www.w3.org/2005/Atom"
|
||||
xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
|
||||
xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
|
||||
xmlns:feedpress="https://feed.press/xmlns"
|
||||
xmlns:media="http://search.yahoo.com/mrss/"
|
||||
version="2.0">
|
||||
<channel>
|
||||
<title>finally</title>
|
||||
</channel>
|
||||
</rss>
|
||||
`, "<lotsofspaces>", strings.Repeat(" ", 500))))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
have := feed.Title
|
||||
want := "finally"
|
||||
if have != want {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
func TestRSSPodcast(t *testing.T) {
|
||||
feed, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<item>
|
||||
<enclosure length="100500" type="audio/x-m4a" url="http://example.com/audio.ext"/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`))
|
||||
have := feed.Items[0].AudioURL
|
||||
want := "http://example.com/audio.ext"
|
||||
if want != have {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
func TestRSSOpusPodcast(t *testing.T) {
|
||||
feed, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<item>
|
||||
<enclosure length="100500" type="audio/opus" url="http://example.com/audio.ext"/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`))
|
||||
have := feed.Items[0].AudioURL
|
||||
want := "http://example.com/audio.ext"
|
||||
if want != have {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
// found in: https://podcast.cscript.site/podcast.xml
|
||||
func TestRSSPodcastDuplicated(t *testing.T) {
|
||||
feed, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
||||
<channel>
|
||||
<item>
|
||||
<content:encoded>
|
||||
<![CDATA[ <audio src="http://example.com/audio.ext"></audio> ]]>
|
||||
</content:encoded>
|
||||
<enclosure length="100500" type="audio/x-m4a" url="http://example.com/audio.ext"/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`))
|
||||
have := feed.Items[0].Content
|
||||
want := `<audio src="http://example.com/audio.ext"></audio>`
|
||||
if want != have {
|
||||
t.Fatalf("content doesn't match\nwant: %#v\nhave: %#v\n", want, have)
|
||||
}
|
||||
if feed.Items[0].AudioURL != "" {
|
||||
t.Fatal("item.audio_url must be unset if present in the content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRSSTitleHTMLTags(t *testing.T) {
|
||||
feed, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
||||
<channel>
|
||||
<item>
|
||||
<title><p>title in p</p></title>
|
||||
</item>
|
||||
<item>
|
||||
<title>very <strong>strong</strong> title</title>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`))
|
||||
have := []string{feed.Items[0].Title, feed.Items[1].Title}
|
||||
want := []string{"title in p", "very strong title"}
|
||||
for i := 0; i < len(want); i++ {
|
||||
if want[i] != have[i] {
|
||||
t.Errorf("title doesn't match\nwant: %#v\nhave: %#v\n", want[i], have[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRSSIsPermalink(t *testing.T) {
|
||||
feed, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
||||
<channel>
|
||||
<item>
|
||||
<guid isPermaLink="true">http://example.com/posts/1</guid>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`))
|
||||
have := feed.Items
|
||||
want := []Item{
|
||||
{
|
||||
GUID: "http://example.com/posts/1",
|
||||
URL: "http://example.com/posts/1",
|
||||
},
|
||||
}
|
||||
for i := 0; i < len(want); i++ {
|
||||
if want[i] != have[i] {
|
||||
t.Errorf("Failed to handle isPermalink\nwant: %#v\nhave: %#v\n", want[i], have[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
112
src/parser/util.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html/charset"
|
||||
)
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, val := range vals {
|
||||
valTrimmed := strings.TrimSpace(val)
|
||||
if len(valTrimmed) > 0 {
|
||||
return valTrimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var linkRe = regexp.MustCompile(`(https?:\/\/\S+)`)
|
||||
|
||||
func plain2html(text string) string {
|
||||
text = linkRe.ReplaceAllString(text, `<a href="$1">$1</a>`)
|
||||
text = strings.ReplaceAll(text, "\n", "<br>")
|
||||
return text
|
||||
}
|
||||
|
||||
func xmlDecoder(r io.Reader) *xml.Decoder {
|
||||
decoder := xml.NewDecoder(r)
|
||||
decoder.Strict = false
|
||||
decoder.CharsetReader = func(cs string, input io.Reader) (io.Reader, error) {
|
||||
r, err := charset.NewReaderLabel(cs, input)
|
||||
if err == nil {
|
||||
r = NewSafeXMLReader(r)
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
return decoder
|
||||
}
|
||||
|
||||
type safexmlreader struct {
|
||||
reader *bufio.Reader
|
||||
buffer *bytes.Buffer
|
||||
}
|
||||
|
||||
func NewSafeXMLReader(r io.Reader) io.Reader {
|
||||
return &safexmlreader{
|
||||
reader: bufio.NewReader(r),
|
||||
buffer: bytes.NewBuffer(make([]byte, 0, 4096)),
|
||||
}
|
||||
}
|
||||
|
||||
func (xr *safexmlreader) Read(p []byte) (int, error) {
|
||||
for xr.buffer.Len() < cap(p) {
|
||||
r, _, err := xr.reader.ReadRune()
|
||||
if err == io.EOF {
|
||||
if xr.buffer.Len() == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if isInCharacterRange(r) {
|
||||
xr.buffer.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return xr.buffer.Read(p)
|
||||
}
|
||||
|
||||
// NOTE: copied from "encoding/xml" package
|
||||
// Decide whether the given rune is in the XML Character Range, per
|
||||
// the Char production of https://www.xml.com/axml/testaxml.htm,
|
||||
// Section 2.2 Characters.
|
||||
func isInCharacterRange(r rune) (inrange bool) {
|
||||
return r == 0x09 ||
|
||||
r == 0x0A ||
|
||||
r == 0x0D ||
|
||||
r >= 0x20 && r <= 0xD7FF ||
|
||||
r >= 0xE000 && r <= 0xFFFD ||
|
||||
r >= 0x10000 && r <= 0x10FFFF
|
||||
}
|
||||
|
||||
// NOTE: copied from "encoding/xml" package
|
||||
// procInst parses the `param="..."` or `param='...'`
|
||||
// value out of the provided string, returning "" if not found.
|
||||
func procInst(param, s string) string {
|
||||
// TODO: this parsing is somewhat lame and not exact.
|
||||
// It works for all actual cases, though.
|
||||
param = param + "="
|
||||
idx := strings.Index(s, param)
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
v := s[idx+len(param):]
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
if v[0] != '\'' && v[0] != '"' {
|
||||
return ""
|
||||
}
|
||||
idx = strings.IndexRune(v[1:], rune(v[0]))
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
return v[1 : idx+1]
|
||||
}
|
||||
88
src/parser/util_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSafeXMLReader(t *testing.T) {
|
||||
var f io.Reader
|
||||
want := []byte("привет мир")
|
||||
f = bytes.NewReader(want)
|
||||
f = NewSafeXMLReader(f)
|
||||
|
||||
have, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Fatalf("invalid output\nwant: %v\nhave: %v", want, have)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeXMLReaderRemoveUnwantedRunes(t *testing.T) {
|
||||
var f io.Reader
|
||||
input := []byte("\aпривет \x0cмир\ufffe\uffff")
|
||||
want := []byte("привет мир")
|
||||
f = bytes.NewReader(input)
|
||||
f = NewSafeXMLReader(f)
|
||||
|
||||
have, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Fatalf("invalid output\nwant: %v\nhave: %v", want, have)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeXMLReaderPartial1(t *testing.T) {
|
||||
var f io.Reader
|
||||
input := []byte("\aпривет \x0cмир\ufffe\uffff")
|
||||
want := []byte("привет мир")
|
||||
f = bytes.NewReader(input)
|
||||
f = NewSafeXMLReader(f)
|
||||
|
||||
buf := make([]byte, 1)
|
||||
for i := 0; i < len(want); i++ {
|
||||
n, err := f.Read(buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("expected 1 byte, got %d", n)
|
||||
}
|
||||
if buf[0] != want[i] {
|
||||
t.Fatalf("invalid char at pos %d\nwant: %v\nhave: %v", i, want[i], buf[0])
|
||||
}
|
||||
}
|
||||
if x, err := f.Read(buf); err != io.EOF {
|
||||
t.Fatalf("expected EOF, %v, %v %v", buf, x, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeXMLReaderPartial2(t *testing.T) {
|
||||
var f io.Reader
|
||||
input := []byte("привет\a\a\a\a\a")
|
||||
f = bytes.NewReader(input)
|
||||
f = NewSafeXMLReader(f)
|
||||
|
||||
buf := make([]byte, 12)
|
||||
n, err := f.Read(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if n != 12 {
|
||||
t.Fatalf("expected 12 bytes")
|
||||
}
|
||||
|
||||
n, err = f.Read(buf)
|
||||
if n != 0 {
|
||||
t.Fatalf("expected 0")
|
||||
}
|
||||
if err != io.EOF {
|
||||
t.Fatalf("expected EOF, got %v", err)
|
||||
}
|
||||
}
|
||||
15
src/platform/fixconsole_default.go
Normal file
@@ -0,0 +1,15 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package platform
|
||||
|
||||
// On non-windows platforms, we don't need to do anything. The console
|
||||
// starts off attached already, if it exists.
|
||||
|
||||
func AttachConsole() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func FixConsoleIfNeeded() error {
|
||||
return nil
|
||||
}
|
||||
134
src/platform/fixconsole_windows.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"golang.org/x/sys/windows"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func AttachConsole() error {
|
||||
const ATTACH_PARENT_PROCESS = ^uintptr(0)
|
||||
proc := syscall.MustLoadDLL("kernel32.dll").MustFindProc("AttachConsole")
|
||||
r1, _, err := proc.Call(ATTACH_PARENT_PROCESS)
|
||||
if r1 == 0 {
|
||||
errno, ok := err.(syscall.Errno)
|
||||
if ok && errno == windows.ERROR_INVALID_HANDLE {
|
||||
// console handle doesn't exist; not a real
|
||||
// error, but the console handle will be
|
||||
// invalid.
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var oldStdin, oldStdout, oldStderr *os.File
|
||||
|
||||
// Windows console output is a mess.
|
||||
//
|
||||
// If you compile as "-H windows", then if you launch your program without
|
||||
// a console, Windows forcibly creates one to use as your stdin/stdout, which
|
||||
// is silly for a GUI app, so we can't do that.
|
||||
//
|
||||
// If you compile as "-H windowsgui", then it doesn't create a console for
|
||||
// your app... but also doesn't provide a working stdin/stdout/stderr even if
|
||||
// you *did* launch from the console. However, you can use AttachConsole()
|
||||
// to get a handle to your parent process's console, if any, and then
|
||||
// os.NewFile() to turn that handle into a fd usable as stdout/stderr.
|
||||
//
|
||||
// However, then you have the problem that if you redirect stdout or stderr
|
||||
// from the shell, you end up ignoring the redirection by forcing it to the
|
||||
// console.
|
||||
//
|
||||
// To fix *that*, we have to detect whether there was a pre-existing stdout
|
||||
// or not. We can check GetStdHandle(), which returns 0 for "should be
|
||||
// console" and nonzero for "already pointing at a file."
|
||||
//
|
||||
// Be careful though! As soon as you run AttachConsole(), it resets *all*
|
||||
// the GetStdHandle() handles to point them at the console instead, thus
|
||||
// throwing away the original file redirects. So we have to GetStdHandle()
|
||||
// *before* AttachConsole().
|
||||
//
|
||||
// For some reason, powershell redirections provide a valid file handle, but
|
||||
// writing to that handle doesn't write to the file. I haven't found a way
|
||||
// to work around that. (Windows 10.0.17763.379)
|
||||
//
|
||||
// Net result is as follows.
|
||||
// Before:
|
||||
//
|
||||
// SHELL NON-REDIRECTED REDIRECTED
|
||||
// explorer.exe no console n/a
|
||||
// cmd.exe broken works
|
||||
// powershell broken broken
|
||||
// WSL bash broken works
|
||||
//
|
||||
// After
|
||||
//
|
||||
// SHELL NON-REDIRECTED REDIRECTED
|
||||
// explorer.exe no console n/a
|
||||
// cmd.exe works works
|
||||
// powershell works broken
|
||||
// WSL bash works works
|
||||
//
|
||||
// We don't seem to make anything worse, at least.
|
||||
func FixConsoleIfNeeded() error {
|
||||
// Retain the original console objects, to prevent Go from automatically
|
||||
// closing their file descriptors when they get garbage collected.
|
||||
// You never want to close file descriptors 0, 1, and 2.
|
||||
oldStdin, oldStdout, oldStderr = os.Stdin, os.Stdout, os.Stderr
|
||||
|
||||
stdin, _ := syscall.GetStdHandle(syscall.STD_INPUT_HANDLE)
|
||||
stdout, _ := syscall.GetStdHandle(syscall.STD_OUTPUT_HANDLE)
|
||||
stderr, _ := syscall.GetStdHandle(syscall.STD_ERROR_HANDLE)
|
||||
|
||||
var invalid syscall.Handle
|
||||
con := invalid
|
||||
|
||||
if stdin == invalid || stdout == invalid || stderr == invalid {
|
||||
err := AttachConsole()
|
||||
if err != nil {
|
||||
return fmt.Errorf("attachconsole: %v", err)
|
||||
}
|
||||
|
||||
if stdin == invalid {
|
||||
stdin, _ = syscall.GetStdHandle(syscall.STD_INPUT_HANDLE)
|
||||
}
|
||||
if stdout == invalid {
|
||||
stdout, _ = syscall.GetStdHandle(syscall.STD_OUTPUT_HANDLE)
|
||||
con = stdout
|
||||
}
|
||||
if stderr == invalid {
|
||||
stderr, _ = syscall.GetStdHandle(syscall.STD_ERROR_HANDLE)
|
||||
con = stderr
|
||||
}
|
||||
}
|
||||
|
||||
if con != invalid {
|
||||
// Make sure the console is configured to convert
|
||||
// \n to \r\n, like Go programs expect.
|
||||
h := windows.Handle(con)
|
||||
var st uint32
|
||||
err := windows.GetConsoleMode(h, &st)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetConsoleMode: %v", err)
|
||||
}
|
||||
err = windows.SetConsoleMode(h, st&^windows.DISABLE_NEWLINE_AUTO_RETURN)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SetConsoleMode: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if stdin != invalid {
|
||||
os.Stdin = os.NewFile(uintptr(stdin), "stdin")
|
||||
}
|
||||
if stdout != invalid {
|
||||
os.Stdout = os.NewFile(uintptr(stdout), "stdout")
|
||||
}
|
||||
if stderr != invalid {
|
||||
os.Stderr = os.NewFile(uintptr(stderr), "stderr")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
//go:build macos || windows
|
||||
// +build macos windows
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"github.com/getlantern/systray"
|
||||
"github.com/nkanaev/yarr/src/server"
|
||||
"github.com/nkanaev/yarr/src/systray"
|
||||
)
|
||||
|
||||
func Start(s *server.Handler) {
|
||||
func Start(s *server.Server) {
|
||||
systrayOnReady := func() {
|
||||
systray.SetIcon(Icon)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//go:build !windows && !macos
|
||||
// +build !windows,!macos
|
||||
|
||||
package platform
|
||||
@@ -6,6 +7,6 @@ import (
|
||||
"github.com/nkanaev/yarr/src/server"
|
||||
)
|
||||
|
||||
func Start(s *server.Handler) {
|
||||
func Start(s *server.Server) {
|
||||
s.Start()
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//go:build macos
|
||||
// +build macos
|
||||
|
||||
package platform
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package platform
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//go:build !windows && !darwin
|
||||
// +build !windows,!darwin
|
||||
|
||||
package platform
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//go:build darwin
|
||||
// +build darwin
|
||||
|
||||
package platform
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package platform
|
||||
|
||||
@@ -1,60 +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 userLogout(rw http.ResponseWriter) {
|
||||
cookie := http.Cookie{
|
||||
Name: "auth",
|
||||
Value: "",
|
||||
MaxAge: -1,
|
||||
}
|
||||
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)
|
||||
}
|
||||