Compare commits
344 Commits
v2.0
...
ce1c4863ee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce1c4863ee | ||
|
|
e7004bbd29 | ||
|
|
72a2bf605b | ||
|
|
06bed5b556 | ||
|
|
ba3034b3cf | ||
|
|
671cb2b9e9 | ||
|
|
15b6f9c566 | ||
|
|
3ab2292eeb | ||
|
|
a995dc7b7a | ||
|
|
4dc266d3d3 | ||
|
|
5110fbd596 | ||
|
|
7de4879a96 | ||
|
|
3e2b90f143 | ||
|
|
4dbedb2f99 | ||
|
|
32cfc3bc1a | ||
|
|
a5b8e62ca7 | ||
|
|
c554650db9 | ||
|
|
3b42d8c703 | ||
|
|
7b5c77f622 | ||
|
|
ba9ddc99f0 | ||
|
|
c452cdddf7 | ||
|
|
d4766429cf | ||
|
|
5c2d9bfc4c | ||
|
|
eef482d81d | ||
|
|
78a45c8533 | ||
|
|
f2556178b3 | ||
|
|
3f10371975 | ||
|
|
dee386b586 | ||
|
|
dc836ed4fd | ||
|
|
76adcf0d62 | ||
|
|
f29ad0c20a | ||
|
|
14835660fb | ||
|
|
d30124bf3c | ||
|
|
138b5ad991 | ||
|
|
2f263e9803 | ||
|
|
76529c895e | ||
|
|
847ec3861a | ||
|
|
85f3956b24 | ||
|
|
7553824520 | ||
|
|
54e197ad85 | ||
|
|
f50894ddb0 | ||
|
|
59af8aa62d | ||
|
|
31274d17a5 | ||
|
|
450f64605e | ||
|
|
391e2dd2c8 | ||
|
|
8fc01db275 | ||
|
|
76c2b9a475 | ||
|
|
14d5a6b52b | ||
|
|
6069330e92 | ||
|
|
552ebb7ad5 | ||
|
|
74e6ee8e8e | ||
|
|
167aef9ba1 | ||
|
|
ed726f26f4 | ||
|
|
760f611007 | ||
|
|
49c704037b | ||
|
|
7a5f8a5e41 | ||
|
|
1bae41a350 | ||
|
|
f1bdbbc0af | ||
|
|
f01c26b2c2 | ||
|
|
cbe1f971a5 | ||
|
|
1d654ac4de | ||
|
|
55b9b4a38b | ||
|
|
e916fdbe6c | ||
|
|
0e3df33d1f | ||
|
|
506fe1cae6 | ||
|
|
1d97314825 | ||
|
|
e1ecb6760b | ||
|
|
953f560a11 | ||
|
|
3d69911aa8 | ||
|
|
1052735535 | ||
|
|
d6504ac2e9 | ||
|
|
2a25f934c5 | ||
|
|
16a7f3409c | ||
|
|
0e11cec99a | ||
|
|
c158912da4 | ||
|
|
08ad04401d | ||
|
|
a851d8ac9d | ||
|
|
5a3547e32e | ||
|
|
b24152c19a | ||
|
|
9f93298cf9 | ||
|
|
ac9b635ed8 | ||
|
|
72a1930b9e | ||
|
|
e339354cc9 | ||
|
|
4b3a278679 | ||
|
|
aa06e65c59 | ||
|
|
dd57abefdd | ||
|
|
be8ba62bb1 | ||
|
|
b7895f6743 | ||
|
|
ebe7b130b8 | ||
|
|
7fe688e97c | ||
|
|
6b02a09f75 | ||
|
|
f0d2ab6493 | ||
|
|
42ee0372fe | ||
|
|
9762e09cb3 | ||
|
|
dd8b7ab27d | ||
|
|
c348593ef4 | ||
|
|
a51da7b8ec | ||
|
|
33503f7896 | ||
|
|
da569b3321 | ||
|
|
11285e4af0 | ||
|
|
9fe02931d8 | ||
|
|
e4f9dc8c72 | ||
|
|
88ed1de58b | ||
|
|
9bc89123f8 | ||
|
|
9fb3da2b4a | ||
|
|
58bb2c22c3 | ||
|
|
29d9ec6ef1 | ||
|
|
d2224399e2 | ||
|
|
67fbed7f6b | ||
|
|
c1df3f8068 | ||
|
|
0aed9b51a9 | ||
|
|
0bd7a66086 | ||
|
|
2b6823a277 | ||
|
|
dd7ed84a6c | ||
|
|
2c6a5ca971 | ||
|
|
5bf7647cba | ||
|
|
f721034ae5 | ||
|
|
a32361fab2 | ||
|
|
572e489db6 | ||
|
|
efcb6f8bf0 | ||
|
|
7e367ef537 | ||
|
|
b9a3326a98 | ||
|
|
484b155a3c | ||
|
|
9cba4e8deb | ||
|
|
749d7b682e | ||
|
|
35850d6310 | ||
|
|
15db17d834 | ||
|
|
a0d86e884a | ||
|
|
acf97c8a3b | ||
|
|
34bf9e5160 | ||
|
|
4420f3a8ae | ||
|
|
8d2ea6cf8a | ||
|
|
e244237474 | ||
|
|
ff81c9d689 | ||
|
|
11d99f106e | ||
|
|
b8afa82a81 | ||
|
|
097a2da5cb | ||
|
|
e6d32946c1 | ||
|
|
fe4eaa4b8d | ||
|
|
48a671b285 | ||
|
|
011c9c7668 | ||
|
|
f06fc1f750 | ||
|
|
0e88d4284d | ||
|
|
1615c6869f | ||
|
|
800f43b299 | ||
|
|
15bff0a0c4 | ||
|
|
e1481f4aac | ||
|
|
7ef97ee6db | ||
|
|
d785fe4c5a | ||
|
|
5254df53dc | ||
|
|
7301eab99c | ||
|
|
ad138c3017 | ||
|
|
b09c95d7ea | ||
|
|
64611a0dd3 | ||
|
|
321ad7608f | ||
|
|
2a8b6ea935 | ||
|
|
e9cbea500b | ||
|
|
223039b2c6 | ||
|
|
7402dfc4e6 | ||
|
|
6b12715506 | ||
|
|
2dc58c5c8e | ||
|
|
0cef51c6ac | ||
|
|
2a4d974965 | ||
|
|
f71792d6a5 | ||
|
|
b571042c5d | ||
|
|
349c966c63 | ||
|
|
4a42b239cc | ||
|
|
b9b3d2350c | ||
|
|
b13cd85f0b | ||
|
|
daffd721eb | ||
|
|
24232d72e9 | ||
|
|
4983e18e23 | ||
|
|
e1954e4cba | ||
|
|
58420ae52b | ||
|
|
b01f71de1a | ||
|
|
379aaed39e | ||
|
|
dc20932060 | ||
|
|
96835ebd33 | ||
|
|
c896f779b5 | ||
|
|
5f606b1c40 | ||
|
|
9d5b8d99f7 | ||
|
|
13c047fc21 | ||
|
|
55751b3eb6 | ||
|
|
b961502a17 | ||
|
|
a895145586 | ||
|
|
5aec3b4dab | ||
|
|
d787060a24 | ||
|
|
c1a29418eb | ||
|
|
17847f999c | ||
|
|
3adcddc70c | ||
|
|
c76ff26bd6 | ||
|
|
50f8648f64 | ||
|
|
5f82a9e339 | ||
|
|
3278ba4eac | ||
|
|
9fc72f8b68 | ||
|
|
b7b707bd43 | ||
|
|
7cf27e0fde | ||
|
|
66f2a973a3 | ||
|
|
7ecbbff18a | ||
|
|
850ce195a0 | ||
|
|
479aebd023 | ||
|
|
9b178d1fb3 | ||
|
|
3ab098db5c | ||
|
|
6d16e93008 | ||
|
|
98934daee4 | ||
|
|
259474cae9 | ||
|
|
1e65a7951b | ||
|
|
bed5640366 | ||
|
|
57ea83cf4f | ||
|
|
219842d723 | ||
|
|
a96fc101f2 | ||
|
|
81a77ce0a4 | ||
|
|
9ed359f964 | ||
|
|
bc18557820 | ||
|
|
7d99edab8d | ||
|
|
32ca121520 | ||
|
|
9f1a0534a3 | ||
|
|
d2678be96d | ||
|
|
95ebbb9d13 | ||
|
|
0f6d4d639d | ||
|
|
795a5d2cb4 | ||
|
|
dd5f760606 | ||
|
|
58d6a46e36 | ||
|
|
a8d7b86cdc | ||
|
|
aac3de7ca2 | ||
|
|
de24659bae | ||
|
|
632412c10e | ||
|
|
012b58bbe4 | ||
|
|
c092842ee4 | ||
|
|
e4c1d01915 | ||
|
|
ce07ddea92 | ||
|
|
bd6322e533 | ||
|
|
91da774286 | ||
|
|
e62906e63d | ||
|
|
56e5625adc | ||
|
|
1ecf4b0bb4 | ||
|
|
57d9421c7f | ||
|
|
a73188944d | ||
|
|
97904cc0f3 | ||
|
|
f28f354992 | ||
|
|
698f5d6d06 | ||
|
|
b935a1c511 | ||
|
|
10e6bfa5a0 | ||
|
|
f030a4075b | ||
|
|
c9dd977600 | ||
|
|
c1bcc0c517 | ||
|
|
2a5692d9a7 | ||
|
|
a8d160f9b1 | ||
|
|
286cbff236 | ||
|
|
fff0870d3b | ||
|
|
fe22460c07 | ||
|
|
18f2789a5d | ||
|
|
7f161a5408 | ||
|
|
cba3fbc48c | ||
|
|
5e46f1480e | ||
|
|
ead253c55f | ||
|
|
6b8da92cb3 | ||
|
|
a91f64ce9d | ||
|
|
e1a6ccf133 | ||
|
|
d2c034a850 | ||
|
|
713930decc | ||
|
|
ee2a825cf0 | ||
|
|
8e9da86f83 | ||
|
|
9eb49fd3a7 | ||
|
|
684bc25b83 | ||
|
|
8ceab03cd7 | ||
|
|
34dad4ac8f | ||
|
|
b40d930f8a | ||
|
|
d4b34e900e | ||
|
|
954b549029 | ||
|
|
fbd0b2310e | ||
|
|
be7af0ccaf | ||
|
|
18221ef12d | ||
|
|
4c0726412b | ||
|
|
d7253a60b8 | ||
|
|
2de3ddff08 | ||
|
|
830248b6ae | ||
|
|
f8db2ef7ad | ||
|
|
109caaa889 | ||
|
|
d0b83babd2 | ||
|
|
de3decbffd | ||
|
|
c92229a698 | ||
|
|
176852b662 | ||
|
|
52cc8ecbbd | ||
|
|
e3e9542f1e | ||
|
|
b78c8bf8bf | ||
|
|
bff7476b58 | ||
|
|
05f5785660 | ||
|
|
cb50aed89a | ||
|
|
df655aca5e | ||
|
|
86853a87bf | ||
|
|
e3109a4384 | ||
|
|
eee8002d69 | ||
|
|
92f11f7513 | ||
|
|
5428e6be3a | ||
|
|
1ad693f931 | ||
|
|
c2d88a7e3f | ||
|
|
3b29d737eb | ||
|
|
fe178b8fc6 | ||
|
|
cca742a1c2 | ||
|
|
c7eddff118 | ||
|
|
cf30ed249f | ||
|
|
26b87dee98 | ||
|
|
77c7f938f1 | ||
|
|
f98de9a0a5 | ||
|
|
6fa2b67024 | ||
|
|
355e5feb62 | ||
|
|
a7dd707062 | ||
|
|
4de46a7bc5 | ||
|
|
2c6fce3322 | ||
|
|
19ecfcd0bc | ||
|
|
d575acfe80 | ||
|
|
d203d38de6 | ||
|
|
9f01f63613 | ||
|
|
982c4ebbbc | ||
|
|
0c5385cef3 | ||
|
|
58f4e1f6c9 | ||
|
|
6b7f69d5c0 | ||
|
|
7aeb458ee5 | ||
|
|
7cfd3b3238 | ||
|
|
55262d38fe | ||
|
|
a45e29feb7 | ||
|
|
9f5fd3bb4d | ||
|
|
63f9d55903 | ||
|
|
8f36ae013e | ||
|
|
851aa1a136 | ||
|
|
f38dcfba3b | ||
|
|
214c7aacfc | ||
|
|
eb9bfc57e2 | ||
|
|
c072783c42 | ||
|
|
9d701678e1 | ||
|
|
37ed856d8b | ||
|
|
28f08ad42a | ||
|
|
da267a56ef | ||
|
|
16e4cad9ad | ||
|
|
d13a04898e | ||
|
|
ff39fbff70 | ||
|
|
92c6aac49e | ||
|
|
4ca81f90e9 | ||
|
|
75e828cb4c | ||
|
|
883214a740 | ||
|
|
36e359c881 | ||
|
|
87b53fb8ec | ||
|
|
2ae62855cc |
25
.github/actions/prepare/action.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Build & Upload
|
||||
inputs:
|
||||
id:
|
||||
description: artifact name
|
||||
required: true
|
||||
cmd:
|
||||
description: command to run
|
||||
required: true
|
||||
out:
|
||||
description: path to output file
|
||||
required: true
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: compile
|
||||
run: ${{ inputs.cmd }}
|
||||
shell: bash
|
||||
- name: archive
|
||||
run: tar -cvf ${{ inputs.out }}.tar ${{ inputs.out }}
|
||||
shell: bash
|
||||
- name: upload
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.id }}
|
||||
path: ${{ inputs.out }}.tar
|
||||
49
.github/workflows/build-docker.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
name: Publish Docker Image
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: nkanaev/yarr
|
||||
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./etc/dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
209
.github/workflows/build.yml
vendored
@@ -1,144 +1,143 @@
|
||||
name: build
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*', 'test*']
|
||||
tags:
|
||||
- v*
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build_macos:
|
||||
name: Build for MacOS
|
||||
runs-on: macos-10.15
|
||||
runs-on: macos-13
|
||||
steps:
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@v2
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
- name: "Setup Go"
|
||||
uses: actions/setup-go@v2
|
||||
go-version: '^1.23'
|
||||
- name: Build arm64 gui
|
||||
uses: ./.github/actions/prepare
|
||||
with:
|
||||
go-version: '^1.16'
|
||||
- name: Cache Go Modules
|
||||
uses: actions/cache@v2
|
||||
id: darwin_arm64_gui
|
||||
cmd: make darwin_arm64_gui
|
||||
out: out/darwin_arm64_gui/yarr.app
|
||||
- name: Build amd64 gui
|
||||
uses: ./.github/actions/prepare
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
- name: "Build"
|
||||
run: make build_macos
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v2
|
||||
id: darwin_amd64_gui
|
||||
cmd: make darwin_amd64_gui
|
||||
out: out/darwin_amd64_gui/yarr.app
|
||||
- name: Build arm64 cli
|
||||
uses: ./.github/actions/prepare
|
||||
with:
|
||||
name: macos
|
||||
path: _output/macos/yarr.app
|
||||
id: darwin_arm64
|
||||
cmd: make darwin_arm64
|
||||
out: out/darwin_arm64/yarr
|
||||
- name: Build amd64 cli
|
||||
uses: ./.github/actions/prepare
|
||||
with:
|
||||
id: darwin_amd64
|
||||
cmd: make darwin_amd64
|
||||
out: out/darwin_amd64/yarr
|
||||
|
||||
build_windows:
|
||||
name: Build for Windows
|
||||
runs-on: windows-2019
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@v2
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
- name: "Setup Go"
|
||||
uses: actions/setup-go@v2
|
||||
go-version: '^1.23'
|
||||
- name: Build amd64 gui
|
||||
uses: ./.github/actions/prepare
|
||||
with:
|
||||
go-version: '^1.16'
|
||||
- name: Cache Go Modules
|
||||
uses: actions/cache@v2
|
||||
id: windows_amd64_gui
|
||||
cmd: make windows_amd64_gui
|
||||
out: out/windows_amd64_gui/yarr.exe
|
||||
- name: Build arm64 gui
|
||||
if: false
|
||||
uses: ./.github/actions/prepare
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
- name: "Build"
|
||||
run: make build_windows
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: windows
|
||||
path: _output/windows/yarr.exe
|
||||
id: windows_arm64_gui
|
||||
cmd: make windows_arm64_gui
|
||||
out: out/windows_arm64_gui/yarr.exe
|
||||
|
||||
build_linux:
|
||||
name: Build for Linux
|
||||
runs-on: ubuntu-18.04
|
||||
build_multi_cli:
|
||||
name: Build for Windows/MacOS/Linux CLI
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@v2
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
- name: "Setup Go"
|
||||
uses: actions/setup-go@v2
|
||||
go-version: '^1.23'
|
||||
- name: Setup Zig
|
||||
uses: mlugg/setup-zig@v1
|
||||
with:
|
||||
go-version: '^1.16'
|
||||
- name: Cache Go Modules
|
||||
uses: actions/cache@v2
|
||||
version: 0.14.0
|
||||
- name: Build linux/amd64
|
||||
uses: ./.github/actions/prepare
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
- name: "Build"
|
||||
run: make build_linux
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v2
|
||||
id: linux_amd64
|
||||
cmd: make linux_amd64
|
||||
out: out/linux_amd64/yarr
|
||||
- name: Build linux/arm64
|
||||
uses: ./.github/actions/prepare
|
||||
with:
|
||||
name: linux
|
||||
path: _output/linux/yarr
|
||||
id: linux_arm64
|
||||
cmd: make linux_arm64
|
||||
out: out/linux_arm64/yarr
|
||||
- name: Build linux/armv7
|
||||
uses: ./.github/actions/prepare
|
||||
with:
|
||||
id: linux_armv7
|
||||
cmd: make linux_armv7
|
||||
out: out/linux_armv7/yarr
|
||||
- name: Build windows/amd64
|
||||
uses: ./.github/actions/prepare
|
||||
with:
|
||||
id: windows_amd64
|
||||
cmd: make windows_amd64
|
||||
out: out/windows_amd64/yarr
|
||||
- name: Build windows/arm64
|
||||
uses: ./.github/actions/prepare
|
||||
with:
|
||||
id: windows_arm64
|
||||
cmd: make windows_arm64
|
||||
out: out/windows_arm64/yarr
|
||||
|
||||
create_release:
|
||||
name: Create Release
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !contains(github.ref, 'test') }}
|
||||
needs: [build_macos, build_windows, build_linux]
|
||||
needs: [build_macos, build_windows, build_multi_cli]
|
||||
steps:
|
||||
- name: Create Release
|
||||
uses: actions/create-release@v1
|
||||
id: create_release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: ${{ github.ref }}
|
||||
draft: true
|
||||
prerelease: true
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@v2
|
||||
uses: actions/download-artifact@v4.1.7
|
||||
with:
|
||||
path: .
|
||||
- name: Preparation
|
||||
run: |
|
||||
set -ex
|
||||
ls -R
|
||||
chmod u+x macos/Contents/MacOS/yarr
|
||||
chmod u+x linux/yarr
|
||||
|
||||
mv macos yarr.app && zip -r yarr-macos.zip yarr.app
|
||||
mv windows/yarr.exe . && zip yarr-windows.zip yarr.exe
|
||||
mv linux/yarr . && zip yarr-linux.zip yarr
|
||||
- name: Upload MacOS
|
||||
uses: actions/upload-release-asset@v1
|
||||
for tarfile in `ls **/*.tar`; do
|
||||
tar -xvf $tarfile
|
||||
done
|
||||
for dir in out/*; do
|
||||
echo "Compressing: $dir"
|
||||
(test -d "$dir" && cd $dir && zip -r ../yarr_`basename $dir`.zip *)
|
||||
done
|
||||
ls out
|
||||
- name: Upload Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./yarr-macos.zip
|
||||
asset_name: yarr-${{ github.ref }}-macos64.zip
|
||||
asset_content_type: application/zip
|
||||
- name: Upload Windows
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./yarr-windows.zip
|
||||
asset_name: yarr-${{ github.ref }}-windows32.zip
|
||||
asset_content_type: application/zip
|
||||
- name: Upload Linux
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./yarr-linux.zip
|
||||
asset_name: yarr-${{ github.ref }}-linux32.zip
|
||||
asset_content_type: application/zip
|
||||
draft: true
|
||||
prerelease: true
|
||||
files: |
|
||||
out/*.zip
|
||||
|
||||
19
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: Test
|
||||
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '^1.18'
|
||||
|
||||
- name: Run tests
|
||||
run: make test
|
||||
6
.gitignore
vendored
@@ -1,7 +1,9 @@
|
||||
/server/assets.go
|
||||
/gofeed
|
||||
/_output
|
||||
/out
|
||||
/yarr
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.syso
|
||||
versioninfo.rc
|
||||
.DS_Store
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"io/ioutil"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var rsrc = `1 VERSIONINFO
|
||||
FILEVERSION {VERSION_COMMA},0,0
|
||||
PRODUCTVERSION {VERSION_COMMA},0,0
|
||||
BEGIN
|
||||
BLOCK "StringFileInfo"
|
||||
BEGIN
|
||||
BLOCK "080904E4"
|
||||
BEGIN
|
||||
VALUE "CompanyName", "Old MacDonald's Farm"
|
||||
VALUE "FileDescription", "Yet another RSS reader"
|
||||
VALUE "FileVersion", "{VERSION}"
|
||||
VALUE "InternalName", "yarr"
|
||||
VALUE "LegalCopyright", "nkanaev"
|
||||
VALUE "OriginalFilename", "yarr.exe"
|
||||
VALUE "ProductName", "yarr"
|
||||
VALUE "ProductVersion", "{VERSION}"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
BEGIN
|
||||
VALUE "Translation", 0x809, 1252
|
||||
END
|
||||
END
|
||||
|
||||
1 ICON "icon.ico"
|
||||
`
|
||||
|
||||
func main() {
|
||||
var version, outfile string
|
||||
flag.StringVar(&version, "version", "0.0", "")
|
||||
flag.StringVar(&outfile, "outfile", "versioninfo.rc", "")
|
||||
flag.Parse()
|
||||
|
||||
version_comma := strings.ReplaceAll(version, ".", ",")
|
||||
|
||||
out := strings.ReplaceAll(rsrc, "{VERSION}", version)
|
||||
out = strings.ReplaceAll(out, "{VERSION_COMMA}", version_comma)
|
||||
|
||||
ioutil.WriteFile(outfile, []byte(out), 0644)
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var plist = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>yarr</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>yarr</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>nkanaev.yarr</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>VERSION</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>yarr</string>
|
||||
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icon</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.news</string>
|
||||
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>True</string>
|
||||
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2020 nkanaev. All rights reserved.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
`
|
||||
|
||||
func run(cmd ...string) {
|
||||
fmt.Println(cmd)
|
||||
err := exec.Command(cmd[0], cmd[1:]...).Run()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
var version, outdir string
|
||||
flag.StringVar(&version, "version", "0.0", "")
|
||||
flag.StringVar(&outdir, "outdir", "", "")
|
||||
flag.Parse()
|
||||
|
||||
outfile := "yarr"
|
||||
|
||||
binDir := path.Join(outdir, "yarr.app", "Contents/MacOS")
|
||||
resDir := path.Join(outdir, "yarr.app", "Contents/Resources")
|
||||
|
||||
plistFile := path.Join(outdir, "yarr.app", "Contents/Info.plist")
|
||||
pkginfoFile := path.Join(outdir, "yarr.app", "Contents/PkgInfo")
|
||||
|
||||
os.MkdirAll(binDir, 0700)
|
||||
os.MkdirAll(resDir, 0700)
|
||||
|
||||
f, _ := ioutil.ReadFile(path.Join(outdir, outfile))
|
||||
ioutil.WriteFile(path.Join(binDir, outfile), f, 0755)
|
||||
|
||||
ioutil.WriteFile(plistFile, []byte(strings.Replace(plist, "VERSION", version, 1)), 0644)
|
||||
ioutil.WriteFile(pkginfoFile, []byte("APPL????"), 0644)
|
||||
|
||||
iconFile := path.Join(outdir, "icon.png")
|
||||
iconsetDir := path.Join(outdir, "icon.iconset")
|
||||
os.Mkdir(iconsetDir, 0700)
|
||||
|
||||
for _, res := range []int{1024, 512, 256, 128, 64, 32, 16} {
|
||||
outfile := fmt.Sprintf("icon_%dx%d.png", res, res)
|
||||
if res == 1024 || res == 64 {
|
||||
outfile = fmt.Sprintf("icon_%dx%d@2x.png", res/2, res/2)
|
||||
}
|
||||
cmd := []string{
|
||||
"sips", "-s", "format", "png", "--resampleWidth", strconv.Itoa(res),
|
||||
iconFile, "--out", path.Join(iconsetDir, outfile),
|
||||
}
|
||||
run(cmd...)
|
||||
}
|
||||
|
||||
icnsFile := path.Join(resDir, "icon.icns")
|
||||
run("iconutil", "-c", "icns", iconsetDir, "-o", icnsFile)
|
||||
}
|
||||
161
cmd/yarr/main.go
Normal file
@@ -0,0 +1,161 @@
|
||||
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)
|
||||
if 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]
|
||||
}
|
||||
return username, password, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
platform.FixConsoleIfNeeded()
|
||||
|
||||
var addr, db, authfile, auth, certfile, keyfile, basepath, logfile string
|
||||
var ver, open bool
|
||||
|
||||
flag.CommandLine.SetOutput(os.Stdout)
|
||||
|
||||
flag.Usage = func() {
|
||||
out := flag.CommandLine.Output()
|
||||
fmt.Fprintf(out, "Usage of %s:\n", os.Args[0])
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprintln(out, "\nThe environmental variables, if present, will be used to provide\nthe default values for the params above:")
|
||||
fmt.Fprintln(out, " ", strings.Join(OptList, ", "))
|
||||
}
|
||||
|
||||
flag.StringVar(&addr, "addr", opt("YARR_ADDR", "127.0.0.1:7070"), "address to run server on")
|
||||
flag.StringVar(&basepath, "base", opt("YARR_BASE", ""), "base path of the service url")
|
||||
flag.StringVar(&authfile, "auth-file", opt("YARR_AUTHFILE", ""), "`path` to a file containing username:password. Takes precedence over --auth (or YARR_AUTH)")
|
||||
flag.StringVar(&auth, "auth", opt("YARR_AUTH", ""), "string with username and password in the format `username:password`")
|
||||
flag.StringVar(&certfile, "cert-file", opt("YARR_CERTFILE", ""), "`path` to cert file for https")
|
||||
flag.StringVar(&keyfile, "key-file", opt("YARR_KEYFILE", ""), "`path` to key file for https")
|
||||
flag.StringVar(&db, "db", opt("YARR_DB", ""), "storage file `path`")
|
||||
flag.StringVar(&logfile, "log-file", opt("YARR_LOGFILE", ""), "`path` to log file to use instead of stdout")
|
||||
flag.BoolVar(&ver, "version", false, "print application version")
|
||||
flag.BoolVar(&open, "open", false, "open the server in browser")
|
||||
flag.Parse()
|
||||
|
||||
if ver {
|
||||
fmt.Printf("v%s (%s)\n", Version, GitHash)
|
||||
return
|
||||
}
|
||||
|
||||
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
|
||||
if logfile != "" {
|
||||
file, err := os.OpenFile(logfile, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to setup log file: ", err)
|
||||
}
|
||||
defer file.Close()
|
||||
log.SetOutput(file)
|
||||
} else {
|
||||
log.SetOutput(os.Stdout)
|
||||
}
|
||||
|
||||
if open && strings.HasPrefix(addr, "unix:") {
|
||||
log.Fatal("Cannot open ", addr, " in browser")
|
||||
}
|
||||
|
||||
if db == "" {
|
||||
configPath, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to get config dir: ", err)
|
||||
}
|
||||
|
||||
storagePath := filepath.Join(configPath, "yarr")
|
||||
if err := os.MkdirAll(storagePath, 0755); err != nil {
|
||||
log.Fatal("Failed to create app config dir: ", err)
|
||||
}
|
||||
db = filepath.Join(storagePath, "storage.db")
|
||||
}
|
||||
|
||||
log.Printf("using db file %s", db)
|
||||
|
||||
var username, password string
|
||||
var err error
|
||||
if authfile != "" {
|
||||
f, err := os.Open(authfile)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to open auth file: ", err)
|
||||
}
|
||||
defer f.Close()
|
||||
username, password, err = parseAuthfile(f)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to parse auth file: ", err)
|
||||
}
|
||||
} else if auth != "" {
|
||||
username, password, err = parseAuthfile(strings.NewReader(auth))
|
||||
if err != nil {
|
||||
log.Fatal("Failed to parse auth literal: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
if (certfile != "" || keyfile != "") && (certfile == "" || keyfile == "") {
|
||||
log.Fatalf("Both cert & key files are required")
|
||||
}
|
||||
|
||||
store, err := storage.New(db)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to initialise database: ", err)
|
||||
}
|
||||
|
||||
worker.SetVersion(Version)
|
||||
srv := server.NewServer(store, addr)
|
||||
|
||||
if basepath != "" {
|
||||
srv.BasePath = "/" + strings.Trim(basepath, "/")
|
||||
}
|
||||
|
||||
if certfile != "" && keyfile != "" {
|
||||
srv.CertFile = certfile
|
||||
srv.KeyFile = keyfile
|
||||
}
|
||||
|
||||
if username != "" && password != "" {
|
||||
srv.Username = username
|
||||
srv.Password = password
|
||||
}
|
||||
|
||||
log.Printf("starting server at %s", srv.GetAddr())
|
||||
if open {
|
||||
platform.Open(srv.GetAddr())
|
||||
}
|
||||
platform.Start(srv)
|
||||
}
|
||||
47
cmd/yarr/main_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPasswordFromAuthfile(t *testing.T) {
|
||||
for _, tc := range [...]struct {
|
||||
authfile string
|
||||
expectedUsername string
|
||||
expectedPassword string
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
authfile: "username:password",
|
||||
expectedUsername: "username",
|
||||
expectedPassword: "password",
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
authfile: "username-and-no-password",
|
||||
expectedError: true,
|
||||
},
|
||||
{
|
||||
authfile: "username:password:with:columns",
|
||||
expectedUsername: "username",
|
||||
expectedPassword: "password:with:columns",
|
||||
expectedError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.authfile, func(t *testing.T) {
|
||||
username, password, err := parseAuthfile(strings.NewReader(tc.authfile))
|
||||
if tc.expectedUsername != username {
|
||||
t.Errorf("expected username %q, got %q", tc.expectedUsername, username)
|
||||
}
|
||||
if tc.expectedPassword != password {
|
||||
t.Errorf("expected password %q, got %q", tc.expectedPassword, password)
|
||||
}
|
||||
if tc.expectedError && err == nil {
|
||||
t.Errorf("expected error, got nil")
|
||||
} else if !tc.expectedError && err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
56
doc/build.md
Normal file
@@ -0,0 +1,56 @@
|
||||
## Compilation
|
||||
|
||||
Prerequisies:
|
||||
|
||||
* Go >= 1.23
|
||||
* C Compiler (GCC / Clang / ...)
|
||||
* Zig >= 0.14.0 (optional, for cross-compiling CLI versions)
|
||||
* binutils (optional, for building Windows GUI version)
|
||||
|
||||
Get the source code:
|
||||
|
||||
git clone https://github.com/nkanaev/yarr.git
|
||||
|
||||
Compile:
|
||||
|
||||
# create cli for the host OS/architecture
|
||||
make host # out/yarr
|
||||
|
||||
# create GUI, works only in the target OS
|
||||
make windows_amd64_gui # out/windows_amd64_gui/yarr.exe
|
||||
make windows_arm64_gui # out/windows_arm64_gui/yarr.exe
|
||||
make darwin_arm64_gui # out/darwin_arm64_gui/yarr.app
|
||||
make darwin_amd64_gui # out/darwin_amd64_gui/yarr.app
|
||||
|
||||
# create cli, cross-compiles within any OS/architecture
|
||||
make linux_amd64
|
||||
make linux_arm64
|
||||
make linux_armv7
|
||||
make windows_amd64
|
||||
make windows_arm64
|
||||
|
||||
# ... or build a docker image
|
||||
docker build -t yarr -f etc/dockerfile .
|
||||
|
||||
## ARM compilation
|
||||
|
||||
The instructions below are to cross-compile *yarr* to `Linux/ARM*`.
|
||||
|
||||
Build:
|
||||
|
||||
docker build -t yarr.arm -f etc/dockerfile.arm .
|
||||
|
||||
Test:
|
||||
|
||||
# inside host
|
||||
docker run -it --rm yarr.arm
|
||||
|
||||
# then, inside container
|
||||
cd /root/out
|
||||
qemu-aarch64 -L /usr/aarch64-linux-gnu/ yarr.arm64
|
||||
|
||||
Extract files from images:
|
||||
|
||||
CID=$(docker create yarr.arm)
|
||||
docker cp -a "$CID:/root/out" .
|
||||
docker rm "$CID"
|
||||
132
doc/changelog.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# upcoming
|
||||
|
||||
- (fix) articles not resetting immediately after feed/filter selection (thank to @scratchmex for the report)
|
||||
- (fix) crash on empty article list with article is selected (thanks to @rksvc)
|
||||
- (fix) invalid article title in RSS feeds with media containing titles (thanks to @bwwu-git for the report)
|
||||
- (fix) missing image enclosures in certain RSS feeds (thanks to @palinek for the report)
|
||||
|
||||
# v2.6 (2025-11-24)
|
||||
|
||||
- (new) serve on unix socket (thanks to @rvighne)
|
||||
- (new) more auto-refresh options: 12h & 24h (thanks to @aswerkljh for suggestion)
|
||||
- (fix) smooth scrolling on iOS (thanks to gatheraled)
|
||||
- (fix) displaying youtube shorts in "Read Here" (thanks to @Dean-Corso for the report)
|
||||
- (etc) theme-color support (thanks to @asimpson)
|
||||
- (etc) cookie security measures (thanks to Tom Fitzhenry)
|
||||
- (etc) restrict access to internal IPs for page crawler (thanks to Omar Kurt)
|
||||
|
||||
# v2.5 (2025-03-26)
|
||||
|
||||
- (new) Fever API support (thanks to @icefed)
|
||||
- (new) editable feed link (thanks to @adaszko)
|
||||
- (new) switch to feed by clicking the title in the article page (thanks to @tarasglek for suggestion)
|
||||
- (new) support multiple media links
|
||||
- (new) next/prev article navigation buttons (thanks to @tillcash)
|
||||
- (fix) duplicate articles caused by the same feed addition (thanks to @adaszko)
|
||||
- (fix) relative article links (thanks to @adazsko for the report)
|
||||
- (fix) atom article links stored in id element (thanks to @adazsko for the report)
|
||||
- (fix) parsing atom feed titles (thanks to @wnh)
|
||||
- (fix) sorting same-day batch articles (thanks to @lamescholar for the report)
|
||||
- (fix) showing login page in the selected theme (thanks to @feddiriko for the report)
|
||||
- (fix) parsing atom feeds with html elements (thanks to @tillcash & @toBeOfUse for the report, @krkk for the fix)
|
||||
- (fix) parsing feeds with missing guids (thanks to @hoyii for the report)
|
||||
- (fix) sending actual client version to servers (thanks to @aidanholm)
|
||||
- (fix) error caused by missing config dir (thanks to @timster)
|
||||
- (etc) load external images with no-referrer policy (thanks to @tillcash for the report)
|
||||
- (etc) open external links with no-referrer policy (thanks to @donovanglover)
|
||||
- (etc) show article content in the list if title is missing (thanks to @asimpson for suggestion)
|
||||
- (etc) accessibility improvements (thanks to @tseykovets)
|
||||
|
||||
# v2.4 (2023-08-15)
|
||||
|
||||
- (new) ARM build support (thanks to @tillcash & @fenuks)
|
||||
- (new) auth configuration via param or env variable (thanks to @pierreprinetti)
|
||||
- (new) web app manifest for an app-like experience on mobile (thanks to @qbit)
|
||||
- (fix) concurrency issue crashing the app (thanks to @quoing)
|
||||
- (fix) favicon visibility in dark mode (thanks to @caycaycarly for the report)
|
||||
- (fix) autoloading more articles not working in certain edge cases (thanks to @fenuks for the report)
|
||||
- (fix) handle Google URL redirects in "Read Here" (thanks to @cubbei for discovery)
|
||||
- (fix) handle failures to extract content in "Read Here" (thanks to @grigio for the report)
|
||||
- (fix) article view width for high resolution screens (thanks to @whaler-ragweed for the report)
|
||||
- (fix) make newly added feed searchable (thanks to @BMorearty for the report)
|
||||
- (fix) feed/article selection accessibility via arrow keys (thanks to @grigio and @tillcash)
|
||||
- (fix) keyboard shortcuts in Firefox (thanks to @kaloyan13)
|
||||
- (fix) keyboard shortcuts in non-English layouts (thanks to @kaloyan13)
|
||||
- (fix) sorting articles with timezone information (thanks to @x2cf)
|
||||
- (fix) handling links set in guid only for certain feeds (thanks to @adaszko for the report)
|
||||
- (fix) crashes caused by feed icon endpoint (thanks to @adaszko)
|
||||
|
||||
# v2.3 (2022-05-03)
|
||||
|
||||
- (fix) handling encodings (thanks to @f100024 & @fserb)
|
||||
- (fix) parsing xml feeds with illegal characters (thanks to @stepelu for the report)
|
||||
- (fix) old articles reappearing as unread (thanks to @adaszko for the report)
|
||||
- (fix) item list scrolling issue on large screens (thanks to @bielej for the report)
|
||||
- (fix) keyboard shortcuts color in dark mode (thanks to @John09f9 for the report)
|
||||
- (etc) autofocus when adding a new feed (thanks to @lakuapik)
|
||||
|
||||
# v2.2 (2021-11-20)
|
||||
|
||||
- (fix) windows console support (thanks to @dufferzafar for the report)
|
||||
- (fix) remove html tags from article titles (thanks to Alex Went for the report)
|
||||
- (etc) autoselect current folder when adding a new feed (thanks to @krkk)
|
||||
- (etc) folder/feed settings menu available across all filters
|
||||
|
||||
# v2.1 (2021-08-16)
|
||||
|
||||
- (new) configuration via env variables
|
||||
- (fix) missing `content-type` headers (thanks to @verahawk for the report)
|
||||
- (fix) handle opml files not following the spec (thanks to @huangnauh for the report)
|
||||
- (fix) pagination in unread/starred feeds (thanks to @Farow for the report)
|
||||
- (fix) handling feeds with non-utf8 encodings (thanks to @fserb for the report)
|
||||
- (fix) errors caused by empty feeds (thanks to @decke)
|
||||
- (fix) recognize all audio mime types as podcasts (thanks to @krkk)
|
||||
- (fix) ui tweaks (thanks to @Farow)
|
||||
|
||||
# v2.0 (2021-04-18)
|
||||
|
||||
- (new) user interface tweaks
|
||||
- (new) feed parser fully rewritten
|
||||
- (new) show youtube/vimeo iframes in "read here"
|
||||
- (new) keyboard shortcuts for article scrolling & toggling "read here"
|
||||
- (new) more options for auto-refresh intervals
|
||||
- (fix) `-base` not serving static files (thanks to @vfaronov)
|
||||
- (etc) 3rd-party dependencies reduced to the bare minimum
|
||||
|
||||
special thanks to @tillcash for feedback & suggestions.
|
||||
|
||||
# v1.4 (2021-03-11)
|
||||
|
||||
- (new) keyboard shortcuts (thanks to @Duarte-Dias)
|
||||
- (new) show podcast audio
|
||||
- (fix) deleting feeds
|
||||
- (etc) minor ui tweaks & changes
|
||||
|
||||
# v1.3 (2021-02-18)
|
||||
|
||||
- (fix) log out functionality if authentication is set
|
||||
- (fix) import opml if authentication is set
|
||||
- (fix) login page if authentication is set (thanks to @einschmidt)
|
||||
|
||||
# v1.2 (2021-02-11)
|
||||
|
||||
- (new) autorefresh rate
|
||||
- (new) reduced bandwidth usage via stateful http headers `last-modified/etag`
|
||||
- (new) show feed errors in feed management modal
|
||||
- (new) `-open` flag for automatically opening the server url
|
||||
- (new) `-base` flag for serving urls under non-root path (thanks to @hcl)
|
||||
- (new) `-auth-file` flag for authentication
|
||||
- (new) `-cert-file` & `-key-file` flags for TLS
|
||||
- (fix) wrapping long words in the ui to prevent vertical scroll
|
||||
- (fix) increased toolbar height in mobile/tablet layout (thanks to @einschmidt)
|
||||
|
||||
# v1.1 (2020-10-05)
|
||||
|
||||
- (new) responsive design
|
||||
- (fix) server crash on favicon fetch timeout (reported by @minioin)
|
||||
- (fix) handling byte order marks in feeds (reported by @ilaer)
|
||||
- (fix) deleting a feed raises exception in the ui if the feed's items are shown.
|
||||
|
||||
# v1.0 (2020-09-24)
|
||||
|
||||
Initial Release
|
||||
@@ -1,47 +0,0 @@
|
||||
# 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
|
||||
254
doc/fever-api.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# API Public Beta
|
||||
|
||||
Fever 1.14 introduces the new Fever API. This API is in public beta and currently supports basic syncing and consuming of content. A subsequent update will allow for adding, editing and deleting feeds and groups. The API’s primary focus is maintaining a local cache of the data in a remote Fever installation.
|
||||
|
||||
I am [soliciting feedback](https://web.archive.org/web/20221221112459/https://feedafever.com/contact) from interested developers and as such the beta API may expand to reflect that feedback. The current API is incomplete but stable. Existing features may be expanded on but will not be removed or modified. New features may be added.
|
||||
|
||||
I’ve created a [simple HTML widget](https://web.archive.org/web/20221221112459/https://feedafever.com/gateway/public/api-widget.html.zip) that allows you to query the Fever API and view the response.
|
||||
|
||||
## Authentication
|
||||
|
||||
Without further ado, the Fever API endpoint URL looks like:
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api
|
||||
```
|
||||
|
||||
All requests must be authenticated with a `POST`ed `api_key`. The value of `api_key` should be the md5 checksum of the Fever accounts email address and password concatenated with a colon. An example of a valid value for `api_key` using PHP’s native `md5()` function:
|
||||
|
||||
```
|
||||
$email = 'you@yourdomain.com';
|
||||
$pass = 'b3stp4s4wd3v4';
|
||||
$api_key = md5($email.':'.$pass);
|
||||
```
|
||||
|
||||
A user may specify that `https` be used to connect to their Fever installation for additional security but you should not assume that all Fever installations support `https`.
|
||||
|
||||
The default response is a JSON object containing two members:
|
||||
|
||||
- `api_version` contains the version of the API responding (positive integer)
|
||||
- `auth` whether the request was successfully authenticated (boolean integer)
|
||||
|
||||
The API can also return XML by passing `xml` as the optional value of the `api` argument like so:
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api=xml
|
||||
```
|
||||
|
||||
The top level XML element is named `response`.
|
||||
|
||||
The response to each successfully authenticated request will have `auth` set to `1` and include at least one additional member:
|
||||
|
||||
- `last_refreshed_on_time` contains the time of the most recently refreshed (not _updated_) feed (Unix timestamp/integer)
|
||||
|
||||
## Read
|
||||
|
||||
When reading from the Fever API you add arguments to the query string of the API endpoint URL. If you attempt to `POST` these arguments (and their optional values) Fever will not recognize the request.
|
||||
|
||||
### Groups
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api&groups
|
||||
```
|
||||
|
||||
A request with the `groups` argument will return two additional members:
|
||||
|
||||
- `groups` contains an array of `group` objects
|
||||
- `feeds_groups` contains an array of `feeds_group` objects
|
||||
|
||||
A `group` object has the following members:
|
||||
|
||||
- `id` (positive integer)
|
||||
- `title` (utf-8 string)
|
||||
|
||||
The `feeds_group` object is documented under “Feeds/Groups Relationships.”
|
||||
|
||||
The “Kindling” super group is not included in this response and is composed of all feeds with an `is_spark` equal to `0`. The “Sparks” super group is not included in this response and is composed of all feeds with an `is_spark` equal to `1`.
|
||||
|
||||
### Feeds
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api&feeds
|
||||
```
|
||||
|
||||
A request with the `feeds` argument will return two additional members:
|
||||
|
||||
- `feeds` contains an array of `group` objects
|
||||
- `feeds_groups` contains an array of `feeds_group` objects
|
||||
|
||||
A `feed` object has the following members:
|
||||
|
||||
- `id` (positive integer)
|
||||
- `favicon_id` (positive integer)
|
||||
- `title` (utf-8 string)
|
||||
- `url` (utf-8 string)
|
||||
- `site_url` (utf-8 string)
|
||||
- `is_spark` (boolean integer)
|
||||
- `last_updated_on_time` (Unix timestamp/integer)
|
||||
|
||||
The `feeds_group` object is documented under “Feeds/Groups Relationships.”
|
||||
|
||||
The “All Items” super feed is not included in this response and is composed of all items from all feeds that belong to a given group. For the “Kindling” super group and all user created groups the items should be limited to feeds with an `is_spark` equal to `0`. For the “Sparks” super group the items should be limited to feeds with an `is_spark` equal to `1`.
|
||||
|
||||
### Feeds/Groups Relationships
|
||||
|
||||
A request with either the `groups` or `feeds` arguments will return an additional member:
|
||||
|
||||
A `feeds_group` object has the following members:
|
||||
|
||||
- `group_id` (positive integer)
|
||||
- `feed_ids` (string/comma-separated list of positive integers)
|
||||
|
||||
### Favicons
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api&favicons
|
||||
```
|
||||
|
||||
A request with the `favicons` argument will return one additional member:
|
||||
|
||||
- `favicons` contains an array of `favicon` objects
|
||||
|
||||
A `favicon` object has the following members:
|
||||
|
||||
- `id` (positive integer)
|
||||
- `data` (base64 encoded image data; prefixed by image type)
|
||||
|
||||
An example `data` value:
|
||||
|
||||
```
|
||||
image/gif;base64,R0lGODlhAQABAIAAAObm5gAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==
|
||||
```
|
||||
|
||||
The `data` member of a `favicon` object can be used with the `data:` protocol to embed an image in CSS or HTML. A PHP/HTML example:
|
||||
|
||||
```
|
||||
echo '<img src="data:'.$favicon['data'].'">';
|
||||
```
|
||||
|
||||
### Items
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api&items
|
||||
```
|
||||
|
||||
A request with the `items` argument will return two additional members:
|
||||
|
||||
- `items` contains an array of `item` objects
|
||||
- `total_items` contains the total number of items stored in the database (added in API version 2)
|
||||
|
||||
An `item` object has the following members:
|
||||
|
||||
- `id` (positive integer)
|
||||
- `feed_id` (positive integer)
|
||||
- `title` (utf-8 string)
|
||||
- `author` (utf-8 string)
|
||||
- `html` (utf-8 string)
|
||||
- `url` (utf-8 string)
|
||||
- `is_saved` (boolean integer)
|
||||
- `is_read` (boolean integer)
|
||||
- `created_on_time` (Unix timestamp/integer)
|
||||
|
||||
Most servers won’t have enough memory allocated to PHP to dump all items at once. Three optional arguments control determine the items included in the response.
|
||||
|
||||
- Use the `since_id` argument with the highest id of locally cached items to request 50 additional items. Repeat until the `items` array in the response is empty.
|
||||
|
||||
- Use the `max_id` argument with the lowest id of locally cached items (or `0` initially) to request 50 previous items. Repeat until the `items` array in the response is empty. (added in API version 2)
|
||||
|
||||
- Use the `with_ids` argument with a comma-separated list of item ids to request (a maximum of 50) specific items. (added in API version 2)
|
||||
|
||||
|
||||
### Hot Links
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api&links
|
||||
```
|
||||
|
||||
A request with the `links` argument will return one additional member:
|
||||
|
||||
- `links` contains an array of `link` objects
|
||||
|
||||
A `link` object has the following members:
|
||||
|
||||
- `id` (positive integer)
|
||||
- `feed_id` (positive integer) only use when `is_item` equals `1`
|
||||
- `item_id` (positive integer) only use when `is_item` equals `1`
|
||||
- `temperature` (positive float)
|
||||
- `is_item` (boolean integer)
|
||||
- `is_local` (boolean integer) used to determine if the source feed and favicon should be displayed
|
||||
- `is_saved` (boolean integer) only use when `is_item` equals `1`
|
||||
- `title` (utf-8 string)
|
||||
- `url` (utf-8 string)
|
||||
- `item_ids` (string/comma-separated list of positive integers)
|
||||
|
||||
When requesting hot links you can control the range and offset by specifying a length of days for each as well as a page to fetch additional hot links. A request with just the `links` argument is equivalent to:
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api&links&offset=0&range=7&page=1
|
||||
```
|
||||
|
||||
Or the first page (`page=1`) of Hot links for the past week (`range=7`) starting now (`offset=0`).
|
||||
|
||||
### Link Caveats
|
||||
|
||||
Fever calculates Hot link temperatures in real-time. The API assumes you have an up-to-date local cache of items, feeds and favicons with which to construct a meaningful Hot view. Because they are ephemeral Hot links should not be cached in the same relational manner as items, feeds, groups and favicons.
|
||||
|
||||
Because Fever saves items and not individual links you can only "save" a Hot link when `is_item` equals `1`.
|
||||
|
||||
## Sync
|
||||
|
||||
The `unread_item_ids` and `saved_item_ids` arguments can be used to keep your local cache synced with the remote Fever installation.
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api&unread_item_ids
|
||||
```
|
||||
|
||||
A request with the `unread_item_ids` argument will return one additional member:
|
||||
|
||||
- `unread_item_ids` (string/comma-separated list of positive integers)
|
||||
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api&saved_item_ids
|
||||
```
|
||||
|
||||
A request with the `saved_item_ids` argument will return one additional member:
|
||||
|
||||
- `saved_item_ids` (string/comma-separated list of positive integers)
|
||||
|
||||
One of these members will be returned as appropriate when marking an item as read, unread, saved, or unsaved and when marking a feed or group as read.
|
||||
|
||||
Because groups and feeds will be limited in number compared to items, they should be synced by comparing an array of locally cached feed or group ids to an array of feed or group ids returned by their respective API request.
|
||||
|
||||
## Write
|
||||
|
||||
The public beta of the API does not provide a way to add, edit or delete feeds or groups but you can mark items, feeds and groups as read and save or unsave items. You can also unread recently read items. When writing to the Fever API you add arguments to the `POST` data you submit to the API endpoint URL.
|
||||
|
||||
Adding `unread_recently_read=1` to your `POST` data will mark recently read items as unread.
|
||||
|
||||
You can update an individual item by adding the following three arguments to your `POST` data:
|
||||
|
||||
- `mark=item`
|
||||
- `as=?` where `?` is replaced with `read`, `saved` or `unsaved`
|
||||
- `id=?` where `?` is replaced with the `id` of the item to modify
|
||||
|
||||
Marking a feed or group as read is similar but requires one additional argument to prevent marking new, unreceived items as read:
|
||||
|
||||
- `mark=?` where `?` is replaced with `feed` or `group`
|
||||
- `as=read`
|
||||
- `id=?` where `?` is replaced with the `id` of the feed or group to modify
|
||||
- `before=?` where `?` is replaced with the Unix timestamp of the the local client’s most recent `items` API request
|
||||
|
||||
You can mark the “Kindling” super group (and the “Sparks” super group) as read by adding the following four arguments to your `POST` data:
|
||||
|
||||
- `mark=group`
|
||||
- `as=read`
|
||||
- `id=0`
|
||||
- `before=?` where `?` is replaced with the Unix timestamp of the the local client’s last `items` API request
|
||||
|
||||
Similarly you can mark just the “Sparks” super group as read by adding the following four arguments to your `POST` data:
|
||||
|
||||
- `mark=group`
|
||||
- `as=read`
|
||||
- `id=-1`
|
||||
- `before=?` where `?` is replaced with the Unix timestamp of the the local client’s last `items` API request
|
||||
1755
doc/fever-api.mhtml
Normal file
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.
|
||||
@@ -159,6 +159,7 @@ Delete any from the list in case they drop support of web feeds.
|
||||
- medium
|
||||
- posthaven
|
||||
- reddit
|
||||
- substack
|
||||
- tumblr
|
||||
- vimeo
|
||||
- wordpress
|
||||
|
||||
@@ -16,7 +16,7 @@ The licenses are included, and the authorship comments are left intact.
|
||||
- allowed uri schemes
|
||||
- added svg whitelist
|
||||
|
||||
- systray
|
||||
https://github.com/getlantern/systray (commit:2c0986d) Apache 2.0
|
||||
- fixconsole
|
||||
https://github.com/apenwarr/fixconsole (commit:5a9f648) Apache 2.0
|
||||
|
||||
removed golog dependency
|
||||
removed `w32` dependency
|
||||
|
||||
12
dockerfile
@@ -1,12 +0,0 @@
|
||||
FROM golang:alpine AS build
|
||||
RUN apk add build-base git
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN make build_linux
|
||||
|
||||
FROM alpine:latest
|
||||
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"]
|
||||
14
etc/dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM golang:alpine3.21 AS build
|
||||
RUN apk add build-base git
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/root/go/pkg \
|
||||
make host
|
||||
|
||||
FROM alpine:latest
|
||||
RUN apk add --no-cache ca-certificates && update-ca-certificates
|
||||
COPY --from=build /src/out/yarr /usr/local/bin/yarr
|
||||
EXPOSE 7070
|
||||
ENTRYPOINT ["/usr/local/bin/yarr"]
|
||||
CMD ["-addr", "0.0.0.0:7070", "-db", "/data/yarr.db"]
|
||||
38
etc/dockerfile.arm
Normal file
@@ -0,0 +1,38 @@
|
||||
FROM ubuntu:20.04
|
||||
|
||||
# Install GCC
|
||||
RUN apt update
|
||||
RUN apt install -y \
|
||||
wget build-essential \
|
||||
gcc-aarch64-linux-gnu \
|
||||
binutils-aarch64-linux-gnu binutils-aarch64-linux-gnu-dbg \
|
||||
gcc-arm-linux-gnueabihf \
|
||||
binutils-arm-linux-gnueabihf binutils-arm-linux-gnueabihf-dbg
|
||||
RUN env DEBIAN_FRONTEND=noninteractive \
|
||||
apt install -y qemu-user qemu-user-static
|
||||
|
||||
# Install Golang
|
||||
RUN wget --quiet https://go.dev/dl/go1.24.1.linux-amd64.tar.gz && \
|
||||
rm -rf /usr/local/go && \
|
||||
tar -C /usr/local -xzf go1.24.1.linux-amd64.tar.gz
|
||||
ENV PATH=$PATH:/usr/local/go/bin
|
||||
|
||||
# Copy source code
|
||||
WORKDIR /root/src
|
||||
RUN mkdir /root/out
|
||||
COPY . .
|
||||
|
||||
# Build ARM64
|
||||
RUN env \
|
||||
CC=aarch64-linux-gnu-gcc \
|
||||
CGO_ENABLED=1 \
|
||||
GOOS=linux GOARCH=arm64 \
|
||||
make host && mv out/yarr /root/out/yarr.arm64
|
||||
|
||||
RUN env \
|
||||
CC=arm-linux-gnueabihf-gcc \
|
||||
CGO_ENABLED=1 \
|
||||
GOOS=linux GOARCH=arm GOARM=7 \
|
||||
make host && mv out/yarr /root/out/yarr.armv7
|
||||
|
||||
CMD ["/bin/bash"]
|
||||
BIN
etc/icon.icns
Normal file
BIN
etc/icon_macos.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
31
etc/install-linux.sh
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [[ ! -d "$HOME/.local/share/applications" ]]; then
|
||||
mkdir -p "$HOME/.local/share/applications"
|
||||
fi
|
||||
|
||||
cat >"$HOME/.local/share/applications/yarr.desktop" <<END
|
||||
[Desktop Entry]
|
||||
Name=yarr
|
||||
Exec=$HOME/.local/bin/yarr -open
|
||||
Icon=yarr
|
||||
Type=Application
|
||||
Categories=Internet;Network;News;Feed;
|
||||
END
|
||||
|
||||
if [[ ! -d "$HOME/.local/share/icons" ]]; then
|
||||
mkdir -p "$HOME/.local/share/icons"
|
||||
fi
|
||||
|
||||
cat >"$HOME/.local/share/icons/yarr.svg" <<END
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-anchor-favicon">
|
||||
<circle cx="12" cy="5" r="3" stroke-width="4" stroke="#ffffff"></circle>
|
||||
<line x1="12" y1="22" x2="12" y2="8" stroke-width="4" stroke="#ffffff"></line>
|
||||
<path d="M5 12H2a10 10 0 0 0 20 0h-3" stroke-width="4" stroke="#ffffff"></path>
|
||||
|
||||
<circle cx="12" cy="5" r="3"></circle>
|
||||
<line x1="12" y1="22" x2="12" y2="8"></line>
|
||||
<path d="M5 12H2a10 10 0 0 0 20 0h-3"></path>
|
||||
</svg>
|
||||
END
|
||||
62
etc/macos_package.sh
Executable file
@@ -0,0 +1,62 @@
|
||||
#/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
usage() {
|
||||
echo "usage: $0 VERSION path/to/icon.icns path/to/binary output/dir"
|
||||
}
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
usage
|
||||
exit
|
||||
fi
|
||||
|
||||
VERSION=$1
|
||||
ICNFILE=$2
|
||||
BINFILE=$3
|
||||
OUTPATH=$4
|
||||
|
||||
mkdir -p $OUTPATH/yarr.app/Contents/MacOS
|
||||
mkdir -p $OUTPATH/yarr.app/Contents/Resources
|
||||
|
||||
mv $BINFILE $OUTPATH/yarr.app/Contents/MacOS/yarr
|
||||
cp $ICNFILE $OUTPATH/yarr.app/Contents/Resources/icon.icns
|
||||
|
||||
chmod u+x $OUTPATH/yarr.app/Contents/MacOS/yarr
|
||||
|
||||
echo -n 'APPL????' >$OUTPATH/yarr.app/Contents/PkgInfo
|
||||
cat <<EOF >$OUTPATH/yarr.app/Contents/Info.plist
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>yarr</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>yarr</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>nkanaev.yarr</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$VERSION</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>yarr</string>
|
||||
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icon</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.news</string>
|
||||
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>True</string>
|
||||
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2020 nkanaev. All rights reserved.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
BIN
etc/promo.png
|
Before Width: | Height: | Size: 727 KiB After Width: | Height: | Size: 335 KiB |
68
etc/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]
|
||||
89
etc/windows_versioninfo.sh
Executable file
@@ -0,0 +1,89 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
# Function to display usage information
|
||||
usage() {
|
||||
echo "Usage: $0 [-version VERSION] [-outfile FILENAME]"
|
||||
echo " -version VERSION Set the version number (default: 0.0)"
|
||||
echo " -outfile FILENAME Set the output file name (default: versioninfo.rc)"
|
||||
echo ""
|
||||
echo "This script generates a Windows resource file with version information."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Default values
|
||||
version="0.0"
|
||||
outfile="versioninfo.rc"
|
||||
|
||||
# Check if help is requested
|
||||
if [[ "$1" == "-h" || "$1" == "--help" ]]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
# Parse command-line options
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-version)
|
||||
if [[ -z "$2" || "$2" == -* ]]; then
|
||||
echo "Error: Missing value for -version parameter"
|
||||
usage
|
||||
fi
|
||||
version="$2"
|
||||
shift 2
|
||||
;;
|
||||
-outfile)
|
||||
if [[ -z "$2" || "$2" == -* ]]; then
|
||||
echo "Error: Missing value for -outfile parameter"
|
||||
usage
|
||||
fi
|
||||
outfile="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "Error: Unknown parameter: $1"
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Replace dots with commas for version_comma
|
||||
version_comma="${version//./,}"
|
||||
|
||||
# Use a here document for the template with ENDFILE delimiter
|
||||
cat <<ENDFILE > "$outfile"
|
||||
1 VERSIONINFO
|
||||
FILEVERSION $version_comma,0,0
|
||||
PRODUCTVERSION $version_comma,0,0
|
||||
BEGIN
|
||||
BLOCK "StringFileInfo"
|
||||
BEGIN
|
||||
BLOCK "080904E4"
|
||||
BEGIN
|
||||
VALUE "CompanyName", "Old MacDonald's Farm"
|
||||
VALUE "FileDescription", "Yet another RSS reader"
|
||||
VALUE "FileVersion", "$version"
|
||||
VALUE "InternalName", "yarr"
|
||||
VALUE "LegalCopyright", "nkanaev"
|
||||
VALUE "OriginalFilename", "yarr.exe"
|
||||
VALUE "ProductName", "yarr"
|
||||
VALUE "ProductVersion", "$version"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
BEGIN
|
||||
VALUE "Translation", 0x809, 1252
|
||||
END
|
||||
END
|
||||
|
||||
1 ICON "icon.ico"
|
||||
ENDFILE
|
||||
|
||||
# Set the correct permissions
|
||||
chmod 644 "$outfile"
|
||||
|
||||
echo "Generated $outfile with version $version"
|
||||
17
go.mod
@@ -1,9 +1,18 @@
|
||||
module github.com/nkanaev/yarr
|
||||
|
||||
go 1.16
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.23.5
|
||||
|
||||
require (
|
||||
github.com/mattn/go-sqlite3 v1.14.0
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e
|
||||
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13
|
||||
fyne.io/systray v1.12.0
|
||||
github.com/lib/pq v1.12.3
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
golang.org/x/net v0.38.0
|
||||
golang.org/x/sys v0.31.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
)
|
||||
|
||||
29
go.sum
@@ -1,15 +1,14 @@
|
||||
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
|
||||
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
|
||||
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=
|
||||
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-20200324143707-d3edc9973b7e h1:3G+cUijn7XD+S4eJFddp53Pv7+slrESplyjG25HgL+k=
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13 h1:5jaG59Zhd+8ZXe8C+lgiAGqkOaZBruqrWclLkgAww34=
|
||||
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM=
|
||||
fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
|
||||
107
makefile
@@ -1,39 +1,92 @@
|
||||
VERSION=2.0
|
||||
VERSION=2.6
|
||||
GITHASH=$(shell git rev-parse --short=8 HEAD)
|
||||
|
||||
CGO_ENABLED=1
|
||||
GO_TAGS = sqlite_foreign_keys sqlite_json sqlite_fts5
|
||||
GO_LDFLAGS = -s -w -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)'
|
||||
|
||||
GO_LDFLAGS = -s -w
|
||||
GO_LDFLAGS := $(GO_LDFLAGS) -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)'
|
||||
GO_FLAGS = -tags "$(GO_TAGS)" -ldflags="$(GO_LDFLAGS)"
|
||||
GO_FLAGS_DEBUG = -tags "$(GO_TAGS) debug"
|
||||
GO_FLAGS_GUI = -tags "$(GO_TAGS) gui" -ldflags="$(GO_LDFLAGS)"
|
||||
GO_FLAGS_GUI_WIN = -tags "$(GO_TAGS) gui" -ldflags="$(GO_LDFLAGS) -H windowsgui"
|
||||
|
||||
build_default:
|
||||
mkdir -p _output
|
||||
go build -tags "sqlite_foreign_keys release" -ldflags="$(GO_LDFLAGS)" -o _output/yarr src/main.go
|
||||
export CGO_ENABLED=1
|
||||
|
||||
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
|
||||
cp src/platform/icon.png _output/macos/icon.png
|
||||
go run bin/package_macos.go -outdir _output/macos -version "$(VERSION)"
|
||||
default: test host
|
||||
|
||||
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
|
||||
# platform-specific files
|
||||
|
||||
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
|
||||
etc/icon.icns: etc/icon_macos.png
|
||||
mkdir -p etc/icon.iconset
|
||||
sips -s format png --resampleWidth 1024 etc/icon_macos.png --out etc/icon.iconset/icon_512x512@2x.png
|
||||
sips -s format png --resampleWidth 512 etc/icon_macos.png --out etc/icon.iconset/icon_512x512.png
|
||||
sips -s format png --resampleWidth 256 etc/icon_macos.png --out etc/icon.iconset/icon_256x256.png
|
||||
sips -s format png --resampleWidth 128 etc/icon_macos.png --out etc/icon.iconset/icon_128x128.png
|
||||
sips -s format png --resampleWidth 64 etc/icon_macos.png --out etc/icon.iconset/icon_32x32@2x.png
|
||||
sips -s format png --resampleWidth 32 etc/icon_macos.png --out etc/icon.iconset/icon_32x32.png
|
||||
sips -s format png --resampleWidth 16 etc/icon_macos.png --out etc/icon.iconset/icon_16x16.png
|
||||
iconutil -c icns etc/icon.iconset -o etc/icon.icns
|
||||
|
||||
src/platform/versioninfo.rc:
|
||||
./etc/windows_versioninfo.sh -version "$(VERSION)" -outfile src/platform/versioninfo.rc
|
||||
windres -i src/platform/versioninfo.rc -O coff -o src/platform/versioninfo.syso
|
||||
go build -tags "sqlite_foreign_keys release windows" -ldflags="$(GO_LDFLAGS) -H windowsgui" -o _output/windows/yarr.exe src/main.go
|
||||
|
||||
# build targets
|
||||
|
||||
host:
|
||||
go build $(GO_FLAGS) -o out/yarr ./cmd/yarr
|
||||
|
||||
darwin_amd64:
|
||||
# cross-compilation not supported: CC="zig cc -target x86_64-macos-none"
|
||||
GOOS=darwin GOARCH=arm64 go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
|
||||
|
||||
darwin_arm64:
|
||||
# cross-compilation not supported: CC="zig cc -target aarch64-macos-none"
|
||||
GOOS=darwin GOARCH=arm64 go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
|
||||
|
||||
linux_amd64:
|
||||
CC="zig cc -target x86_64-linux-musl -O2 -g0" CGO_CFLAGS="-D_LARGEFILE64_SOURCE" GOOS=linux GOARCH=amd64 \
|
||||
go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
|
||||
|
||||
linux_arm64:
|
||||
CC="zig cc -target aarch64-linux-musl -O2 -g0" CGO_CFLAGS="-D_LARGEFILE64_SOURCE" GOOS=linux GOARCH=arm64 \
|
||||
go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
|
||||
|
||||
linux_armv7:
|
||||
CC="zig cc -target arm-linux-musleabihf -O2 -g0" CGO_CFLAGS="-D_LARGEFILE64_SOURCE" GOOS=linux GOARCH=arm GOARM=7 \
|
||||
go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
|
||||
|
||||
windows_amd64:
|
||||
CC="zig cc -target x86_64-windows-gnu" GOOS=windows GOARCH=amd64 go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
|
||||
|
||||
windows_arm64:
|
||||
CC="zig cc -target aarch64-windows-gnu" GOOS=windows GOARCH=arm64 go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
|
||||
|
||||
darwin_arm64_gui: etc/icon.icns
|
||||
GOOS=darwin GOARCH=arm64 go build $(GO_FLAGS_GUI) -o out/$@/yarr ./cmd/yarr
|
||||
./etc/macos_package.sh $(VERSION) etc/icon.icns out/$@/yarr out/$@
|
||||
|
||||
darwin_amd64_gui: etc/icon.icns
|
||||
GOOS=darwin GOARCH=amd64 go build $(GO_FLAGS_GUI) -o out/$@/yarr ./cmd/yarr
|
||||
./etc/macos_package.sh $(VERSION) etc/icon.icns out/$@/yarr out/$@
|
||||
|
||||
windows_amd64_gui: src/platform/versioninfo.rc
|
||||
GOOS=windows GOARCH=amd64 go build $(GO_FLAGS_GUI_WIN) -o out/$@/yarr.exe ./cmd/yarr
|
||||
|
||||
windows_arm64_gui: src/platform/versioninfo.rc
|
||||
GOOS=windows GOARCH=arm64 go build $(GO_FLAGS_GUI_WIN) -o out/$@/yarr.exe ./cmd/yarr
|
||||
|
||||
YARR_DB ?= local.db
|
||||
|
||||
serve:
|
||||
go run -tags "sqlite_foreign_keys" src/main.go -db local.db
|
||||
go run $(GO_FLAGS_DEBUG) ./cmd/yarr -db "$(YARR_DB)"
|
||||
|
||||
test:
|
||||
cd src && go test -tags "sqlite_foreign_keys release" ./...
|
||||
go test $(GO_FLAGS) ./...
|
||||
|
||||
.PHONY: \
|
||||
host \
|
||||
darwin_amd64 darwin_amd64_gui \
|
||||
darwin_arm64 darwin_arm64_gui \
|
||||
windows_amd64 windows_amd64_gui \
|
||||
windows_arm64 windows_arm64_gui \
|
||||
serve serve_postgres test
|
||||
|
||||
64
readme.md
@@ -3,72 +3,38 @@
|
||||
**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).
|
||||
|
||||

|
||||
|
||||
Subscribe: [releases](https://github.com/nkanaev/yarr/releases.atom) / [devlog](https://hachyderm.io/@nkanaev.rss) ([Mastodon](https://hachyderm.io/@nkanaev))
|
||||
|
||||
## usage
|
||||
|
||||
The latest prebuilt binaries for Linux/MacOS/Windows are available
|
||||
[here](https://github.com/nkanaev/yarr/releases/latest).
|
||||
The archives follow the naming convention `yarr_{OS}_{ARCH}[_gui].zip`, where:
|
||||
|
||||
### macos
|
||||
* `OS` is the target operating system
|
||||
* `ARCH` is the CPU architecture (`arm64` for AArch64, `amd64` for X86-64)
|
||||
* `-gui` indicates that the binary ships with the GUI (tray icon), and is a command line application if omitted
|
||||
|
||||
Download `yarr-*-macos64.zip`, unzip it, place `yarr.app` in `/Applications` folder.
|
||||
Usage instructions:
|
||||
|
||||
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:
|
||||
* MacOS: place `yarr.app` in `/Applications` folder, [open the app][macos-open], click the anchor menu bar icon, select "Open".
|
||||
|
||||
xattr -d com.apple.quarantine /Applications/yarr.app
|
||||
* Windows: open `yarr.exe`, click the anchor system tray icon, select "Open".
|
||||
|
||||
### windows
|
||||
* Linux: place `yarr` in `$HOME/.local/bin` and run [the script](etc/install-linux.sh).
|
||||
|
||||
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.
|
||||
|
||||
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 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
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
@@ -29,9 +29,18 @@ func Template(path string) *template.Template {
|
||||
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 := io.ReadAll(svgfile)
|
||||
// should never happen
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return template.HTML(content)
|
||||
},
|
||||
}).ParseFS(FS, path))
|
||||
@@ -42,7 +51,7 @@ func Template(path string) *template.Template {
|
||||
return tmpl
|
||||
}
|
||||
|
||||
func Render(path string, writer io.Writer, data interface{}) {
|
||||
func Render(path string, writer io.Writer, data any) {
|
||||
tmpl := Template(path)
|
||||
tmpl.Execute(writer, data)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// +build release
|
||||
//go:build !debug
|
||||
|
||||
package assets
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-more-vertical"><circle cx="12" cy="12" r="1"></circle><circle cx="12" cy="5" r="1"></circle><circle cx="12" cy="19" r="1"></circle></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-up"><polyline points="18 15 12 9 6 15"></polyline></svg>
|
||||
|
Before Width: | Height: | Size: 341 B After Width: | Height: | Size: 268 B |
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 |
|
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-menu"><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg>
|
||||
|
Before Width: | Height: | Size: 346 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 |
@@ -5,7 +5,10 @@
|
||||
<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/icon.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="theme-color" content="" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<script>
|
||||
window.app = window.app || {}
|
||||
@@ -21,61 +24,86 @@
|
||||
<div class="p-2 toolbar d-flex align-items-center">
|
||||
<div class="icon mx-2">{% inline "anchor.svg" %}</div>
|
||||
<div class="flex-grow-1"></div>
|
||||
<button class="toolbar-item"
|
||||
<button class="toolbar-item ml-1"
|
||||
:class="{active: filterSelected == 'unread'}"
|
||||
title="Unread"
|
||||
:aria-pressed="filterSelected == 'unread'"
|
||||
:title="$t('unread')"
|
||||
@click="filterSelected = 'unread'">
|
||||
<span class="icon">{% inline "circle-full.svg" %}</span>
|
||||
</button>
|
||||
<button class="toolbar-item"
|
||||
<button class="toolbar-item mx-1"
|
||||
:class="{active: filterSelected == 'starred'}"
|
||||
title="Starred"
|
||||
:aria-pressed="filterSelected == 'starred'"
|
||||
:title="$t('starred')"
|
||||
@click="filterSelected = 'starred'">
|
||||
<span class="icon">{% inline "star-full.svg" %}</span>
|
||||
</button>
|
||||
<button class="toolbar-item"
|
||||
<button class="toolbar-item mr-1"
|
||||
:class="{active: filterSelected == ''}"
|
||||
title="All"
|
||||
:aria-pressed="filterSelected == ''"
|
||||
:title="$t('all')"
|
||||
@click="filterSelected = ''">
|
||||
<span class="icon">{% inline "assorted.svg" %}</span>
|
||||
</button>
|
||||
<div class="flex-grow-1"></div>
|
||||
<dropdown class="settings-dropdown" toggle-class="btn btn-link toolbar-item px-2" ref="menuDropdown" drop="right" title="Settings">
|
||||
<dropdown class="settings-dropdown" toggle-class="btn btn-link toolbar-item px-2" ref="menuDropdown" drop="right" :title="$t('settings')">
|
||||
<template v-slot:button>
|
||||
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
||||
</template>
|
||||
|
||||
<button class="dropdown-item" @click="showSettings('create')">
|
||||
<span class="icon mr-1">{% inline "plus.svg" %}</span>
|
||||
New Feed
|
||||
{{ $t('new_feed') }}
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item" @click="fetchAllFeeds()">
|
||||
<span class="icon mr-1">{% inline "rotate-cw.svg" %}</span>
|
||||
Refresh Feeds
|
||||
{{ $t('refresh_feeds') }}
|
||||
</button>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<header class="dropdown-header">Auto Refresh</header>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('theme') }}</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>
|
||||
<button class="btn btn-link col-4 px-0 rounded-0"
|
||||
:class="'theme-'+t"
|
||||
:aria-label="t"
|
||||
:aria-pressed="theme.name == t"
|
||||
@click.stop="theme.name = t"
|
||||
v-for="t in ['light', 'sepia', 'night']">
|
||||
<span class="icon" v-if="theme.name == t">{% inline "check.svg" %}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<header class="dropdown-header">Show first</header>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('auto_refresh') }}</header>
|
||||
<div class="row text-center m-0">
|
||||
<button class="dropdown-item col-4 px-0"
|
||||
@click.stop="changeRefreshRate(-1)"
|
||||
:disabled="!refreshRate">
|
||||
<span class="icon">
|
||||
{% inline "chevron-down.svg" %}
|
||||
</span>
|
||||
</button>
|
||||
<div class="col-4 d-flex align-items-center justify-content-center">{{ refreshRateTitle }}</div>
|
||||
<button class="dropdown-item col-4 px-0"
|
||||
@click.stop="changeRefreshRate(1)" :disabled="refreshRate === refreshRateOptions.at(-1).value">
|
||||
<span class="icon">
|
||||
{% inline "chevron-up.svg" %}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('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>
|
||||
<button class="dropdown-item px-0" :aria-pressed="itemSortNewestFirst" :class="{active: itemSortNewestFirst}" @click.stop="itemSortNewestFirst=true">{{ $t('new') }}</button>
|
||||
<button class="dropdown-item px-0" :aria-pressed="!itemSortNewestFirst" :class="{active: !itemSortNewestFirst}" @click.stop="itemSortNewestFirst=false">{{ $t('old') }}</button>
|
||||
</div>
|
||||
<div class="dropdown-divider"></div>
|
||||
<header class="dropdown-header">Subscriptions</header>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('subscriptions') }}</header>
|
||||
<form id="opml-import-form" enctype="multipart/form-data" tabindex="-1">
|
||||
<input type="file"
|
||||
id="opml-import"
|
||||
@@ -84,42 +112,50 @@
|
||||
style="opacity: 0; width: 1px; height: 0; position: absolute; z-index: -1;">
|
||||
<label class="dropdown-item mb-0 cursor-pointer" for="opml-import" @click.stop="">
|
||||
<span class="icon mr-1">{% inline "download.svg" %}</span>
|
||||
Import
|
||||
{{ $t('import') }}
|
||||
</label>
|
||||
</form>
|
||||
<a class="dropdown-item" href="./opml/export">
|
||||
<span class="icon mr-1">{% inline "upload.svg" %}</span>
|
||||
Export
|
||||
{{ $t('export') }}
|
||||
</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item" @click="showSettings('shortcuts')">
|
||||
<span class="icon mr-1">{% inline "help-circle.svg" %}</span>
|
||||
Shortcuts
|
||||
{{ $t('shortcuts') }}
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">ᚨ / 𐎠 / 𑖀</header>
|
||||
<button
|
||||
v-for="lang in languages"
|
||||
class="dropdown-item"
|
||||
:class="{active: language==lang.code}"
|
||||
@click.stop="changeLanguage(lang.code)">
|
||||
{{ lang.name }}
|
||||
</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
|
||||
{{ $t('log_out') }}
|
||||
</button>
|
||||
</dropdown>
|
||||
</div>
|
||||
<div id="feed-list-scroll" class="p-2 overflow-auto border-top flex-grow-1">
|
||||
<div id="feed-list-scroll" class="p-2 overflow-auto scroll-touch border-top flex-grow-1">
|
||||
<label class="selectgroup">
|
||||
<input type="radio" name="feed" value="" v-model="feedSelected">
|
||||
<div class="selectgroup-label d-flex align-items-center w-100">
|
||||
<span class="icon mr-2">{% inline "layers.svg" %}</span>
|
||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='unread'">All Unread</span>
|
||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='starred'">All Starred</span>
|
||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected==''">All Feeds</span>
|
||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='unread'">{{ $t('all_unread') }}</span>
|
||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='starred'">{{ $t('all_starred') }}</span>
|
||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected==''">{{ $t('all_feeds') }}</span>
|
||||
<span class="counter text-right">{{ filteredTotalStats }}</span>
|
||||
</div>
|
||||
</label>
|
||||
<div v-for="folder in foldersWithFeeds">
|
||||
<label class="selectgroup mt-1"
|
||||
:class="{'d-none': filterSelected
|
||||
&& !filteredFolderStats[folder.id]
|
||||
&& (!itemSelectedDetails || feedsById[itemSelectedDetails.feed_id].folder_id != folder.id)}">
|
||||
<input type="radio" name="feed" :value="'folder:'+folder.id" v-model="feedSelected">
|
||||
:class="{'d-none': mustHideFolder(folder)}"
|
||||
v-if="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}"
|
||||
@@ -132,9 +168,7 @@
|
||||
</label>
|
||||
<div v-show="!folder.id || folder.is_expanded" class="mt-1" :class="{'pl-3': folder.id}">
|
||||
<label class="selectgroup"
|
||||
:class="{'d-none': filterSelected
|
||||
&& !filteredFeedStats[feed.id]
|
||||
&& (!itemSelectedDetails || itemSelectedDetails.feed_id != feed.id)}"
|
||||
:class="{'d-none': mustHideFeed(feed)}"
|
||||
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">
|
||||
@@ -154,7 +188,7 @@
|
||||
</div>
|
||||
<div class="p-2 toolbar d-flex align-items-center border-top flex-shrink-0" v-if="loading.feeds">
|
||||
<span class="icon loading mx-2"></span>
|
||||
<span class="text-truncate cursor-default noselect">Refreshing ({{ loading.feeds }} left)</span>
|
||||
<span class="text-truncate cursor-default noselect">{{ $t('refreshing') }} ({{ loading.feeds }} {{ $t('left') }})</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- item list -->
|
||||
@@ -163,7 +197,7 @@
|
||||
<div class="px-2 toolbar d-flex align-items-center">
|
||||
<button class="toolbar-item mr-2 d-block d-md-none"
|
||||
@click="feedSelected = null"
|
||||
title="Show Feeds">
|
||||
:title="$t('show_feeds')">
|
||||
<span class="icon">{% inline "chevron-left.svg" %}</span>
|
||||
</button>
|
||||
<div class="input-icon flex-grow-1">
|
||||
@@ -174,33 +208,42 @@
|
||||
<button class="toolbar-item ml-2"
|
||||
@click="markItemsRead()"
|
||||
v-if="filterSelected == 'unread'"
|
||||
title="Mark All Read">
|
||||
:title="$t('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="!filterSelected && current.type == 'feed'">
|
||||
:title="$t('feed_settings')"
|
||||
v-if="current.type == 'feed'">
|
||||
<template v-slot:button>
|
||||
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
||||
</template>
|
||||
<header class="dropdown-header">{{ current.feed.title }}</header>
|
||||
<a class="dropdown-item" :href="current.feed.link" target="_blank" v-if="current.feed.link">
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ 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
|
||||
{{ $t('website') }}
|
||||
</a>
|
||||
<a class="dropdown-item" :href="current.feed.feed_link" target="_blank" v-if="current.feed.feed_link">
|
||||
<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
|
||||
{{ $t('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
|
||||
{{ $t('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>
|
||||
{{ $t('change_link') }}
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<header class="dropdown-header">Move to...</header>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('move_to') }}</header>
|
||||
<button class="dropdown-item"
|
||||
v-if="folder.id != current.feed.folder_id"
|
||||
v-for="folder in folders"
|
||||
@@ -214,53 +257,53 @@
|
||||
</button>
|
||||
<button class="dropdown-item text-muted" @click="moveFeedToNewFolder(current.feed)">
|
||||
<span class="icon mr-1">{% inline "folder-plus.svg" %}</span>
|
||||
new folder
|
||||
{{ $t('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
|
||||
{{ $t('delete') }}
|
||||
</button>
|
||||
</dropdown>
|
||||
<dropdown class="settings-dropdown"
|
||||
toggle-class="btn btn-link toolbar-item px-2 ml-2"
|
||||
title="Folder Settings"
|
||||
:title="$t('folder_settings')"
|
||||
drop="right"
|
||||
v-if="!filterSelected && current.type == 'folder'">
|
||||
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>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ current.folder.title }}</header>
|
||||
<button class="dropdown-item" @click="renameFolder(current.folder)">
|
||||
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
||||
Rename
|
||||
{{ $t('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
|
||||
{{ $t('delete') }}
|
||||
</button>
|
||||
</dropdown>
|
||||
</div>
|
||||
<div id="item-list-scroll" class="p-2 overflow-auto border-top flex-grow-1" v-scroll="loadMoreItems" ref="itemlist">
|
||||
<div id="item-list-scroll" class="p-2 overflow-auto scroll-touch border-top flex-grow-1" v-scroll="loadMoreItems" ref="itemlist">
|
||||
<label v-for="item in items" :key="item.id"
|
||||
class="selectgroup">
|
||||
<input type="radio" name="item" :value="item.id" v-model="itemSelected">
|
||||
<div class="selectgroup-label d-flex flex-column">
|
||||
<div style="line-height: 1; opacity: .7; margin-bottom: .1rem;" class="d-flex align-items-center">
|
||||
<div style="line-height: 100%; opacity: .7; margin-bottom: .1rem;" class="d-flex align-items-center">
|
||||
<transition name="indicator">
|
||||
<span class="icon icon-small mr-1" v-if="item.status=='unread'">{% inline "circle-full.svg" %}</span>
|
||||
<span class="icon icon-small mr-1" v-if="item.status=='starred'">{% inline "star-full.svg" %}</span>
|
||||
</transition>
|
||||
<small class="flex-fill text-truncate mr-1">
|
||||
{{ feedsById[item.feed_id].title }}
|
||||
{{ (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>{{ item.title || $t('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] }}
|
||||
@@ -271,32 +314,24 @@
|
||||
<div class="toolbar px-2 d-flex align-items-center" v-if="itemSelectedDetails">
|
||||
<button class="toolbar-item"
|
||||
@click="toggleItemStarred(itemSelectedDetails)"
|
||||
title="Mark Starred">
|
||||
:title="$t('mark_starred')">
|
||||
<span class="icon" v-if="itemSelectedDetails.status=='starred'" >{% inline "star-full.svg" %}</span>
|
||||
<span class="icon" v-else-if="itemSelectedDetails.status!='starred'" >{% inline "star.svg" %}</span>
|
||||
</button>
|
||||
<button class="toolbar-item"
|
||||
title="Mark Unread"
|
||||
:title="$t('mark_unread')"
|
||||
@click="toggleItemRead(itemSelectedDetails)">
|
||||
<span class="icon" v-if="itemSelectedDetails.status=='unread'">{% inline "circle-full.svg" %}</span>
|
||||
<span class="icon" v-if="itemSelectedDetails.status!='unread'">{% inline "circle.svg" %}</span>
|
||||
</button>
|
||||
<dropdown class="settings-dropdown" toggle-class="toolbar-item px-2" drop="center" title="Appearance">
|
||||
<dropdown class="settings-dropdown" toggle-class="toolbar-item px-2" drop="center" :title="$t('appearance')">
|
||||
<template v-slot:button>
|
||||
<span class="icon">{% inline "sliders.svg" %}</span>
|
||||
</template>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<button class="dropdown-item" :class="{active: !theme.font}" @click.stop="theme.font = ''">{{ $t('sans_serif') }}</button>
|
||||
<button class="dropdown-item font-serif" :class="{active: theme.font == 'serif'}" @click.stop="theme.font = 'serif'">{{ $t('serif') }}</button>
|
||||
<button class="dropdown-item font-monospace" :class="{active: theme.font == 'monospace'}" @click.stop="theme.font = 'monospace'">{{ $t('monospace') }}</button>
|
||||
|
||||
<div class="d-flex text-center">
|
||||
<button class="dropdown-item" style="font-size: 0.8rem" @click.stop="incrFont(-1)">A</button>
|
||||
@@ -306,33 +341,51 @@
|
||||
<button class="toolbar-item"
|
||||
:class="{active: itemSelectedReadability}"
|
||||
@click="toggleReadability()"
|
||||
title="Read Here">
|
||||
:title="$t('read_here')">
|
||||
<span class="icon" :class="{'icon-loading': loading.readability}">{% inline "book-open.svg" %}</span>
|
||||
</button>
|
||||
<a class="toolbar-item" :href="itemSelectedDetails.link" target="_blank" title="Open Link">
|
||||
<a class="toolbar-item" :href="itemSelectedDetails.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" :title="$t('open_link')">
|
||||
<span class="icon">{% inline "external-link.svg" %}</span>
|
||||
</a>
|
||||
<div class="flex-grow-1"></div>
|
||||
<button class="toolbar-item" @click="itemSelected=null" title="Close Article">
|
||||
<button class="toolbar-item" @click="navigateToItem(-1)" :title="$t('previous_article')" :disabled="!items.length || itemSelected == items[0].id">
|
||||
<span class="icon">{% inline "chevron-left.svg" %}</span>
|
||||
</button>
|
||||
<button class="toolbar-item" @click="navigateToItem(+1)" :title="$t('next_article')" :disabled="!items.length || itemSelected == items[items.length - 1].id">
|
||||
<span class="icon">{% inline "chevron-right.svg" %}</span>
|
||||
</button>
|
||||
<button class="toolbar-item" @click="itemSelected=null" :title="$t('close_article')">
|
||||
<span class="icon">{% inline "x.svg" %}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="itemSelectedDetails"
|
||||
ref="content"
|
||||
class="content px-4 pt-3 pb-5 border-top overflow-auto"
|
||||
class="content px-4 pt-3 pb-5 border-top overflow-auto scroll-touch"
|
||||
:class="{'font-serif': theme.font == 'serif', 'font-monospace': theme.font == 'monospace'}"
|
||||
:style="{'font-size': theme.size + 'rem'}">
|
||||
<h1><b>{{ itemSelectedDetails.title || 'untitled' }}</b></h1>
|
||||
<div class="text-muted">
|
||||
<div>{{ feedsById[itemSelectedDetails.feed_id].title }}</div>
|
||||
<time>{{ formatDate(itemSelectedDetails.date) }}</time>
|
||||
<div class="content-wrapper">
|
||||
<h1><b>{{ itemSelectedDetails.title || $t('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">
|
||||
<div v-if="contentImages.length">
|
||||
<figure v-for="media in contentImages">
|
||||
<img :src="media.url" loading="lazy">
|
||||
<figcaption v-if="media.description">{{ media.description }}</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<audio class="w-100" controls v-for="media in contentAudios" :src="media.url"></audio>
|
||||
<video class="w-100" controls v-for="media in contentVideos" :src="media.url"></video>
|
||||
</div>
|
||||
<div v-html="itemSelectedContent"></div>
|
||||
</div>
|
||||
<hr>
|
||||
<div v-if="!itemSelectedReadability">
|
||||
<img :src="itemSelectedDetails.image" v-if="itemSelectedDetails.image" class="mb-3">
|
||||
<audio class="w-100" controls v-if="itemSelectedDetails.podcast_url" :src="itemSelectedDetails.podcast_url"></audio>
|
||||
</div>
|
||||
<div v-html="itemSelectedContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
<modal :open="!!settings" @hide="settings = ''">
|
||||
@@ -340,22 +393,22 @@
|
||||
<span class="icon">{% inline "x.svg" %}</span>
|
||||
</button>
|
||||
<div v-if="settings=='create'">
|
||||
<p class="cursor-default"><b>New Feed</b></p>
|
||||
<p class="cursor-default"><b>{{ $t('new_feed') }}</b></p>
|
||||
<form action="" @submit.prevent="createFeed(event)" class="mt-4">
|
||||
<label for="feed-url">URL</label>
|
||||
<input id="feed-url" name="url" type="url" class="form-control" required autocomplete="off" :readonly="feedNewChoice.length > 0">
|
||||
<label for="feed-url">{{ $t('url') }}</label>
|
||||
<input id="feed-url" name="url" type="url" class="form-control" required autocomplete="off" :readonly="feedNewChoice.length > 0" placeholder="https://example.com/feed" v-focus>
|
||||
<label for="feed-folder" class="mt-3 d-block">
|
||||
Folder
|
||||
<a href="#" class="float-right text-decoration-none" @click.prevent="createNewFeedFolder()">new folder</a>
|
||||
{{ $t('folder') }}
|
||||
<a href="#" class="float-right text-decoration-none" @click.prevent="createNewFeedFolder()">{{ $t('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">
|
||||
Multiple feeds found. Choose one below:
|
||||
<a href="#" class="float-right text-decoration-none" @click.prevent="resetFeedChoice()">cancel</a>
|
||||
{{ $t('multiple_feeds_found') }}
|
||||
<a href="#" class="float-right text-decoration-none" @click.prevent="resetFeedChoice()">{{ $t('cancel') }}</a>
|
||||
</p>
|
||||
<label class="selectgroup" v-for="choice in feedNewChoice">
|
||||
<input type="radio" name="feedToAdd" :value="choice.url" v-model="feedNewChoiceSelected">
|
||||
@@ -365,28 +418,29 @@
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<button class="btn btn-block btn-default mt-3" :class="{loading: loading.newfeed}" type="submit">Add</button>
|
||||
<button class="btn btn-block btn-default mt-3" :class="{loading: loading.newfeed}" type="submit">{{ $t('add') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
<div v-else-if="settings=='shortcuts'">
|
||||
<p class="cursor-default"><b>Keyboard Shortcuts</b></p>
|
||||
<p class="cursor-default"><b>{{ $t('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>
|
||||
<td>{{ $t('kb_show_filters') }}</td></tr>
|
||||
<tr><td><kbd>/</kbd></td> <td>{{ $t('kb_focus_search') }}</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>{{ $t('kb_next_prev_article') }}</td></tr>
|
||||
<tr><td><kbd>l</kbd> <kbd>h</kbd></td> <td>{{ $t('kb_next_prev_feed') }}</td></tr>
|
||||
<tr><td><kbd>q</kbd></td> <td>{{ $t('kb_close_article') }}</td></tr>
|
||||
|
||||
<tr><td colspan=2> </td></tr>
|
||||
<tr><td><kbd>R</kbd></td> <td>mark all read</td></tr>
|
||||
<tr><td><kbd>r</kbd></td> <td>mark read / unread</td></tr>
|
||||
<tr><td><kbd>s</kbd></td> <td>mark starred / unstarred</td></tr>
|
||||
<tr><td><kbd>o</kbd></td> <td>open link</td></tr>
|
||||
<tr><td><kbd>i</kbd></td> <td>read here</td> </tr>
|
||||
<tr><td><kbd>f</kbd> <kbd>b</kbd></td> <td>scroll content forward / backward</td>
|
||||
<tr><td><kbd>R</kbd></td> <td>{{ $t('kb_mark_all_read') }}</td></tr>
|
||||
<tr><td><kbd>r</kbd></td> <td>{{ $t('kb_mark_read') }}</td></tr>
|
||||
<tr><td><kbd>s</kbd></td> <td>{{ $t('kb_mark_starred') }}</td></tr>
|
||||
<tr><td><kbd>o</kbd></td> <td>{{ $t('kb_open_link') }}</td></tr>
|
||||
<tr><td><kbd>i</kbd></td> <td>{{ $t('kb_read_here') }}</td> </tr>
|
||||
<tr><td><kbd>f</kbd> <kbd>b</kbd></td> <td>{{ $t('kb_scroll_content') }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
@@ -395,6 +449,7 @@
|
||||
<!-- external -->
|
||||
<script src="./static/javascripts/vue.min.js"></script>
|
||||
<!-- internal -->
|
||||
<script src="./static/javascripts/i18n.js"></script>
|
||||
<script src="./static/javascripts/api.js"></script>
|
||||
<script src="./static/javascripts/app.js"></script>
|
||||
<script src="./static/javascripts/key.js"></script>
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
return api('post', './logout')
|
||||
},
|
||||
crawl: function(url) {
|
||||
return api('get', './page?url=' + url).then(json)
|
||||
return api('get', './page?url=' + encodeURIComponent(url)).then(json)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -2,6 +2,26 @@
|
||||
|
||||
var TITLE = document.title
|
||||
|
||||
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 debounce = function(callback, wait) {
|
||||
var timeout
|
||||
return function() {
|
||||
@@ -21,6 +41,12 @@ Vue.directive('scroll', {
|
||||
},
|
||||
})
|
||||
|
||||
Vue.directive('focus', {
|
||||
inserted: function(el) {
|
||||
el.focus()
|
||||
}
|
||||
})
|
||||
|
||||
Vue.component('drag', {
|
||||
props: ['width'],
|
||||
template: '<div class="drag"></div>',
|
||||
@@ -47,13 +73,13 @@ Vue.component('drag', {
|
||||
})
|
||||
|
||||
Vue.component('dropdown', {
|
||||
props: ['class', 'toggle-class', 'ref', 'drop'],
|
||||
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"><slot name="button"></slot></button>
|
||||
<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>
|
||||
`,
|
||||
@@ -176,15 +202,19 @@ Vue.component('relative-time', {
|
||||
},
|
||||
})
|
||||
|
||||
Vue.use(i18n)
|
||||
|
||||
var vm = new Vue({
|
||||
created: function() {
|
||||
this.refreshStats()
|
||||
.then(this.refreshFeeds.bind(this))
|
||||
.then(this.refreshItems.bind(this))
|
||||
.then(this.refreshItems.bind(this, false))
|
||||
|
||||
api.feeds.list_errors().then(function(errors) {
|
||||
vm.feed_errors = errors
|
||||
})
|
||||
this.updateMetaTheme(app.settings.theme_name)
|
||||
this.$setLang(app.settings.language)
|
||||
},
|
||||
data: function() {
|
||||
var s = app.settings
|
||||
@@ -197,10 +227,7 @@ var vm = new Vue({
|
||||
'feedNewChoice': [],
|
||||
'feedNewChoiceSelected': '',
|
||||
'items': [],
|
||||
'itemsPage': {
|
||||
'cur': 1,
|
||||
'num': 1,
|
||||
},
|
||||
'itemsHasMore': true,
|
||||
'itemSelected': null,
|
||||
'itemSelectedDetails': null,
|
||||
'itemSelectedReadability': '',
|
||||
@@ -226,9 +253,32 @@ var vm = new Vue({
|
||||
'font': s.theme_font,
|
||||
'size': s.theme_size,
|
||||
},
|
||||
'themeColors': {
|
||||
'night': '#0e0e0e',
|
||||
'sepia': '#f4f0e5',
|
||||
'light': '#fff',
|
||||
},
|
||||
'refreshRate': s.refresh_rate,
|
||||
'authenticated': app.authenticated,
|
||||
'feed_errors': {},
|
||||
|
||||
'refreshRateOptions': [
|
||||
{ title: "0", value: 0 },
|
||||
{ title: "10m", value: 10 },
|
||||
{ title: "30m", value: 30 },
|
||||
{ title: "1h", value: 60 },
|
||||
{ title: "2h", value: 120 },
|
||||
{ title: "4h", value: 240 },
|
||||
{ title: "12h", value: 720 },
|
||||
{ title: "24h", value: 1440 },
|
||||
],
|
||||
|
||||
'language': s.language,
|
||||
'languages': [
|
||||
{code: 'en', name: 'English' },
|
||||
{code: 'zh', name: '简体中文'},
|
||||
{code: 'ru', name: 'Русский'},
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -275,11 +325,28 @@ var vm = new Vue({
|
||||
|
||||
return this.itemSelectedDetails.content || ''
|
||||
},
|
||||
contentImages: function() {
|
||||
if (!this.itemSelectedDetails) return []
|
||||
return (this.itemSelectedDetails.media_links || []).filter(l => l.type === 'image')
|
||||
},
|
||||
contentAudios: function() {
|
||||
if (!this.itemSelectedDetails) return []
|
||||
return (this.itemSelectedDetails.media_links || []).filter(l => l.type === 'audio')
|
||||
},
|
||||
contentVideos: function() {
|
||||
if (!this.itemSelectedDetails) return []
|
||||
return (this.itemSelectedDetails.media_links || []).filter(l => l.type === 'video')
|
||||
},
|
||||
refreshRateTitle: function () {
|
||||
const entry = this.refreshRateOptions.find(o => o.value === this.refreshRate)
|
||||
return entry ? entry.title : '0'
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'theme': {
|
||||
deep: true,
|
||||
handler: function(theme) {
|
||||
this.updateMetaTheme(theme.name)
|
||||
document.body.classList.value = 'theme-' + theme.name
|
||||
api.settings.update({
|
||||
theme_name: theme.name,
|
||||
@@ -304,14 +371,18 @@ 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))
|
||||
this.itemSelected = null
|
||||
this.items = []
|
||||
this.itemsHasMore = true
|
||||
api.settings.update({filter: newVal}).then(this.refreshItems.bind(this, false))
|
||||
this.computeStats()
|
||||
},
|
||||
'feedSelected': function(newVal, oldVal) {
|
||||
if (oldVal === undefined) return // do nothing, initial setup
|
||||
api.settings.update({feed: newVal}).then(this.refreshItems.bind(this))
|
||||
this.itemSelected = null
|
||||
this.items = []
|
||||
this.itemsHasMore = true
|
||||
api.settings.update({feed: newVal}).then(this.refreshItems.bind(this, false))
|
||||
if (this.$refs.itemlist) this.$refs.itemlist.scrollTop = 0
|
||||
},
|
||||
'itemSelected': function(newVal, oldVal) {
|
||||
@@ -339,7 +410,7 @@ var vm = new Vue({
|
||||
}, 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
|
||||
@@ -355,6 +426,9 @@ var vm = new Vue({
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
updateMetaTheme: function(theme) {
|
||||
document.querySelector("meta[name='theme-color']").content = this.themeColors[theme]
|
||||
},
|
||||
refreshStats: function(loopMode) {
|
||||
return api.status().then(function(data) {
|
||||
if (loopMode && !vm.itemSelected) vm.refreshItems()
|
||||
@@ -404,34 +478,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()
|
||||
@@ -439,6 +532,7 @@ var vm = new Vue({
|
||||
vm.items = []
|
||||
vm.itemsPage = {'cur': 1, 'num': 1}
|
||||
vm.itemSelected = null
|
||||
vm.itemsHasMore = false
|
||||
vm.refreshStats()
|
||||
})
|
||||
},
|
||||
@@ -498,15 +592,20 @@ var vm = new Vue({
|
||||
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) {
|
||||
@@ -518,12 +617,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()
|
||||
})
|
||||
@@ -633,10 +727,7 @@ var vm = new Vue({
|
||||
fetchAllFeeds: function() {
|
||||
if (this.loading.feeds) return
|
||||
api.feeds.refresh().then(function() {
|
||||
// NOTE: this is hacky
|
||||
setTimeout(function() {
|
||||
vm.refreshStats()
|
||||
}, 1000)
|
||||
vm.refreshStats()
|
||||
})
|
||||
},
|
||||
computeStats: function() {
|
||||
@@ -667,6 +758,97 @@ 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
|
||||
const navigationList = this.foldersWithFeeds
|
||||
.filter(folder => !folder.id || !vm.mustHideFolder(folder))
|
||||
.map((folder) => {
|
||||
if (this.mustHideFolder(folder)) return []
|
||||
const folds = folder.id ? [`folder:${folder.id}`] : []
|
||||
const feeds = (folder.is_expanded || !folder.id)
|
||||
? (folder.feeds || []).filter(f => !vm.mustHideFeed(f)).map(f => `feed:${f.id}`)
|
||||
: []
|
||||
return folds.concat(feeds)
|
||||
})
|
||||
.flat()
|
||||
navigationList.unshift('')
|
||||
|
||||
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)
|
||||
})
|
||||
},
|
||||
changeRefreshRate: function(offset) {
|
||||
const curIdx = this.refreshRateOptions.findIndex(o => o.value === this.refreshRate)
|
||||
if (curIdx <= 0 && offset < 0) return
|
||||
if (curIdx >= (this.refreshRateOptions.length - 1) && offset > 0) return
|
||||
this.refreshRate = this.refreshRateOptions[curIdx + offset].value
|
||||
},
|
||||
mustHideFolder: function (folder) {
|
||||
return this.filterSelected
|
||||
&& !(this.current.folder.id == folder.id || this.current.feed.folder_id == folder.id)
|
||||
&& !this.filteredFolderStats[folder.id]
|
||||
&& (!this.itemSelectedDetails || (this.feedsById[this.itemSelectedDetails.feed_id] || {}).folder_id != folder.id)
|
||||
},
|
||||
mustHideFeed: function (feed) {
|
||||
return this.filterSelected
|
||||
&& !(this.current.feed.id == feed.id)
|
||||
&& !this.filteredFeedStats[feed.id]
|
||||
&& (!this.itemSelectedDetails || this.itemSelectedDetails.feed_id != feed.id)
|
||||
},
|
||||
changeLanguage(lang) {
|
||||
this.$setLang(lang)
|
||||
this.language = lang
|
||||
api.settings.update({language: lang})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
372
src/assets/javascripts/i18n.js
Normal file
@@ -0,0 +1,372 @@
|
||||
(function (exports) {
|
||||
const translations = {
|
||||
"unread": {
|
||||
"en": "Unread",
|
||||
"zh": "未读",
|
||||
"ru": "Непрочитанные"
|
||||
},
|
||||
"starred": {
|
||||
"en": "Starred",
|
||||
"zh": "星标",
|
||||
"ru": "Избранные"
|
||||
},
|
||||
"all": {
|
||||
"en": "All",
|
||||
"zh": "全部",
|
||||
"ru": "Все"
|
||||
},
|
||||
"settings": {
|
||||
"en": "Settings",
|
||||
"zh": "设置",
|
||||
"ru": "Настройки"
|
||||
},
|
||||
"new_feed": {
|
||||
"en": "New Feed",
|
||||
"zh": "新建订阅",
|
||||
"ru": "Новая лента"
|
||||
},
|
||||
"refresh_feeds": {
|
||||
"en": "Refresh Feeds",
|
||||
"zh": "刷新订阅",
|
||||
"ru": "Обновить ленты"
|
||||
},
|
||||
"theme": {
|
||||
"en": "Theme",
|
||||
"zh": "主题",
|
||||
"ru": "Тема"
|
||||
},
|
||||
"auto_refresh": {
|
||||
"en": "Auto Refresh",
|
||||
"zh": "自动刷新",
|
||||
"ru": "Автообновление"
|
||||
},
|
||||
"show_first": {
|
||||
"en": "Show first",
|
||||
"zh": "优先显示",
|
||||
"ru": "Сначала"
|
||||
},
|
||||
"new": {
|
||||
"en": "New",
|
||||
"zh": "最新",
|
||||
"ru": "Новые"
|
||||
},
|
||||
"old": {
|
||||
"en": "Old",
|
||||
"zh": "最旧",
|
||||
"ru": "Старые"
|
||||
},
|
||||
"subscriptions": {
|
||||
"en": "Subscriptions",
|
||||
"zh": "订阅管理",
|
||||
"ru": "Подписки"
|
||||
},
|
||||
"import": {
|
||||
"en": "Import",
|
||||
"zh": "导入",
|
||||
"ru": "Импорт"
|
||||
},
|
||||
"export": {
|
||||
"en": "Export",
|
||||
"zh": "导出",
|
||||
"ru": "Экспорт"
|
||||
},
|
||||
"shortcuts": {
|
||||
"en": "Shortcuts",
|
||||
"zh": "快捷键",
|
||||
"ru": "Горячие клавиши"
|
||||
},
|
||||
"log_out": {
|
||||
"en": "Log out",
|
||||
"zh": "登出",
|
||||
"ru": "Выйти"
|
||||
},
|
||||
"all_unread": {
|
||||
"en": "All Unread",
|
||||
"zh": "全部未读",
|
||||
"ru": "Все непрочитанные"
|
||||
},
|
||||
"all_starred": {
|
||||
"en": "All Starred",
|
||||
"zh": "全部星标",
|
||||
"ru": "Все избранные"
|
||||
},
|
||||
"all_feeds": {
|
||||
"en": "All Feeds",
|
||||
"zh": "全部订阅",
|
||||
"ru": "Все ленты"
|
||||
},
|
||||
"refreshing": {
|
||||
"en": "Refreshing",
|
||||
"zh": "正在刷新",
|
||||
"ru": "Обновление"
|
||||
},
|
||||
"left": {
|
||||
"en": "left",
|
||||
"zh": "剩余",
|
||||
"ru": "осталось"
|
||||
},
|
||||
"show_feeds": {
|
||||
"en": "Show Feeds",
|
||||
"zh": "显示订阅",
|
||||
"ru": "Показать ленты"
|
||||
},
|
||||
"mark_all_read": {
|
||||
"en": "Mark All Read",
|
||||
"zh": "全部标记为已读",
|
||||
"ru": "Отметить все как прочитанные"
|
||||
},
|
||||
"feed_settings": {
|
||||
"en": "Feed Settings",
|
||||
"zh": "订阅设置",
|
||||
"ru": "Настройки ленты"
|
||||
},
|
||||
"folder_settings": {
|
||||
"en": "Folder Settings",
|
||||
"zh": "文件夹设置",
|
||||
"ru": "Настройки папки"
|
||||
},
|
||||
"website": {
|
||||
"en": "Website",
|
||||
"zh": "网站",
|
||||
"ru": "Сайт"
|
||||
},
|
||||
"feed_link": {
|
||||
"en": "Feed Link",
|
||||
"zh": "订阅链接",
|
||||
"ru": "Ссылка на ленту"
|
||||
},
|
||||
"rename": {
|
||||
"en": "Rename",
|
||||
"zh": "重命名",
|
||||
"ru": "Переименовать"
|
||||
},
|
||||
"change_link": {
|
||||
"en": "Change Link",
|
||||
"zh": "修改链接",
|
||||
"ru": "Изменить ссылку"
|
||||
},
|
||||
"move_to": {
|
||||
"en": "Move to...",
|
||||
"zh": "移动到...",
|
||||
"ru": "Переместить в..."
|
||||
},
|
||||
"new_folder": {
|
||||
"en": "new folder",
|
||||
"zh": "新建文件夹",
|
||||
"ru": "новая папка"
|
||||
},
|
||||
"delete": {
|
||||
"en": "Delete",
|
||||
"zh": "删除",
|
||||
"ru": "Удалить"
|
||||
},
|
||||
"mark_starred": {
|
||||
"en": "Mark Starred",
|
||||
"zh": "标记星标",
|
||||
"ru": "Пометить избранным"
|
||||
},
|
||||
"mark_unread": {
|
||||
"en": "Mark Unread",
|
||||
"zh": "标记未读",
|
||||
"ru": "Пометить непрочитанным"
|
||||
},
|
||||
"appearance": {
|
||||
"en": "Appearance",
|
||||
"zh": "外观",
|
||||
"ru": "Внешний вид"
|
||||
},
|
||||
"read_here": {
|
||||
"en": "Read Here",
|
||||
"zh": "在此阅读",
|
||||
"ru": "Читать здесь"
|
||||
},
|
||||
"open_link": {
|
||||
"en": "Open Link",
|
||||
"zh": "打开链接",
|
||||
"ru": "Открыть ссылку"
|
||||
},
|
||||
"previous_article": {
|
||||
"en": "Previous Article",
|
||||
"zh": "上一篇",
|
||||
"ru": "Предыдущая статья"
|
||||
},
|
||||
"next_article": {
|
||||
"en": "Next Article",
|
||||
"zh": "下一篇",
|
||||
"ru": "Следующая статья"
|
||||
},
|
||||
"close_article": {
|
||||
"en": "Close Article",
|
||||
"zh": "关闭文章",
|
||||
"ru": "Закрыть статью"
|
||||
},
|
||||
"untitled": {
|
||||
"en": "untitled",
|
||||
"zh": "无标题",
|
||||
"ru": "без названия"
|
||||
},
|
||||
"sans_serif": {
|
||||
"en": "sans-serif",
|
||||
"zh": "无衬线",
|
||||
"ru": "sans-serif"
|
||||
},
|
||||
"serif": {
|
||||
"en": "serif",
|
||||
"zh": "衬线",
|
||||
"ru": "serif"
|
||||
},
|
||||
"monospace": {
|
||||
"en": "monospace",
|
||||
"zh": "等宽",
|
||||
"ru": "monospace"
|
||||
},
|
||||
"url": {
|
||||
"en": "URL",
|
||||
"zh": "网址",
|
||||
"ru": "URL"
|
||||
},
|
||||
"folder": {
|
||||
"en": "Folder",
|
||||
"zh": "文件夹",
|
||||
"ru": "Папка"
|
||||
},
|
||||
"add": {
|
||||
"en": "Add",
|
||||
"zh": "添加",
|
||||
"ru": "Добавить"
|
||||
},
|
||||
"keyboard_shortcuts": {
|
||||
"en": "Keyboard Shortcuts",
|
||||
"zh": "键盘快捷键",
|
||||
"ru": "Горячие клавиши"
|
||||
},
|
||||
"multiple_feeds_found": {
|
||||
"en": "Multiple feeds found. Choose one below:",
|
||||
"zh": "找到多个订阅源,请选择一个:",
|
||||
"ru": "Найдено несколько лент. Выберите одну:"
|
||||
},
|
||||
"cancel": {
|
||||
"en": "cancel",
|
||||
"zh": "取消",
|
||||
"ru": "отмена"
|
||||
},
|
||||
"kb_show_filters": {
|
||||
"en": "show unread / starred / all feeds",
|
||||
"zh": "显示未读/星标/全部订阅",
|
||||
"ru": "показать непрочитанные / избранные / все ленты"
|
||||
},
|
||||
"kb_focus_search": {
|
||||
"en": "focus the search bar",
|
||||
"zh": "聚焦搜索栏",
|
||||
"ru": "фокус на строку поиска"
|
||||
},
|
||||
"kb_next_prev_article": {
|
||||
"en": "next / prev article",
|
||||
"zh": "下一篇/上一篇文章",
|
||||
"ru": "следующая / предыдущая статья"
|
||||
},
|
||||
"kb_next_prev_feed": {
|
||||
"en": "next / prev feed",
|
||||
"zh": "下一个/上一个订阅",
|
||||
"ru": "следующая / предыдущая лента"
|
||||
},
|
||||
"kb_close_article": {
|
||||
"en": "close article",
|
||||
"zh": "关闭文章",
|
||||
"ru": "закрыть статью"
|
||||
},
|
||||
"kb_mark_all_read": {
|
||||
"en": "mark all read",
|
||||
"zh": "全部标记为已读",
|
||||
"ru": "отметить все как прочитанные"
|
||||
},
|
||||
"kb_mark_read": {
|
||||
"en": "mark read / unread",
|
||||
"zh": "标记已读/未读",
|
||||
"ru": "отметить как прочитанное / непрочитанное"
|
||||
},
|
||||
"kb_mark_starred": {
|
||||
"en": "mark starred / unstarred",
|
||||
"zh": "标记星标/取消星标",
|
||||
"ru": "пометить избранным / убрать из избранного"
|
||||
},
|
||||
"kb_open_link": {
|
||||
"en": "open link",
|
||||
"zh": "打开链接",
|
||||
"ru": "открыть ссылку"
|
||||
},
|
||||
"kb_read_here": {
|
||||
"en": "read here",
|
||||
"zh": "在此阅读",
|
||||
"ru": "читать здесь"
|
||||
},
|
||||
"kb_scroll_content": {
|
||||
"en": "scroll content forward / backward",
|
||||
"zh": "向前/向后滚动内容",
|
||||
"ru": "прокрутка вперед / назад"
|
||||
},
|
||||
"prompt_folder_name": {
|
||||
"en": "Enter folder name:",
|
||||
"zh": "请输入文件夹名称:",
|
||||
"ru": "Введите имя папки:"
|
||||
},
|
||||
"prompt_new_title": {
|
||||
"en": "Enter new title",
|
||||
"zh": "请输入新标题",
|
||||
"ru": "Введите новый заголовок"
|
||||
},
|
||||
"prompt_feed_link": {
|
||||
"en": "Enter feed link",
|
||||
"zh": "请输入订阅链接",
|
||||
"ru": "Введите ссылку на ленту"
|
||||
},
|
||||
"confirm_delete_folder": {
|
||||
"en": "Are you sure you want to delete",
|
||||
"zh": "确定要删除",
|
||||
"ru": "Вы уверены, что хотите удалить"
|
||||
},
|
||||
"confirm_delete_feed": {
|
||||
"en": "Are you sure you want to delete",
|
||||
"zh": "确定要删除",
|
||||
"ru": "Вы уверены, что хотите удалить"
|
||||
},
|
||||
"alert_no_feeds": {
|
||||
"en": "No feeds found at the given url.",
|
||||
"zh": "在指定的网址未找到订阅源。",
|
||||
"ru": "Лент по данному адресу не найдено."
|
||||
},
|
||||
"login": {
|
||||
"en": "Login",
|
||||
"zh": "登录",
|
||||
"ru": "Вход"
|
||||
},
|
||||
"username": {
|
||||
"en": "Username",
|
||||
"zh": "用户名",
|
||||
"ru": "Имя пользователя"
|
||||
},
|
||||
"password": {
|
||||
"en": "Password",
|
||||
"zh": "密码",
|
||||
"ru": "Пароль"
|
||||
},
|
||||
};
|
||||
class i18n {
|
||||
constructor() {
|
||||
this.lang = 'en'
|
||||
}
|
||||
setLang(lang) {
|
||||
this.lang = lang
|
||||
}
|
||||
$t(code) {
|
||||
return translations[code][this.lang]
|
||||
}
|
||||
}
|
||||
exports.i18n = {
|
||||
install(Vue, opts) {
|
||||
const x = new i18n();
|
||||
Vue.prototype.$t = x.$t
|
||||
Vue.prototype.$setLang = x.setLang
|
||||
}
|
||||
}
|
||||
})(window)
|
||||
@@ -1,79 +1,4 @@
|
||||
function scrollto(target, scroll) {
|
||||
var padding = 10
|
||||
var targetRect = target.getBoundingClientRect()
|
||||
var scrollRect = scroll.getBoundingClientRect()
|
||||
|
||||
// target
|
||||
var relativeOffset = targetRect.y - scrollRect.y
|
||||
var absoluteOffset = relativeOffset + scroll.scrollTop
|
||||
|
||||
if (padding <= relativeOffset && relativeOffset + targetRect.height <= scrollRect.height - padding) return
|
||||
|
||||
var newPos = scroll.scrollTop
|
||||
if (relativeOffset < padding) {
|
||||
newPos = absoluteOffset - padding
|
||||
} else {
|
||||
newPos = absoluteOffset - scrollRect.height + targetRect.height + padding
|
||||
}
|
||||
scroll.scrollTop = Math.round(newPos)
|
||||
}
|
||||
|
||||
var helperFunctions = {
|
||||
// navigation helper, navigate relative to selected item
|
||||
navigateToItem: function(relativePosition) {
|
||||
if (vm.itemSelected == null) {
|
||||
// if no item is selected, select first
|
||||
if (vm.items.length !== 0) vm.itemSelected = vm.items[0].id
|
||||
return
|
||||
}
|
||||
|
||||
var itemPosition = vm.items.findIndex(function(x) { return x.id === vm.itemSelected })
|
||||
if (itemPosition === -1) {
|
||||
if (vm.items.length !== 0) vm.itemSelected = vm.items[0].id
|
||||
return
|
||||
}
|
||||
|
||||
var newPosition = itemPosition + relativePosition
|
||||
if (newPosition < 0 || newPosition >= vm.items.length) return
|
||||
|
||||
vm.itemSelected = vm.items[newPosition].id
|
||||
|
||||
vm.$nextTick(function() {
|
||||
var scroll = document.querySelector('#item-list-scroll')
|
||||
|
||||
var handle = scroll.querySelector('input[type=radio]:checked')
|
||||
var target = handle && handle.parentElement
|
||||
|
||||
if (target && scroll) scrollto(target, scroll)
|
||||
})
|
||||
},
|
||||
// navigation helper, navigate relative to selected feed
|
||||
navigateToFeed: function(relativePosition) {
|
||||
var navigationList = Array.from(document.querySelectorAll('#col-feed-list input[name=feed]'))
|
||||
.filter(function(r) { return r.offsetParent !== null && r.value !== 'folder:null' })
|
||||
.map(function(r) { return r.value })
|
||||
|
||||
var currentFeedPosition = navigationList.indexOf(vm.feedSelected)
|
||||
|
||||
if (currentFeedPosition == -1) {
|
||||
vm.feedSelected = ''
|
||||
return
|
||||
}
|
||||
|
||||
var newPosition = currentFeedPosition+relativePosition
|
||||
if (newPosition < 0 || newPosition >= navigationList.length) return
|
||||
|
||||
vm.feedSelected = navigationList[newPosition]
|
||||
|
||||
vm.$nextTick(function() {
|
||||
var scroll = document.querySelector('#feed-list-scroll')
|
||||
|
||||
var handle = scroll.querySelector('input[type=radio]:checked')
|
||||
var target = handle && handle.parentElement
|
||||
|
||||
if (target && scroll) scrollto(target, scroll)
|
||||
})
|
||||
},
|
||||
scrollContent: function(direction) {
|
||||
var padding = 40
|
||||
var scroll = document.querySelector('.content')
|
||||
@@ -92,7 +17,7 @@ var helperFunctions = {
|
||||
var shortcutFunctions = {
|
||||
openItemLink: function() {
|
||||
if (vm.itemSelectedDetails && vm.itemSelectedDetails.link) {
|
||||
window.open(vm.itemSelectedDetails.link, '_blank')
|
||||
window.open(vm.itemSelectedDetails.link, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
},
|
||||
toggleReadability: function() {
|
||||
@@ -118,16 +43,16 @@ var shortcutFunctions = {
|
||||
document.getElementById("searchbar").focus()
|
||||
},
|
||||
nextItem(){
|
||||
helperFunctions.navigateToItem(+1)
|
||||
vm.navigateToItem(+1)
|
||||
},
|
||||
previousItem() {
|
||||
helperFunctions.navigateToItem(-1)
|
||||
vm.navigateToItem(-1)
|
||||
},
|
||||
nextFeed(){
|
||||
helperFunctions.navigateToFeed(+1)
|
||||
vm.navigateToFeed(+1)
|
||||
},
|
||||
previousFeed() {
|
||||
helperFunctions.navigateToFeed(-1)
|
||||
vm.navigateToFeed(-1)
|
||||
},
|
||||
scrollForward: function() {
|
||||
helperFunctions.scrollContent(+1)
|
||||
@@ -135,6 +60,9 @@ var shortcutFunctions = {
|
||||
scrollBackward: function() {
|
||||
helperFunctions.scrollContent(-1)
|
||||
},
|
||||
closeItem: function () {
|
||||
vm.itemSelected = null
|
||||
},
|
||||
showAll() {
|
||||
vm.filterSelected = ''
|
||||
},
|
||||
@@ -160,11 +88,31 @@ var keybindings = {
|
||||
"h": shortcutFunctions.previousFeed,
|
||||
"f": shortcutFunctions.scrollForward,
|
||||
"b": shortcutFunctions.scrollBackward,
|
||||
"q": shortcutFunctions.closeItem,
|
||||
"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,
|
||||
"KeyQ": shortcutFunctions.closeItem,
|
||||
"Digit1": shortcutFunctions.showUnread,
|
||||
"Digit2": shortcutFunctions.showStarred,
|
||||
"Digit3": shortcutFunctions.showAll,
|
||||
}
|
||||
|
||||
function isTextBox(element) {
|
||||
var tagName = element.tagName.toLowerCase()
|
||||
// Input elements that aren't text
|
||||
@@ -179,10 +127,10 @@ function isTextBox(element) {
|
||||
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) {
|
||||
if (isTextBox(event.target) || event.metaKey || event.ctrlKey || event.altKey) {
|
||||
return
|
||||
}
|
||||
var keybindFunction = keybindings[event.key]
|
||||
var keybindFunction = keybindings[event.key] || codebindings[event.code]
|
||||
if (keybindFunction) {
|
||||
event.preventDefault()
|
||||
keybindFunction()
|
||||
|
||||
@@ -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/icon.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,7 +22,7 @@
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<body class="theme-{% .settings.theme_name %}">
|
||||
<form action="" method="post">
|
||||
<img src="./static/graphicarts/anchor.svg" alt="">
|
||||
{% if .error %}
|
||||
@@ -30,7 +31,7 @@
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input name="username" class="form-control" id="username" autocomplete="off"
|
||||
value="{% if .username %}{% .username %}{% end %}" required>
|
||||
value="{% if .username %}{% .username %}{% end %}" required autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body {
|
||||
html {
|
||||
font-size: 15px !important;
|
||||
}
|
||||
|
||||
body {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
@@ -85,6 +88,10 @@ select.form-control:not([multiple]):not([size]) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.table-compact {
|
||||
color: unset !important;
|
||||
}
|
||||
|
||||
.table-compact tr td:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
@@ -93,6 +100,10 @@ select.form-control:not([multiple]):not([size]) {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.scroll-touch {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* custom elements */
|
||||
|
||||
.font-serif {
|
||||
@@ -160,7 +171,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 {
|
||||
@@ -349,6 +362,11 @@ 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;
|
||||
@@ -360,6 +378,27 @@ select.form-control:not([multiple]):not([size]) {
|
||||
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;
|
||||
@@ -395,6 +434,11 @@ select.form-control:not([multiple]):not([size]) {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* theme: light */
|
||||
|
||||
button.theme-light {
|
||||
@@ -402,11 +446,11 @@ button.theme-light {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -2,6 +2,7 @@ package htmlutil
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Any(els []string, el string, match func(string, string) bool) bool {
|
||||
@@ -31,3 +32,7 @@ func URLDomain(val string) string {
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
func IsAPossibleLink(val string) bool {
|
||||
return strings.HasPrefix(val, "http://") || strings.HasPrefix(val, "https://")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
@@ -61,3 +62,16 @@ func ExtractText(content string) string {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -24,3 +24,21 @@ func TestExtractText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package readability
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
@@ -26,10 +27,16 @@ var (
|
||||
|
||||
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`)
|
||||
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`)
|
||||
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
|
||||
@@ -59,6 +66,9 @@ func ExtractContent(page io.Reader) (string, error) {
|
||||
best = body
|
||||
break
|
||||
}
|
||||
if best == nil {
|
||||
return "", errors.New("failed to extract content")
|
||||
}
|
||||
}
|
||||
//log.Printf("[Readability] TopCandidate: %v", topCandidate)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -58,19 +59,36 @@ func Sanitize(baseURL, input string) string {
|
||||
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 + ">")
|
||||
}
|
||||
|
||||
tagStack = append(tagStack, 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) {
|
||||
@@ -129,7 +147,10 @@ func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([
|
||||
}
|
||||
|
||||
attrNames = append(attrNames, attribute.Key)
|
||||
htmlAttrs = append(htmlAttrs, fmt.Sprintf(`%s="%s"`, attribute.Key, html.EscapeString(value)))
|
||||
htmlAttrs = append(
|
||||
htmlAttrs,
|
||||
fmt.Sprintf(`%s="%s"`, attribute.Key, html.EscapeString(value)),
|
||||
)
|
||||
}
|
||||
|
||||
extraAttrNames, extraHTMLAttributes := getExtraAttributes(tagName)
|
||||
@@ -144,13 +165,27 @@ func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([
|
||||
func getExtraAttributes(tagName string) ([]string, []string) {
|
||||
switch tagName {
|
||||
case "a":
|
||||
return []string{"rel", "target", "referrerpolicy"}, []string{`rel="noopener noreferrer"`, `target="_blank"`, `referrerpolicy="no-referrer"`}
|
||||
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"`}
|
||||
return []string{
|
||||
"sandbox",
|
||||
"loading",
|
||||
}, []string{
|
||||
`sandbox="allow-scripts allow-same-origin allow-popups"`,
|
||||
`loading="lazy"`,
|
||||
}
|
||||
case "img":
|
||||
return []string{"loading"}, []string{`loading="lazy"`}
|
||||
return []string{"loading"}, []string{`loading="lazy"`, `referrerpolicy="no-referrer"`}
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
@@ -191,10 +226,8 @@ func hasRequiredAttributes(tagName string, attributes []string) bool {
|
||||
for element, attrs := range elements {
|
||||
if tagName == element {
|
||||
for _, attribute := range attributes {
|
||||
for _, attr := range attrs {
|
||||
if attr == attribute {
|
||||
return true
|
||||
}
|
||||
if slices.Contains(attrs, attribute) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,13 +284,7 @@ func isValidIframeSource(baseURL, src string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, safeDomain := range whitelist {
|
||||
if safeDomain == domain {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return slices.Contains(whitelist, domain)
|
||||
}
|
||||
|
||||
func getTagAllowList() map[string][]string {
|
||||
@@ -321,13 +348,7 @@ func getTagAllowList() map[string][]string {
|
||||
}
|
||||
|
||||
func inList(needle string, haystack []string) bool {
|
||||
for _, element := range haystack {
|
||||
if element == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return slices.Contains(haystack, needle)
|
||||
}
|
||||
|
||||
func isBlockedTag(tagName string) bool {
|
||||
@@ -337,17 +358,10 @@ func isBlockedTag(tagName string) bool {
|
||||
"style",
|
||||
}
|
||||
|
||||
for _, element := range blacklist {
|
||||
if element == tagName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return slices.Contains(blacklist, tagName)
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
One or more strings separated by commas, indicating possible image sources for the user agent to use.
|
||||
|
||||
Each string is composed of:
|
||||
@@ -355,7 +369,6 @@ Each string is composed of:
|
||||
- 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
|
||||
@@ -417,3 +430,22 @@ func isValidDataAttribute(value string) bool {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -8,10 +8,11 @@ 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)
|
||||
want := `<p>This is a <strong>text</strong> with an image: <img src="http://example.org/" alt="Test" loading="lazy" referrerpolicy="no-referrer">.</p>`
|
||||
have := Sanitize("http://example.org/", input)
|
||||
|
||||
if input != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, input, output)
|
||||
if have != want {
|
||||
t.Errorf("Wrong output: \nwant: %#v\nhave: %#v", want, have)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,31 +28,31 @@ func TestImgWithTextDataURL(t *testing.T) {
|
||||
|
||||
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)
|
||||
want := `<img src="data:image/gif;base64,test" alt="Example" loading="lazy" referrerpolicy="no-referrer">`
|
||||
have := Sanitize("http://example.org/", input)
|
||||
|
||||
if output != expected {
|
||||
t.Errorf(`Wrong output: %s`, output)
|
||||
if have != want {
|
||||
t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
want := `<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" referrerpolicy="no-referrer">`
|
||||
have := Sanitize("http://example.org/", input)
|
||||
|
||||
if output != expected {
|
||||
t.Errorf(`Wrong output: %s`, output)
|
||||
if have != want {
|
||||
t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
want := `<img srcset="data:image/gif;base64,test" src="http://example.org/example-320w.jpg" alt="Example" loading="lazy" referrerpolicy="no-referrer">`
|
||||
have := Sanitize("http://example.org/", input)
|
||||
|
||||
if output != expected {
|
||||
t.Errorf(`Wrong output: %s`, output)
|
||||
if have != want {
|
||||
t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,16 +68,16 @@ func TestSourceWithSrcsetAndMedia(t *testing.T) {
|
||||
|
||||
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)
|
||||
want := `<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" referrerpolicy="no-referrer">`
|
||||
have := Sanitize("http://example.org/", input)
|
||||
|
||||
if output != expected {
|
||||
t.Errorf(`Wrong output: %s`, output)
|
||||
if have != want {
|
||||
t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
|
||||
}
|
||||
}
|
||||
|
||||
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>`
|
||||
input := `<p>This <br> is a <strong>text</strong><br/>.</p>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if input != output {
|
||||
@@ -95,11 +96,11 @@ func TestTable(t *testing.T) {
|
||||
|
||||
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)
|
||||
want := `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" referrerpolicy="no-referrer"/>`
|
||||
have := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
if want != have {
|
||||
t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +164,16 @@ func TestInvalidNestedTag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidIFrame(t *testing.T) {
|
||||
input := `<iframe src="http://example.org/"></iframe>`
|
||||
want := `<iframe src="http://example.org/" sandbox="allow-scripts allow-same-origin allow-popups" loading="lazy"></iframe>`
|
||||
have := Sanitize("http://example.org/", input)
|
||||
|
||||
if want != have {
|
||||
t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidIFrame(t *testing.T) {
|
||||
input := `<iframe src="http://example.org/"></iframe>`
|
||||
expected := ``
|
||||
@@ -175,7 +186,7 @@ func TestInvalidIFrame(t *testing.T) {
|
||||
|
||||
func TestIFrameWithChildElements(t *testing.T) {
|
||||
input := `<iframe src="https://www.youtube.com/"><p>test</p></iframe>`
|
||||
expected := `<iframe src="https://www.youtube.com/" sandbox="allow-scripts allow-same-origin allow-popups" loading="lazy"></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 {
|
||||
@@ -255,7 +266,7 @@ func TestEspaceAttributes(t *testing.T) {
|
||||
|
||||
func TestReplaceIframeURL(t *testing.T) {
|
||||
input := `<iframe src="https://player.vimeo.com/video/123456?title=0&byline=0"></iframe>`
|
||||
expected := `<iframe src="https://player.vimeo.com/video/123456?title=0&byline=0" sandbox="allow-scripts allow-same-origin allow-popups" loading="lazy"></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 {
|
||||
@@ -292,3 +303,13 @@ func TestReplaceStyle(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package scraper
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/nkanaev/yarr/src/content/htmlutil"
|
||||
@@ -21,10 +23,8 @@ func FindFeeds(body string, base string) map[string]string {
|
||||
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
|
||||
}
|
||||
if slices.Contains(linkTypes, t) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -35,6 +35,18 @@ func FindFeeds(body string, base string) map[string]string {
|
||||
link := htmlutil.AbsoluteUrl(href, base)
|
||||
if link != "" {
|
||||
candidates[link] = name
|
||||
|
||||
l, err := url.Parse(link)
|
||||
if err == nil && l.Host == "www.youtube.com" && l.Path == "/feeds/videos.xml" {
|
||||
// https://wiki.archiveteam.org/index.php/YouTube/Technical_details#Playlists
|
||||
channelID, found := strings.CutPrefix(l.Query().Get("channel_id"), "UC")
|
||||
if found {
|
||||
const url string = "https://www.youtube.com/feeds/videos.xml?playlist_id="
|
||||
candidates[url+"UULF"+channelID] = name + " - Videos"
|
||||
candidates[url+"UULV"+channelID] = name + " - Live Streams"
|
||||
candidates[url+"UUSH"+channelID] = name + " - Short videos"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ func VideoIFrame(link string) string {
|
||||
youtubeID := ""
|
||||
if l.Host == "www.youtube.com" && l.Path == "/watch" {
|
||||
youtubeID = l.Query().Get("v")
|
||||
} else if l.Host == "www.youtube.com" && strings.HasPrefix(l.Path, "/shorts/") {
|
||||
youtubeID = strings.TrimPrefix(l.Path, "/shorts/")
|
||||
} else if l.Host == "youtu.be" {
|
||||
youtubeID = strings.TrimLeft(l.Path, "/")
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
106
src/main.go
@@ -1,106 +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() {
|
||||
log.SetOutput(os.Stdout)
|
||||
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
|
||||
|
||||
var addr, db, authfile, certfile, keyfile, basepath 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(&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
|
||||
}
|
||||
|
||||
configPath, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to get config dir: ", err)
|
||||
}
|
||||
|
||||
if db == "" {
|
||||
storagePath := filepath.Join(configPath, "yarr")
|
||||
if err := os.MkdirAll(storagePath, 0755); err != nil {
|
||||
log.Fatal("Failed to create app config dir: ", err)
|
||||
}
|
||||
db = filepath.Join(storagePath, "storage.db")
|
||||
}
|
||||
|
||||
log.Printf("using db file %s", db)
|
||||
|
||||
var username, password string
|
||||
if authfile != "" {
|
||||
f, err := os.Open(authfile)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to open auth file: ", err)
|
||||
}
|
||||
defer f.Close()
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) != 2 {
|
||||
log.Fatalf("Invalid auth: %v (expected `username:password`)", line)
|
||||
}
|
||||
username = parts[0]
|
||||
password = parts[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (certfile != "" || keyfile != "") && (certfile == "" || keyfile == "") {
|
||||
log.Fatalf("Both cert & key files are required")
|
||||
}
|
||||
|
||||
store, err := storage.New(db)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to initialise database: ", err)
|
||||
}
|
||||
|
||||
srv := server.NewServer(store, addr)
|
||||
|
||||
if basepath != "" {
|
||||
srv.BasePath = "/" + strings.Trim(basepath, "/")
|
||||
}
|
||||
|
||||
if certfile != "" && keyfile != "" {
|
||||
srv.CertFile = certfile
|
||||
srv.KeyFile = keyfile
|
||||
}
|
||||
|
||||
if username != "" && password != "" {
|
||||
srv.Username = username
|
||||
srv.Password = password
|
||||
}
|
||||
|
||||
log.Printf("starting server at %s", srv.GetAddr())
|
||||
if open {
|
||||
platform.Open(srv.GetAddr())
|
||||
}
|
||||
platform.Start(srv)
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package parser
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"html"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
@@ -47,6 +46,8 @@ 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
|
||||
}
|
||||
@@ -56,7 +57,7 @@ func (a *atomText) String() string {
|
||||
if a.Type == "xhtml" {
|
||||
data = a.XML
|
||||
}
|
||||
return html.UnescapeString(strings.TrimSpace(data))
|
||||
return strings.TrimSpace(data)
|
||||
}
|
||||
|
||||
func (links atomLinks) First(rel string) string {
|
||||
@@ -81,15 +82,32 @@ func ParseAtom(r io.Reader) (*Feed, error) {
|
||||
SiteURL: firstNonEmpty(srcfeed.Links.First("alternate"), srcfeed.Links.First("")),
|
||||
}
|
||||
for _, srcitem := range srcfeed.Entries {
|
||||
link := firstNonEmpty(srcitem.OrigLink, srcitem.Links.First("alternate"), srcitem.Links.First(""))
|
||||
linkFromID := ""
|
||||
guidFromID := ""
|
||||
if htmlutil.IsAPossibleLink(srcitem.ID) {
|
||||
linkFromID = srcitem.ID
|
||||
guidFromID = srcitem.ID + "::" + srcitem.Updated
|
||||
}
|
||||
|
||||
mediaLinks := srcitem.mediaLinks()
|
||||
|
||||
link := firstNonEmpty(
|
||||
srcitem.OrigLink,
|
||||
srcitem.Links.First("alternate"),
|
||||
srcitem.Links.First(""),
|
||||
linkFromID,
|
||||
)
|
||||
dstfeed.Items = append(dstfeed.Items, Item{
|
||||
GUID: firstNonEmpty(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: "",
|
||||
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(),
|
||||
),
|
||||
MediaLinks: mediaLinks,
|
||||
})
|
||||
}
|
||||
return dstfeed, nil
|
||||
|
||||
@@ -40,13 +40,11 @@ func TestAtom(t *testing.T) {
|
||||
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: "",
|
||||
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>`,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -94,6 +92,44 @@ func TestAtomHTMLTitle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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"?>
|
||||
@@ -103,9 +139,15 @@ func TestAtomImageLink(t *testing.T) {
|
||||
</entry>
|
||||
</feed>
|
||||
`))
|
||||
have := feed.Items[0].ImageURL
|
||||
want := `https://example.com/image.png?width=100&height=100`
|
||||
if want != have {
|
||||
if len(feed.Items[0].MediaLinks) != 1 {
|
||||
t.Fatalf("Expected 1 media link, got: %#v", feed.Items[0].MediaLinks)
|
||||
}
|
||||
have := feed.Items[0].MediaLinks[0]
|
||||
want := MediaLink{
|
||||
URL: `https://example.com/image.png?width=100&height=100`,
|
||||
Type: "image",
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Fatalf("item.image_url doesn't match\nwant: %#v\nhave: %#v\n", want, have)
|
||||
}
|
||||
}
|
||||
@@ -127,7 +169,68 @@ func TestAtomImageLinkDuplicated(t *testing.T) {
|
||||
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")
|
||||
if len(feed.Items[0].MediaLinks) != 0 {
|
||||
t.Fatal("item media link must be excluded 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package parser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -9,15 +10,27 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nkanaev/yarr/src/content/htmlutil"
|
||||
"golang.org/x/net/html/charset"
|
||||
)
|
||||
|
||||
var UnknownFormat = errors.New("unknown feed format")
|
||||
var ErrUnknownFormat = errors.New("unknown feed format")
|
||||
|
||||
type processor func(r io.Reader) (*Feed, error)
|
||||
type feedProbe struct {
|
||||
feedType string
|
||||
callback func(r io.Reader) (*Feed, error)
|
||||
encoding string
|
||||
}
|
||||
|
||||
func sniff(lookup string) (string, processor) {
|
||||
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))
|
||||
@@ -26,24 +39,42 @@ func sniff(lookup string) (string, processor) {
|
||||
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":
|
||||
return "rss", ParseRSS
|
||||
out.feedType = "rss"
|
||||
out.callback = ParseRSS
|
||||
return
|
||||
case "RDF":
|
||||
return "rdf", ParseRDF
|
||||
out.feedType = "rdf"
|
||||
out.callback = ParseRDF
|
||||
return
|
||||
case "feed":
|
||||
return "atom", ParseAtom
|
||||
out.feedType = "atom"
|
||||
out.callback = ParseAtom
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
case '{':
|
||||
return "json", ParseJSON
|
||||
out.feedType = "json"
|
||||
out.callback = ParseJSON
|
||||
return
|
||||
}
|
||||
return "", nil
|
||||
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 {
|
||||
@@ -56,18 +87,43 @@ func Parse(r io.Reader) (*Feed, error) {
|
||||
r = io.MultiReader(bytes.NewReader(lookup), r)
|
||||
}
|
||||
|
||||
_, callback := sniff(string(lookup))
|
||||
if callback == nil {
|
||||
return nil, UnknownFormat
|
||||
out := sniff(string(lookup))
|
||||
if out.feedType == "" {
|
||||
return nil, ErrUnknownFormat
|
||||
}
|
||||
|
||||
feed, err := callback(r)
|
||||
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)
|
||||
@@ -75,14 +131,17 @@ func (feed *Feed) cleanup() {
|
||||
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(item.Title)
|
||||
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 = ""
|
||||
if len(feed.Items[i].MediaLinks) > 0 {
|
||||
mediaLinks := make([]MediaLink, 0)
|
||||
for _, link := range item.MediaLinks {
|
||||
if !strings.Contains(item.Content, link.URL) {
|
||||
mediaLinks = append(mediaLinks, link)
|
||||
}
|
||||
}
|
||||
feed.Items[i].MediaLinks = mediaLinks
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,3 +173,12 @@ func (feed *Feed) TranslateURLs(base string) error {
|
||||
}
|
||||
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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,38 +7,45 @@ import (
|
||||
)
|
||||
|
||||
func TestSniff(t *testing.T) {
|
||||
testcases := [][2]string{
|
||||
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>`,
|
||||
"rdf",
|
||||
feedProbe{feedType: "rdf", callback: ParseRDF},
|
||||
},
|
||||
{
|
||||
`<?xml version="1.0" encoding="ISO-8859-1"?><rss version="2.0"><channel></channel></rss>`,
|
||||
"rss",
|
||||
feedProbe{feedType: "rss", callback: ParseRSS, encoding: "iso-8859-1"},
|
||||
},
|
||||
{
|
||||
`<?xml version="1.0"?><rss version="2.0"><channel></channel></rss>`,
|
||||
"rss",
|
||||
feedProbe{feedType: "rss", callback: ParseRSS},
|
||||
},
|
||||
{
|
||||
`<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>`,
|
||||
"atom",
|
||||
feedProbe{feedType: "atom", callback: ParseAtom, encoding: "utf-8"},
|
||||
},
|
||||
{
|
||||
`{}`,
|
||||
"json",
|
||||
feedProbe{feedType: "json", callback: ParseJSON},
|
||||
},
|
||||
{
|
||||
`<!DOCTYPE html><html><head><title></title></head><body></body></html>`,
|
||||
"",
|
||||
feedProbe{},
|
||||
},
|
||||
}
|
||||
for _, testcase := range testcases {
|
||||
have, _ := sniff(testcase[0])
|
||||
want := testcase[1]
|
||||
if want != have {
|
||||
t.Log(testcase[0])
|
||||
t.Errorf("Invalid format: want=%#v have=%#v", want, have)
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,3 +114,73 @@ func TestParseFeedWithBOM(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
type media struct {
|
||||
MediaGroups []mediaGroup `xml:"http://search.yahoo.com/mrss/ group"`
|
||||
MediaContents []mediaContent `xml:"http://search.yahoo.com/mrss/ content"`
|
||||
@@ -8,12 +12,17 @@ type media struct {
|
||||
}
|
||||
|
||||
type mediaGroup struct {
|
||||
MediaContent []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 mediaContent struct {
|
||||
MediaThumbnails []mediaThumbnail `xml:"http://search.yahoo.com/mrss/ thumbnail"`
|
||||
MediaThumbnails []mediaThumbnail `xml:"http://search.yahoo.com/mrss/ thumbnail"`
|
||||
MediaType string `xml:"type,attr"`
|
||||
MediaMedium string `xml:"medium,attr"`
|
||||
MediaURL string `xml:"url,attr"`
|
||||
MediaDescription mediaDescription `xml:"http://search.yahoo.com/mrss/ description"`
|
||||
}
|
||||
|
||||
type mediaThumbnail struct {
|
||||
@@ -21,35 +30,68 @@ type mediaThumbnail struct {
|
||||
}
|
||||
|
||||
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 ""
|
||||
Type string `xml:"type,attr"`
|
||||
Text string `xml:",chardata"`
|
||||
}
|
||||
|
||||
func (m *media) firstMediaDescription() string {
|
||||
for _, d := range m.MediaDescriptions {
|
||||
return plain2html(d.Description)
|
||||
return plain2html(d.Text)
|
||||
}
|
||||
for _, g := range m.MediaGroups {
|
||||
for _, d := range g.MediaDescriptions {
|
||||
return plain2html(d.Description)
|
||||
return plain2html(d.Text)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *media) mediaLinks() []MediaLink {
|
||||
links := make([]MediaLink, 0)
|
||||
for _, thumbnail := range m.MediaThumbnails {
|
||||
links = append(links, MediaLink{URL: thumbnail.URL, Type: "image"})
|
||||
}
|
||||
for _, group := range m.MediaGroups {
|
||||
for _, thumbnail := range group.MediaThumbnails {
|
||||
links = append(links, MediaLink{
|
||||
URL: thumbnail.URL,
|
||||
Type: "image",
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, content := range m.MediaContents {
|
||||
if content.MediaURL != "" {
|
||||
url := content.MediaURL
|
||||
description := content.MediaDescription.Text
|
||||
if strings.HasPrefix(content.MediaType, "image/") {
|
||||
links = append(links, MediaLink{URL: url, Type: "image", Description: description})
|
||||
} else if strings.HasPrefix(content.MediaType, "audio/") {
|
||||
links = append(links, MediaLink{URL: url, Type: "audio", Description: description})
|
||||
} else if strings.HasPrefix(content.MediaType, "video/") {
|
||||
links = append(links, MediaLink{URL: url, Type: "video", Description: description})
|
||||
} else if content.MediaMedium == "image" || content.MediaMedium == "audio" || content.MediaMedium == "video" {
|
||||
links = append(
|
||||
links,
|
||||
MediaLink{URL: url, Type: content.MediaMedium, Description: description},
|
||||
)
|
||||
} else {
|
||||
if len(content.MediaThumbnails) > 0 {
|
||||
links = append(links, MediaLink{
|
||||
URL: content.MediaThumbnails[0].URL,
|
||||
Type: "image",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, thumbnail := range content.MediaThumbnails {
|
||||
links = append(links, MediaLink{
|
||||
URL: thumbnail.URL,
|
||||
Type: "image",
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(links) == 0 {
|
||||
return nil
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
@@ -14,7 +14,12 @@ type Item struct {
|
||||
URL string
|
||||
Title string
|
||||
|
||||
Content string
|
||||
ImageURL string
|
||||
AudioURL string
|
||||
Content string
|
||||
MediaLinks []MediaLink
|
||||
}
|
||||
|
||||
type MediaLink struct {
|
||||
URL string
|
||||
Type string
|
||||
Description string
|
||||
}
|
||||
|
||||
@@ -42,8 +42,16 @@ func TestRDFFeed(t *testing.T) {
|
||||
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"},
|
||||
{
|
||||
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",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -20,12 +20,12 @@ type rssFeed struct {
|
||||
}
|
||||
|
||||
type rssItem struct {
|
||||
GUID string `xml:"guid"`
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
GUID rssGuid `xml:"rss guid"`
|
||||
Title string `xml:"rss title"`
|
||||
Link string `xml:"rss link"`
|
||||
Description string `xml:"rss description"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
Enclosures []rssEnclosure `xml:"enclosure"`
|
||||
PubDate string `xml:"rss pubDate"`
|
||||
Enclosures []rssEnclosure `xml:"rss enclosure"`
|
||||
|
||||
DublinCoreDate string `xml:"http://purl.org/dc/elements/1.1/ date"`
|
||||
ContentEncoded string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
|
||||
@@ -36,6 +36,11 @@ type rssItem struct {
|
||||
media
|
||||
}
|
||||
|
||||
type rssGuid struct {
|
||||
GUID string `xml:",chardata"`
|
||||
IsPermaLink string `xml:"isPermaLink,attr"`
|
||||
}
|
||||
|
||||
type rssLink struct {
|
||||
XMLName xml.Name
|
||||
Data string `xml:",chardata"`
|
||||
@@ -69,26 +74,40 @@ func ParseRSS(r io.Reader) (*Feed, error) {
|
||||
SiteURL: srcfeed.Link,
|
||||
}
|
||||
for _, srcitem := range srcfeed.Items {
|
||||
podcastURL := ""
|
||||
mediaLinks := srcitem.mediaLinks()
|
||||
for _, e := range srcitem.Enclosures {
|
||||
if e.Type == "audio/mpeg" || e.Type == "audio/x-m4a" {
|
||||
podcastURL = e.URL
|
||||
|
||||
if srcitem.OrigEnclosureLink != "" && strings.Contains(podcastURL, path.Base(srcitem.OrigEnclosureLink)) {
|
||||
if strings.HasPrefix(e.Type, "audio/") {
|
||||
podcastURL := e.URL
|
||||
if srcitem.OrigEnclosureLink != "" &&
|
||||
strings.Contains(podcastURL, path.Base(srcitem.OrigEnclosureLink)) {
|
||||
podcastURL = srcitem.OrigEnclosureLink
|
||||
}
|
||||
mediaLinks = append(mediaLinks, MediaLink{URL: podcastURL, Type: "audio"})
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, e := range srcitem.Enclosures {
|
||||
if strings.HasPrefix(e.Type, "image/") {
|
||||
mediaLinks = append(mediaLinks, MediaLink{URL: e.URL, Type: "image"})
|
||||
}
|
||||
}
|
||||
|
||||
permalink := ""
|
||||
if srcitem.GUID.IsPermaLink == "true" {
|
||||
permalink = srcitem.GUID.GUID
|
||||
}
|
||||
|
||||
dstfeed.Items = append(dstfeed.Items, Item{
|
||||
GUID: firstNonEmpty(srcitem.GUID, srcitem.Link),
|
||||
Date: dateParse(firstNonEmpty(srcitem.DublinCoreDate, srcitem.PubDate)),
|
||||
URL: firstNonEmpty(srcitem.OrigLink, srcitem.Link),
|
||||
Title: srcitem.Title,
|
||||
Content: firstNonEmpty(srcitem.ContentEncoded, srcitem.Description),
|
||||
AudioURL: podcastURL,
|
||||
ImageURL: srcitem.firstMediaThumbnail(),
|
||||
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,
|
||||
srcitem.firstMediaDescription(),
|
||||
),
|
||||
MediaLinks: mediaLinks,
|
||||
})
|
||||
}
|
||||
return dstfeed, nil
|
||||
|
||||
@@ -75,9 +75,15 @@ func TestRSSMediaContentThumbnail(t *testing.T) {
|
||||
</channel>
|
||||
</rss>
|
||||
`))
|
||||
have := feed.Items[0].ImageURL
|
||||
want := "https://i.vimeocdn.com/video/1092705247_960.jpg"
|
||||
if have != want {
|
||||
if len(feed.Items[0].MediaLinks) != 1 {
|
||||
t.Fatalf("Expected 1 media link, got %#v", feed.Items[0].MediaLinks)
|
||||
}
|
||||
have := feed.Items[0].MediaLinks[0]
|
||||
want := MediaLink{
|
||||
URL: "https://i.vimeocdn.com/video/1092705247_960.jpg",
|
||||
Type: "image",
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.FailNow()
|
||||
@@ -127,9 +133,41 @@ func TestRSSPodcast(t *testing.T) {
|
||||
</channel>
|
||||
</rss>
|
||||
`))
|
||||
have := feed.Items[0].AudioURL
|
||||
want := "http://example.com/audio.ext"
|
||||
if want != have {
|
||||
if len(feed.Items[0].MediaLinks) != 1 {
|
||||
t.Fatal("Invalid media links")
|
||||
}
|
||||
have := feed.Items[0].MediaLinks[0]
|
||||
want := MediaLink{
|
||||
URL: "http://example.com/audio.ext",
|
||||
Type: "audio",
|
||||
}
|
||||
if !reflect.DeepEqual(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>
|
||||
`))
|
||||
if len(feed.Items[0].MediaLinks) != 1 {
|
||||
t.Fatal("Invalid media links")
|
||||
}
|
||||
have := feed.Items[0].MediaLinks[0]
|
||||
want := MediaLink{
|
||||
URL: "http://example.com/audio.ext",
|
||||
Type: "audio",
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.FailNow()
|
||||
@@ -156,7 +194,136 @@ func TestRSSPodcastDuplicated(t *testing.T) {
|
||||
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")
|
||||
|
||||
if len(feed.Items[0].MediaLinks) != 0 {
|
||||
t.Fatal("item media must be excluded 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 := range want {
|
||||
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 := range want {
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Errorf("Failed to handle isPermalink\nwant: %#v\nhave: %#v\n", want[i], have[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/nkanaev/yarr/issues/284
|
||||
func TestRSSEnclosureImage(t *testing.T) {
|
||||
feed, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<item>
|
||||
<title>Post with image</title>
|
||||
<link>http://example.com/post/1</link>
|
||||
<enclosure url="http://example.com/photo.jpg" type="image/jpeg" length="123456"/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`))
|
||||
if len(feed.Items[0].MediaLinks) != 1 {
|
||||
t.Fatalf("Expected 1 media link, got %d: %#v", len(feed.Items[0].MediaLinks), feed.Items[0].MediaLinks)
|
||||
}
|
||||
have := feed.Items[0].MediaLinks[0]
|
||||
want := MediaLink{
|
||||
URL: "http://example.com/photo.jpg",
|
||||
Type: "image",
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
func TestRSSMultipleMedia(t *testing.T) {
|
||||
feed, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/">
|
||||
<channel>
|
||||
<item>
|
||||
<guid isPermaLink="true">http://example.com/posts/1</guid>
|
||||
<media:content url="https://example.com/path/to/image1.png" type="image/png" fileSize="1000" medium="image">
|
||||
<media:description type="plain">description 1</media:description>
|
||||
</media:content>
|
||||
<media:content url="https://example.com/path/to/image2.png" type="image/png" fileSize="2000" medium="image">
|
||||
<media:description type="plain">description 2</media:description>
|
||||
</media:content>
|
||||
<media:content url="https://example.com/path/to/video1.mp4" type="video/mp4" fileSize="2000" medium="image">
|
||||
<media:description type="plain">video description</media:description>
|
||||
</media:content>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`))
|
||||
have := feed.Items
|
||||
want := []Item{
|
||||
{
|
||||
GUID: "http://example.com/posts/1",
|
||||
URL: "http://example.com/posts/1",
|
||||
MediaLinks: []MediaLink{
|
||||
{
|
||||
URL: "https://example.com/path/to/image1.png",
|
||||
Type: "image",
|
||||
Description: "description 1",
|
||||
},
|
||||
{
|
||||
URL: "https://example.com/path/to/image2.png",
|
||||
Type: "image",
|
||||
Description: "description 2",
|
||||
},
|
||||
{
|
||||
URL: "https://example.com/path/to/video1.mp4",
|
||||
Type: "video",
|
||||
Description: "video description",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fatal("invalid rss")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"regexp"
|
||||
@@ -30,6 +32,81 @@ func plain2html(text string) string {
|
||||
func xmlDecoder(r io.Reader) *xml.Decoder {
|
||||
decoder := xml.NewDecoder(r)
|
||||
decoder.Strict = false
|
||||
decoder.CharsetReader = charset.NewReaderLabel
|
||||
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 := range want {
|
||||
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)
|
||||
}
|
||||
}
|
||||
14
src/platform/fixconsole_default.go
Normal file
@@ -0,0 +1,14 @@
|
||||
//go: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
|
||||
}
|
||||
136
src/platform/fixconsole_windows.go
Normal file
@@ -0,0 +1,136 @@
|
||||
//go:build windows
|
||||
|
||||
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,15 +1,16 @@
|
||||
// +build macos windows
|
||||
//go:build (darwin || windows) && gui
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"fyne.io/systray"
|
||||
"github.com/nkanaev/yarr/src/server"
|
||||
"github.com/nkanaev/yarr/src/systray"
|
||||
)
|
||||
|
||||
func Start(s *server.Server) {
|
||||
systrayOnReady := func() {
|
||||
systray.SetIcon(Icon)
|
||||
systray.SetTemplateIcon(Icon, Icon)
|
||||
systray.SetTooltip("yarr")
|
||||
|
||||
menuOpen := systray.AddMenuItem("Open", "")
|
||||
systray.AddSeparator()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// +build !windows,!macos
|
||||
//go:build !gui
|
||||
|
||||
package platform
|
||||
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 48 48"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="feather feather-anchor"
|
||||
version="1.1"
|
||||
id="svg905"
|
||||
sodipodi:docname="icon.svg"
|
||||
inkscape:version="1.0.1 (c497b03c, 2020-09-10)"
|
||||
inkscape:export-filename="/Users/nkanaev/Desktop/icon.png"
|
||||
inkscape:export-xdpi="2048"
|
||||
inkscape:export-ydpi="2048">
|
||||
<metadata
|
||||
id="metadata911">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs909">
|
||||
<inkscape:perspective
|
||||
sodipodi:type="inkscape:persp3d"
|
||||
inkscape:vp_x="0 : 24 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_z="48 : 24 : 1"
|
||||
inkscape:persp3d-origin="24 : 16 : 1"
|
||||
id="perspective842" />
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1440"
|
||||
inkscape:window-height="900"
|
||||
id="namedview907"
|
||||
showgrid="false"
|
||||
inkscape:zoom="4.9128436"
|
||||
inkscape:cx="30.960444"
|
||||
inkscape:cy="52.71331"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg905"
|
||||
inkscape:document-rotation="0" />
|
||||
<rect
|
||||
style="fill:#212529;stroke:none;stroke-width:3;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1"
|
||||
id="rect913"
|
||||
width="48"
|
||||
height="48"
|
||||
x="0"
|
||||
y="0"
|
||||
ry="24"
|
||||
rx="0" />
|
||||
<g
|
||||
id="g940"
|
||||
transform="matrix(1.4545455,0,0,1.4545455,6.545454,6.545454)"
|
||||
style="fill:none;stroke:#ffffff;stroke-opacity:1">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="5"
|
||||
id="circle899"
|
||||
r="3"
|
||||
style="fill:none;stroke:#ffffff;stroke-opacity:1" />
|
||||
<line
|
||||
x1="12"
|
||||
y1="22"
|
||||
x2="12"
|
||||
y2="8"
|
||||
id="line901"
|
||||
style="fill:none;stroke:#ffffff;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 5,12 H 2 a 10,10 0 0 0 20,0 h -3"
|
||||
id="path903"
|
||||
style="fill:none;stroke:#ffffff;stroke-opacity:1" />
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.8 KiB |
@@ -1,8 +1,8 @@
|
||||
// +build macos
|
||||
//go:build darwin && gui
|
||||
|
||||
package platform
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed icon.png
|
||||
//go:embed icon_mac.png
|
||||
var Icon []byte
|
||||
|
||||
BIN
src/platform/icon_mac.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
118
src/platform/icon_mac.svg
Normal file
@@ -0,0 +1,118 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 48 48"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="feather feather-anchor"
|
||||
version="1.1"
|
||||
id="svg905"
|
||||
sodipodi:docname="icon_mac.svg"
|
||||
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
|
||||
inkscape:export-filename="icon_mac.png"
|
||||
inkscape:export-xdpi="2048"
|
||||
inkscape:export-ydpi="2048"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<metadata
|
||||
id="metadata911">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs909">
|
||||
<inkscape:perspective
|
||||
sodipodi:type="inkscape:persp3d"
|
||||
inkscape:vp_x="0 : 24 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_z="48 : 24 : 1"
|
||||
inkscape:persp3d-origin="24 : 16 : 1"
|
||||
id="perspective842" />
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1440"
|
||||
inkscape:window-height="895"
|
||||
id="namedview907"
|
||||
showgrid="false"
|
||||
inkscape:zoom="4.9128436"
|
||||
inkscape:cx="31.14286"
|
||||
inkscape:cy="52.718959"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="33"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg905"
|
||||
inkscape:document-rotation="0"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1" />
|
||||
<rect
|
||||
style="display:none;fill:#800000;stroke-width:4;stroke-linecap:round;stroke-dasharray:none"
|
||||
id="rect3"
|
||||
width="89.561165"
|
||||
height="70.427643"
|
||||
x="-21.576099"
|
||||
y="-7.734828"
|
||||
ry="24"
|
||||
inkscape:label="background-test" />
|
||||
<rect
|
||||
style="display:none;fill:#000000;fill-opacity:1;stroke:none;stroke-width:3;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect913"
|
||||
width="48"
|
||||
height="48"
|
||||
x="0"
|
||||
y="0"
|
||||
ry="24"
|
||||
rx="0"
|
||||
inkscape:label="circle" />
|
||||
<path
|
||||
id="rect2"
|
||||
style="fill:#000000;stroke:none;stroke-width:3"
|
||||
inkscape:label="circle-hollow"
|
||||
d="M 24 0 C 10.704 0 0 10.704 0 24 C 0 37.296 10.704 48 24 48 C 37.296 48 48 37.296 48 24 C 48 10.704 37.296 0 24 0 z M 24 7.4550781 C 27.49085 7.4550781 30.363281 10.327509 30.363281 13.818359 C 30.363281 16.611253 28.523046 19.006548 26 19.853516 L 26 36.380859 C 31.271218 35.519062 35.266025 31.300336 36.144531 26 L 34.181641 26 A 2.0000001 2.0000001 0 0 1 32.181641 24 A 2.0000001 2.0000001 0 0 1 34.181641 22 L 38.544922 22 A 2.0002001 2.0002001 0 0 1 40.544922 24 C 40.544922 33.114118 33.114111 40.544922 24 40.544922 C 14.885889 40.544922 7.4550781 33.114118 7.4550781 24 A 2.0002001 2.0002001 0 0 1 9.4550781 22 L 13.818359 22 A 2.0000001 2.0000001 0 0 1 15.818359 24 A 2.0000001 2.0000001 0 0 1 13.818359 26 L 11.855469 26 C 12.733975 31.300336 16.728783 35.519062 22 36.380859 L 22 19.853516 C 19.476954 19.006548 17.636719 16.611253 17.636719 13.818359 C 17.636719 10.327509 20.50915 7.4550781 24 7.4550781 z M 24 11.455078 C 22.670911 11.455078 21.636719 12.48927 21.636719 13.818359 C 21.636719 15.147449 22.670911 16.181641 24 16.181641 C 25.329089 16.181641 26.363281 15.147449 26.363281 13.818359 C 26.363281 12.48927 25.329089 11.455078 24 11.455078 z " />
|
||||
<g
|
||||
id="g1"
|
||||
transform="matrix(1.4545455,0,0,1.4545455,6.545454,6.545454)"
|
||||
style="display:none;fill:none;stroke:#ffffff;stroke-width:2.75;stroke-dasharray:none;stroke-opacity:1"
|
||||
inkscape:label="anchor_backup">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="5"
|
||||
id="circle1"
|
||||
r="3"
|
||||
style="fill:none;stroke:#ffffff;stroke-width:2.75;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<line
|
||||
x1="12"
|
||||
y1="22"
|
||||
x2="12"
|
||||
y2="8"
|
||||
id="line1"
|
||||
style="fill:none;stroke:#ffffff;stroke-width:2.75;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 5,12 H 2 a 10,10 0 0 0 20,0 h -3"
|
||||
id="path1"
|
||||
style="fill:none;stroke:#ffffff;stroke-width:2.75;stroke-dasharray:none;stroke-opacity:1" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
@@ -1,4 +1,4 @@
|
||||
// +build windows
|
||||
//go:build windows && gui
|
||||
|
||||
package platform
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// +build !windows,!darwin
|
||||
//go:build linux || freebsd || openbsd
|
||||
|
||||
package platform
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// +build darwin
|
||||
//go:build darwin
|
||||
|
||||
package platform
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// +build windows
|
||||
//go:build windows
|
||||
|
||||
package platform
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func IsAuthenticated(req *http.Request, username, password string) bool {
|
||||
@@ -24,10 +23,12 @@ func IsAuthenticated(req *http.Request, username, password string) bool {
|
||||
|
||||
func Authenticate(rw http.ResponseWriter, username, password, basepath string) {
|
||||
http.SetCookie(rw, &http.Cookie{
|
||||
Name: "auth",
|
||||
Value: username + ":" + secret(username, password),
|
||||
Expires: time.Now().Add(time.Hour * 24 * 7), // 1 week,
|
||||
Path: basepath,
|
||||
Name: "auth",
|
||||
Value: username + ":" + secret(username, password),
|
||||
MaxAge: 604800, // 1 week
|
||||
Path: basepath,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,23 +6,23 @@ import (
|
||||
|
||||
"github.com/nkanaev/yarr/src/assets"
|
||||
"github.com/nkanaev/yarr/src/server/router"
|
||||
"github.com/nkanaev/yarr/src/storage"
|
||||
)
|
||||
|
||||
type Middleware struct {
|
||||
Username string
|
||||
Password string
|
||||
BasePath string
|
||||
Public string
|
||||
}
|
||||
|
||||
func unsafeMethod(method string) bool {
|
||||
return method == "POST" || method == "PUT" || method == "DELETE"
|
||||
Public []string
|
||||
DB storage.Storage
|
||||
}
|
||||
|
||||
func (m *Middleware) Handler(c *router.Context) {
|
||||
if strings.HasPrefix(c.Req.URL.Path, m.BasePath+m.Public) {
|
||||
c.Next()
|
||||
return
|
||||
for _, path := range m.Public {
|
||||
if strings.HasPrefix(c.Req.URL.Path, m.BasePath+path) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
if IsAuthenticated(c.Req, m.Username, m.Password) {
|
||||
c.Next()
|
||||
@@ -44,12 +44,15 @@ func (m *Middleware) Handler(c *router.Context) {
|
||||
c.Redirect(rootUrl)
|
||||
return
|
||||
} else {
|
||||
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]string{
|
||||
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]any{
|
||||
"username": username,
|
||||
"error": "Invalid username/password",
|
||||
"settings": m.DB.GetSettings().Map(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.HTML(http.StatusOK, assets.Template("login.html"), nil)
|
||||
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]any{
|
||||
"settings": m.DB.GetSettings().Map(),
|
||||
})
|
||||
}
|
||||
|
||||
405
src/server/fever.go
Normal file
@@ -0,0 +1,405 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nkanaev/yarr/src/server/auth"
|
||||
"github.com/nkanaev/yarr/src/server/router"
|
||||
"github.com/nkanaev/yarr/src/storage"
|
||||
"github.com/nkanaev/yarr/src/storage/model"
|
||||
)
|
||||
|
||||
type FeverGroup struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type FeverFeedsGroup struct {
|
||||
GroupID int64 `json:"group_id"`
|
||||
FeedIDs string `json:"feed_ids"`
|
||||
}
|
||||
|
||||
type FeverFeed struct {
|
||||
ID int64 `json:"id"`
|
||||
FaviconID int64 `json:"favicon_id"`
|
||||
Title string `json:"title"`
|
||||
Url string `json:"url"`
|
||||
SiteUrl string `json:"site_url"`
|
||||
IsSpark int `json:"is_spark"`
|
||||
LastUpdated int64 `json:"last_updated_on_time"`
|
||||
}
|
||||
|
||||
type FeverItem struct {
|
||||
ID int64 `json:"id"`
|
||||
FeedID int64 `json:"feed_id"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
HTML string `json:"html"`
|
||||
Url string `json:"url"`
|
||||
IsSaved int `json:"is_saved"`
|
||||
IsRead int `json:"is_read"`
|
||||
CreatedAt int64 `json:"created_on_time"`
|
||||
}
|
||||
|
||||
type FeverFavicon struct {
|
||||
ID int64 `json:"id"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
func writeFeverJSON(c *router.Context, data map[string]any, lastRefreshed int64) {
|
||||
data["api_version"] = 3
|
||||
data["auth"] = 1
|
||||
// TODO: remove duplicates
|
||||
data["last_refreshed_on_time"] = lastRefreshed
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
func getLastRefreshedOnTime(feedStates []model.FeedState) int64 {
|
||||
var lastRefreshed int64
|
||||
for _, state := range feedStates {
|
||||
if state.LastRefreshed.Unix() > lastRefreshed {
|
||||
lastRefreshed = state.LastRefreshed.Unix()
|
||||
}
|
||||
}
|
||||
return lastRefreshed
|
||||
}
|
||||
|
||||
func (s *Server) feverAuth(c *router.Context) bool {
|
||||
if s.Username != "" && s.Password != "" {
|
||||
apiKey := c.Req.FormValue("api_key")
|
||||
apiKey = strings.ToLower(apiKey)
|
||||
md5HashValue := md5.Sum(fmt.Appendf(nil, "%s:%s", s.Username, s.Password))
|
||||
hexMD5HashValue := fmt.Sprintf("%x", md5HashValue[:])
|
||||
if !auth.StringsEqual(apiKey, hexMD5HashValue) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func formHasValue(values url.Values, value string) bool {
|
||||
if _, ok := values[value]; ok {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) handleFever(c *router.Context) {
|
||||
c.Req.ParseForm()
|
||||
if !s.feverAuth(c) {
|
||||
c.JSON(http.StatusOK, map[string]any{
|
||||
"api_version": 3,
|
||||
"auth": 0,
|
||||
"last_refreshed_on_time": 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case formHasValue(c.Req.Form, "groups"):
|
||||
s.feverGroupsHandler(c)
|
||||
case formHasValue(c.Req.Form, "feeds"):
|
||||
s.feverFeedsHandler(c)
|
||||
case formHasValue(c.Req.Form, "unread_item_ids"):
|
||||
s.feverUnreadItemIDsHandler(c)
|
||||
case formHasValue(c.Req.Form, "saved_item_ids"):
|
||||
s.feverSavedItemIDsHandler(c)
|
||||
case formHasValue(c.Req.Form, "favicons"):
|
||||
s.feverFaviconsHandler(c)
|
||||
case formHasValue(c.Req.Form, "items"):
|
||||
s.feverItemsHandler(c)
|
||||
case formHasValue(c.Req.Form, "links"):
|
||||
s.feverLinksHandler(c)
|
||||
case formHasValue(c.Req.Form, "mark"):
|
||||
s.feverMarkHandler(c)
|
||||
default:
|
||||
states, _ := s.db.ListFeedStates()
|
||||
c.JSON(http.StatusOK, map[string]any{
|
||||
"api_version": 3,
|
||||
"auth": 1,
|
||||
"last_refreshed_on_time": getLastRefreshedOnTime(states),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func joinInts(values []int64) string {
|
||||
var result strings.Builder
|
||||
for i, val := range values {
|
||||
fmt.Fprintf(&result, "%d", val)
|
||||
if i != len(values)-1 {
|
||||
result.WriteString(",")
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func feedGroups(db storage.Storage) []*FeverFeedsGroup {
|
||||
feeds := db.ListFeeds()
|
||||
|
||||
groupFeeds := make(map[int64][]int64)
|
||||
for _, feed := range feeds {
|
||||
if feed.FolderId == nil {
|
||||
continue
|
||||
}
|
||||
groupFeeds[*feed.FolderId] = append(groupFeeds[*feed.FolderId], feed.Id)
|
||||
}
|
||||
result := make([]*FeverFeedsGroup, 0)
|
||||
for groupId, feedIds := range groupFeeds {
|
||||
result = append(result, &FeverFeedsGroup{
|
||||
GroupID: groupId,
|
||||
FeedIDs: joinInts(feedIds),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Server) feverGroupsHandler(c *router.Context) {
|
||||
folders := s.db.ListFolders()
|
||||
groups := make([]*FeverGroup, len(folders))
|
||||
for i, folder := range folders {
|
||||
groups[i] = &FeverGroup{ID: folder.Id, Title: folder.Title}
|
||||
}
|
||||
states, _ := s.db.ListFeedStates()
|
||||
writeFeverJSON(c, map[string]any{
|
||||
"groups": groups,
|
||||
"feeds_groups": feedGroups(s.db),
|
||||
}, getLastRefreshedOnTime(states))
|
||||
}
|
||||
|
||||
func (s *Server) feverFeedsHandler(c *router.Context) {
|
||||
feeds := s.db.ListFeeds()
|
||||
states, _ := s.db.ListFeedStates()
|
||||
statesMap := make(map[int64]model.FeedState)
|
||||
for _, state := range states {
|
||||
statesMap[state.FeedID] = state
|
||||
}
|
||||
|
||||
feverFeeds := make([]*FeverFeed, len(feeds))
|
||||
for i, feed := range feeds {
|
||||
var lastUpdated int64
|
||||
if state, ok := statesMap[feed.Id]; ok {
|
||||
lastUpdated = state.LastRefreshed.Unix()
|
||||
}
|
||||
feverFeeds[i] = &FeverFeed{
|
||||
ID: feed.Id,
|
||||
FaviconID: feed.Id,
|
||||
Title: feed.Title,
|
||||
Url: feed.FeedLink,
|
||||
SiteUrl: feed.Link,
|
||||
IsSpark: 0,
|
||||
LastUpdated: lastUpdated,
|
||||
}
|
||||
}
|
||||
writeFeverJSON(c, map[string]any{
|
||||
"feeds": feverFeeds,
|
||||
"feeds_groups": feedGroups(s.db),
|
||||
}, getLastRefreshedOnTime(states))
|
||||
}
|
||||
|
||||
func (s *Server) feverFaviconsHandler(c *router.Context) {
|
||||
feeds := s.db.ListFeeds()
|
||||
favicons := make([]*FeverFavicon, len(feeds))
|
||||
for i, feed := range feeds {
|
||||
data := "data:image/gif;base64,R0lGODlhAQABAAAAACw="
|
||||
if feed.HasIcon {
|
||||
icon := s.db.GetFeed(feed.Id).Icon
|
||||
data = fmt.Sprintf(
|
||||
"data:%s;base64,%s",
|
||||
http.DetectContentType(*icon),
|
||||
base64.StdEncoding.EncodeToString(*icon),
|
||||
)
|
||||
}
|
||||
favicons[i] = &FeverFavicon{ID: feed.Id, Data: data}
|
||||
}
|
||||
|
||||
states, _ := s.db.ListFeedStates()
|
||||
writeFeverJSON(c, map[string]any{
|
||||
"favicons": favicons,
|
||||
}, getLastRefreshedOnTime(states))
|
||||
}
|
||||
|
||||
// for memory pressure reasons, we only return a limited number of items
|
||||
// documented at https://github.com/DigitalDJ/tinytinyrss-fever-plugin/blob/master/fever-api.md#items
|
||||
const listLimit = 50
|
||||
|
||||
func (s *Server) feverItemsHandler(c *router.Context) {
|
||||
filter := model.ItemFilter{}
|
||||
query := c.Req.URL.Query()
|
||||
|
||||
switch {
|
||||
case query.Get("with_ids") != "":
|
||||
ids := make([]int64, 0)
|
||||
for _, idstr := range strings.Split(query.Get("with_ids"), ",") {
|
||||
if idnum, err := strconv.ParseInt(idstr, 10, 64); err == nil {
|
||||
ids = append(ids, idnum)
|
||||
}
|
||||
}
|
||||
filter.IDs = &ids
|
||||
case query.Get("since_id") != "":
|
||||
idstr := query.Get("since_id")
|
||||
if idnum, err := strconv.ParseInt(idstr, 10, 64); err == nil {
|
||||
filter.SinceID = &idnum
|
||||
}
|
||||
case query.Get("max_id") != "":
|
||||
idstr := query.Get("max_id")
|
||||
if idnum, err := strconv.ParseInt(idstr, 10, 64); err == nil {
|
||||
filter.MaxID = &idnum
|
||||
}
|
||||
}
|
||||
|
||||
items := s.db.ListItems(filter, listLimit, true, true)
|
||||
|
||||
feverItems := make([]FeverItem, len(items))
|
||||
for i, item := range items {
|
||||
date := item.Date
|
||||
time := date.Unix()
|
||||
|
||||
isSaved := 0
|
||||
if item.Status == model.STARRED {
|
||||
isSaved = 1
|
||||
}
|
||||
isRead := 0
|
||||
if item.Status == model.READ {
|
||||
isRead = 1
|
||||
}
|
||||
feverItems[i] = FeverItem{
|
||||
ID: item.Id,
|
||||
FeedID: item.FeedId,
|
||||
Title: item.Title,
|
||||
Author: "",
|
||||
HTML: item.Content,
|
||||
Url: item.Link,
|
||||
IsSaved: isSaved,
|
||||
IsRead: isRead,
|
||||
CreatedAt: time,
|
||||
}
|
||||
}
|
||||
|
||||
totalItems := s.db.CountItems()
|
||||
|
||||
states, _ := s.db.ListFeedStates()
|
||||
writeFeverJSON(c, map[string]any{
|
||||
"items": feverItems,
|
||||
"total_items": totalItems,
|
||||
}, getLastRefreshedOnTime(states))
|
||||
}
|
||||
|
||||
func (s *Server) feverLinksHandler(c *router.Context) {
|
||||
states, _ := s.db.ListFeedStates()
|
||||
writeFeverJSON(c, map[string]any{
|
||||
"links": make([]any, 0),
|
||||
}, getLastRefreshedOnTime(states))
|
||||
}
|
||||
|
||||
func (s *Server) feverUnreadItemIDsHandler(c *router.Context) {
|
||||
status := model.UNREAD
|
||||
itemIds := make([]int64, 0)
|
||||
|
||||
itemFilter := model.ItemFilter{
|
||||
Status: &status,
|
||||
}
|
||||
for {
|
||||
items := s.db.ListItems(itemFilter, listLimit, true, false)
|
||||
if len(items) == 0 {
|
||||
break
|
||||
}
|
||||
for _, item := range items {
|
||||
itemIds = append(itemIds, item.Id)
|
||||
}
|
||||
itemFilter.After = &items[len(items)-1].Id
|
||||
}
|
||||
states, _ := s.db.ListFeedStates()
|
||||
writeFeverJSON(c, map[string]any{
|
||||
"unread_item_ids": joinInts(itemIds),
|
||||
}, getLastRefreshedOnTime(states))
|
||||
}
|
||||
|
||||
func (s *Server) feverSavedItemIDsHandler(c *router.Context) {
|
||||
status := model.STARRED
|
||||
itemIds := make([]int64, 0)
|
||||
|
||||
itemFilter := model.ItemFilter{
|
||||
Status: &status,
|
||||
}
|
||||
for {
|
||||
items := s.db.ListItems(itemFilter, listLimit, true, false)
|
||||
if len(items) == 0 {
|
||||
break
|
||||
}
|
||||
for _, item := range items {
|
||||
itemIds = append(itemIds, item.Id)
|
||||
}
|
||||
itemFilter.After = &items[len(items)-1].Id
|
||||
}
|
||||
states, _ := s.db.ListFeedStates()
|
||||
writeFeverJSON(c, map[string]any{
|
||||
"saved_item_ids": joinInts(itemIds),
|
||||
}, getLastRefreshedOnTime(states))
|
||||
}
|
||||
|
||||
func (s *Server) feverMarkHandler(c *router.Context) {
|
||||
id, err := strconv.ParseInt(c.Req.Form.Get("id"), 10, 64)
|
||||
if err != nil {
|
||||
log.Print("invalid id:", err)
|
||||
return
|
||||
}
|
||||
|
||||
switch c.Req.Form.Get("mark") {
|
||||
case "item":
|
||||
var status model.ItemStatus
|
||||
switch c.Req.Form.Get("as") {
|
||||
case "read":
|
||||
status = model.READ
|
||||
case "unread":
|
||||
status = model.UNREAD
|
||||
case "saved":
|
||||
status = model.STARRED
|
||||
case "unsaved":
|
||||
status = model.READ
|
||||
default:
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.db.UpdateItemStatus(id, status)
|
||||
case "feed":
|
||||
if c.Req.Form.Get("as") != "read" {
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
markFilter := model.MarkFilter{FeedID: &id}
|
||||
x, _ := strconv.ParseInt(c.Req.Form.Get("before"), 10, 64)
|
||||
if x > 0 {
|
||||
before := time.Unix(x, 0).UTC()
|
||||
markFilter.Before = &before
|
||||
}
|
||||
s.db.MarkItemsRead(markFilter)
|
||||
case "group":
|
||||
if c.Req.Form.Get("as") != "read" {
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
markFilter := model.MarkFilter{}
|
||||
if id > 0 {
|
||||
markFilter.FolderID = &id
|
||||
}
|
||||
x, _ := strconv.ParseInt(c.Req.Form.Get("before"), 10, 64)
|
||||
if x > 0 {
|
||||
before := time.Unix(x, 0).UTC()
|
||||
markFilter.Before = &before
|
||||
}
|
||||
s.db.MarkItemsRead(markFilter)
|
||||
default:
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, map[string]any{
|
||||
"api_version": 3,
|
||||
"auth": 1,
|
||||
})
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package server
|
||||
|
||||
import "github.com/nkanaev/yarr/src/storage"
|
||||
import "github.com/nkanaev/yarr/src/storage/model"
|
||||
|
||||
type ItemUpdateForm struct {
|
||||
Status *storage.ItemStatus `json:"status,omitempty"`
|
||||
Status *model.ItemStatus `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type FolderCreateForm struct {
|
||||
|
||||
@@ -28,15 +28,16 @@ func (rw *gzipResponseWriter) WriteHeader(statusCode int) {
|
||||
}
|
||||
|
||||
func Middleware(c *router.Context) {
|
||||
if strings.Contains(c.Req.Header.Get("Accept-Encoding"), "gzip") {
|
||||
gz := &gzipResponseWriter{out: gzip.NewWriter(c.Out), src: c.Out}
|
||||
defer gz.out.Close()
|
||||
|
||||
c.Out.Header().Set("Content-Encoding", "gzip")
|
||||
c.Out = gz
|
||||
if !strings.Contains(c.Req.Header.Get("Accept-Encoding"), "gzip") {
|
||||
c.Next()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
gz := &gzipResponseWriter{out: gzip.NewWriter(c.Out), src: c.Out}
|
||||
defer gz.out.Close()
|
||||
|
||||
c.Out.Header().Set("Content-Encoding", "gzip")
|
||||
c.Out = gz
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package opml
|
||||
import (
|
||||
"encoding/xml"
|
||||
"io"
|
||||
|
||||
"golang.org/x/net/html/charset"
|
||||
)
|
||||
|
||||
type opml struct {
|
||||
@@ -13,6 +15,7 @@ type opml struct {
|
||||
type outline struct {
|
||||
Type string `xml:"type,attr,omitempty"`
|
||||
Title string `xml:"text,attr"`
|
||||
Title2 string `xml:"title,attr,omitempty"`
|
||||
FeedUrl string `xml:"xmlUrl,attr,omitempty"`
|
||||
SiteUrl string `xml:"htmlUrl,attr,omitempty"`
|
||||
Outlines []outline `xml:"outline,omitempty"`
|
||||
@@ -21,14 +24,18 @@ type outline struct {
|
||||
func buildFolder(title string, outlines []outline) Folder {
|
||||
folder := Folder{Title: title}
|
||||
for _, outline := range outlines {
|
||||
if outline.Type == "rss" {
|
||||
if outline.Type == "rss" || outline.FeedUrl != "" {
|
||||
folder.Feeds = append(folder.Feeds, Feed{
|
||||
Title: outline.Title,
|
||||
FeedUrl: outline.FeedUrl,
|
||||
SiteUrl: outline.SiteUrl,
|
||||
})
|
||||
} else {
|
||||
subfolder := buildFolder(outline.Title, outline.Outlines)
|
||||
title := outline.Title
|
||||
if title == "" {
|
||||
title = outline.Title2
|
||||
}
|
||||
subfolder := buildFolder(title, outline.Outlines)
|
||||
folder.Folders = append(folder.Folders, subfolder)
|
||||
}
|
||||
}
|
||||
@@ -40,6 +47,7 @@ func Parse(r io.Reader) (Folder, error) {
|
||||
decoder := xml.NewDecoder(r)
|
||||
decoder.Entity = xml.HTMLEntity
|
||||
decoder.Strict = false
|
||||
decoder.CharsetReader = charset.NewReaderLabel
|
||||
|
||||
err := decoder.Decode(&val)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package opml
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -56,3 +57,76 @@ func TestParse(t *testing.T) {
|
||||
t.Fatal("invalid opml")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFallback(t *testing.T) {
|
||||
// as reported in https://github.com/nkanaev/yarr/pull/56
|
||||
// the feed below comes without `outline[text]` & `outline[type=rss]` attributes
|
||||
have, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<opml xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" version="1.0">
|
||||
<head>
|
||||
<title>Newsflow</title>
|
||||
</head>
|
||||
<body>
|
||||
<outline title="foldertitle">
|
||||
<outline htmlUrl="https://example.com" text="feedtext" title="feedtitle" xmlUrl="https://example.com/feed.xml" />
|
||||
</outline>
|
||||
</body>
|
||||
</opml>
|
||||
`))
|
||||
want := Folder{
|
||||
Folders: []Folder{{
|
||||
Title: "foldertitle",
|
||||
Feeds: []Feed{
|
||||
{
|
||||
Title: "feedtext",
|
||||
FeedUrl: "https://example.com/feed.xml",
|
||||
SiteUrl: "https://example.com",
|
||||
},
|
||||
},
|
||||
}},
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fatal("invalid opml")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWithEncoding(t *testing.T) {
|
||||
file, err := os.Open("sample_win1251.xml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
have, err := Parse(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := Folder{
|
||||
Title: "",
|
||||
Feeds: []Feed{
|
||||
{
|
||||
Title: "пример1",
|
||||
FeedUrl: "https://baz.com/feed.xml",
|
||||
SiteUrl: "https://baz.com/",
|
||||
},
|
||||
},
|
||||
Folders: []Folder{
|
||||
{
|
||||
Title: "папка",
|
||||
Feeds: []Feed{
|
||||
{
|
||||
Title: "пример2",
|
||||
FeedUrl: "https://foo.com/feed.xml",
|
||||
SiteUrl: "https://foo.com/",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fatal("invalid opml")
|
||||
}
|
||||
}
|
||||
|
||||
10
src/server/opml/sample_win1251.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="windows-1251"?>
|
||||
<opml version="1.1">
|
||||
<head><title><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD></title></head>
|
||||
<body>
|
||||
<outline text="<22><><EFBFBD><EFBFBD><EFBFBD>">
|
||||
<outline type="rss" text="<22><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2" description="<22><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2" xmlUrl="https://foo.com/feed.xml" htmlUrl="https://foo.com/"/>
|
||||
</outline>
|
||||
<outline type="rss" text="<22><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>1" description="<22><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>1" xmlUrl="https://baz.com/feed.xml" htmlUrl="https://baz.com/"/>
|
||||
</body>
|
||||
</opml>
|
||||
@@ -24,20 +24,20 @@ func (c *Context) Next() {
|
||||
c.chain[c.index](c)
|
||||
}
|
||||
|
||||
func (c *Context) JSON(status int, data interface{}) {
|
||||
func (c *Context) JSON(status int, data any) {
|
||||
body, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
c.Out.WriteHeader(status)
|
||||
c.Out.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
c.Out.WriteHeader(status)
|
||||
c.Out.Write(body)
|
||||
c.Out.Write([]byte("\n"))
|
||||
}
|
||||
|
||||
func (c *Context) HTML(status int, tmpl *template.Template, data interface{}) {
|
||||
c.Out.WriteHeader(status)
|
||||
func (c *Context) HTML(status int, tmpl *template.Template, data any) {
|
||||
c.Out.Header().Set("Content-Type", "text/html")
|
||||
c.Out.WriteHeader(status)
|
||||
tmpl.Execute(c.Out, data)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,10 +32,13 @@ func (r *Router) Use(h Handler) {
|
||||
}
|
||||
|
||||
func (r *Router) For(path string, handler Handler) {
|
||||
chain := make([]Handler, 0)
|
||||
chain = append(chain, r.middle...)
|
||||
chain = append(chain, handler)
|
||||
|
||||
x := Route{}
|
||||
x.regex = routeRegexp(path)
|
||||
x.chain = append(r.middle, handler)
|
||||
|
||||
x.chain = chain
|
||||
r.routes = append(r.routes, x)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/nkanaev/yarr/src/assets"
|
||||
"github.com/nkanaev/yarr/src/content/htmlutil"
|
||||
"github.com/nkanaev/yarr/src/content/readability"
|
||||
"github.com/nkanaev/yarr/src/content/sanitizer"
|
||||
"github.com/nkanaev/yarr/src/content/silo"
|
||||
@@ -17,7 +20,7 @@ import (
|
||||
"github.com/nkanaev/yarr/src/server/gzip"
|
||||
"github.com/nkanaev/yarr/src/server/opml"
|
||||
"github.com/nkanaev/yarr/src/server/router"
|
||||
"github.com/nkanaev/yarr/src/storage"
|
||||
"github.com/nkanaev/yarr/src/storage/model"
|
||||
"github.com/nkanaev/yarr/src/worker"
|
||||
)
|
||||
|
||||
@@ -31,12 +34,14 @@ func (s *Server) handler() http.Handler {
|
||||
BasePath: s.BasePath,
|
||||
Username: s.Username,
|
||||
Password: s.Password,
|
||||
Public: "/static",
|
||||
Public: []string{"/static", "/fever", "/manifest.json"},
|
||||
DB: s.db,
|
||||
}
|
||||
r.Use(a.Handler)
|
||||
}
|
||||
|
||||
r.For("/", s.handleIndex)
|
||||
r.For("/manifest.json", s.handleManifest)
|
||||
r.For("/static/*path", s.handleStatic)
|
||||
r.For("/api/status", s.handleStatus)
|
||||
r.For("/api/folders", s.handleFolderList)
|
||||
@@ -53,13 +58,14 @@ func (s *Server) handler() http.Handler {
|
||||
r.For("/opml/export", s.handleOPMLExport)
|
||||
r.For("/page", s.handlePageCrawl)
|
||||
r.For("/logout", s.handleLogout)
|
||||
r.For("/fever/", s.handleFever)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (s *Server) handleIndex(c *router.Context) {
|
||||
c.HTML(http.StatusOK, assets.Template("index.html"), map[string]interface{}{
|
||||
"settings": s.db.GetSettings(),
|
||||
c.HTML(http.StatusOK, assets.Template("index.html"), map[string]any{
|
||||
"settings": s.db.GetSettings().Map(),
|
||||
"authenticated": s.Username != "" && s.Password != "",
|
||||
})
|
||||
}
|
||||
@@ -71,11 +77,30 @@ func (s *Server) handleStatic(c *router.Context) {
|
||||
c.Out.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.StripPrefix(s.BasePath+"/static/", http.FileServer(http.FS(assets.FS))).ServeHTTP(c.Out, c.Req)
|
||||
http.StripPrefix(s.BasePath+"/static/", http.FileServer(http.FS(assets.FS))).
|
||||
ServeHTTP(c.Out, c.Req)
|
||||
}
|
||||
|
||||
func (s *Server) handleManifest(c *router.Context) {
|
||||
c.JSON(http.StatusOK, map[string]any{
|
||||
"$schema": "https://json.schemastore.org/web-manifest-combined.json",
|
||||
"name": "yarr!",
|
||||
"short_name": "yarr",
|
||||
"description": "yet another rss reader",
|
||||
"display": "standalone",
|
||||
"start_url": "/" + strings.TrimPrefix(s.BasePath, "/"),
|
||||
"icons": []map[string]any{
|
||||
{
|
||||
"src": s.BasePath + "/static/graphicarts/favicon.png",
|
||||
"sizes": "64x64",
|
||||
"type": "image/png",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleStatus(c *router.Context) {
|
||||
c.JSON(http.StatusOK, map[string]interface{}{
|
||||
c.JSON(http.StatusOK, map[string]any{
|
||||
"running": s.worker.FeedsPending(),
|
||||
"stats": s.db.FeedStats(),
|
||||
})
|
||||
@@ -116,12 +141,10 @@ func (s *Server) handleFolder(c *router.Context) {
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Title != nil {
|
||||
s.db.RenameFolder(id, *body.Title)
|
||||
}
|
||||
if body.IsExpanded != nil {
|
||||
s.db.ToggleFolderExpanded(id, *body.IsExpanded)
|
||||
}
|
||||
s.db.UpdateFolder(id, model.UpdateFolderParams{
|
||||
Title: body.Title,
|
||||
IsExpanded: body.IsExpanded,
|
||||
})
|
||||
c.Out.WriteHeader(http.StatusOK)
|
||||
} else if c.Req.Method == "DELETE" {
|
||||
s.db.DeleteFolder(id)
|
||||
@@ -139,23 +162,67 @@ func (s *Server) handleFeedRefresh(c *router.Context) {
|
||||
}
|
||||
|
||||
func (s *Server) handleFeedErrors(c *router.Context) {
|
||||
errors := s.db.GetFeedErrors()
|
||||
errors := make(map[int64]string)
|
||||
states, err := s.db.ListFeedStates()
|
||||
if err == nil {
|
||||
for _, state := range states {
|
||||
if state.LastError != "" {
|
||||
errors[state.FeedID] = state.LastError
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, errors)
|
||||
}
|
||||
|
||||
type feedicon struct {
|
||||
ctype string
|
||||
bytes []byte
|
||||
etag string
|
||||
}
|
||||
|
||||
func (s *Server) handleFeedIcon(c *router.Context) {
|
||||
id, err := c.VarInt64("id")
|
||||
if err != nil {
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
feed := s.db.GetFeed(id)
|
||||
if feed != nil && feed.Icon != nil {
|
||||
c.Out.Header().Set("Content-Type", http.DetectContentType(*feed.Icon))
|
||||
c.Out.Write(*feed.Icon)
|
||||
} else {
|
||||
c.Out.WriteHeader(http.StatusNotFound)
|
||||
|
||||
cachekey := "icon:" + strconv.FormatInt(id, 10)
|
||||
s.cache_mutex.Lock()
|
||||
cachedat := s.cache[cachekey]
|
||||
s.cache_mutex.Unlock()
|
||||
if cachedat == nil {
|
||||
feed := s.db.GetFeed(id)
|
||||
if feed == nil || feed.Icon == nil {
|
||||
c.Out.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
hash := md5.New()
|
||||
hash.Write(*feed.Icon)
|
||||
|
||||
etag := fmt.Sprintf("%x", hash.Sum(nil))[:16]
|
||||
|
||||
cachedat = feedicon{
|
||||
ctype: http.DetectContentType(*feed.Icon),
|
||||
bytes: *(*feed).Icon,
|
||||
etag: etag,
|
||||
}
|
||||
s.cache_mutex.Lock()
|
||||
s.cache[cachekey] = cachedat
|
||||
s.cache_mutex.Unlock()
|
||||
}
|
||||
|
||||
icon := cachedat.(feedicon)
|
||||
|
||||
if c.Req.Header.Get("If-None-Match") == icon.etag {
|
||||
c.Out.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
c.Out.Header().Set("Content-Type", icon.ctype)
|
||||
c.Out.Header().Set("Etag", icon.etag)
|
||||
c.Out.Write(icon.bytes)
|
||||
}
|
||||
|
||||
func (s *Server) handleFeedList(c *router.Context) {
|
||||
@@ -176,21 +243,26 @@ func (s *Server) handleFeedList(c *router.Context) {
|
||||
log.Printf("Faild to discover feed for %s: %s", form.Url, err)
|
||||
c.JSON(http.StatusOK, map[string]string{"status": "notfound"})
|
||||
case len(result.Sources) > 0:
|
||||
c.JSON(http.StatusOK, map[string]interface{}{"status": "multiple", "choice": result.Sources})
|
||||
case result.Feed != nil:
|
||||
feed := s.db.CreateFeed(
|
||||
result.Feed.Title,
|
||||
"",
|
||||
result.Feed.SiteURL,
|
||||
result.FeedLink,
|
||||
form.FolderID,
|
||||
c.JSON(
|
||||
http.StatusOK,
|
||||
map[string]any{"status": "multiple", "choice": result.Sources},
|
||||
)
|
||||
s.db.CreateItems(worker.ConvertItems(result.Feed.Items, *feed))
|
||||
case result.Feed != nil:
|
||||
feed := s.db.CreateFeed(model.CreateFeedParams{
|
||||
Title: result.Feed.Title,
|
||||
Link: result.Feed.SiteURL,
|
||||
FeedLink: result.FeedLink,
|
||||
FolderID: form.FolderID,
|
||||
})
|
||||
items := worker.ConvertItems(result.Feed.Items, *feed)
|
||||
if len(items) > 0 {
|
||||
s.db.CreateItems(items)
|
||||
}
|
||||
s.worker.FindFeedFavicon(*feed)
|
||||
|
||||
c.JSON(http.StatusOK, map[string]interface{}{
|
||||
c.JSON(http.StatusOK, map[string]any{
|
||||
"status": "success",
|
||||
"feed": feed,
|
||||
"feed": feed,
|
||||
})
|
||||
default:
|
||||
c.JSON(http.StatusOK, map[string]string{"status": "notfound"})
|
||||
@@ -210,25 +282,34 @@ func (s *Server) handleFeed(c *router.Context) {
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
body := make(map[string]any)
|
||||
if err := json.NewDecoder(c.Req.Body).Decode(&body); err != nil {
|
||||
log.Print(err)
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
params := model.UpdateFeedParams{}
|
||||
if title, ok := body["title"]; ok {
|
||||
if reflect.TypeOf(title).Kind() == reflect.String {
|
||||
s.db.RenameFeed(id, title.(string))
|
||||
t := title.(string)
|
||||
params.Title = &t
|
||||
}
|
||||
}
|
||||
if f_id, ok := body["folder_id"]; ok {
|
||||
if f_id == nil {
|
||||
s.db.UpdateFeedFolder(id, nil)
|
||||
params.FolderID = model.SetNullable[int64](nil)
|
||||
} else if reflect.TypeOf(f_id).Kind() == reflect.Float64 {
|
||||
folderId := int64(f_id.(float64))
|
||||
s.db.UpdateFeedFolder(id, &folderId)
|
||||
params.FolderID = model.SetNullable(&folderId)
|
||||
}
|
||||
}
|
||||
if link, ok := body["feed_link"]; ok {
|
||||
if reflect.TypeOf(link).Kind() == reflect.String {
|
||||
l := link.(string)
|
||||
params.FeedLink = &l
|
||||
}
|
||||
}
|
||||
s.db.UpdateFeed(id, params)
|
||||
c.Out.WriteHeader(http.StatusOK)
|
||||
} else if c.Req.Method == "DELETE" {
|
||||
s.db.DeleteFeed(id)
|
||||
@@ -250,7 +331,18 @@ func (s *Server) handleItem(c *router.Context) {
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// runtime fix for relative links
|
||||
if !htmlutil.IsAPossibleLink(item.Link) {
|
||||
if feed := s.db.GetFeed(item.FeedId); feed != nil {
|
||||
item.Link = htmlutil.AbsoluteUrl(item.Link, feed.Link)
|
||||
}
|
||||
}
|
||||
|
||||
item.Content = sanitizer.Sanitize(item.Link, item.Content)
|
||||
for i, link := range item.MediaLinks {
|
||||
item.MediaLinks[i].Description = sanitizer.Sanitize(item.Link, link.Description)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, item)
|
||||
} else if c.Req.Method == "PUT" {
|
||||
@@ -272,37 +364,46 @@ func (s *Server) handleItem(c *router.Context) {
|
||||
func (s *Server) handleItemList(c *router.Context) {
|
||||
if c.Req.Method == "GET" {
|
||||
perPage := 20
|
||||
curPage := 1
|
||||
query := c.Req.URL.Query()
|
||||
if page, err := c.QueryInt64("page"); err == nil {
|
||||
curPage = int(page)
|
||||
}
|
||||
filter := storage.ItemFilter{}
|
||||
|
||||
filter := model.ItemFilter{}
|
||||
if folderID, err := c.QueryInt64("folder_id"); err == nil {
|
||||
filter.FolderID = &folderID
|
||||
}
|
||||
if feedID, err := c.QueryInt64("feed_id"); err == nil {
|
||||
filter.FeedID = &feedID
|
||||
}
|
||||
if after, err := c.QueryInt64("after"); err == nil {
|
||||
filter.After = &after
|
||||
}
|
||||
if status := query.Get("status"); len(status) != 0 {
|
||||
statusValue := storage.StatusValues[status]
|
||||
statusValue := model.StatusValues[status]
|
||||
filter.Status = &statusValue
|
||||
}
|
||||
if search := query.Get("search"); len(search) != 0 {
|
||||
filter.Search = &search
|
||||
}
|
||||
newestFirst := query.Get("oldest_first") != "true"
|
||||
items := s.db.ListItems(filter, (curPage-1)*perPage, perPage, newestFirst)
|
||||
count := s.db.CountItems(filter)
|
||||
c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"page": map[string]int{
|
||||
"cur": curPage,
|
||||
"num": int(math.Ceil(float64(count) / float64(perPage))),
|
||||
},
|
||||
"list": items,
|
||||
|
||||
items := s.db.ListItems(filter, perPage+1, newestFirst, true)
|
||||
hasMore := false
|
||||
if len(items) == perPage+1 {
|
||||
hasMore = true
|
||||
items = items[:perPage]
|
||||
}
|
||||
|
||||
for i, item := range items {
|
||||
if item.Title == "" {
|
||||
text := htmlutil.ExtractText(item.Content)
|
||||
items[i].Title = htmlutil.TruncateText(text, 140)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, map[string]any{
|
||||
"list": items,
|
||||
"has_more": hasMore,
|
||||
})
|
||||
} else if c.Req.Method == "PUT" {
|
||||
filter := storage.MarkFilter{}
|
||||
filter := model.MarkFilter{}
|
||||
|
||||
if folderID, err := c.QueryInt64("folder_id"); err == nil {
|
||||
filter.FolderID = &folderID
|
||||
@@ -321,14 +422,14 @@ func (s *Server) handleSettings(c *router.Context) {
|
||||
if c.Req.Method == "GET" {
|
||||
c.JSON(http.StatusOK, s.db.GetSettings())
|
||||
} else if c.Req.Method == "PUT" {
|
||||
settings := make(map[string]interface{})
|
||||
if err := json.NewDecoder(c.Req.Body).Decode(&settings); err != nil {
|
||||
var params model.UpdateSettingsParams
|
||||
if err := json.NewDecoder(c.Req.Body).Decode(¶ms); err != nil {
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if s.db.UpdateSettings(settings) {
|
||||
if _, ok := settings["refresh_rate"]; ok {
|
||||
s.worker.SetRefreshRate(s.db.GetSettingsValueInt64("refresh_rate"))
|
||||
if s.db.UpdateSettings(params) {
|
||||
if params.RefreshRate != nil {
|
||||
s.worker.SetRefreshRate(s.db.GetSettings().RefreshRate)
|
||||
}
|
||||
c.Out.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
@@ -351,16 +452,24 @@ func (s *Server) handleOPMLImport(c *router.Context) {
|
||||
return
|
||||
}
|
||||
for _, f := range doc.Feeds {
|
||||
s.db.CreateFeed(f.Title, "", f.SiteUrl, f.FeedUrl, nil)
|
||||
s.db.CreateFeed(model.CreateFeedParams{
|
||||
Title: f.Title,
|
||||
Link: f.SiteUrl,
|
||||
FeedLink: f.FeedUrl,
|
||||
})
|
||||
}
|
||||
for _, f := range doc.Folders {
|
||||
folder := s.db.CreateFolder(f.Title)
|
||||
for _, ff := range f.AllFeeds() {
|
||||
s.db.CreateFeed(ff.Title, "", ff.SiteUrl, ff.FeedUrl, &folder.Id)
|
||||
s.db.CreateFeed(model.CreateFeedParams{
|
||||
Title: ff.Title,
|
||||
Link: ff.SiteUrl,
|
||||
FeedLink: ff.FeedUrl,
|
||||
FolderID: &folder.Id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
s.worker.FindFavicons()
|
||||
s.worker.RefreshFeeds()
|
||||
|
||||
c.Out.WriteHeader(http.StatusOK)
|
||||
@@ -376,9 +485,8 @@ func (s *Server) handleOPMLExport(c *router.Context) {
|
||||
|
||||
doc := opml.Folder{}
|
||||
|
||||
feedsByFolderID := make(map[int64][]*storage.Feed)
|
||||
feedsByFolderID := make(map[int64][]*model.Feed)
|
||||
for _, feed := range s.db.ListFeeds() {
|
||||
feed := feed
|
||||
if feed.FolderId == nil {
|
||||
doc.Feeds = append(doc.Feeds, opml.Feed{
|
||||
Title: feed.Title,
|
||||
@@ -414,24 +522,31 @@ func (s *Server) handleOPMLExport(c *router.Context) {
|
||||
func (s *Server) handlePageCrawl(c *router.Context) {
|
||||
url := c.Req.URL.Query().Get("url")
|
||||
|
||||
if newUrl := silo.RedirectURL(url); newUrl != "" {
|
||||
url = newUrl
|
||||
}
|
||||
if content := silo.VideoIFrame(url); content != "" {
|
||||
c.JSON(http.StatusOK, map[string]string{
|
||||
"content": content,
|
||||
"content": sanitizer.Sanitize(url, content),
|
||||
})
|
||||
return
|
||||
}
|
||||
if isInternalFromURL(url) {
|
||||
log.Printf("attempt to access internal IP %s from %s", url, c.Req.RemoteAddr)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := http.Get(url)
|
||||
body, err := worker.GetBody(url)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer res.Body.Close()
|
||||
content, err := readability.ExtractContent(res.Body)
|
||||
content, err := readability.ExtractContent(strings.NewReader(body))
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
c.Out.WriteHeader(http.StatusNoContent)
|
||||
c.JSON(http.StatusOK, map[string]string{
|
||||
"content": "error: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
content = sanitizer.Sanitize(url, content)
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/nkanaev/yarr/src/storage"
|
||||
"github.com/nkanaev/yarr/src/storage/model"
|
||||
)
|
||||
|
||||
func TestStatic(t *testing.T) {
|
||||
@@ -43,3 +52,64 @@ func TestStaticBanTemplates(t *testing.T) {
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexGzipped(t *testing.T) {
|
||||
log.SetOutput(io.Discard)
|
||||
db, _ := storage.New(":memory:")
|
||||
log.SetOutput(os.Stderr)
|
||||
handler := NewServer(db, "127.0.0.1:8000").handler()
|
||||
url := "/"
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest("GET", url, nil)
|
||||
request.Header.Set("accept-encoding", "gzip")
|
||||
handler.ServeHTTP(recorder, request)
|
||||
response := recorder.Result()
|
||||
if response.StatusCode != 200 {
|
||||
t.FailNow()
|
||||
}
|
||||
if response.Header.Get("content-encoding") != "gzip" {
|
||||
t.Errorf("invalid content-encoding header: %#v", response.Header.Get("content-encoding"))
|
||||
}
|
||||
if response.Header.Get("content-type") != "text/html" {
|
||||
t.Errorf("invalid content-type header: %#v", response.Header.Get("content-type"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedIcons(t *testing.T) {
|
||||
log.SetOutput(io.Discard)
|
||||
db, _ := storage.New(":memory:")
|
||||
icon := []byte("test")
|
||||
feed := db.CreateFeed(model.CreateFeedParams{})
|
||||
db.UpdateFeed(feed.Id, model.UpdateFeedParams{Icon: model.SetNullable(&icon)})
|
||||
log.SetOutput(os.Stderr)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
url := fmt.Sprintf("/api/feeds/%d/icon", feed.Id)
|
||||
request := httptest.NewRequest("GET", url, nil)
|
||||
|
||||
handler := NewServer(db, "127.0.0.1:8000").handler()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
response := recorder.Result()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatal()
|
||||
}
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
if !reflect.DeepEqual(body, icon) {
|
||||
t.Fatal()
|
||||
}
|
||||
if response.Header.Get("Etag") == "" {
|
||||
t.Fatal()
|
||||
}
|
||||
|
||||
recorder2 := httptest.NewRecorder()
|
||||
request2 := httptest.NewRequest("GET", url, nil)
|
||||
request2.Header.Set("If-None-Match", response.Header.Get("Etag"))
|
||||
handler.ServeHTTP(recorder2, request2)
|
||||
response2 := recorder2.Result()
|
||||
|
||||
if response2.StatusCode != http.StatusNotModified {
|
||||
t.Fatal("got", response2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,22 @@ package server
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/nkanaev/yarr/src/storage"
|
||||
"github.com/nkanaev/yarr/src/worker"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
Addr string
|
||||
db *storage.Storage
|
||||
worker *worker.Worker
|
||||
Addr string
|
||||
db storage.Storage
|
||||
worker *worker.Worker
|
||||
cache map[string]any
|
||||
cache_mutex *sync.Mutex
|
||||
|
||||
BasePath string
|
||||
|
||||
@@ -23,11 +29,13 @@ type Server struct {
|
||||
KeyFile string
|
||||
}
|
||||
|
||||
func NewServer(db *storage.Storage, addr string) *Server {
|
||||
func NewServer(db storage.Storage, addr string) *Server {
|
||||
return &Server{
|
||||
db: db,
|
||||
Addr: addr,
|
||||
worker: worker.NewWorker(db),
|
||||
db: db,
|
||||
Addr: addr,
|
||||
worker: worker.NewWorker(db),
|
||||
cache: make(map[string]any),
|
||||
cache_mutex: &sync.Mutex{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,22 +48,38 @@ func (h *Server) GetAddr() string {
|
||||
}
|
||||
|
||||
func (s *Server) Start() {
|
||||
refreshRate := s.db.GetSettingsValueInt64("refresh_rate")
|
||||
s.worker.FindFavicons()
|
||||
refreshRate := s.db.GetSettings().RefreshRate
|
||||
s.worker.StartFeedCleaner()
|
||||
s.worker.SetRefreshRate(refreshRate)
|
||||
if refreshRate > 0 {
|
||||
s.worker.RefreshFeeds()
|
||||
}
|
||||
|
||||
httpserver := &http.Server{Addr: s.Addr, Handler: s.handler()}
|
||||
|
||||
var ln net.Listener
|
||||
var err error
|
||||
if s.CertFile != "" && s.KeyFile != "" {
|
||||
err = httpserver.ListenAndServeTLS(s.CertFile, s.KeyFile)
|
||||
|
||||
if path, isUnix := strings.CutPrefix(s.Addr, "unix:"); isUnix {
|
||||
err = os.Remove(path)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
}
|
||||
ln, err = net.Listen("unix", path)
|
||||
} else {
|
||||
err = httpserver.ListenAndServe()
|
||||
ln, err = net.Listen("tcp", s.Addr)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
httpserver := &http.Server{Handler: s.handler()}
|
||||
if s.CertFile != "" && s.KeyFile != "" {
|
||||
err = httpserver.ServeTLS(ln, s.CertFile, s.KeyFile)
|
||||
ln.Close()
|
||||
} else {
|
||||
err = httpserver.Serve(ln)
|
||||
}
|
||||
|
||||
if err != http.ErrServerClosed {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
35
src/server/util.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func isInternalFromURL(urlStr string) bool {
|
||||
parsedURL, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
host := parsedURL.Host
|
||||
|
||||
// Handle "host:port" format
|
||||
if strings.Contains(host, ":") {
|
||||
host, _, err = net.SplitHostPort(host)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast()
|
||||
}
|
||||
31
src/server/util_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package server
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsInternalFromURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
url string
|
||||
expected bool
|
||||
}{
|
||||
{"http://192.168.1.1:8080", true},
|
||||
{"http://10.0.0.5", true},
|
||||
{"http://172.16.0.1", true},
|
||||
{"http://172.31.255.255", true},
|
||||
{"http://172.32.0.1", false}, // outside private range
|
||||
{"http://127.0.0.1", true},
|
||||
{"http://127.0.0.1:7000", true},
|
||||
{"http://127.0.0.1:7000/secret", true},
|
||||
{"http://169.254.0.5", true},
|
||||
{"http://localhost", true}, // resolves to 127.0.0.1
|
||||
{"http://8.8.8.8", false},
|
||||
{"http://google.com", false}, // resolves to public IPs
|
||||
{"invalid-url", false}, // invalid format
|
||||
{"", false}, // empty string
|
||||
}
|
||||
for _, test := range tests {
|
||||
result := isInternalFromURL(test.url)
|
||||
if result != test.expected {
|
||||
t.Errorf("isInternalFromURL(%q) = %v; want %v", test.url, result, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||