Compare commits
233 Commits
v2.1
...
08ad04401d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
25
.github/actions/prepare/action.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
name: Build & Upload
|
||||||
|
inputs:
|
||||||
|
id:
|
||||||
|
description: artifact name
|
||||||
|
required: true
|
||||||
|
cmd:
|
||||||
|
description: command to run
|
||||||
|
required: true
|
||||||
|
out:
|
||||||
|
description: path to output file
|
||||||
|
required: true
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: compile
|
||||||
|
run: ${{ inputs.cmd }}
|
||||||
|
shell: bash
|
||||||
|
- name: archive
|
||||||
|
run: tar -cvf ${{ inputs.out }}.tar ${{ inputs.out }}
|
||||||
|
shell: bash
|
||||||
|
- name: upload
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.id }}
|
||||||
|
path: ${{ inputs.out }}.tar
|
||||||
41
.github/workflows/build-docker.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: Publish Docker Image
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- v*
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: nkanaev/yarr
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push-image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./etc/dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
209
.github/workflows/build.yml
vendored
@@ -1,144 +1,143 @@
|
|||||||
name: build
|
name: Build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: ['v*', 'test*']
|
tags:
|
||||||
|
- v*
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build_macos:
|
build_macos:
|
||||||
name: Build for MacOS
|
name: Build for MacOS
|
||||||
runs-on: macos-10.15
|
runs-on: macos-13
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout"
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
submodules: 'recursive'
|
go-version: '^1.23'
|
||||||
- name: "Setup Go"
|
- name: Build arm64 gui
|
||||||
uses: actions/setup-go@v2
|
uses: ./.github/actions/prepare
|
||||||
with:
|
with:
|
||||||
go-version: '^1.16'
|
id: darwin_arm64_gui
|
||||||
- name: Cache Go Modules
|
cmd: make darwin_arm64_gui
|
||||||
uses: actions/cache@v2
|
out: out/darwin_arm64_gui/yarr.app
|
||||||
|
- name: Build amd64 gui
|
||||||
|
uses: ./.github/actions/prepare
|
||||||
with:
|
with:
|
||||||
path: ~/go/pkg/mod
|
id: darwin_amd64_gui
|
||||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
cmd: make darwin_amd64_gui
|
||||||
restore-keys: |
|
out: out/darwin_amd64_gui/yarr.app
|
||||||
${{ runner.os }}-go-
|
- name: Build arm64 cli
|
||||||
- name: "Build"
|
uses: ./.github/actions/prepare
|
||||||
run: make build_macos
|
|
||||||
- name: Upload
|
|
||||||
uses: actions/upload-artifact@v2
|
|
||||||
with:
|
with:
|
||||||
name: macos
|
id: darwin_arm64
|
||||||
path: _output/macos/yarr.app
|
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:
|
build_windows:
|
||||||
name: Build for Windows
|
name: Build for Windows
|
||||||
runs-on: windows-2019
|
runs-on: windows-2022
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout"
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
submodules: 'recursive'
|
go-version: '^1.23'
|
||||||
- name: "Setup Go"
|
- name: Build amd64 gui
|
||||||
uses: actions/setup-go@v2
|
uses: ./.github/actions/prepare
|
||||||
with:
|
with:
|
||||||
go-version: '^1.16'
|
id: windows_amd64_gui
|
||||||
- name: Cache Go Modules
|
cmd: make windows_amd64_gui
|
||||||
uses: actions/cache@v2
|
out: out/windows_amd64_gui/yarr.exe
|
||||||
|
- name: Build arm64 gui
|
||||||
|
if: false
|
||||||
|
uses: ./.github/actions/prepare
|
||||||
with:
|
with:
|
||||||
path: ~/go/pkg/mod
|
id: windows_arm64_gui
|
||||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
cmd: make windows_arm64_gui
|
||||||
restore-keys: |
|
out: out/windows_arm64_gui/yarr.exe
|
||||||
${{ runner.os }}-go-
|
|
||||||
- name: "Build"
|
|
||||||
run: make build_windows
|
|
||||||
- name: Upload
|
|
||||||
uses: actions/upload-artifact@v2
|
|
||||||
with:
|
|
||||||
name: windows
|
|
||||||
path: _output/windows/yarr.exe
|
|
||||||
|
|
||||||
build_linux:
|
build_multi_cli:
|
||||||
name: Build for Linux
|
name: Build for Windows/MacOS/Linux CLI
|
||||||
runs-on: ubuntu-18.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout"
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
submodules: 'recursive'
|
go-version: '^1.23'
|
||||||
- name: "Setup Go"
|
- name: Setup Zig
|
||||||
uses: actions/setup-go@v2
|
uses: mlugg/setup-zig@v1
|
||||||
with:
|
with:
|
||||||
go-version: '^1.16'
|
version: 0.14.0
|
||||||
- name: Cache Go Modules
|
- name: Build linux/amd64
|
||||||
uses: actions/cache@v2
|
uses: ./.github/actions/prepare
|
||||||
with:
|
with:
|
||||||
path: ~/go/pkg/mod
|
id: linux_amd64
|
||||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
cmd: make linux_amd64
|
||||||
restore-keys: |
|
out: out/linux_amd64/yarr
|
||||||
${{ runner.os }}-go-
|
- name: Build linux/arm64
|
||||||
- name: "Build"
|
uses: ./.github/actions/prepare
|
||||||
run: make build_linux
|
|
||||||
- name: Upload
|
|
||||||
uses: actions/upload-artifact@v2
|
|
||||||
with:
|
with:
|
||||||
name: linux
|
id: linux_arm64
|
||||||
path: _output/linux/yarr
|
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:
|
create_release:
|
||||||
name: Create Release
|
name: Create Release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ !contains(github.ref, 'test') }}
|
needs: [build_macos, build_windows, build_multi_cli]
|
||||||
needs: [build_macos, build_windows, build_linux]
|
|
||||||
steps:
|
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
|
- name: Download Artifacts
|
||||||
uses: actions/download-artifact@v2
|
uses: actions/download-artifact@v4.1.7
|
||||||
with:
|
with:
|
||||||
path: .
|
path: .
|
||||||
- name: Preparation
|
- name: Preparation
|
||||||
run: |
|
run: |
|
||||||
|
set -ex
|
||||||
ls -R
|
ls -R
|
||||||
chmod u+x macos/Contents/MacOS/yarr
|
for tarfile in `ls **/*.tar`; do
|
||||||
chmod u+x linux/yarr
|
tar -xvf $tarfile
|
||||||
|
done
|
||||||
mv macos yarr.app && zip -r yarr-macos.zip yarr.app
|
for dir in out/*; do
|
||||||
mv windows/yarr.exe . && zip yarr-windows.zip yarr.exe
|
echo "Compressing: $dir"
|
||||||
mv linux/yarr . && zip yarr-linux.zip yarr
|
(test -d "$dir" && cd $dir && zip -r ../yarr_`basename $dir`.zip *)
|
||||||
- name: Upload MacOS
|
done
|
||||||
uses: actions/upload-release-asset@v1
|
ls out
|
||||||
|
- name: Upload Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
draft: true
|
||||||
asset_path: ./yarr-macos.zip
|
prerelease: true
|
||||||
asset_name: yarr-${{ github.ref }}-macos64.zip
|
files: |
|
||||||
asset_content_type: application/zip
|
out/*.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
|
|
||||||
|
|||||||
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
|
/_output
|
||||||
|
/out
|
||||||
/yarr
|
/yarr
|
||||||
*.db
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
*.syso
|
*.syso
|
||||||
versioninfo.rc
|
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)
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
"github.com/nkanaev/yarr/src/platform"
|
"github.com/nkanaev/yarr/src/platform"
|
||||||
"github.com/nkanaev/yarr/src/server"
|
"github.com/nkanaev/yarr/src/server"
|
||||||
"github.com/nkanaev/yarr/src/storage"
|
"github.com/nkanaev/yarr/src/storage"
|
||||||
|
"github.com/nkanaev/yarr/src/worker"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Version string = "0.0"
|
var Version string = "0.0"
|
||||||
@@ -28,8 +30,25 @@ func opt(envVar, defaultValue string) string {
|
|||||||
return defaultValue
|
return defaultValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseAuthfile(authfile io.Reader) (username, password string, err error) {
|
||||||
|
scanner := bufio.NewScanner(authfile)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
parts := strings.SplitN(line, ":", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", "", fmt.Errorf("wrong syntax (expected `username:password`)")
|
||||||
|
}
|
||||||
|
username = parts[0]
|
||||||
|
password = parts[1]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return username, password, nil
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var addr, db, authfile, certfile, keyfile, basepath, logfile string
|
platform.FixConsoleIfNeeded()
|
||||||
|
|
||||||
|
var addr, db, authfile, auth, certfile, keyfile, basepath, logfile string
|
||||||
var ver, open bool
|
var ver, open bool
|
||||||
|
|
||||||
flag.CommandLine.SetOutput(os.Stdout)
|
flag.CommandLine.SetOutput(os.Stdout)
|
||||||
@@ -44,7 +63,8 @@ func main() {
|
|||||||
|
|
||||||
flag.StringVar(&addr, "addr", opt("YARR_ADDR", "127.0.0.1:7070"), "address to run server on")
|
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(&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")
|
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(&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(&keyfile, "key-file", opt("YARR_KEYFILE", ""), "`path` to key file for https")
|
||||||
flag.StringVar(&db, "db", opt("YARR_DB", ""), "storage file `path`")
|
flag.StringVar(&db, "db", opt("YARR_DB", ""), "storage file `path`")
|
||||||
@@ -70,12 +90,16 @@ func main() {
|
|||||||
log.SetOutput(os.Stdout)
|
log.SetOutput(os.Stdout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if open && strings.HasPrefix(addr, "unix:") {
|
||||||
|
log.Fatal("Cannot open ", addr, " in browser")
|
||||||
|
}
|
||||||
|
|
||||||
|
if db == "" {
|
||||||
configPath, err := os.UserConfigDir()
|
configPath, err := os.UserConfigDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Failed to get config dir: ", err)
|
log.Fatal("Failed to get config dir: ", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if db == "" {
|
|
||||||
storagePath := filepath.Join(configPath, "yarr")
|
storagePath := filepath.Join(configPath, "yarr")
|
||||||
if err := os.MkdirAll(storagePath, 0755); err != nil {
|
if err := os.MkdirAll(storagePath, 0755); err != nil {
|
||||||
log.Fatal("Failed to create app config dir: ", err)
|
log.Fatal("Failed to create app config dir: ", err)
|
||||||
@@ -86,22 +110,21 @@ func main() {
|
|||||||
log.Printf("using db file %s", db)
|
log.Printf("using db file %s", db)
|
||||||
|
|
||||||
var username, password string
|
var username, password string
|
||||||
|
var err error
|
||||||
if authfile != "" {
|
if authfile != "" {
|
||||||
f, err := os.Open(authfile)
|
f, err := os.Open(authfile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Failed to open auth file: ", err)
|
log.Fatal("Failed to open auth file: ", err)
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
scanner := bufio.NewScanner(f)
|
username, password, err = parseAuthfile(f)
|
||||||
for scanner.Scan() {
|
if err != nil {
|
||||||
line := scanner.Text()
|
log.Fatal("Failed to parse auth file: ", err)
|
||||||
parts := strings.Split(line, ":")
|
|
||||||
if len(parts) != 2 {
|
|
||||||
log.Fatalf("Invalid auth: %v (expected `username:password`)", line)
|
|
||||||
}
|
}
|
||||||
username = parts[0]
|
} else if auth != "" {
|
||||||
password = parts[1]
|
username, password, err = parseAuthfile(strings.NewReader(auth))
|
||||||
break
|
if err != nil {
|
||||||
|
log.Fatal("Failed to parse auth literal: ", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +137,7 @@ func main() {
|
|||||||
log.Fatal("Failed to initialise database: ", err)
|
log.Fatal("Failed to initialise database: ", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
worker.SetVersion(Version)
|
||||||
srv := server.NewServer(store, addr)
|
srv := server.NewServer(store, addr)
|
||||||
|
|
||||||
if basepath != "" {
|
if basepath != "" {
|
||||||
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"
|
||||||
124
doc/changelog.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# upcoming
|
||||||
|
|
||||||
|
- (new) serve on unix socket (thanks to @rvighne)
|
||||||
|
- (new) more auto-refresh options: 12h & 24h (thanks to @aswerkljh for suggestion)
|
||||||
|
- (fix) smooth scrolling on iOS (thanks to gatheraled)
|
||||||
|
- (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,58 +0,0 @@
|
|||||||
# upcoming
|
|
||||||
|
|
||||||
- (new) configuration via env variables
|
|
||||||
- (fix) missing `content-type` headers (thanks to @verahawk for the report)
|
|
||||||
- (fix) handle opml files not following the spec (thanks to @huangnauh for the report)
|
|
||||||
- (fix) pagination in unread/starred feeds (thanks to @Farow for the report)
|
|
||||||
- (fix) handling feeds with non-utf8 encodings (thanks to @fserb for the report)
|
|
||||||
- (fix) errors caused by empty feeds (thanks to @decke)
|
|
||||||
- (fix) recognize all audio mime types as podcasts (thanks to @krkk)
|
|
||||||
- (fix) ui tweaks (thanks to @Farow)
|
|
||||||
|
|
||||||
# v2.0 (2021-04-18)
|
|
||||||
|
|
||||||
- (new) user interface tweaks
|
|
||||||
- (new) feed parser fully rewritten
|
|
||||||
- (new) show youtube/vimeo iframes in "read here"
|
|
||||||
- (new) keyboard shortcuts for article scrolling & toggling "read here"
|
|
||||||
- (new) more options for auto-refresh intervals
|
|
||||||
- (fix) `-base` not serving static files (thanks to @vfaronov)
|
|
||||||
- (etc) 3rd-party dependencies reduced to the bare minimum
|
|
||||||
|
|
||||||
special thanks to @tillcash for feedback & suggestions.
|
|
||||||
|
|
||||||
# v1.4 (2021-03-11)
|
|
||||||
|
|
||||||
- (new) keyboard shortcuts (thanks to @Duarte-Dias)
|
|
||||||
- (new) show podcast audio
|
|
||||||
- (fix) deleting feeds
|
|
||||||
- (etc) minor ui tweaks & changes
|
|
||||||
|
|
||||||
# v1.3 (2021-02-18)
|
|
||||||
|
|
||||||
- (fix) log out functionality if authentication is set
|
|
||||||
- (fix) import opml if authentication is set
|
|
||||||
- (fix) login page if authentication is set (thanks to @einschmidt)
|
|
||||||
|
|
||||||
# v1.2 (2021-02-11)
|
|
||||||
|
|
||||||
- (new) autorefresh rate
|
|
||||||
- (new) reduced bandwidth usage via stateful http headers `last-modified/etag`
|
|
||||||
- (new) show feed errors in feed management modal
|
|
||||||
- (new) `-open` flag for automatically opening the server url
|
|
||||||
- (new) `-base` flag for serving urls under non-root path (thanks to @hcl)
|
|
||||||
- (new) `-auth-file` flag for authentication
|
|
||||||
- (new) `-cert-file` & `-key-file` flags for TLS
|
|
||||||
- (fix) wrapping long words in the ui to prevent vertical scroll
|
|
||||||
- (fix) increased toolbar height in mobile/tablet layout (thanks to @einschmidt)
|
|
||||||
|
|
||||||
# v1.1 (2020-10-05)
|
|
||||||
|
|
||||||
- (new) responsive design
|
|
||||||
- (fix) server crash on favicon fetch timeout (reported by @minioin)
|
|
||||||
- (fix) handling byte order marks in feeds (reported by @ilaer)
|
|
||||||
- (fix) deleting a feed raises exception in the ui if the feed's items are shown.
|
|
||||||
|
|
||||||
# v1.0 (2020-09-24)
|
|
||||||
|
|
||||||
Initial Release
|
|
||||||
19
doc/fever.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Fever API support
|
||||||
|
|
||||||
|
Fever API is a kind of RSS HTTP API interface, because the Fever API definition is not very clear, so the implementation of Fever server and Client may have some compatibility problems.
|
||||||
|
|
||||||
|
The Fever API implemented by Yarr is based on the Fever API spec: https://github.com/DigitalDJ/tinytinyrss-fever-plugin/blob/master/fever-api.md.
|
||||||
|
|
||||||
|
Here are some Apps that have been tested to work with yarr. Feel free to test other Clients/Apps and update the list here.
|
||||||
|
|
||||||
|
> Different apps support different URL/Address formats. Please note whether the URL entered has `http://` scheme and `/` suffix.
|
||||||
|
|
||||||
|
| App | Platforms | Config Server URL |
|
||||||
|
|:------------------------------------------------------------------------- | ---------------- |:--------------------------------------------------- |
|
||||||
|
| [Reeder](https://reederapp.com/) | MacOS<br>iOS | 127.0.0.1:7070/fever<br>http://127.0.0.1:7070/fever |
|
||||||
|
| [ReadKit](https://readkit.app/) | MacOS<br>iOS | http://127.0.0.1:7070/fever |
|
||||||
|
| [Fluent Reader](https://github.com/yang991178/fluent-reader) | MacOS<br>Windows | http://127.0.0.1:7070/fever/ |
|
||||||
|
| [Unread](https://apps.apple.com/us/app/unread-an-rss-reader/id1363637349) | iOS | http://127.0.0.1:7070/fever |
|
||||||
|
| [Fiery Feeds](https://voidstern.net/fiery-feeds) | MacOS<br>iOS | http://127.0.0.1:7070/fever |
|
||||||
|
|
||||||
|
If you are having trouble using Fever, please open an issue and @icefed, thanks.
|
||||||
68
doc/samples.yml
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
- site: https://vimeo.com/channels/staffpicks/videos
|
||||||
|
feed: https://vimeo.com/channels/staffpicks/videos/rss
|
||||||
|
tags: [vimeo, image]
|
||||||
|
|
||||||
|
- site: https://www.youtube.com/@everyframeapainting/videos
|
||||||
|
feed: https://www.youtube.com/feeds/videos.xml?channel_id=UCjFqcJQXGZ6T6sxyFB-5i6A"
|
||||||
|
tags: [youtube, image]
|
||||||
|
|
||||||
|
- site: https://iwdrm.tumblr.com/
|
||||||
|
feed: https://iwdrm.tumblr.com/rss
|
||||||
|
tags: [tumblr, image]
|
||||||
|
|
||||||
|
- site: https://falseknees.tumblr.com/
|
||||||
|
feed: https://falseknees.tumblr.com/rss
|
||||||
|
tags: [tumblr, image]
|
||||||
|
|
||||||
|
- site: https://accidentallyquadratic.tumblr.com/
|
||||||
|
feed: https://accidentallyquadratic.tumblr.com/rss
|
||||||
|
info: text blog with code sections
|
||||||
|
tags: [tumblr, text, code]
|
||||||
|
|
||||||
|
- site: https://www.flickr.com/photos/maratsafin/
|
||||||
|
feed: https://www.flickr.com/services/feeds/photos_public.gne?id=59021497@N07&lang=en-us&format=atom
|
||||||
|
tags: [flickr, image]
|
||||||
|
|
||||||
|
- site: https://www.reddit.com/r/comics
|
||||||
|
feed: https://www.reddit.com/r/comics.rss
|
||||||
|
tags: [reddit, image]
|
||||||
|
|
||||||
|
- site: https://www.reddit.com/r/AITAH
|
||||||
|
feed: https://www.reddit.com/r/AITAH.rss
|
||||||
|
tags: [reddit, text]
|
||||||
|
|
||||||
|
- site: https://idothei.wordpress.com/
|
||||||
|
feed: https://idothei.wordpress.com/feed/
|
||||||
|
tags: [wordpress, text]
|
||||||
|
|
||||||
|
- site: https://www.vidarholen.net/contents/blog/
|
||||||
|
feed: https://www.vidarholen.net/contents/blog/?feed=rss2
|
||||||
|
tags: [wordpress, text]
|
||||||
|
|
||||||
|
- site: https://blog.posthaven.com/
|
||||||
|
feed: https://blog.posthaven.com/posts.atom
|
||||||
|
tags: [posthaven, text]
|
||||||
|
|
||||||
|
- site: https://medium.com/@dailynewsletter
|
||||||
|
feed: https://medium.com/feed/@dailynewsletter
|
||||||
|
tags: [medium, text]
|
||||||
|
|
||||||
|
- site: https://thereveal.substack.com/
|
||||||
|
feed: https://thereveal.substack.com/feed
|
||||||
|
tags: [substack, text]
|
||||||
|
|
||||||
|
- site: https://tema.livejournal.com/
|
||||||
|
feed: https://tema.livejournal.com/data/rss
|
||||||
|
tags: [livejournal, text]
|
||||||
|
|
||||||
|
- site: https://mametter.hatenablog.com/
|
||||||
|
feed: https://mametter.hatenablog.com/feed
|
||||||
|
tags: [hatena, text]
|
||||||
|
|
||||||
|
- site: https://juliepowell.blogspot.com/
|
||||||
|
feed: https://juliepowell.blogspot.com/feeds/posts/default
|
||||||
|
tags: [blogger, text]
|
||||||
|
|
||||||
|
- site: https://micro.blog/val
|
||||||
|
feed: https://micro.blog/posts/val
|
||||||
|
tags: [json, microblog]
|
||||||
@@ -20,3 +20,8 @@ The licenses are included, and the authorship comments are left intact.
|
|||||||
https://github.com/getlantern/systray (commit:2c0986d) Apache 2.0
|
https://github.com/getlantern/systray (commit:2c0986d) Apache 2.0
|
||||||
|
|
||||||
removed golog dependency
|
removed golog dependency
|
||||||
|
|
||||||
|
- fixconsole
|
||||||
|
https://github.com/apenwarr/fixconsole (commit:5a9f648) Apache 2.0
|
||||||
|
|
||||||
|
removed `w32` dependency
|
||||||
|
|||||||
0
doc/todo.txt
Normal file
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: 173 KiB |
89
etc/windows_versioninfo.sh
Executable file
@@ -0,0 +1,89 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Function to display usage information
|
||||||
|
usage() {
|
||||||
|
echo "Usage: $0 [-version VERSION] [-outfile FILENAME]"
|
||||||
|
echo " -version VERSION Set the version number (default: 0.0)"
|
||||||
|
echo " -outfile FILENAME Set the output file name (default: versioninfo.rc)"
|
||||||
|
echo ""
|
||||||
|
echo "This script generates a Windows resource file with version information."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default values
|
||||||
|
version="0.0"
|
||||||
|
outfile="versioninfo.rc"
|
||||||
|
|
||||||
|
# Check if help is requested
|
||||||
|
if [[ "$1" == "-h" || "$1" == "--help" ]]; then
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse command-line options
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
-version)
|
||||||
|
if [[ -z "$2" || "$2" == -* ]]; then
|
||||||
|
echo "Error: Missing value for -version parameter"
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
version="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-outfile)
|
||||||
|
if [[ -z "$2" || "$2" == -* ]]; then
|
||||||
|
echo "Error: Missing value for -outfile parameter"
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
outfile="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Error: Unknown parameter: $1"
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Replace dots with commas for version_comma
|
||||||
|
version_comma="${version//./,}"
|
||||||
|
|
||||||
|
# Use a here document for the template with ENDFILE delimiter
|
||||||
|
cat <<ENDFILE > "$outfile"
|
||||||
|
1 VERSIONINFO
|
||||||
|
FILEVERSION $version_comma,0,0
|
||||||
|
PRODUCTVERSION $version_comma,0,0
|
||||||
|
BEGIN
|
||||||
|
BLOCK "StringFileInfo"
|
||||||
|
BEGIN
|
||||||
|
BLOCK "080904E4"
|
||||||
|
BEGIN
|
||||||
|
VALUE "CompanyName", "Old MacDonald's Farm"
|
||||||
|
VALUE "FileDescription", "Yet another RSS reader"
|
||||||
|
VALUE "FileVersion", "$version"
|
||||||
|
VALUE "InternalName", "yarr"
|
||||||
|
VALUE "LegalCopyright", "nkanaev"
|
||||||
|
VALUE "OriginalFilename", "yarr.exe"
|
||||||
|
VALUE "ProductName", "yarr"
|
||||||
|
VALUE "ProductVersion", "$version"
|
||||||
|
END
|
||||||
|
END
|
||||||
|
BLOCK "VarFileInfo"
|
||||||
|
BEGIN
|
||||||
|
VALUE "Translation", 0x809, 1252
|
||||||
|
END
|
||||||
|
END
|
||||||
|
|
||||||
|
1 ICON "icon.ico"
|
||||||
|
ENDFILE
|
||||||
|
|
||||||
|
# Set the correct permissions
|
||||||
|
chmod 644 "$outfile"
|
||||||
|
|
||||||
|
echo "Generated $outfile with version $version"
|
||||||
12
go.mod
@@ -1,9 +1,13 @@
|
|||||||
module github.com/nkanaev/yarr
|
module github.com/nkanaev/yarr
|
||||||
|
|
||||||
go 1.16
|
go 1.23.0
|
||||||
|
|
||||||
|
toolchain go1.23.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/mattn/go-sqlite3 v1.14.7
|
github.com/mattn/go-sqlite3 v1.14.24
|
||||||
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420
|
golang.org/x/net v0.38.0
|
||||||
golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6
|
golang.org/x/sys v0.31.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require golang.org/x/text v0.23.0 // indirect
|
||||||
|
|||||||
20
go.sum
@@ -1,12 +1,8 @@
|
|||||||
github.com/mattn/go-sqlite3 v1.14.7 h1:fxWBnXkxfM6sRiuH3bqJ4CfzZojMOLVc0UTsTglEghA=
|
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||||
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420 h1:a8jGStKg0XqKDlKqjLrXn0ioF5MH36pT7Z0BRTqLhbk=
|
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||||
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6 h1:cdsMqa2nXzqlgs183pHxtvoVwU7CyzaCTAUOg94af4c=
|
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||||
golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
|
||||||
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
|
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
|
||||||
|
|||||||
99
makefile
@@ -1,33 +1,90 @@
|
|||||||
VERSION=2.1
|
VERSION=2.5
|
||||||
GITHASH=$(shell git rev-parse --short=8 HEAD)
|
GITHASH=$(shell git rev-parse --short=8 HEAD)
|
||||||
|
|
||||||
CGO_ENABLED=1
|
GO_TAGS = sqlite_foreign_keys sqlite_json
|
||||||
|
GO_LDFLAGS = -s -w -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)'
|
||||||
|
|
||||||
GO_LDFLAGS = -s -w
|
GO_FLAGS = -tags "$(GO_TAGS)" -ldflags="$(GO_LDFLAGS)"
|
||||||
GO_LDFLAGS := $(GO_LDFLAGS) -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)'
|
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:
|
export CGO_ENABLED=1
|
||||||
mkdir -p _output
|
|
||||||
go build -tags "sqlite_foreign_keys release" -ldflags="$(GO_LDFLAGS)" -o _output/yarr src/main.go
|
|
||||||
|
|
||||||
build_macos:
|
default: test host
|
||||||
mkdir -p _output/macos
|
|
||||||
GOOS=darwin GOARCH=amd64 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)"
|
|
||||||
|
|
||||||
build_linux:
|
# platform-specific files
|
||||||
mkdir -p _output/linux
|
|
||||||
GOOS=linux GOARCH=amd64 go build -tags "sqlite_foreign_keys release linux" -ldflags="$(GO_LDFLAGS)" -o _output/linux/yarr src/main.go
|
|
||||||
|
|
||||||
build_windows:
|
etc/icon.icns: etc/icon_macos.png
|
||||||
mkdir -p _output/windows
|
mkdir -p etc/icon.iconset
|
||||||
go run bin/generate_versioninfo.go -version "$(VERSION)" -outfile src/platform/versioninfo.rc
|
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
|
windres -i src/platform/versioninfo.rc -O coff -o src/platform/versioninfo.syso
|
||||||
GOOS=windows GOARCH=amd64 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
|
||||||
|
|
||||||
serve:
|
serve:
|
||||||
go run -tags "sqlite_foreign_keys" src/main.go -db local.db
|
go run $(GO_FLAGS_DEBUG) ./cmd/yarr -db local.db
|
||||||
|
|
||||||
test:
|
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 test
|
||||||
|
|||||||
59
readme.md
@@ -3,7 +3,7 @@
|
|||||||
**yarr** (yet another rss reader) is a web-based feed aggregator which can be used both
|
**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.
|
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).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -11,59 +11,28 @@ It is written in Go with the frontend in Vue.js. The storage is backed by SQLite
|
|||||||
|
|
||||||
The latest prebuilt binaries for Linux/MacOS/Windows are available
|
The latest prebuilt binaries for Linux/MacOS/Windows are available
|
||||||
[here](https://github.com/nkanaev/yarr/releases/latest).
|
[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:
|
||||||
To open the app follow the instructions provided [here][macos-open] or run the command below:
|
|
||||||
|
|
||||||
xattr -d com.apple.quarantine /Applications/yarr.app
|
* MacOS: place `yarr.app` in `/Applications` folder, [open the app][macos-open], click the anchor menu bar icon, select "Open".
|
||||||
|
|
||||||
|
* Windows: open `yarr.exe`, click the anchor system tray icon, select "Open".
|
||||||
|
|
||||||
|
* Linux: place `yarr` in `$HOME/.local/bin` and run [the script](etc/install-linux.sh).
|
||||||
|
|
||||||
[macos-open]: https://support.apple.com/en-gb/guide/mac-help/mh40616/mac
|
[macos-open]: https://support.apple.com/en-gb/guide/mac-help/mh40616/mac
|
||||||
|
|
||||||
### windows
|
|
||||||
|
|
||||||
Download `yarr-*-windows32.zip`, unzip it, open `yarr.exe`
|
|
||||||
|
|
||||||
### 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;
|
|
||||||
|
|
||||||
For self-hosting, see `yarr -h` for auth, tls & server configuration flags.
|
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:
|
* [Building from source code](doc/build.md)
|
||||||
|
* [Fever API support](doc/fever.md)
|
||||||
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 .
|
|
||||||
|
|
||||||
## credits
|
## credits
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
type assetsfs struct {
|
type assetsfs struct {
|
||||||
@@ -18,8 +20,10 @@ var FS assetsfs
|
|||||||
|
|
||||||
func (afs assetsfs) Open(name string) (fs.File, error) {
|
func (afs assetsfs) Open(name string) (fs.File, error) {
|
||||||
if afs.embedded != nil {
|
if afs.embedded != nil {
|
||||||
|
fmt.Println("serving from embedded")
|
||||||
return afs.embedded.Open(name)
|
return afs.embedded.Open(name)
|
||||||
}
|
}
|
||||||
|
fmt.Println("serving local")
|
||||||
return os.DirFS("src/assets").Open(name)
|
return os.DirFS("src/assets").Open(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,9 +33,18 @@ func Template(path string) *template.Template {
|
|||||||
if !found {
|
if !found {
|
||||||
tmpl = template.Must(template.New(path).Delims("{%", "%}").Funcs(template.FuncMap{
|
tmpl = template.Must(template.New(path).Delims("{%", "%}").Funcs(template.FuncMap{
|
||||||
"inline": func(svg string) template.HTML {
|
"inline": func(svg string) template.HTML {
|
||||||
svgfile, _ := FS.Open("graphicarts/" + svg)
|
svgfile, err := FS.Open("graphicarts/" + svg)
|
||||||
content, _ := ioutil.ReadAll(svgfile)
|
// should never happen
|
||||||
svgfile.Close()
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer svgfile.Close()
|
||||||
|
|
||||||
|
content, err := ioutil.ReadAll(svgfile)
|
||||||
|
// should never happen
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
return template.HTML(content)
|
return template.HTML(content)
|
||||||
},
|
},
|
||||||
}).ParseFS(FS, path))
|
}).ParseFS(FS, path))
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// +build release
|
//go:build !debug
|
||||||
|
|
||||||
package assets
|
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>
|
<title>yarr!</title>
|
||||||
<link rel="stylesheet" href="./static/stylesheets/bootstrap.min.css">
|
<link rel="stylesheet" href="./static/stylesheets/bootstrap.min.css">
|
||||||
<link rel="stylesheet" href="./static/stylesheets/app.css">
|
<link rel="stylesheet" href="./static/stylesheets/app.css">
|
||||||
<link rel="icon shortcut" href="./static/graphicarts/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">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<script>
|
<script>
|
||||||
window.app = window.app || {}
|
window.app = window.app || {}
|
||||||
@@ -21,20 +24,23 @@
|
|||||||
<div class="p-2 toolbar d-flex align-items-center">
|
<div class="p-2 toolbar d-flex align-items-center">
|
||||||
<div class="icon mx-2">{% inline "anchor.svg" %}</div>
|
<div class="icon mx-2">{% inline "anchor.svg" %}</div>
|
||||||
<div class="flex-grow-1"></div>
|
<div class="flex-grow-1"></div>
|
||||||
<button class="toolbar-item"
|
<button class="toolbar-item ml-1"
|
||||||
:class="{active: filterSelected == 'unread'}"
|
:class="{active: filterSelected == 'unread'}"
|
||||||
|
:aria-pressed="filterSelected == 'unread'"
|
||||||
title="Unread"
|
title="Unread"
|
||||||
@click="filterSelected = 'unread'">
|
@click="filterSelected = 'unread'">
|
||||||
<span class="icon">{% inline "circle-full.svg" %}</span>
|
<span class="icon">{% inline "circle-full.svg" %}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="toolbar-item"
|
<button class="toolbar-item mx-1"
|
||||||
:class="{active: filterSelected == 'starred'}"
|
:class="{active: filterSelected == 'starred'}"
|
||||||
|
:aria-pressed="filterSelected == 'starred'"
|
||||||
title="Starred"
|
title="Starred"
|
||||||
@click="filterSelected = 'starred'">
|
@click="filterSelected = 'starred'">
|
||||||
<span class="icon">{% inline "star-full.svg" %}</span>
|
<span class="icon">{% inline "star-full.svg" %}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="toolbar-item"
|
<button class="toolbar-item mr-1"
|
||||||
:class="{active: filterSelected == ''}"
|
:class="{active: filterSelected == ''}"
|
||||||
|
:aria-pressed="filterSelected == ''"
|
||||||
title="All"
|
title="All"
|
||||||
@click="filterSelected = ''">
|
@click="filterSelected = ''">
|
||||||
<span class="icon">{% inline "assorted.svg" %}</span>
|
<span class="icon">{% inline "assorted.svg" %}</span>
|
||||||
@@ -57,25 +63,47 @@
|
|||||||
|
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
|
|
||||||
<header class="dropdown-header">Auto Refresh</header>
|
<header class="dropdown-header" role="heading" aria-level="2">Theme</header>
|
||||||
<div class="row text-center m-0">
|
<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="btn btn-link col-4 px-0 rounded-0"
|
||||||
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 10}" @click.stop="refreshRate = 10">10m</button>
|
:class="'theme-'+t"
|
||||||
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 30}" @click.stop="refreshRate = 30">30m</button>
|
:aria-label="t"
|
||||||
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 60}" @click.stop="refreshRate = 60">1h</button>
|
:aria-pressed="theme.name == t"
|
||||||
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 120}" @click.stop="refreshRate = 120">2h</button>
|
@click.stop="theme.name = t"
|
||||||
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 240}" @click.stop="refreshRate = 240">4h</button>
|
v-for="t in ['light', 'sepia', 'night']">
|
||||||
|
<span class="icon" v-if="theme.name == t">{% inline "check.svg" %}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
|
|
||||||
<header class="dropdown-header">Show first</header>
|
<header class="dropdown-header" role="heading" aria-level="2">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">Show first</header>
|
||||||
<div class="d-flex text-center">
|
<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" :aria-pressed="itemSortNewestFirst" :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=false">Old</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<header class="dropdown-header">Subscriptions</header>
|
<header class="dropdown-header" role="heading" aria-level="2">Subscriptions</header>
|
||||||
<form id="opml-import-form" enctype="multipart/form-data" tabindex="-1">
|
<form id="opml-import-form" enctype="multipart/form-data" tabindex="-1">
|
||||||
<input type="file"
|
<input type="file"
|
||||||
id="opml-import"
|
id="opml-import"
|
||||||
@@ -103,7 +131,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</dropdown>
|
</dropdown>
|
||||||
</div>
|
</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">
|
<label class="selectgroup">
|
||||||
<input type="radio" name="feed" value="" v-model="feedSelected">
|
<input type="radio" name="feed" value="" v-model="feedSelected">
|
||||||
<div class="selectgroup-label d-flex align-items-center w-100">
|
<div class="selectgroup-label d-flex align-items-center w-100">
|
||||||
@@ -116,10 +144,9 @@
|
|||||||
</label>
|
</label>
|
||||||
<div v-for="folder in foldersWithFeeds">
|
<div v-for="folder in foldersWithFeeds">
|
||||||
<label class="selectgroup mt-1"
|
<label class="selectgroup mt-1"
|
||||||
:class="{'d-none': filterSelected
|
:class="{'d-none': mustHideFolder(folder)}"
|
||||||
&& !filteredFolderStats[folder.id]
|
v-if="folder.id">
|
||||||
&& (!itemSelectedDetails || feedsById[itemSelectedDetails.feed_id].folder_id != folder.id)}">
|
<input type="radio" name="feed" :value="'folder:'+folder.id" v-model="feedSelected" v-if="folder.id">
|
||||||
<input type="radio" name="feed" :value="'folder:'+folder.id" v-model="feedSelected">
|
|
||||||
<div class="selectgroup-label d-flex align-items-center w-100" v-if="folder.id">
|
<div class="selectgroup-label d-flex align-items-center w-100" v-if="folder.id">
|
||||||
<span class="icon mr-2"
|
<span class="icon mr-2"
|
||||||
:class="{expanded: folder.is_expanded}"
|
:class="{expanded: folder.is_expanded}"
|
||||||
@@ -132,9 +159,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<div v-show="!folder.id || folder.is_expanded" class="mt-1" :class="{'pl-3': folder.id}">
|
<div v-show="!folder.id || folder.is_expanded" class="mt-1" :class="{'pl-3': folder.id}">
|
||||||
<label class="selectgroup"
|
<label class="selectgroup"
|
||||||
:class="{'d-none': filterSelected
|
:class="{'d-none': mustHideFeed(feed)}"
|
||||||
&& !filteredFeedStats[feed.id]
|
|
||||||
&& (!itemSelectedDetails || itemSelectedDetails.feed_id != feed.id)}"
|
|
||||||
v-for="feed in folder.feeds">
|
v-for="feed in folder.feeds">
|
||||||
<input type="radio" name="feed" :value="'feed:'+feed.id" v-model="feedSelected">
|
<input type="radio" name="feed" :value="'feed:'+feed.id" v-model="feedSelected">
|
||||||
<div class="selectgroup-label d-flex align-items-center w-100">
|
<div class="selectgroup-label d-flex align-items-center w-100">
|
||||||
@@ -177,20 +202,25 @@
|
|||||||
title="Mark All Read">
|
title="Mark All Read">
|
||||||
<span class="icon">{% inline "check.svg" %}</span>
|
<span class="icon">{% inline "check.svg" %}</span>
|
||||||
</button>
|
</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"
|
<dropdown class="settings-dropdown"
|
||||||
toggle-class="btn btn-link toolbar-item px-2 ml-2"
|
toggle-class="btn btn-link toolbar-item px-2 ml-2"
|
||||||
drop="right"
|
drop="right"
|
||||||
title="Feed Settings"
|
title="Feed Settings"
|
||||||
v-if="!filterSelected && current.type == 'feed'">
|
v-if="current.type == 'feed'">
|
||||||
<template v-slot:button>
|
<template v-slot:button>
|
||||||
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
||||||
</template>
|
</template>
|
||||||
<header class="dropdown-header">{{ current.feed.title }}</header>
|
<header class="dropdown-header" role="heading" aria-level="2">{{ current.feed.title }}</header>
|
||||||
<a class="dropdown-item" :href="current.feed.link" target="_blank" v-if="current.feed.link">
|
<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>
|
<span class="icon mr-1">{% inline "globe.svg" %}</span>
|
||||||
Website
|
Website
|
||||||
</a>
|
</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>
|
<span class="icon mr-1">{% inline "rss.svg" %}</span>
|
||||||
Feed Link
|
Feed Link
|
||||||
</a>
|
</a>
|
||||||
@@ -199,8 +229,12 @@
|
|||||||
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
||||||
Rename
|
Rename
|
||||||
</button>
|
</button>
|
||||||
|
<button class="dropdown-item" @click="updateFeedLink(current.feed)" v-if="current.feed.feed_link">
|
||||||
|
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
||||||
|
Change Link
|
||||||
|
</button>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<header class="dropdown-header">Move to...</header>
|
<header class="dropdown-header" role="heading" aria-level="2">Move to...</header>
|
||||||
<button class="dropdown-item"
|
<button class="dropdown-item"
|
||||||
v-if="folder.id != current.feed.folder_id"
|
v-if="folder.id != current.feed.folder_id"
|
||||||
v-for="folder in folders"
|
v-for="folder in folders"
|
||||||
@@ -226,11 +260,11 @@
|
|||||||
toggle-class="btn btn-link toolbar-item px-2 ml-2"
|
toggle-class="btn btn-link toolbar-item px-2 ml-2"
|
||||||
title="Folder Settings"
|
title="Folder Settings"
|
||||||
drop="right"
|
drop="right"
|
||||||
v-if="!filterSelected && current.type == 'folder'">
|
v-if="current.type == 'folder'">
|
||||||
<template v-slot:button>
|
<template v-slot:button>
|
||||||
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
||||||
</template>
|
</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)">
|
<button class="dropdown-item" @click="renameFolder(current.folder)">
|
||||||
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
||||||
Rename
|
Rename
|
||||||
@@ -242,7 +276,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</dropdown>
|
</dropdown>
|
||||||
</div>
|
</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"
|
<label v-for="item in items" :key="item.id"
|
||||||
class="selectgroup">
|
class="selectgroup">
|
||||||
<input type="radio" name="item" :value="item.id" v-model="itemSelected">
|
<input type="radio" name="item" :value="item.id" v-model="itemSelected">
|
||||||
@@ -253,7 +287,7 @@
|
|||||||
<span class="icon icon-small mr-1" v-if="item.status=='starred'">{% inline "star-full.svg" %}</span>
|
<span class="icon icon-small mr-1" v-if="item.status=='starred'">{% inline "star-full.svg" %}</span>
|
||||||
</transition>
|
</transition>
|
||||||
<small class="flex-fill text-truncate mr-1">
|
<small class="flex-fill text-truncate mr-1">
|
||||||
{{ feedsById[item.feed_id].title }}
|
{{ (feedsById[item.feed_id] || {}).title }}
|
||||||
</small>
|
</small>
|
||||||
<small class="flex-shrink-0"><relative-time v-bind:title="formatDate(item.date)" :val="item.date"/></small>
|
<small class="flex-shrink-0"><relative-time v-bind:title="formatDate(item.date)" :val="item.date"/></small>
|
||||||
</div>
|
</div>
|
||||||
@@ -285,14 +319,6 @@
|
|||||||
<template v-slot:button>
|
<template v-slot:button>
|
||||||
<span class="icon">{% inline "sliders.svg" %}</span>
|
<span class="icon">{% inline "sliders.svg" %}</span>
|
||||||
</template>
|
</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" :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-serif" :class="{active: theme.font == 'serif'}" @click.stop="theme.font = 'serif'">serif</button>
|
||||||
@@ -309,32 +335,50 @@
|
|||||||
title="Read Here">
|
title="Read Here">
|
||||||
<span class="icon" :class="{'icon-loading': loading.readability}">{% inline "book-open.svg" %}</span>
|
<span class="icon" :class="{'icon-loading': loading.readability}">{% inline "book-open.svg" %}</span>
|
||||||
</button>
|
</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="Open Link">
|
||||||
<span class="icon">{% inline "external-link.svg" %}</span>
|
<span class="icon">{% inline "external-link.svg" %}</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="flex-grow-1"></div>
|
<div class="flex-grow-1"></div>
|
||||||
|
<button class="toolbar-item" @click="navigateToItem(-1)" title="Previous Article" :disabled="itemSelected == items[0].id">
|
||||||
|
<span class="icon">{% inline "chevron-left.svg" %}</span>
|
||||||
|
</button>
|
||||||
|
<button class="toolbar-item" @click="navigateToItem(+1)" title="Next Article" :disabled="itemSelected == items[items.length - 1].id">
|
||||||
|
<span class="icon">{% inline "chevron-right.svg" %}</span>
|
||||||
|
</button>
|
||||||
<button class="toolbar-item" @click="itemSelected=null" title="Close Article">
|
<button class="toolbar-item" @click="itemSelected=null" title="Close Article">
|
||||||
<span class="icon">{% inline "x.svg" %}</span>
|
<span class="icon">{% inline "x.svg" %}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="itemSelectedDetails"
|
<div v-if="itemSelectedDetails"
|
||||||
ref="content"
|
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'}"
|
:class="{'font-serif': theme.font == 'serif', 'font-monospace': theme.font == 'monospace'}"
|
||||||
:style="{'font-size': theme.size + 'rem'}">
|
:style="{'font-size': theme.size + 'rem'}">
|
||||||
|
<div class="content-wrapper">
|
||||||
<h1><b>{{ itemSelectedDetails.title || 'untitled' }}</b></h1>
|
<h1><b>{{ itemSelectedDetails.title || 'untitled' }}</b></h1>
|
||||||
<div class="text-muted">
|
<div class="text-muted">
|
||||||
<div>{{ feedsById[itemSelectedDetails.feed_id].title }}</div>
|
<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>
|
<time>{{ formatDate(itemSelectedDetails.date) }}</time>
|
||||||
</div>
|
</div>
|
||||||
<hr>
|
<hr>
|
||||||
<div v-if="!itemSelectedReadability">
|
<div v-if="!itemSelectedReadability">
|
||||||
<img :src="itemSelectedDetails.image" v-if="itemSelectedDetails.image" class="mb-3">
|
<div v-if="contentImages.length">
|
||||||
<audio class="w-100" controls v-if="itemSelectedDetails.podcast_url" :src="itemSelectedDetails.podcast_url"></audio>
|
<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>
|
||||||
<div v-html="itemSelectedContent"></div>
|
<div v-html="itemSelectedContent"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<modal :open="!!settings" @hide="settings = ''">
|
<modal :open="!!settings" @hide="settings = ''">
|
||||||
<button class="btn btn-link outline-none float-right p-2 mr-n2 mt-n2" style="line-height: 1" @click="settings = ''">
|
<button class="btn btn-link outline-none float-right p-2 mr-n2 mt-n2" style="line-height: 1" @click="settings = ''">
|
||||||
<span class="icon">{% inline "x.svg" %}</span>
|
<span class="icon">{% inline "x.svg" %}</span>
|
||||||
@@ -343,14 +387,14 @@
|
|||||||
<p class="cursor-default"><b>New Feed</b></p>
|
<p class="cursor-default"><b>New Feed</b></p>
|
||||||
<form action="" @submit.prevent="createFeed(event)" class="mt-4">
|
<form action="" @submit.prevent="createFeed(event)" class="mt-4">
|
||||||
<label for="feed-url">URL</label>
|
<label for="feed-url">URL</label>
|
||||||
<input id="feed-url" name="url" type="url" class="form-control" required autocomplete="off" :readonly="feedNewChoice.length > 0">
|
<input id="feed-url" name="url" type="url" class="form-control" required autocomplete="off" :readonly="feedNewChoice.length > 0" placeholder="https://example.com/feed" v-focus>
|
||||||
<label for="feed-folder" class="mt-3 d-block">
|
<label for="feed-folder" class="mt-3 d-block">
|
||||||
Folder
|
Folder
|
||||||
<a href="#" class="float-right text-decoration-none" @click.prevent="createNewFeedFolder()">new folder</a>
|
<a href="#" class="float-right text-decoration-none" @click.prevent="createNewFeedFolder()">new folder</a>
|
||||||
</label>
|
</label>
|
||||||
<select class="form-control" id="feed-folder" name="folder_id" ref="newFeedFolder">
|
<select class="form-control" id="feed-folder" name="folder_id" ref="newFeedFolder">
|
||||||
<option value="">---</option>
|
<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>
|
</select>
|
||||||
<div class="mt-4" v-if="feedNewChoice.length">
|
<div class="mt-4" v-if="feedNewChoice.length">
|
||||||
<p class="mb-2">
|
<p class="mb-2">
|
||||||
@@ -379,6 +423,7 @@
|
|||||||
<tr><td colspan=2> </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>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>l</kbd> <kbd>h</kbd></td> <td>next / prev feed</td></tr>
|
||||||
|
<tr><td><kbd>q</kbd></td> <td>close article</td></tr>
|
||||||
|
|
||||||
<tr><td colspan=2> </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 all read</td></tr>
|
||||||
|
|||||||
@@ -105,7 +105,7 @@
|
|||||||
return api('post', './logout')
|
return api('post', './logout')
|
||||||
},
|
},
|
||||||
crawl: function(url) {
|
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
|
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 debounce = function(callback, wait) {
|
||||||
var timeout
|
var timeout
|
||||||
return function() {
|
return function() {
|
||||||
@@ -21,6 +41,12 @@ Vue.directive('scroll', {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Vue.directive('focus', {
|
||||||
|
inserted: function(el) {
|
||||||
|
el.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
Vue.component('drag', {
|
Vue.component('drag', {
|
||||||
props: ['width'],
|
props: ['width'],
|
||||||
template: '<div class="drag"></div>',
|
template: '<div class="drag"></div>',
|
||||||
@@ -185,6 +211,7 @@ var vm = new Vue({
|
|||||||
api.feeds.list_errors().then(function(errors) {
|
api.feeds.list_errors().then(function(errors) {
|
||||||
vm.feed_errors = errors
|
vm.feed_errors = errors
|
||||||
})
|
})
|
||||||
|
this.updateMetaTheme(app.settings.theme_name)
|
||||||
},
|
},
|
||||||
data: function() {
|
data: function() {
|
||||||
var s = app.settings
|
var s = app.settings
|
||||||
@@ -223,9 +250,25 @@ var vm = new Vue({
|
|||||||
'font': s.theme_font,
|
'font': s.theme_font,
|
||||||
'size': s.theme_size,
|
'size': s.theme_size,
|
||||||
},
|
},
|
||||||
|
'themeColors': {
|
||||||
|
'night': '#0e0e0e',
|
||||||
|
'sepia': '#f4f0e5',
|
||||||
|
'light': '#fff',
|
||||||
|
},
|
||||||
'refreshRate': s.refresh_rate,
|
'refreshRate': s.refresh_rate,
|
||||||
'authenticated': app.authenticated,
|
'authenticated': app.authenticated,
|
||||||
'feed_errors': {},
|
'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 },
|
||||||
|
],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -272,11 +315,28 @@ var vm = new Vue({
|
|||||||
|
|
||||||
return this.itemSelectedDetails.content || ''
|
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: {
|
watch: {
|
||||||
'theme': {
|
'theme': {
|
||||||
deep: true,
|
deep: true,
|
||||||
handler: function(theme) {
|
handler: function(theme) {
|
||||||
|
this.updateMetaTheme(theme.name)
|
||||||
document.body.classList.value = 'theme-' + theme.name
|
document.body.classList.value = 'theme-' + theme.name
|
||||||
api.settings.update({
|
api.settings.update({
|
||||||
theme_name: theme.name,
|
theme_name: theme.name,
|
||||||
@@ -352,6 +412,9 @@ var vm = new Vue({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
updateMetaTheme: function(theme) {
|
||||||
|
document.querySelector("meta[name='theme-color']").content = this.themeColors[theme]
|
||||||
|
},
|
||||||
refreshStats: function(loopMode) {
|
refreshStats: function(loopMode) {
|
||||||
return api.status().then(function(data) {
|
return api.status().then(function(data) {
|
||||||
if (loopMode && !vm.itemSelected) vm.refreshItems()
|
if (loopMode && !vm.itemSelected) vm.refreshItems()
|
||||||
@@ -401,9 +464,10 @@ var vm = new Vue({
|
|||||||
vm.feeds = values[1]
|
vm.feeds = values[1]
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
refreshItems: function(loadMore) {
|
refreshItems: function(loadMore = false) {
|
||||||
if (this.feedSelected === null) {
|
if (this.feedSelected === null) {
|
||||||
vm.items = []
|
vm.items = []
|
||||||
|
vm.itemsHasMore = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,14 +485,32 @@ var vm = new Vue({
|
|||||||
}
|
}
|
||||||
vm.itemsHasMore = data.has_more
|
vm.itemsHasMore = data.has_more
|
||||||
vm.loading.items = false
|
vm.loading.items = false
|
||||||
|
|
||||||
|
// load more if there's some space left at the bottom of the item list.
|
||||||
|
vm.$nextTick(function() {
|
||||||
|
if (vm.itemsHasMore && !vm.loading.items && vm.itemListCloseToBottom()) {
|
||||||
|
vm.refreshItems(true)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
itemListCloseToBottom: function() {
|
||||||
|
// approx. vertical space at the bottom of the list (loading el & paddings) when 1rem = 16px
|
||||||
|
var bottomSpace = 70
|
||||||
|
var scale = (parseFloat(getComputedStyle(document.documentElement).fontSize) || 16) / 16
|
||||||
|
|
||||||
|
var el = this.$refs.itemlist
|
||||||
|
|
||||||
|
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) {
|
loadMoreItems: function(event, el) {
|
||||||
if (!this.itemsHasMore) return
|
if (!this.itemsHasMore) return
|
||||||
|
|
||||||
if (this.loading.items) return
|
if (this.loading.items) return
|
||||||
var closeToBottom = (el.scrollHeight - el.scrollTop - el.offsetHeight) < 50
|
if (this.itemListCloseToBottom()) return this.refreshItems(true)
|
||||||
if (closeToBottom) this.refreshItems(true)
|
if (this.itemSelected && this.itemSelected === this.items[this.items.length - 1].id) return this.refreshItems(true)
|
||||||
},
|
},
|
||||||
markItemsRead: function() {
|
markItemsRead: function() {
|
||||||
var query = this.getItemsQuery()
|
var query = this.getItemsQuery()
|
||||||
@@ -436,6 +518,7 @@ var vm = new Vue({
|
|||||||
vm.items = []
|
vm.items = []
|
||||||
vm.itemsPage = {'cur': 1, 'num': 1}
|
vm.itemsPage = {'cur': 1, 'num': 1}
|
||||||
vm.itemSelected = null
|
vm.itemSelected = null
|
||||||
|
vm.itemsHasMore = false
|
||||||
vm.refreshStats()
|
vm.refreshStats()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -495,15 +578,20 @@ var vm = new Vue({
|
|||||||
deleteFolder: function(folder) {
|
deleteFolder: function(folder) {
|
||||||
if (confirm('Are you sure you want to delete ' + folder.title + '?')) {
|
if (confirm('Are you sure you want to delete ' + folder.title + '?')) {
|
||||||
api.folders.delete(folder.id).then(function() {
|
api.folders.delete(folder.id).then(function() {
|
||||||
if (vm.feedSelected === 'folder:'+folder.id) {
|
vm.feedSelected = null
|
||||||
vm.items = []
|
|
||||||
vm.feedSelected = ''
|
|
||||||
}
|
|
||||||
vm.refreshStats()
|
vm.refreshStats()
|
||||||
vm.refreshFeeds()
|
vm.refreshFeeds()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
updateFeedLink: function(feed) {
|
||||||
|
var newLink = prompt('Enter feed link', feed.feed_link)
|
||||||
|
if (newLink) {
|
||||||
|
api.feeds.update(feed.id, {feed_link: newLink}).then(function() {
|
||||||
|
feed.feed_link = newLink
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
renameFeed: function(feed) {
|
renameFeed: function(feed) {
|
||||||
var newTitle = prompt('Enter new title', feed.title)
|
var newTitle = prompt('Enter new title', feed.title)
|
||||||
if (newTitle) {
|
if (newTitle) {
|
||||||
@@ -515,12 +603,7 @@ var vm = new Vue({
|
|||||||
deleteFeed: function(feed) {
|
deleteFeed: function(feed) {
|
||||||
if (confirm('Are you sure you want to delete ' + feed.title + '?')) {
|
if (confirm('Are you sure you want to delete ' + feed.title + '?')) {
|
||||||
api.feeds.delete(feed.id).then(function() {
|
api.feeds.delete(feed.id).then(function() {
|
||||||
// unselect feed to prevent reading properties of null in template
|
vm.feedSelected = null
|
||||||
var isSelected = !vm.feedSelected
|
|
||||||
|| (vm.feedSelected === 'feed:'+feed.id
|
|
||||||
|| (feed.folder_id && vm.feedSelected === 'folder:'+feed.folder_id));
|
|
||||||
if (isSelected) vm.feedSelected = null
|
|
||||||
|
|
||||||
vm.refreshStats()
|
vm.refreshStats()
|
||||||
vm.refreshFeeds()
|
vm.refreshFeeds()
|
||||||
})
|
})
|
||||||
@@ -661,6 +744,90 @@ var vm = new Vue({
|
|||||||
this.filteredFolderStats = statsFolders
|
this.filteredFolderStats = statsFolders
|
||||||
this.filteredTotalStats = statsTotal
|
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)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
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) {
|
scrollContent: function(direction) {
|
||||||
var padding = 40
|
var padding = 40
|
||||||
var scroll = document.querySelector('.content')
|
var scroll = document.querySelector('.content')
|
||||||
@@ -92,7 +17,7 @@ var helperFunctions = {
|
|||||||
var shortcutFunctions = {
|
var shortcutFunctions = {
|
||||||
openItemLink: function() {
|
openItemLink: function() {
|
||||||
if (vm.itemSelectedDetails && vm.itemSelectedDetails.link) {
|
if (vm.itemSelectedDetails && vm.itemSelectedDetails.link) {
|
||||||
window.open(vm.itemSelectedDetails.link, '_blank')
|
window.open(vm.itemSelectedDetails.link, '_blank', 'noopener,noreferrer')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
toggleReadability: function() {
|
toggleReadability: function() {
|
||||||
@@ -118,16 +43,16 @@ var shortcutFunctions = {
|
|||||||
document.getElementById("searchbar").focus()
|
document.getElementById("searchbar").focus()
|
||||||
},
|
},
|
||||||
nextItem(){
|
nextItem(){
|
||||||
helperFunctions.navigateToItem(+1)
|
vm.navigateToItem(+1)
|
||||||
},
|
},
|
||||||
previousItem() {
|
previousItem() {
|
||||||
helperFunctions.navigateToItem(-1)
|
vm.navigateToItem(-1)
|
||||||
},
|
},
|
||||||
nextFeed(){
|
nextFeed(){
|
||||||
helperFunctions.navigateToFeed(+1)
|
vm.navigateToFeed(+1)
|
||||||
},
|
},
|
||||||
previousFeed() {
|
previousFeed() {
|
||||||
helperFunctions.navigateToFeed(-1)
|
vm.navigateToFeed(-1)
|
||||||
},
|
},
|
||||||
scrollForward: function() {
|
scrollForward: function() {
|
||||||
helperFunctions.scrollContent(+1)
|
helperFunctions.scrollContent(+1)
|
||||||
@@ -135,6 +60,9 @@ var shortcutFunctions = {
|
|||||||
scrollBackward: function() {
|
scrollBackward: function() {
|
||||||
helperFunctions.scrollContent(-1)
|
helperFunctions.scrollContent(-1)
|
||||||
},
|
},
|
||||||
|
closeItem: function () {
|
||||||
|
vm.itemSelected = null
|
||||||
|
},
|
||||||
showAll() {
|
showAll() {
|
||||||
vm.filterSelected = ''
|
vm.filterSelected = ''
|
||||||
},
|
},
|
||||||
@@ -160,11 +88,31 @@ var keybindings = {
|
|||||||
"h": shortcutFunctions.previousFeed,
|
"h": shortcutFunctions.previousFeed,
|
||||||
"f": shortcutFunctions.scrollForward,
|
"f": shortcutFunctions.scrollForward,
|
||||||
"b": shortcutFunctions.scrollBackward,
|
"b": shortcutFunctions.scrollBackward,
|
||||||
|
"q": shortcutFunctions.closeItem,
|
||||||
"1": shortcutFunctions.showUnread,
|
"1": shortcutFunctions.showUnread,
|
||||||
"2": shortcutFunctions.showStarred,
|
"2": shortcutFunctions.showStarred,
|
||||||
"3": shortcutFunctions.showAll,
|
"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) {
|
function isTextBox(element) {
|
||||||
var tagName = element.tagName.toLowerCase()
|
var tagName = element.tagName.toLowerCase()
|
||||||
// Input elements that aren't text
|
// Input elements that aren't text
|
||||||
@@ -179,10 +127,10 @@ function isTextBox(element) {
|
|||||||
document.addEventListener('keydown',function(event) {
|
document.addEventListener('keydown',function(event) {
|
||||||
// Ignore while focused on text or
|
// Ignore while focused on text or
|
||||||
// when using modifier keys (to not clash with browser behaviour)
|
// 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
|
return
|
||||||
}
|
}
|
||||||
var keybindFunction = keybindings[event.key]
|
var keybindFunction = keybindings[event.key] || codebindings[event.code]
|
||||||
if (keybindFunction) {
|
if (keybindFunction) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
keybindFunction()
|
keybindFunction()
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
<title>yarr!</title>
|
<title>yarr!</title>
|
||||||
<link rel="stylesheet" href="./static/stylesheets/bootstrap.min.css">
|
<link rel="stylesheet" href="./static/stylesheets/bootstrap.min.css">
|
||||||
<link rel="stylesheet" href="./static/stylesheets/app.css">
|
<link rel="stylesheet" href="./static/stylesheets/app.css">
|
||||||
<link rel="icon shortcut" href="./static/graphicarts/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">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<style>
|
<style>
|
||||||
form {
|
form {
|
||||||
@@ -21,7 +22,7 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="theme-{% .settings.theme_name %}">
|
||||||
<form action="" method="post">
|
<form action="" method="post">
|
||||||
<img src="./static/graphicarts/anchor.svg" alt="">
|
<img src="./static/graphicarts/anchor.svg" alt="">
|
||||||
{% if .error %}
|
{% if .error %}
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
html {
|
||||||
font-size: 15px !important;
|
font-size: 15px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +88,10 @@ select.form-control:not([multiple]):not([size]) {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-compact {
|
||||||
|
color: unset !important;
|
||||||
|
}
|
||||||
|
|
||||||
.table-compact tr td:first-child {
|
.table-compact tr td:first-child {
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
@@ -93,6 +100,10 @@ select.form-control:not([multiple]):not([size]) {
|
|||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scroll-touch {
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
/* custom elements */
|
/* custom elements */
|
||||||
|
|
||||||
.font-serif {
|
.font-serif {
|
||||||
@@ -160,7 +171,9 @@ select.form-control:not([multiple]):not([size]) {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
top: 0; left: 0;
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selectgroup + .selectgroup {
|
.selectgroup + .selectgroup {
|
||||||
@@ -349,6 +362,11 @@ select.form-control:not([multiple]):not([size]) {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content-wrapper {
|
||||||
|
max-width: 60rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.content img, .content video {
|
.content img, .content video {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
@@ -416,6 +434,11 @@ select.form-control:not([multiple]):not([size]) {
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content p {
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* theme: light */
|
/* theme: light */
|
||||||
|
|
||||||
button.theme-light {
|
button.theme-light {
|
||||||
@@ -423,11 +446,11 @@ button.theme-light {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a,
|
a,
|
||||||
.btn-link:hover,
|
.btn-link:hover {
|
||||||
.toolbar-item.active {
|
|
||||||
color: #0080d4;
|
color: #0080d4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar-item.active,
|
||||||
.dropdown-item.active,
|
.dropdown-item.active,
|
||||||
.dropdown-item:active,
|
.dropdown-item:active,
|
||||||
.selectgroup input:checked + .selectgroup-label {
|
.selectgroup input:checked + .selectgroup-label {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package htmlutil
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Any(els []string, el string, match func(string, string) bool) bool {
|
func Any(els []string, el string, match func(string, string) bool) bool {
|
||||||
@@ -31,3 +32,7 @@ func URLDomain(val string) string {
|
|||||||
}
|
}
|
||||||
return val
|
return val
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func IsAPossibleLink(val string) bool {
|
||||||
|
return strings.HasPrefix(val, "http://") || strings.HasPrefix(val, "https://")
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
)
|
)
|
||||||
@@ -61,3 +62,16 @@ func ExtractText(content string) string {
|
|||||||
text = whitespaceRegex.ReplaceAllLiteralString(text, " ")
|
text = whitespaceRegex.ReplaceAllLiteralString(text, " ")
|
||||||
return 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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
@@ -59,6 +60,9 @@ func ExtractContent(page io.Reader) (string, error) {
|
|||||||
best = body
|
best = body
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
if best == nil {
|
||||||
|
return "", errors.New("failed to extract content")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
//log.Printf("[Readability] TopCandidate: %v", topCandidate)
|
//log.Printf("[Readability] TopCandidate: %v", topCandidate)
|
||||||
|
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ func getExtraAttributes(tagName string) ([]string, []string) {
|
|||||||
case "iframe":
|
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":
|
case "img":
|
||||||
return []string{"loading"}, []string{`loading="lazy"`}
|
return []string{"loading"}, []string{`loading="lazy"`, `referrerpolicy="no-referrer"`}
|
||||||
default:
|
default:
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -364,7 +364,6 @@ func isBlockedTag(tagName string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
||||||
One or more strings separated by commas, indicating possible image sources for the user agent to use.
|
One or more strings separated by commas, indicating possible image sources for the user agent to use.
|
||||||
|
|
||||||
Each string is composed of:
|
Each string is composed of:
|
||||||
@@ -372,7 +371,6 @@ Each string is composed of:
|
|||||||
- Optionally, whitespace followed by one 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 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).
|
- A pixel density descriptor (a positive floating point number directly followed by x).
|
||||||
|
|
||||||
*/
|
*/
|
||||||
func sanitizeSrcsetAttr(baseURL, value string) string {
|
func sanitizeSrcsetAttr(baseURL, value string) string {
|
||||||
var sanitizedSources []string
|
var sanitizedSources []string
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import "testing"
|
|||||||
|
|
||||||
func TestValidInput(t *testing.T) {
|
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>`
|
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 {
|
if have != want {
|
||||||
t.Errorf(`Wrong output: "%s" != "%s"`, input, output)
|
t.Errorf("Wrong output: \nwant: %#v\nhave: %#v", want, have)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,31 +28,31 @@ func TestImgWithTextDataURL(t *testing.T) {
|
|||||||
|
|
||||||
func TestImgWithDataURL(t *testing.T) {
|
func TestImgWithDataURL(t *testing.T) {
|
||||||
input := `<img src="data:image/gif;base64,test" alt="Example">`
|
input := `<img src="data:image/gif;base64,test" alt="Example">`
|
||||||
expected := `<img src="data:image/gif;base64,test" alt="Example" loading="lazy">`
|
want := `<img src="data:image/gif;base64,test" alt="Example" loading="lazy" referrerpolicy="no-referrer">`
|
||||||
output := Sanitize("http://example.org/", input)
|
have := Sanitize("http://example.org/", input)
|
||||||
|
|
||||||
if output != expected {
|
if have != want {
|
||||||
t.Errorf(`Wrong output: %s`, output)
|
t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImgWithSrcset(t *testing.T) {
|
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">`
|
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">`
|
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">`
|
||||||
output := Sanitize("http://example.org/", input)
|
have := Sanitize("http://example.org/", input)
|
||||||
|
|
||||||
if output != expected {
|
if have != want {
|
||||||
t.Errorf(`Wrong output: %s`, output)
|
t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImgWithSrcsetAndDataURL(t *testing.T) {
|
func TestImgWithSrcsetAndDataURL(t *testing.T) {
|
||||||
input := `<img srcset="data:image/gif;base64,test" src="http://example.org/example-320w.jpg" alt="Example">`
|
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">`
|
want := `<img srcset="data:image/gif;base64,test" src="http://example.org/example-320w.jpg" alt="Example" loading="lazy" referrerpolicy="no-referrer">`
|
||||||
output := Sanitize("http://example.org/", input)
|
have := Sanitize("http://example.org/", input)
|
||||||
|
|
||||||
if output != expected {
|
if have != want {
|
||||||
t.Errorf(`Wrong output: %s`, output)
|
t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,16 +68,16 @@ func TestSourceWithSrcsetAndMedia(t *testing.T) {
|
|||||||
|
|
||||||
func TestMediumImgWithSrcset(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">`
|
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">`
|
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">`
|
||||||
output := Sanitize("http://example.org/", input)
|
have := Sanitize("http://example.org/", input)
|
||||||
|
|
||||||
if output != expected {
|
if have != want {
|
||||||
t.Errorf(`Wrong output: %s`, output)
|
t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSelfClosingTags(t *testing.T) {
|
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)
|
output := Sanitize("http://example.org/", input)
|
||||||
|
|
||||||
if input != output {
|
if input != output {
|
||||||
@@ -95,11 +96,11 @@ func TestTable(t *testing.T) {
|
|||||||
|
|
||||||
func TestRelativeURL(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"/>`
|
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"/>`
|
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"/>`
|
||||||
output := Sanitize("http://example.org/", input)
|
have := Sanitize("http://example.org/", input)
|
||||||
|
|
||||||
if expected != output {
|
if want != have {
|
||||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,11 +166,11 @@ func TestInvalidNestedTag(t *testing.T) {
|
|||||||
|
|
||||||
func TestValidIFrame(t *testing.T) {
|
func TestValidIFrame(t *testing.T) {
|
||||||
input := `<iframe src="http://example.org/"></iframe>`
|
input := `<iframe src="http://example.org/"></iframe>`
|
||||||
expected := `<iframe src="http://example.org/" sandbox="allow-scripts allow-same-origin allow-popups" loading="lazy"></iframe>`
|
want := `<iframe src="http://example.org/" sandbox="allow-scripts allow-same-origin allow-popups" loading="lazy"></iframe>`
|
||||||
output := Sanitize("http://example.org/", input)
|
have := Sanitize("http://example.org/", input)
|
||||||
|
|
||||||
if expected != output {
|
if want != have {
|
||||||
t.Errorf("Wrong output:\nwant: %s\nhave: %s", expected, output)
|
t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package scraper
|
package scraper
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/nkanaev/yarr/src/content/htmlutil"
|
"github.com/nkanaev/yarr/src/content/htmlutil"
|
||||||
@@ -35,6 +36,18 @@ func FindFeeds(body string, base string) map[string]string {
|
|||||||
link := htmlutil.AbsoluteUrl(href, base)
|
link := htmlutil.AbsoluteUrl(href, base)
|
||||||
if link != "" {
|
if link != "" {
|
||||||
candidates[link] = name
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ package parser
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"html"
|
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -47,6 +46,8 @@ type atomLinks []atomLink
|
|||||||
func (a *atomText) Text() string {
|
func (a *atomText) Text() string {
|
||||||
if a.Type == "html" {
|
if a.Type == "html" {
|
||||||
return htmlutil.ExtractText(a.Data)
|
return htmlutil.ExtractText(a.Data)
|
||||||
|
} else if a.Type == "xhtml" {
|
||||||
|
return htmlutil.ExtractText(a.XML)
|
||||||
}
|
}
|
||||||
return a.Data
|
return a.Data
|
||||||
}
|
}
|
||||||
@@ -56,7 +57,7 @@ func (a *atomText) String() string {
|
|||||||
if a.Type == "xhtml" {
|
if a.Type == "xhtml" {
|
||||||
data = a.XML
|
data = a.XML
|
||||||
}
|
}
|
||||||
return html.UnescapeString(strings.TrimSpace(data))
|
return strings.TrimSpace(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (links atomLinks) First(rel string) string {
|
func (links atomLinks) First(rel string) string {
|
||||||
@@ -81,15 +82,23 @@ func ParseAtom(r io.Reader) (*Feed, error) {
|
|||||||
SiteURL: firstNonEmpty(srcfeed.Links.First("alternate"), srcfeed.Links.First("")),
|
SiteURL: firstNonEmpty(srcfeed.Links.First("alternate"), srcfeed.Links.First("")),
|
||||||
}
|
}
|
||||||
for _, srcitem := range srcfeed.Entries {
|
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{
|
dstfeed.Items = append(dstfeed.Items, Item{
|
||||||
GUID: firstNonEmpty(srcitem.ID, link),
|
GUID: firstNonEmpty(guidFromID, srcitem.ID, link),
|
||||||
Date: dateParse(firstNonEmpty(srcitem.Published, srcitem.Updated)),
|
Date: dateParse(firstNonEmpty(srcitem.Published, srcitem.Updated)),
|
||||||
URL: link,
|
URL: link,
|
||||||
Title: srcitem.Title.Text(),
|
Title: srcitem.Title.Text(),
|
||||||
Content: firstNonEmpty(srcitem.Content.String(), srcitem.Summary.String(), srcitem.firstMediaDescription()),
|
Content: firstNonEmpty(srcitem.Content.String(), srcitem.Summary.String(), srcitem.firstMediaDescription()),
|
||||||
ImageURL: srcitem.firstMediaThumbnail(),
|
MediaLinks: mediaLinks,
|
||||||
AudioURL: "",
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return dstfeed, nil
|
return dstfeed, nil
|
||||||
|
|||||||
@@ -45,8 +45,6 @@ func TestAtom(t *testing.T) {
|
|||||||
URL: "http://example.org/2003/12/13/atom03.html",
|
URL: "http://example.org/2003/12/13/atom03.html",
|
||||||
Title: "Atom-Powered Robots Run Amok",
|
Title: "Atom-Powered Robots Run Amok",
|
||||||
Content: `<div xmlns="http://www.w3.org/1999/xhtml"><p>This is the entry content.</p></div>`,
|
Content: `<div xmlns="http://www.w3.org/1999/xhtml"><p>This is the entry content.</p></div>`,
|
||||||
ImageURL: "",
|
|
||||||
AudioURL: "",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -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) {
|
func TestAtomImageLink(t *testing.T) {
|
||||||
feed, _ := Parse(strings.NewReader(`
|
feed, _ := Parse(strings.NewReader(`
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
@@ -103,9 +139,15 @@ func TestAtomImageLink(t *testing.T) {
|
|||||||
</entry>
|
</entry>
|
||||||
</feed>
|
</feed>
|
||||||
`))
|
`))
|
||||||
have := feed.Items[0].ImageURL
|
if len(feed.Items[0].MediaLinks) != 1 {
|
||||||
want := `https://example.com/image.png?width=100&height=100`
|
t.Fatalf("Expected 1 media link, got: %#v", feed.Items[0].MediaLinks)
|
||||||
if want != have {
|
}
|
||||||
|
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)
|
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 {
|
if want != have {
|
||||||
t.Fatalf("want: %#v\nhave: %#v\n", want, have)
|
t.Fatalf("want: %#v\nhave: %#v\n", want, have)
|
||||||
}
|
}
|
||||||
if feed.Items[0].ImageURL != "" {
|
if len(feed.Items[0].MediaLinks) != 0 {
|
||||||
t.Fatal("item.image_url must be unset if present in the content")
|
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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -9,18 +10,25 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/nkanaev/yarr/src/content/htmlutil"
|
||||||
|
"golang.org/x/net/html/charset"
|
||||||
)
|
)
|
||||||
|
|
||||||
var UnknownFormat = errors.New("unknown feed format")
|
var UnknownFormat = 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.TrimSpace(lookup)
|
||||||
lookup = strings.TrimLeft(lookup, "\x00\xEF\xBB\xBF\xFE\xFF")
|
lookup = strings.TrimLeft(lookup, "\x00\xEF\xBB\xBF\xFE\xFF")
|
||||||
|
|
||||||
if len(lookup) < 0 {
|
if len(lookup) == 0 {
|
||||||
return "", nil
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch lookup[0] {
|
switch lookup[0] {
|
||||||
@@ -31,24 +39,42 @@ func sniff(lookup string) (string, processor) {
|
|||||||
if token == nil {
|
if token == nil {
|
||||||
break
|
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 {
|
if el, ok := token.(xml.StartElement); ok {
|
||||||
switch el.Name.Local {
|
switch el.Name.Local {
|
||||||
case "rss":
|
case "rss":
|
||||||
return "rss", ParseRSS
|
out.feedType = "rss"
|
||||||
|
out.callback = ParseRSS
|
||||||
|
return
|
||||||
case "RDF":
|
case "RDF":
|
||||||
return "rdf", ParseRDF
|
out.feedType = "rdf"
|
||||||
|
out.callback = ParseRDF
|
||||||
|
return
|
||||||
case "feed":
|
case "feed":
|
||||||
return "atom", ParseAtom
|
out.feedType = "atom"
|
||||||
|
out.callback = ParseAtom
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case '{':
|
case '{':
|
||||||
return "json", ParseJSON
|
out.feedType = "json"
|
||||||
|
out.callback = ParseJSON
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return "", nil
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func Parse(r io.Reader) (*Feed, error) {
|
func Parse(r io.Reader) (*Feed, error) {
|
||||||
|
return ParseWithEncoding(r, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseWithEncoding(r io.Reader, fallbackEncoding string) (*Feed, error) {
|
||||||
lookup := make([]byte, 2048)
|
lookup := make([]byte, 2048)
|
||||||
n, err := io.ReadFull(r, lookup)
|
n, err := io.ReadFull(r, lookup)
|
||||||
switch {
|
switch {
|
||||||
@@ -61,18 +87,43 @@ func Parse(r io.Reader) (*Feed, error) {
|
|||||||
r = io.MultiReader(bytes.NewReader(lookup), r)
|
r = io.MultiReader(bytes.NewReader(lookup), r)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, callback := sniff(string(lookup))
|
out := sniff(string(lookup))
|
||||||
if callback == nil {
|
if out.feedType == "" {
|
||||||
return nil, UnknownFormat
|
return nil, UnknownFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
if feed != nil {
|
||||||
feed.cleanup()
|
feed.cleanup()
|
||||||
}
|
}
|
||||||
return feed, err
|
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() {
|
func (feed *Feed) cleanup() {
|
||||||
feed.Title = strings.TrimSpace(feed.Title)
|
feed.Title = strings.TrimSpace(feed.Title)
|
||||||
feed.SiteURL = strings.TrimSpace(feed.SiteURL)
|
feed.SiteURL = strings.TrimSpace(feed.SiteURL)
|
||||||
@@ -80,14 +131,17 @@ func (feed *Feed) cleanup() {
|
|||||||
for i, item := range feed.Items {
|
for i, item := range feed.Items {
|
||||||
feed.Items[i].GUID = strings.TrimSpace(item.GUID)
|
feed.Items[i].GUID = strings.TrimSpace(item.GUID)
|
||||||
feed.Items[i].URL = strings.TrimSpace(item.URL)
|
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)
|
feed.Items[i].Content = strings.TrimSpace(item.Content)
|
||||||
|
|
||||||
if item.ImageURL != "" && strings.Contains(item.Content, item.ImageURL) {
|
if len(feed.Items[i].MediaLinks) > 0 {
|
||||||
feed.Items[i].ImageURL = ""
|
mediaLinks := make([]MediaLink, 0)
|
||||||
|
for _, link := range item.MediaLinks {
|
||||||
|
if !strings.Contains(item.Content, link.URL) {
|
||||||
|
mediaLinks = append(mediaLinks, link)
|
||||||
}
|
}
|
||||||
if item.AudioURL != "" && strings.Contains(item.Content, item.AudioURL) {
|
}
|
||||||
feed.Items[i].AudioURL = ""
|
feed.Items[i].MediaLinks = mediaLinks
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,3 +173,12 @@ func (feed *Feed) TranslateURLs(base string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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,40 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestSniff(t *testing.T) {
|
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>`,
|
`<?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>`,
|
`<?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>`,
|
`<?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>`,
|
`<?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>`,
|
`<!DOCTYPE html><html><head><title></title></head><body></body></html>`,
|
||||||
"",
|
feedProbe{},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, testcase := range testcases {
|
for _, testcase := range testcases {
|
||||||
have, _ := sniff(testcase[0])
|
want := testcase.want
|
||||||
want := testcase[1]
|
have := sniff(testcase.input)
|
||||||
if want != have {
|
if want.encoding != have.encoding || want.feedType != have.feedType {
|
||||||
t.Log(testcase[0])
|
t.Errorf("Invalid output\n---\n%s\n---\n\nwant=%#v\nhave=%#v", testcase.input, want, have)
|
||||||
t.Errorf("Invalid format: want=%#v have=%#v", want, have)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,3 +109,73 @@ func TestParseFeedWithBOM(t *testing.T) {
|
|||||||
t.FailNow()
|
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
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
type media struct {
|
type media struct {
|
||||||
MediaGroups []mediaGroup `xml:"http://search.yahoo.com/mrss/ group"`
|
MediaGroups []mediaGroup `xml:"http://search.yahoo.com/mrss/ group"`
|
||||||
MediaContents []mediaContent `xml:"http://search.yahoo.com/mrss/ content"`
|
MediaContents []mediaContent `xml:"http://search.yahoo.com/mrss/ content"`
|
||||||
@@ -8,12 +12,17 @@ type media struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type mediaGroup struct {
|
type mediaGroup struct {
|
||||||
|
MediaContent []mediaContent `xml:"http://search.yahoo.com/mrss/ content"`
|
||||||
MediaThumbnails []mediaThumbnail `xml:"http://search.yahoo.com/mrss/ thumbnail"`
|
MediaThumbnails []mediaThumbnail `xml:"http://search.yahoo.com/mrss/ thumbnail"`
|
||||||
MediaDescriptions []mediaDescription `xml:"http://search.yahoo.com/mrss/ description"`
|
MediaDescriptions []mediaDescription `xml:"http://search.yahoo.com/mrss/ description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type mediaContent struct {
|
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 {
|
type mediaThumbnail struct {
|
||||||
@@ -22,7 +31,7 @@ type mediaThumbnail struct {
|
|||||||
|
|
||||||
type mediaDescription struct {
|
type mediaDescription struct {
|
||||||
Type string `xml:"type,attr"`
|
Type string `xml:"type,attr"`
|
||||||
Description string `xml:",chardata"`
|
Text string `xml:",chardata"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *media) firstMediaThumbnail() string {
|
func (m *media) firstMediaThumbnail() string {
|
||||||
@@ -44,12 +53,59 @@ func (m *media) firstMediaThumbnail() string {
|
|||||||
|
|
||||||
func (m *media) firstMediaDescription() string {
|
func (m *media) firstMediaDescription() string {
|
||||||
for _, d := range m.MediaDescriptions {
|
for _, d := range m.MediaDescriptions {
|
||||||
return plain2html(d.Description)
|
return plain2html(d.Text)
|
||||||
}
|
}
|
||||||
for _, g := range m.MediaGroups {
|
for _, g := range m.MediaGroups {
|
||||||
for _, d := range g.MediaDescriptions {
|
for _, d := range g.MediaDescriptions {
|
||||||
return plain2html(d.Description)
|
return plain2html(d.Text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ type Item struct {
|
|||||||
Title string
|
Title string
|
||||||
|
|
||||||
Content string
|
Content string
|
||||||
ImageURL string
|
MediaLinks []MediaLink
|
||||||
AudioURL string
|
}
|
||||||
|
|
||||||
|
type MediaLink struct {
|
||||||
|
URL string
|
||||||
|
Type string
|
||||||
|
Description string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ type rssFeed struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type rssItem struct {
|
type rssItem struct {
|
||||||
GUID string `xml:"guid"`
|
GUID rssGuid `xml:"guid"`
|
||||||
Title string `xml:"title"`
|
Title string `xml:"title"`
|
||||||
Link string `xml:"link"`
|
Link string `xml:"rss link"`
|
||||||
Description string `xml:"rss description"`
|
Description string `xml:"rss description"`
|
||||||
PubDate string `xml:"pubDate"`
|
PubDate string `xml:"pubDate"`
|
||||||
Enclosures []rssEnclosure `xml:"enclosure"`
|
Enclosures []rssEnclosure `xml:"enclosure"`
|
||||||
@@ -36,6 +36,11 @@ type rssItem struct {
|
|||||||
media
|
media
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type rssGuid struct {
|
||||||
|
GUID string `xml:",chardata"`
|
||||||
|
IsPermaLink string `xml:"isPermaLink,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
type rssLink struct {
|
type rssLink struct {
|
||||||
XMLName xml.Name
|
XMLName xml.Name
|
||||||
Data string `xml:",chardata"`
|
Data string `xml:",chardata"`
|
||||||
@@ -69,26 +74,30 @@ func ParseRSS(r io.Reader) (*Feed, error) {
|
|||||||
SiteURL: srcfeed.Link,
|
SiteURL: srcfeed.Link,
|
||||||
}
|
}
|
||||||
for _, srcitem := range srcfeed.Items {
|
for _, srcitem := range srcfeed.Items {
|
||||||
podcastURL := ""
|
mediaLinks := srcitem.mediaLinks()
|
||||||
for _, e := range srcitem.Enclosures {
|
for _, e := range srcitem.Enclosures {
|
||||||
if strings.HasPrefix(e.Type, "audio/") {
|
if strings.HasPrefix(e.Type, "audio/") {
|
||||||
podcastURL = e.URL
|
podcastURL := e.URL
|
||||||
|
|
||||||
if srcitem.OrigEnclosureLink != "" && strings.Contains(podcastURL, path.Base(srcitem.OrigEnclosureLink)) {
|
if srcitem.OrigEnclosureLink != "" && strings.Contains(podcastURL, path.Base(srcitem.OrigEnclosureLink)) {
|
||||||
podcastURL = srcitem.OrigEnclosureLink
|
podcastURL = srcitem.OrigEnclosureLink
|
||||||
}
|
}
|
||||||
|
mediaLinks = append(mediaLinks, MediaLink{URL: podcastURL, Type: "audio"})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
permalink := ""
|
||||||
|
if srcitem.GUID.IsPermaLink == "true" {
|
||||||
|
permalink = srcitem.GUID.GUID
|
||||||
|
}
|
||||||
|
|
||||||
dstfeed.Items = append(dstfeed.Items, Item{
|
dstfeed.Items = append(dstfeed.Items, Item{
|
||||||
GUID: firstNonEmpty(srcitem.GUID, srcitem.Link),
|
GUID: firstNonEmpty(srcitem.GUID.GUID, srcitem.Link),
|
||||||
Date: dateParse(firstNonEmpty(srcitem.DublinCoreDate, srcitem.PubDate)),
|
Date: dateParse(firstNonEmpty(srcitem.DublinCoreDate, srcitem.PubDate)),
|
||||||
URL: firstNonEmpty(srcitem.OrigLink, srcitem.Link),
|
URL: firstNonEmpty(srcitem.OrigLink, srcitem.Link, permalink),
|
||||||
Title: srcitem.Title,
|
Title: srcitem.Title,
|
||||||
Content: firstNonEmpty(srcitem.ContentEncoded, srcitem.Description),
|
Content: firstNonEmpty(srcitem.ContentEncoded, srcitem.Description, srcitem.firstMediaDescription()),
|
||||||
AudioURL: podcastURL,
|
MediaLinks: mediaLinks,
|
||||||
ImageURL: srcitem.firstMediaThumbnail(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return dstfeed, nil
|
return dstfeed, nil
|
||||||
|
|||||||
@@ -75,9 +75,15 @@ func TestRSSMediaContentThumbnail(t *testing.T) {
|
|||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
`))
|
`))
|
||||||
have := feed.Items[0].ImageURL
|
if len(feed.Items[0].MediaLinks) != 1 {
|
||||||
want := "https://i.vimeocdn.com/video/1092705247_960.jpg"
|
t.Fatalf("Expected 1 media link, got %#v", feed.Items[0].MediaLinks)
|
||||||
if have != want {
|
}
|
||||||
|
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("want: %#v", want)
|
||||||
t.Logf("have: %#v", have)
|
t.Logf("have: %#v", have)
|
||||||
t.FailNow()
|
t.FailNow()
|
||||||
@@ -127,9 +133,15 @@ func TestRSSPodcast(t *testing.T) {
|
|||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
`))
|
`))
|
||||||
have := feed.Items[0].AudioURL
|
if len(feed.Items[0].MediaLinks) != 1 {
|
||||||
want := "http://example.com/audio.ext"
|
t.Fatal("Invalid media links")
|
||||||
if want != have {
|
}
|
||||||
|
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("want: %#v", want)
|
||||||
t.Logf("have: %#v", have)
|
t.Logf("have: %#v", have)
|
||||||
t.FailNow()
|
t.FailNow()
|
||||||
@@ -147,9 +159,15 @@ func TestRSSOpusPodcast(t *testing.T) {
|
|||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
`))
|
`))
|
||||||
have := feed.Items[0].AudioURL
|
if len(feed.Items[0].MediaLinks) != 1 {
|
||||||
want := "http://example.com/audio.ext"
|
t.Fatal("Invalid media links")
|
||||||
if want != have {
|
}
|
||||||
|
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("want: %#v", want)
|
||||||
t.Logf("have: %#v", have)
|
t.Logf("have: %#v", have)
|
||||||
t.FailNow()
|
t.FailNow()
|
||||||
@@ -176,7 +194,95 @@ func TestRSSPodcastDuplicated(t *testing.T) {
|
|||||||
if want != have {
|
if want != have {
|
||||||
t.Fatalf("content doesn't match\nwant: %#v\nhave: %#v\n", 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 := 0; i < len(want); i++ {
|
||||||
|
if want[i] != have[i] {
|
||||||
|
t.Errorf("title doesn't match\nwant: %#v\nhave: %#v\n", want[i], have[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRSSIsPermalink(t *testing.T) {
|
||||||
|
feed, _ := Parse(strings.NewReader(`
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
||||||
|
<channel>
|
||||||
|
<item>
|
||||||
|
<guid isPermaLink="true">http://example.com/posts/1</guid>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
|
`))
|
||||||
|
have := feed.Items
|
||||||
|
want := []Item{
|
||||||
|
{
|
||||||
|
GUID: "http://example.com/posts/1",
|
||||||
|
URL: "http://example.com/posts/1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for i := 0; i < len(want); i++ {
|
||||||
|
if !reflect.DeepEqual(want, have) {
|
||||||
|
t.Errorf("Failed to handle isPermalink\nwant: %#v\nhave: %#v\n", want[i], have[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
package parser
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"io"
|
"io"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -30,6 +32,81 @@ func plain2html(text string) string {
|
|||||||
func xmlDecoder(r io.Reader) *xml.Decoder {
|
func xmlDecoder(r io.Reader) *xml.Decoder {
|
||||||
decoder := xml.NewDecoder(r)
|
decoder := xml.NewDecoder(r)
|
||||||
decoder.Strict = false
|
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
|
return decoder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type safexmlreader struct {
|
||||||
|
reader *bufio.Reader
|
||||||
|
buffer *bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSafeXMLReader(r io.Reader) io.Reader {
|
||||||
|
return &safexmlreader{
|
||||||
|
reader: bufio.NewReader(r),
|
||||||
|
buffer: bytes.NewBuffer(make([]byte, 0, 4096)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (xr *safexmlreader) Read(p []byte) (int, error) {
|
||||||
|
for xr.buffer.Len() < cap(p) {
|
||||||
|
r, _, err := xr.reader.ReadRune()
|
||||||
|
if err == io.EOF {
|
||||||
|
if xr.buffer.Len() == 0 {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if isInCharacterRange(r) {
|
||||||
|
xr.buffer.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return xr.buffer.Read(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: copied from "encoding/xml" package
|
||||||
|
// Decide whether the given rune is in the XML Character Range, per
|
||||||
|
// the Char production of https://www.xml.com/axml/testaxml.htm,
|
||||||
|
// Section 2.2 Characters.
|
||||||
|
func isInCharacterRange(r rune) (inrange bool) {
|
||||||
|
return r == 0x09 ||
|
||||||
|
r == 0x0A ||
|
||||||
|
r == 0x0D ||
|
||||||
|
r >= 0x20 && r <= 0xD7FF ||
|
||||||
|
r >= 0xE000 && r <= 0xFFFD ||
|
||||||
|
r >= 0x10000 && r <= 0x10FFFF
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: copied from "encoding/xml" package
|
||||||
|
// procInst parses the `param="..."` or `param='...'`
|
||||||
|
// value out of the provided string, returning "" if not found.
|
||||||
|
func procInst(param, s string) string {
|
||||||
|
// TODO: this parsing is somewhat lame and not exact.
|
||||||
|
// It works for all actual cases, though.
|
||||||
|
param = param + "="
|
||||||
|
idx := strings.Index(s, param)
|
||||||
|
if idx == -1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
v := s[idx+len(param):]
|
||||||
|
if v == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if v[0] != '\'' && v[0] != '"' {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
idx = strings.IndexRune(v[1:], rune(v[0]))
|
||||||
|
if idx == -1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return v[1 : idx+1]
|
||||||
|
}
|
||||||
|
|||||||
88
src/parser/util_test.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSafeXMLReader(t *testing.T) {
|
||||||
|
var f io.Reader
|
||||||
|
want := []byte("привет мир")
|
||||||
|
f = bytes.NewReader(want)
|
||||||
|
f = NewSafeXMLReader(f)
|
||||||
|
|
||||||
|
have, err := io.ReadAll(f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(want, have) {
|
||||||
|
t.Fatalf("invalid output\nwant: %v\nhave: %v", want, have)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSafeXMLReaderRemoveUnwantedRunes(t *testing.T) {
|
||||||
|
var f io.Reader
|
||||||
|
input := []byte("\aпривет \x0cмир\ufffe\uffff")
|
||||||
|
want := []byte("привет мир")
|
||||||
|
f = bytes.NewReader(input)
|
||||||
|
f = NewSafeXMLReader(f)
|
||||||
|
|
||||||
|
have, err := io.ReadAll(f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(want, have) {
|
||||||
|
t.Fatalf("invalid output\nwant: %v\nhave: %v", want, have)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSafeXMLReaderPartial1(t *testing.T) {
|
||||||
|
var f io.Reader
|
||||||
|
input := []byte("\aпривет \x0cмир\ufffe\uffff")
|
||||||
|
want := []byte("привет мир")
|
||||||
|
f = bytes.NewReader(input)
|
||||||
|
f = NewSafeXMLReader(f)
|
||||||
|
|
||||||
|
buf := make([]byte, 1)
|
||||||
|
for i := 0; i < len(want); i++ {
|
||||||
|
n, err := f.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("expected 1 byte, got %d", n)
|
||||||
|
}
|
||||||
|
if buf[0] != want[i] {
|
||||||
|
t.Fatalf("invalid char at pos %d\nwant: %v\nhave: %v", i, want[i], buf[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if x, err := f.Read(buf); err != io.EOF {
|
||||||
|
t.Fatalf("expected EOF, %v, %v %v", buf, x, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSafeXMLReaderPartial2(t *testing.T) {
|
||||||
|
var f io.Reader
|
||||||
|
input := []byte("привет\a\a\a\a\a")
|
||||||
|
f = bytes.NewReader(input)
|
||||||
|
f = NewSafeXMLReader(f)
|
||||||
|
|
||||||
|
buf := make([]byte, 12)
|
||||||
|
n, err := f.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
if n != 12 {
|
||||||
|
t.Fatalf("expected 12 bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err = f.Read(buf)
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatalf("expected 0")
|
||||||
|
}
|
||||||
|
if err != io.EOF {
|
||||||
|
t.Fatalf("expected EOF, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
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,4 +1,4 @@
|
|||||||
// +build macos windows
|
//go:build (darwin || windows) && gui
|
||||||
|
|
||||||
package platform
|
package platform
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
func Start(s *server.Server) {
|
func Start(s *server.Server) {
|
||||||
systrayOnReady := func() {
|
systrayOnReady := func() {
|
||||||
systray.SetIcon(Icon)
|
systray.SetIcon(Icon)
|
||||||
|
systray.SetTooltip("yarr")
|
||||||
|
|
||||||
menuOpen := systray.AddMenuItem("Open", "")
|
menuOpen := systray.AddMenuItem("Open", "")
|
||||||
systray.AddSeparator()
|
systray.AddSeparator()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// +build !windows,!macos
|
//go:build !gui
|
||||||
|
|
||||||
package platform
|
package platform
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// +build macos
|
//go:build darwin && gui
|
||||||
|
|
||||||
package platform
|
package platform
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// +build windows
|
//go:build windows && gui
|
||||||
|
|
||||||
package platform
|
package platform
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// +build !windows,!darwin
|
//go:build linux || freebsd || openbsd
|
||||||
|
|
||||||
package platform
|
package platform
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// +build darwin
|
//go:build darwin
|
||||||
|
|
||||||
package platform
|
package platform
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// +build windows
|
//go:build windows
|
||||||
|
|
||||||
package platform
|
package platform
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func IsAuthenticated(req *http.Request, username, password string) bool {
|
func IsAuthenticated(req *http.Request, username, password string) bool {
|
||||||
@@ -26,8 +25,10 @@ func Authenticate(rw http.ResponseWriter, username, password, basepath string) {
|
|||||||
http.SetCookie(rw, &http.Cookie{
|
http.SetCookie(rw, &http.Cookie{
|
||||||
Name: "auth",
|
Name: "auth",
|
||||||
Value: username + ":" + secret(username, password),
|
Value: username + ":" + secret(username, password),
|
||||||
Expires: time.Now().Add(time.Hour * 24 * 7), // 1 week,
|
MaxAge: 604800, // 1 week
|
||||||
Path: basepath,
|
Path: basepath,
|
||||||
|
Secure: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,15 @@ import (
|
|||||||
|
|
||||||
"github.com/nkanaev/yarr/src/assets"
|
"github.com/nkanaev/yarr/src/assets"
|
||||||
"github.com/nkanaev/yarr/src/server/router"
|
"github.com/nkanaev/yarr/src/server/router"
|
||||||
|
"github.com/nkanaev/yarr/src/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Middleware struct {
|
type Middleware struct {
|
||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
BasePath string
|
BasePath string
|
||||||
Public string
|
Public []string
|
||||||
|
DB *storage.Storage
|
||||||
}
|
}
|
||||||
|
|
||||||
func unsafeMethod(method string) bool {
|
func unsafeMethod(method string) bool {
|
||||||
@@ -20,10 +22,12 @@ func unsafeMethod(method string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Middleware) Handler(c *router.Context) {
|
func (m *Middleware) Handler(c *router.Context) {
|
||||||
if strings.HasPrefix(c.Req.URL.Path, m.BasePath+m.Public) {
|
for _, path := range m.Public {
|
||||||
|
if strings.HasPrefix(c.Req.URL.Path, m.BasePath+path) {
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if IsAuthenticated(c.Req, m.Username, m.Password) {
|
if IsAuthenticated(c.Req, m.Username, m.Password) {
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
@@ -44,12 +48,15 @@ func (m *Middleware) Handler(c *router.Context) {
|
|||||||
c.Redirect(rootUrl)
|
c.Redirect(rootUrl)
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]string{
|
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]interface{}{
|
||||||
"username": username,
|
"username": username,
|
||||||
"error": "Invalid username/password",
|
"error": "Invalid username/password",
|
||||||
|
"settings": m.DB.GetSettings(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.HTML(http.StatusOK, assets.Template("login.html"), nil)
|
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]interface{}{
|
||||||
|
"settings": m.DB.GetSettings(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
393
src/server/fever.go
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
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]interface{}, lastRefreshed int64) {
|
||||||
|
data["api_version"] = 3
|
||||||
|
data["auth"] = 1
|
||||||
|
data["last_refreshed_on_time"] = lastRefreshed
|
||||||
|
c.JSON(http.StatusOK, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLastRefreshedOnTime(httpStates map[int64]storage.HTTPState) int64 {
|
||||||
|
if len(httpStates) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastRefreshed int64
|
||||||
|
for _, state := range httpStates {
|
||||||
|
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([]byte(fmt.Sprintf("%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]interface{}{
|
||||||
|
"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:
|
||||||
|
c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"api_version": 3,
|
||||||
|
"auth": 1,
|
||||||
|
"last_refreshed_on_time": getLastRefreshedOnTime(s.db.ListHTTPStates()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
}
|
||||||
|
writeFeverJSON(c, map[string]interface{}{
|
||||||
|
"groups": groups,
|
||||||
|
"feeds_groups": feedGroups(s.db),
|
||||||
|
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) feverFeedsHandler(c *router.Context) {
|
||||||
|
feeds := s.db.ListFeeds()
|
||||||
|
httpStates := s.db.ListHTTPStates()
|
||||||
|
|
||||||
|
feverFeeds := make([]*FeverFeed, len(feeds))
|
||||||
|
for i, feed := range feeds {
|
||||||
|
var lastUpdated int64
|
||||||
|
if state, ok := httpStates[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]interface{}{
|
||||||
|
"feeds": feverFeeds,
|
||||||
|
"feeds_groups": feedGroups(s.db),
|
||||||
|
}, getLastRefreshedOnTime(httpStates))
|
||||||
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFeverJSON(c, map[string]interface{}{
|
||||||
|
"favicons": favicons,
|
||||||
|
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 := storage.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 == storage.STARRED {
|
||||||
|
isSaved = 1
|
||||||
|
}
|
||||||
|
isRead := 0
|
||||||
|
if item.Status == storage.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(storage.ItemFilter{})
|
||||||
|
|
||||||
|
writeFeverJSON(c, map[string]interface{}{
|
||||||
|
"items": feverItems,
|
||||||
|
"total_items": totalItems,
|
||||||
|
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) feverLinksHandler(c *router.Context) {
|
||||||
|
writeFeverJSON(c, map[string]interface{}{
|
||||||
|
"links": make([]interface{}, 0),
|
||||||
|
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) feverUnreadItemIDsHandler(c *router.Context) {
|
||||||
|
status := storage.UNREAD
|
||||||
|
itemIds := make([]int64, 0)
|
||||||
|
|
||||||
|
itemFilter := storage.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
|
||||||
|
}
|
||||||
|
writeFeverJSON(c, map[string]interface{}{
|
||||||
|
"unread_item_ids": joinInts(itemIds),
|
||||||
|
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) feverSavedItemIDsHandler(c *router.Context) {
|
||||||
|
status := storage.STARRED
|
||||||
|
itemIds := make([]int64, 0)
|
||||||
|
|
||||||
|
itemFilter := storage.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
|
||||||
|
}
|
||||||
|
writeFeverJSON(c, map[string]interface{}{
|
||||||
|
"saved_item_ids": joinInts(itemIds),
|
||||||
|
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 storage.ItemStatus
|
||||||
|
switch c.Req.Form.Get("as") {
|
||||||
|
case "read":
|
||||||
|
status = storage.READ
|
||||||
|
case "unread":
|
||||||
|
status = storage.UNREAD
|
||||||
|
case "saved":
|
||||||
|
status = storage.STARRED
|
||||||
|
case "unsaved":
|
||||||
|
status = storage.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 := storage.MarkFilter{FeedID: &id}
|
||||||
|
x, _ := strconv.ParseInt(c.Req.Form.Get("before"), 10, 64)
|
||||||
|
if x > 0 {
|
||||||
|
before := time.Unix(x, 0)
|
||||||
|
markFilter.Before = &before
|
||||||
|
}
|
||||||
|
s.db.MarkItemsRead(markFilter)
|
||||||
|
case "group":
|
||||||
|
if c.Req.Form.Get("as") != "read" {
|
||||||
|
c.Out.WriteHeader(http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
markFilter := storage.MarkFilter{FolderID: &id}
|
||||||
|
x, _ := strconv.ParseInt(c.Req.Form.Get("before"), 10, 64)
|
||||||
|
if x > 0 {
|
||||||
|
before := time.Unix(x, 0)
|
||||||
|
markFilter.Before = &before
|
||||||
|
}
|
||||||
|
s.db.MarkItemsRead(markFilter)
|
||||||
|
default:
|
||||||
|
c.Out.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"api_version": 3,
|
||||||
|
"auth": 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package opml
|
|||||||
import (
|
import (
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
|
"golang.org/x/net/html/charset"
|
||||||
)
|
)
|
||||||
|
|
||||||
type opml struct {
|
type opml struct {
|
||||||
@@ -45,6 +47,7 @@ func Parse(r io.Reader) (Folder, error) {
|
|||||||
decoder := xml.NewDecoder(r)
|
decoder := xml.NewDecoder(r)
|
||||||
decoder.Entity = xml.HTMLEntity
|
decoder.Entity = xml.HTMLEntity
|
||||||
decoder.Strict = false
|
decoder.Strict = false
|
||||||
|
decoder.CharsetReader = charset.NewReaderLabel
|
||||||
|
|
||||||
err := decoder.Decode(&val)
|
err := decoder.Decode(&val)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package opml
|
package opml
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -87,3 +88,41 @@ func TestParseFallback(t *testing.T) {
|
|||||||
t.Fatal("invalid opml")
|
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>
|
||||||
@@ -32,10 +32,13 @@ func (r *Router) Use(h Handler) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) For(path string, handler 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 := Route{}
|
||||||
x.regex = routeRegexp(path)
|
x.regex = routeRegexp(path)
|
||||||
x.chain = append(r.middle, handler)
|
x.chain = chain
|
||||||
|
|
||||||
r.routes = append(r.routes, x)
|
r.routes = append(r.routes, x)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/nkanaev/yarr/src/assets"
|
"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/readability"
|
||||||
"github.com/nkanaev/yarr/src/content/sanitizer"
|
"github.com/nkanaev/yarr/src/content/sanitizer"
|
||||||
"github.com/nkanaev/yarr/src/content/silo"
|
"github.com/nkanaev/yarr/src/content/silo"
|
||||||
@@ -33,12 +34,14 @@ func (s *Server) handler() http.Handler {
|
|||||||
BasePath: s.BasePath,
|
BasePath: s.BasePath,
|
||||||
Username: s.Username,
|
Username: s.Username,
|
||||||
Password: s.Password,
|
Password: s.Password,
|
||||||
Public: "/static",
|
Public: []string{"/static", "/fever", "/manifest.json"},
|
||||||
|
DB: s.db,
|
||||||
}
|
}
|
||||||
r.Use(a.Handler)
|
r.Use(a.Handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
r.For("/", s.handleIndex)
|
r.For("/", s.handleIndex)
|
||||||
|
r.For("/manifest.json", s.handleManifest)
|
||||||
r.For("/static/*path", s.handleStatic)
|
r.For("/static/*path", s.handleStatic)
|
||||||
r.For("/api/status", s.handleStatus)
|
r.For("/api/status", s.handleStatus)
|
||||||
r.For("/api/folders", s.handleFolderList)
|
r.For("/api/folders", s.handleFolderList)
|
||||||
@@ -55,6 +58,7 @@ func (s *Server) handler() http.Handler {
|
|||||||
r.For("/opml/export", s.handleOPMLExport)
|
r.For("/opml/export", s.handleOPMLExport)
|
||||||
r.For("/page", s.handlePageCrawl)
|
r.For("/page", s.handlePageCrawl)
|
||||||
r.For("/logout", s.handleLogout)
|
r.For("/logout", s.handleLogout)
|
||||||
|
r.For("/fever/", s.handleFever)
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
@@ -76,6 +80,24 @@ func (s *Server) handleStatic(c *router.Context) {
|
|||||||
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]interface{}{
|
||||||
|
"$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]interface{}{
|
||||||
|
{
|
||||||
|
"src": s.BasePath + "/static/graphicarts/favicon.png",
|
||||||
|
"sizes": "64x64",
|
||||||
|
"type": "image/png",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleStatus(c *router.Context) {
|
func (s *Server) handleStatus(c *router.Context) {
|
||||||
c.JSON(http.StatusOK, map[string]interface{}{
|
c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"running": s.worker.FeedsPending(),
|
"running": s.worker.FeedsPending(),
|
||||||
@@ -159,7 +181,9 @@ func (s *Server) handleFeedIcon(c *router.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cachekey := "icon:" + strconv.FormatInt(id, 10)
|
cachekey := "icon:" + strconv.FormatInt(id, 10)
|
||||||
|
s.cache_mutex.Lock()
|
||||||
cachedat := s.cache[cachekey]
|
cachedat := s.cache[cachekey]
|
||||||
|
s.cache_mutex.Unlock()
|
||||||
if cachedat == nil {
|
if cachedat == nil {
|
||||||
feed := s.db.GetFeed(id)
|
feed := s.db.GetFeed(id)
|
||||||
if feed == nil || feed.Icon == nil {
|
if feed == nil || feed.Icon == nil {
|
||||||
@@ -177,7 +201,9 @@ func (s *Server) handleFeedIcon(c *router.Context) {
|
|||||||
bytes: *(*feed).Icon,
|
bytes: *(*feed).Icon,
|
||||||
etag: etag,
|
etag: etag,
|
||||||
}
|
}
|
||||||
|
s.cache_mutex.Lock()
|
||||||
s.cache[cachekey] = cachedat
|
s.cache[cachekey] = cachedat
|
||||||
|
s.cache_mutex.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
icon := cachedat.(feedicon)
|
icon := cachedat.(feedicon)
|
||||||
@@ -219,7 +245,12 @@ func (s *Server) handleFeedList(c *router.Context) {
|
|||||||
result.FeedLink,
|
result.FeedLink,
|
||||||
form.FolderID,
|
form.FolderID,
|
||||||
)
|
)
|
||||||
s.db.CreateItems(worker.ConvertItems(result.Feed.Items, *feed))
|
items := worker.ConvertItems(result.Feed.Items, *feed)
|
||||||
|
if len(items) > 0 {
|
||||||
|
s.db.CreateItems(items)
|
||||||
|
s.db.SetFeedSize(feed.Id, len(items))
|
||||||
|
s.db.SyncSearch()
|
||||||
|
}
|
||||||
s.worker.FindFeedFavicon(*feed)
|
s.worker.FindFeedFavicon(*feed)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, map[string]interface{}{
|
c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
@@ -263,6 +294,11 @@ func (s *Server) handleFeed(c *router.Context) {
|
|||||||
s.db.UpdateFeedFolder(id, &folderId)
|
s.db.UpdateFeedFolder(id, &folderId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if link, ok := body["feed_link"]; ok {
|
||||||
|
if reflect.TypeOf(link).Kind() == reflect.String {
|
||||||
|
s.db.UpdateFeedLink(id, link.(string))
|
||||||
|
}
|
||||||
|
}
|
||||||
c.Out.WriteHeader(http.StatusOK)
|
c.Out.WriteHeader(http.StatusOK)
|
||||||
} else if c.Req.Method == "DELETE" {
|
} else if c.Req.Method == "DELETE" {
|
||||||
s.db.DeleteFeed(id)
|
s.db.DeleteFeed(id)
|
||||||
@@ -284,7 +320,18 @@ func (s *Server) handleItem(c *router.Context) {
|
|||||||
c.Out.WriteHeader(http.StatusBadRequest)
|
c.Out.WriteHeader(http.StatusBadRequest)
|
||||||
return
|
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)
|
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)
|
c.JSON(http.StatusOK, item)
|
||||||
} else if c.Req.Method == "PUT" {
|
} else if c.Req.Method == "PUT" {
|
||||||
@@ -327,12 +374,19 @@ func (s *Server) handleItemList(c *router.Context) {
|
|||||||
}
|
}
|
||||||
newestFirst := query.Get("oldest_first") != "true"
|
newestFirst := query.Get("oldest_first") != "true"
|
||||||
|
|
||||||
items := s.db.ListItems(filter, perPage+1, newestFirst)
|
items := s.db.ListItems(filter, perPage+1, newestFirst, true)
|
||||||
hasMore := false
|
hasMore := false
|
||||||
if len(items) == perPage+1 {
|
if len(items) == perPage+1 {
|
||||||
hasMore = true
|
hasMore = true
|
||||||
items = items[:perPage]
|
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]interface{}{
|
c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"list": items,
|
"list": items,
|
||||||
"has_more": hasMore,
|
"has_more": hasMore,
|
||||||
@@ -450,24 +504,31 @@ func (s *Server) handleOPMLExport(c *router.Context) {
|
|||||||
func (s *Server) handlePageCrawl(c *router.Context) {
|
func (s *Server) handlePageCrawl(c *router.Context) {
|
||||||
url := c.Req.URL.Query().Get("url")
|
url := c.Req.URL.Query().Get("url")
|
||||||
|
|
||||||
|
if newUrl := silo.RedirectURL(url); newUrl != "" {
|
||||||
|
url = newUrl
|
||||||
|
}
|
||||||
if content := silo.VideoIFrame(url); content != "" {
|
if content := silo.VideoIFrame(url); content != "" {
|
||||||
c.JSON(http.StatusOK, map[string]string{
|
c.JSON(http.StatusOK, map[string]string{
|
||||||
"content": sanitizer.Sanitize(url, content),
|
"content": sanitizer.Sanitize(url, content),
|
||||||
})
|
})
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
log.Print(err)
|
log.Print(err)
|
||||||
c.Out.WriteHeader(http.StatusBadRequest)
|
c.Out.WriteHeader(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer res.Body.Close()
|
content, err := readability.ExtractContent(strings.NewReader(body))
|
||||||
content, err := readability.ExtractContent(res.Body)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Print(err)
|
c.JSON(http.StatusOK, map[string]string{
|
||||||
c.Out.WriteHeader(http.StatusNoContent)
|
"content": "error: " + err.Error(),
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
content = sanitizer.Sanitize(url, content)
|
content = sanitizer.Sanitize(url, content)
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/nkanaev/yarr/src/storage"
|
"github.com/nkanaev/yarr/src/storage"
|
||||||
"github.com/nkanaev/yarr/src/worker"
|
"github.com/nkanaev/yarr/src/worker"
|
||||||
@@ -13,6 +17,7 @@ type Server struct {
|
|||||||
db *storage.Storage
|
db *storage.Storage
|
||||||
worker *worker.Worker
|
worker *worker.Worker
|
||||||
cache map[string]interface{}
|
cache map[string]interface{}
|
||||||
|
cache_mutex *sync.Mutex
|
||||||
|
|
||||||
BasePath string
|
BasePath string
|
||||||
|
|
||||||
@@ -30,6 +35,7 @@ func NewServer(db *storage.Storage, addr string) *Server {
|
|||||||
Addr: addr,
|
Addr: addr,
|
||||||
worker: worker.NewWorker(db),
|
worker: worker.NewWorker(db),
|
||||||
cache: make(map[string]interface{}),
|
cache: make(map[string]interface{}),
|
||||||
|
cache_mutex: &sync.Mutex{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,14 +56,31 @@ func (s *Server) Start() {
|
|||||||
s.worker.RefreshFeeds()
|
s.worker.RefreshFeeds()
|
||||||
}
|
}
|
||||||
|
|
||||||
httpserver := &http.Server{Addr: s.Addr, Handler: s.handler()}
|
var ln net.Listener
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
if s.CertFile != "" && s.KeyFile != "" {
|
|
||||||
err = httpserver.ListenAndServeTLS(s.CertFile, s.KeyFile)
|
if path, isUnix := strings.CutPrefix(s.Addr, "unix:"); isUnix {
|
||||||
} else {
|
err = os.Remove(path)
|
||||||
err = httpserver.ListenAndServe()
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
}
|
}
|
||||||
|
ln, err = net.Listen("unix", path)
|
||||||
|
} else {
|
||||||
|
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 {
|
if err != http.ErrServerClosed {
|
||||||
log.Fatal(err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,18 +20,19 @@ func (s *Storage) CreateFeed(title, description, link, feedLink string, folderId
|
|||||||
if title == "" {
|
if title == "" {
|
||||||
title = feedLink
|
title = feedLink
|
||||||
}
|
}
|
||||||
result, err := s.db.Exec(`
|
row := s.db.QueryRow(`
|
||||||
insert into feeds (title, description, link, feed_link, folder_id)
|
insert into feeds (title, description, link, feed_link, folder_id)
|
||||||
values (?, ?, ?, ?, ?)
|
values (?, ?, ?, ?, ?)
|
||||||
on conflict (feed_link) do update set folder_id=?`,
|
on conflict (feed_link) do update set folder_id = ?
|
||||||
|
returning id`,
|
||||||
title, description, link, feedLink, folderId,
|
title, description, link, feedLink, folderId,
|
||||||
folderId,
|
folderId,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var id int64
|
||||||
|
err := row.Scan(&id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
log.Print(err)
|
||||||
}
|
|
||||||
id, idErr := result.LastInsertId()
|
|
||||||
if idErr != nil {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &Feed{
|
return &Feed{
|
||||||
@@ -70,6 +71,11 @@ func (s *Storage) UpdateFeedFolder(feedId int64, newFolderId *int64) bool {
|
|||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Storage) UpdateFeedLink(feedId int64, newLink string) bool {
|
||||||
|
_, err := s.db.Exec(`update feeds set feed_link = ? where id = ?`, newLink, feedId)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Storage) UpdateFeedIcon(feedId int64, icon *[]byte) bool {
|
func (s *Storage) UpdateFeedIcon(feedId int64, icon *[]byte) bool {
|
||||||
_, err := s.db.Exec(`update feeds set icon = ? where id = ?`, icon, feedId)
|
_, err := s.db.Exec(`update feeds set icon = ? where id = ?`, icon, feedId)
|
||||||
return err == nil
|
return err == nil
|
||||||
@@ -194,3 +200,15 @@ func (s *Storage) GetFeedErrors() map[int64]string {
|
|||||||
}
|
}
|
||||||
return errors
|
return errors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Storage) SetFeedSize(feedId int64, size int) {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
insert into feed_sizes (feed_id, size)
|
||||||
|
values (?, ?)
|
||||||
|
on conflict (feed_id) do update set size = excluded.size`,
|
||||||
|
feedId, size,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,23 @@ func TestCreateFeed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateFeedSameLink(t *testing.T) {
|
||||||
|
db := testDB()
|
||||||
|
feed1 := db.CreateFeed("title", "", "", "http://example1.com/feed.xml", nil)
|
||||||
|
if feed1 == nil || feed1.Id == 0 {
|
||||||
|
t.Fatal("expected feed")
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
db.CreateFeed("title", "", "", "http://example2.com/feed.xml", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
feed2 := db.CreateFeed("title", "", "http://example.com", "http://example1.com/feed.xml", nil)
|
||||||
|
if feed1.Id != feed2.Id {
|
||||||
|
t.Fatalf("expected the same feed.\nwant: %#v\nhave: %#v", feed1, feed2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReadFeed(t *testing.T) {
|
func TestReadFeed(t *testing.T) {
|
||||||
db := testDB()
|
db := testDB()
|
||||||
if db.GetFeed(100500) != nil {
|
if db.GetFeed(100500) != nil {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package storage
|
package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,35 +12,21 @@ type Folder struct {
|
|||||||
|
|
||||||
func (s *Storage) CreateFolder(title string) *Folder {
|
func (s *Storage) CreateFolder(title string) *Folder {
|
||||||
expanded := true
|
expanded := true
|
||||||
result, err := s.db.Exec(`
|
row := s.db.QueryRow(`
|
||||||
insert into folders (title, is_expanded) values (?, ?)
|
insert into folders (title, is_expanded) values (?, ?)
|
||||||
on conflict (title) do nothing`,
|
on conflict (title) do update set title = ?
|
||||||
|
returning id`,
|
||||||
title, expanded,
|
title, expanded,
|
||||||
|
// provide title again so that we can extract row id
|
||||||
|
title,
|
||||||
)
|
)
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var id int64
|
var id int64
|
||||||
numrows, err := result.RowsAffected()
|
err := row.Scan(&id)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Print(err)
|
log.Print(err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if numrows == 1 {
|
|
||||||
id, err = result.LastInsertId()
|
|
||||||
if err != nil {
|
|
||||||
log.Print(err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
err = s.db.QueryRow(`select id, is_expanded from folders where title=?`, title).Scan(&id, &expanded)
|
|
||||||
if err != nil {
|
|
||||||
log.Print(err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &Folder{Id: id, Title: title, IsExpanded: expanded}
|
return &Folder{Id: id, Title: title, IsExpanded: expanded}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package storage
|
package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -43,6 +45,25 @@ func (s *ItemStatus) UnmarshalJSON(b []byte) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MediaLink struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaLinks []MediaLink
|
||||||
|
|
||||||
|
func (m *MediaLinks) Scan(src any) error {
|
||||||
|
if data, ok := src.([]byte); ok {
|
||||||
|
return json.Unmarshal(data, m)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MediaLinks) Value() (driver.Value, error) {
|
||||||
|
return json.Marshal(m)
|
||||||
|
}
|
||||||
|
|
||||||
type Item struct {
|
type Item struct {
|
||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
GUID string `json:"guid"`
|
GUID string `json:"guid"`
|
||||||
@@ -52,8 +73,7 @@ type Item struct {
|
|||||||
Content string `json:"content,omitempty"`
|
Content string `json:"content,omitempty"`
|
||||||
Date time.Time `json:"date"`
|
Date time.Time `json:"date"`
|
||||||
Status ItemStatus `json:"status"`
|
Status ItemStatus `json:"status"`
|
||||||
ImageURL *string `json:"image"`
|
MediaLinks MediaLinks `json:"media_links"`
|
||||||
AudioURL *string `json:"podcast_url"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ItemFilter struct {
|
type ItemFilter struct {
|
||||||
@@ -62,11 +82,35 @@ type ItemFilter struct {
|
|||||||
Status *ItemStatus
|
Status *ItemStatus
|
||||||
Search *string
|
Search *string
|
||||||
After *int64
|
After *int64
|
||||||
|
IDs *[]int64
|
||||||
|
SinceID *int64
|
||||||
|
MaxID *int64
|
||||||
|
Before *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type MarkFilter struct {
|
type MarkFilter struct {
|
||||||
FolderID *int64
|
FolderID *int64
|
||||||
FeedID *int64
|
FeedID *int64
|
||||||
|
|
||||||
|
Before *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ItemList []Item
|
||||||
|
|
||||||
|
func (list ItemList) Len() int {
|
||||||
|
return len(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list ItemList) SortKey(i int) string {
|
||||||
|
return list[i].Date.Format(time.RFC3339) + "::" + list[i].GUID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list ItemList) Less(i, j int) bool {
|
||||||
|
return list.SortKey(i) < list.SortKey(j)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (list ItemList) Swap(i, j int) {
|
||||||
|
list[i], list[j] = list[j], list[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) CreateItems(items []Item) bool {
|
func (s *Storage) CreateItems(items []Item) bool {
|
||||||
@@ -76,19 +120,26 @@ func (s *Storage) CreateItems(items []Item) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now().UTC()
|
||||||
|
|
||||||
for _, item := range items {
|
itemsSorted := ItemList(items)
|
||||||
|
sort.Sort(itemsSorted)
|
||||||
|
|
||||||
|
for _, item := range itemsSorted {
|
||||||
_, err = tx.Exec(`
|
_, err = tx.Exec(`
|
||||||
insert into items (
|
insert into items (
|
||||||
guid, feed_id, title, link, date,
|
guid, feed_id, title, link, date,
|
||||||
content, image, podcast_url,
|
content, media_links,
|
||||||
date_arrived, status
|
date_arrived, status
|
||||||
)
|
)
|
||||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
values (
|
||||||
|
?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f', ?),
|
||||||
|
?, ?,
|
||||||
|
?, ?
|
||||||
|
)
|
||||||
on conflict (feed_id, guid) do nothing`,
|
on conflict (feed_id, guid) do nothing`,
|
||||||
item.GUID, item.FeedId, item.Title, item.Link, item.Date,
|
item.GUID, item.FeedId, item.Title, item.Link, item.Date,
|
||||||
item.Content, item.ImageURL, item.AudioURL,
|
item.Content, item.MediaLinks,
|
||||||
now, UNREAD,
|
now, UNREAD,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -140,6 +191,28 @@ func listQueryPredicate(filter ItemFilter, newestFirst bool) (string, []interfac
|
|||||||
cond = append(cond, fmt.Sprintf("(i.date, i.id) %s (select date, id from items where id = ?)", compare))
|
cond = append(cond, fmt.Sprintf("(i.date, i.id) %s (select date, id from items where id = ?)", compare))
|
||||||
args = append(args, *filter.After)
|
args = append(args, *filter.After)
|
||||||
}
|
}
|
||||||
|
if filter.IDs != nil && len(*filter.IDs) > 0 {
|
||||||
|
qmarks := make([]string, len(*filter.IDs))
|
||||||
|
idargs := make([]interface{}, len(*filter.IDs))
|
||||||
|
for i, id := range *filter.IDs {
|
||||||
|
qmarks[i] = "?"
|
||||||
|
idargs[i] = id
|
||||||
|
}
|
||||||
|
cond = append(cond, "i.id in ("+strings.Join(qmarks, ",")+")")
|
||||||
|
args = append(args, idargs...)
|
||||||
|
}
|
||||||
|
if filter.SinceID != nil {
|
||||||
|
cond = append(cond, "i.id > ?")
|
||||||
|
args = append(args, filter.SinceID)
|
||||||
|
}
|
||||||
|
if filter.MaxID != nil {
|
||||||
|
cond = append(cond, "i.id < ?")
|
||||||
|
args = append(args, filter.MaxID)
|
||||||
|
}
|
||||||
|
if filter.Before != nil {
|
||||||
|
cond = append(cond, "i.date < ?")
|
||||||
|
args = append(args, filter.Before)
|
||||||
|
}
|
||||||
|
|
||||||
predicate := "1"
|
predicate := "1"
|
||||||
if len(cond) > 0 {
|
if len(cond) > 0 {
|
||||||
@@ -149,7 +222,24 @@ func listQueryPredicate(filter ItemFilter, newestFirst bool) (string, []interfac
|
|||||||
return predicate, args
|
return predicate, args
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) ListItems(filter ItemFilter, limit int, newestFirst bool) []Item {
|
func (s *Storage) CountItems(filter ItemFilter) int {
|
||||||
|
predicate, args := listQueryPredicate(filter, false)
|
||||||
|
|
||||||
|
var count int
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
select count(*)
|
||||||
|
from items
|
||||||
|
where %s
|
||||||
|
`, predicate)
|
||||||
|
err := s.db.QueryRow(query, args...).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
log.Print(err)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) ListItems(filter ItemFilter, limit int, newestFirst bool, withContent bool) []Item {
|
||||||
predicate, args := listQueryPredicate(filter, newestFirst)
|
predicate, args := listQueryPredicate(filter, newestFirst)
|
||||||
result := make([]Item, 0, 0)
|
result := make([]Item, 0, 0)
|
||||||
|
|
||||||
@@ -157,17 +247,26 @@ func (s *Storage) ListItems(filter ItemFilter, limit int, newestFirst bool) []It
|
|||||||
if !newestFirst {
|
if !newestFirst {
|
||||||
order = "date asc, id asc"
|
order = "date asc, id asc"
|
||||||
}
|
}
|
||||||
|
if filter.IDs != nil || filter.SinceID != nil {
|
||||||
|
order = "i.id asc"
|
||||||
|
}
|
||||||
|
if filter.MaxID != nil {
|
||||||
|
order = "i.id desc"
|
||||||
|
}
|
||||||
|
|
||||||
|
selectCols := "i.id, i.guid, i.feed_id, i.title, i.link, i.date, i.status, i.media_links"
|
||||||
|
if withContent {
|
||||||
|
selectCols += ", i.content"
|
||||||
|
} else {
|
||||||
|
selectCols += ", '' as content"
|
||||||
|
}
|
||||||
query := fmt.Sprintf(`
|
query := fmt.Sprintf(`
|
||||||
select
|
select %s
|
||||||
i.id, i.guid, i.feed_id,
|
|
||||||
i.title, i.link, i.date,
|
|
||||||
i.status, i.image, i.podcast_url
|
|
||||||
from items i
|
from items i
|
||||||
where %s
|
where %s
|
||||||
order by %s
|
order by %s
|
||||||
limit %d
|
limit %d
|
||||||
`, predicate, order, limit)
|
`, selectCols, predicate, order, limit)
|
||||||
rows, err := s.db.Query(query, args...)
|
rows, err := s.db.Query(query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Print(err)
|
log.Print(err)
|
||||||
@@ -178,7 +277,7 @@ func (s *Storage) ListItems(filter ItemFilter, limit int, newestFirst bool) []It
|
|||||||
err = rows.Scan(
|
err = rows.Scan(
|
||||||
&x.Id, &x.GUID, &x.FeedId,
|
&x.Id, &x.GUID, &x.FeedId,
|
||||||
&x.Title, &x.Link, &x.Date,
|
&x.Title, &x.Link, &x.Date,
|
||||||
&x.Status, &x.ImageURL, &x.AudioURL,
|
&x.Status, &x.MediaLinks, &x.Content,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Print(err)
|
log.Print(err)
|
||||||
@@ -194,12 +293,12 @@ func (s *Storage) GetItem(id int64) *Item {
|
|||||||
err := s.db.QueryRow(`
|
err := s.db.QueryRow(`
|
||||||
select
|
select
|
||||||
i.id, i.guid, i.feed_id, i.title, i.link, i.content,
|
i.id, i.guid, i.feed_id, i.title, i.link, i.content,
|
||||||
i.date, i.status, i.image, i.podcast_url
|
i.date, i.status, i.media_links
|
||||||
from items i
|
from items i
|
||||||
where i.id = ?
|
where i.id = ?
|
||||||
`, id).Scan(
|
`, id).Scan(
|
||||||
&i.Id, &i.GUID, &i.FeedId, &i.Title, &i.Link, &i.Content,
|
&i.Id, &i.GUID, &i.FeedId, &i.Title, &i.Link, &i.Content,
|
||||||
&i.Date, &i.Status, &i.ImageURL, &i.AudioURL,
|
&i.Date, &i.Status, &i.MediaLinks,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Print(err)
|
log.Print(err)
|
||||||
@@ -214,7 +313,11 @@ func (s *Storage) UpdateItemStatus(item_id int64, status ItemStatus) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) MarkItemsRead(filter MarkFilter) bool {
|
func (s *Storage) MarkItemsRead(filter MarkFilter) bool {
|
||||||
predicate, args := listQueryPredicate(ItemFilter{FolderID: filter.FolderID, FeedID: filter.FeedID}, false)
|
predicate, args := listQueryPredicate(ItemFilter{
|
||||||
|
FolderID: filter.FolderID,
|
||||||
|
FeedID: filter.FeedID,
|
||||||
|
Before: filter.Before,
|
||||||
|
}, false)
|
||||||
query := fmt.Sprintf(`
|
query := fmt.Sprintf(`
|
||||||
update items as i set status = %d
|
update items as i set status = %d
|
||||||
where %s and i.status != %d
|
where %s and i.status != %d
|
||||||
@@ -292,45 +395,70 @@ func (s *Storage) SyncSearch() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
itemsKeepSize = 50
|
||||||
|
itemsKeepDays = 90
|
||||||
|
)
|
||||||
|
|
||||||
|
// Delete old articles from the database to cleanup space.
|
||||||
|
//
|
||||||
|
// The rules:
|
||||||
|
// - Never delete starred entries.
|
||||||
|
// - Keep at least the same amount of articles the feed provides (default: 50).
|
||||||
|
// This prevents from deleting items for rarely updated and/or ever-growing
|
||||||
|
// feeds which might eventually reappear as unread.
|
||||||
|
// - Keep entries for a certain period (default: 90 days).
|
||||||
func (s *Storage) DeleteOldItems() {
|
func (s *Storage) DeleteOldItems() {
|
||||||
rows, err := s.db.Query(fmt.Sprintf(`
|
rows, err := s.db.Query(`
|
||||||
select feed_id, count(*) as num_items
|
select
|
||||||
from items
|
i.feed_id,
|
||||||
where status != %d
|
max(coalesce(s.size, 0), ?) as max_items,
|
||||||
group by feed_id
|
count(*) as num_items
|
||||||
having num_items > 50
|
from items i
|
||||||
`, STARRED))
|
left outer join feed_sizes s on s.feed_id = i.feed_id
|
||||||
|
where status != ?
|
||||||
|
group by i.feed_id
|
||||||
|
`, itemsKeepSize, STARRED)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Print(err)
|
log.Print(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
feedIds := make([]int64, 0)
|
feedLimits := make(map[int64]int64, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var id int64
|
var feedId, limit int64
|
||||||
rows.Scan(&id, nil)
|
rows.Scan(&feedId, &limit, nil)
|
||||||
feedIds = append(feedIds, id)
|
feedLimits[feedId] = limit
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, feedId := range feedIds {
|
for feedId, limit := range feedLimits {
|
||||||
result, err := s.db.Exec(`
|
result, err := s.db.Exec(`
|
||||||
delete from items where feed_id = ? and status != ? and date_arrived < ?`,
|
delete from items
|
||||||
|
where id in (
|
||||||
|
select i.id
|
||||||
|
from items i
|
||||||
|
where i.feed_id = ? and status != ?
|
||||||
|
order by date desc
|
||||||
|
limit -1 offset ?
|
||||||
|
) and date_arrived < ?
|
||||||
|
`,
|
||||||
feedId,
|
feedId,
|
||||||
STARRED,
|
STARRED,
|
||||||
time.Now().Add(-time.Hour*24*90), // 90 days
|
limit,
|
||||||
|
time.Now().UTC().Add(-time.Hour*time.Duration(24*itemsKeepDays)),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Print(err)
|
log.Print(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
num, err := result.RowsAffected()
|
numDeleted, err := result.RowsAffected()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Print(err)
|
log.Print(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if num > 0 {
|
if numDeleted > 0 {
|
||||||
log.Printf("Deleted %d old items (%d)", num, feedId)
|
log.Printf("Deleted %d old items (feed: %d)", numDeleted, feedId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package storage
|
|||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -76,12 +77,12 @@ func getItem(db *Storage, guid string) *Item {
|
|||||||
err := db.db.QueryRow(`
|
err := db.db.QueryRow(`
|
||||||
select
|
select
|
||||||
i.id, i.guid, i.feed_id, i.title, i.link, i.content,
|
i.id, i.guid, i.feed_id, i.title, i.link, i.content,
|
||||||
i.date, i.status, i.image, i.podcast_url
|
i.date, i.status, i.media_links
|
||||||
from items i
|
from items i
|
||||||
where i.guid = ?
|
where i.guid = ?
|
||||||
`, guid).Scan(
|
`, guid).Scan(
|
||||||
&i.Id, &i.GUID, &i.FeedId, &i.Title, &i.Link, &i.Content,
|
&i.Id, &i.GUID, &i.FeedId, &i.Title, &i.Link, &i.Content,
|
||||||
&i.Date, &i.Status, &i.ImageURL, &i.AudioURL,
|
&i.Date, &i.Status, &i.MediaLinks,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
@@ -103,7 +104,7 @@ func TestListItems(t *testing.T) {
|
|||||||
|
|
||||||
// filter by folder_id
|
// filter by folder_id
|
||||||
|
|
||||||
have := getItemGuids(db.ListItems(ItemFilter{FolderID: &scope.folder1.Id}, 10, false))
|
have := getItemGuids(db.ListItems(ItemFilter{FolderID: &scope.folder1.Id}, 10, false, false))
|
||||||
want := []string{"item111", "item112", "item113", "item121", "item122"}
|
want := []string{"item111", "item112", "item113", "item121", "item122"}
|
||||||
if !reflect.DeepEqual(have, want) {
|
if !reflect.DeepEqual(have, want) {
|
||||||
t.Logf("want: %#v", want)
|
t.Logf("want: %#v", want)
|
||||||
@@ -111,7 +112,7 @@ func TestListItems(t *testing.T) {
|
|||||||
t.Fail()
|
t.Fail()
|
||||||
}
|
}
|
||||||
|
|
||||||
have = getItemGuids(db.ListItems(ItemFilter{FolderID: &scope.folder2.Id}, 10, false))
|
have = getItemGuids(db.ListItems(ItemFilter{FolderID: &scope.folder2.Id}, 10, false, false))
|
||||||
want = []string{"item211", "item212"}
|
want = []string{"item211", "item212"}
|
||||||
if !reflect.DeepEqual(have, want) {
|
if !reflect.DeepEqual(have, want) {
|
||||||
t.Logf("want: %#v", want)
|
t.Logf("want: %#v", want)
|
||||||
@@ -121,7 +122,7 @@ func TestListItems(t *testing.T) {
|
|||||||
|
|
||||||
// filter by feed_id
|
// filter by feed_id
|
||||||
|
|
||||||
have = getItemGuids(db.ListItems(ItemFilter{FeedID: &scope.feed11.Id}, 10, false))
|
have = getItemGuids(db.ListItems(ItemFilter{FeedID: &scope.feed11.Id}, 10, false, false))
|
||||||
want = []string{"item111", "item112", "item113"}
|
want = []string{"item111", "item112", "item113"}
|
||||||
if !reflect.DeepEqual(have, want) {
|
if !reflect.DeepEqual(have, want) {
|
||||||
t.Logf("want: %#v", want)
|
t.Logf("want: %#v", want)
|
||||||
@@ -129,7 +130,7 @@ func TestListItems(t *testing.T) {
|
|||||||
t.Fail()
|
t.Fail()
|
||||||
}
|
}
|
||||||
|
|
||||||
have = getItemGuids(db.ListItems(ItemFilter{FeedID: &scope.feed01.Id}, 10, false))
|
have = getItemGuids(db.ListItems(ItemFilter{FeedID: &scope.feed01.Id}, 10, false, false))
|
||||||
want = []string{"item011", "item012", "item013"}
|
want = []string{"item011", "item012", "item013"}
|
||||||
if !reflect.DeepEqual(have, want) {
|
if !reflect.DeepEqual(have, want) {
|
||||||
t.Logf("want: %#v", want)
|
t.Logf("want: %#v", want)
|
||||||
@@ -140,7 +141,7 @@ func TestListItems(t *testing.T) {
|
|||||||
// filter by status
|
// filter by status
|
||||||
|
|
||||||
var starred ItemStatus = STARRED
|
var starred ItemStatus = STARRED
|
||||||
have = getItemGuids(db.ListItems(ItemFilter{Status: &starred}, 10, false))
|
have = getItemGuids(db.ListItems(ItemFilter{Status: &starred}, 10, false, false))
|
||||||
want = []string{"item113", "item212", "item013"}
|
want = []string{"item113", "item212", "item013"}
|
||||||
if !reflect.DeepEqual(have, want) {
|
if !reflect.DeepEqual(have, want) {
|
||||||
t.Logf("want: %#v", want)
|
t.Logf("want: %#v", want)
|
||||||
@@ -149,7 +150,7 @@ func TestListItems(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var unread ItemStatus = UNREAD
|
var unread ItemStatus = UNREAD
|
||||||
have = getItemGuids(db.ListItems(ItemFilter{Status: &unread}, 10, false))
|
have = getItemGuids(db.ListItems(ItemFilter{Status: &unread}, 10, false, false))
|
||||||
want = []string{"item111", "item121", "item011"}
|
want = []string{"item111", "item121", "item011"}
|
||||||
if !reflect.DeepEqual(have, want) {
|
if !reflect.DeepEqual(have, want) {
|
||||||
t.Logf("want: %#v", want)
|
t.Logf("want: %#v", want)
|
||||||
@@ -159,7 +160,7 @@ func TestListItems(t *testing.T) {
|
|||||||
|
|
||||||
// limit
|
// limit
|
||||||
|
|
||||||
have = getItemGuids(db.ListItems(ItemFilter{}, 2, false))
|
have = getItemGuids(db.ListItems(ItemFilter{}, 2, false, false))
|
||||||
want = []string{"item111", "item112"}
|
want = []string{"item111", "item112"}
|
||||||
if !reflect.DeepEqual(have, want) {
|
if !reflect.DeepEqual(have, want) {
|
||||||
t.Logf("want: %#v", want)
|
t.Logf("want: %#v", want)
|
||||||
@@ -170,7 +171,7 @@ func TestListItems(t *testing.T) {
|
|||||||
// filter by search
|
// filter by search
|
||||||
db.SyncSearch()
|
db.SyncSearch()
|
||||||
search1 := "title111"
|
search1 := "title111"
|
||||||
have = getItemGuids(db.ListItems(ItemFilter{Search: &search1}, 4, true))
|
have = getItemGuids(db.ListItems(ItemFilter{Search: &search1}, 4, true, false))
|
||||||
want = []string{"item111"}
|
want = []string{"item111"}
|
||||||
if !reflect.DeepEqual(have, want) {
|
if !reflect.DeepEqual(have, want) {
|
||||||
t.Logf("want: %#v", want)
|
t.Logf("want: %#v", want)
|
||||||
@@ -179,7 +180,7 @@ func TestListItems(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sort by date
|
// sort by date
|
||||||
have = getItemGuids(db.ListItems(ItemFilter{}, 4, true))
|
have = getItemGuids(db.ListItems(ItemFilter{}, 4, true, false))
|
||||||
want = []string{"item013", "item012", "item011", "item212"}
|
want = []string{"item013", "item012", "item011", "item212"}
|
||||||
if !reflect.DeepEqual(have, want) {
|
if !reflect.DeepEqual(have, want) {
|
||||||
t.Logf("want: %#v", want)
|
t.Logf("want: %#v", want)
|
||||||
@@ -196,7 +197,7 @@ func TestListItemsPaginated(t *testing.T) {
|
|||||||
item121 := getItem(db, "item121")
|
item121 := getItem(db, "item121")
|
||||||
|
|
||||||
// all, newest first
|
// all, newest first
|
||||||
have := getItemGuids(db.ListItems(ItemFilter{After: &item012.Id}, 3, true))
|
have := getItemGuids(db.ListItems(ItemFilter{After: &item012.Id}, 3, true, false))
|
||||||
want := []string{"item011", "item212", "item211"}
|
want := []string{"item011", "item212", "item211"}
|
||||||
if !reflect.DeepEqual(have, want) {
|
if !reflect.DeepEqual(have, want) {
|
||||||
t.Logf("want: %#v", want)
|
t.Logf("want: %#v", want)
|
||||||
@@ -206,7 +207,7 @@ func TestListItemsPaginated(t *testing.T) {
|
|||||||
|
|
||||||
// unread, newest first
|
// unread, newest first
|
||||||
unread := UNREAD
|
unread := UNREAD
|
||||||
have = getItemGuids(db.ListItems(ItemFilter{After: &item012.Id, Status: &unread}, 3, true))
|
have = getItemGuids(db.ListItems(ItemFilter{After: &item012.Id, Status: &unread}, 3, true, false))
|
||||||
want = []string{"item011", "item121", "item111"}
|
want = []string{"item011", "item121", "item111"}
|
||||||
if !reflect.DeepEqual(have, want) {
|
if !reflect.DeepEqual(have, want) {
|
||||||
t.Logf("want: %#v", want)
|
t.Logf("want: %#v", want)
|
||||||
@@ -216,7 +217,7 @@ func TestListItemsPaginated(t *testing.T) {
|
|||||||
|
|
||||||
// starred, oldest first
|
// starred, oldest first
|
||||||
starred := STARRED
|
starred := STARRED
|
||||||
have = getItemGuids(db.ListItems(ItemFilter{After: &item121.Id, Status: &starred}, 3, false))
|
have = getItemGuids(db.ListItems(ItemFilter{After: &item121.Id, Status: &starred}, 3, false, false))
|
||||||
want = []string{"item212", "item013"}
|
want = []string{"item212", "item013"}
|
||||||
if !reflect.DeepEqual(have, want) {
|
if !reflect.DeepEqual(have, want) {
|
||||||
t.Logf("want: %#v", want)
|
t.Logf("want: %#v", want)
|
||||||
@@ -232,7 +233,7 @@ func TestMarkItemsRead(t *testing.T) {
|
|||||||
db1 := testDB()
|
db1 := testDB()
|
||||||
testItemsSetup(db1)
|
testItemsSetup(db1)
|
||||||
db1.MarkItemsRead(MarkFilter{})
|
db1.MarkItemsRead(MarkFilter{})
|
||||||
have := getItemGuids(db1.ListItems(ItemFilter{Status: &read}, 10, false))
|
have := getItemGuids(db1.ListItems(ItemFilter{Status: &read}, 10, false, false))
|
||||||
want := []string{
|
want := []string{
|
||||||
"item111", "item112", "item121", "item122",
|
"item111", "item112", "item121", "item122",
|
||||||
"item211", "item011", "item012",
|
"item211", "item011", "item012",
|
||||||
@@ -246,7 +247,7 @@ func TestMarkItemsRead(t *testing.T) {
|
|||||||
db2 := testDB()
|
db2 := testDB()
|
||||||
scope2 := testItemsSetup(db2)
|
scope2 := testItemsSetup(db2)
|
||||||
db2.MarkItemsRead(MarkFilter{FolderID: &scope2.folder1.Id})
|
db2.MarkItemsRead(MarkFilter{FolderID: &scope2.folder1.Id})
|
||||||
have = getItemGuids(db2.ListItems(ItemFilter{Status: &read}, 10, false))
|
have = getItemGuids(db2.ListItems(ItemFilter{Status: &read}, 10, false, false))
|
||||||
want = []string{
|
want = []string{
|
||||||
"item111", "item112", "item121", "item122",
|
"item111", "item112", "item121", "item122",
|
||||||
"item211", "item012",
|
"item211", "item012",
|
||||||
@@ -260,7 +261,7 @@ func TestMarkItemsRead(t *testing.T) {
|
|||||||
db3 := testDB()
|
db3 := testDB()
|
||||||
scope3 := testItemsSetup(db3)
|
scope3 := testItemsSetup(db3)
|
||||||
db3.MarkItemsRead(MarkFilter{FeedID: &scope3.feed11.Id})
|
db3.MarkItemsRead(MarkFilter{FeedID: &scope3.feed11.Id})
|
||||||
have = getItemGuids(db3.ListItems(ItemFilter{Status: &read}, 10, false))
|
have = getItemGuids(db3.ListItems(ItemFilter{Status: &read}, 10, false, false))
|
||||||
want = []string{
|
want = []string{
|
||||||
"item111", "item112", "item122",
|
"item111", "item112", "item122",
|
||||||
"item211", "item012",
|
"item211", "item012",
|
||||||
@@ -271,3 +272,59 @@ func TestMarkItemsRead(t *testing.T) {
|
|||||||
t.Fail()
|
t.Fail()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeleteOldItems(t *testing.T) {
|
||||||
|
extraItems := 10
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
db := testDB()
|
||||||
|
feed := db.CreateFeed("feed", "", "", "http://test.com/feed11.xml", nil)
|
||||||
|
|
||||||
|
items := make([]Item, 0)
|
||||||
|
for i := 0; i < itemsKeepSize+extraItems; i++ {
|
||||||
|
istr := strconv.Itoa(i)
|
||||||
|
items = append(items, Item{
|
||||||
|
GUID: istr,
|
||||||
|
FeedId: feed.Id,
|
||||||
|
Title: istr,
|
||||||
|
Date: now.Add(time.Hour * time.Duration(i)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
db.CreateItems(items)
|
||||||
|
|
||||||
|
db.SetFeedSize(feed.Id, itemsKeepSize)
|
||||||
|
var feedSize int
|
||||||
|
err := db.db.QueryRow(
|
||||||
|
`select size from feed_sizes where feed_id = ?`, feed.Id,
|
||||||
|
).Scan(&feedSize)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if feedSize != itemsKeepSize {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected feed size to get updated\nwant: %d\nhave: %d",
|
||||||
|
itemsKeepSize+extraItems,
|
||||||
|
feedSize,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// expire only the first 3 articles
|
||||||
|
_, err = db.db.Exec(
|
||||||
|
`update items set date_arrived = ?
|
||||||
|
where id in (select id from items limit 3)`,
|
||||||
|
now.Add(-time.Hour*time.Duration(itemsKeepDays*24)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.DeleteOldItems()
|
||||||
|
feedItems := db.ListItems(ItemFilter{FeedID: &feed.Id}, 1000, false, false)
|
||||||
|
if len(feedItems) != len(items)-3 {
|
||||||
|
t.Fatalf(
|
||||||
|
"invalid number of old items kept\nwant: %d\nhave: %d",
|
||||||
|
len(items)-3,
|
||||||
|
len(feedItems),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var migrations = []func(*sql.Tx) error{
|
var migrations = []func(*sql.Tx) error{
|
||||||
@@ -13,13 +14,19 @@ var migrations = []func(*sql.Tx) error{
|
|||||||
m04_item_podcasturl,
|
m04_item_podcasturl,
|
||||||
m05_move_description_to_content,
|
m05_move_description_to_content,
|
||||||
m06_fill_missing_dates,
|
m06_fill_missing_dates,
|
||||||
|
m07_add_feed_size,
|
||||||
|
m08_normalize_datetime,
|
||||||
|
m09_change_item_index,
|
||||||
|
m10_add_item_medialinks,
|
||||||
}
|
}
|
||||||
|
|
||||||
var maxVersion = int64(len(migrations))
|
var maxVersion = int64(len(migrations))
|
||||||
|
|
||||||
func migrate(db *sql.DB) error {
|
func migrate(db *sql.DB) error {
|
||||||
var version int64
|
var version int64
|
||||||
db.QueryRow("pragma user_version").Scan(&version)
|
if err := db.QueryRow("pragma user_version").Scan(&version); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if version >= maxVersion {
|
if version >= maxVersion {
|
||||||
return nil
|
return nil
|
||||||
@@ -259,3 +266,66 @@ func m06_fill_missing_dates(tx *sql.Tx) error {
|
|||||||
_, err := tx.Exec(sql)
|
_, err := tx.Exec(sql)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func m07_add_feed_size(tx *sql.Tx) error {
|
||||||
|
sql := `
|
||||||
|
create table if not exists feed_sizes (
|
||||||
|
feed_id references feeds(id) on delete cascade unique,
|
||||||
|
size integer not null default 0
|
||||||
|
);
|
||||||
|
`
|
||||||
|
_, err := tx.Exec(sql)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func m08_normalize_datetime(tx *sql.Tx) error {
|
||||||
|
rows, err := tx.Query(`select id, date_arrived from items;`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var dateArrived time.Time
|
||||||
|
err = rows.Scan(&id, &dateArrived)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(`update items set date_arrived = ? where id = ?;`, dateArrived.UTC(), id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(`update items set date = strftime('%Y-%m-%d %H:%M:%f', date);`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func m09_change_item_index(tx *sql.Tx) error {
|
||||||
|
sql := `
|
||||||
|
drop index if exists idx_item_status;
|
||||||
|
create index if not exists idx_item__date_id_status on items(date,id,status);
|
||||||
|
`
|
||||||
|
_, err := tx.Exec(sql)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func m10_add_item_medialinks(tx *sql.Tx) error {
|
||||||
|
sql := `
|
||||||
|
alter table items add column media_links json;
|
||||||
|
update items set media_links = case
|
||||||
|
when coalesce(image, '') != '' and coalesce(podcast_url, '') != ''
|
||||||
|
then json_array(json_object('type', 'image', 'url', image), json_object('type', 'audio', 'url', podcast_url))
|
||||||
|
|
||||||
|
when coalesce(image, '') != ''
|
||||||
|
then json_array(json_object('type', 'image', 'url', image))
|
||||||
|
|
||||||
|
when coalesce(podcast_url, '') != ''
|
||||||
|
then json_array(json_object('type', 'audio', 'url', podcast_url))
|
||||||
|
|
||||||
|
else null
|
||||||
|
end;
|
||||||
|
alter table items drop column image;
|
||||||
|
alter table items drop column podcast_url;
|
||||||
|
`
|
||||||
|
_, err := tx.Exec(sql)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package storage
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -10,14 +13,17 @@ type Storage struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New(path string) (*Storage, error) {
|
func New(path string) (*Storage, error) {
|
||||||
|
if pos := strings.IndexRune(path, '?'); pos == -1 {
|
||||||
|
params := "_journal=WAL&_sync=NORMAL&_busy_timeout=5000&cache=shared"
|
||||||
|
log.Printf("opening db with params: %s", params)
|
||||||
|
path = path + "?" + params
|
||||||
|
}
|
||||||
|
|
||||||
db, err := sql.Open("sqlite3", path)
|
db, err := sql.Open("sqlite3", path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: https://foxcpp.dev/articles/the-right-way-to-use-go-sqlite3
|
|
||||||
db.SetMaxOpenConns(1)
|
|
||||||
|
|
||||||
if err = migrate(db); err != nil {
|
if err = migrate(db); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ func testDB() *Storage {
|
|||||||
log.SetOutput(io.Discard)
|
log.SetOutput(io.Discard)
|
||||||
db, _ := New(":memory:")
|
db, _ := New(":memory:")
|
||||||
log.SetOutput(os.Stderr)
|
log.SetOutput(os.Stderr)
|
||||||
|
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ hash:
|
|||||||
changes:
|
changes:
|
||||||
|
|
||||||
-removed `getlantern/golog` dependency
|
-removed `getlantern/golog` dependency
|
||||||
|
-prevent from compiling in linux
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
//go:build darwin || windows
|
||||||
|
// +build darwin windows
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Package systray is a cross-platform Go library to place an icon and menu in the notification area.
|
Package systray is a cross-platform Go library to place an icon and menu in the notification area.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
//go:build never
|
||||||
|
// +build never
|
||||||
|
|
||||||
package systray
|
package systray
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
// +build !windows
|
//go:build darwin
|
||||||
|
// +build darwin
|
||||||
|
|
||||||
package systray
|
package systray
|
||||||
|
|
||||||
/*
|
/*
|
||||||
#cgo linux pkg-config: gtk+-3.0 appindicator3-0.1
|
|
||||||
#cgo darwin CFLAGS: -DDARWIN -x objective-c -fobjc-arc
|
#cgo darwin CFLAGS: -DDARWIN -x objective-c -fobjc-arc
|
||||||
#cgo darwin LDFLAGS: -framework Cocoa
|
#cgo darwin LDFLAGS: -framework Cocoa
|
||||||
|
|
||||||
|
|||||||