Compare commits
85 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
d2c034a850 | ||
|
713930decc | ||
|
ee2a825cf0 | ||
|
8e9da86f83 | ||
|
9eb49fd3a7 | ||
|
684bc25b83 | ||
|
8ceab03cd7 | ||
|
34dad4ac8f | ||
|
b40d930f8a | ||
|
d4b34e900e | ||
|
954b549029 | ||
|
fbd0b2310e | ||
|
be7af0ccaf | ||
|
18221ef12d | ||
|
4c0726412b | ||
|
d7253a60b8 | ||
|
2de3ddff08 | ||
|
830248b6ae | ||
|
f8db2ef7ad | ||
|
109caaa889 | ||
|
d0b83babd2 | ||
|
de3decbffd | ||
|
c92229a698 | ||
|
176852b662 | ||
|
52cc8ecbbd | ||
|
e3e9542f1e | ||
|
b78c8bf8bf | ||
|
bff7476b58 | ||
|
05f5785660 | ||
|
cb50aed89a | ||
|
df655aca5e | ||
|
86853a87bf | ||
|
e3109a4384 | ||
|
eee8002d69 | ||
|
92f11f7513 | ||
|
5428e6be3a | ||
|
1ad693f931 | ||
|
c2d88a7e3f | ||
|
3b29d737eb | ||
|
fe178b8fc6 | ||
|
cca742a1c2 | ||
|
c7eddff118 | ||
|
cf30ed249f | ||
|
26b87dee98 | ||
|
77c7f938f1 | ||
|
f98de9a0a5 | ||
|
6fa2b67024 | ||
|
355e5feb62 | ||
|
a7dd707062 | ||
|
4de46a7bc5 | ||
|
2c6fce3322 | ||
|
19ecfcd0bc | ||
|
d575acfe80 | ||
|
d203d38de6 | ||
|
9f01f63613 | ||
|
982c4ebbbc | ||
|
0c5385cef3 | ||
|
58f4e1f6c9 | ||
|
6b7f69d5c0 | ||
|
7aeb458ee5 | ||
|
7cfd3b3238 | ||
|
55262d38fe | ||
|
a45e29feb7 | ||
|
9f5fd3bb4d | ||
|
63f9d55903 | ||
|
8f36ae013e | ||
|
851aa1a136 | ||
|
f38dcfba3b | ||
|
214c7aacfc | ||
|
eb9bfc57e2 | ||
|
c072783c42 | ||
|
9d701678e1 | ||
|
37ed856d8b | ||
|
28f08ad42a | ||
|
da267a56ef | ||
|
16e4cad9ad | ||
|
d13a04898e | ||
|
ff39fbff70 | ||
|
92c6aac49e | ||
|
4ca81f90e9 | ||
|
75e828cb4c | ||
|
883214a740 | ||
|
36e359c881 | ||
|
87b53fb8ec | ||
|
2ae62855cc |
4
.github/workflows/build.yml
vendored
@@ -131,7 +131,7 @@ jobs:
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./yarr-windows.zip
|
||||
asset_name: yarr-${{ github.ref }}-windows32.zip
|
||||
asset_name: yarr-${{ github.ref }}-windows64.zip
|
||||
asset_content_type: application/zip
|
||||
- name: Upload Linux
|
||||
uses: actions/upload-release-asset@v1
|
||||
@@ -140,5 +140,5 @@ jobs:
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./yarr-linux.zip
|
||||
asset_name: yarr-${{ github.ref }}-linux32.zip
|
||||
asset_name: yarr-${{ github.ref }}-linux64.zip
|
||||
asset_content_type: application/zip
|
||||
|
2
.gitignore
vendored
@@ -1,5 +1,3 @@
|
||||
/server/assets.go
|
||||
/gofeed
|
||||
/_output
|
||||
/yarr
|
||||
*.db
|
||||
|
@@ -1,3 +1,30 @@
|
||||
# upcoming
|
||||
|
||||
- (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
|
||||
|
@@ -159,6 +159,7 @@ Delete any from the list in case they drop support of web feeds.
|
||||
- medium
|
||||
- posthaven
|
||||
- reddit
|
||||
- substack
|
||||
- tumblr
|
||||
- vimeo
|
||||
- wordpress
|
||||
|
@@ -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
|
||||
|
||||
removed golog dependency
|
||||
|
||||
- fixconsole
|
||||
https://github.com/apenwarr/fixconsole (commit:5a9f648) Apache 2.0
|
||||
|
||||
removed `w32` dependency
|
||||
|
BIN
etc/promo.png
Before Width: | Height: | Size: 727 KiB After Width: | Height: | Size: 223 KiB |
6
go.mod
@@ -3,7 +3,7 @@ module github.com/nkanaev/yarr
|
||||
go 1.16
|
||||
|
||||
require (
|
||||
github.com/mattn/go-sqlite3 v1.14.0
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e
|
||||
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13
|
||||
github.com/mattn/go-sqlite3 v1.14.7
|
||||
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420
|
||||
golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6
|
||||
)
|
||||
|
27
go.sum
@@ -1,15 +1,12 @@
|
||||
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
|
||||
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.0 h1:mLyGNKR8+Vv9CAU7PphKa2hkEqxxhn8i32J6FPj1/QA=
|
||||
github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e h1:3G+cUijn7XD+S4eJFddp53Pv7+slrESplyjG25HgL+k=
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13 h1:5jaG59Zhd+8ZXe8C+lgiAGqkOaZBruqrWclLkgAww34=
|
||||
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
github.com/mattn/go-sqlite3 v1.14.7 h1:fxWBnXkxfM6sRiuH3bqJ4CfzZojMOLVc0UTsTglEghA=
|
||||
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420 h1:a8jGStKg0XqKDlKqjLrXn0ioF5MH36pT7Z0BRTqLhbk=
|
||||
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6 h1:cdsMqa2nXzqlgs183pHxtvoVwU7CyzaCTAUOg94af4c=
|
||||
golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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=
|
||||
|
14
makefile
@@ -1,4 +1,4 @@
|
||||
VERSION=2.0
|
||||
VERSION=2.3
|
||||
GITHASH=$(shell git rev-parse --short=8 HEAD)
|
||||
|
||||
CGO_ENABLED=1
|
||||
@@ -11,26 +11,20 @@ build_default:
|
||||
go build -tags "sqlite_foreign_keys release" -ldflags="$(GO_LDFLAGS)" -o _output/yarr src/main.go
|
||||
|
||||
build_macos:
|
||||
set GOOS=darwin
|
||||
set GOARCH=amd64
|
||||
mkdir -p _output/macos
|
||||
go build -tags "sqlite_foreign_keys release macos" -ldflags="$(GO_LDFLAGS)" -o _output/macos/yarr src/main.go
|
||||
GOOS=darwin 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:
|
||||
set GOOS=linux
|
||||
set GOARCH=386
|
||||
mkdir -p _output/linux
|
||||
go build -tags "sqlite_foreign_keys release linux" -ldflags="$(GO_LDFLAGS)" -o _output/linux/yarr src/main.go
|
||||
GOOS=linux GOARCH=amd64 go build -tags "sqlite_foreign_keys release linux" -ldflags="$(GO_LDFLAGS)" -o _output/linux/yarr src/main.go
|
||||
|
||||
build_windows:
|
||||
set GOOS=windows
|
||||
set GOARCH=386
|
||||
mkdir -p _output/windows
|
||||
go run bin/generate_versioninfo.go -version "$(VERSION)" -outfile src/platform/versioninfo.rc
|
||||
windres -i src/platform/versioninfo.rc -O coff -o src/platform/versioninfo.syso
|
||||
go build -tags "sqlite_foreign_keys release windows" -ldflags="$(GO_LDFLAGS) -H windowsgui" -o _output/windows/yarr.exe src/main.go
|
||||
GOOS=windows GOARCH=amd64 go build -tags "sqlite_foreign_keys release windows" -ldflags="$(GO_LDFLAGS) -H windowsgui" -o _output/windows/yarr.exe src/main.go
|
||||
|
||||
serve:
|
||||
go run -tags "sqlite_foreign_keys" src/main.go -db local.db
|
||||
|
13
readme.md
@@ -15,20 +15,15 @@ The latest prebuilt binaries for Linux/MacOS/Windows are available
|
||||
### macos
|
||||
|
||||
Download `yarr-*-macos64.zip`, unzip it, place `yarr.app` in `/Applications` folder.
|
||||
|
||||
The binaries are not signed, because the author doesn't want to buy a certificate.
|
||||
Apple hates cheapskate developers, therefore the OS will refuse to run the application.
|
||||
To bypass these measures, you can run the command:
|
||||
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-open]: https://support.apple.com/en-gb/guide/mac-help/mh40616/mac
|
||||
|
||||
### windows
|
||||
|
||||
Download `yarr-*-windows32.zip`, unzip it, place wherever you'd like to
|
||||
(`C:\Program Files` or Recycle Bin). Create a shortcut manually if you'd like to.
|
||||
|
||||
Microsoft doesn't like cheapskate developers too,
|
||||
but might only gently warn you about that, which you can safely ignore.
|
||||
Download `yarr-*-windows32.zip`, unzip it, open `yarr.exe`
|
||||
|
||||
### linux
|
||||
|
||||
|
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
@@ -29,9 +30,18 @@ func Template(path string) *template.Template {
|
||||
if !found {
|
||||
tmpl = template.Must(template.New(path).Delims("{%", "%}").Funcs(template.FuncMap{
|
||||
"inline": func(svg string) template.HTML {
|
||||
svgfile, _ := FS.Open("graphicarts/" + svg)
|
||||
content, _ := ioutil.ReadAll(svgfile)
|
||||
svgfile.Close()
|
||||
svgfile, err := FS.Open("graphicarts/" + svg)
|
||||
// should never happen
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer svgfile.Close()
|
||||
|
||||
content, err := ioutil.ReadAll(svgfile)
|
||||
// should never happen
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return template.HTML(content)
|
||||
},
|
||||
}).ParseFS(FS, path))
|
||||
|
@@ -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-chevron-down"><polyline points="6 9 12 15 18 9"></polyline></svg>
|
Before Width: | Height: | Size: 269 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-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-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>
|
Before Width: | Height: | Size: 341 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 |
@@ -57,6 +57,18 @@
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<header class="dropdown-header">Theme</header>
|
||||
<div class="row text-center m-0">
|
||||
<button class="btn btn-link col-4 px-0 rounded-0"
|
||||
:class="'theme-'+t"
|
||||
@click.stop="theme.name = t"
|
||||
v-for="t in ['light', 'sepia', 'night']">
|
||||
<span class="icon" v-if="theme.name == t">{% inline "check.svg" %}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<header class="dropdown-header">Auto Refresh</header>
|
||||
<div class="row text-center m-0">
|
||||
<button class="dropdown-item col-4 px-0" :class="{active: !refreshRate}" @click.stop="refreshRate = 0">0</button>
|
||||
@@ -117,6 +129,7 @@
|
||||
<div v-for="folder in foldersWithFeeds">
|
||||
<label class="selectgroup mt-1"
|
||||
:class="{'d-none': filterSelected
|
||||
&& !(current.folder.id == folder.id || current.feed.folder_id == folder.id)
|
||||
&& !filteredFolderStats[folder.id]
|
||||
&& (!itemSelectedDetails || feedsById[itemSelectedDetails.feed_id].folder_id != folder.id)}">
|
||||
<input type="radio" name="feed" :value="'folder:'+folder.id" v-model="feedSelected">
|
||||
@@ -133,6 +146,7 @@
|
||||
<div v-show="!folder.id || folder.is_expanded" class="mt-1" :class="{'pl-3': folder.id}">
|
||||
<label class="selectgroup"
|
||||
:class="{'d-none': filterSelected
|
||||
&& !(current.feed.id == feed.id)
|
||||
&& !filteredFeedStats[feed.id]
|
||||
&& (!itemSelectedDetails || itemSelectedDetails.feed_id != feed.id)}"
|
||||
v-for="feed in folder.feeds">
|
||||
@@ -177,11 +191,16 @@
|
||||
title="Mark All Read">
|
||||
<span class="icon">{% inline "check.svg" %}</span>
|
||||
</button>
|
||||
|
||||
|
||||
<button class="btn btn-link toolbar-item px-2 ml-2" v-if="!current.type" disabled>
|
||||
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
||||
</button>
|
||||
<dropdown class="settings-dropdown"
|
||||
toggle-class="btn btn-link toolbar-item px-2 ml-2"
|
||||
drop="right"
|
||||
title="Feed Settings"
|
||||
v-if="!filterSelected && current.type == 'feed'">
|
||||
v-if="current.type == 'feed'">
|
||||
<template v-slot:button>
|
||||
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
||||
</template>
|
||||
@@ -226,7 +245,7 @@
|
||||
toggle-class="btn btn-link toolbar-item px-2 ml-2"
|
||||
title="Folder Settings"
|
||||
drop="right"
|
||||
v-if="!filterSelected && current.type == 'folder'">
|
||||
v-if="current.type == 'folder'">
|
||||
<template v-slot:button>
|
||||
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
||||
</template>
|
||||
@@ -255,12 +274,12 @@
|
||||
<small class="flex-fill text-truncate mr-1">
|
||||
{{ feedsById[item.feed_id].title }}
|
||||
</small>
|
||||
<small class="flex-shrink-0"><relative-time :val="item.date"/></small>
|
||||
<small class="flex-shrink-0"><relative-time v-bind:title="formatDate(item.date)" :val="item.date"/></small>
|
||||
</div>
|
||||
<div>{{ item.title || 'untitled' }}</div>
|
||||
</div>
|
||||
</label>
|
||||
<button class="btn btn-link btn-block loading my-3" v-if="itemsPage.cur < itemsPage.num"></button>
|
||||
<button class="btn btn-link btn-block loading my-3" v-if="itemsHasMore"></button>
|
||||
</div>
|
||||
<div class="px-3 py-2 border-top text-danger text-break" v-if="feed_errors[current.feed.id]">
|
||||
{{ feed_errors[current.feed.id] }}
|
||||
@@ -285,14 +304,6 @@
|
||||
<template v-slot:button>
|
||||
<span class="icon">{% inline "sliders.svg" %}</span>
|
||||
</template>
|
||||
<div class="row text-center m-0">
|
||||
<button class="btn btn-link col-4 px-0 rounded-0"
|
||||
:class="'theme-'+t"
|
||||
@click.stop="theme.name = t"
|
||||
v-for="t in ['light', 'sepia', 'night']">
|
||||
<span class="icon" v-if="theme.name == t">{% inline "check.svg" %}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="dropdown-item" :class="{active: !theme.font}" @click.stop="theme.font = ''">sans-serif</button>
|
||||
<button class="dropdown-item font-serif" :class="{active: theme.font == 'serif'}" @click.stop="theme.font = 'serif'">serif</button>
|
||||
@@ -343,14 +354,14 @@
|
||||
<p class="cursor-default"><b>New Feed</b></p>
|
||||
<form action="" @submit.prevent="createFeed(event)" class="mt-4">
|
||||
<label for="feed-url">URL</label>
|
||||
<input id="feed-url" name="url" type="url" class="form-control" required autocomplete="off" :readonly="feedNewChoice.length > 0">
|
||||
<input id="feed-url" name="url" type="url" class="form-control" required autocomplete="off" :readonly="feedNewChoice.length > 0" placeholder="https://example.com/feed" v-focus>
|
||||
<label for="feed-folder" class="mt-3 d-block">
|
||||
Folder
|
||||
<a href="#" class="float-right text-decoration-none" @click.prevent="createNewFeedFolder()">new folder</a>
|
||||
</label>
|
||||
<select class="form-control" id="feed-folder" name="folder_id" ref="newFeedFolder">
|
||||
<option value="">---</option>
|
||||
<option :value="folder.id" v-for="folder in folders">{{ folder.title }}</option>
|
||||
<option :value="folder.id" v-for="folder in folders" :selected="folder.id === current.feed.folder_id || folder.id === current.folder.id">{{ folder.title }}</option>
|
||||
</select>
|
||||
<div class="mt-4" v-if="feedNewChoice.length">
|
||||
<p class="mb-2">
|
||||
|
@@ -21,6 +21,12 @@ Vue.directive('scroll', {
|
||||
},
|
||||
})
|
||||
|
||||
Vue.directive('focus', {
|
||||
inserted: function(el) {
|
||||
el.focus()
|
||||
}
|
||||
})
|
||||
|
||||
Vue.component('drag', {
|
||||
props: ['width'],
|
||||
template: '<div class="drag"></div>',
|
||||
@@ -47,13 +53,13 @@ Vue.component('drag', {
|
||||
})
|
||||
|
||||
Vue.component('dropdown', {
|
||||
props: ['class', 'toggle-class', 'ref', 'drop'],
|
||||
props: ['class', 'toggle-class', 'ref', 'drop', 'title'],
|
||||
data: function() {
|
||||
return {open: false}
|
||||
},
|
||||
template: `
|
||||
<div class="dropdown" :class="$attrs.class">
|
||||
<button ref="btn" @click="toggle" :class="btnToggleClass"><slot name="button"></slot></button>
|
||||
<button ref="btn" @click="toggle" :class="btnToggleClass" :title="$props.title"><slot name="button"></slot></button>
|
||||
<div ref="menu" class="dropdown-menu" :class="{show: open}"><slot v-if="open"></slot></div>
|
||||
</div>
|
||||
`,
|
||||
@@ -180,7 +186,7 @@ var vm = new Vue({
|
||||
created: function() {
|
||||
this.refreshStats()
|
||||
.then(this.refreshFeeds.bind(this))
|
||||
.then(this.refreshItems.bind(this))
|
||||
.then(this.refreshItems.bind(this, false))
|
||||
|
||||
api.feeds.list_errors().then(function(errors) {
|
||||
vm.feed_errors = errors
|
||||
@@ -197,10 +203,7 @@ var vm = new Vue({
|
||||
'feedNewChoice': [],
|
||||
'feedNewChoiceSelected': '',
|
||||
'items': [],
|
||||
'itemsPage': {
|
||||
'cur': 1,
|
||||
'num': 1,
|
||||
},
|
||||
'itemsHasMore': true,
|
||||
'itemSelected': null,
|
||||
'itemSelectedDetails': null,
|
||||
'itemSelectedReadability': '',
|
||||
@@ -304,13 +307,13 @@ var vm = new Vue({
|
||||
},
|
||||
'filterSelected': function(newVal, oldVal) {
|
||||
if (oldVal === undefined) return // do nothing, initial setup
|
||||
api.settings.update({filter: newVal}).then(this.refreshItems.bind(this))
|
||||
api.settings.update({filter: newVal}).then(this.refreshItems.bind(this, false))
|
||||
this.itemSelected = null
|
||||
this.computeStats()
|
||||
},
|
||||
'feedSelected': function(newVal, oldVal) {
|
||||
if (oldVal === undefined) return // do nothing, initial setup
|
||||
api.settings.update({feed: newVal}).then(this.refreshItems.bind(this))
|
||||
api.settings.update({feed: newVal}).then(this.refreshItems.bind(this, false))
|
||||
this.itemSelected = null
|
||||
if (this.$refs.itemlist) this.$refs.itemlist.scrollTop = 0
|
||||
},
|
||||
@@ -339,7 +342,7 @@ var vm = new Vue({
|
||||
}, 500),
|
||||
'itemSortNewestFirst': function(newVal, oldVal) {
|
||||
if (oldVal === undefined) return // do nothing, initial setup
|
||||
api.settings.update({sort_newest_first: newVal}).then(this.refreshItems.bind(this))
|
||||
api.settings.update({sort_newest_first: newVal}).then(vm.refreshItems.bind(this, false))
|
||||
},
|
||||
'feedListWidth': debounce(function(newVal, oldVal) {
|
||||
if (oldVal === undefined) return // do nothing, initial setup
|
||||
@@ -404,34 +407,44 @@ var vm = new Vue({
|
||||
vm.feeds = values[1]
|
||||
})
|
||||
},
|
||||
refreshItems: function() {
|
||||
refreshItems: function(loadMore) {
|
||||
if (this.feedSelected === null) {
|
||||
vm.items = []
|
||||
vm.itemsPage = {'cur': 1, 'num': 1}
|
||||
return
|
||||
}
|
||||
|
||||
var query = this.getItemsQuery()
|
||||
if (loadMore) {
|
||||
query.after = vm.items[vm.items.length-1].id
|
||||
}
|
||||
|
||||
this.loading.items = true
|
||||
return api.items.list(query).then(function(data) {
|
||||
vm.items = data.list
|
||||
vm.itemsPage = data.page
|
||||
api.items.list(query).then(function(data) {
|
||||
if (loadMore) {
|
||||
vm.items = vm.items.concat(data.list)
|
||||
} else {
|
||||
vm.items = data.list
|
||||
}
|
||||
vm.itemsHasMore = data.has_more
|
||||
vm.loading.items = false
|
||||
|
||||
// load more if there's some space left at the bottom of the item list.
|
||||
vm.$nextTick(function() {
|
||||
if (vm.itemsHasMore && !vm.loading.items && vm.itemListCloseToBottom()) {
|
||||
vm.refreshItems(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
loadMoreItems: function(event, el) {
|
||||
if (this.itemsPage.cur >= this.itemsPage.num) return
|
||||
if (this.loading.items) return
|
||||
itemListCloseToBottom: function() {
|
||||
var el = this.$refs.itemlist
|
||||
var closeToBottom = (el.scrollHeight - el.scrollTop - el.offsetHeight) < 50
|
||||
if (closeToBottom) {
|
||||
this.loading.moreitems = true
|
||||
var query = this.getItemsQuery()
|
||||
query.page = this.itemsPage.cur + 1
|
||||
api.items.list(query).then(function(data) {
|
||||
vm.items = vm.items.concat(data.list)
|
||||
vm.itemsPage = data.page
|
||||
vm.loading.items = false
|
||||
})
|
||||
}
|
||||
return closeToBottom
|
||||
},
|
||||
loadMoreItems: function(event, el) {
|
||||
if (!this.itemsHasMore) return
|
||||
if (this.loading.items) return
|
||||
if (this.itemListCloseToBottom()) this.refreshItems(true)
|
||||
},
|
||||
markItemsRead: function() {
|
||||
var query = this.getItemsQuery()
|
||||
@@ -439,6 +452,7 @@ var vm = new Vue({
|
||||
vm.items = []
|
||||
vm.itemsPage = {'cur': 1, 'num': 1}
|
||||
vm.itemSelected = null
|
||||
vm.itemsHasMore = false
|
||||
vm.refreshStats()
|
||||
})
|
||||
},
|
||||
@@ -633,10 +647,7 @@ var vm = new Vue({
|
||||
fetchAllFeeds: function() {
|
||||
if (this.loading.feeds) return
|
||||
api.feeds.refresh().then(function() {
|
||||
// NOTE: this is hacky
|
||||
setTimeout(function() {
|
||||
vm.refreshStats()
|
||||
}, 1000)
|
||||
vm.refreshStats()
|
||||
})
|
||||
},
|
||||
computeStats: function() {
|
||||
|
@@ -30,7 +30,7 @@
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input name="username" class="form-control" id="username" autocomplete="off"
|
||||
value="{% if .username %}{% .username %}{% end %}" required>
|
||||
value="{% if .username %}{% .username %}{% end %}" required autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
|
@@ -85,6 +85,10 @@ select.form-control:not([multiple]):not([size]) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.table-compact {
|
||||
color: unset !important;
|
||||
}
|
||||
|
||||
.table-compact tr td:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
@@ -360,6 +364,27 @@ select.form-control:not([multiple]):not([size]) {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.content .video-wrapper {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content .video-wrapper::before {
|
||||
display: block;
|
||||
padding-top: 56.25%; /* 16x9 aspect ratio */
|
||||
content: "";
|
||||
}
|
||||
|
||||
.content .video-wrapper iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.content pre {
|
||||
overflow-x: auto;
|
||||
color: inherit;
|
||||
|
@@ -58,19 +58,36 @@ func Sanitize(baseURL, input string) string {
|
||||
attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
|
||||
|
||||
if hasRequiredAttributes(tagName, attrNames) {
|
||||
wrap := isVideoIframe(token)
|
||||
if wrap {
|
||||
buffer.WriteString(`<div class="video-wrapper">`)
|
||||
}
|
||||
|
||||
if len(attrNames) > 0 {
|
||||
buffer.WriteString("<" + tagName + " " + htmlAttributes + ">")
|
||||
} else {
|
||||
buffer.WriteString("<" + tagName + ">")
|
||||
}
|
||||
|
||||
tagStack = append(tagStack, tagName)
|
||||
if tagName == "iframe" {
|
||||
// autoclose iframes
|
||||
buffer.WriteString("</iframe>")
|
||||
if wrap {
|
||||
buffer.WriteString("</div>")
|
||||
}
|
||||
} else {
|
||||
tagStack = append(tagStack, tagName)
|
||||
}
|
||||
}
|
||||
} else if isBlockedTag(tagName) {
|
||||
blacklistedTagDepth++
|
||||
}
|
||||
case html.EndTagToken:
|
||||
tagName := token.Data
|
||||
// iframes are autoclosed. see above
|
||||
if tagName == "iframe" {
|
||||
continue
|
||||
}
|
||||
if isValidTag(tagName) && inList(tagName, tagStack) {
|
||||
buffer.WriteString(fmt.Sprintf("</%s>", tagName))
|
||||
} else if isBlockedTag(tagName) {
|
||||
@@ -417,3 +434,22 @@ func isValidDataAttribute(value string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isVideoIframe(token html.Token) bool {
|
||||
videoWhitelist := map[string]bool{
|
||||
"player.bilibili.com": true,
|
||||
"player.vimeo.com": true,
|
||||
"www.dailymotion.com": true,
|
||||
"www.youtube-nocookie.com": true,
|
||||
"www.youtube.com": true,
|
||||
}
|
||||
if token.Data == "iframe" {
|
||||
for _, attr := range token.Attr {
|
||||
if attr.Key == "src" {
|
||||
domain := htmlutil.URLDomain(attr.Val)
|
||||
return videoWhitelist[domain]
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
@@ -163,6 +163,16 @@ func TestInvalidNestedTag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidIFrame(t *testing.T) {
|
||||
input := `<iframe src="http://example.org/"></iframe>`
|
||||
expected := `<iframe src="http://example.org/" sandbox="allow-scripts allow-same-origin allow-popups" loading="lazy"></iframe>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf("Wrong output:\nwant: %s\nhave: %s", expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidIFrame(t *testing.T) {
|
||||
input := `<iframe src="http://example.org/"></iframe>`
|
||||
expected := ``
|
||||
@@ -175,7 +185,7 @@ func TestInvalidIFrame(t *testing.T) {
|
||||
|
||||
func TestIFrameWithChildElements(t *testing.T) {
|
||||
input := `<iframe src="https://www.youtube.com/"><p>test</p></iframe>`
|
||||
expected := `<iframe src="https://www.youtube.com/" sandbox="allow-scripts allow-same-origin allow-popups" loading="lazy"></iframe>`
|
||||
expected := `<div class="video-wrapper"><iframe src="https://www.youtube.com/" sandbox="allow-scripts allow-same-origin allow-popups" loading="lazy"></iframe></div>`
|
||||
output := Sanitize("http://example.com/", input)
|
||||
|
||||
if expected != output {
|
||||
@@ -255,7 +265,7 @@ func TestEspaceAttributes(t *testing.T) {
|
||||
|
||||
func TestReplaceIframeURL(t *testing.T) {
|
||||
input := `<iframe src="https://player.vimeo.com/video/123456?title=0&byline=0"></iframe>`
|
||||
expected := `<iframe src="https://player.vimeo.com/video/123456?title=0&byline=0" sandbox="allow-scripts allow-same-origin allow-popups" loading="lazy"></iframe>`
|
||||
expected := `<div class="video-wrapper"><iframe src="https://player.vimeo.com/video/123456?title=0&byline=0" sandbox="allow-scripts allow-same-origin allow-popups" loading="lazy"></iframe></div>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
@@ -292,3 +302,13 @@ func TestReplaceStyle(t *testing.T) {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapYoutubeIFrames(t *testing.T) {
|
||||
input := `<iframe src="https://www.youtube.com/embed/foobar"></iframe>`
|
||||
expected := `<div class="video-wrapper"><iframe src="https://www.youtube.com/embed/foobar" sandbox="allow-scripts allow-same-origin allow-popups" loading="lazy"></iframe></div>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if expected != output {
|
||||
t.Errorf("Wrong output:\nwant: %v\nhave: %v", expected, output)
|
||||
}
|
||||
}
|
||||
|
54
src/main.go
@@ -17,18 +17,40 @@ import (
|
||||
var Version string = "0.0"
|
||||
var GitHash string = "unknown"
|
||||
|
||||
func main() {
|
||||
log.SetOutput(os.Stdout)
|
||||
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
|
||||
var OptList = make([]string, 0)
|
||||
|
||||
var addr, db, authfile, certfile, keyfile, basepath string
|
||||
func opt(envVar, defaultValue string) string {
|
||||
OptList = append(OptList, envVar)
|
||||
value := os.Getenv(envVar)
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func main() {
|
||||
platform.FixConsoleIfNeeded()
|
||||
|
||||
var addr, db, authfile, certfile, keyfile, basepath, logfile string
|
||||
var ver, open bool
|
||||
flag.StringVar(&addr, "addr", "127.0.0.1:7070", "address to run server on")
|
||||
flag.StringVar(&authfile, "auth-file", "", "path to a file containing username:password")
|
||||
flag.StringVar(&basepath, "base", "", "base path of the service url")
|
||||
flag.StringVar(&certfile, "cert-file", "", "path to cert file for https")
|
||||
flag.StringVar(&keyfile, "key-file", "", "path to key file for https")
|
||||
flag.StringVar(&db, "db", "", "storage file path")
|
||||
|
||||
flag.CommandLine.SetOutput(os.Stdout)
|
||||
|
||||
flag.Usage = func() {
|
||||
out := flag.CommandLine.Output()
|
||||
fmt.Fprintf(out, "Usage of %s:\n", os.Args[0])
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprintln(out, "\nThe environmental variables, if present, will be used to provide\nthe default values for the params above:")
|
||||
fmt.Fprintln(out, " ", strings.Join(OptList, ", "))
|
||||
}
|
||||
|
||||
flag.StringVar(&addr, "addr", opt("YARR_ADDR", "127.0.0.1:7070"), "address to run server on")
|
||||
flag.StringVar(&basepath, "base", opt("YARR_BASE", ""), "base path of the service url")
|
||||
flag.StringVar(&authfile, "auth-file", opt("YARR_AUTHFILE", ""), "`path` to a file containing username:password")
|
||||
flag.StringVar(&certfile, "cert-file", opt("YARR_CERTFILE", ""), "`path` to cert file for https")
|
||||
flag.StringVar(&keyfile, "key-file", opt("YARR_KEYFILE", ""), "`path` to key file for https")
|
||||
flag.StringVar(&db, "db", opt("YARR_DB", ""), "storage file `path`")
|
||||
flag.StringVar(&logfile, "log-file", opt("YARR_LOGFILE", ""), "`path` to log file to use instead of stdout")
|
||||
flag.BoolVar(&ver, "version", false, "print application version")
|
||||
flag.BoolVar(&open, "open", false, "open the server in browser")
|
||||
flag.Parse()
|
||||
@@ -38,6 +60,18 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
|
||||
if logfile != "" {
|
||||
file, err := os.OpenFile(logfile, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to setup log file: ", err)
|
||||
}
|
||||
defer file.Close()
|
||||
log.SetOutput(file)
|
||||
} else {
|
||||
log.SetOutput(os.Stdout)
|
||||
}
|
||||
|
||||
configPath, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to get config dir: ", err)
|
||||
|
@@ -9,15 +9,27 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nkanaev/yarr/src/content/htmlutil"
|
||||
"golang.org/x/net/html/charset"
|
||||
)
|
||||
|
||||
var UnknownFormat = errors.New("unknown feed format")
|
||||
|
||||
type processor func(r io.Reader) (*Feed, error)
|
||||
type feedProbe struct {
|
||||
feedType string
|
||||
callback func(r io.Reader) (*Feed, error)
|
||||
encoding string
|
||||
}
|
||||
|
||||
func sniff(lookup string) (string, processor) {
|
||||
func sniff(lookup string) (out feedProbe) {
|
||||
lookup = strings.TrimSpace(lookup)
|
||||
lookup = strings.TrimLeft(lookup, "\x00\xEF\xBB\xBF\xFE\xFF")
|
||||
|
||||
if len(lookup) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
switch lookup[0] {
|
||||
case '<':
|
||||
decoder := xmlDecoder(strings.NewReader(lookup))
|
||||
@@ -26,24 +38,42 @@ func sniff(lookup string) (string, processor) {
|
||||
if token == nil {
|
||||
break
|
||||
}
|
||||
|
||||
// check <?xml encoding="ENCODING" ?>
|
||||
if el, ok := token.(xml.ProcInst); ok && el.Target == "xml" {
|
||||
out.encoding = strings.ToLower(procInst("encoding", string(el.Inst)))
|
||||
}
|
||||
|
||||
if el, ok := token.(xml.StartElement); ok {
|
||||
switch el.Name.Local {
|
||||
case "rss":
|
||||
return "rss", ParseRSS
|
||||
out.feedType = "rss"
|
||||
out.callback = ParseRSS
|
||||
return
|
||||
case "RDF":
|
||||
return "rdf", ParseRDF
|
||||
out.feedType = "rdf"
|
||||
out.callback = ParseRDF
|
||||
return
|
||||
case "feed":
|
||||
return "atom", ParseAtom
|
||||
out.feedType = "atom"
|
||||
out.callback = ParseAtom
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
case '{':
|
||||
return "json", ParseJSON
|
||||
out.feedType = "json"
|
||||
out.callback = ParseJSON
|
||||
return
|
||||
}
|
||||
return "", nil
|
||||
return
|
||||
}
|
||||
|
||||
func Parse(r io.Reader) (*Feed, error) {
|
||||
return ParseWithEncoding(r, "")
|
||||
}
|
||||
|
||||
func ParseWithEncoding(r io.Reader, fallbackEncoding string) (*Feed, error) {
|
||||
lookup := make([]byte, 2048)
|
||||
n, err := io.ReadFull(r, lookup)
|
||||
switch {
|
||||
@@ -56,18 +86,42 @@ func Parse(r io.Reader) (*Feed, error) {
|
||||
r = io.MultiReader(bytes.NewReader(lookup), r)
|
||||
}
|
||||
|
||||
_, callback := sniff(string(lookup))
|
||||
if callback == nil {
|
||||
out := sniff(string(lookup))
|
||||
if out.feedType == "" {
|
||||
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 {
|
||||
feed.cleanup()
|
||||
}
|
||||
return feed, err
|
||||
}
|
||||
|
||||
func ParseAndFix(r io.Reader, baseURL, fallbackEncoding string) (*Feed, error) {
|
||||
feed, err := ParseWithEncoding(r, fallbackEncoding)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
feed.TranslateURLs(baseURL)
|
||||
feed.SetMissingDatesTo(time.Now())
|
||||
return feed, nil
|
||||
}
|
||||
|
||||
func (feed *Feed) cleanup() {
|
||||
feed.Title = strings.TrimSpace(feed.Title)
|
||||
feed.SiteURL = strings.TrimSpace(feed.SiteURL)
|
||||
@@ -75,7 +129,7 @@ func (feed *Feed) cleanup() {
|
||||
for i, item := range feed.Items {
|
||||
feed.Items[i].GUID = strings.TrimSpace(item.GUID)
|
||||
feed.Items[i].URL = strings.TrimSpace(item.URL)
|
||||
feed.Items[i].Title = strings.TrimSpace(item.Title)
|
||||
feed.Items[i].Title = strings.TrimSpace(htmlutil.ExtractText(item.Title))
|
||||
feed.Items[i].Content = strings.TrimSpace(item.Content)
|
||||
|
||||
if item.ImageURL != "" && strings.Contains(item.Content, item.ImageURL) {
|
||||
|
@@ -7,38 +7,40 @@ import (
|
||||
)
|
||||
|
||||
func TestSniff(t *testing.T) {
|
||||
testcases := [][2]string{
|
||||
testcases := []struct{
|
||||
input string
|
||||
want feedProbe
|
||||
}{
|
||||
{
|
||||
`<?xml version="1.0"?><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"></rdf:RDF>`,
|
||||
"rdf",
|
||||
feedProbe{feedType: "rdf", callback: ParseRDF},
|
||||
},
|
||||
{
|
||||
`<?xml version="1.0" encoding="ISO-8859-1"?><rss version="2.0"><channel></channel></rss>`,
|
||||
"rss",
|
||||
feedProbe{feedType: "rss", callback: ParseRSS, encoding: "iso-8859-1"},
|
||||
},
|
||||
{
|
||||
`<?xml version="1.0"?><rss version="2.0"><channel></channel></rss>`,
|
||||
"rss",
|
||||
feedProbe{feedType: "rss", callback: ParseRSS},
|
||||
},
|
||||
{
|
||||
`<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>`,
|
||||
"atom",
|
||||
feedProbe{feedType: "atom", callback: ParseAtom, encoding: "utf-8"},
|
||||
},
|
||||
{
|
||||
`{}`,
|
||||
"json",
|
||||
feedProbe{feedType: "json", callback: ParseJSON},
|
||||
},
|
||||
{
|
||||
`<!DOCTYPE html><html><head><title></title></head><body></body></html>`,
|
||||
"",
|
||||
feedProbe{},
|
||||
},
|
||||
}
|
||||
for _, testcase := range testcases {
|
||||
have, _ := sniff(testcase[0])
|
||||
want := testcase[1]
|
||||
if want != have {
|
||||
t.Log(testcase[0])
|
||||
t.Errorf("Invalid format: want=%#v have=%#v", want, have)
|
||||
want := testcase.want
|
||||
have := sniff(testcase.input)
|
||||
if want.encoding != have.encoding || want.feedType != have.feedType {
|
||||
t.Errorf("Invalid output\n---\n%s\n---\n\nwant=%#v\nhave=%#v", testcase.input, want, have)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,3 +109,44 @@ func TestParseFeedWithBOM(t *testing.T) {
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCleanIllegalCharsInUTF8(t *testing.T) {
|
||||
data := `
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
||||
<channel>
|
||||
<item>
|
||||
<title>` + "\a" + `title</title>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`
|
||||
feed, err := Parse(strings.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(feed.Items) != 1 || feed.Items[0].Title != "title" {
|
||||
t.Fatalf("invalid feed, got: %v", feed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCleanIllegalCharsInNonUTF8(t *testing.T) {
|
||||
// echo привет | iconv -f utf8 -t cp1251 | hexdump -C
|
||||
data := `
|
||||
<?xml version="1.0" encoding="windows-1251"?>
|
||||
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
||||
<channel>
|
||||
<item>
|
||||
<title>` + "\a \xef\xf0\xe8\xe2\xe5\xf2\x0a \a" + `</title>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`
|
||||
feed, err := Parse(strings.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(feed.Items) != 1 || feed.Items[0].Title != "привет" {
|
||||
t.Fatalf("invalid feed, got: %v", feed)
|
||||
}
|
||||
}
|
||||
|
@@ -22,7 +22,7 @@ type rssFeed struct {
|
||||
type rssItem struct {
|
||||
GUID string `xml:"guid"`
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
Link string `xml:"rss link"`
|
||||
Description string `xml:"rss description"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
Enclosures []rssEnclosure `xml:"enclosure"`
|
||||
@@ -71,7 +71,7 @@ func ParseRSS(r io.Reader) (*Feed, error) {
|
||||
for _, srcitem := range srcfeed.Items {
|
||||
podcastURL := ""
|
||||
for _, e := range srcitem.Enclosures {
|
||||
if e.Type == "audio/mpeg" || e.Type == "audio/x-m4a" {
|
||||
if strings.HasPrefix(e.Type, "audio/") {
|
||||
podcastURL = e.URL
|
||||
|
||||
if srcitem.OrigEnclosureLink != "" && strings.Contains(podcastURL, path.Base(srcitem.OrigEnclosureLink)) {
|
||||
|
@@ -136,6 +136,26 @@ func TestRSSPodcast(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRSSOpusPodcast(t *testing.T) {
|
||||
feed, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<item>
|
||||
<enclosure length="100500" type="audio/opus" url="http://example.com/audio.ext"/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`))
|
||||
have := feed.Items[0].AudioURL
|
||||
want := "http://example.com/audio.ext"
|
||||
if want != have {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
// found in: https://podcast.cscript.site/podcast.xml
|
||||
func TestRSSPodcastDuplicated(t *testing.T) {
|
||||
feed, _ := Parse(strings.NewReader(`
|
||||
@@ -160,3 +180,26 @@ func TestRSSPodcastDuplicated(t *testing.T) {
|
||||
t.Fatal("item.audio_url must be unset if present in the content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRSSTitleHTMLTags(t *testing.T) {
|
||||
feed, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
||||
<channel>
|
||||
<item>
|
||||
<title><p>title in p</p></title>
|
||||
</item>
|
||||
<item>
|
||||
<title>very <strong>strong</strong> title</title>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`))
|
||||
have := []string{feed.Items[0].Title, feed.Items[1].Title}
|
||||
want := []string{"title in p", "very strong title"}
|
||||
for i := 0; i < len(want); i++ {
|
||||
if want[i] != have[i] {
|
||||
t.Errorf("title doesn't match\nwant: %#v\nhave: %#v\n", want[i], have[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,6 +1,8 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"regexp"
|
||||
@@ -30,6 +32,81 @@ func plain2html(text string) string {
|
||||
func xmlDecoder(r io.Reader) *xml.Decoder {
|
||||
decoder := xml.NewDecoder(r)
|
||||
decoder.Strict = false
|
||||
decoder.CharsetReader = charset.NewReaderLabel
|
||||
decoder.CharsetReader = func(cs string, input io.Reader) (io.Reader, error) {
|
||||
r, err := charset.NewReaderLabel(cs, input)
|
||||
if err == nil {
|
||||
r = NewSafeXMLReader(r)
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
return decoder
|
||||
}
|
||||
|
||||
type safexmlreader struct {
|
||||
reader *bufio.Reader
|
||||
buffer *bytes.Buffer
|
||||
}
|
||||
|
||||
func NewSafeXMLReader(r io.Reader) io.Reader {
|
||||
return &safexmlreader{
|
||||
reader: bufio.NewReader(r),
|
||||
buffer: bytes.NewBuffer(make([]byte, 0, 4096)),
|
||||
}
|
||||
}
|
||||
|
||||
func (xr *safexmlreader) Read(p []byte) (int, error) {
|
||||
for xr.buffer.Len() < cap(p) {
|
||||
r, _, err := xr.reader.ReadRune()
|
||||
if err == io.EOF {
|
||||
if xr.buffer.Len() == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if isInCharacterRange(r) {
|
||||
xr.buffer.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return xr.buffer.Read(p)
|
||||
}
|
||||
|
||||
// NOTE: copied from "encoding/xml" package
|
||||
// Decide whether the given rune is in the XML Character Range, per
|
||||
// the Char production of https://www.xml.com/axml/testaxml.htm,
|
||||
// Section 2.2 Characters.
|
||||
func isInCharacterRange(r rune) (inrange bool) {
|
||||
return r == 0x09 ||
|
||||
r == 0x0A ||
|
||||
r == 0x0D ||
|
||||
r >= 0x20 && r <= 0xD7FF ||
|
||||
r >= 0xE000 && r <= 0xFFFD ||
|
||||
r >= 0x10000 && r <= 0x10FFFF
|
||||
}
|
||||
|
||||
// NOTE: copied from "encoding/xml" package
|
||||
// procInst parses the `param="..."` or `param='...'`
|
||||
// value out of the provided string, returning "" if not found.
|
||||
func procInst(param, s string) string {
|
||||
// TODO: this parsing is somewhat lame and not exact.
|
||||
// It works for all actual cases, though.
|
||||
param = param + "="
|
||||
idx := strings.Index(s, param)
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
v := s[idx+len(param):]
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
if v[0] != '\'' && v[0] != '"' {
|
||||
return ""
|
||||
}
|
||||
idx = strings.IndexRune(v[1:], rune(v[0]))
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
return v[1 : idx+1]
|
||||
}
|
||||
|
88
src/parser/util_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSafeXMLReader(t *testing.T) {
|
||||
var f io.Reader
|
||||
want := []byte("привет мир")
|
||||
f = bytes.NewReader(want)
|
||||
f = NewSafeXMLReader(f)
|
||||
|
||||
have, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Fatalf("invalid output\nwant: %v\nhave: %v", want, have)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeXMLReaderRemoveUnwantedRunes(t *testing.T) {
|
||||
var f io.Reader
|
||||
input := []byte("\aпривет \x0cмир\ufffe\uffff")
|
||||
want := []byte("привет мир")
|
||||
f = bytes.NewReader(input)
|
||||
f = NewSafeXMLReader(f)
|
||||
|
||||
have, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Fatalf("invalid output\nwant: %v\nhave: %v", want, have)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeXMLReaderPartial1(t *testing.T) {
|
||||
var f io.Reader
|
||||
input := []byte("\aпривет \x0cмир\ufffe\uffff")
|
||||
want := []byte("привет мир")
|
||||
f = bytes.NewReader(input)
|
||||
f = NewSafeXMLReader(f)
|
||||
|
||||
buf := make([]byte, 1)
|
||||
for i := 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 @@
|
||||
// +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
|
||||
}
|
131
src/platform/fixconsole_windows.go
Normal file
@@ -0,0 +1,131 @@
|
||||
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
|
||||
}
|
@@ -28,15 +28,16 @@ func (rw *gzipResponseWriter) WriteHeader(statusCode int) {
|
||||
}
|
||||
|
||||
func Middleware(c *router.Context) {
|
||||
if strings.Contains(c.Req.Header.Get("Accept-Encoding"), "gzip") {
|
||||
gz := &gzipResponseWriter{out: gzip.NewWriter(c.Out), src: c.Out}
|
||||
defer gz.out.Close()
|
||||
|
||||
c.Out.Header().Set("Content-Encoding", "gzip")
|
||||
c.Out = gz
|
||||
if !strings.Contains(c.Req.Header.Get("Accept-Encoding"), "gzip") {
|
||||
c.Next()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
gz := &gzipResponseWriter{out: gzip.NewWriter(c.Out), src: c.Out}
|
||||
defer gz.out.Close()
|
||||
|
||||
c.Out.Header().Set("Content-Encoding", "gzip")
|
||||
c.Out = gz
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
@@ -3,6 +3,8 @@ package opml
|
||||
import (
|
||||
"encoding/xml"
|
||||
"io"
|
||||
|
||||
"golang.org/x/net/html/charset"
|
||||
)
|
||||
|
||||
type opml struct {
|
||||
@@ -13,6 +15,7 @@ type opml struct {
|
||||
type outline struct {
|
||||
Type string `xml:"type,attr,omitempty"`
|
||||
Title string `xml:"text,attr"`
|
||||
Title2 string `xml:"title,attr,omitempty"`
|
||||
FeedUrl string `xml:"xmlUrl,attr,omitempty"`
|
||||
SiteUrl string `xml:"htmlUrl,attr,omitempty"`
|
||||
Outlines []outline `xml:"outline,omitempty"`
|
||||
@@ -21,14 +24,18 @@ type outline struct {
|
||||
func buildFolder(title string, outlines []outline) Folder {
|
||||
folder := Folder{Title: title}
|
||||
for _, outline := range outlines {
|
||||
if outline.Type == "rss" {
|
||||
if outline.Type == "rss" || outline.FeedUrl != "" {
|
||||
folder.Feeds = append(folder.Feeds, Feed{
|
||||
Title: outline.Title,
|
||||
FeedUrl: outline.FeedUrl,
|
||||
SiteUrl: outline.SiteUrl,
|
||||
})
|
||||
} else {
|
||||
subfolder := buildFolder(outline.Title, outline.Outlines)
|
||||
title := outline.Title
|
||||
if title == "" {
|
||||
title = outline.Title2
|
||||
}
|
||||
subfolder := buildFolder(title, outline.Outlines)
|
||||
folder.Folders = append(folder.Folders, subfolder)
|
||||
}
|
||||
}
|
||||
@@ -40,6 +47,7 @@ func Parse(r io.Reader) (Folder, error) {
|
||||
decoder := xml.NewDecoder(r)
|
||||
decoder.Entity = xml.HTMLEntity
|
||||
decoder.Strict = false
|
||||
decoder.CharsetReader = charset.NewReaderLabel
|
||||
|
||||
err := decoder.Decode(&val)
|
||||
if err != nil {
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package opml
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -56,3 +57,72 @@ func TestParse(t *testing.T) {
|
||||
t.Fatal("invalid opml")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFallback(t *testing.T) {
|
||||
// as reported in https://github.com/nkanaev/yarr/pull/56
|
||||
// the feed below comes without `outline[text]` & `outline[type=rss]` attributes
|
||||
have, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<opml xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" version="1.0">
|
||||
<head>
|
||||
<title>Newsflow</title>
|
||||
</head>
|
||||
<body>
|
||||
<outline title="foldertitle">
|
||||
<outline htmlUrl="https://example.com" text="feedtext" title="feedtitle" xmlUrl="https://example.com/feed.xml" />
|
||||
</outline>
|
||||
</body>
|
||||
</opml>
|
||||
`))
|
||||
want := Folder{
|
||||
Folders: []Folder{{
|
||||
Title: "foldertitle",
|
||||
Feeds: []Feed{
|
||||
{Title: "feedtext", FeedUrl: "https://example.com/feed.xml", SiteUrl: "https://example.com"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fatal("invalid opml")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWithEncoding(t *testing.T) {
|
||||
file, err := os.Open("sample_win1251.xml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
have, err := Parse(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := Folder{
|
||||
Title: "",
|
||||
Feeds: []Feed{
|
||||
{
|
||||
Title: "пример1",
|
||||
FeedUrl: "https://baz.com/feed.xml",
|
||||
SiteUrl: "https://baz.com/",
|
||||
},
|
||||
},
|
||||
Folders: []Folder{
|
||||
{
|
||||
Title: "папка",
|
||||
Feeds: []Feed{
|
||||
{
|
||||
Title: "пример2",
|
||||
FeedUrl: "https://foo.com/feed.xml",
|
||||
SiteUrl: "https://foo.com/",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fatal("invalid opml")
|
||||
}
|
||||
}
|
||||
|
10
src/server/opml/sample_win1251.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="windows-1251"?>
|
||||
<opml version="1.1">
|
||||
<head><title><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD></title></head>
|
||||
<body>
|
||||
<outline text="<22><><EFBFBD><EFBFBD><EFBFBD>">
|
||||
<outline type="rss" text="<22><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2" description="<22><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>2" xmlUrl="https://foo.com/feed.xml" htmlUrl="https://foo.com/"/>
|
||||
</outline>
|
||||
<outline type="rss" text="<22><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>1" description="<22><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>1" xmlUrl="https://baz.com/feed.xml" htmlUrl="https://baz.com/"/>
|
||||
</body>
|
||||
</opml>
|
@@ -29,15 +29,15 @@ func (c *Context) JSON(status int, data interface{}) {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
c.Out.WriteHeader(status)
|
||||
c.Out.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
c.Out.WriteHeader(status)
|
||||
c.Out.Write(body)
|
||||
c.Out.Write([]byte("\n"))
|
||||
}
|
||||
|
||||
func (c *Context) HTML(status int, tmpl *template.Template, data interface{}) {
|
||||
c.Out.WriteHeader(status)
|
||||
c.Out.Header().Set("Content-Type", "text/html")
|
||||
c.Out.WriteHeader(status)
|
||||
tmpl.Execute(c.Out, data)
|
||||
}
|
||||
|
||||
|
@@ -1,12 +1,14 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/nkanaev/yarr/src/assets"
|
||||
@@ -143,19 +145,51 @@ func (s *Server) handleFeedErrors(c *router.Context) {
|
||||
c.JSON(http.StatusOK, errors)
|
||||
}
|
||||
|
||||
type feedicon struct {
|
||||
ctype string
|
||||
bytes []byte
|
||||
etag string
|
||||
}
|
||||
|
||||
func (s *Server) handleFeedIcon(c *router.Context) {
|
||||
id, err := c.VarInt64("id")
|
||||
if err != nil {
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
feed := s.db.GetFeed(id)
|
||||
if feed != nil && feed.Icon != nil {
|
||||
c.Out.Header().Set("Content-Type", http.DetectContentType(*feed.Icon))
|
||||
c.Out.Write(*feed.Icon)
|
||||
} else {
|
||||
c.Out.WriteHeader(http.StatusNotFound)
|
||||
|
||||
cachekey := "icon:" + strconv.FormatInt(id, 10)
|
||||
cachedat := s.cache[cachekey]
|
||||
if cachedat == nil {
|
||||
feed := s.db.GetFeed(id)
|
||||
if feed == nil || feed.Icon == nil {
|
||||
c.Out.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
hash := md5.New()
|
||||
hash.Write(*feed.Icon)
|
||||
|
||||
etag := fmt.Sprintf("%x", hash.Sum(nil))[:16]
|
||||
|
||||
cachedat = feedicon{
|
||||
ctype: http.DetectContentType(*feed.Icon),
|
||||
bytes: *(*feed).Icon,
|
||||
etag: etag,
|
||||
}
|
||||
s.cache[cachekey] = cachedat
|
||||
}
|
||||
|
||||
icon := cachedat.(feedicon)
|
||||
|
||||
if c.Req.Header.Get("If-None-Match") == icon.etag {
|
||||
c.Out.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
c.Out.Header().Set("Content-Type", icon.ctype)
|
||||
c.Out.Header().Set("Etag", icon.etag)
|
||||
c.Out.Write(icon.bytes)
|
||||
}
|
||||
|
||||
func (s *Server) handleFeedList(c *router.Context) {
|
||||
@@ -185,12 +219,16 @@ func (s *Server) handleFeedList(c *router.Context) {
|
||||
result.FeedLink,
|
||||
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.worker.FindFeedFavicon(*feed)
|
||||
|
||||
c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"status": "success",
|
||||
"feed": feed,
|
||||
"feed": feed,
|
||||
})
|
||||
default:
|
||||
c.JSON(http.StatusOK, map[string]string{"status": "notfound"})
|
||||
@@ -272,11 +310,8 @@ func (s *Server) handleItem(c *router.Context) {
|
||||
func (s *Server) handleItemList(c *router.Context) {
|
||||
if c.Req.Method == "GET" {
|
||||
perPage := 20
|
||||
curPage := 1
|
||||
query := c.Req.URL.Query()
|
||||
if page, err := c.QueryInt64("page"); err == nil {
|
||||
curPage = int(page)
|
||||
}
|
||||
|
||||
filter := storage.ItemFilter{}
|
||||
if folderID, err := c.QueryInt64("folder_id"); err == nil {
|
||||
filter.FolderID = &folderID
|
||||
@@ -284,6 +319,9 @@ func (s *Server) handleItemList(c *router.Context) {
|
||||
if feedID, err := c.QueryInt64("feed_id"); err == nil {
|
||||
filter.FeedID = &feedID
|
||||
}
|
||||
if after, err := c.QueryInt64("after"); err == nil {
|
||||
filter.After = &after
|
||||
}
|
||||
if status := query.Get("status"); len(status) != 0 {
|
||||
statusValue := storage.StatusValues[status]
|
||||
filter.Status = &statusValue
|
||||
@@ -292,14 +330,16 @@ func (s *Server) handleItemList(c *router.Context) {
|
||||
filter.Search = &search
|
||||
}
|
||||
newestFirst := query.Get("oldest_first") != "true"
|
||||
items := s.db.ListItems(filter, (curPage-1)*perPage, perPage, newestFirst)
|
||||
count := s.db.CountItems(filter)
|
||||
|
||||
items := s.db.ListItems(filter, perPage+1, newestFirst)
|
||||
hasMore := false
|
||||
if len(items) == perPage+1 {
|
||||
hasMore = true
|
||||
items = items[:perPage]
|
||||
}
|
||||
c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"page": map[string]int{
|
||||
"cur": curPage,
|
||||
"num": int(math.Ceil(float64(count) / float64(perPage))),
|
||||
},
|
||||
"list": items,
|
||||
"has_more": hasMore,
|
||||
})
|
||||
} else if c.Req.Method == "PUT" {
|
||||
filter := storage.MarkFilter{}
|
||||
@@ -416,19 +456,18 @@ func (s *Server) handlePageCrawl(c *router.Context) {
|
||||
|
||||
if content := silo.VideoIFrame(url); content != "" {
|
||||
c.JSON(http.StatusOK, map[string]string{
|
||||
"content": content,
|
||||
"content": sanitizer.Sanitize(url, content),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
res, err := http.Get(url)
|
||||
body, err := worker.GetBody(url)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer res.Body.Close()
|
||||
content, err := readability.ExtractContent(res.Body)
|
||||
content, err := readability.ExtractContent(strings.NewReader(body))
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
c.Out.WriteHeader(http.StatusNoContent)
|
||||
|
@@ -1,8 +1,16 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/nkanaev/yarr/src/storage"
|
||||
)
|
||||
|
||||
func TestStatic(t *testing.T) {
|
||||
@@ -43,3 +51,64 @@ func TestStaticBanTemplates(t *testing.T) {
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexGzipped(t *testing.T) {
|
||||
log.SetOutput(io.Discard)
|
||||
db, _ := storage.New(":memory:")
|
||||
log.SetOutput(os.Stderr)
|
||||
handler := NewServer(db, "127.0.0.1:8000").handler()
|
||||
url := "/"
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest("GET", url, nil)
|
||||
request.Header.Set("accept-encoding", "gzip")
|
||||
handler.ServeHTTP(recorder, request)
|
||||
response := recorder.Result()
|
||||
if response.StatusCode != 200 {
|
||||
t.FailNow()
|
||||
}
|
||||
if response.Header.Get("content-encoding") != "gzip" {
|
||||
t.Errorf("invalid content-encoding header: %#v", response.Header.Get("content-encoding"))
|
||||
}
|
||||
if response.Header.Get("content-type") != "text/html" {
|
||||
t.Errorf("invalid content-type header: %#v", response.Header.Get("content-type"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedIcons(t *testing.T) {
|
||||
log.SetOutput(io.Discard)
|
||||
db, _ := storage.New(":memory:")
|
||||
icon := []byte("test")
|
||||
feed := db.CreateFeed("", "", "", "", nil)
|
||||
db.UpdateFeedIcon(feed.Id, &icon)
|
||||
log.SetOutput(os.Stderr)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
url := fmt.Sprintf("/api/feeds/%d/icon", feed.Id)
|
||||
request := httptest.NewRequest("GET", url, nil)
|
||||
|
||||
handler := NewServer(db, "127.0.0.1:8000").handler()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
response := recorder.Result()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatal()
|
||||
}
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
if !reflect.DeepEqual(body, icon) {
|
||||
t.Fatal()
|
||||
}
|
||||
if response.Header.Get("Etag") == "" {
|
||||
t.Fatal()
|
||||
}
|
||||
|
||||
recorder2 := httptest.NewRecorder()
|
||||
request2 := httptest.NewRequest("GET", url, nil)
|
||||
request2.Header.Set("If-None-Match", response.Header.Get("Etag"))
|
||||
handler.ServeHTTP(recorder2, request2)
|
||||
response2 := recorder2.Result()
|
||||
|
||||
if response2.StatusCode != http.StatusNotModified {
|
||||
t.Fatal("got", response2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
@@ -12,6 +12,7 @@ type Server struct {
|
||||
Addr string
|
||||
db *storage.Storage
|
||||
worker *worker.Worker
|
||||
cache map[string]interface{}
|
||||
|
||||
BasePath string
|
||||
|
||||
@@ -28,6 +29,7 @@ func NewServer(db *storage.Storage, addr string) *Server {
|
||||
db: db,
|
||||
Addr: addr,
|
||||
worker: worker.NewWorker(db),
|
||||
cache: make(map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -76,10 +76,10 @@ func (s *Storage) UpdateFeedIcon(feedId int64, icon *[]byte) bool {
|
||||
}
|
||||
|
||||
func (s *Storage) ListFeeds() []Feed {
|
||||
result := make([]Feed, 0, 0)
|
||||
result := make([]Feed, 0)
|
||||
rows, err := s.db.Query(`
|
||||
select id, folder_id, title, description, link, feed_link,
|
||||
ifnull(icon, '') != '' as has_icon
|
||||
ifnull(length(icon), 0) > 0 as has_icon
|
||||
from feeds
|
||||
order by title collate nocase
|
||||
`)
|
||||
@@ -107,6 +107,36 @@ func (s *Storage) ListFeeds() []Feed {
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Storage) ListFeedsMissingIcons() []Feed {
|
||||
result := make([]Feed, 0)
|
||||
rows, err := s.db.Query(`
|
||||
select id, folder_id, title, description, link, feed_link
|
||||
from feeds
|
||||
where icon is null
|
||||
`)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return result
|
||||
}
|
||||
for rows.Next() {
|
||||
var f Feed
|
||||
err = rows.Scan(
|
||||
&f.Id,
|
||||
&f.FolderId,
|
||||
&f.Title,
|
||||
&f.Description,
|
||||
&f.Link,
|
||||
&f.FeedLink,
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return result
|
||||
}
|
||||
result = append(result, f)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Storage) GetFeed(id int64) *Feed {
|
||||
var f Feed
|
||||
err := s.db.QueryRow(`
|
||||
@@ -164,3 +194,15 @@ func (s *Storage) GetFeedErrors() map[int64]string {
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
@@ -61,6 +61,7 @@ type ItemFilter struct {
|
||||
FeedID *int64
|
||||
Status *ItemStatus
|
||||
Search *string
|
||||
After *int64
|
||||
}
|
||||
|
||||
type MarkFilter struct {
|
||||
@@ -106,7 +107,7 @@ func (s *Storage) CreateItems(items []Item) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func listQueryPredicate(filter ItemFilter) (string, []interface{}) {
|
||||
func listQueryPredicate(filter ItemFilter, newestFirst bool) (string, []interface{}) {
|
||||
cond := make([]string, 0)
|
||||
args := make([]interface{}, 0)
|
||||
if filter.FolderID != nil {
|
||||
@@ -131,6 +132,14 @@ func listQueryPredicate(filter ItemFilter) (string, []interface{}) {
|
||||
cond = append(cond, "i.search_rowid in (select rowid from search where search match ?)")
|
||||
args = append(args, strings.Join(terms, " "))
|
||||
}
|
||||
if filter.After != nil {
|
||||
compare := ">"
|
||||
if newestFirst {
|
||||
compare = "<"
|
||||
}
|
||||
cond = append(cond, fmt.Sprintf("(i.date, i.id) %s (select date, id from items where id = ?)", compare))
|
||||
args = append(args, *filter.After)
|
||||
}
|
||||
|
||||
predicate := "1"
|
||||
if len(cond) > 0 {
|
||||
@@ -140,13 +149,13 @@ func listQueryPredicate(filter ItemFilter) (string, []interface{}) {
|
||||
return predicate, args
|
||||
}
|
||||
|
||||
func (s *Storage) ListItems(filter ItemFilter, offset, limit int, newestFirst bool) []Item {
|
||||
predicate, args := listQueryPredicate(filter)
|
||||
func (s *Storage) ListItems(filter ItemFilter, limit int, newestFirst bool) []Item {
|
||||
predicate, args := listQueryPredicate(filter, newestFirst)
|
||||
result := make([]Item, 0, 0)
|
||||
|
||||
order := "date desc"
|
||||
order := "date desc, id desc"
|
||||
if !newestFirst {
|
||||
order = "date asc"
|
||||
order = "date asc, id asc"
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
@@ -157,8 +166,8 @@ func (s *Storage) ListItems(filter ItemFilter, offset, limit int, newestFirst bo
|
||||
from items i
|
||||
where %s
|
||||
order by %s
|
||||
limit %d offset %d
|
||||
`, predicate, order, limit, offset)
|
||||
limit %d
|
||||
`, predicate, order, limit)
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
@@ -199,28 +208,13 @@ func (s *Storage) GetItem(id int64) *Item {
|
||||
return i
|
||||
}
|
||||
|
||||
func (s *Storage) CountItems(filter ItemFilter) int64 {
|
||||
predicate, args := listQueryPredicate(filter)
|
||||
query := fmt.Sprintf(`
|
||||
select count(i.id)
|
||||
from items i
|
||||
where %s`, predicate)
|
||||
row := s.db.QueryRow(query, args...)
|
||||
if row != nil {
|
||||
var result int64
|
||||
row.Scan(&result)
|
||||
return result
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *Storage) UpdateItemStatus(item_id int64, status ItemStatus) bool {
|
||||
_, err := s.db.Exec(`update items set status = ? where id = ?`, status, item_id)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Storage) MarkItemsRead(filter MarkFilter) bool {
|
||||
predicate, args := listQueryPredicate(ItemFilter{FolderID: filter.FolderID, FeedID: filter.FeedID})
|
||||
predicate, args := listQueryPredicate(ItemFilter{FolderID: filter.FolderID, FeedID: filter.FeedID}, false)
|
||||
query := fmt.Sprintf(`
|
||||
update items as i set status = %d
|
||||
where %s and i.status != %d
|
||||
@@ -298,45 +292,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() {
|
||||
rows, err := s.db.Query(fmt.Sprintf(`
|
||||
select feed_id, count(*) as num_items
|
||||
from items
|
||||
where status != %d
|
||||
group by feed_id
|
||||
having num_items > 50
|
||||
`, STARRED))
|
||||
rows, err := s.db.Query(`
|
||||
select
|
||||
i.feed_id,
|
||||
max(coalesce(s.size, 0), ?) as max_items,
|
||||
count(*) as num_items
|
||||
from items i
|
||||
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 {
|
||||
log.Print(err)
|
||||
return
|
||||
}
|
||||
|
||||
feedIds := make([]int64, 0)
|
||||
feedLimits := make(map[int64]int64, 0)
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
rows.Scan(&id, nil)
|
||||
feedIds = append(feedIds, id)
|
||||
var feedId, limit int64
|
||||
rows.Scan(&feedId, &limit, nil)
|
||||
feedLimits[feedId] = limit
|
||||
}
|
||||
|
||||
for _, feedId := range feedIds {
|
||||
for feedId, limit := range feedLimits {
|
||||
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,
|
||||
STARRED,
|
||||
time.Now().Add(-time.Hour*24*90), // 90 days
|
||||
limit,
|
||||
time.Now().Add(-time.Hour*time.Duration(24*itemsKeepDays)),
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return
|
||||
}
|
||||
num, err := result.RowsAffected()
|
||||
numDeleted, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return
|
||||
}
|
||||
if num > 0 {
|
||||
log.Printf("Deleted %d old items (%d)", num, feedId)
|
||||
if numDeleted > 0 {
|
||||
log.Printf("Deleted %d old items (feed: %d)", numDeleted, feedId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,7 +1,9 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"log"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -44,18 +46,18 @@ func testItemsSetup(db *Storage) testItemScope {
|
||||
db.CreateItems([]Item{
|
||||
// feed11
|
||||
{GUID: "item111", FeedId: feed11.Id, Title: "title111", Date: now.Add(time.Hour * 24 * 1)},
|
||||
{GUID: "item112", FeedId: feed11.Id, Title: "title112", Date: now.Add(time.Hour * 24 * 2)},
|
||||
{GUID: "item113", FeedId: feed11.Id, Title: "title113", Date: now.Add(time.Hour * 24 * 3)},
|
||||
{GUID: "item112", FeedId: feed11.Id, Title: "title112", Date: now.Add(time.Hour * 24 * 2)}, // read
|
||||
{GUID: "item113", FeedId: feed11.Id, Title: "title113", Date: now.Add(time.Hour * 24 * 3)}, // starred
|
||||
// feed12
|
||||
{GUID: "item121", FeedId: feed12.Id, Title: "title121", Date: now.Add(time.Hour * 24 * 4)},
|
||||
{GUID: "item122", FeedId: feed12.Id, Title: "title122", Date: now.Add(time.Hour * 24 * 5)},
|
||||
{GUID: "item122", FeedId: feed12.Id, Title: "title122", Date: now.Add(time.Hour * 24 * 5)}, // read
|
||||
// feed21
|
||||
{GUID: "item211", FeedId: feed21.Id, Title: "title211", Date: now.Add(time.Hour * 24 * 6)},
|
||||
{GUID: "item212", FeedId: feed21.Id, Title: "title212", Date: now.Add(time.Hour * 24 * 7)},
|
||||
{GUID: "item211", FeedId: feed21.Id, Title: "title211", Date: now.Add(time.Hour * 24 * 6)}, // read
|
||||
{GUID: "item212", FeedId: feed21.Id, Title: "title212", Date: now.Add(time.Hour * 24 * 7)}, // starred
|
||||
// feed01
|
||||
{GUID: "item011", FeedId: feed01.Id, Title: "title011", Date: now.Add(time.Hour * 24 * 8)},
|
||||
{GUID: "item012", FeedId: feed01.Id, Title: "title012", Date: now.Add(time.Hour * 24 * 9)},
|
||||
{GUID: "item013", FeedId: feed01.Id, Title: "title013", Date: now.Add(time.Hour * 24 * 10)},
|
||||
{GUID: "item012", FeedId: feed01.Id, Title: "title012", Date: now.Add(time.Hour * 24 * 9)}, // read
|
||||
{GUID: "item013", FeedId: feed01.Id, Title: "title013", Date: now.Add(time.Hour * 24 * 10)}, // starred
|
||||
})
|
||||
db.db.Exec(`update items set status = ? where guid in ("item112", "item122", "item211", "item012")`, READ)
|
||||
db.db.Exec(`update items set status = ? where guid in ("item113", "item212", "item013")`, STARRED)
|
||||
@@ -70,7 +72,25 @@ func testItemsSetup(db *Storage) testItemScope {
|
||||
}
|
||||
}
|
||||
|
||||
func testItemGuids(items []Item) []string {
|
||||
func getItem(db *Storage, guid string) *Item {
|
||||
i := &Item{}
|
||||
err := db.db.QueryRow(`
|
||||
select
|
||||
i.id, i.guid, i.feed_id, i.title, i.link, i.content,
|
||||
i.date, i.status, i.image, i.podcast_url
|
||||
from items i
|
||||
where i.guid = ?
|
||||
`, guid).Scan(
|
||||
&i.Id, &i.GUID, &i.FeedId, &i.Title, &i.Link, &i.Content,
|
||||
&i.Date, &i.Status, &i.ImageURL, &i.AudioURL,
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func getItemGuids(items []Item) []string {
|
||||
guids := make([]string, 0)
|
||||
for _, item := range items {
|
||||
guids = append(guids, item.GUID)
|
||||
@@ -84,7 +104,7 @@ func TestListItems(t *testing.T) {
|
||||
|
||||
// filter by folder_id
|
||||
|
||||
have := testItemGuids(db.ListItems(ItemFilter{FolderID: &scope.folder1.Id}, 0, 10, false))
|
||||
have := getItemGuids(db.ListItems(ItemFilter{FolderID: &scope.folder1.Id}, 10, false))
|
||||
want := []string{"item111", "item112", "item113", "item121", "item122"}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
@@ -92,7 +112,7 @@ func TestListItems(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
have = testItemGuids(db.ListItems(ItemFilter{FolderID: &scope.folder2.Id}, 0, 10, false))
|
||||
have = getItemGuids(db.ListItems(ItemFilter{FolderID: &scope.folder2.Id}, 10, false))
|
||||
want = []string{"item211", "item212"}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
@@ -102,7 +122,7 @@ func TestListItems(t *testing.T) {
|
||||
|
||||
// filter by feed_id
|
||||
|
||||
have = testItemGuids(db.ListItems(ItemFilter{FeedID: &scope.feed11.Id}, 0, 10, false))
|
||||
have = getItemGuids(db.ListItems(ItemFilter{FeedID: &scope.feed11.Id}, 10, false))
|
||||
want = []string{"item111", "item112", "item113"}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
@@ -110,7 +130,7 @@ func TestListItems(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
have = testItemGuids(db.ListItems(ItemFilter{FeedID: &scope.feed01.Id}, 0, 10, false))
|
||||
have = getItemGuids(db.ListItems(ItemFilter{FeedID: &scope.feed01.Id}, 10, false))
|
||||
want = []string{"item011", "item012", "item013"}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
@@ -121,7 +141,7 @@ func TestListItems(t *testing.T) {
|
||||
// filter by status
|
||||
|
||||
var starred ItemStatus = STARRED
|
||||
have = testItemGuids(db.ListItems(ItemFilter{Status: &starred}, 0, 10, false))
|
||||
have = getItemGuids(db.ListItems(ItemFilter{Status: &starred}, 10, false))
|
||||
want = []string{"item113", "item212", "item013"}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
@@ -130,7 +150,7 @@ func TestListItems(t *testing.T) {
|
||||
}
|
||||
|
||||
var unread ItemStatus = UNREAD
|
||||
have = testItemGuids(db.ListItems(ItemFilter{Status: &unread}, 0, 10, false))
|
||||
have = getItemGuids(db.ListItems(ItemFilter{Status: &unread}, 10, false))
|
||||
want = []string{"item111", "item121", "item011"}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
@@ -138,9 +158,9 @@ func TestListItems(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// filter by offset,limit
|
||||
// limit
|
||||
|
||||
have = testItemGuids(db.ListItems(ItemFilter{}, 0, 2, false))
|
||||
have = getItemGuids(db.ListItems(ItemFilter{}, 2, false))
|
||||
want = []string{"item111", "item112"}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
@@ -148,18 +168,10 @@ func TestListItems(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
have = testItemGuids(db.ListItems(ItemFilter{}, 2, 3, false))
|
||||
want = []string{"item113", "item121", "item122"}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// filter by search
|
||||
db.SyncSearch()
|
||||
search1 := "title111"
|
||||
have = testItemGuids(db.ListItems(ItemFilter{Search: &search1}, 0, 4, true))
|
||||
have = getItemGuids(db.ListItems(ItemFilter{Search: &search1}, 4, true))
|
||||
want = []string{"item111"}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
@@ -168,7 +180,7 @@ func TestListItems(t *testing.T) {
|
||||
}
|
||||
|
||||
// sort by date
|
||||
have = testItemGuids(db.ListItems(ItemFilter{}, 0, 4, true))
|
||||
have = getItemGuids(db.ListItems(ItemFilter{}, 4, true))
|
||||
want = []string{"item013", "item012", "item011", "item212"}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
@@ -177,72 +189,37 @@ func TestListItems(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountItems(t *testing.T) {
|
||||
func TestListItemsPaginated(t *testing.T) {
|
||||
db := testDB()
|
||||
scope := testItemsSetup(db)
|
||||
testItemsSetup(db)
|
||||
|
||||
have := db.CountItems(ItemFilter{})
|
||||
want := int64(10)
|
||||
if have != want {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// folders
|
||||
item012 := getItem(db, "item012")
|
||||
item121 := getItem(db, "item121")
|
||||
|
||||
have = db.CountItems(ItemFilter{FolderID: &scope.folder1.Id})
|
||||
want = int64(5)
|
||||
if have != want {
|
||||
// all, newest first
|
||||
have := getItemGuids(db.ListItems(ItemFilter{After: &item012.Id}, 3, true))
|
||||
want := []string{"item011", "item212", "item211"}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
have = db.CountItems(ItemFilter{FolderID: &scope.folder2.Id})
|
||||
want = int64(2)
|
||||
if have != want {
|
||||
// unread, newest first
|
||||
unread := UNREAD
|
||||
have = getItemGuids(db.ListItems(ItemFilter{After: &item012.Id, Status: &unread}, 3, true))
|
||||
want = []string{"item011", "item121", "item111"}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// feeds
|
||||
|
||||
have = db.CountItems(ItemFilter{FeedID: &scope.feed21.Id})
|
||||
want = int64(2)
|
||||
if have != want {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
have = db.CountItems(ItemFilter{FeedID: &scope.feed01.Id})
|
||||
want = int64(3)
|
||||
if have != want {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// statuses
|
||||
|
||||
var unread ItemStatus = UNREAD
|
||||
have = db.CountItems(ItemFilter{Status: &unread})
|
||||
want = int64(3)
|
||||
if have != want {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// search
|
||||
|
||||
db.SyncSearch()
|
||||
search := "title0"
|
||||
have = db.CountItems(ItemFilter{Search: &search})
|
||||
want = int64(3)
|
||||
if have != want {
|
||||
// starred, oldest first
|
||||
starred := STARRED
|
||||
have = getItemGuids(db.ListItems(ItemFilter{After: &item121.Id, Status: &starred}, 3, false))
|
||||
want = []string{"item212", "item013"}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fail()
|
||||
@@ -256,7 +233,7 @@ func TestMarkItemsRead(t *testing.T) {
|
||||
db1 := testDB()
|
||||
testItemsSetup(db1)
|
||||
db1.MarkItemsRead(MarkFilter{})
|
||||
have := testItemGuids(db1.ListItems(ItemFilter{Status: &read}, 0, 10, false))
|
||||
have := getItemGuids(db1.ListItems(ItemFilter{Status: &read}, 10, false))
|
||||
want := []string{
|
||||
"item111", "item112", "item121", "item122",
|
||||
"item211", "item011", "item012",
|
||||
@@ -270,7 +247,7 @@ func TestMarkItemsRead(t *testing.T) {
|
||||
db2 := testDB()
|
||||
scope2 := testItemsSetup(db2)
|
||||
db2.MarkItemsRead(MarkFilter{FolderID: &scope2.folder1.Id})
|
||||
have = testItemGuids(db2.ListItems(ItemFilter{Status: &read}, 0, 10, false))
|
||||
have = getItemGuids(db2.ListItems(ItemFilter{Status: &read}, 10, false))
|
||||
want = []string{
|
||||
"item111", "item112", "item121", "item122",
|
||||
"item211", "item012",
|
||||
@@ -284,7 +261,7 @@ func TestMarkItemsRead(t *testing.T) {
|
||||
db3 := testDB()
|
||||
scope3 := testItemsSetup(db3)
|
||||
db3.MarkItemsRead(MarkFilter{FeedID: &scope3.feed11.Id})
|
||||
have = testItemGuids(db3.ListItems(ItemFilter{Status: &read}, 0, 10, false))
|
||||
have = getItemGuids(db3.ListItems(ItemFilter{Status: &read}, 10, false))
|
||||
want = []string{
|
||||
"item111", "item112", "item122",
|
||||
"item211", "item012",
|
||||
@@ -295,3 +272,59 @@ func TestMarkItemsRead(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteOldItems(t *testing.T) {
|
||||
extraItems := 10
|
||||
|
||||
now := time.Now()
|
||||
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)
|
||||
if len(feedItems) != len(items)-3 {
|
||||
t.Fatalf(
|
||||
"invalid number of old items kept\nwant: %d\nhave: %d",
|
||||
len(items)-3,
|
||||
len(feedItems),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@@ -13,6 +13,7 @@ var migrations = []func(*sql.Tx) error{
|
||||
m04_item_podcasturl,
|
||||
m05_move_description_to_content,
|
||||
m06_fill_missing_dates,
|
||||
m07_add_feed_size,
|
||||
}
|
||||
|
||||
var maxVersion = int64(len(migrations))
|
||||
@@ -259,3 +260,14 @@ func m06_fill_missing_dates(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(sql)
|
||||
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
|
||||
}
|
||||
|
@@ -15,6 +15,7 @@ func New(path string) (*Storage, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// TODO: https://foxcpp.dev/articles/the-right-way-to-use-go-sqlite3
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
if err = migrate(db); err != nil {
|
||||
|
@@ -11,6 +11,7 @@ func testDB() *Storage {
|
||||
log.SetOutput(io.Discard)
|
||||
db, _ := New(":memory:")
|
||||
log.SetOutput(os.Stderr)
|
||||
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
|
||||
return db
|
||||
}
|
||||
|
||||
|
@@ -9,3 +9,4 @@ hash:
|
||||
changes:
|
||||
|
||||
-removed `getlantern/golog` dependency
|
||||
-prevent from compiling in linux
|
||||
|
@@ -1,3 +1,5 @@
|
||||
// +build darwin windows
|
||||
|
||||
/*
|
||||
Package systray is a cross-platform Go library to place an icon and menu in the notification area.
|
||||
*/
|
||||
|
@@ -1,3 +1,5 @@
|
||||
// +build never
|
||||
|
||||
package systray
|
||||
|
||||
/*
|
||||
|
@@ -1,9 +1,8 @@
|
||||
// +build !windows
|
||||
// +build darwin
|
||||
|
||||
package systray
|
||||
|
||||
/*
|
||||
#cgo linux pkg-config: gtk+-3.0 appindicator3-0.1
|
||||
#cgo darwin CFLAGS: -DDARWIN -x objective-c -fobjc-arc
|
||||
#cgo darwin LDFLAGS: -framework Cocoa
|
||||
|
||||
|
@@ -4,10 +4,12 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
"strings"
|
||||
|
||||
"github.com/nkanaev/yarr/src/content/scraper"
|
||||
"github.com/nkanaev/yarr/src/parser"
|
||||
@@ -37,29 +39,32 @@ func DiscoverFeed(candidateUrl string) (*DiscoverResult, error) {
|
||||
if res.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("status code %d", res.StatusCode)
|
||||
}
|
||||
cs := getCharset(res)
|
||||
|
||||
body, err := charset.NewReader(res.Body, res.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
content, err := ioutil.ReadAll(body)
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Try to feed into parser
|
||||
feed, err := parser.Parse(bytes.NewReader(content))
|
||||
feed, err := parser.ParseAndFix(bytes.NewReader(body), candidateUrl, cs)
|
||||
if err == nil {
|
||||
feed.TranslateURLs(candidateUrl)
|
||||
feed.SetMissingDatesTo(time.Now())
|
||||
result.Feed = feed
|
||||
result.FeedLink = candidateUrl
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Possibly an html link. Search for feed links
|
||||
content := string(body)
|
||||
if cs != "" {
|
||||
if r, err := charset.NewReaderLabel(cs, bytes.NewReader(body)); err == nil {
|
||||
if body, err := io.ReadAll(r); err == nil {
|
||||
content = string(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
sources := make([]FeedSource, 0)
|
||||
for url, title := range scraper.FindFeeds(string(content), candidateUrl) {
|
||||
for url, title := range scraper.FindFeeds(content, candidateUrl) {
|
||||
sources = append(sources, FeedSource{Title: title, Url: url})
|
||||
}
|
||||
switch {
|
||||
@@ -76,8 +81,16 @@ func DiscoverFeed(candidateUrl string) (*DiscoverResult, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func findFavicon(websiteUrl, feedUrl string) (*[]byte, error) {
|
||||
candidateUrls := make([]string, 0)
|
||||
var emptyIcon = make([]byte, 0)
|
||||
var imageTypes = map[string]bool{
|
||||
"image/x-icon": true,
|
||||
"image/png": true,
|
||||
"image/jpeg": true,
|
||||
"image/gif": true,
|
||||
}
|
||||
|
||||
func findFavicon(siteUrl, feedUrl string) (*[]byte, error) {
|
||||
urls := make([]string, 0)
|
||||
|
||||
favicon := func(link string) string {
|
||||
u, err := url.Parse(link)
|
||||
@@ -87,49 +100,43 @@ func findFavicon(websiteUrl, feedUrl string) (*[]byte, error) {
|
||||
return fmt.Sprintf("%s://%s/favicon.ico", u.Scheme, u.Host)
|
||||
}
|
||||
|
||||
if len(websiteUrl) != 0 {
|
||||
res, err := client.get(websiteUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := ioutil.ReadAll(res.Body)
|
||||
defer res.Body.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
candidateUrls = append(candidateUrls, scraper.FindIcons(string(body), websiteUrl)...)
|
||||
if c := favicon(websiteUrl); len(c) != 0 {
|
||||
candidateUrls = append(candidateUrls, c)
|
||||
}
|
||||
}
|
||||
if c := favicon(feedUrl); len(c) != 0 {
|
||||
candidateUrls = append(candidateUrls, c)
|
||||
}
|
||||
|
||||
imageTypes := [4]string{
|
||||
"image/x-icon",
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
}
|
||||
for _, url := range candidateUrls {
|
||||
res, err := client.get(url)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode == 200 {
|
||||
if content, err := ioutil.ReadAll(res.Body); err == nil {
|
||||
ctype := http.DetectContentType(content)
|
||||
for _, itype := range imageTypes {
|
||||
if ctype == itype {
|
||||
return &content, nil
|
||||
}
|
||||
if siteUrl != "" {
|
||||
if res, err := client.get(siteUrl); err == nil {
|
||||
defer res.Body.Close()
|
||||
if body, err := ioutil.ReadAll(res.Body); err == nil {
|
||||
urls = append(urls, scraper.FindIcons(string(body), siteUrl)...)
|
||||
if c := favicon(siteUrl); c != "" {
|
||||
urls = append(urls, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
|
||||
if c := favicon(feedUrl); c != "" {
|
||||
urls = append(urls, c)
|
||||
}
|
||||
|
||||
for _, u := range urls {
|
||||
res, err := client.get(u)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != 200 {
|
||||
continue
|
||||
}
|
||||
|
||||
content, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
ctype := http.DetectContentType(content)
|
||||
if imageTypes[ctype] {
|
||||
return &content, nil
|
||||
}
|
||||
}
|
||||
return &emptyIcon, nil
|
||||
}
|
||||
|
||||
func ConvertItems(items []parser.Item, feed storage.Feed) []storage.Item {
|
||||
@@ -183,12 +190,7 @@ func listItems(f storage.Feed, db *storage.Storage) ([]storage.Item, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
body, err := charset.NewReader(res.Body, res.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
feed, err := parser.Parse(body)
|
||||
feed, err := parser.ParseAndFix(res.Body, f.FeedLink, getCharset(res))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -198,7 +200,42 @@ func listItems(f storage.Feed, db *storage.Storage) ([]storage.Item, error) {
|
||||
if lmod != "" || etag != "" {
|
||||
db.SetHTTPState(f.Id, lmod, etag)
|
||||
}
|
||||
feed.TranslateURLs(f.FeedLink)
|
||||
feed.SetMissingDatesTo(time.Now())
|
||||
return ConvertItems(feed.Items, f), nil
|
||||
}
|
||||
|
||||
func getCharset(res *http.Response) string {
|
||||
contentType := res.Header.Get("Content-Type")
|
||||
if _, params, err := mime.ParseMediaType(contentType); err == nil {
|
||||
if cs, ok := params["charset"]; ok {
|
||||
if e, _ := charset.Lookup(cs); e != nil {
|
||||
return cs
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func GetBody(url string) (string, error) {
|
||||
res, err := client.get(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var r io.Reader
|
||||
|
||||
ctype := res.Header.Get("Content-Type")
|
||||
if strings.Contains(ctype, "charset") {
|
||||
r, err = charset.NewReader(res.Body, ctype)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
r = res.Body
|
||||
}
|
||||
body, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
@@ -9,6 +9,8 @@ import (
|
||||
"github.com/nkanaev/yarr/src/storage"
|
||||
)
|
||||
|
||||
const NUM_WORKERS = 4
|
||||
|
||||
type Worker struct {
|
||||
db *storage.Storage
|
||||
pending *int32
|
||||
@@ -39,10 +41,8 @@ func (w *Worker) StartFeedCleaner() {
|
||||
|
||||
func (w *Worker) FindFavicons() {
|
||||
go func() {
|
||||
for _, feed := range w.db.ListFeeds() {
|
||||
if !feed.HasIcon {
|
||||
w.FindFeedFavicon(feed)
|
||||
}
|
||||
for _, feed := range w.db.ListFeedsMissingIcons() {
|
||||
w.FindFeedFavicon(feed)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -88,45 +88,51 @@ func (w *Worker) SetRefreshRate(minute int64) {
|
||||
}
|
||||
|
||||
func (w *Worker) RefreshFeeds() {
|
||||
log.Print("Refreshing feeds")
|
||||
go w.refresher()
|
||||
}
|
||||
|
||||
func (w *Worker) refresher() {
|
||||
w.reflock.Lock()
|
||||
defer w.reflock.Unlock()
|
||||
|
||||
w.db.ResetFeedErrors()
|
||||
|
||||
feeds := w.db.ListFeeds()
|
||||
if len(feeds) == 0 {
|
||||
if *w.pending > 0 {
|
||||
log.Print("Refreshing already in progress")
|
||||
return
|
||||
}
|
||||
|
||||
feeds := w.db.ListFeeds()
|
||||
if len(feeds) == 0 {
|
||||
log.Print("Nothing to refresh")
|
||||
return
|
||||
}
|
||||
|
||||
log.Print("Refreshing feeds")
|
||||
atomic.StoreInt32(w.pending, int32(len(feeds)))
|
||||
go w.refresher(feeds)
|
||||
}
|
||||
|
||||
func (w *Worker) refresher(feeds []storage.Feed) {
|
||||
w.db.ResetFeedErrors()
|
||||
|
||||
srcqueue := make(chan storage.Feed, len(feeds))
|
||||
dstqueue := make(chan []storage.Item)
|
||||
|
||||
// hardcoded to 4 workers ;)
|
||||
go w.worker(srcqueue, dstqueue)
|
||||
go w.worker(srcqueue, dstqueue)
|
||||
go w.worker(srcqueue, dstqueue)
|
||||
go w.worker(srcqueue, dstqueue)
|
||||
for i := 0; i < NUM_WORKERS; i++ {
|
||||
go w.worker(srcqueue, dstqueue)
|
||||
}
|
||||
|
||||
for _, feed := range feeds {
|
||||
srcqueue <- feed
|
||||
}
|
||||
for i := 0; i < len(feeds); i++ {
|
||||
w.db.CreateItems(<-dstqueue)
|
||||
items := <-dstqueue
|
||||
if len(items) > 0 {
|
||||
w.db.CreateItems(items)
|
||||
w.db.SetFeedSize(items[0].FeedId, len(items))
|
||||
}
|
||||
atomic.AddInt32(w.pending, -1)
|
||||
w.db.SyncSearch()
|
||||
}
|
||||
close(srcqueue)
|
||||
close(dstqueue)
|
||||
|
||||
w.db.SyncSearch()
|
||||
log.Printf("Finished refreshing %d feeds", len(feeds))
|
||||
|
||||
w.reflock.Unlock()
|
||||
}
|
||||
|
||||
func (w *Worker) worker(srcqueue <-chan storage.Feed, dstqueue chan<- []storage.Item) {
|
||||
|