Compare commits
2 Commits
master
...
72fe155e1e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72fe155e1e | ||
|
|
539e86f21f |
25
.github/actions/prepare/action.yml
vendored
@@ -1,25 +0,0 @@
|
||||
name: Build & Upload
|
||||
inputs:
|
||||
id:
|
||||
description: artifact name
|
||||
required: true
|
||||
cmd:
|
||||
description: command to run
|
||||
required: true
|
||||
out:
|
||||
description: path to output file
|
||||
required: true
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: compile
|
||||
run: ${{ inputs.cmd }}
|
||||
shell: bash
|
||||
- name: archive
|
||||
run: tar -cvf ${{ inputs.out }}.tar ${{ inputs.out }}
|
||||
shell: bash
|
||||
- name: upload
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.id }}
|
||||
path: ${{ inputs.out }}.tar
|
||||
@@ -1,16 +1,10 @@
|
||||
name: Publish Docker Image
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
tags: [ 'v*.*.*', 'v*.*', 'v*', 'latest' ]
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: nkanaev/yarr
|
||||
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -19,16 +13,10 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -36,21 +24,15 @@ jobs:
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=schedule
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=raw,value=bleeding,enable=${{ github.ref_name == 'master' }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a
|
||||
with:
|
||||
context: .
|
||||
file: ./etc/dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
207
.github/workflows/build.yml
vendored
@@ -1,143 +1,144 @@
|
||||
name: Build
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
workflow_dispatch:
|
||||
tags: ['v*', 'test*']
|
||||
|
||||
jobs:
|
||||
build_macos:
|
||||
name: Build for MacOS
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-13
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- name: Build arm64 gui
|
||||
uses: ./.github/actions/prepare
|
||||
submodules: 'recursive'
|
||||
- name: "Setup Go"
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
id: darwin_arm64_gui
|
||||
cmd: make darwin_arm64_gui
|
||||
out: out/darwin_arm64_gui/yarr.app
|
||||
- name: Build amd64 gui
|
||||
uses: ./.github/actions/prepare
|
||||
go-version: '^1.17'
|
||||
- name: Cache Go Modules
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
id: darwin_amd64_gui
|
||||
cmd: make darwin_amd64_gui
|
||||
out: out/darwin_amd64_gui/yarr.app
|
||||
- name: Build arm64 cli
|
||||
uses: ./.github/actions/prepare
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
- name: "Build"
|
||||
run: make build_macos
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
id: darwin_arm64
|
||||
cmd: make darwin_arm64
|
||||
out: out/darwin_arm64/yarr
|
||||
- name: Build amd64 cli
|
||||
uses: ./.github/actions/prepare
|
||||
with:
|
||||
id: darwin_amd64
|
||||
cmd: make darwin_amd64
|
||||
out: out/darwin_amd64/yarr
|
||||
name: macos
|
||||
path: _output/macos/yarr.app
|
||||
|
||||
build_windows:
|
||||
name: Build for Windows
|
||||
runs-on: windows-latest
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- name: Build amd64 gui
|
||||
uses: ./.github/actions/prepare
|
||||
submodules: 'recursive'
|
||||
- name: "Setup Go"
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
id: windows_amd64_gui
|
||||
cmd: make windows_amd64_gui
|
||||
out: out/windows_amd64_gui/yarr.exe
|
||||
- name: Build arm64 gui
|
||||
if: false
|
||||
uses: ./.github/actions/prepare
|
||||
go-version: '^1.17'
|
||||
- name: Cache Go Modules
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
id: windows_arm64_gui
|
||||
cmd: make windows_arm64_gui
|
||||
out: out/windows_arm64_gui/yarr.exe
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
- name: "Build"
|
||||
run: make build_windows
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: windows
|
||||
path: _output/windows/yarr.exe
|
||||
|
||||
build_multi_cli:
|
||||
name: Build for Windows/Linux (CLI)
|
||||
build_linux:
|
||||
name: Build for Linux
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- name: Setup Zig
|
||||
uses: mlugg/setup-zig@v1
|
||||
submodules: 'recursive'
|
||||
- name: "Setup Go"
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
version: 0.14.0
|
||||
- name: Build linux/amd64
|
||||
uses: ./.github/actions/prepare
|
||||
go-version: '^1.17'
|
||||
- name: Cache Go Modules
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
id: linux_amd64
|
||||
cmd: make linux_amd64
|
||||
out: out/linux_amd64/yarr
|
||||
- name: Build linux/arm64
|
||||
uses: ./.github/actions/prepare
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
- name: "Build"
|
||||
run: make build_linux
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
id: linux_arm64
|
||||
cmd: make linux_arm64
|
||||
out: out/linux_arm64/yarr
|
||||
- name: Build linux/armv7
|
||||
uses: ./.github/actions/prepare
|
||||
with:
|
||||
id: linux_armv7
|
||||
cmd: make linux_armv7
|
||||
out: out/linux_armv7/yarr
|
||||
- name: Build windows/amd64
|
||||
uses: ./.github/actions/prepare
|
||||
with:
|
||||
id: windows_amd64
|
||||
cmd: make windows_amd64
|
||||
out: out/windows_amd64/yarr
|
||||
- name: Build windows/arm64
|
||||
uses: ./.github/actions/prepare
|
||||
with:
|
||||
id: windows_arm64
|
||||
cmd: make windows_arm64
|
||||
out: out/windows_arm64/yarr
|
||||
name: linux
|
||||
path: _output/linux/yarr
|
||||
|
||||
create_release:
|
||||
name: Create Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build_macos, build_windows, build_multi_cli]
|
||||
if: ${{ !contains(github.ref, 'test') }}
|
||||
needs: [build_macos, build_windows, build_linux]
|
||||
steps:
|
||||
- name: Create Release
|
||||
uses: actions/create-release@v1
|
||||
id: create_release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: ${{ github.ref }}
|
||||
draft: true
|
||||
prerelease: true
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@v4.1.7
|
||||
uses: actions/download-artifact@v2
|
||||
with:
|
||||
path: .
|
||||
- name: Preparation
|
||||
run: |
|
||||
set -ex
|
||||
ls -R
|
||||
for tarfile in `ls **/*.tar`; do
|
||||
tar -xvf $tarfile
|
||||
done
|
||||
for dir in out/*; do
|
||||
echo "Compressing: $dir"
|
||||
(test -d "$dir" && cd $dir && zip -r ../yarr_`basename $dir`.zip *)
|
||||
done
|
||||
ls out
|
||||
- name: Upload Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
chmod u+x macos/Contents/MacOS/yarr
|
||||
chmod u+x linux/yarr
|
||||
|
||||
mv macos yarr.app && zip -r yarr-macos.zip yarr.app
|
||||
mv windows/yarr.exe . && zip yarr-windows.zip yarr.exe
|
||||
mv linux/yarr . && zip yarr-linux.zip yarr
|
||||
- name: Upload MacOS
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
draft: true
|
||||
prerelease: true
|
||||
files: |
|
||||
out/*.zip
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./yarr-macos.zip
|
||||
asset_name: yarr-${{ github.ref }}-macos64.zip
|
||||
asset_content_type: application/zip
|
||||
- name: Upload Windows
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./yarr-windows.zip
|
||||
asset_name: yarr-${{ github.ref }}-windows64.zip
|
||||
asset_content_type: application/zip
|
||||
- name: Upload Linux
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./yarr-linux.zip
|
||||
asset_name: yarr-${{ github.ref }}-linux64.zip
|
||||
asset_content_type: application/zip
|
||||
|
||||
22
.github/workflows/test.yml
vendored
@@ -1,22 +0,0 @@
|
||||
name: Test
|
||||
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
YARR_POSTGRES_TEST_IMAGE: postgres:17-alpine
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: Run tests
|
||||
run: make test
|
||||
5
.gitignore
vendored
@@ -1,10 +1,5 @@
|
||||
/_output
|
||||
/out
|
||||
/yarr
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.syso
|
||||
versioninfo.rc
|
||||
.DS_Store
|
||||
node_modules/
|
||||
|
||||
48
cmd/generate_versioninfo/main.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"io/ioutil"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var rsrc = `1 VERSIONINFO
|
||||
FILEVERSION {VERSION_COMMA},0,0
|
||||
PRODUCTVERSION {VERSION_COMMA},0,0
|
||||
BEGIN
|
||||
BLOCK "StringFileInfo"
|
||||
BEGIN
|
||||
BLOCK "080904E4"
|
||||
BEGIN
|
||||
VALUE "CompanyName", "Old MacDonald's Farm"
|
||||
VALUE "FileDescription", "Yet another RSS reader"
|
||||
VALUE "FileVersion", "{VERSION}"
|
||||
VALUE "InternalName", "yarr"
|
||||
VALUE "LegalCopyright", "nkanaev"
|
||||
VALUE "OriginalFilename", "yarr.exe"
|
||||
VALUE "ProductName", "yarr"
|
||||
VALUE "ProductVersion", "{VERSION}"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
BEGIN
|
||||
VALUE "Translation", 0x809, 1252
|
||||
END
|
||||
END
|
||||
|
||||
1 ICON "icon.ico"
|
||||
`
|
||||
|
||||
func main() {
|
||||
var version, outfile string
|
||||
flag.StringVar(&version, "version", "0.0", "")
|
||||
flag.StringVar(&outfile, "outfile", "versioninfo.rc", "")
|
||||
flag.Parse()
|
||||
|
||||
version_comma := strings.ReplaceAll(version, ".", ",")
|
||||
|
||||
out := strings.ReplaceAll(rsrc, "{VERSION}", version)
|
||||
out = strings.ReplaceAll(out, "{VERSION_COMMA}", version_comma)
|
||||
|
||||
ioutil.WriteFile(outfile, []byte(out), 0644)
|
||||
}
|
||||
99
cmd/package_macos/main.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var plist = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>yarr</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>yarr</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>nkanaev.yarr</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>VERSION</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>yarr</string>
|
||||
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icon</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.news</string>
|
||||
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>True</string>
|
||||
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2020 nkanaev. All rights reserved.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
`
|
||||
|
||||
func run(cmd ...string) {
|
||||
fmt.Println(cmd)
|
||||
err := exec.Command(cmd[0], cmd[1:]...).Run()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
var version, outdir string
|
||||
flag.StringVar(&version, "version", "0.0", "")
|
||||
flag.StringVar(&outdir, "outdir", "", "")
|
||||
flag.Parse()
|
||||
|
||||
outfile := "yarr"
|
||||
|
||||
binDir := path.Join(outdir, "yarr.app", "Contents/MacOS")
|
||||
resDir := path.Join(outdir, "yarr.app", "Contents/Resources")
|
||||
|
||||
plistFile := path.Join(outdir, "yarr.app", "Contents/Info.plist")
|
||||
pkginfoFile := path.Join(outdir, "yarr.app", "Contents/PkgInfo")
|
||||
|
||||
os.MkdirAll(binDir, 0700)
|
||||
os.MkdirAll(resDir, 0700)
|
||||
|
||||
f, _ := ioutil.ReadFile(path.Join(outdir, outfile))
|
||||
ioutil.WriteFile(path.Join(binDir, outfile), f, 0755)
|
||||
|
||||
ioutil.WriteFile(plistFile, []byte(strings.Replace(plist, "VERSION", version, 1)), 0644)
|
||||
ioutil.WriteFile(pkginfoFile, []byte("APPL????"), 0644)
|
||||
|
||||
iconFile := path.Join(outdir, "icon.png")
|
||||
iconsetDir := path.Join(outdir, "icon.iconset")
|
||||
os.Mkdir(iconsetDir, 0700)
|
||||
|
||||
for _, res := range []int{1024, 512, 256, 128, 64, 32, 16} {
|
||||
outfile := fmt.Sprintf("icon_%dx%d.png", res, res)
|
||||
if res == 1024 || res == 64 {
|
||||
outfile = fmt.Sprintf("icon_%dx%d@2x.png", res/2, res/2)
|
||||
}
|
||||
cmd := []string{
|
||||
"sips", "-s", "format", "png", "--resampleWidth", strconv.Itoa(res),
|
||||
iconFile, "--out", path.Join(iconsetDir, outfile),
|
||||
}
|
||||
run(cmd...)
|
||||
}
|
||||
|
||||
icnsFile := path.Join(resDir, "icon.icns")
|
||||
run("iconutil", "-c", "icns", iconsetDir, "-o", icnsFile)
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/nkanaev/yarr/src/platform"
|
||||
"github.com/nkanaev/yarr/src/server"
|
||||
"github.com/nkanaev/yarr/src/storage"
|
||||
"github.com/nkanaev/yarr/src/worker"
|
||||
)
|
||||
|
||||
var Version string = "0.0"
|
||||
@@ -32,7 +31,7 @@ func opt(envVar, defaultValue string) string {
|
||||
|
||||
func parseAuthfile(authfile io.Reader) (username, password string, err error) {
|
||||
scanner := bufio.NewScanner(authfile)
|
||||
if scanner.Scan() {
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
@@ -40,6 +39,7 @@ func parseAuthfile(authfile io.Reader) (username, password string, err error) {
|
||||
}
|
||||
username = parts[0]
|
||||
password = parts[1]
|
||||
break
|
||||
}
|
||||
return username, password, nil
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func main() {
|
||||
flag.Parse()
|
||||
|
||||
if ver {
|
||||
fmt.Printf("%s (%s)\n", Version, GitHash)
|
||||
fmt.Printf("v%s (%s)\n", Version, GitHash)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -89,16 +89,12 @@ func main() {
|
||||
log.SetOutput(os.Stdout)
|
||||
}
|
||||
|
||||
if open && strings.HasPrefix(addr, "unix:") {
|
||||
log.Fatal("Cannot open ", addr, " in browser")
|
||||
configPath, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to get config dir: ", err)
|
||||
}
|
||||
|
||||
if db == "" {
|
||||
configPath, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to get config dir: ", err)
|
||||
}
|
||||
|
||||
storagePath := filepath.Join(configPath, "yarr")
|
||||
if err := os.MkdirAll(storagePath, 0755); err != nil {
|
||||
log.Fatal("Failed to create app config dir: ", err)
|
||||
@@ -109,7 +105,6 @@ func main() {
|
||||
log.Printf("using db file %s", db)
|
||||
|
||||
var username, password string
|
||||
var err error
|
||||
if authfile != "" {
|
||||
f, err := os.Open(authfile)
|
||||
if err != nil {
|
||||
@@ -136,7 +131,6 @@ func main() {
|
||||
log.Fatal("Failed to initialise database: ", err)
|
||||
}
|
||||
|
||||
worker.SetVersion(Version)
|
||||
srv := server.NewServer(store, addr)
|
||||
|
||||
if basepath != "" {
|
||||
|
||||
32
doc/build.md
@@ -1,33 +1,21 @@
|
||||
## Compilation
|
||||
|
||||
Prerequisies:
|
||||
|
||||
* Go >= 1.23
|
||||
* C Compiler (GCC / Clang / ...)
|
||||
* Zig >= 0.14.0 (optional, for cross-compiling CLI versions)
|
||||
* binutils (optional, for building Windows GUI version)
|
||||
|
||||
Get the source code:
|
||||
Install `Go >= 1.17` and `GCC`. Get the source code:
|
||||
|
||||
git clone https://github.com/nkanaev/yarr.git
|
||||
|
||||
Compile:
|
||||
Then run one of the corresponding commands:
|
||||
|
||||
# create cli for the host OS/architecture
|
||||
make host # out/yarr
|
||||
# create an executable for the host os
|
||||
make build_macos # -> _output/macos/yarr.app
|
||||
make build_linux # -> _output/linux/yarr
|
||||
make build_windows # -> _output/windows/yarr.exe
|
||||
|
||||
# create GUI, works only in the target OS
|
||||
make windows_amd64_gui # out/windows_amd64_gui/yarr.exe
|
||||
make windows_arm64_gui # out/windows_arm64_gui/yarr.exe
|
||||
make darwin_arm64_gui # out/darwin_arm64_gui/yarr.app
|
||||
make darwin_amd64_gui # out/darwin_amd64_gui/yarr.app
|
||||
# host-specific cli version (no gui)
|
||||
make build_default # -> _output/yarr
|
||||
|
||||
# create cli, cross-compiles within any OS/architecture
|
||||
make linux_amd64
|
||||
make linux_arm64
|
||||
make linux_armv7
|
||||
make windows_amd64
|
||||
make windows_arm64
|
||||
# ... or start a dev server locally
|
||||
make serve # starts a server at http://localhost:7070
|
||||
|
||||
# ... or build a docker image
|
||||
docker build -t yarr -f etc/dockerfile .
|
||||
|
||||
@@ -1,46 +1,11 @@
|
||||
# upcoming
|
||||
|
||||
- (new) initial PostgreSQL support
|
||||
- (new) i18n: English, Chinese, French, German, Japanese, Portuguese, Russian, Spanish
|
||||
- (fix) articles not resetting immediately after feed/filter selection (thank to @scratchmex for the report)
|
||||
- (fix) crash on empty article list with article is selected (thanks to @rksvc)
|
||||
- (fix) invalid article title in RSS feeds with media containing titles (thanks to @bwwu-git for the report)
|
||||
- (fix) missing image enclosures in certain RSS feeds (thanks to @palinek for the report)
|
||||
- (fix) parsing namespaced legacy RSS feeds (thanks to @f100024)
|
||||
- (fix) marking feeds read in Fever API (thanks to @weskoop)
|
||||
- (etc) systray improvements for macOS
|
||||
|
||||
# v2.6 (2025-11-24)
|
||||
|
||||
- (new) serve on unix socket (thanks to @rvighne)
|
||||
- (new) more auto-refresh options: 12h & 24h (thanks to @aswerkljh for suggestion)
|
||||
- (fix) smooth scrolling on iOS (thanks to gatheraled)
|
||||
- (fix) displaying youtube shorts in "Read Here" (thanks to @Dean-Corso for the report)
|
||||
- (etc) theme-color support (thanks to @asimpson)
|
||||
- (etc) cookie security measures (thanks to Tom Fitzhenry)
|
||||
- (etc) restrict access to internal IPs for page crawler (thanks to Omar Kurt)
|
||||
|
||||
# v2.5 (2025-03-26)
|
||||
# upcoming
|
||||
|
||||
- (new) Fever API support (thanks to @icefed)
|
||||
- (new) editable feed link (thanks to @adaszko)
|
||||
- (new) switch to feed by clicking the title in the article page (thanks to @tarasglek for suggestion)
|
||||
- (new) support multiple media links
|
||||
- (new) next/prev article navigation buttons (thanks to @tillcash)
|
||||
- (fix) duplicate articles caused by the same feed addition (thanks to @adaszko)
|
||||
- (fix) relative article links (thanks to @adazsko for the report)
|
||||
- (fix) atom article links stored in id element (thanks to @adazsko for the report)
|
||||
- (fix) parsing atom feed titles (thanks to @wnh)
|
||||
- (fix) sorting same-day batch articles (thanks to @lamescholar for the report)
|
||||
- (fix) showing login page in the selected theme (thanks to @feddiriko for the report)
|
||||
- (fix) parsing atom feeds with html elements (thanks to @tillcash & @toBeOfUse for the report, @krkk for the fix)
|
||||
- (fix) parsing feeds with missing guids (thanks to @hoyii for the report)
|
||||
- (fix) sending actual client version to servers (thanks to @aidanholm)
|
||||
- (fix) error caused by missing config dir (thanks to @timster)
|
||||
- (etc) load external images with no-referrer policy (thanks to @tillcash for the report)
|
||||
- (etc) open external links with no-referrer policy (thanks to @donovanglover)
|
||||
- (etc) show article content in the list if title is missing (thanks to @asimpson for suggestion)
|
||||
- (etc) accessibility improvements (thanks to @tseykovets)
|
||||
|
||||
# v2.4 (2023-08-15)
|
||||
|
||||
254
doc/fever-api.md
@@ -1,254 +0,0 @@
|
||||
# API Public Beta
|
||||
|
||||
Fever 1.14 introduces the new Fever API. This API is in public beta and currently supports basic syncing and consuming of content. A subsequent update will allow for adding, editing and deleting feeds and groups. The API’s primary focus is maintaining a local cache of the data in a remote Fever installation.
|
||||
|
||||
I am [soliciting feedback](https://web.archive.org/web/20221221112459/https://feedafever.com/contact) from interested developers and as such the beta API may expand to reflect that feedback. The current API is incomplete but stable. Existing features may be expanded on but will not be removed or modified. New features may be added.
|
||||
|
||||
I’ve created a [simple HTML widget](https://web.archive.org/web/20221221112459/https://feedafever.com/gateway/public/api-widget.html.zip) that allows you to query the Fever API and view the response.
|
||||
|
||||
## Authentication
|
||||
|
||||
Without further ado, the Fever API endpoint URL looks like:
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api
|
||||
```
|
||||
|
||||
All requests must be authenticated with a `POST`ed `api_key`. The value of `api_key` should be the md5 checksum of the Fever accounts email address and password concatenated with a colon. An example of a valid value for `api_key` using PHP’s native `md5()` function:
|
||||
|
||||
```
|
||||
$email = 'you@yourdomain.com';
|
||||
$pass = 'b3stp4s4wd3v4';
|
||||
$api_key = md5($email.':'.$pass);
|
||||
```
|
||||
|
||||
A user may specify that `https` be used to connect to their Fever installation for additional security but you should not assume that all Fever installations support `https`.
|
||||
|
||||
The default response is a JSON object containing two members:
|
||||
|
||||
- `api_version` contains the version of the API responding (positive integer)
|
||||
- `auth` whether the request was successfully authenticated (boolean integer)
|
||||
|
||||
The API can also return XML by passing `xml` as the optional value of the `api` argument like so:
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api=xml
|
||||
```
|
||||
|
||||
The top level XML element is named `response`.
|
||||
|
||||
The response to each successfully authenticated request will have `auth` set to `1` and include at least one additional member:
|
||||
|
||||
- `last_refreshed_on_time` contains the time of the most recently refreshed (not _updated_) feed (Unix timestamp/integer)
|
||||
|
||||
## Read
|
||||
|
||||
When reading from the Fever API you add arguments to the query string of the API endpoint URL. If you attempt to `POST` these arguments (and their optional values) Fever will not recognize the request.
|
||||
|
||||
### Groups
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api&groups
|
||||
```
|
||||
|
||||
A request with the `groups` argument will return two additional members:
|
||||
|
||||
- `groups` contains an array of `group` objects
|
||||
- `feeds_groups` contains an array of `feeds_group` objects
|
||||
|
||||
A `group` object has the following members:
|
||||
|
||||
- `id` (positive integer)
|
||||
- `title` (utf-8 string)
|
||||
|
||||
The `feeds_group` object is documented under “Feeds/Groups Relationships.”
|
||||
|
||||
The “Kindling” super group is not included in this response and is composed of all feeds with an `is_spark` equal to `0`. The “Sparks” super group is not included in this response and is composed of all feeds with an `is_spark` equal to `1`.
|
||||
|
||||
### Feeds
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api&feeds
|
||||
```
|
||||
|
||||
A request with the `feeds` argument will return two additional members:
|
||||
|
||||
- `feeds` contains an array of `group` objects
|
||||
- `feeds_groups` contains an array of `feeds_group` objects
|
||||
|
||||
A `feed` object has the following members:
|
||||
|
||||
- `id` (positive integer)
|
||||
- `favicon_id` (positive integer)
|
||||
- `title` (utf-8 string)
|
||||
- `url` (utf-8 string)
|
||||
- `site_url` (utf-8 string)
|
||||
- `is_spark` (boolean integer)
|
||||
- `last_updated_on_time` (Unix timestamp/integer)
|
||||
|
||||
The `feeds_group` object is documented under “Feeds/Groups Relationships.”
|
||||
|
||||
The “All Items” super feed is not included in this response and is composed of all items from all feeds that belong to a given group. For the “Kindling” super group and all user created groups the items should be limited to feeds with an `is_spark` equal to `0`. For the “Sparks” super group the items should be limited to feeds with an `is_spark` equal to `1`.
|
||||
|
||||
### Feeds/Groups Relationships
|
||||
|
||||
A request with either the `groups` or `feeds` arguments will return an additional member:
|
||||
|
||||
A `feeds_group` object has the following members:
|
||||
|
||||
- `group_id` (positive integer)
|
||||
- `feed_ids` (string/comma-separated list of positive integers)
|
||||
|
||||
### Favicons
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api&favicons
|
||||
```
|
||||
|
||||
A request with the `favicons` argument will return one additional member:
|
||||
|
||||
- `favicons` contains an array of `favicon` objects
|
||||
|
||||
A `favicon` object has the following members:
|
||||
|
||||
- `id` (positive integer)
|
||||
- `data` (base64 encoded image data; prefixed by image type)
|
||||
|
||||
An example `data` value:
|
||||
|
||||
```
|
||||
image/gif;base64,R0lGODlhAQABAIAAAObm5gAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==
|
||||
```
|
||||
|
||||
The `data` member of a `favicon` object can be used with the `data:` protocol to embed an image in CSS or HTML. A PHP/HTML example:
|
||||
|
||||
```
|
||||
echo '<img src="data:'.$favicon['data'].'">';
|
||||
```
|
||||
|
||||
### Items
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api&items
|
||||
```
|
||||
|
||||
A request with the `items` argument will return two additional members:
|
||||
|
||||
- `items` contains an array of `item` objects
|
||||
- `total_items` contains the total number of items stored in the database (added in API version 2)
|
||||
|
||||
An `item` object has the following members:
|
||||
|
||||
- `id` (positive integer)
|
||||
- `feed_id` (positive integer)
|
||||
- `title` (utf-8 string)
|
||||
- `author` (utf-8 string)
|
||||
- `html` (utf-8 string)
|
||||
- `url` (utf-8 string)
|
||||
- `is_saved` (boolean integer)
|
||||
- `is_read` (boolean integer)
|
||||
- `created_on_time` (Unix timestamp/integer)
|
||||
|
||||
Most servers won’t have enough memory allocated to PHP to dump all items at once. Three optional arguments control determine the items included in the response.
|
||||
|
||||
- Use the `since_id` argument with the highest id of locally cached items to request 50 additional items. Repeat until the `items` array in the response is empty.
|
||||
|
||||
- Use the `max_id` argument with the lowest id of locally cached items (or `0` initially) to request 50 previous items. Repeat until the `items` array in the response is empty. (added in API version 2)
|
||||
|
||||
- Use the `with_ids` argument with a comma-separated list of item ids to request (a maximum of 50) specific items. (added in API version 2)
|
||||
|
||||
|
||||
### Hot Links
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api&links
|
||||
```
|
||||
|
||||
A request with the `links` argument will return one additional member:
|
||||
|
||||
- `links` contains an array of `link` objects
|
||||
|
||||
A `link` object has the following members:
|
||||
|
||||
- `id` (positive integer)
|
||||
- `feed_id` (positive integer) only use when `is_item` equals `1`
|
||||
- `item_id` (positive integer) only use when `is_item` equals `1`
|
||||
- `temperature` (positive float)
|
||||
- `is_item` (boolean integer)
|
||||
- `is_local` (boolean integer) used to determine if the source feed and favicon should be displayed
|
||||
- `is_saved` (boolean integer) only use when `is_item` equals `1`
|
||||
- `title` (utf-8 string)
|
||||
- `url` (utf-8 string)
|
||||
- `item_ids` (string/comma-separated list of positive integers)
|
||||
|
||||
When requesting hot links you can control the range and offset by specifying a length of days for each as well as a page to fetch additional hot links. A request with just the `links` argument is equivalent to:
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api&links&offset=0&range=7&page=1
|
||||
```
|
||||
|
||||
Or the first page (`page=1`) of Hot links for the past week (`range=7`) starting now (`offset=0`).
|
||||
|
||||
### Link Caveats
|
||||
|
||||
Fever calculates Hot link temperatures in real-time. The API assumes you have an up-to-date local cache of items, feeds and favicons with which to construct a meaningful Hot view. Because they are ephemeral Hot links should not be cached in the same relational manner as items, feeds, groups and favicons.
|
||||
|
||||
Because Fever saves items and not individual links you can only "save" a Hot link when `is_item` equals `1`.
|
||||
|
||||
## Sync
|
||||
|
||||
The `unread_item_ids` and `saved_item_ids` arguments can be used to keep your local cache synced with the remote Fever installation.
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api&unread_item_ids
|
||||
```
|
||||
|
||||
A request with the `unread_item_ids` argument will return one additional member:
|
||||
|
||||
- `unread_item_ids` (string/comma-separated list of positive integers)
|
||||
|
||||
|
||||
```
|
||||
http://yourdomain.com/fever/?api&saved_item_ids
|
||||
```
|
||||
|
||||
A request with the `saved_item_ids` argument will return one additional member:
|
||||
|
||||
- `saved_item_ids` (string/comma-separated list of positive integers)
|
||||
|
||||
One of these members will be returned as appropriate when marking an item as read, unread, saved, or unsaved and when marking a feed or group as read.
|
||||
|
||||
Because groups and feeds will be limited in number compared to items, they should be synced by comparing an array of locally cached feed or group ids to an array of feed or group ids returned by their respective API request.
|
||||
|
||||
## Write
|
||||
|
||||
The public beta of the API does not provide a way to add, edit or delete feeds or groups but you can mark items, feeds and groups as read and save or unsave items. You can also unread recently read items. When writing to the Fever API you add arguments to the `POST` data you submit to the API endpoint URL.
|
||||
|
||||
Adding `unread_recently_read=1` to your `POST` data will mark recently read items as unread.
|
||||
|
||||
You can update an individual item by adding the following three arguments to your `POST` data:
|
||||
|
||||
- `mark=item`
|
||||
- `as=?` where `?` is replaced with `read`, `saved` or `unsaved`
|
||||
- `id=?` where `?` is replaced with the `id` of the item to modify
|
||||
|
||||
Marking a feed or group as read is similar but requires one additional argument to prevent marking new, unreceived items as read:
|
||||
|
||||
- `mark=?` where `?` is replaced with `feed` or `group`
|
||||
- `as=read`
|
||||
- `id=?` where `?` is replaced with the `id` of the feed or group to modify
|
||||
- `before=?` where `?` is replaced with the Unix timestamp of the the local client’s most recent `items` API request
|
||||
|
||||
You can mark the “Kindling” super group (and the “Sparks” super group) as read by adding the following four arguments to your `POST` data:
|
||||
|
||||
- `mark=group`
|
||||
- `as=read`
|
||||
- `id=0`
|
||||
- `before=?` where `?` is replaced with the Unix timestamp of the the local client’s last `items` API request
|
||||
|
||||
Similarly you can mark just the “Sparks” super group as read by adding the following four arguments to your `POST` data:
|
||||
|
||||
- `mark=group`
|
||||
- `as=read`
|
||||
- `id=-1`
|
||||
- `before=?` where `?` is replaced with the Unix timestamp of the the local client’s last `items` API request
|
||||
1755
doc/fever-api.mhtml
@@ -16,6 +16,11 @@ The licenses are included, and the authorship comments are left intact.
|
||||
- allowed uri schemes
|
||||
- added svg whitelist
|
||||
|
||||
- systray
|
||||
https://github.com/getlantern/systray (commit:2c0986d) Apache 2.0
|
||||
|
||||
removed golog dependency
|
||||
|
||||
- fixconsole
|
||||
https://github.com/apenwarr/fixconsole (commit:5a9f648) Apache 2.0
|
||||
|
||||
|
||||
0
doc/todo.txt
Normal file
@@ -1,14 +1,13 @@
|
||||
FROM golang:alpine3.21 AS build
|
||||
FROM golang:alpine AS build
|
||||
RUN apk add build-base git
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/root/go/pkg \
|
||||
make host
|
||||
RUN make build_linux
|
||||
|
||||
FROM alpine:latest
|
||||
RUN apk add --no-cache ca-certificates && update-ca-certificates
|
||||
COPY --from=build /src/out/yarr /usr/local/bin/yarr
|
||||
RUN apk add --no-cache ca-certificates && \
|
||||
update-ca-certificates
|
||||
COPY --from=build /src/_output/linux/yarr /usr/local/bin/yarr
|
||||
EXPOSE 7070
|
||||
ENTRYPOINT ["/usr/local/bin/yarr"]
|
||||
CMD ["-addr", "0.0.0.0:7070", "-db", "/data/yarr.db"]
|
||||
|
||||
@@ -12,9 +12,9 @@ RUN env DEBIAN_FRONTEND=noninteractive \
|
||||
apt install -y qemu-user qemu-user-static
|
||||
|
||||
# Install Golang
|
||||
RUN wget --quiet https://go.dev/dl/go1.24.1.linux-amd64.tar.gz && \
|
||||
RUN wget --quiet https://go.dev/dl/go1.18.2.linux-amd64.tar.gz && \
|
||||
rm -rf /usr/local/go && \
|
||||
tar -C /usr/local -xzf go1.24.1.linux-amd64.tar.gz
|
||||
tar -C /usr/local -xzf go1.18.2.linux-amd64.tar.gz
|
||||
ENV PATH=$PATH:/usr/local/go/bin
|
||||
|
||||
# Copy source code
|
||||
@@ -27,12 +27,18 @@ RUN env \
|
||||
CC=aarch64-linux-gnu-gcc \
|
||||
CGO_ENABLED=1 \
|
||||
GOOS=linux GOARCH=arm64 \
|
||||
make host && mv out/yarr /root/out/yarr.arm64
|
||||
go build \
|
||||
-tags "sqlite_foreign_keys linux" \
|
||||
-ldflags="-s -w" \
|
||||
-o /root/out/yarr.arm64 ./cmd/yarr
|
||||
|
||||
RUN env \
|
||||
CC=arm-linux-gnueabihf-gcc \
|
||||
CGO_ENABLED=1 \
|
||||
GOOS=linux GOARCH=arm GOARM=7 \
|
||||
make host && mv out/yarr /root/out/yarr.armv7
|
||||
go build \
|
||||
-tags "sqlite_foreign_keys linux" \
|
||||
-ldflags="-s -w" \
|
||||
-o /root/out/yarr.arm7 ./cmd/yarr
|
||||
|
||||
CMD ["/bin/bash"]
|
||||
|
||||
BIN
etc/icon.icns
|
Before Width: | Height: | Size: 54 KiB |
@@ -10,7 +10,7 @@ Name=yarr
|
||||
Exec=$HOME/.local/bin/yarr -open
|
||||
Icon=yarr
|
||||
Type=Application
|
||||
Categories=Internet;Network;News;Feed;
|
||||
Categories=Internet;
|
||||
END
|
||||
|
||||
if [[ ! -d "$HOME/.local/share/icons" ]]; then
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
#/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
usage() {
|
||||
echo "usage: $0 VERSION path/to/icon.icns path/to/binary output/dir"
|
||||
}
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
usage
|
||||
exit
|
||||
fi
|
||||
|
||||
VERSION=$1
|
||||
ICNFILE=$2
|
||||
BINFILE=$3
|
||||
OUTPATH=$4
|
||||
|
||||
mkdir -p $OUTPATH/yarr.app/Contents/MacOS
|
||||
mkdir -p $OUTPATH/yarr.app/Contents/Resources
|
||||
|
||||
mv $BINFILE $OUTPATH/yarr.app/Contents/MacOS/yarr
|
||||
cp $ICNFILE $OUTPATH/yarr.app/Contents/Resources/icon.icns
|
||||
|
||||
chmod u+x $OUTPATH/yarr.app/Contents/MacOS/yarr
|
||||
|
||||
echo -n 'APPL????' >$OUTPATH/yarr.app/Contents/PkgInfo
|
||||
cat <<EOF >$OUTPATH/yarr.app/Contents/Info.plist
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>yarr</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>yarr</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>nkanaev.yarr</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$VERSION</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>yarr</string>
|
||||
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icon</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.news</string>
|
||||
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>True</string>
|
||||
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2020 nkanaev. All rights reserved.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
BIN
etc/promo.png
|
Before Width: | Height: | Size: 335 KiB After Width: | Height: | Size: 173 KiB |
@@ -1,68 +0,0 @@
|
||||
- site: https://vimeo.com/channels/staffpicks/videos
|
||||
feed: https://vimeo.com/channels/staffpicks/videos/rss
|
||||
tags: [vimeo, image]
|
||||
|
||||
- site: https://www.youtube.com/@everyframeapainting/videos
|
||||
feed: https://www.youtube.com/feeds/videos.xml?channel_id=UCjFqcJQXGZ6T6sxyFB-5i6A"
|
||||
tags: [youtube, image]
|
||||
|
||||
- site: https://iwdrm.tumblr.com/
|
||||
feed: https://iwdrm.tumblr.com/rss
|
||||
tags: [tumblr, image]
|
||||
|
||||
- site: https://falseknees.tumblr.com/
|
||||
feed: https://falseknees.tumblr.com/rss
|
||||
tags: [tumblr, image]
|
||||
|
||||
- site: https://accidentallyquadratic.tumblr.com/
|
||||
feed: https://accidentallyquadratic.tumblr.com/rss
|
||||
info: text blog with code sections
|
||||
tags: [tumblr, text, code]
|
||||
|
||||
- site: https://www.flickr.com/photos/maratsafin/
|
||||
feed: https://www.flickr.com/services/feeds/photos_public.gne?id=59021497@N07&lang=en-us&format=atom
|
||||
tags: [flickr, image]
|
||||
|
||||
- site: https://www.reddit.com/r/comics
|
||||
feed: https://www.reddit.com/r/comics.rss
|
||||
tags: [reddit, image]
|
||||
|
||||
- site: https://www.reddit.com/r/AITAH
|
||||
feed: https://www.reddit.com/r/AITAH.rss
|
||||
tags: [reddit, text]
|
||||
|
||||
- site: https://idothei.wordpress.com/
|
||||
feed: https://idothei.wordpress.com/feed/
|
||||
tags: [wordpress, text]
|
||||
|
||||
- site: https://www.vidarholen.net/contents/blog/
|
||||
feed: https://www.vidarholen.net/contents/blog/?feed=rss2
|
||||
tags: [wordpress, text]
|
||||
|
||||
- site: https://blog.posthaven.com/
|
||||
feed: https://blog.posthaven.com/posts.atom
|
||||
tags: [posthaven, text]
|
||||
|
||||
- site: https://medium.com/@dailynewsletter
|
||||
feed: https://medium.com/feed/@dailynewsletter
|
||||
tags: [medium, text]
|
||||
|
||||
- site: https://thereveal.substack.com/
|
||||
feed: https://thereveal.substack.com/feed
|
||||
tags: [substack, text]
|
||||
|
||||
- site: https://tema.livejournal.com/
|
||||
feed: https://tema.livejournal.com/data/rss
|
||||
tags: [livejournal, text]
|
||||
|
||||
- site: https://mametter.hatenablog.com/
|
||||
feed: https://mametter.hatenablog.com/feed
|
||||
tags: [hatena, text]
|
||||
|
||||
- site: https://juliepowell.blogspot.com/
|
||||
feed: https://juliepowell.blogspot.com/feeds/posts/default
|
||||
tags: [blogger, text]
|
||||
|
||||
- site: https://micro.blog/val
|
||||
feed: https://micro.blog/posts/val
|
||||
tags: [json, microblog]
|
||||
@@ -1,90 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
# Function to display usage information
|
||||
usage() {
|
||||
echo "Usage: $0 [-version VERSION] [-outfile FILENAME]"
|
||||
echo " -version VERSION Set the version number (default: 0.0)"
|
||||
echo " -outfile FILENAME Set the output file name (default: versioninfo.rc)"
|
||||
echo ""
|
||||
echo "This script generates a Windows resource file with version information."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Default values
|
||||
version="0.0"
|
||||
outfile="versioninfo.rc"
|
||||
|
||||
# Check if help is requested
|
||||
if [[ "$1" == "-h" || "$1" == "--help" ]]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
# Parse command-line options
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-version)
|
||||
if [[ -z "$2" || "$2" == -* ]]; then
|
||||
echo "Error: Missing value for -version parameter"
|
||||
usage
|
||||
fi
|
||||
version="$2"
|
||||
shift 2
|
||||
;;
|
||||
-outfile)
|
||||
if [[ -z "$2" || "$2" == -* ]]; then
|
||||
echo "Error: Missing value for -outfile parameter"
|
||||
usage
|
||||
fi
|
||||
outfile="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "Error: Unknown parameter: $1"
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Strip leading 'v' and replace dots with commas for version_comma
|
||||
version_num="${version#v}"
|
||||
version_comma="${version_num//./,}"
|
||||
|
||||
# Use a here document for the template with ENDFILE delimiter
|
||||
cat <<ENDFILE > "$outfile"
|
||||
1 VERSIONINFO
|
||||
FILEVERSION $version_comma,0,0
|
||||
PRODUCTVERSION $version_comma,0,0
|
||||
BEGIN
|
||||
BLOCK "StringFileInfo"
|
||||
BEGIN
|
||||
BLOCK "080904E4"
|
||||
BEGIN
|
||||
VALUE "CompanyName", "Old MacDonald's Farm"
|
||||
VALUE "FileDescription", "Yet another RSS reader"
|
||||
VALUE "FileVersion", "$version"
|
||||
VALUE "InternalName", "yarr"
|
||||
VALUE "LegalCopyright", "nkanaev"
|
||||
VALUE "OriginalFilename", "yarr.exe"
|
||||
VALUE "ProductName", "yarr"
|
||||
VALUE "ProductVersion", "$version"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
BEGIN
|
||||
VALUE "Translation", 0x809, 1252
|
||||
END
|
||||
END
|
||||
|
||||
1 ICON "icon.ico"
|
||||
ENDFILE
|
||||
|
||||
# Set the correct permissions
|
||||
chmod 644 "$outfile"
|
||||
|
||||
echo "Generated $outfile with version $version"
|
||||
15
go.mod
@@ -1,16 +1,11 @@
|
||||
module github.com/nkanaev/yarr
|
||||
|
||||
go 1.25.0
|
||||
go 1.17
|
||||
|
||||
require (
|
||||
fyne.io/systray v1.12.0
|
||||
github.com/lib/pq v1.12.3
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
golang.org/x/net v0.38.0
|
||||
golang.org/x/sys v0.31.0
|
||||
github.com/mattn/go-sqlite3 v1.14.7
|
||||
golang.org/x/net v0.17.0
|
||||
golang.org/x/sys v0.13.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
)
|
||||
require golang.org/x/text v0.13.0 // indirect
|
||||
|
||||
59
go.sum
@@ -1,14 +1,45 @@
|
||||
fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM=
|
||||
fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
github.com/mattn/go-sqlite3 v1.14.7 h1:fxWBnXkxfM6sRiuH3bqJ4CfzZojMOLVc0UTsTglEghA=
|
||||
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
101
makefile
@@ -1,92 +1,33 @@
|
||||
VERSION=$(shell git describe --exact-match --tags HEAD 2>/dev/null || echo bleeding)
|
||||
VERSION=2.4
|
||||
GITHASH=$(shell git rev-parse --short=8 HEAD)
|
||||
|
||||
GO_TAGS = sqlite_foreign_keys sqlite_json sqlite_fts5
|
||||
GO_LDFLAGS = -s -w -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)'
|
||||
CGO_ENABLED=1
|
||||
|
||||
GO_FLAGS = -tags "$(GO_TAGS)" -ldflags="$(GO_LDFLAGS)"
|
||||
GO_FLAGS_DEBUG = -tags "$(GO_TAGS) debug"
|
||||
GO_FLAGS_GUI = -tags "$(GO_TAGS) gui" -ldflags="$(GO_LDFLAGS)"
|
||||
GO_FLAGS_GUI_WIN = -tags "$(GO_TAGS) gui" -ldflags="$(GO_LDFLAGS) -H windowsgui"
|
||||
GO_LDFLAGS = -s -w
|
||||
GO_LDFLAGS := $(GO_LDFLAGS) -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITHASH)'
|
||||
|
||||
export CGO_ENABLED=1
|
||||
build_default:
|
||||
mkdir -p _output
|
||||
go build -tags "sqlite_foreign_keys" -ldflags="$(GO_LDFLAGS)" -o _output/yarr ./cmd/yarr
|
||||
|
||||
default: test host
|
||||
build_macos:
|
||||
mkdir -p _output/macos
|
||||
GOOS=darwin GOARCH=amd64 go build -tags "sqlite_foreign_keys macos" -ldflags="$(GO_LDFLAGS)" -o _output/macos/yarr ./cmd/yarr
|
||||
cp src/platform/icon.png _output/macos/icon.png
|
||||
go run ./cmd/package_macos -outdir _output/macos -version "$(VERSION)"
|
||||
|
||||
# platform-specific files
|
||||
build_linux:
|
||||
mkdir -p _output/linux
|
||||
GOOS=linux GOARCH=amd64 go build -tags "sqlite_foreign_keys linux" -ldflags="$(GO_LDFLAGS)" -o _output/linux/yarr ./cmd/yarr
|
||||
|
||||
etc/icon.icns: etc/icon_macos.png
|
||||
mkdir -p etc/icon.iconset
|
||||
sips -s format png --resampleWidth 1024 etc/icon_macos.png --out etc/icon.iconset/icon_512x512@2x.png
|
||||
sips -s format png --resampleWidth 512 etc/icon_macos.png --out etc/icon.iconset/icon_512x512.png
|
||||
sips -s format png --resampleWidth 256 etc/icon_macos.png --out etc/icon.iconset/icon_256x256.png
|
||||
sips -s format png --resampleWidth 128 etc/icon_macos.png --out etc/icon.iconset/icon_128x128.png
|
||||
sips -s format png --resampleWidth 64 etc/icon_macos.png --out etc/icon.iconset/icon_32x32@2x.png
|
||||
sips -s format png --resampleWidth 32 etc/icon_macos.png --out etc/icon.iconset/icon_32x32.png
|
||||
sips -s format png --resampleWidth 16 etc/icon_macos.png --out etc/icon.iconset/icon_16x16.png
|
||||
iconutil -c icns etc/icon.iconset -o etc/icon.icns
|
||||
|
||||
src/platform/versioninfo.rc:
|
||||
./etc/windows_versioninfo.sh -version "$(VERSION)" -outfile src/platform/versioninfo.rc
|
||||
build_windows:
|
||||
mkdir -p _output/windows
|
||||
go run ./cmd/generate_versioninfo -version "$(VERSION)" -outfile src/platform/versioninfo.rc
|
||||
windres -i src/platform/versioninfo.rc -O coff -o src/platform/versioninfo.syso
|
||||
|
||||
# build targets
|
||||
|
||||
host:
|
||||
go build $(GO_FLAGS) -o out/yarr ./cmd/yarr
|
||||
|
||||
darwin_amd64:
|
||||
# cross-compilation not supported: CC="zig cc -target x86_64-macos-none"
|
||||
GOOS=darwin GOARCH=arm64 go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
|
||||
|
||||
darwin_arm64:
|
||||
# cross-compilation not supported: CC="zig cc -target aarch64-macos-none"
|
||||
GOOS=darwin GOARCH=arm64 go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
|
||||
|
||||
linux_amd64:
|
||||
CC="zig cc -target x86_64-linux-musl -O2 -g0" CGO_CFLAGS="-D_LARGEFILE64_SOURCE" GOOS=linux GOARCH=amd64 \
|
||||
go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
|
||||
|
||||
linux_arm64:
|
||||
CC="zig cc -target aarch64-linux-musl -O2 -g0" CGO_CFLAGS="-D_LARGEFILE64_SOURCE" GOOS=linux GOARCH=arm64 \
|
||||
go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
|
||||
|
||||
linux_armv7:
|
||||
CC="zig cc -target arm-linux-musleabihf -O2 -g0" CGO_CFLAGS="-D_LARGEFILE64_SOURCE" GOOS=linux GOARCH=arm GOARM=7 \
|
||||
go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
|
||||
|
||||
windows_amd64:
|
||||
CC="zig cc -target x86_64-windows-gnu" GOOS=windows GOARCH=amd64 go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
|
||||
|
||||
windows_arm64:
|
||||
CC="zig cc -target aarch64-windows-gnu" GOOS=windows GOARCH=arm64 go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
|
||||
|
||||
darwin_arm64_gui: etc/icon.icns
|
||||
GOOS=darwin GOARCH=arm64 go build $(GO_FLAGS_GUI) -o out/$@/yarr ./cmd/yarr
|
||||
./etc/macos_package.sh $(VERSION) etc/icon.icns out/$@/yarr out/$@
|
||||
|
||||
darwin_amd64_gui: etc/icon.icns
|
||||
GOOS=darwin GOARCH=amd64 go build $(GO_FLAGS_GUI) -o out/$@/yarr ./cmd/yarr
|
||||
./etc/macos_package.sh $(VERSION) etc/icon.icns out/$@/yarr out/$@
|
||||
|
||||
windows_amd64_gui: src/platform/versioninfo.rc
|
||||
GOOS=windows GOARCH=amd64 go build $(GO_FLAGS_GUI_WIN) -o out/$@/yarr.exe ./cmd/yarr
|
||||
|
||||
windows_arm64_gui: src/platform/versioninfo.rc
|
||||
GOOS=windows GOARCH=arm64 go build $(GO_FLAGS_GUI_WIN) -o out/$@/yarr.exe ./cmd/yarr
|
||||
|
||||
YARR_DB ?= local.db
|
||||
GOOS=windows GOARCH=amd64 go build -tags "sqlite_foreign_keys windows" -ldflags="$(GO_LDFLAGS) -H windowsgui" -o _output/windows/yarr.exe ./cmd/yarr
|
||||
|
||||
serve:
|
||||
go run $(GO_FLAGS_DEBUG) ./cmd/yarr -db "$(YARR_DB)"
|
||||
go run -tags "sqlite_foreign_keys" ./cmd/yarr -db local.db
|
||||
|
||||
test:
|
||||
go test $(GO_FLAGS) ./...
|
||||
|
||||
.PHONY: \
|
||||
host \
|
||||
darwin_amd64 darwin_amd64_gui \
|
||||
darwin_arm64 darwin_arm64_gui \
|
||||
windows_amd64 windows_amd64_gui \
|
||||
windows_arm64 windows_arm64_gui \
|
||||
serve serve_postgres test
|
||||
go test -tags "sqlite_foreign_keys" ./...
|
||||
|
||||
22
readme.md
@@ -7,25 +7,23 @@ The app is a single binary with an embedded database (SQLite).
|
||||
|
||||

|
||||
|
||||
Subscribe: [releases](https://github.com/nkanaev/yarr/releases.atom) / [devlog](https://hachyderm.io/@nkanaev.rss) ([Mastodon](https://hachyderm.io/@nkanaev))
|
||||
|
||||
## usage
|
||||
|
||||
The latest prebuilt binaries for Linux/MacOS/Windows are available
|
||||
[here](https://github.com/nkanaev/yarr/releases/latest).
|
||||
The archives follow the naming convention `yarr_{OS}_{ARCH}[_gui].zip`, where:
|
||||
The latest prebuilt binaries for Linux/MacOS/Windows AMD64 are available
|
||||
[here](https://github.com/nkanaev/yarr/releases/latest). Installation instructions:
|
||||
|
||||
* `OS` is the target operating system
|
||||
* `ARCH` is the CPU architecture (`arm64` for AArch64, `amd64` for X86-64)
|
||||
* `-gui` indicates that the binary ships with the GUI (tray icon), and is a command line application if omitted
|
||||
* MacOS
|
||||
|
||||
Usage instructions:
|
||||
Download `yarr-*-macos64.zip`, unzip it, place `yarr.app` in `/Applications` folder, [open the app][macos-open], click the anchor menu bar icon, select "Open".
|
||||
|
||||
* MacOS: place `yarr.app` in `/Applications` folder, [open the app][macos-open], click the anchor menu bar icon, select "Open".
|
||||
* Windows
|
||||
|
||||
* Windows: open `yarr.exe`, click the anchor system tray icon, select "Open".
|
||||
Download `yarr-*-windows64.zip`, unzip it, open `yarr.exe`, click the anchor system tray icon, select "Open".
|
||||
|
||||
* Linux: place `yarr` in `$HOME/.local/bin` and run [the script](etc/install-linux.sh).
|
||||
* Linux
|
||||
|
||||
Download `yarr-*-linux64.zip`, unzip it, place `yarr` in `$HOME/.local/bin`
|
||||
and run [the script](etc/install-linux.sh).
|
||||
|
||||
[macos-open]: https://support.apple.com/en-gb/guide/mac-help/mh40616/mac
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
@@ -36,7 +37,7 @@ func Template(path string) *template.Template {
|
||||
}
|
||||
defer svgfile.Close()
|
||||
|
||||
content, err := io.ReadAll(svgfile)
|
||||
content, err := ioutil.ReadAll(svgfile)
|
||||
// should never happen
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -51,7 +52,7 @@ func Template(path string) *template.Template {
|
||||
return tmpl
|
||||
}
|
||||
|
||||
func Render(path string, writer io.Writer, data any) {
|
||||
func Render(path string, writer io.Writer, data interface{}) {
|
||||
tmpl := Template(path)
|
||||
tmpl.Execute(writer, data)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build !debug
|
||||
|
||||
package assets
|
||||
|
||||
import "embed"
|
||||
|
||||
@@ -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-chevron-up"><polyline points="18 15 12 9 6 15"></polyline></svg>
|
||||
|
Before Width: | Height: | Size: 268 B |
@@ -8,7 +8,6 @@
|
||||
<link rel="icon" href="./static/graphicarts/favicon.svg" type="image/svg+xml">
|
||||
<link rel="alternate icon" href="./static/graphicarts/favicon.png" type="image/png">
|
||||
<link rel="manifest" href="./manifest.json" />
|
||||
<meta name="theme-color" content="" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<script>
|
||||
window.app = window.app || {}
|
||||
@@ -24,52 +23,46 @@
|
||||
<div class="p-2 toolbar d-flex align-items-center">
|
||||
<div class="icon mx-2">{% inline "anchor.svg" %}</div>
|
||||
<div class="flex-grow-1"></div>
|
||||
<button class="toolbar-item ml-1"
|
||||
<button class="toolbar-item"
|
||||
:class="{active: filterSelected == 'unread'}"
|
||||
:aria-pressed="filterSelected == 'unread'"
|
||||
:title="$t('unread')"
|
||||
title="Unread"
|
||||
@click="filterSelected = 'unread'">
|
||||
<span class="icon">{% inline "circle-full.svg" %}</span>
|
||||
</button>
|
||||
<button class="toolbar-item mx-1"
|
||||
<button class="toolbar-item"
|
||||
:class="{active: filterSelected == 'starred'}"
|
||||
:aria-pressed="filterSelected == 'starred'"
|
||||
:title="$t('starred')"
|
||||
title="Starred"
|
||||
@click="filterSelected = 'starred'">
|
||||
<span class="icon">{% inline "star-full.svg" %}</span>
|
||||
</button>
|
||||
<button class="toolbar-item mr-1"
|
||||
<button class="toolbar-item"
|
||||
:class="{active: filterSelected == ''}"
|
||||
:aria-pressed="filterSelected == ''"
|
||||
:title="$t('all')"
|
||||
title="All"
|
||||
@click="filterSelected = ''">
|
||||
<span class="icon">{% inline "assorted.svg" %}</span>
|
||||
</button>
|
||||
<div class="flex-grow-1"></div>
|
||||
<dropdown class="settings-dropdown" toggle-class="btn btn-link toolbar-item px-2" ref="menuDropdown" drop="right" :title="$t('settings')">
|
||||
<dropdown class="settings-dropdown" toggle-class="btn btn-link toolbar-item px-2" ref="menuDropdown" drop="right" title="Settings">
|
||||
<template v-slot:button>
|
||||
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
||||
</template>
|
||||
|
||||
<button class="dropdown-item" @click="showSettings('create')">
|
||||
<span class="icon mr-1">{% inline "plus.svg" %}</span>
|
||||
{{ $t('new_feed') }}
|
||||
New Feed
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item" @click="fetchAllFeeds()">
|
||||
<span class="icon mr-1">{% inline "rotate-cw.svg" %}</span>
|
||||
{{ $t('refresh_feeds') }}
|
||||
Refresh Feeds
|
||||
</button>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('theme') }}</header>
|
||||
<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"
|
||||
:title="t"
|
||||
:aria-label="t"
|
||||
:aria-pressed="theme.name == t"
|
||||
@click.stop="theme.name = t"
|
||||
v-for="t in ['light', 'sepia', 'night']">
|
||||
<span class="icon" v-if="theme.name == t">{% inline "check.svg" %}</span>
|
||||
@@ -78,33 +71,25 @@
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('auto_refresh') }}</header>
|
||||
<header class="dropdown-header">Auto Refresh</header>
|
||||
<div class="row text-center m-0">
|
||||
<button class="dropdown-item col-4 px-0"
|
||||
@click.stop="changeRefreshRate(-1)"
|
||||
:disabled="!refreshRate">
|
||||
<span class="icon">
|
||||
{% inline "chevron-down.svg" %}
|
||||
</span>
|
||||
</button>
|
||||
<div class="col-4 d-flex align-items-center justify-content-center">{{ refreshRateTitle }}</div>
|
||||
<button class="dropdown-item col-4 px-0"
|
||||
@click.stop="changeRefreshRate(1)" :disabled="refreshRate === refreshRateOptions.at(-1).value">
|
||||
<span class="icon">
|
||||
{% inline "chevron-up.svg" %}
|
||||
</span>
|
||||
</button>
|
||||
<button class="dropdown-item col-4 px-0" :class="{active: !refreshRate}" @click.stop="refreshRate = 0">0</button>
|
||||
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 10}" @click.stop="refreshRate = 10">10m</button>
|
||||
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 30}" @click.stop="refreshRate = 30">30m</button>
|
||||
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 60}" @click.stop="refreshRate = 60">1h</button>
|
||||
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 120}" @click.stop="refreshRate = 120">2h</button>
|
||||
<button class="dropdown-item col-4 px-0" :class="{active: refreshRate == 240}" @click.stop="refreshRate = 240">4h</button>
|
||||
</div>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('show_first') }}</header>
|
||||
<header class="dropdown-header">Show first</header>
|
||||
<div class="d-flex text-center">
|
||||
<button class="dropdown-item px-0" :aria-pressed="itemSortNewestFirst" :class="{active: itemSortNewestFirst}" @click.stop="itemSortNewestFirst=true">{{ $t('new') }}</button>
|
||||
<button class="dropdown-item px-0" :aria-pressed="!itemSortNewestFirst" :class="{active: !itemSortNewestFirst}" @click.stop="itemSortNewestFirst=false">{{ $t('old') }}</button>
|
||||
<button class="dropdown-item px-0" :class="{active: itemSortNewestFirst}" @click.stop="itemSortNewestFirst=true">New</button>
|
||||
<button class="dropdown-item px-0" :class="{active: !itemSortNewestFirst}" @click.stop="itemSortNewestFirst=false">Old</button>
|
||||
</div>
|
||||
<div class="dropdown-divider"></div>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('subscriptions') }}</header>
|
||||
<header class="dropdown-header">Subscriptions</header>
|
||||
<form id="opml-import-form" enctype="multipart/form-data" tabindex="-1">
|
||||
<input type="file"
|
||||
id="opml-import"
|
||||
@@ -113,55 +98,42 @@
|
||||
style="opacity: 0; width: 1px; height: 0; position: absolute; z-index: -1;">
|
||||
<label class="dropdown-item mb-0 cursor-pointer" for="opml-import" @click.stop="">
|
||||
<span class="icon mr-1">{% inline "download.svg" %}</span>
|
||||
{{ $t('import') }}
|
||||
Import
|
||||
</label>
|
||||
</form>
|
||||
<a class="dropdown-item" href="./opml/export">
|
||||
<span class="icon mr-1">{% inline "upload.svg" %}</span>
|
||||
{{ $t('export') }}
|
||||
Export
|
||||
</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item" @click="showSettings('shortcuts')">
|
||||
<span class="icon mr-1">{% inline "help-circle.svg" %}</span>
|
||||
{{ $t('shortcuts') }}
|
||||
Shortcuts
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">A / あ / 文</header>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<button
|
||||
v-for="lang in languages"
|
||||
class="dropdown-item text-center col-3 px-0"
|
||||
:aria-label="lang.name"
|
||||
:title="lang.name"
|
||||
:class="{active: language==lang.code}"
|
||||
@click.stop="changeLanguage(lang.code)">
|
||||
{{ lang.code }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown-divider" v-if="authenticated"></div>
|
||||
<button class="dropdown-item" v-if="authenticated" @click="logout()">
|
||||
<span class="icon mr-1">{% inline "log-out.svg" %}</span>
|
||||
{{ $t('log_out') }}
|
||||
Log out
|
||||
</button>
|
||||
</dropdown>
|
||||
</div>
|
||||
<div id="feed-list-scroll" class="p-2 overflow-auto scroll-touch border-top flex-grow-1">
|
||||
<div id="feed-list-scroll" class="p-2 overflow-auto border-top flex-grow-1">
|
||||
<label class="selectgroup">
|
||||
<input type="radio" name="feed" value="" v-model="feedSelected">
|
||||
<div class="selectgroup-label d-flex align-items-center w-100">
|
||||
<span class="icon mr-2">{% inline "layers.svg" %}</span>
|
||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='unread'">{{ $t('all_unread') }}</span>
|
||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='starred'">{{ $t('all_starred') }}</span>
|
||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected==''">{{ $t('all_feeds') }}</span>
|
||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='unread'">All Unread</span>
|
||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='starred'">All Starred</span>
|
||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected==''">All Feeds</span>
|
||||
<span class="counter text-right">{{ filteredTotalStats }}</span>
|
||||
</div>
|
||||
</label>
|
||||
<div v-for="folder in foldersWithFeeds">
|
||||
<label class="selectgroup mt-1"
|
||||
:class="{'d-none': mustHideFolder(folder)}"
|
||||
v-if="folder.id">
|
||||
:class="{'d-none': filterSelected
|
||||
&& !(current.folder.id == folder.id || current.feed.folder_id == folder.id)
|
||||
&& !filteredFolderStats[folder.id]
|
||||
&& (!itemSelectedDetails || (feedsById[itemSelectedDetails.feed_id] || {}).folder_id != folder.id)}">
|
||||
<input type="radio" name="feed" :value="'folder:'+folder.id" v-model="feedSelected" v-if="folder.id">
|
||||
<div class="selectgroup-label d-flex align-items-center w-100" v-if="folder.id">
|
||||
<span class="icon mr-2"
|
||||
@@ -175,7 +147,10 @@
|
||||
</label>
|
||||
<div v-show="!folder.id || folder.is_expanded" class="mt-1" :class="{'pl-3': folder.id}">
|
||||
<label class="selectgroup"
|
||||
:class="{'d-none': mustHideFeed(feed)}"
|
||||
:class="{'d-none': filterSelected
|
||||
&& !(current.feed.id == feed.id)
|
||||
&& !filteredFeedStats[feed.id]
|
||||
&& (!itemSelectedDetails || itemSelectedDetails.feed_id != feed.id)}"
|
||||
v-for="feed in folder.feeds">
|
||||
<input type="radio" name="feed" :value="'feed:'+feed.id" v-model="feedSelected">
|
||||
<div class="selectgroup-label d-flex align-items-center w-100">
|
||||
@@ -195,7 +170,7 @@
|
||||
</div>
|
||||
<div class="p-2 toolbar d-flex align-items-center border-top flex-shrink-0" v-if="loading.feeds">
|
||||
<span class="icon loading mx-2"></span>
|
||||
<span class="text-truncate cursor-default noselect">{{ $t('refreshing_progress', {count: loading.feeds}) }}</span>
|
||||
<span class="text-truncate cursor-default noselect">Refreshing ({{ loading.feeds }} left)</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- item list -->
|
||||
@@ -204,7 +179,7 @@
|
||||
<div class="px-2 toolbar d-flex align-items-center">
|
||||
<button class="toolbar-item mr-2 d-block d-md-none"
|
||||
@click="feedSelected = null"
|
||||
:title="$t('show_feeds')">
|
||||
title="Show Feeds">
|
||||
<span class="icon">{% inline "chevron-left.svg" %}</span>
|
||||
</button>
|
||||
<div class="input-icon flex-grow-1">
|
||||
@@ -215,7 +190,7 @@
|
||||
<button class="toolbar-item ml-2"
|
||||
@click="markItemsRead()"
|
||||
v-if="filterSelected == 'unread'"
|
||||
:title="$t('mark_all_read')">
|
||||
title="Mark All Read">
|
||||
<span class="icon">{% inline "check.svg" %}</span>
|
||||
</button>
|
||||
|
||||
@@ -226,31 +201,27 @@
|
||||
<dropdown class="settings-dropdown"
|
||||
toggle-class="btn btn-link toolbar-item px-2 ml-2"
|
||||
drop="right"
|
||||
:title="$t('feed_settings')"
|
||||
title="Feed Settings"
|
||||
v-if="current.type == 'feed'">
|
||||
<template v-slot:button>
|
||||
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
||||
</template>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ current.feed.title }}</header>
|
||||
<a class="dropdown-item" :href="current.feed.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" v-if="current.feed.link">
|
||||
<header class="dropdown-header">{{ current.feed.title }}</header>
|
||||
<a class="dropdown-item" :href="current.feed.link" target="_blank" v-if="current.feed.link">
|
||||
<span class="icon mr-1">{% inline "globe.svg" %}</span>
|
||||
{{ $t('website') }}
|
||||
Website
|
||||
</a>
|
||||
<a class="dropdown-item" :href="current.feed.feed_link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" v-if="current.feed.feed_link">
|
||||
<a class="dropdown-item" :href="current.feed.feed_link" target="_blank" v-if="current.feed.feed_link">
|
||||
<span class="icon mr-1">{% inline "rss.svg" %}</span>
|
||||
{{ $t('feed_link') }}
|
||||
Feed Link
|
||||
</a>
|
||||
<div class="dropdown-divider" v-if="current.feed.link || current.feed.feed_link"></div>
|
||||
<button class="dropdown-item" @click="renameFeed(current.feed)">
|
||||
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
||||
{{ $t('rename') }}
|
||||
</button>
|
||||
<button class="dropdown-item" @click="updateFeedLink(current.feed)" v-if="current.feed.feed_link">
|
||||
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
||||
{{ $t('change_link') }}
|
||||
Rename
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('move_to') }}</header>
|
||||
<header class="dropdown-header">Move to...</header>
|
||||
<button class="dropdown-item"
|
||||
v-if="folder.id != current.feed.folder_id"
|
||||
v-for="folder in folders"
|
||||
@@ -264,40 +235,40 @@
|
||||
</button>
|
||||
<button class="dropdown-item text-muted" @click="moveFeedToNewFolder(current.feed)">
|
||||
<span class="icon mr-1">{% inline "folder-plus.svg" %}</span>
|
||||
{{ $t('new_folder') }}
|
||||
new folder
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item text-danger" @click.prevent="deleteFeed(current.feed)">
|
||||
<span class="icon mr-1">{% inline "trash.svg" %}</span>
|
||||
{{ $t('delete') }}
|
||||
Delete
|
||||
</button>
|
||||
</dropdown>
|
||||
<dropdown class="settings-dropdown"
|
||||
toggle-class="btn btn-link toolbar-item px-2 ml-2"
|
||||
:title="$t('folder_settings')"
|
||||
title="Folder Settings"
|
||||
drop="right"
|
||||
v-if="current.type == 'folder'">
|
||||
<template v-slot:button>
|
||||
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
||||
</template>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ current.folder.title }}</header>
|
||||
<header class="dropdown-header">{{ current.folder.title }}</header>
|
||||
<button class="dropdown-item" @click="renameFolder(current.folder)">
|
||||
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
||||
{{ $t('rename') }}
|
||||
Rename
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item text-danger" @click="deleteFolder(current.folder)">
|
||||
<span class="icon mr-1">{% inline "trash.svg" %}</span>
|
||||
{{ $t('delete') }}
|
||||
Delete
|
||||
</button>
|
||||
</dropdown>
|
||||
</div>
|
||||
<div id="item-list-scroll" class="p-2 overflow-auto scroll-touch border-top flex-grow-1" v-scroll="loadMoreItems" ref="itemlist">
|
||||
<div id="item-list-scroll" class="p-2 overflow-auto border-top flex-grow-1" v-scroll="loadMoreItems" ref="itemlist">
|
||||
<label v-for="item in items" :key="item.id"
|
||||
class="selectgroup">
|
||||
<input type="radio" name="item" :value="item.id" v-model="itemSelected">
|
||||
<div class="selectgroup-label d-flex flex-column">
|
||||
<div style="line-height: 100%; opacity: .7; margin-bottom: .1rem;" class="d-flex align-items-center">
|
||||
<div style="line-height: 1; opacity: .7; margin-bottom: .1rem;" class="d-flex align-items-center">
|
||||
<transition name="indicator">
|
||||
<span class="icon icon-small mr-1" v-if="item.status=='unread'">{% inline "circle-full.svg" %}</span>
|
||||
<span class="icon icon-small mr-1" v-if="item.status=='starred'">{% inline "star-full.svg" %}</span>
|
||||
@@ -307,7 +278,7 @@
|
||||
</small>
|
||||
<small class="flex-shrink-0"><relative-time v-bind:title="formatDate(item.date)" :val="item.date"/></small>
|
||||
</div>
|
||||
<div>{{ item.title || $t('untitled') }}</div>
|
||||
<div>{{ item.title || 'untitled' }}</div>
|
||||
</div>
|
||||
</label>
|
||||
<button class="btn btn-link btn-block loading my-3" v-if="itemsHasMore"></button>
|
||||
@@ -321,24 +292,24 @@
|
||||
<div class="toolbar px-2 d-flex align-items-center" v-if="itemSelectedDetails">
|
||||
<button class="toolbar-item"
|
||||
@click="toggleItemStarred(itemSelectedDetails)"
|
||||
:title="$t('mark_starred')">
|
||||
title="Mark Starred">
|
||||
<span class="icon" v-if="itemSelectedDetails.status=='starred'" >{% inline "star-full.svg" %}</span>
|
||||
<span class="icon" v-else-if="itemSelectedDetails.status!='starred'" >{% inline "star.svg" %}</span>
|
||||
</button>
|
||||
<button class="toolbar-item"
|
||||
:title="$t('mark_unread')"
|
||||
title="Mark Unread"
|
||||
@click="toggleItemRead(itemSelectedDetails)">
|
||||
<span class="icon" v-if="itemSelectedDetails.status=='unread'">{% inline "circle-full.svg" %}</span>
|
||||
<span class="icon" v-if="itemSelectedDetails.status!='unread'">{% inline "circle.svg" %}</span>
|
||||
</button>
|
||||
<dropdown class="settings-dropdown" toggle-class="toolbar-item px-2" drop="center" :title="$t('appearance')">
|
||||
<dropdown class="settings-dropdown" toggle-class="toolbar-item px-2" drop="center" title="Appearance">
|
||||
<template v-slot:button>
|
||||
<span class="icon">{% inline "sliders.svg" %}</span>
|
||||
</template>
|
||||
|
||||
<button class="dropdown-item" :class="{active: !theme.font}" @click.stop="theme.font = ''">{{ $t('sans_serif') }}</button>
|
||||
<button class="dropdown-item font-serif" :class="{active: theme.font == 'serif'}" @click.stop="theme.font = 'serif'">{{ $t('serif') }}</button>
|
||||
<button class="dropdown-item font-monospace" :class="{active: theme.font == 'monospace'}" @click.stop="theme.font = 'monospace'">{{ $t('monospace') }}</button>
|
||||
<button class="dropdown-item" :class="{active: !theme.font}" @click.stop="theme.font = ''">sans-serif</button>
|
||||
<button class="dropdown-item font-serif" :class="{active: theme.font == 'serif'}" @click.stop="theme.font = 'serif'">serif</button>
|
||||
<button class="dropdown-item font-monospace" :class="{active: theme.font == 'monospace'}" @click.stop="theme.font = 'monospace'">monospace</button>
|
||||
|
||||
<div class="d-flex text-center">
|
||||
<button class="dropdown-item" style="font-size: 0.8rem" @click.stop="incrFont(-1)">A</button>
|
||||
@@ -348,48 +319,32 @@
|
||||
<button class="toolbar-item"
|
||||
:class="{active: itemSelectedReadability}"
|
||||
@click="toggleReadability()"
|
||||
:title="$t('read_here')">
|
||||
title="Read Here">
|
||||
<span class="icon" :class="{'icon-loading': loading.readability}">{% inline "book-open.svg" %}</span>
|
||||
</button>
|
||||
<a class="toolbar-item" :href="itemSelectedDetails.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" :title="$t('open_link')">
|
||||
<a class="toolbar-item" :href="itemSelectedDetails.link" target="_blank" title="Open Link">
|
||||
<span class="icon">{% inline "external-link.svg" %}</span>
|
||||
</a>
|
||||
<div class="flex-grow-1"></div>
|
||||
<button class="toolbar-item" @click="navigateToItem(-1)" :title="$t('previous_article')" :disabled="!items.length || itemSelected == items[0].id">
|
||||
<span class="icon">{% inline "chevron-left.svg" %}</span>
|
||||
</button>
|
||||
<button class="toolbar-item" @click="navigateToItem(+1)" :title="$t('next_article')" :disabled="!items.length || itemSelected == items[items.length - 1].id">
|
||||
<span class="icon">{% inline "chevron-right.svg" %}</span>
|
||||
</button>
|
||||
<button class="toolbar-item" @click="itemSelected=null" :title="$t('close_article')">
|
||||
<button class="toolbar-item" @click="itemSelected=null" title="Close Article">
|
||||
<span class="icon">{% inline "x.svg" %}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="itemSelectedDetails"
|
||||
ref="content"
|
||||
class="content px-4 pt-3 pb-5 border-top overflow-auto scroll-touch"
|
||||
class="content px-4 pt-3 pb-5 border-top overflow-auto"
|
||||
:class="{'font-serif': theme.font == 'serif', 'font-monospace': theme.font == 'monospace'}"
|
||||
:style="{'font-size': theme.size + 'rem'}">
|
||||
<div class="content-wrapper">
|
||||
<h1><b>{{ itemSelectedDetails.title || $t('untitled') }}</b></h1>
|
||||
<h1><b>{{ itemSelectedDetails.title || 'untitled' }}</b></h1>
|
||||
<div class="text-muted">
|
||||
<div>
|
||||
<span class="cursor-pointer" @click="feedSelected = 'feed:'+(feedsById[itemSelectedDetails.feed_id] || {}).id">
|
||||
{{ (feedsById[itemSelectedDetails.feed_id] || {}).title }}
|
||||
</span>
|
||||
</div>
|
||||
<div>{{ (feedsById[itemSelectedDetails.feed_id] || {}).title }}</div>
|
||||
<time>{{ formatDate(itemSelectedDetails.date) }}</time>
|
||||
</div>
|
||||
<hr>
|
||||
<div v-if="!itemSelectedReadability">
|
||||
<div v-if="contentImages.length">
|
||||
<figure v-for="media in contentImages">
|
||||
<img :src="media.url" loading="lazy">
|
||||
<figcaption v-if="media.description">{{ media.description }}</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<audio class="w-100" controls v-for="media in contentAudios" :src="media.url"></audio>
|
||||
<video class="w-100" controls v-for="media in contentVideos" :src="media.url"></video>
|
||||
<img :src="itemSelectedDetails.image" v-if="itemSelectedDetails.image" class="mb-3">
|
||||
<audio class="w-100" controls v-if="itemSelectedDetails.podcast_url" :src="itemSelectedDetails.podcast_url"></audio>
|
||||
</div>
|
||||
<div v-html="itemSelectedContent"></div>
|
||||
</div>
|
||||
@@ -400,13 +355,13 @@
|
||||
<span class="icon">{% inline "x.svg" %}</span>
|
||||
</button>
|
||||
<div v-if="settings=='create'">
|
||||
<p class="cursor-default"><b>{{ $t('new_feed') }}</b></p>
|
||||
<p class="cursor-default"><b>New Feed</b></p>
|
||||
<form action="" @submit.prevent="createFeed(event)" class="mt-4">
|
||||
<label for="feed-url">{{ $t('url') }}</label>
|
||||
<label for="feed-url">URL</label>
|
||||
<input id="feed-url" name="url" type="url" class="form-control" required autocomplete="off" :readonly="feedNewChoice.length > 0" placeholder="https://example.com/feed" v-focus>
|
||||
<label for="feed-folder" class="mt-3 d-block">
|
||||
{{ $t('folder') }}
|
||||
<a href="#" class="float-right text-decoration-none" @click.prevent="createNewFeedFolder()">{{ $t('new_folder') }}</a>
|
||||
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>
|
||||
@@ -414,8 +369,8 @@
|
||||
</select>
|
||||
<div class="mt-4" v-if="feedNewChoice.length">
|
||||
<p class="mb-2">
|
||||
{{ $t('multiple_feeds_found') }}
|
||||
<a href="#" class="float-right text-decoration-none" @click.prevent="resetFeedChoice()">{{ $t('cancel') }}</a>
|
||||
Multiple feeds found. Choose one below:
|
||||
<a href="#" class="float-right text-decoration-none" @click.prevent="resetFeedChoice()">cancel</a>
|
||||
</p>
|
||||
<label class="selectgroup" v-for="choice in feedNewChoice">
|
||||
<input type="radio" name="feedToAdd" :value="choice.url" v-model="feedNewChoiceSelected">
|
||||
@@ -425,29 +380,28 @@
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<button class="btn btn-block btn-default mt-3" :class="{loading: loading.newfeed}" type="submit">{{ $t('add') }}</button>
|
||||
<button class="btn btn-block btn-default mt-3" :class="{loading: loading.newfeed}" type="submit">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
<div v-else-if="settings=='shortcuts'">
|
||||
<p class="cursor-default"><b>{{ $t('keyboard_shortcuts') }}</b></p>
|
||||
<p class="cursor-default"><b>Keyboard Shortcuts</b></p>
|
||||
|
||||
<table class="table table-borderless table-sm table-compact m-0">
|
||||
<tr><td><kbd>1</kbd> <kbd>2</kbd> <kbd>3</kbd></td>
|
||||
<td>{{ $t('kb_show_filters') }}</td></tr>
|
||||
<tr><td><kbd>/</kbd></td> <td>{{ $t('kb_focus_search') }}</td></tr>
|
||||
<td>show unread / starred / all feeds</td></tr>
|
||||
<tr><td><kbd>/</kbd></td> <td>focus the search bar</td></tr>
|
||||
|
||||
<tr><td colspan=2> </td></tr>
|
||||
<tr><td><kbd>j</kbd> <kbd>k</kbd></td> <td>{{ $t('kb_next_prev_article') }}</td></tr>
|
||||
<tr><td><kbd>l</kbd> <kbd>h</kbd></td> <td>{{ $t('kb_next_prev_feed') }}</td></tr>
|
||||
<tr><td><kbd>q</kbd></td> <td>{{ $t('kb_close_article') }}</td></tr>
|
||||
<tr><td><kbd>j</kbd> <kbd>k</kbd></td> <td>next / prev article</td></tr>
|
||||
<tr><td><kbd>l</kbd> <kbd>h</kbd></td> <td>next / prev feed</td></tr>
|
||||
|
||||
<tr><td colspan=2> </td></tr>
|
||||
<tr><td><kbd>R</kbd></td> <td>{{ $t('kb_mark_all_read') }}</td></tr>
|
||||
<tr><td><kbd>r</kbd></td> <td>{{ $t('kb_mark_read') }}</td></tr>
|
||||
<tr><td><kbd>s</kbd></td> <td>{{ $t('kb_mark_starred') }}</td></tr>
|
||||
<tr><td><kbd>o</kbd></td> <td>{{ $t('kb_open_link') }}</td></tr>
|
||||
<tr><td><kbd>i</kbd></td> <td>{{ $t('kb_read_here') }}</td> </tr>
|
||||
<tr><td><kbd>f</kbd> <kbd>b</kbd></td> <td>{{ $t('kb_scroll_content') }}</td>
|
||||
<tr><td><kbd>R</kbd></td> <td>mark all read</td></tr>
|
||||
<tr><td><kbd>r</kbd></td> <td>mark read / unread</td></tr>
|
||||
<tr><td><kbd>s</kbd></td> <td>mark starred / unstarred</td></tr>
|
||||
<tr><td><kbd>o</kbd></td> <td>open link</td></tr>
|
||||
<tr><td><kbd>i</kbd></td> <td>read here</td> </tr>
|
||||
<tr><td><kbd>f</kbd> <kbd>b</kbd></td> <td>scroll content forward / backward</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
@@ -455,9 +409,7 @@
|
||||
</div>
|
||||
<!-- external -->
|
||||
<script src="./static/javascripts/vue.min.js"></script>
|
||||
<script src="./static/javascripts/fluent.js"></script>
|
||||
<!-- internal -->
|
||||
<script src="./static/javascripts/i18n.js"></script>
|
||||
<script src="./static/javascripts/api.js"></script>
|
||||
<script src="./static/javascripts/app.js"></script>
|
||||
<script src="./static/javascripts/key.js"></script>
|
||||
|
||||
@@ -2,26 +2,6 @@
|
||||
|
||||
var TITLE = document.title
|
||||
|
||||
function scrollto(target, scroll) {
|
||||
var padding = 10
|
||||
var targetRect = target.getBoundingClientRect()
|
||||
var scrollRect = scroll.getBoundingClientRect()
|
||||
|
||||
// target
|
||||
var relativeOffset = targetRect.y - scrollRect.y
|
||||
var absoluteOffset = relativeOffset + scroll.scrollTop
|
||||
|
||||
if (padding <= relativeOffset && relativeOffset + targetRect.height <= scrollRect.height - padding) return
|
||||
|
||||
var newPos = scroll.scrollTop
|
||||
if (relativeOffset < padding) {
|
||||
newPos = absoluteOffset - padding
|
||||
} else {
|
||||
newPos = absoluteOffset - scrollRect.height + targetRect.height + padding
|
||||
}
|
||||
scroll.scrollTop = Math.round(newPos)
|
||||
}
|
||||
|
||||
var debounce = function(callback, wait) {
|
||||
var timeout
|
||||
return function() {
|
||||
@@ -202,8 +182,6 @@ Vue.component('relative-time', {
|
||||
},
|
||||
})
|
||||
|
||||
Vue.use(i18n)
|
||||
|
||||
var vm = new Vue({
|
||||
created: function() {
|
||||
this.refreshStats()
|
||||
@@ -213,8 +191,6 @@ var vm = new Vue({
|
||||
api.feeds.list_errors().then(function(errors) {
|
||||
vm.feed_errors = errors
|
||||
})
|
||||
this.updateMetaTheme(app.settings.theme_name)
|
||||
this.$setLang(app.settings.language)
|
||||
},
|
||||
data: function() {
|
||||
var s = app.settings
|
||||
@@ -253,37 +229,9 @@ var vm = new Vue({
|
||||
'font': s.theme_font,
|
||||
'size': s.theme_size,
|
||||
},
|
||||
'themeColors': {
|
||||
'night': '#0e0e0e',
|
||||
'sepia': '#f4f0e5',
|
||||
'light': '#fff',
|
||||
},
|
||||
'refreshRate': s.refresh_rate,
|
||||
'authenticated': app.authenticated,
|
||||
'feed_errors': {},
|
||||
|
||||
'refreshRateOptions': [
|
||||
{ title: "0", value: 0 },
|
||||
{ title: "10m", value: 10 },
|
||||
{ title: "30m", value: 30 },
|
||||
{ title: "1h", value: 60 },
|
||||
{ title: "2h", value: 120 },
|
||||
{ title: "4h", value: 240 },
|
||||
{ title: "12h", value: 720 },
|
||||
{ title: "24h", value: 1440 },
|
||||
],
|
||||
|
||||
'language': s.language,
|
||||
'languages': [
|
||||
{code: 'en', name: 'English' },
|
||||
{code: 'de', name: 'Deutsch'},
|
||||
{code: 'es', name: 'Español'},
|
||||
{code: 'fr', name: 'Français'},
|
||||
{code: 'ja', name: '日本語'},
|
||||
{code: 'pt', name: 'Português'},
|
||||
{code: 'ru', name: 'Русский'},
|
||||
{code: 'zh', name: '简体中文'},
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -330,28 +278,11 @@ var vm = new Vue({
|
||||
|
||||
return this.itemSelectedDetails.content || ''
|
||||
},
|
||||
contentImages: function() {
|
||||
if (!this.itemSelectedDetails) return []
|
||||
return (this.itemSelectedDetails.media_links || []).filter(l => l.type === 'image')
|
||||
},
|
||||
contentAudios: function() {
|
||||
if (!this.itemSelectedDetails) return []
|
||||
return (this.itemSelectedDetails.media_links || []).filter(l => l.type === 'audio')
|
||||
},
|
||||
contentVideos: function() {
|
||||
if (!this.itemSelectedDetails) return []
|
||||
return (this.itemSelectedDetails.media_links || []).filter(l => l.type === 'video')
|
||||
},
|
||||
refreshRateTitle: function () {
|
||||
const entry = this.refreshRateOptions.find(o => o.value === this.refreshRate)
|
||||
return entry ? entry.title : '0'
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'theme': {
|
||||
deep: true,
|
||||
handler: function(theme) {
|
||||
this.updateMetaTheme(theme.name)
|
||||
document.body.classList.value = 'theme-' + theme.name
|
||||
api.settings.update({
|
||||
theme_name: theme.name,
|
||||
@@ -376,18 +307,14 @@ var vm = new Vue({
|
||||
},
|
||||
'filterSelected': function(newVal, oldVal) {
|
||||
if (oldVal === undefined) return // do nothing, initial setup
|
||||
this.itemSelected = null
|
||||
this.items = []
|
||||
this.itemsHasMore = true
|
||||
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
|
||||
this.itemSelected = null
|
||||
this.items = []
|
||||
this.itemsHasMore = true
|
||||
api.settings.update({feed: newVal}).then(this.refreshItems.bind(this, false))
|
||||
this.itemSelected = null
|
||||
if (this.$refs.itemlist) this.$refs.itemlist.scrollTop = 0
|
||||
},
|
||||
'itemSelected': function(newVal, oldVal) {
|
||||
@@ -431,9 +358,6 @@ var vm = new Vue({
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
updateMetaTheme: function(theme) {
|
||||
document.querySelector("meta[name='theme-color']").content = this.themeColors[theme]
|
||||
},
|
||||
refreshStats: function(loopMode) {
|
||||
return api.status().then(function(data) {
|
||||
if (loopMode && !vm.itemSelected) vm.refreshItems()
|
||||
@@ -483,7 +407,7 @@ var vm = new Vue({
|
||||
vm.feeds = values[1]
|
||||
})
|
||||
},
|
||||
refreshItems: function(loadMore = false) {
|
||||
refreshItems: function(loadMore) {
|
||||
if (this.feedSelected === null) {
|
||||
vm.items = []
|
||||
vm.itemsHasMore = false
|
||||
@@ -496,7 +420,7 @@ var vm = new Vue({
|
||||
}
|
||||
|
||||
this.loading.items = true
|
||||
return api.items.list(query).then(function(data) {
|
||||
api.items.list(query).then(function(data) {
|
||||
if (loadMore) {
|
||||
vm.items = vm.items.concat(data.list)
|
||||
} else {
|
||||
@@ -519,17 +443,13 @@ var vm = new Vue({
|
||||
var scale = (parseFloat(getComputedStyle(document.documentElement).fontSize) || 16) / 16
|
||||
|
||||
var el = this.$refs.itemlist
|
||||
|
||||
if (el.scrollHeight === 0) return false // element is invisible (responsive design)
|
||||
|
||||
var closeToBottom = (el.scrollHeight - el.scrollTop - el.offsetHeight) < bottomSpace * scale
|
||||
return closeToBottom
|
||||
},
|
||||
loadMoreItems: function(event, el) {
|
||||
if (!this.itemsHasMore) return
|
||||
if (this.loading.items) return
|
||||
if (this.itemListCloseToBottom()) return this.refreshItems(true)
|
||||
if (this.itemSelected && this.itemSelected === this.items[this.items.length - 1].id) return this.refreshItems(true)
|
||||
if (this.itemListCloseToBottom()) this.refreshItems(true)
|
||||
},
|
||||
markItemsRead: function() {
|
||||
var query = this.getItemsQuery()
|
||||
@@ -560,7 +480,7 @@ var vm = new Vue({
|
||||
})
|
||||
},
|
||||
moveFeedToNewFolder: function(feed) {
|
||||
var title = prompt(this.$t('prompt_folder_name'))
|
||||
var title = prompt('Enter folder name:')
|
||||
if (!title) return
|
||||
api.folders.create({'title': title}).then(function(folder) {
|
||||
api.feeds.update(feed.id, {folder_id: folder.id}).then(function() {
|
||||
@@ -571,7 +491,7 @@ var vm = new Vue({
|
||||
})
|
||||
},
|
||||
createNewFeedFolder: function() {
|
||||
var title = prompt(this.$t('prompt_folder_name'))
|
||||
var title = prompt('Enter folder name:')
|
||||
if (!title) return
|
||||
api.folders.create({'title': title}).then(function(result) {
|
||||
vm.refreshFeeds().then(function() {
|
||||
@@ -584,7 +504,7 @@ var vm = new Vue({
|
||||
})
|
||||
},
|
||||
renameFolder: function(folder) {
|
||||
var newTitle = prompt(this.$t('prompt_new_title'), folder.title)
|
||||
var newTitle = prompt('Enter new title', folder.title)
|
||||
if (newTitle) {
|
||||
api.folders.update(folder.id, {title: newTitle}).then(function() {
|
||||
folder.title = newTitle
|
||||
@@ -595,7 +515,7 @@ var vm = new Vue({
|
||||
}
|
||||
},
|
||||
deleteFolder: function(folder) {
|
||||
if (confirm(this.$t('confirm_delete', {name: folder.title}))) {
|
||||
if (confirm('Are you sure you want to delete ' + folder.title + '?')) {
|
||||
api.folders.delete(folder.id).then(function() {
|
||||
vm.feedSelected = null
|
||||
vm.refreshStats()
|
||||
@@ -603,16 +523,8 @@ var vm = new Vue({
|
||||
})
|
||||
}
|
||||
},
|
||||
updateFeedLink: function(feed) {
|
||||
var newLink = prompt(this.$t('prompt_feed_link'), feed.feed_link)
|
||||
if (newLink) {
|
||||
api.feeds.update(feed.id, {feed_link: newLink}).then(function() {
|
||||
feed.feed_link = newLink
|
||||
})
|
||||
}
|
||||
},
|
||||
renameFeed: function(feed) {
|
||||
var newTitle = prompt(this.$t('prompt_new_title'), feed.title)
|
||||
var newTitle = prompt('Enter new title', feed.title)
|
||||
if (newTitle) {
|
||||
api.feeds.update(feed.id, {title: newTitle}).then(function() {
|
||||
feed.title = newTitle
|
||||
@@ -620,7 +532,7 @@ var vm = new Vue({
|
||||
}
|
||||
},
|
||||
deleteFeed: function(feed) {
|
||||
if (confirm(this.$t('confirm_delete', {name: feed.title}))) {
|
||||
if (confirm('Are you sure you want to delete ' + feed.title + '?')) {
|
||||
api.feeds.delete(feed.id).then(function() {
|
||||
vm.feedSelected = null
|
||||
vm.refreshStats()
|
||||
@@ -763,97 +675,6 @@ var vm = new Vue({
|
||||
this.filteredFolderStats = statsFolders
|
||||
this.filteredTotalStats = statsTotal
|
||||
},
|
||||
// navigation helper, navigate relative to selected item
|
||||
navigateToItem: function(relativePosition) {
|
||||
let vm = this
|
||||
if (vm.itemSelected == null) {
|
||||
// if no item is selected, select first
|
||||
if (vm.items.length !== 0) vm.itemSelected = vm.items[0].id
|
||||
return
|
||||
}
|
||||
|
||||
var itemPosition = vm.items.findIndex(function(x) { return x.id === vm.itemSelected })
|
||||
if (itemPosition === -1) {
|
||||
if (vm.items.length !== 0) vm.itemSelected = vm.items[0].id
|
||||
return
|
||||
}
|
||||
|
||||
var newPosition = itemPosition + relativePosition
|
||||
if (newPosition < 0 || newPosition >= vm.items.length) return
|
||||
|
||||
vm.itemSelected = vm.items[newPosition].id
|
||||
|
||||
vm.$nextTick(function() {
|
||||
var scroll = document.querySelector('#item-list-scroll')
|
||||
|
||||
var handle = scroll.querySelector('input[type=radio]:checked')
|
||||
var target = handle && handle.parentElement
|
||||
|
||||
if (target && scroll) scrollto(target, scroll)
|
||||
|
||||
vm.loadMoreItems()
|
||||
})
|
||||
},
|
||||
// navigation helper, navigate relative to selected feed
|
||||
navigateToFeed: function(relativePosition) {
|
||||
let vm = this
|
||||
const navigationList = this.foldersWithFeeds
|
||||
.filter(folder => !folder.id || !vm.mustHideFolder(folder))
|
||||
.map((folder) => {
|
||||
if (this.mustHideFolder(folder)) return []
|
||||
const folds = folder.id ? [`folder:${folder.id}`] : []
|
||||
const feeds = (folder.is_expanded || !folder.id)
|
||||
? (folder.feeds || []).filter(f => !vm.mustHideFeed(f)).map(f => `feed:${f.id}`)
|
||||
: []
|
||||
return folds.concat(feeds)
|
||||
})
|
||||
.flat()
|
||||
navigationList.unshift('')
|
||||
|
||||
var currentFeedPosition = navigationList.indexOf(vm.feedSelected)
|
||||
|
||||
if (currentFeedPosition == -1) {
|
||||
vm.feedSelected = ''
|
||||
return
|
||||
}
|
||||
|
||||
var newPosition = currentFeedPosition+relativePosition
|
||||
if (newPosition < 0 || newPosition >= navigationList.length) return
|
||||
|
||||
vm.feedSelected = navigationList[newPosition]
|
||||
|
||||
vm.$nextTick(function() {
|
||||
var scroll = document.querySelector('#feed-list-scroll')
|
||||
|
||||
var handle = scroll.querySelector('input[type=radio]:checked')
|
||||
var target = handle && handle.parentElement
|
||||
|
||||
if (target && scroll) scrollto(target, scroll)
|
||||
})
|
||||
},
|
||||
changeRefreshRate: function(offset) {
|
||||
const curIdx = this.refreshRateOptions.findIndex(o => o.value === this.refreshRate)
|
||||
if (curIdx <= 0 && offset < 0) return
|
||||
if (curIdx >= (this.refreshRateOptions.length - 1) && offset > 0) return
|
||||
this.refreshRate = this.refreshRateOptions[curIdx + offset].value
|
||||
},
|
||||
mustHideFolder: function (folder) {
|
||||
return this.filterSelected
|
||||
&& !(this.current.folder.id == folder.id || this.current.feed.folder_id == folder.id)
|
||||
&& !this.filteredFolderStats[folder.id]
|
||||
&& (!this.itemSelectedDetails || (this.feedsById[this.itemSelectedDetails.feed_id] || {}).folder_id != folder.id)
|
||||
},
|
||||
mustHideFeed: function (feed) {
|
||||
return this.filterSelected
|
||||
&& !(this.current.feed.id == feed.id)
|
||||
&& !this.filteredFeedStats[feed.id]
|
||||
&& (!this.itemSelectedDetails || this.itemSelectedDetails.feed_id != feed.id)
|
||||
},
|
||||
changeLanguage(lang) {
|
||||
this.$setLang(lang)
|
||||
this.language = lang
|
||||
api.settings.update({language: lang})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,716 +0,0 @@
|
||||
(function (exports) {
|
||||
const translations = {
|
||||
"unread": {
|
||||
"en": "Unread",
|
||||
"de": "Ungelesene",
|
||||
"fr": "Non lus",
|
||||
"es": "No leídos",
|
||||
"ja": "未読",
|
||||
"pt": "Não lidos",
|
||||
"zh": "未读",
|
||||
"ru": "Непрочитанные"
|
||||
},
|
||||
"starred": {
|
||||
"en": "Starred",
|
||||
"de": "Markierte",
|
||||
"fr": "Favoris",
|
||||
"es": "Destacados",
|
||||
"ja": "スター付き",
|
||||
"pt": "Favoritos",
|
||||
"zh": "星标",
|
||||
"ru": "Избранные"
|
||||
},
|
||||
"all": {
|
||||
"en": "All",
|
||||
"de": "Alle",
|
||||
"fr": "Tout",
|
||||
"es": "Todo",
|
||||
"ja": "すべて",
|
||||
"pt": "Tudo",
|
||||
"zh": "全部",
|
||||
"ru": "Все"
|
||||
},
|
||||
"settings": {
|
||||
"en": "Settings",
|
||||
"de": "Einstellungen",
|
||||
"fr": "Paramètres",
|
||||
"es": "Ajustes",
|
||||
"ja": "設定",
|
||||
"pt": "Configurações",
|
||||
"zh": "设置",
|
||||
"ru": "Настройки"
|
||||
},
|
||||
"new_feed": {
|
||||
"en": "New Feed",
|
||||
"de": "Neuer Feed",
|
||||
"fr": "Nouveau flux",
|
||||
"es": "Nueva fuente",
|
||||
"ja": "新規フィード",
|
||||
"pt": "Novo feed",
|
||||
"zh": "新建订阅",
|
||||
"ru": "Новая лента"
|
||||
},
|
||||
"refresh_feeds": {
|
||||
"en": "Refresh Feeds",
|
||||
"de": "Feeds aktualisieren",
|
||||
"fr": "Actualiser les flux",
|
||||
"es": "Actualizar fuentes",
|
||||
"ja": "フィードを更新",
|
||||
"pt": "Atualizar feeds",
|
||||
"zh": "刷新订阅",
|
||||
"ru": "Обновить ленты"
|
||||
},
|
||||
"theme": {
|
||||
"en": "Theme",
|
||||
"de": "Design",
|
||||
"fr": "Thème",
|
||||
"es": "Tema",
|
||||
"ja": "テーマ",
|
||||
"pt": "Tema",
|
||||
"zh": "主题",
|
||||
"ru": "Тема"
|
||||
},
|
||||
"auto_refresh": {
|
||||
"en": "Auto Refresh",
|
||||
"de": "Automatisch aktualisieren",
|
||||
"fr": "Actualisation automatique",
|
||||
"es": "Actualización automática",
|
||||
"ja": "自動更新",
|
||||
"pt": "Atualização automática",
|
||||
"zh": "自动刷新",
|
||||
"ru": "Автообновление"
|
||||
},
|
||||
"show_first": {
|
||||
"en": "Show first",
|
||||
"de": "Zuerst anzeigen",
|
||||
"fr": "Afficher d'abord",
|
||||
"es": "Mostrar primero",
|
||||
"ja": "表示順",
|
||||
"pt": "Mostrar primeiro",
|
||||
"zh": "优先显示",
|
||||
"ru": "Сначала"
|
||||
},
|
||||
"new": {
|
||||
"en": "New",
|
||||
"de": "Neue",
|
||||
"fr": "Récents",
|
||||
"es": "Nuevos",
|
||||
"ja": "新しい順",
|
||||
"pt": "Novos",
|
||||
"zh": "最新",
|
||||
"ru": "Новые"
|
||||
},
|
||||
"old": {
|
||||
"en": "Old",
|
||||
"de": "Alte",
|
||||
"fr": "Anciens",
|
||||
"es": "Antiguos",
|
||||
"ja": "古い順",
|
||||
"pt": "Antigos",
|
||||
"zh": "最旧",
|
||||
"ru": "Старые"
|
||||
},
|
||||
"subscriptions": {
|
||||
"en": "Subscriptions",
|
||||
"de": "Abonnements",
|
||||
"fr": "Abonnements",
|
||||
"es": "Suscripciones",
|
||||
"ja": "購読管理",
|
||||
"pt": "Assinaturas",
|
||||
"zh": "订阅管理",
|
||||
"ru": "Подписки"
|
||||
},
|
||||
"import": {
|
||||
"en": "Import",
|
||||
"de": "Importieren",
|
||||
"fr": "Importer",
|
||||
"es": "Importar",
|
||||
"ja": "インポート",
|
||||
"pt": "Importar",
|
||||
"zh": "导入",
|
||||
"ru": "Импорт"
|
||||
},
|
||||
"export": {
|
||||
"en": "Export",
|
||||
"de": "Exportieren",
|
||||
"fr": "Exporter",
|
||||
"es": "Exportar",
|
||||
"ja": "エクスポート",
|
||||
"pt": "Exportar",
|
||||
"zh": "导出",
|
||||
"ru": "Экспорт"
|
||||
},
|
||||
"shortcuts": {
|
||||
"en": "Shortcuts",
|
||||
"de": "Tastenkürzel",
|
||||
"fr": "Raccourcis",
|
||||
"es": "Atajos",
|
||||
"ja": "ショートカット",
|
||||
"pt": "Atalhos",
|
||||
"zh": "快捷键",
|
||||
"ru": "Горячие клавиши"
|
||||
},
|
||||
"log_out": {
|
||||
"en": "Log out",
|
||||
"de": "Abmelden",
|
||||
"fr": "Déconnexion",
|
||||
"es": "Cerrar sesión",
|
||||
"ja": "ログアウト",
|
||||
"pt": "Sair",
|
||||
"zh": "登出",
|
||||
"ru": "Выйти"
|
||||
},
|
||||
"all_unread": {
|
||||
"en": "All Unread",
|
||||
"de": "Alle ungelesenen",
|
||||
"fr": "Tous les non lus",
|
||||
"es": "Todos los no leídos",
|
||||
"ja": "すべての未読",
|
||||
"pt": "Todos os não lidos",
|
||||
"zh": "全部未读",
|
||||
"ru": "Все непрочитанные"
|
||||
},
|
||||
"all_starred": {
|
||||
"en": "All Starred",
|
||||
"de": "Alle markierten",
|
||||
"fr": "Tous les favoris",
|
||||
"es": "Todos los destacados",
|
||||
"ja": "すべてのスター付き",
|
||||
"pt": "Todos os favoritos",
|
||||
"zh": "全部星标",
|
||||
"ru": "Все избранные"
|
||||
},
|
||||
"all_feeds": {
|
||||
"en": "All Feeds",
|
||||
"de": "Alle Feeds",
|
||||
"fr": "Tous les flux",
|
||||
"es": "Todas las fuentes",
|
||||
"ja": "すべてのフィード",
|
||||
"pt": "Todos os feeds",
|
||||
"zh": "全部订阅",
|
||||
"ru": "Все ленты"
|
||||
},
|
||||
"refreshing_progress": {
|
||||
"en": "Refreshing ({ $count } left)",
|
||||
"de": "Aktualisiere ({ $count } übrig)",
|
||||
"fr": "Actualisation ({ $count } restantes)",
|
||||
"es": "Actualizando ({ $count } restantes)",
|
||||
"ja": "更新中(残り{ $count })",
|
||||
"pt": "Atualizando ({ $count } restantes)",
|
||||
"zh": "正在刷新(剩余{ $count })",
|
||||
"ru": "Обновление: осталось { $count }"
|
||||
},
|
||||
"show_feeds": {
|
||||
"en": "Show Feeds",
|
||||
"de": "Feeds anzeigen",
|
||||
"fr": "Afficher les flux",
|
||||
"es": "Mostrar fuentes",
|
||||
"ja": "フィードを表示",
|
||||
"pt": "Mostrar feeds",
|
||||
"zh": "显示订阅",
|
||||
"ru": "Показать ленты"
|
||||
},
|
||||
"mark_all_read": {
|
||||
"en": "Mark All Read",
|
||||
"de": "Alle als gelesen markieren",
|
||||
"fr": "Tout marquer comme lu",
|
||||
"es": "Marcar todo como leído",
|
||||
"ja": "すべて既読にする",
|
||||
"pt": "Marcar todos como lidos",
|
||||
"zh": "全部标记为已读",
|
||||
"ru": "Отметить все как прочитанные"
|
||||
},
|
||||
"feed_settings": {
|
||||
"en": "Feed Settings",
|
||||
"de": "Feed-Einstellungen",
|
||||
"fr": "Paramètres du flux",
|
||||
"es": "Ajustes de fuente",
|
||||
"ja": "フィード設定",
|
||||
"pt": "Configurações do feed",
|
||||
"zh": "订阅设置",
|
||||
"ru": "Настройки ленты"
|
||||
},
|
||||
"folder_settings": {
|
||||
"en": "Folder Settings",
|
||||
"de": "Ordner-Einstellungen",
|
||||
"fr": "Paramètres du dossier",
|
||||
"es": "Ajustes de carpeta",
|
||||
"ja": "フォルダ設定",
|
||||
"pt": "Configurações da pasta",
|
||||
"zh": "文件夹设置",
|
||||
"ru": "Настройки папки"
|
||||
},
|
||||
"website": {
|
||||
"en": "Website",
|
||||
"de": "Webseite",
|
||||
"fr": "Site web",
|
||||
"es": "Sitio web",
|
||||
"ja": "ウェブサイト",
|
||||
"pt": "Site",
|
||||
"zh": "网站",
|
||||
"ru": "Сайт"
|
||||
},
|
||||
"feed_link": {
|
||||
"en": "Feed Link",
|
||||
"de": "Feed-Link",
|
||||
"fr": "Lien du flux",
|
||||
"es": "Enlace de la fuente",
|
||||
"ja": "フィードリンク",
|
||||
"pt": "Link do feed",
|
||||
"zh": "订阅链接",
|
||||
"ru": "Ссылка на ленту"
|
||||
},
|
||||
"rename": {
|
||||
"en": "Rename",
|
||||
"de": "Umbenennen",
|
||||
"fr": "Renommer",
|
||||
"es": "Renombrar",
|
||||
"ja": "名前変更",
|
||||
"pt": "Renomear",
|
||||
"zh": "重命名",
|
||||
"ru": "Переименовать"
|
||||
},
|
||||
"change_link": {
|
||||
"en": "Change Link",
|
||||
"de": "Link ändern",
|
||||
"fr": "Changer le lien",
|
||||
"es": "Cambiar enlace",
|
||||
"ja": "リンク変更",
|
||||
"pt": "Alterar link",
|
||||
"zh": "修改链接",
|
||||
"ru": "Изменить ссылку"
|
||||
},
|
||||
"move_to": {
|
||||
"en": "Move to...",
|
||||
"de": "Verschieben nach...",
|
||||
"fr": "Déplacer vers...",
|
||||
"es": "Mover a...",
|
||||
"ja": "移動...",
|
||||
"pt": "Mover para...",
|
||||
"zh": "移动到...",
|
||||
"ru": "Переместить в..."
|
||||
},
|
||||
"new_folder": {
|
||||
"en": "new folder",
|
||||
"de": "neuer Ordner",
|
||||
"fr": "nouveau dossier",
|
||||
"es": "nueva carpeta",
|
||||
"ja": "新規フォルダ",
|
||||
"pt": "nova pasta",
|
||||
"zh": "新建文件夹",
|
||||
"ru": "новая папка"
|
||||
},
|
||||
"delete": {
|
||||
"en": "Delete",
|
||||
"de": "Löschen",
|
||||
"fr": "Supprimer",
|
||||
"es": "Eliminar",
|
||||
"ja": "削除",
|
||||
"pt": "Excluir",
|
||||
"zh": "删除",
|
||||
"ru": "Удалить"
|
||||
},
|
||||
"mark_starred": {
|
||||
"en": "Mark Starred",
|
||||
"de": "Als markiert kennzeichnen",
|
||||
"fr": "Marquer comme favori",
|
||||
"es": "Marcar como destacado",
|
||||
"ja": "スターを付ける",
|
||||
"pt": "Marcar como favorito",
|
||||
"zh": "标记星标",
|
||||
"ru": "Пометить избранным"
|
||||
},
|
||||
"mark_unread": {
|
||||
"en": "Mark Unread",
|
||||
"de": "Als ungelesen kennzeichnen",
|
||||
"fr": "Marquer comme non lu",
|
||||
"es": "Marcar como no leído",
|
||||
"ja": "未読にする",
|
||||
"pt": "Marcar como não lido",
|
||||
"zh": "标记未读",
|
||||
"ru": "Пометить непрочитанным"
|
||||
},
|
||||
"appearance": {
|
||||
"en": "Appearance",
|
||||
"de": "Darstellung",
|
||||
"fr": "Apparence",
|
||||
"es": "Apariencia",
|
||||
"ja": "表示設定",
|
||||
"pt": "Aparência",
|
||||
"zh": "外观",
|
||||
"ru": "Внешний вид"
|
||||
},
|
||||
"read_here": {
|
||||
"en": "Read Here",
|
||||
"de": "Hier lesen",
|
||||
"fr": "Lire ici",
|
||||
"es": "Leer aquí",
|
||||
"ja": "ここで読む",
|
||||
"pt": "Ler aqui",
|
||||
"zh": "在此阅读",
|
||||
"ru": "Читать здесь"
|
||||
},
|
||||
"open_link": {
|
||||
"en": "Open Link",
|
||||
"de": "Link öffnen",
|
||||
"fr": "Ouvrir le lien",
|
||||
"es": "Abrir enlace",
|
||||
"ja": "リンクを開く",
|
||||
"pt": "Abrir link",
|
||||
"zh": "打开链接",
|
||||
"ru": "Открыть ссылку"
|
||||
},
|
||||
"previous_article": {
|
||||
"en": "Previous Article",
|
||||
"de": "Vorheriger Artikel",
|
||||
"fr": "Article précédent",
|
||||
"es": "Artículo anterior",
|
||||
"ja": "前の記事",
|
||||
"pt": "Artigo anterior",
|
||||
"zh": "上一篇",
|
||||
"ru": "Предыдущая статья"
|
||||
},
|
||||
"next_article": {
|
||||
"en": "Next Article",
|
||||
"de": "Nächster Artikel",
|
||||
"fr": "Article suivant",
|
||||
"es": "Artículo siguiente",
|
||||
"ja": "次の記事",
|
||||
"pt": "Próximo artigo",
|
||||
"zh": "下一篇",
|
||||
"ru": "Следующая статья"
|
||||
},
|
||||
"close_article": {
|
||||
"en": "Close Article",
|
||||
"de": "Artikel schließen",
|
||||
"fr": "Fermer l'article",
|
||||
"es": "Cerrar artículo",
|
||||
"ja": "記事を閉じる",
|
||||
"pt": "Fechar artigo",
|
||||
"zh": "关闭文章",
|
||||
"ru": "Закрыть статью"
|
||||
},
|
||||
"untitled": {
|
||||
"en": "untitled",
|
||||
"de": "unbenannt",
|
||||
"fr": "sans titre",
|
||||
"es": "sin título",
|
||||
"ja": "無題",
|
||||
"pt": "sem título",
|
||||
"zh": "无标题",
|
||||
"ru": "без названия"
|
||||
},
|
||||
"sans_serif": {
|
||||
"en": "sans-serif",
|
||||
"de": "serifenlos",
|
||||
"fr": "sans empattement",
|
||||
"es": "sans-serif",
|
||||
"ja": "ゴシック体",
|
||||
"pt": "sem serifa",
|
||||
"zh": "无衬线",
|
||||
"ru": "sans-serif"
|
||||
},
|
||||
"serif": {
|
||||
"en": "serif",
|
||||
"de": "Serife",
|
||||
"fr": "empattement",
|
||||
"es": "serifa",
|
||||
"ja": "明朝体",
|
||||
"pt": "com serifa",
|
||||
"zh": "衬线",
|
||||
"ru": "serif"
|
||||
},
|
||||
"monospace": {
|
||||
"en": "monospace",
|
||||
"de": "monospace",
|
||||
"fr": "monospace",
|
||||
"es": "monoespacio",
|
||||
"ja": "等幅",
|
||||
"pt": "monoespaçada",
|
||||
"zh": "等宽",
|
||||
"ru": "monospace"
|
||||
},
|
||||
"url": {
|
||||
"en": "URL",
|
||||
"de": "URL",
|
||||
"fr": "URL",
|
||||
"es": "URL",
|
||||
"ja": "URL",
|
||||
"pt": "URL",
|
||||
"zh": "网址",
|
||||
"ru": "URL"
|
||||
},
|
||||
"folder": {
|
||||
"en": "Folder",
|
||||
"de": "Ordner",
|
||||
"fr": "Dossier",
|
||||
"es": "Carpeta",
|
||||
"ja": "フォルダ",
|
||||
"pt": "Pasta",
|
||||
"zh": "文件夹",
|
||||
"ru": "Папка"
|
||||
},
|
||||
"add": {
|
||||
"en": "Add",
|
||||
"de": "Hinzufügen",
|
||||
"fr": "Ajouter",
|
||||
"es": "Añadir",
|
||||
"ja": "追加",
|
||||
"pt": "Adicionar",
|
||||
"zh": "添加",
|
||||
"ru": "Добавить"
|
||||
},
|
||||
"keyboard_shortcuts": {
|
||||
"en": "Keyboard Shortcuts",
|
||||
"de": "Tastenkürzel",
|
||||
"fr": "Raccourcis clavier",
|
||||
"es": "Atajos de teclado",
|
||||
"ja": "キーボードショートカット",
|
||||
"pt": "Atalhos do teclado",
|
||||
"zh": "键盘快捷键",
|
||||
"ru": "Горячие клавиши"
|
||||
},
|
||||
"multiple_feeds_found": {
|
||||
"en": "Multiple feeds found. Choose one below:",
|
||||
"de": "Mehrere Feeds gefunden. Bitte wählen Sie einen aus:",
|
||||
"fr": "Plusieurs flux trouvés. Choisissez-en un ci-dessous :",
|
||||
"es": "Múltiples fuentes encontradas. Elija una:",
|
||||
"ja": "複数のフィードが見つかりました。以下から選択してください:",
|
||||
"pt": "Múltiplos feeds encontrados. Escolha um abaixo:",
|
||||
"zh": "找到多个订阅源,请选择一个:",
|
||||
"ru": "Найдено несколько лент. Выберите одну:"
|
||||
},
|
||||
"cancel": {
|
||||
"en": "cancel",
|
||||
"de": "abbrechen",
|
||||
"fr": "annuler",
|
||||
"es": "cancelar",
|
||||
"ja": "キャンセル",
|
||||
"pt": "cancelar",
|
||||
"zh": "取消",
|
||||
"ru": "отмена"
|
||||
},
|
||||
"kb_show_filters": {
|
||||
"en": "show unread / starred / all feeds",
|
||||
"de": "ungelesene / markierte / alle Feeds anzeigen",
|
||||
"fr": "afficher les flux non lus / favoris / tous",
|
||||
"es": "mostrar fuentes no leídas / destacadas / todas",
|
||||
"ja": "未読/スター付き/すべてのフィードを表示",
|
||||
"pt": "mostrar feeds não lidos / favoritos / todos",
|
||||
"zh": "显示未读/星标/全部订阅",
|
||||
"ru": "показать непрочитанные / избранные / все ленты"
|
||||
},
|
||||
"kb_focus_search": {
|
||||
"en": "focus the search bar",
|
||||
"de": "Suchleiste fokussieren",
|
||||
"fr": "focus sur la barre de recherche",
|
||||
"es": "enfocar la barra de búsqueda",
|
||||
"ja": "検索バーにフォーカス",
|
||||
"pt": "focar na barra de pesquisa",
|
||||
"zh": "聚焦搜索栏",
|
||||
"ru": "фокус на строку поиска"
|
||||
},
|
||||
"kb_next_prev_article": {
|
||||
"en": "next / prev article",
|
||||
"de": "nächster / vorheriger Artikel",
|
||||
"fr": "article suivant / précédent",
|
||||
"es": "artículo siguiente / anterior",
|
||||
"ja": "次の/前の記事",
|
||||
"pt": "próximo / artigo anterior",
|
||||
"zh": "下一篇/上一篇文章",
|
||||
"ru": "следующая / предыдущая статья"
|
||||
},
|
||||
"kb_next_prev_feed": {
|
||||
"en": "next / prev feed",
|
||||
"de": "nächster / vorheriger Feed",
|
||||
"fr": "flux suivant / précédent",
|
||||
"es": "fuente siguiente / anterior",
|
||||
"ja": "次の/前のフィード",
|
||||
"pt": "próximo / feed anterior",
|
||||
"zh": "下一个/上一个订阅",
|
||||
"ru": "следующая / предыдущая лента"
|
||||
},
|
||||
"kb_close_article": {
|
||||
"en": "close article",
|
||||
"de": "Artikel schließen",
|
||||
"fr": "fermer l'article",
|
||||
"es": "cerrar artículo",
|
||||
"ja": "記事を閉じる",
|
||||
"pt": "fechar artigo",
|
||||
"zh": "关闭文章",
|
||||
"ru": "закрыть статью"
|
||||
},
|
||||
"kb_mark_all_read": {
|
||||
"en": "mark all read",
|
||||
"de": "alle als gelesen markieren",
|
||||
"fr": "tout marquer comme lu",
|
||||
"es": "marcar todo como leído",
|
||||
"ja": "すべて既読にする",
|
||||
"pt": "marcar todos como lidos",
|
||||
"zh": "全部标记为已读",
|
||||
"ru": "отметить все как прочитанные"
|
||||
},
|
||||
"kb_mark_read": {
|
||||
"en": "mark read / unread",
|
||||
"de": "als gelesen / ungelesen markieren",
|
||||
"fr": "marquer comme lu / non lu",
|
||||
"es": "marcar como leído / no leído",
|
||||
"ja": "既読/未読を切り替え",
|
||||
"pt": "marcar como lido / não lido",
|
||||
"zh": "标记已读/未读",
|
||||
"ru": "отметить как прочитанное / непрочитанное"
|
||||
},
|
||||
"kb_mark_starred": {
|
||||
"en": "mark starred / unstarred",
|
||||
"de": "als markiert / nicht markiert kennzeichnen",
|
||||
"fr": "marquer comme favori / non favori",
|
||||
"es": "marcar como destacado / no destacado",
|
||||
"ja": "スターを付ける/外す",
|
||||
"pt": "marcar como favorito / não favorito",
|
||||
"zh": "标记星标/取消星标",
|
||||
"ru": "пометить избранным / убрать из избранного"
|
||||
},
|
||||
"kb_open_link": {
|
||||
"en": "open link",
|
||||
"de": "Link öffnen",
|
||||
"fr": "ouvrir le lien",
|
||||
"es": "abrir enlace",
|
||||
"ja": "リンクを開く",
|
||||
"pt": "abrir link",
|
||||
"zh": "打开链接",
|
||||
"ru": "открыть ссылку"
|
||||
},
|
||||
"kb_read_here": {
|
||||
"en": "read here",
|
||||
"de": "hier lesen",
|
||||
"fr": "lire ici",
|
||||
"es": "leer aquí",
|
||||
"ja": "ここで読む",
|
||||
"pt": "ler aqui",
|
||||
"zh": "在此阅读",
|
||||
"ru": "читать здесь"
|
||||
},
|
||||
"kb_scroll_content": {
|
||||
"en": "scroll content forward / backward",
|
||||
"de": "Inhalt vorwärts / rückwärts scrollen",
|
||||
"fr": "faire défiler le contenu avant / arrière",
|
||||
"es": "desplazar contenido hacia adelante / atrás",
|
||||
"ja": "コンテンツを前/後にスクロール",
|
||||
"pt": "rolar conteúdo para frente / trás",
|
||||
"zh": "向前/向后滚动内容",
|
||||
"ru": "прокрутка вперед / назад"
|
||||
},
|
||||
"prompt_folder_name": {
|
||||
"en": "Enter folder name:",
|
||||
"de": "Ordnernamen eingeben:",
|
||||
"fr": "Entrez le nom du dossier :",
|
||||
"es": "Introduzca el nombre de la carpeta:",
|
||||
"ja": "フォルダ名を入力してください:",
|
||||
"pt": "Digite o nome da pasta:",
|
||||
"zh": "请输入文件夹名称:",
|
||||
"ru": "Введите имя папки:"
|
||||
},
|
||||
"prompt_new_title": {
|
||||
"en": "Enter new title",
|
||||
"de": "Neuen Titel eingeben",
|
||||
"fr": "Entrez un nouveau titre",
|
||||
"es": "Introduzca un nuevo título",
|
||||
"ja": "新しいタイトルを入力してください",
|
||||
"pt": "Digite o novo título",
|
||||
"zh": "请输入新标题",
|
||||
"ru": "Введите новый заголовок"
|
||||
},
|
||||
"prompt_feed_link": {
|
||||
"en": "Enter feed link",
|
||||
"de": "Feed-Link eingeben",
|
||||
"fr": "Entrez le lien du flux",
|
||||
"es": "Introduzca el enlace de la fuente",
|
||||
"ja": "フィードリンクを入力してください",
|
||||
"pt": "Digite o link do feed",
|
||||
"zh": "请输入订阅链接",
|
||||
"ru": "Введите ссылку на ленту"
|
||||
},
|
||||
"confirm_delete": {
|
||||
"en": "Are you sure you want to delete { $name }?",
|
||||
"de": "Möchten Sie { $name } wirklich löschen?",
|
||||
"fr": "Voulez-vous vraiment supprimer { $name } ?",
|
||||
"es": "¿Está seguro de que quiere eliminar { $name }?",
|
||||
"ja": "{ $name }を削除してもよろしいですか?",
|
||||
"pt": "Tem certeza que deseja excluir { $name }?",
|
||||
"zh": "确定要删除{ $name }?",
|
||||
"ru": "Вы уверены, что хотите удалить { $name }?"
|
||||
},
|
||||
"alert_no_feeds": {
|
||||
"en": "No feeds found at the given url.",
|
||||
"de": "Keine Feeds unter der angegebenen URL gefunden.",
|
||||
"fr": "Aucun flux trouvé à cette URL.",
|
||||
"es": "No se encontraron fuentes en la URL proporcionada.",
|
||||
"ja": "指定されたURLにフィードが見つかりませんでした。",
|
||||
"pt": "Nenhum feed encontrado no URL fornecido.",
|
||||
"zh": "在指定的网址未找到订阅源。",
|
||||
"ru": "Лент по данному адресу не найдено."
|
||||
},
|
||||
"login": {
|
||||
"en": "Login",
|
||||
"de": "Anmelden",
|
||||
"fr": "Connexion",
|
||||
"es": "Iniciar sesión",
|
||||
"ja": "ログイン",
|
||||
"pt": "Entrar",
|
||||
"zh": "登录",
|
||||
"ru": "Вход"
|
||||
},
|
||||
"login_error": {
|
||||
"en": "Invalid username or password",
|
||||
"de": "Ungültiger Benutzername oder Passwort",
|
||||
"fr": "Nom d'utilisateur ou mot de passe invalide",
|
||||
"es": "Nombre de usuario o contraseña inválidos",
|
||||
"ja": "ユーザー名またはパスワードが無効です",
|
||||
"pt": "Nome de usuário ou senha inválidos",
|
||||
"zh": "用户名或密码错误",
|
||||
"ru": "Неверное имя пользователя или пароль"
|
||||
},
|
||||
"username": {
|
||||
"en": "Username",
|
||||
"de": "Benutzername",
|
||||
"fr": "Nom d'utilisateur",
|
||||
"es": "Nombre de usuario",
|
||||
"ja": "ユーザー名",
|
||||
"pt": "Nome de usuário",
|
||||
"zh": "用户名",
|
||||
"ru": "Имя пользователя"
|
||||
},
|
||||
"password": {
|
||||
"en": "Password",
|
||||
"de": "Passwort",
|
||||
"fr": "Mot de passe",
|
||||
"es": "Contraseña",
|
||||
"ja": "パスワード",
|
||||
"pt": "Senha",
|
||||
"zh": "密码",
|
||||
"ru": "Пароль"
|
||||
},
|
||||
};
|
||||
function ftlFrom(lang) {
|
||||
return Object.entries(translations)
|
||||
.map(([key, langs]) => `${key} = ${langs[lang]}`)
|
||||
.join('\n')
|
||||
}
|
||||
exports.i18n = {
|
||||
install(Vue) {
|
||||
let bundle = null
|
||||
Vue.prototype.$setLang = function (lang) {
|
||||
const ftl = ftlFrom(lang)
|
||||
const resource = new FluentBundle.FluentResource(ftl)
|
||||
bundle = new FluentBundle.FluentBundle(lang)
|
||||
bundle.addResource(resource)
|
||||
}
|
||||
Vue.prototype.$t = function (code, args) {
|
||||
if (!bundle) return
|
||||
const msg = bundle.getMessage(code)
|
||||
if (!msg || !msg.value) return
|
||||
return bundle.formatPattern(msg.value, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
})(window)
|
||||
@@ -1,4 +1,79 @@
|
||||
function scrollto(target, scroll) {
|
||||
var padding = 10
|
||||
var targetRect = target.getBoundingClientRect()
|
||||
var scrollRect = scroll.getBoundingClientRect()
|
||||
|
||||
// target
|
||||
var relativeOffset = targetRect.y - scrollRect.y
|
||||
var absoluteOffset = relativeOffset + scroll.scrollTop
|
||||
|
||||
if (padding <= relativeOffset && relativeOffset + targetRect.height <= scrollRect.height - padding) return
|
||||
|
||||
var newPos = scroll.scrollTop
|
||||
if (relativeOffset < padding) {
|
||||
newPos = absoluteOffset - padding
|
||||
} else {
|
||||
newPos = absoluteOffset - scrollRect.height + targetRect.height + padding
|
||||
}
|
||||
scroll.scrollTop = Math.round(newPos)
|
||||
}
|
||||
|
||||
var helperFunctions = {
|
||||
// navigation helper, navigate relative to selected item
|
||||
navigateToItem: function(relativePosition) {
|
||||
if (vm.itemSelected == null) {
|
||||
// if no item is selected, select first
|
||||
if (vm.items.length !== 0) vm.itemSelected = vm.items[0].id
|
||||
return
|
||||
}
|
||||
|
||||
var itemPosition = vm.items.findIndex(function(x) { return x.id === vm.itemSelected })
|
||||
if (itemPosition === -1) {
|
||||
if (vm.items.length !== 0) vm.itemSelected = vm.items[0].id
|
||||
return
|
||||
}
|
||||
|
||||
var newPosition = itemPosition + relativePosition
|
||||
if (newPosition < 0 || newPosition >= vm.items.length) return
|
||||
|
||||
vm.itemSelected = vm.items[newPosition].id
|
||||
|
||||
vm.$nextTick(function() {
|
||||
var scroll = document.querySelector('#item-list-scroll')
|
||||
|
||||
var handle = scroll.querySelector('input[type=radio]:checked')
|
||||
var target = handle && handle.parentElement
|
||||
|
||||
if (target && scroll) scrollto(target, scroll)
|
||||
})
|
||||
},
|
||||
// navigation helper, navigate relative to selected feed
|
||||
navigateToFeed: function(relativePosition) {
|
||||
var navigationList = Array.from(document.querySelectorAll('#col-feed-list input[name=feed]'))
|
||||
.filter(function(r) { return r.offsetParent !== null && r.value !== 'folder:null' })
|
||||
.map(function(r) { return r.value })
|
||||
|
||||
var currentFeedPosition = navigationList.indexOf(vm.feedSelected)
|
||||
|
||||
if (currentFeedPosition == -1) {
|
||||
vm.feedSelected = ''
|
||||
return
|
||||
}
|
||||
|
||||
var newPosition = currentFeedPosition+relativePosition
|
||||
if (newPosition < 0 || newPosition >= navigationList.length) return
|
||||
|
||||
vm.feedSelected = navigationList[newPosition]
|
||||
|
||||
vm.$nextTick(function() {
|
||||
var scroll = document.querySelector('#feed-list-scroll')
|
||||
|
||||
var handle = scroll.querySelector('input[type=radio]:checked')
|
||||
var target = handle && handle.parentElement
|
||||
|
||||
if (target && scroll) scrollto(target, scroll)
|
||||
})
|
||||
},
|
||||
scrollContent: function(direction) {
|
||||
var padding = 40
|
||||
var scroll = document.querySelector('.content')
|
||||
@@ -17,7 +92,7 @@ var helperFunctions = {
|
||||
var shortcutFunctions = {
|
||||
openItemLink: function() {
|
||||
if (vm.itemSelectedDetails && vm.itemSelectedDetails.link) {
|
||||
window.open(vm.itemSelectedDetails.link, '_blank', 'noopener,noreferrer')
|
||||
window.open(vm.itemSelectedDetails.link, '_blank')
|
||||
}
|
||||
},
|
||||
toggleReadability: function() {
|
||||
@@ -43,16 +118,16 @@ var shortcutFunctions = {
|
||||
document.getElementById("searchbar").focus()
|
||||
},
|
||||
nextItem(){
|
||||
vm.navigateToItem(+1)
|
||||
helperFunctions.navigateToItem(+1)
|
||||
},
|
||||
previousItem() {
|
||||
vm.navigateToItem(-1)
|
||||
helperFunctions.navigateToItem(-1)
|
||||
},
|
||||
nextFeed(){
|
||||
vm.navigateToFeed(+1)
|
||||
helperFunctions.navigateToFeed(+1)
|
||||
},
|
||||
previousFeed() {
|
||||
vm.navigateToFeed(-1)
|
||||
helperFunctions.navigateToFeed(-1)
|
||||
},
|
||||
scrollForward: function() {
|
||||
helperFunctions.scrollContent(+1)
|
||||
@@ -60,9 +135,6 @@ var shortcutFunctions = {
|
||||
scrollBackward: function() {
|
||||
helperFunctions.scrollContent(-1)
|
||||
},
|
||||
closeItem: function () {
|
||||
vm.itemSelected = null
|
||||
},
|
||||
showAll() {
|
||||
vm.filterSelected = ''
|
||||
},
|
||||
@@ -88,7 +160,6 @@ var keybindings = {
|
||||
"h": shortcutFunctions.previousFeed,
|
||||
"f": shortcutFunctions.scrollForward,
|
||||
"b": shortcutFunctions.scrollBackward,
|
||||
"q": shortcutFunctions.closeItem,
|
||||
"1": shortcutFunctions.showUnread,
|
||||
"2": shortcutFunctions.showStarred,
|
||||
"3": shortcutFunctions.showAll,
|
||||
@@ -107,7 +178,6 @@ var codebindings = {
|
||||
"KeyH": shortcutFunctions.previousFeed,
|
||||
"KeyF": shortcutFunctions.scrollForward,
|
||||
"KeyB": shortcutFunctions.scrollBackward,
|
||||
"KeyQ": shortcutFunctions.closeItem,
|
||||
"Digit1": shortcutFunctions.showUnread,
|
||||
"Digit2": shortcutFunctions.showStarred,
|
||||
"Digit3": shortcutFunctions.showAll,
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
<link rel="alternate icon" href="./static/graphicarts/favicon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<style>
|
||||
[v-cloak] { display: none }
|
||||
form {
|
||||
max-width: 300px;
|
||||
margin: 0 auto;
|
||||
@@ -23,34 +22,22 @@
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="theme-{% .settings.theme_name %}">
|
||||
<div id="app" v-cloak>
|
||||
<form action="" method="post">
|
||||
<img src="./static/graphicarts/anchor.svg" alt="">
|
||||
<div class="text-danger text-center my-3" v-if="hasError">{{ $t('login_error') }}</div>
|
||||
<div class="form-group">
|
||||
<label for="username">{{ $t('username') }}</label>
|
||||
<input name="username" class="form-control" id="username" autocomplete="off"
|
||||
value="{% if .username %}{% .username %}{% end %}" required autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">{{ $t('password') }}</label>
|
||||
<input name="password" class="form-control" id="password" type="password" required>
|
||||
</div>
|
||||
<button class="btn btn-block btn-default" type="submit">{{ $t('login') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
<script src="./static/javascripts/vue.min.js"></script>
|
||||
<script src="./static/javascripts/fluent.js"></script>
|
||||
<script src="./static/javascripts/i18n.js"></script>
|
||||
<script>
|
||||
Vue.use(i18n)
|
||||
new Vue({
|
||||
data: { hasError: {% .hasError %} },
|
||||
created: function () {
|
||||
this.$setLang('{% .settings.language %}')
|
||||
}
|
||||
}).$mount('#app')
|
||||
</script>
|
||||
<body>
|
||||
<form action="" method="post">
|
||||
<img src="./static/graphicarts/anchor.svg" alt="">
|
||||
{% if .error %}
|
||||
<div class="text-danger text-center my-3">{% .error %}</div>
|
||||
{% end %}
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input name="username" class="form-control" id="username" autocomplete="off"
|
||||
value="{% if .username %}{% .username %}{% end %}" required autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input name="password" class="form-control" id="password" type="password" required>
|
||||
</div>
|
||||
<button class="btn btn-block btn-default" type="submit">Login</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
html {
|
||||
font-size: 15px !important;
|
||||
}
|
||||
|
||||
body {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
@@ -97,10 +100,6 @@ select.form-control:not([multiple]):not([size]) {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.scroll-touch {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* custom elements */
|
||||
|
||||
.font-serif {
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
@@ -62,16 +61,3 @@ func ExtractText(content string) string {
|
||||
text = whitespaceRegex.ReplaceAllLiteralString(text, " ")
|
||||
return text
|
||||
}
|
||||
|
||||
func TruncateText(input string, size int) string {
|
||||
runes := []rune(input)
|
||||
if len(runes) <= size {
|
||||
return input
|
||||
}
|
||||
for i := size - 1; i > 0; i-- {
|
||||
if unicode.IsSpace(runes[i]) {
|
||||
return string(runes[:i]) + " ..."
|
||||
}
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
@@ -24,21 +24,3 @@ func TestExtractText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateText(t *testing.T) {
|
||||
input := "Lorem ipsum — классический текст-«рыба»"
|
||||
|
||||
size := 30
|
||||
want := "Lorem ipsum — классический ..."
|
||||
have := TruncateText(input, size)
|
||||
if want != have {
|
||||
t.Errorf("\nsize: %d\nwant: %#v\nhave: %#v", size, want, have)
|
||||
}
|
||||
|
||||
size = 1000
|
||||
want = input
|
||||
have = TruncateText(input, size)
|
||||
if want != have {
|
||||
t.Errorf("\nsize: %d\nwant: %#v\nhave: %#v", size, want, have)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,16 +27,10 @@ var (
|
||||
|
||||
blacklistCandidatesRegexp = regexp.MustCompile(`(?i)popupbody|-ad|g-plus`)
|
||||
okMaybeItsACandidateRegexp = regexp.MustCompile(`(?i)and|article|body|column|main|shadow`)
|
||||
unlikelyCandidatesRegexp = regexp.MustCompile(
|
||||
`(?i)banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|foot|header|legends|menu|modal|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote`,
|
||||
)
|
||||
unlikelyCandidatesRegexp = regexp.MustCompile(`(?i)banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|foot|header|legends|menu|modal|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote`)
|
||||
|
||||
negativeRegexp = regexp.MustCompile(
|
||||
`(?i)hidden|^hid$|hid$|hid|^hid |banner|combx|comment|com-|contact|foot|footer|footnote|masthead|media|meta|modal|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|tool|widget|byline|author|dateline|writtenby|p-author`,
|
||||
)
|
||||
positiveRegexp = regexp.MustCompile(
|
||||
`(?i)article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story`,
|
||||
)
|
||||
negativeRegexp = regexp.MustCompile(`(?i)hidden|^hid$|hid$|hid|^hid |banner|combx|comment|com-|contact|foot|footer|footnote|masthead|media|meta|modal|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|tool|widget|byline|author|dateline|writtenby|p-author`)
|
||||
positiveRegexp = regexp.MustCompile(`(?i)article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story`)
|
||||
)
|
||||
|
||||
type nodeScores map[*html.Node]float32
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -147,10 +146,7 @@ func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([
|
||||
}
|
||||
|
||||
attrNames = append(attrNames, attribute.Key)
|
||||
htmlAttrs = append(
|
||||
htmlAttrs,
|
||||
fmt.Sprintf(`%s="%s"`, attribute.Key, html.EscapeString(value)),
|
||||
)
|
||||
htmlAttrs = append(htmlAttrs, fmt.Sprintf(`%s="%s"`, attribute.Key, html.EscapeString(value)))
|
||||
}
|
||||
|
||||
extraAttrNames, extraHTMLAttributes := getExtraAttributes(tagName)
|
||||
@@ -165,27 +161,13 @@ func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([
|
||||
func getExtraAttributes(tagName string) ([]string, []string) {
|
||||
switch tagName {
|
||||
case "a":
|
||||
return []string{
|
||||
"rel",
|
||||
"target",
|
||||
"referrerpolicy",
|
||||
}, []string{
|
||||
`rel="noopener noreferrer"`,
|
||||
`target="_blank"`,
|
||||
`referrerpolicy="no-referrer"`,
|
||||
}
|
||||
return []string{"rel", "target", "referrerpolicy"}, []string{`rel="noopener noreferrer"`, `target="_blank"`, `referrerpolicy="no-referrer"`}
|
||||
case "video", "audio":
|
||||
return []string{"controls"}, []string{"controls"}
|
||||
case "iframe":
|
||||
return []string{
|
||||
"sandbox",
|
||||
"loading",
|
||||
}, []string{
|
||||
`sandbox="allow-scripts allow-same-origin allow-popups"`,
|
||||
`loading="lazy"`,
|
||||
}
|
||||
return []string{"sandbox", "loading"}, []string{`sandbox="allow-scripts allow-same-origin allow-popups"`, `loading="lazy"`}
|
||||
case "img":
|
||||
return []string{"loading"}, []string{`loading="lazy"`, `referrerpolicy="no-referrer"`}
|
||||
return []string{"loading"}, []string{`loading="lazy"`}
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
@@ -226,8 +208,10 @@ func hasRequiredAttributes(tagName string, attributes []string) bool {
|
||||
for element, attrs := range elements {
|
||||
if tagName == element {
|
||||
for _, attribute := range attributes {
|
||||
if slices.Contains(attrs, attribute) {
|
||||
return true
|
||||
for _, attr := range attrs {
|
||||
if attr == attribute {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,7 +268,13 @@ func isValidIframeSource(baseURL, src string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
return slices.Contains(whitelist, domain)
|
||||
for _, safeDomain := range whitelist {
|
||||
if safeDomain == domain {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func getTagAllowList() map[string][]string {
|
||||
@@ -348,7 +338,13 @@ func getTagAllowList() map[string][]string {
|
||||
}
|
||||
|
||||
func inList(needle string, haystack []string) bool {
|
||||
return slices.Contains(haystack, needle)
|
||||
for _, element := range haystack {
|
||||
if element == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isBlockedTag(tagName string) bool {
|
||||
@@ -358,7 +354,13 @@ func isBlockedTag(tagName string) bool {
|
||||
"style",
|
||||
}
|
||||
|
||||
return slices.Contains(blacklist, tagName)
|
||||
for _, element := range blacklist {
|
||||
if element == tagName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -8,11 +8,10 @@ import "testing"
|
||||
|
||||
func TestValidInput(t *testing.T) {
|
||||
input := `<p>This is a <strong>text</strong> with an image: <img src="http://example.org/" alt="Test" loading="lazy">.</p>`
|
||||
want := `<p>This is a <strong>text</strong> with an image: <img src="http://example.org/" alt="Test" loading="lazy" referrerpolicy="no-referrer">.</p>`
|
||||
have := Sanitize("http://example.org/", input)
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if have != want {
|
||||
t.Errorf("Wrong output: \nwant: %#v\nhave: %#v", want, have)
|
||||
if input != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, input, output)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,31 +27,31 @@ func TestImgWithTextDataURL(t *testing.T) {
|
||||
|
||||
func TestImgWithDataURL(t *testing.T) {
|
||||
input := `<img src="data:image/gif;base64,test" alt="Example">`
|
||||
want := `<img src="data:image/gif;base64,test" alt="Example" loading="lazy" referrerpolicy="no-referrer">`
|
||||
have := Sanitize("http://example.org/", input)
|
||||
expected := `<img src="data:image/gif;base64,test" alt="Example" loading="lazy">`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if have != want {
|
||||
t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
|
||||
if output != expected {
|
||||
t.Errorf(`Wrong output: %s`, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImgWithSrcset(t *testing.T) {
|
||||
input := `<img srcset="example-320w.jpg, example-480w.jpg 1.5x, example-640w.jpg 2x, example-640w.jpg 640w" src="example-640w.jpg" alt="Example">`
|
||||
want := `<img srcset="http://example.org/example-320w.jpg, http://example.org/example-480w.jpg 1.5x, http://example.org/example-640w.jpg 2x, http://example.org/example-640w.jpg 640w" src="http://example.org/example-640w.jpg" alt="Example" loading="lazy" referrerpolicy="no-referrer">`
|
||||
have := Sanitize("http://example.org/", input)
|
||||
expected := `<img srcset="http://example.org/example-320w.jpg, http://example.org/example-480w.jpg 1.5x, http://example.org/example-640w.jpg 2x, http://example.org/example-640w.jpg 640w" src="http://example.org/example-640w.jpg" alt="Example" loading="lazy">`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if have != want {
|
||||
t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
|
||||
if output != expected {
|
||||
t.Errorf(`Wrong output: %s`, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImgWithSrcsetAndDataURL(t *testing.T) {
|
||||
input := `<img srcset="data:image/gif;base64,test" src="http://example.org/example-320w.jpg" alt="Example">`
|
||||
want := `<img srcset="data:image/gif;base64,test" src="http://example.org/example-320w.jpg" alt="Example" loading="lazy" referrerpolicy="no-referrer">`
|
||||
have := Sanitize("http://example.org/", input)
|
||||
expected := `<img srcset="data:image/gif;base64,test" src="http://example.org/example-320w.jpg" alt="Example" loading="lazy">`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if have != want {
|
||||
t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
|
||||
if output != expected {
|
||||
t.Errorf(`Wrong output: %s`, output)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,16 +67,16 @@ func TestSourceWithSrcsetAndMedia(t *testing.T) {
|
||||
|
||||
func TestMediumImgWithSrcset(t *testing.T) {
|
||||
input := `<img alt="Image for post" class="t u v ef aj" src="https://miro.medium.com/max/5460/1*aJ9JibWDqO81qMfNtqgqrw.jpeg" srcset="https://miro.medium.com/max/552/1*aJ9JibWDqO81qMfNtqgqrw.jpeg 276w, https://miro.medium.com/max/1000/1*aJ9JibWDqO81qMfNtqgqrw.jpeg 500w" sizes="500px" width="2730" height="3407">`
|
||||
want := `<img alt="Image for post" src="https://miro.medium.com/max/5460/1*aJ9JibWDqO81qMfNtqgqrw.jpeg" srcset="https://miro.medium.com/max/552/1*aJ9JibWDqO81qMfNtqgqrw.jpeg 276w, https://miro.medium.com/max/1000/1*aJ9JibWDqO81qMfNtqgqrw.jpeg 500w" sizes="500px" loading="lazy" referrerpolicy="no-referrer">`
|
||||
have := Sanitize("http://example.org/", input)
|
||||
expected := `<img alt="Image for post" src="https://miro.medium.com/max/5460/1*aJ9JibWDqO81qMfNtqgqrw.jpeg" srcset="https://miro.medium.com/max/552/1*aJ9JibWDqO81qMfNtqgqrw.jpeg 276w, https://miro.medium.com/max/1000/1*aJ9JibWDqO81qMfNtqgqrw.jpeg 500w" sizes="500px" loading="lazy">`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if have != want {
|
||||
t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
|
||||
if output != expected {
|
||||
t.Errorf(`Wrong output: %s`, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfClosingTags(t *testing.T) {
|
||||
input := `<p>This <br> is a <strong>text</strong><br/>.</p>`
|
||||
input := `<p>This <br> is a <strong>text</strong> <br/>with an image: <img src="http://example.org/" alt="Test" loading="lazy"/>.</p>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if input != output {
|
||||
@@ -96,11 +95,11 @@ func TestTable(t *testing.T) {
|
||||
|
||||
func TestRelativeURL(t *testing.T) {
|
||||
input := `This <a href="/test.html">link is relative</a> and this image: <img src="../folder/image.png"/>`
|
||||
want := `This <a href="http://example.org/test.html" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer">link is relative</a> and this image: <img src="http://example.org/folder/image.png" loading="lazy" referrerpolicy="no-referrer"/>`
|
||||
have := Sanitize("http://example.org/", input)
|
||||
expected := `This <a href="http://example.org/test.html" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer">link is relative</a> and this image: <img src="http://example.org/folder/image.png" loading="lazy"/>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if want != have {
|
||||
t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
|
||||
if expected != output {
|
||||
t.Errorf(`Wrong output: "%s" != "%s"`, expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,11 +165,11 @@ func TestInvalidNestedTag(t *testing.T) {
|
||||
|
||||
func TestValidIFrame(t *testing.T) {
|
||||
input := `<iframe src="http://example.org/"></iframe>`
|
||||
want := `<iframe src="http://example.org/" sandbox="allow-scripts allow-same-origin allow-popups" loading="lazy"></iframe>`
|
||||
have := Sanitize("http://example.org/", input)
|
||||
expected := `<iframe src="http://example.org/" sandbox="allow-scripts allow-same-origin allow-popups" loading="lazy"></iframe>`
|
||||
output := Sanitize("http://example.org/", input)
|
||||
|
||||
if want != have {
|
||||
t.Errorf("Wrong output:\nwant: %s\nhave: %s", want, have)
|
||||
if expected != output {
|
||||
t.Errorf("Wrong output:\nwant: %s\nhave: %s", expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package scraper
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/nkanaev/yarr/src/content/htmlutil"
|
||||
@@ -23,8 +21,10 @@ func FindFeeds(body string, base string) map[string]string {
|
||||
isFeedLink := func(n *html.Node) bool {
|
||||
if n.Type == html.ElementNode && n.Data == "link" {
|
||||
t := htmlutil.Attr(n, "type")
|
||||
if slices.Contains(linkTypes, t) {
|
||||
return true
|
||||
for _, tt := range linkTypes {
|
||||
if tt == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
@@ -35,18 +35,6 @@ func FindFeeds(body string, base string) map[string]string {
|
||||
link := htmlutil.AbsoluteUrl(href, base)
|
||||
if link != "" {
|
||||
candidates[link] = name
|
||||
|
||||
l, err := url.Parse(link)
|
||||
if err == nil && l.Host == "www.youtube.com" && l.Path == "/feeds/videos.xml" {
|
||||
// https://wiki.archiveteam.org/index.php/YouTube/Technical_details#Playlists
|
||||
channelID, found := strings.CutPrefix(l.Query().Get("channel_id"), "UC")
|
||||
if found {
|
||||
const url string = "https://www.youtube.com/feeds/videos.xml?playlist_id="
|
||||
candidates[url+"UULF"+channelID] = name + " - Videos"
|
||||
candidates[url+"UULV"+channelID] = name + " - Live Streams"
|
||||
candidates[url+"UUSH"+channelID] = name + " - Short videos"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,8 +22,6 @@ func VideoIFrame(link string) string {
|
||||
youtubeID := ""
|
||||
if l.Host == "www.youtube.com" && l.Path == "/watch" {
|
||||
youtubeID = l.Query().Get("v")
|
||||
} else if l.Host == "www.youtube.com" && strings.HasPrefix(l.Path, "/shorts/") {
|
||||
youtubeID = strings.TrimPrefix(l.Path, "/shorts/")
|
||||
} else if l.Host == "youtu.be" {
|
||||
youtubeID = strings.TrimLeft(l.Path, "/")
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package parser
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"html"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
@@ -57,7 +58,7 @@ func (a *atomText) String() string {
|
||||
if a.Type == "xhtml" {
|
||||
data = a.XML
|
||||
}
|
||||
return strings.TrimSpace(data)
|
||||
return html.UnescapeString(strings.TrimSpace(data))
|
||||
}
|
||||
|
||||
func (links atomLinks) First(rel string) string {
|
||||
@@ -89,25 +90,15 @@ func ParseAtom(r io.Reader) (*Feed, error) {
|
||||
guidFromID = srcitem.ID + "::" + srcitem.Updated
|
||||
}
|
||||
|
||||
mediaLinks := srcitem.mediaLinks()
|
||||
|
||||
link := firstNonEmpty(
|
||||
srcitem.OrigLink,
|
||||
srcitem.Links.First("alternate"),
|
||||
srcitem.Links.First(""),
|
||||
linkFromID,
|
||||
)
|
||||
link := firstNonEmpty(srcitem.OrigLink, srcitem.Links.First("alternate"), srcitem.Links.First(""), linkFromID)
|
||||
dstfeed.Items = append(dstfeed.Items, Item{
|
||||
GUID: firstNonEmpty(guidFromID, srcitem.ID, link),
|
||||
Date: dateParse(firstNonEmpty(srcitem.Published, srcitem.Updated)),
|
||||
URL: link,
|
||||
Title: srcitem.Title.Text(),
|
||||
Content: firstNonEmpty(
|
||||
srcitem.Content.String(),
|
||||
srcitem.Summary.String(),
|
||||
srcitem.firstMediaDescription(),
|
||||
),
|
||||
MediaLinks: mediaLinks,
|
||||
GUID: firstNonEmpty(guidFromID, srcitem.ID, link),
|
||||
Date: dateParse(firstNonEmpty(srcitem.Published, srcitem.Updated)),
|
||||
URL: link,
|
||||
Title: srcitem.Title.Text(),
|
||||
Content: firstNonEmpty(srcitem.Content.String(), srcitem.Summary.String(), srcitem.firstMediaDescription()),
|
||||
ImageURL: srcitem.firstMediaThumbnail(),
|
||||
AudioURL: "",
|
||||
})
|
||||
}
|
||||
return dstfeed, nil
|
||||
|
||||
@@ -40,11 +40,13 @@ func TestAtom(t *testing.T) {
|
||||
SiteURL: "http://example.org/",
|
||||
Items: []Item{
|
||||
{
|
||||
GUID: "urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a",
|
||||
Date: time.Unix(1071340202, 0).UTC(),
|
||||
URL: "http://example.org/2003/12/13/atom03.html",
|
||||
Title: "Atom-Powered Robots Run Amok",
|
||||
Content: `<div xmlns="http://www.w3.org/1999/xhtml"><p>This is the entry content.</p></div>`,
|
||||
GUID: "urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a",
|
||||
Date: time.Unix(1071340202, 0).UTC(),
|
||||
URL: "http://example.org/2003/12/13/atom03.html",
|
||||
Title: "Atom-Powered Robots Run Amok",
|
||||
Content: `<div xmlns="http://www.w3.org/1999/xhtml"><p>This is the entry content.</p></div>`,
|
||||
ImageURL: "",
|
||||
AudioURL: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -80,7 +82,7 @@ func TestAtomHTMLTitle(t *testing.T) {
|
||||
feed, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<entry><title type="html">say <code>what</code>?</title></entry>
|
||||
<entry><title type="html">say <code>what</code>?</entry>
|
||||
</feed>
|
||||
`))
|
||||
have := feed.Items[0].Title
|
||||
@@ -96,13 +98,12 @@ func TestAtomXHTMLTitle(t *testing.T) {
|
||||
feed, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<entry><title type="xhtml">say <code>what</code>?</title></entry>
|
||||
<entry><title type="xhtml">say <code>what</code>?</entry>
|
||||
</feed>
|
||||
`))
|
||||
have := feed.Items[0].Title
|
||||
want := "say what?"
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Log(feed)
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.FailNow()
|
||||
@@ -140,15 +141,9 @@ func TestAtomImageLink(t *testing.T) {
|
||||
</entry>
|
||||
</feed>
|
||||
`))
|
||||
if len(feed.Items[0].MediaLinks) != 1 {
|
||||
t.Fatalf("Expected 1 media link, got: %#v", feed.Items[0].MediaLinks)
|
||||
}
|
||||
have := feed.Items[0].MediaLinks[0]
|
||||
want := MediaLink{
|
||||
URL: `https://example.com/image.png?width=100&height=100`,
|
||||
Type: "image",
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
have := feed.Items[0].ImageURL
|
||||
want := `https://example.com/image.png?width=100&height=100`
|
||||
if want != have {
|
||||
t.Fatalf("item.image_url doesn't match\nwant: %#v\nhave: %#v\n", want, have)
|
||||
}
|
||||
}
|
||||
@@ -170,8 +165,8 @@ func TestAtomImageLinkDuplicated(t *testing.T) {
|
||||
if want != have {
|
||||
t.Fatalf("want: %#v\nhave: %#v\n", want, have)
|
||||
}
|
||||
if len(feed.Items[0].MediaLinks) != 0 {
|
||||
t.Fatal("item media link must be excluded if present in the content")
|
||||
if feed.Items[0].ImageURL != "" {
|
||||
t.Fatal("item.image_url must be unset if present in the content")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,41 +192,25 @@ func TestAtomLinkInID(t *testing.T) {
|
||||
have := feed.Items
|
||||
want := []Item{
|
||||
Item{
|
||||
GUID: "https://example.com/posts/1::2003-12-13T09:17:51",
|
||||
Date: time.Date(2003, time.December, 13, 9, 17, 51, 0, time.UTC),
|
||||
URL: "https://example.com/posts/1",
|
||||
Title: "one updated",
|
||||
},
|
||||
GUID: "https://example.com/posts/1::2003-12-13T09:17:51",
|
||||
Date: time.Date(2003, time.December, 13, 9, 17, 51, 0, time.UTC),
|
||||
URL: "https://example.com/posts/1",
|
||||
Title: "one updated",
|
||||
},
|
||||
Item{
|
||||
GUID: "urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6",
|
||||
Date: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC), URL: "",
|
||||
Title: "two",
|
||||
},
|
||||
Item{
|
||||
GUID: "https://example.com/posts/1::",
|
||||
Date: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
|
||||
URL: "https://example.com/posts/1",
|
||||
Title: "one",
|
||||
Content: "",
|
||||
},
|
||||
Date: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC), URL: "",
|
||||
Title: "two",
|
||||
},
|
||||
Item{
|
||||
GUID: "https://example.com/posts/1::",
|
||||
Date: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
|
||||
URL: "https://example.com/posts/1",
|
||||
Title: "one",
|
||||
Content: "",
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Fatalf("\nwant: %#v\nhave: %#v\n", want, have)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomDoesntEscapeHTMLTags(t *testing.T) {
|
||||
feed, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<entry><summary type="html">&lt;script&gt;alert(1);&lt;/script&gt;</summary></entry>
|
||||
</feed>
|
||||
`))
|
||||
have := feed.Items[0].Content
|
||||
want := "<script>alert(1);</script>"
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package parser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -15,7 +14,7 @@ import (
|
||||
"golang.org/x/net/html/charset"
|
||||
)
|
||||
|
||||
var ErrUnknownFormat = errors.New("unknown feed format")
|
||||
var UnknownFormat = errors.New("unknown feed format")
|
||||
|
||||
type feedProbe struct {
|
||||
feedType string
|
||||
@@ -89,7 +88,7 @@ func ParseWithEncoding(r io.Reader, fallbackEncoding string) (*Feed, error) {
|
||||
|
||||
out := sniff(string(lookup))
|
||||
if out.feedType == "" {
|
||||
return nil, ErrUnknownFormat
|
||||
return nil, UnknownFormat
|
||||
}
|
||||
|
||||
if out.encoding == "" && fallbackEncoding != "" {
|
||||
@@ -120,7 +119,6 @@ func ParseAndFix(r io.Reader, baseURL, fallbackEncoding string) (*Feed, error) {
|
||||
}
|
||||
feed.TranslateURLs(baseURL)
|
||||
feed.SetMissingDatesTo(time.Now())
|
||||
feed.SetMissingGUIDs()
|
||||
return feed, nil
|
||||
}
|
||||
|
||||
@@ -134,14 +132,11 @@ func (feed *Feed) cleanup() {
|
||||
feed.Items[i].Title = strings.TrimSpace(htmlutil.ExtractText(item.Title))
|
||||
feed.Items[i].Content = strings.TrimSpace(item.Content)
|
||||
|
||||
if len(feed.Items[i].MediaLinks) > 0 {
|
||||
mediaLinks := make([]MediaLink, 0)
|
||||
for _, link := range item.MediaLinks {
|
||||
if !strings.Contains(item.Content, link.URL) {
|
||||
mediaLinks = append(mediaLinks, link)
|
||||
}
|
||||
}
|
||||
feed.Items[i].MediaLinks = mediaLinks
|
||||
if item.ImageURL != "" && strings.Contains(item.Content, item.ImageURL) {
|
||||
feed.Items[i].ImageURL = ""
|
||||
}
|
||||
if item.AudioURL != "" && strings.Contains(item.Content, item.AudioURL) {
|
||||
feed.Items[i].AudioURL = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,12 +168,3 @@ func (feed *Feed) TranslateURLs(base string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (feed *Feed) SetMissingGUIDs() {
|
||||
for i, item := range feed.Items {
|
||||
if item.GUID == "" {
|
||||
id := strings.Join([]string{item.Title, item.Date.Format(time.RFC3339), item.URL}, ";;")
|
||||
feed.Items[i].GUID = fmt.Sprintf("%x", sha256.Sum256([]byte(id)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,12 +40,7 @@ func TestSniff(t *testing.T) {
|
||||
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,
|
||||
)
|
||||
t.Errorf("Invalid output\n---\n%s\n---\n\nwant=%#v\nhave=%#v", testcase.input, want, have)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,32 +150,3 @@ func TestParseCleanIllegalCharsInNonUTF8(t *testing.T) {
|
||||
t.Fatalf("invalid feed, got: %v", feed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMissingGUID(t *testing.T) {
|
||||
data := `
|
||||
<?xml version="1.0" encoding="windows-1251"?>
|
||||
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
||||
<channel>
|
||||
<item>
|
||||
<title>foo</title>
|
||||
</item>
|
||||
<item>
|
||||
<title>bar</title>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`
|
||||
feed, err := ParseAndFix(strings.NewReader(data), "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(feed.Items) != 2 {
|
||||
t.Fatalf("expected 2 items, got %d", len(feed.Items))
|
||||
}
|
||||
if feed.Items[0].GUID == "" || feed.Items[1].GUID == "" {
|
||||
t.Fatalf("item GUIDs are missing, got %#v", feed.Items)
|
||||
}
|
||||
if feed.Items[0].GUID == feed.Items[1].GUID {
|
||||
t.Fatalf("item GUIDs are not unique, got %#v", feed.Items)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
type media struct {
|
||||
MediaGroups []mediaGroup `xml:"http://search.yahoo.com/mrss/ group"`
|
||||
MediaContents []mediaContent `xml:"http://search.yahoo.com/mrss/ content"`
|
||||
@@ -12,17 +8,12 @@ type media struct {
|
||||
}
|
||||
|
||||
type mediaGroup struct {
|
||||
MediaContent []mediaContent `xml:"http://search.yahoo.com/mrss/ content"`
|
||||
MediaThumbnails []mediaThumbnail `xml:"http://search.yahoo.com/mrss/ thumbnail"`
|
||||
MediaDescriptions []mediaDescription `xml:"http://search.yahoo.com/mrss/ description"`
|
||||
}
|
||||
|
||||
type mediaContent struct {
|
||||
MediaThumbnails []mediaThumbnail `xml:"http://search.yahoo.com/mrss/ thumbnail"`
|
||||
MediaType string `xml:"type,attr"`
|
||||
MediaMedium string `xml:"medium,attr"`
|
||||
MediaURL string `xml:"url,attr"`
|
||||
MediaDescription mediaDescription `xml:"http://search.yahoo.com/mrss/ description"`
|
||||
MediaThumbnails []mediaThumbnail `xml:"http://search.yahoo.com/mrss/ thumbnail"`
|
||||
}
|
||||
|
||||
type mediaThumbnail struct {
|
||||
@@ -30,68 +21,35 @@ type mediaThumbnail struct {
|
||||
}
|
||||
|
||||
type mediaDescription struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Text string `xml:",chardata"`
|
||||
Type string `xml:"type,attr"`
|
||||
Description string `xml:",chardata"`
|
||||
}
|
||||
|
||||
func (m *media) firstMediaDescription() string {
|
||||
for _, d := range m.MediaDescriptions {
|
||||
return plain2html(d.Text)
|
||||
func (m *media) firstMediaThumbnail() string {
|
||||
for _, c := range m.MediaContents {
|
||||
for _, t := range c.MediaThumbnails {
|
||||
return t.URL
|
||||
}
|
||||
}
|
||||
for _, t := range m.MediaThumbnails {
|
||||
return t.URL
|
||||
}
|
||||
for _, g := range m.MediaGroups {
|
||||
for _, d := range g.MediaDescriptions {
|
||||
return plain2html(d.Text)
|
||||
for _, t := range g.MediaThumbnails {
|
||||
return t.URL
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *media) mediaLinks() []MediaLink {
|
||||
links := make([]MediaLink, 0)
|
||||
for _, thumbnail := range m.MediaThumbnails {
|
||||
links = append(links, MediaLink{URL: thumbnail.URL, Type: "image"})
|
||||
func (m *media) firstMediaDescription() string {
|
||||
for _, d := range m.MediaDescriptions {
|
||||
return plain2html(d.Description)
|
||||
}
|
||||
for _, group := range m.MediaGroups {
|
||||
for _, thumbnail := range group.MediaThumbnails {
|
||||
links = append(links, MediaLink{
|
||||
URL: thumbnail.URL,
|
||||
Type: "image",
|
||||
})
|
||||
for _, g := range m.MediaGroups {
|
||||
for _, d := range g.MediaDescriptions {
|
||||
return plain2html(d.Description)
|
||||
}
|
||||
}
|
||||
for _, content := range m.MediaContents {
|
||||
if content.MediaURL != "" {
|
||||
url := content.MediaURL
|
||||
description := content.MediaDescription.Text
|
||||
if strings.HasPrefix(content.MediaType, "image/") {
|
||||
links = append(links, MediaLink{URL: url, Type: "image", Description: description})
|
||||
} else if strings.HasPrefix(content.MediaType, "audio/") {
|
||||
links = append(links, MediaLink{URL: url, Type: "audio", Description: description})
|
||||
} else if strings.HasPrefix(content.MediaType, "video/") {
|
||||
links = append(links, MediaLink{URL: url, Type: "video", Description: description})
|
||||
} else if content.MediaMedium == "image" || content.MediaMedium == "audio" || content.MediaMedium == "video" {
|
||||
links = append(
|
||||
links,
|
||||
MediaLink{URL: url, Type: content.MediaMedium, Description: description},
|
||||
)
|
||||
} else {
|
||||
if len(content.MediaThumbnails) > 0 {
|
||||
links = append(links, MediaLink{
|
||||
URL: content.MediaThumbnails[0].URL,
|
||||
Type: "image",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, thumbnail := range content.MediaThumbnails {
|
||||
links = append(links, MediaLink{
|
||||
URL: thumbnail.URL,
|
||||
Type: "image",
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(links) == 0 {
|
||||
return nil
|
||||
}
|
||||
return links
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -14,12 +14,7 @@ type Item struct {
|
||||
URL string
|
||||
Title string
|
||||
|
||||
Content string
|
||||
MediaLinks []MediaLink
|
||||
}
|
||||
|
||||
type MediaLink struct {
|
||||
URL string
|
||||
Type string
|
||||
Description string
|
||||
Content string
|
||||
ImageURL string
|
||||
AudioURL string
|
||||
}
|
||||
|
||||
@@ -42,16 +42,8 @@ func TestRDFFeed(t *testing.T) {
|
||||
Title: "Mozilla Dot Org",
|
||||
SiteURL: "http://www.mozilla.org",
|
||||
Items: []Item{
|
||||
{
|
||||
GUID: "http://www.mozilla.org/status/",
|
||||
URL: "http://www.mozilla.org/status/",
|
||||
Title: "New Status Updates",
|
||||
},
|
||||
{
|
||||
GUID: "http://www.mozilla.org/bugs/",
|
||||
URL: "http://www.mozilla.org/bugs/",
|
||||
Title: "Bugzilla Reorganized",
|
||||
},
|
||||
{GUID: "http://www.mozilla.org/status/", URL: "http://www.mozilla.org/status/", Title: "New Status Updates"},
|
||||
{GUID: "http://www.mozilla.org/bugs/", URL: "http://www.mozilla.org/bugs/", Title: "Bugzilla Reorganized"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -20,12 +20,12 @@ type rssFeed struct {
|
||||
}
|
||||
|
||||
type rssItem struct {
|
||||
GUID rssGuid `xml:"rss guid"`
|
||||
Title string `xml:"rss title"`
|
||||
GUID rssGuid `xml:"guid"`
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"rss link"`
|
||||
Description string `xml:"rss description"`
|
||||
PubDate string `xml:"rss pubDate"`
|
||||
Enclosures []rssEnclosure `xml:"rss enclosure"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
Enclosures []rssEnclosure `xml:"enclosure"`
|
||||
|
||||
DublinCoreDate string `xml:"http://purl.org/dc/elements/1.1/ date"`
|
||||
ContentEncoded string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
|
||||
@@ -48,6 +48,12 @@ type rssLink struct {
|
||||
Rel string `xml:"rel,attr"`
|
||||
}
|
||||
|
||||
type rssTitle struct {
|
||||
XMLName xml.Name
|
||||
Data string `xml:",chardata"`
|
||||
Inner string `xml:",innerxml"`
|
||||
}
|
||||
|
||||
type rssEnclosure struct {
|
||||
URL string `xml:"url,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
@@ -57,10 +63,9 @@ type rssEnclosure struct {
|
||||
func ParseRSS(r io.Reader) (*Feed, error) {
|
||||
srcfeed := rssFeed{}
|
||||
|
||||
rawDecoder := xmlDecoder(r)
|
||||
rawDecoder.DefaultSpace = "rss"
|
||||
rssDecoder := xml.NewTokenDecoder(&rssTokenReader{Decoder: rawDecoder})
|
||||
if err := rssDecoder.Decode(&srcfeed); err != nil {
|
||||
decoder := xmlDecoder(r)
|
||||
decoder.DefaultSpace = "rss"
|
||||
if err := decoder.Decode(&srcfeed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -69,23 +74,17 @@ func ParseRSS(r io.Reader) (*Feed, error) {
|
||||
SiteURL: srcfeed.Link,
|
||||
}
|
||||
for _, srcitem := range srcfeed.Items {
|
||||
mediaLinks := srcitem.mediaLinks()
|
||||
podcastURL := ""
|
||||
for _, e := range srcitem.Enclosures {
|
||||
if strings.HasPrefix(e.Type, "audio/") {
|
||||
podcastURL := e.URL
|
||||
if srcitem.OrigEnclosureLink != "" &&
|
||||
strings.Contains(podcastURL, path.Base(srcitem.OrigEnclosureLink)) {
|
||||
podcastURL = e.URL
|
||||
|
||||
if srcitem.OrigEnclosureLink != "" && strings.Contains(podcastURL, path.Base(srcitem.OrigEnclosureLink)) {
|
||||
podcastURL = srcitem.OrigEnclosureLink
|
||||
}
|
||||
mediaLinks = append(mediaLinks, MediaLink{URL: podcastURL, Type: "audio"})
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, e := range srcitem.Enclosures {
|
||||
if strings.HasPrefix(e.Type, "image/") {
|
||||
mediaLinks = append(mediaLinks, MediaLink{URL: e.URL, Type: "image"})
|
||||
}
|
||||
}
|
||||
|
||||
permalink := ""
|
||||
if srcitem.GUID.IsPermaLink == "true" {
|
||||
@@ -93,16 +92,13 @@ func ParseRSS(r io.Reader) (*Feed, error) {
|
||||
}
|
||||
|
||||
dstfeed.Items = append(dstfeed.Items, Item{
|
||||
GUID: firstNonEmpty(srcitem.GUID.GUID, srcitem.Link),
|
||||
Date: dateParse(firstNonEmpty(srcitem.DublinCoreDate, srcitem.PubDate)),
|
||||
URL: firstNonEmpty(srcitem.OrigLink, srcitem.Link, permalink),
|
||||
Title: srcitem.Title,
|
||||
Content: firstNonEmpty(
|
||||
srcitem.ContentEncoded,
|
||||
srcitem.Description,
|
||||
srcitem.firstMediaDescription(),
|
||||
),
|
||||
MediaLinks: mediaLinks,
|
||||
GUID: firstNonEmpty(srcitem.GUID.GUID, srcitem.Link),
|
||||
Date: dateParse(firstNonEmpty(srcitem.DublinCoreDate, srcitem.PubDate)),
|
||||
URL: firstNonEmpty(srcitem.OrigLink, srcitem.Link, permalink),
|
||||
Title: srcitem.Title,
|
||||
Content: firstNonEmpty(srcitem.ContentEncoded, srcitem.Description),
|
||||
AudioURL: podcastURL,
|
||||
ImageURL: srcitem.firstMediaThumbnail(),
|
||||
})
|
||||
}
|
||||
return dstfeed, nil
|
||||
|
||||
@@ -75,15 +75,9 @@ func TestRSSMediaContentThumbnail(t *testing.T) {
|
||||
</channel>
|
||||
</rss>
|
||||
`))
|
||||
if len(feed.Items[0].MediaLinks) != 1 {
|
||||
t.Fatalf("Expected 1 media link, got %#v", feed.Items[0].MediaLinks)
|
||||
}
|
||||
have := feed.Items[0].MediaLinks[0]
|
||||
want := MediaLink{
|
||||
URL: "https://i.vimeocdn.com/video/1092705247_960.jpg",
|
||||
Type: "image",
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
have := feed.Items[0].ImageURL
|
||||
want := "https://i.vimeocdn.com/video/1092705247_960.jpg"
|
||||
if have != want {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.FailNow()
|
||||
@@ -133,15 +127,9 @@ func TestRSSPodcast(t *testing.T) {
|
||||
</channel>
|
||||
</rss>
|
||||
`))
|
||||
if len(feed.Items[0].MediaLinks) != 1 {
|
||||
t.Fatal("Invalid media links")
|
||||
}
|
||||
have := feed.Items[0].MediaLinks[0]
|
||||
want := MediaLink{
|
||||
URL: "http://example.com/audio.ext",
|
||||
Type: "audio",
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
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()
|
||||
@@ -159,15 +147,9 @@ func TestRSSOpusPodcast(t *testing.T) {
|
||||
</channel>
|
||||
</rss>
|
||||
`))
|
||||
if len(feed.Items[0].MediaLinks) != 1 {
|
||||
t.Fatal("Invalid media links")
|
||||
}
|
||||
have := feed.Items[0].MediaLinks[0]
|
||||
want := MediaLink{
|
||||
URL: "http://example.com/audio.ext",
|
||||
Type: "audio",
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
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()
|
||||
@@ -194,9 +176,8 @@ func TestRSSPodcastDuplicated(t *testing.T) {
|
||||
if want != have {
|
||||
t.Fatalf("content doesn't match\nwant: %#v\nhave: %#v\n", want, have)
|
||||
}
|
||||
|
||||
if len(feed.Items[0].MediaLinks) != 0 {
|
||||
t.Fatal("item media must be excluded if present in the content")
|
||||
if feed.Items[0].AudioURL != "" {
|
||||
t.Fatal("item.audio_url must be unset if present in the content")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +197,7 @@ func TestRSSTitleHTMLTags(t *testing.T) {
|
||||
`))
|
||||
have := []string{feed.Items[0].Title, feed.Items[1].Title}
|
||||
want := []string{"title in p", "very strong title"}
|
||||
for i := range want {
|
||||
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])
|
||||
}
|
||||
@@ -241,154 +222,9 @@ func TestRSSIsPermalink(t *testing.T) {
|
||||
URL: "http://example.com/posts/1",
|
||||
},
|
||||
}
|
||||
for i := range want {
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
for i := 0; i < len(want); i++ {
|
||||
if want[i] != have[i] {
|
||||
t.Errorf("Failed to handle isPermalink\nwant: %#v\nhave: %#v\n", want[i], have[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/nkanaev/yarr/issues/284
|
||||
func TestRSSEnclosureImage(t *testing.T) {
|
||||
feed, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<item>
|
||||
<title>Post with image</title>
|
||||
<link>http://example.com/post/1</link>
|
||||
<enclosure url="http://example.com/photo.jpg" type="image/jpeg" length="123456"/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`))
|
||||
if len(feed.Items[0].MediaLinks) != 1 {
|
||||
t.Fatalf("Expected 1 media link, got %d: %#v", len(feed.Items[0].MediaLinks), feed.Items[0].MediaLinks)
|
||||
}
|
||||
have := feed.Items[0].MediaLinks[0]
|
||||
want := MediaLink{
|
||||
URL: "http://example.com/photo.jpg",
|
||||
Type: "image",
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
func TestRSSMultipleMedia(t *testing.T) {
|
||||
feed, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/">
|
||||
<channel>
|
||||
<item>
|
||||
<guid isPermaLink="true">http://example.com/posts/1</guid>
|
||||
<media:content url="https://example.com/path/to/image1.png" type="image/png" fileSize="1000" medium="image">
|
||||
<media:description type="plain">description 1</media:description>
|
||||
</media:content>
|
||||
<media:content url="https://example.com/path/to/image2.png" type="image/png" fileSize="2000" medium="image">
|
||||
<media:description type="plain">description 2</media:description>
|
||||
</media:content>
|
||||
<media:content url="https://example.com/path/to/video1.mp4" type="video/mp4" fileSize="2000" medium="image">
|
||||
<media:description type="plain">video description</media:description>
|
||||
</media:content>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`))
|
||||
have := feed.Items
|
||||
want := []Item{
|
||||
{
|
||||
GUID: "http://example.com/posts/1",
|
||||
URL: "http://example.com/posts/1",
|
||||
MediaLinks: []MediaLink{
|
||||
{
|
||||
URL: "https://example.com/path/to/image1.png",
|
||||
Type: "image",
|
||||
Description: "description 1",
|
||||
},
|
||||
{
|
||||
URL: "https://example.com/path/to/image2.png",
|
||||
Type: "image",
|
||||
Description: "description 2",
|
||||
},
|
||||
{
|
||||
URL: "https://example.com/path/to/video1.mp4",
|
||||
Type: "video",
|
||||
Description: "video description",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fatal("invalid rss")
|
||||
}
|
||||
}
|
||||
|
||||
// When both RSS <link> and Atom <atom:link> elements are present in an item,
|
||||
// the RSS link must not be lost. The <link> tag is namespace-qualified as
|
||||
// `rss link` to disambiguate — see commit ee2a825, found in:
|
||||
// https://rss.nytimes.com/services/xml/rss/nyt/Arts.xml
|
||||
func TestRSSItemLinkWithAtomLinkPresent(t *testing.T) {
|
||||
have, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>Example</title>
|
||||
<item>
|
||||
<title>Article</title>
|
||||
<link>http://example.com/article/1</link>
|
||||
<atom:link href="http://example.com/article/1/atom" rel="alternate" type="text/html"/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`))
|
||||
want := &Feed{
|
||||
Title: "Example",
|
||||
Items: []Item{
|
||||
{
|
||||
GUID: "http://example.com/article/1",
|
||||
URL: "http://example.com/article/1",
|
||||
Title: "Article",
|
||||
},
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Fatalf("RSS link lost when atom:link is present\nwant: %#v\nhave: %#v", want, have)
|
||||
}
|
||||
}
|
||||
|
||||
// Feeds that declare a default namespace on the root <rss> element (e.g. the
|
||||
// legacy Userland namespace) must still parse — see sud.ua/rss/rss_news_uk.xml.
|
||||
func TestRSSDefaultNamespace(t *testing.T) {
|
||||
have, _ := Parse(strings.NewReader(`
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss xmlns="http://backend.userland.com/rss2" version="2.0">
|
||||
<channel>
|
||||
<title>Feed</title>
|
||||
<item>
|
||||
<title>Title 1</title>
|
||||
<link>https://example.com/news/1</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`))
|
||||
want := &Feed{
|
||||
Title: "Feed",
|
||||
Items: []Item{
|
||||
{
|
||||
GUID: "https://example.com/news/1",
|
||||
URL: "https://example.com/news/1",
|
||||
Title: "Title 1",
|
||||
},
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(want, have) {
|
||||
t.Fatalf("default-namespaced rss not parsed \nwant: %#v\nhave: %#v", want, have)
|
||||
// t.Logf("have: %#v", have)
|
||||
// t.Fatal("default-namespaced rss not parsed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,59 +42,6 @@ func xmlDecoder(r io.Reader) *xml.Decoder {
|
||||
return decoder
|
||||
}
|
||||
|
||||
// XML token reader that strips the default namespace.
|
||||
// It's primary purpose is to support namespaced legacy UserLand RSS feeds.
|
||||
// NOTE: token readers cannot populate ",innerxml"-tagged struct fields,
|
||||
// see https://github.com/golang/go/issues/39645
|
||||
type rssTokenReader struct {
|
||||
Decoder *xml.Decoder
|
||||
defaultNS string
|
||||
}
|
||||
|
||||
func (r *rssTokenReader) Token() (xml.Token, error) {
|
||||
tok, err := r.Decoder.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
// extract default namespace: <rss xmlns="<defaultNS>">
|
||||
if t.Name.Local == "rss" {
|
||||
for _, attr := range t.Attr {
|
||||
if attr.Name.Space == "" && attr.Name.Local == "xmlns" && attr.Value != "" {
|
||||
r.defaultNS = attr.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if r.defaultNS != "" {
|
||||
// Rewrite element namespace
|
||||
if t.Name.Space == r.defaultNS {
|
||||
t.Name.Space = r.Decoder.DefaultSpace
|
||||
}
|
||||
// Rewrite attribute namespaces
|
||||
attrs := t.Attr[:0]
|
||||
for _, a := range t.Attr {
|
||||
if a.Name.Space == r.defaultNS {
|
||||
a.Name.Space = r.Decoder.DefaultSpace
|
||||
}
|
||||
attrs = append(attrs, a)
|
||||
}
|
||||
t.Attr = attrs
|
||||
}
|
||||
return t, nil
|
||||
case xml.EndElement:
|
||||
if r.defaultNS != "" && t.Name.Space == r.defaultNS {
|
||||
t.Name.Space = r.Decoder.DefaultSpace
|
||||
}
|
||||
return t, nil
|
||||
default:
|
||||
return tok, nil
|
||||
}
|
||||
}
|
||||
|
||||
type safexmlreader struct {
|
||||
reader *bufio.Reader
|
||||
buffer *bytes.Buffer
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestSafeXMLReaderPartial1(t *testing.T) {
|
||||
f = NewSafeXMLReader(f)
|
||||
|
||||
buf := make([]byte, 1)
|
||||
for i := range want {
|
||||
for i := 0; i < len(want); i++ {
|
||||
n, err := f.Read(buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package platform
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build windows
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
//go:build (darwin || windows) && gui
|
||||
//go:build macos || windows
|
||||
// +build macos windows
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"fyne.io/systray"
|
||||
"github.com/nkanaev/yarr/src/server"
|
||||
"github.com/nkanaev/yarr/src/systray"
|
||||
)
|
||||
|
||||
func Start(s *server.Server) {
|
||||
systrayOnReady := func() {
|
||||
systray.SetTemplateIcon(Icon, Icon)
|
||||
systray.SetTooltip("yarr")
|
||||
systray.SetIcon(Icon)
|
||||
|
||||
menuOpen := systray.AddMenuItem("Open", "")
|
||||
systray.AddSeparator()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//go:build !gui
|
||||
//go:build !windows && !macos
|
||||
// +build !windows,!macos
|
||||
|
||||
package platform
|
||||
|
||||
|
||||
100
src/platform/icon.svg
Normal file
@@ -0,0 +1,100 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 48 48"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="feather feather-anchor"
|
||||
version="1.1"
|
||||
id="svg905"
|
||||
sodipodi:docname="icon.svg"
|
||||
inkscape:version="1.0.1 (c497b03c, 2020-09-10)"
|
||||
inkscape:export-filename="/Users/nkanaev/Desktop/icon.png"
|
||||
inkscape:export-xdpi="2048"
|
||||
inkscape:export-ydpi="2048">
|
||||
<metadata
|
||||
id="metadata911">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs909">
|
||||
<inkscape:perspective
|
||||
sodipodi:type="inkscape:persp3d"
|
||||
inkscape:vp_x="0 : 24 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_z="48 : 24 : 1"
|
||||
inkscape:persp3d-origin="24 : 16 : 1"
|
||||
id="perspective842" />
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1440"
|
||||
inkscape:window-height="900"
|
||||
id="namedview907"
|
||||
showgrid="false"
|
||||
inkscape:zoom="4.9128436"
|
||||
inkscape:cx="30.960444"
|
||||
inkscape:cy="52.71331"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg905"
|
||||
inkscape:document-rotation="0" />
|
||||
<rect
|
||||
style="fill:#212529;stroke:none;stroke-width:3;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1"
|
||||
id="rect913"
|
||||
width="48"
|
||||
height="48"
|
||||
x="0"
|
||||
y="0"
|
||||
ry="24"
|
||||
rx="0" />
|
||||
<g
|
||||
id="g940"
|
||||
transform="matrix(1.4545455,0,0,1.4545455,6.545454,6.545454)"
|
||||
style="fill:none;stroke:#ffffff;stroke-opacity:1">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="5"
|
||||
id="circle899"
|
||||
r="3"
|
||||
style="fill:none;stroke:#ffffff;stroke-opacity:1" />
|
||||
<line
|
||||
x1="12"
|
||||
y1="22"
|
||||
x2="12"
|
||||
y2="8"
|
||||
id="line901"
|
||||
style="fill:none;stroke:#ffffff;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 5,12 H 2 a 10,10 0 0 0 20,0 h -3"
|
||||
id="path903"
|
||||
style="fill:none;stroke:#ffffff;stroke-opacity:1" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -1,8 +1,9 @@
|
||||
//go:build darwin && gui
|
||||
//go:build macos
|
||||
// +build macos
|
||||
|
||||
package platform
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed icon_mac.png
|
||||
//go:embed icon.png
|
||||
var Icon []byte
|
||||
|
||||
|
Before Width: | Height: | Size: 40 KiB |
@@ -1,118 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 48 48"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="feather feather-anchor"
|
||||
version="1.1"
|
||||
id="svg905"
|
||||
sodipodi:docname="icon_mac.svg"
|
||||
inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
|
||||
inkscape:export-filename="icon_mac.png"
|
||||
inkscape:export-xdpi="2048"
|
||||
inkscape:export-ydpi="2048"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<metadata
|
||||
id="metadata911">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs909">
|
||||
<inkscape:perspective
|
||||
sodipodi:type="inkscape:persp3d"
|
||||
inkscape:vp_x="0 : 24 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_z="48 : 24 : 1"
|
||||
inkscape:persp3d-origin="24 : 16 : 1"
|
||||
id="perspective842" />
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1440"
|
||||
inkscape:window-height="895"
|
||||
id="namedview907"
|
||||
showgrid="false"
|
||||
inkscape:zoom="4.9128436"
|
||||
inkscape:cx="31.14286"
|
||||
inkscape:cy="52.718959"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="33"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg905"
|
||||
inkscape:document-rotation="0"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1" />
|
||||
<rect
|
||||
style="display:none;fill:#800000;stroke-width:4;stroke-linecap:round;stroke-dasharray:none"
|
||||
id="rect3"
|
||||
width="89.561165"
|
||||
height="70.427643"
|
||||
x="-21.576099"
|
||||
y="-7.734828"
|
||||
ry="24"
|
||||
inkscape:label="background-test" />
|
||||
<rect
|
||||
style="display:none;fill:#000000;fill-opacity:1;stroke:none;stroke-width:3;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect913"
|
||||
width="48"
|
||||
height="48"
|
||||
x="0"
|
||||
y="0"
|
||||
ry="24"
|
||||
rx="0"
|
||||
inkscape:label="circle" />
|
||||
<path
|
||||
id="rect2"
|
||||
style="fill:#000000;stroke:none;stroke-width:3"
|
||||
inkscape:label="circle-hollow"
|
||||
d="M 24 0 C 10.704 0 0 10.704 0 24 C 0 37.296 10.704 48 24 48 C 37.296 48 48 37.296 48 24 C 48 10.704 37.296 0 24 0 z M 24 7.4550781 C 27.49085 7.4550781 30.363281 10.327509 30.363281 13.818359 C 30.363281 16.611253 28.523046 19.006548 26 19.853516 L 26 36.380859 C 31.271218 35.519062 35.266025 31.300336 36.144531 26 L 34.181641 26 A 2.0000001 2.0000001 0 0 1 32.181641 24 A 2.0000001 2.0000001 0 0 1 34.181641 22 L 38.544922 22 A 2.0002001 2.0002001 0 0 1 40.544922 24 C 40.544922 33.114118 33.114111 40.544922 24 40.544922 C 14.885889 40.544922 7.4550781 33.114118 7.4550781 24 A 2.0002001 2.0002001 0 0 1 9.4550781 22 L 13.818359 22 A 2.0000001 2.0000001 0 0 1 15.818359 24 A 2.0000001 2.0000001 0 0 1 13.818359 26 L 11.855469 26 C 12.733975 31.300336 16.728783 35.519062 22 36.380859 L 22 19.853516 C 19.476954 19.006548 17.636719 16.611253 17.636719 13.818359 C 17.636719 10.327509 20.50915 7.4550781 24 7.4550781 z M 24 11.455078 C 22.670911 11.455078 21.636719 12.48927 21.636719 13.818359 C 21.636719 15.147449 22.670911 16.181641 24 16.181641 C 25.329089 16.181641 26.363281 15.147449 26.363281 13.818359 C 26.363281 12.48927 25.329089 11.455078 24 11.455078 z " />
|
||||
<g
|
||||
id="g1"
|
||||
transform="matrix(1.4545455,0,0,1.4545455,6.545454,6.545454)"
|
||||
style="display:none;fill:none;stroke:#ffffff;stroke-width:2.75;stroke-dasharray:none;stroke-opacity:1"
|
||||
inkscape:label="anchor_backup">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="5"
|
||||
id="circle1"
|
||||
r="3"
|
||||
style="fill:none;stroke:#ffffff;stroke-width:2.75;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<line
|
||||
x1="12"
|
||||
y1="22"
|
||||
x2="12"
|
||||
y2="8"
|
||||
id="line1"
|
||||
style="fill:none;stroke:#ffffff;stroke-width:2.75;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<path
|
||||
d="M 5,12 H 2 a 10,10 0 0 0 20,0 h -3"
|
||||
id="path1"
|
||||
style="fill:none;stroke:#ffffff;stroke-width:2.75;stroke-dasharray:none;stroke-opacity:1" />
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.6 KiB |
@@ -1,4 +1,5 @@
|
||||
//go:build windows && gui
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package platform
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//go:build linux || freebsd || openbsd
|
||||
//go:build !windows && !darwin
|
||||
// +build !windows,!darwin
|
||||
|
||||
package platform
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//go:build darwin
|
||||
// +build darwin
|
||||
|
||||
package platform
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package platform
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func IsAuthenticated(req *http.Request, username, password string) bool {
|
||||
@@ -23,12 +24,10 @@ func IsAuthenticated(req *http.Request, username, password string) bool {
|
||||
|
||||
func Authenticate(rw http.ResponseWriter, username, password, basepath string) {
|
||||
http.SetCookie(rw, &http.Cookie{
|
||||
Name: "auth",
|
||||
Value: username + ":" + secret(username, password),
|
||||
MaxAge: 604800, // 1 week
|
||||
Path: basepath,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Name: "auth",
|
||||
Value: username + ":" + secret(username, password),
|
||||
Expires: time.Now().Add(time.Hour * 24 * 7), // 1 week,
|
||||
Path: basepath,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
"github.com/nkanaev/yarr/src/assets"
|
||||
"github.com/nkanaev/yarr/src/server/router"
|
||||
"github.com/nkanaev/yarr/src/storage"
|
||||
)
|
||||
|
||||
type Middleware struct {
|
||||
@@ -14,7 +13,10 @@ type Middleware struct {
|
||||
Password string
|
||||
BasePath string
|
||||
Public []string
|
||||
DB storage.Storage
|
||||
}
|
||||
|
||||
func unsafeMethod(method string) bool {
|
||||
return method == "POST" || method == "PUT" || method == "DELETE"
|
||||
}
|
||||
|
||||
func (m *Middleware) Handler(c *router.Context) {
|
||||
@@ -44,16 +46,12 @@ func (m *Middleware) Handler(c *router.Context) {
|
||||
c.Redirect(rootUrl)
|
||||
return
|
||||
} else {
|
||||
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]any{
|
||||
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]string{
|
||||
"username": username,
|
||||
"hasError": true,
|
||||
"settings": m.DB.GetSettings().Map(),
|
||||
"error": "Invalid username/password",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]any{
|
||||
"hasError": false,
|
||||
"settings": m.DB.GetSettings().Map(),
|
||||
})
|
||||
c.HTML(http.StatusOK, assets.Template("login.html"), nil)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/nkanaev/yarr/src/server/auth"
|
||||
"github.com/nkanaev/yarr/src/server/router"
|
||||
"github.com/nkanaev/yarr/src/storage"
|
||||
"github.com/nkanaev/yarr/src/storage/model"
|
||||
)
|
||||
|
||||
type FeverGroup struct {
|
||||
@@ -54,17 +53,20 @@ type FeverFavicon struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
func writeFeverJSON(c *router.Context, data map[string]any, lastRefreshed int64) {
|
||||
func writeFeverJSON(c *router.Context, data map[string]interface{}, lastRefreshed int64) {
|
||||
data["api_version"] = 3
|
||||
data["auth"] = 1
|
||||
// TODO: remove duplicates
|
||||
data["last_refreshed_on_time"] = lastRefreshed
|
||||
c.JSON(http.StatusOK, data)
|
||||
}
|
||||
|
||||
func getLastRefreshedOnTime(feedStates []model.FeedState) int64 {
|
||||
func getLastRefreshedOnTime(httpStates map[int64]storage.HTTPState) int64 {
|
||||
if len(httpStates) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
var lastRefreshed int64
|
||||
for _, state := range feedStates {
|
||||
for _, state := range httpStates {
|
||||
if state.LastRefreshed.Unix() > lastRefreshed {
|
||||
lastRefreshed = state.LastRefreshed.Unix()
|
||||
}
|
||||
@@ -76,7 +78,7 @@ func (s *Server) feverAuth(c *router.Context) bool {
|
||||
if s.Username != "" && s.Password != "" {
|
||||
apiKey := c.Req.FormValue("api_key")
|
||||
apiKey = strings.ToLower(apiKey)
|
||||
md5HashValue := md5.Sum(fmt.Appendf(nil, "%s:%s", s.Username, s.Password))
|
||||
md5HashValue := md5.Sum([]byte(fmt.Sprintf("%s:%s", s.Username, s.Password)))
|
||||
hexMD5HashValue := fmt.Sprintf("%x", md5HashValue[:])
|
||||
if !auth.StringsEqual(apiKey, hexMD5HashValue) {
|
||||
return false
|
||||
@@ -95,7 +97,7 @@ func formHasValue(values url.Values, value string) bool {
|
||||
func (s *Server) handleFever(c *router.Context) {
|
||||
c.Req.ParseForm()
|
||||
if !s.feverAuth(c) {
|
||||
c.JSON(http.StatusOK, map[string]any{
|
||||
c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"api_version": 3,
|
||||
"auth": 0,
|
||||
"last_refreshed_on_time": 0,
|
||||
@@ -121,11 +123,10 @@ func (s *Server) handleFever(c *router.Context) {
|
||||
case formHasValue(c.Req.Form, "mark"):
|
||||
s.feverMarkHandler(c)
|
||||
default:
|
||||
states, _ := s.db.ListFeedStates()
|
||||
c.JSON(http.StatusOK, map[string]any{
|
||||
c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"api_version": 3,
|
||||
"auth": 1,
|
||||
"last_refreshed_on_time": getLastRefreshedOnTime(states),
|
||||
"last_refreshed_on_time": getLastRefreshedOnTime(s.db.ListHTTPStates()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -141,7 +142,7 @@ func joinInts(values []int64) string {
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func feedGroups(db storage.Storage) []*FeverFeedsGroup {
|
||||
func feedGroups(db *storage.Storage) []*FeverFeedsGroup {
|
||||
feeds := db.ListFeeds()
|
||||
|
||||
groupFeeds := make(map[int64][]int64)
|
||||
@@ -167,25 +168,20 @@ func (s *Server) feverGroupsHandler(c *router.Context) {
|
||||
for i, folder := range folders {
|
||||
groups[i] = &FeverGroup{ID: folder.Id, Title: folder.Title}
|
||||
}
|
||||
states, _ := s.db.ListFeedStates()
|
||||
writeFeverJSON(c, map[string]any{
|
||||
writeFeverJSON(c, map[string]interface{}{
|
||||
"groups": groups,
|
||||
"feeds_groups": feedGroups(s.db),
|
||||
}, getLastRefreshedOnTime(states))
|
||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
||||
}
|
||||
|
||||
func (s *Server) feverFeedsHandler(c *router.Context) {
|
||||
feeds := s.db.ListFeeds()
|
||||
states, _ := s.db.ListFeedStates()
|
||||
statesMap := make(map[int64]model.FeedState)
|
||||
for _, state := range states {
|
||||
statesMap[state.FeedID] = state
|
||||
}
|
||||
httpStates := s.db.ListHTTPStates()
|
||||
|
||||
feverFeeds := make([]*FeverFeed, len(feeds))
|
||||
for i, feed := range feeds {
|
||||
var lastUpdated int64
|
||||
if state, ok := statesMap[feed.Id]; ok {
|
||||
if state, ok := httpStates[feed.Id]; ok {
|
||||
lastUpdated = state.LastRefreshed.Unix()
|
||||
}
|
||||
feverFeeds[i] = &FeverFeed{
|
||||
@@ -198,10 +194,10 @@ func (s *Server) feverFeedsHandler(c *router.Context) {
|
||||
LastUpdated: lastUpdated,
|
||||
}
|
||||
}
|
||||
writeFeverJSON(c, map[string]any{
|
||||
writeFeverJSON(c, map[string]interface{}{
|
||||
"feeds": feverFeeds,
|
||||
"feeds_groups": feedGroups(s.db),
|
||||
}, getLastRefreshedOnTime(states))
|
||||
}, getLastRefreshedOnTime(httpStates))
|
||||
}
|
||||
|
||||
func (s *Server) feverFaviconsHandler(c *router.Context) {
|
||||
@@ -220,10 +216,9 @@ func (s *Server) feverFaviconsHandler(c *router.Context) {
|
||||
favicons[i] = &FeverFavicon{ID: feed.Id, Data: data}
|
||||
}
|
||||
|
||||
states, _ := s.db.ListFeedStates()
|
||||
writeFeverJSON(c, map[string]any{
|
||||
writeFeverJSON(c, map[string]interface{}{
|
||||
"favicons": favicons,
|
||||
}, getLastRefreshedOnTime(states))
|
||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
||||
}
|
||||
|
||||
// for memory pressure reasons, we only return a limited number of items
|
||||
@@ -231,7 +226,7 @@ func (s *Server) feverFaviconsHandler(c *router.Context) {
|
||||
const listLimit = 50
|
||||
|
||||
func (s *Server) feverItemsHandler(c *router.Context) {
|
||||
filter := model.ItemFilter{}
|
||||
filter := storage.ItemFilter{}
|
||||
query := c.Req.URL.Query()
|
||||
|
||||
switch {
|
||||
@@ -263,11 +258,11 @@ func (s *Server) feverItemsHandler(c *router.Context) {
|
||||
time := date.Unix()
|
||||
|
||||
isSaved := 0
|
||||
if item.Status == model.STARRED {
|
||||
if item.Status == storage.STARRED {
|
||||
isSaved = 1
|
||||
}
|
||||
isRead := 0
|
||||
if item.Status == model.READ {
|
||||
if item.Status == storage.READ {
|
||||
isRead = 1
|
||||
}
|
||||
feverItems[i] = FeverItem{
|
||||
@@ -283,27 +278,25 @@ func (s *Server) feverItemsHandler(c *router.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
totalItems := s.db.CountItems()
|
||||
totalItems := s.db.CountItems(storage.ItemFilter{})
|
||||
|
||||
states, _ := s.db.ListFeedStates()
|
||||
writeFeverJSON(c, map[string]any{
|
||||
writeFeverJSON(c, map[string]interface{}{
|
||||
"items": feverItems,
|
||||
"total_items": totalItems,
|
||||
}, getLastRefreshedOnTime(states))
|
||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
||||
}
|
||||
|
||||
func (s *Server) feverLinksHandler(c *router.Context) {
|
||||
states, _ := s.db.ListFeedStates()
|
||||
writeFeverJSON(c, map[string]any{
|
||||
"links": make([]any, 0),
|
||||
}, getLastRefreshedOnTime(states))
|
||||
writeFeverJSON(c, map[string]interface{}{
|
||||
"links": make([]interface{}, 0),
|
||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
||||
}
|
||||
|
||||
func (s *Server) feverUnreadItemIDsHandler(c *router.Context) {
|
||||
status := model.UNREAD
|
||||
status := storage.UNREAD
|
||||
itemIds := make([]int64, 0)
|
||||
|
||||
itemFilter := model.ItemFilter{
|
||||
itemFilter := storage.ItemFilter{
|
||||
Status: &status,
|
||||
}
|
||||
for {
|
||||
@@ -316,17 +309,16 @@ func (s *Server) feverUnreadItemIDsHandler(c *router.Context) {
|
||||
}
|
||||
itemFilter.After = &items[len(items)-1].Id
|
||||
}
|
||||
states, _ := s.db.ListFeedStates()
|
||||
writeFeverJSON(c, map[string]any{
|
||||
writeFeverJSON(c, map[string]interface{}{
|
||||
"unread_item_ids": joinInts(itemIds),
|
||||
}, getLastRefreshedOnTime(states))
|
||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
||||
}
|
||||
|
||||
func (s *Server) feverSavedItemIDsHandler(c *router.Context) {
|
||||
status := model.STARRED
|
||||
status := storage.STARRED
|
||||
itemIds := make([]int64, 0)
|
||||
|
||||
itemFilter := model.ItemFilter{
|
||||
itemFilter := storage.ItemFilter{
|
||||
Status: &status,
|
||||
}
|
||||
for {
|
||||
@@ -339,10 +331,9 @@ func (s *Server) feverSavedItemIDsHandler(c *router.Context) {
|
||||
}
|
||||
itemFilter.After = &items[len(items)-1].Id
|
||||
}
|
||||
states, _ := s.db.ListFeedStates()
|
||||
writeFeverJSON(c, map[string]any{
|
||||
writeFeverJSON(c, map[string]interface{}{
|
||||
"saved_item_ids": joinInts(itemIds),
|
||||
}, getLastRefreshedOnTime(states))
|
||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
||||
}
|
||||
|
||||
func (s *Server) feverMarkHandler(c *router.Context) {
|
||||
@@ -354,16 +345,16 @@ func (s *Server) feverMarkHandler(c *router.Context) {
|
||||
|
||||
switch c.Req.Form.Get("mark") {
|
||||
case "item":
|
||||
var status model.ItemStatus
|
||||
var status storage.ItemStatus
|
||||
switch c.Req.Form.Get("as") {
|
||||
case "read":
|
||||
status = model.READ
|
||||
status = storage.READ
|
||||
case "unread":
|
||||
status = model.UNREAD
|
||||
status = storage.UNREAD
|
||||
case "saved":
|
||||
status = model.STARRED
|
||||
status = storage.STARRED
|
||||
case "unsaved":
|
||||
status = model.READ
|
||||
status = storage.READ
|
||||
default:
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
@@ -373,10 +364,10 @@ func (s *Server) feverMarkHandler(c *router.Context) {
|
||||
if c.Req.Form.Get("as") != "read" {
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
markFilter := model.MarkFilter{FeedID: &id}
|
||||
markFilter := storage.MarkFilter{FeedID: &id}
|
||||
x, _ := strconv.ParseInt(c.Req.Form.Get("before"), 10, 64)
|
||||
if x > 0 {
|
||||
before := time.Unix(x, 0).UTC()
|
||||
before := time.Unix(x, 0)
|
||||
markFilter.Before = &before
|
||||
}
|
||||
s.db.MarkItemsRead(markFilter)
|
||||
@@ -384,13 +375,10 @@ func (s *Server) feverMarkHandler(c *router.Context) {
|
||||
if c.Req.Form.Get("as") != "read" {
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
markFilter := model.MarkFilter{}
|
||||
if id > 0 {
|
||||
markFilter.FolderID = &id
|
||||
}
|
||||
markFilter := storage.MarkFilter{FolderID: &id}
|
||||
x, _ := strconv.ParseInt(c.Req.Form.Get("before"), 10, 64)
|
||||
if x > 0 {
|
||||
before := time.Unix(x, 0).UTC()
|
||||
before := time.Unix(x, 0)
|
||||
markFilter.Before = &before
|
||||
}
|
||||
s.db.MarkItemsRead(markFilter)
|
||||
@@ -398,7 +386,7 @@ func (s *Server) feverMarkHandler(c *router.Context) {
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, map[string]any{
|
||||
c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"api_version": 3,
|
||||
"auth": 1,
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package server
|
||||
|
||||
import "github.com/nkanaev/yarr/src/storage/model"
|
||||
import "github.com/nkanaev/yarr/src/storage"
|
||||
|
||||
type ItemUpdateForm struct {
|
||||
Status *model.ItemStatus `json:"status,omitempty"`
|
||||
Status *storage.ItemStatus `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type FolderCreateForm struct {
|
||||
|
||||
@@ -78,11 +78,7 @@ func TestParseFallback(t *testing.T) {
|
||||
Folders: []Folder{{
|
||||
Title: "foldertitle",
|
||||
Feeds: []Feed{
|
||||
{
|
||||
Title: "feedtext",
|
||||
FeedUrl: "https://example.com/feed.xml",
|
||||
SiteUrl: "https://example.com",
|
||||
},
|
||||
{Title: "feedtext", FeedUrl: "https://example.com/feed.xml", SiteUrl: "https://example.com"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func (c *Context) Next() {
|
||||
c.chain[c.index](c)
|
||||
}
|
||||
|
||||
func (c *Context) JSON(status int, data any) {
|
||||
func (c *Context) JSON(status int, data interface{}) {
|
||||
body, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -35,7 +35,7 @@ func (c *Context) JSON(status int, data any) {
|
||||
c.Out.Write([]byte("\n"))
|
||||
}
|
||||
|
||||
func (c *Context) HTML(status int, tmpl *template.Template, data any) {
|
||||
func (c *Context) HTML(status int, tmpl *template.Template, data interface{}) {
|
||||
c.Out.Header().Set("Content-Type", "text/html")
|
||||
c.Out.WriteHeader(status)
|
||||
tmpl.Execute(c.Out, data)
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nkanaev/yarr/src/assets"
|
||||
"github.com/nkanaev/yarr/src/content/htmlutil"
|
||||
@@ -21,7 +20,7 @@ import (
|
||||
"github.com/nkanaev/yarr/src/server/gzip"
|
||||
"github.com/nkanaev/yarr/src/server/opml"
|
||||
"github.com/nkanaev/yarr/src/server/router"
|
||||
"github.com/nkanaev/yarr/src/storage/model"
|
||||
"github.com/nkanaev/yarr/src/storage"
|
||||
"github.com/nkanaev/yarr/src/worker"
|
||||
)
|
||||
|
||||
@@ -35,8 +34,7 @@ func (s *Server) handler() http.Handler {
|
||||
BasePath: s.BasePath,
|
||||
Username: s.Username,
|
||||
Password: s.Password,
|
||||
Public: []string{"/static", "/fever", "/manifest.json"},
|
||||
DB: s.db,
|
||||
Public: []string{"/static", "/fever"},
|
||||
}
|
||||
r.Use(a.Handler)
|
||||
}
|
||||
@@ -65,8 +63,8 @@ func (s *Server) handler() http.Handler {
|
||||
}
|
||||
|
||||
func (s *Server) handleIndex(c *router.Context) {
|
||||
c.HTML(http.StatusOK, assets.Template("index.html"), map[string]any{
|
||||
"settings": s.db.GetSettings().Map(),
|
||||
c.HTML(http.StatusOK, assets.Template("index.html"), map[string]interface{}{
|
||||
"settings": s.db.GetSettings(),
|
||||
"authenticated": s.Username != "" && s.Password != "",
|
||||
})
|
||||
}
|
||||
@@ -78,19 +76,18 @@ func (s *Server) handleStatic(c *router.Context) {
|
||||
c.Out.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.StripPrefix(s.BasePath+"/static/", http.FileServer(http.FS(assets.FS))).
|
||||
ServeHTTP(c.Out, c.Req)
|
||||
http.StripPrefix(s.BasePath+"/static/", http.FileServer(http.FS(assets.FS))).ServeHTTP(c.Out, c.Req)
|
||||
}
|
||||
|
||||
func (s *Server) handleManifest(c *router.Context) {
|
||||
c.JSON(http.StatusOK, map[string]any{
|
||||
c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"$schema": "https://json.schemastore.org/web-manifest-combined.json",
|
||||
"name": "yarr!",
|
||||
"short_name": "yarr",
|
||||
"description": "yet another rss reader",
|
||||
"display": "standalone",
|
||||
"start_url": "/" + strings.TrimPrefix(s.BasePath, "/"),
|
||||
"icons": []map[string]any{
|
||||
"start_url": s.BasePath,
|
||||
"icons": []map[string]interface{}{
|
||||
{
|
||||
"src": s.BasePath + "/static/graphicarts/favicon.png",
|
||||
"sizes": "64x64",
|
||||
@@ -101,7 +98,7 @@ func (s *Server) handleManifest(c *router.Context) {
|
||||
}
|
||||
|
||||
func (s *Server) handleStatus(c *router.Context) {
|
||||
c.JSON(http.StatusOK, map[string]any{
|
||||
c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"running": s.worker.FeedsPending(),
|
||||
"stats": s.db.FeedStats(),
|
||||
})
|
||||
@@ -142,10 +139,12 @@ func (s *Server) handleFolder(c *router.Context) {
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.db.UpdateFolder(id, model.UpdateFolderParams{
|
||||
Title: body.Title,
|
||||
IsExpanded: body.IsExpanded,
|
||||
})
|
||||
if body.Title != nil {
|
||||
s.db.RenameFolder(id, *body.Title)
|
||||
}
|
||||
if body.IsExpanded != nil {
|
||||
s.db.ToggleFolderExpanded(id, *body.IsExpanded)
|
||||
}
|
||||
c.Out.WriteHeader(http.StatusOK)
|
||||
} else if c.Req.Method == "DELETE" {
|
||||
s.db.DeleteFolder(id)
|
||||
@@ -163,15 +162,7 @@ func (s *Server) handleFeedRefresh(c *router.Context) {
|
||||
}
|
||||
|
||||
func (s *Server) handleFeedErrors(c *router.Context) {
|
||||
errors := make(map[int64]string)
|
||||
states, err := s.db.ListFeedStates()
|
||||
if err == nil {
|
||||
for _, state := range states {
|
||||
if state.LastError != "" {
|
||||
errors[state.FeedID] = state.LastError
|
||||
}
|
||||
}
|
||||
}
|
||||
errors := s.db.GetFeedErrors()
|
||||
c.JSON(http.StatusOK, errors)
|
||||
}
|
||||
|
||||
@@ -244,24 +235,24 @@ func (s *Server) handleFeedList(c *router.Context) {
|
||||
log.Printf("Faild to discover feed for %s: %s", form.Url, err)
|
||||
c.JSON(http.StatusOK, map[string]string{"status": "notfound"})
|
||||
case len(result.Sources) > 0:
|
||||
c.JSON(
|
||||
http.StatusOK,
|
||||
map[string]any{"status": "multiple", "choice": result.Sources},
|
||||
)
|
||||
c.JSON(http.StatusOK, map[string]interface{}{"status": "multiple", "choice": result.Sources})
|
||||
case result.Feed != nil:
|
||||
feed := s.db.CreateFeed(model.CreateFeedParams{
|
||||
Title: result.Feed.Title,
|
||||
Link: result.Feed.SiteURL,
|
||||
FeedLink: result.FeedLink,
|
||||
FolderID: form.FolderID,
|
||||
})
|
||||
feed := s.db.CreateFeed(
|
||||
result.Feed.Title,
|
||||
"",
|
||||
result.Feed.SiteURL,
|
||||
result.FeedLink,
|
||||
form.FolderID,
|
||||
)
|
||||
items := worker.ConvertItems(result.Feed.Items, *feed)
|
||||
if len(items) > 0 {
|
||||
s.db.CreateItems(items)
|
||||
s.db.SetFeedSize(feed.Id, len(items))
|
||||
s.db.SyncSearch()
|
||||
}
|
||||
s.worker.FindFeedFavicon(*feed)
|
||||
|
||||
c.JSON(http.StatusOK, map[string]any{
|
||||
c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"status": "success",
|
||||
"feed": feed,
|
||||
})
|
||||
@@ -283,34 +274,25 @@ func (s *Server) handleFeed(c *router.Context) {
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
body := make(map[string]any)
|
||||
body := make(map[string]interface{})
|
||||
if err := json.NewDecoder(c.Req.Body).Decode(&body); err != nil {
|
||||
log.Print(err)
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
params := model.UpdateFeedParams{}
|
||||
if title, ok := body["title"]; ok {
|
||||
if reflect.TypeOf(title).Kind() == reflect.String {
|
||||
t := title.(string)
|
||||
params.Title = &t
|
||||
s.db.RenameFeed(id, title.(string))
|
||||
}
|
||||
}
|
||||
if f_id, ok := body["folder_id"]; ok {
|
||||
if f_id == nil {
|
||||
params.FolderID = model.SetNullable[int64](nil)
|
||||
s.db.UpdateFeedFolder(id, nil)
|
||||
} else if reflect.TypeOf(f_id).Kind() == reflect.Float64 {
|
||||
folderId := int64(f_id.(float64))
|
||||
params.FolderID = model.SetNullable(&folderId)
|
||||
s.db.UpdateFeedFolder(id, &folderId)
|
||||
}
|
||||
}
|
||||
if link, ok := body["feed_link"]; ok {
|
||||
if reflect.TypeOf(link).Kind() == reflect.String {
|
||||
l := link.(string)
|
||||
params.FeedLink = &l
|
||||
}
|
||||
}
|
||||
s.db.UpdateFeed(id, params)
|
||||
c.Out.WriteHeader(http.StatusOK)
|
||||
} else if c.Req.Method == "DELETE" {
|
||||
s.db.DeleteFeed(id)
|
||||
@@ -341,9 +323,6 @@ func (s *Server) handleItem(c *router.Context) {
|
||||
}
|
||||
|
||||
item.Content = sanitizer.Sanitize(item.Link, item.Content)
|
||||
for i, link := range item.MediaLinks {
|
||||
item.MediaLinks[i].Description = sanitizer.Sanitize(item.Link, link.Description)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, item)
|
||||
} else if c.Req.Method == "PUT" {
|
||||
@@ -367,7 +346,7 @@ func (s *Server) handleItemList(c *router.Context) {
|
||||
perPage := 20
|
||||
query := c.Req.URL.Query()
|
||||
|
||||
filter := model.ItemFilter{}
|
||||
filter := storage.ItemFilter{}
|
||||
if folderID, err := c.QueryInt64("folder_id"); err == nil {
|
||||
filter.FolderID = &folderID
|
||||
}
|
||||
@@ -378,7 +357,7 @@ func (s *Server) handleItemList(c *router.Context) {
|
||||
filter.After = &after
|
||||
}
|
||||
if status := query.Get("status"); len(status) != 0 {
|
||||
statusValue := model.StatusValues[status]
|
||||
statusValue := storage.StatusValues[status]
|
||||
filter.Status = &statusValue
|
||||
}
|
||||
if search := query.Get("search"); len(search) != 0 {
|
||||
@@ -386,25 +365,18 @@ func (s *Server) handleItemList(c *router.Context) {
|
||||
}
|
||||
newestFirst := query.Get("oldest_first") != "true"
|
||||
|
||||
items := s.db.ListItems(filter, perPage+1, newestFirst, true)
|
||||
items := s.db.ListItems(filter, perPage+1, newestFirst, false)
|
||||
hasMore := false
|
||||
if len(items) == perPage+1 {
|
||||
hasMore = true
|
||||
items = items[:perPage]
|
||||
}
|
||||
|
||||
for i, item := range items {
|
||||
if item.Title == "" {
|
||||
text := htmlutil.ExtractText(item.Content)
|
||||
items[i].Title = htmlutil.TruncateText(text, 140)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, map[string]any{
|
||||
c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"list": items,
|
||||
"has_more": hasMore,
|
||||
})
|
||||
} else if c.Req.Method == "PUT" {
|
||||
filter := model.MarkFilter{}
|
||||
filter := storage.MarkFilter{}
|
||||
|
||||
if folderID, err := c.QueryInt64("folder_id"); err == nil {
|
||||
filter.FolderID = &folderID
|
||||
@@ -423,14 +395,14 @@ func (s *Server) handleSettings(c *router.Context) {
|
||||
if c.Req.Method == "GET" {
|
||||
c.JSON(http.StatusOK, s.db.GetSettings())
|
||||
} else if c.Req.Method == "PUT" {
|
||||
var params model.UpdateSettingsParams
|
||||
if err := json.NewDecoder(c.Req.Body).Decode(¶ms); err != nil {
|
||||
settings := make(map[string]interface{})
|
||||
if err := json.NewDecoder(c.Req.Body).Decode(&settings); err != nil {
|
||||
c.Out.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if s.db.UpdateSettings(params) {
|
||||
if params.RefreshRate != nil {
|
||||
s.worker.SetRefreshRate(s.db.GetSettings().RefreshRate)
|
||||
if s.db.UpdateSettings(settings) {
|
||||
if _, ok := settings["refresh_rate"]; ok {
|
||||
s.worker.SetRefreshRate(s.db.GetSettingsValueInt64("refresh_rate"))
|
||||
}
|
||||
c.Out.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
@@ -453,24 +425,16 @@ func (s *Server) handleOPMLImport(c *router.Context) {
|
||||
return
|
||||
}
|
||||
for _, f := range doc.Feeds {
|
||||
s.db.CreateFeed(model.CreateFeedParams{
|
||||
Title: f.Title,
|
||||
Link: f.SiteUrl,
|
||||
FeedLink: f.FeedUrl,
|
||||
})
|
||||
s.db.CreateFeed(f.Title, "", f.SiteUrl, f.FeedUrl, nil)
|
||||
}
|
||||
for _, f := range doc.Folders {
|
||||
folder := s.db.CreateFolder(f.Title)
|
||||
for _, ff := range f.AllFeeds() {
|
||||
s.db.CreateFeed(model.CreateFeedParams{
|
||||
Title: ff.Title,
|
||||
Link: ff.SiteUrl,
|
||||
FeedLink: ff.FeedUrl,
|
||||
FolderID: &folder.Id,
|
||||
})
|
||||
s.db.CreateFeed(ff.Title, "", ff.SiteUrl, ff.FeedUrl, &folder.Id)
|
||||
}
|
||||
}
|
||||
|
||||
s.worker.FindFavicons()
|
||||
s.worker.RefreshFeeds()
|
||||
|
||||
c.Out.WriteHeader(http.StatusOK)
|
||||
@@ -481,14 +445,14 @@ func (s *Server) handleOPMLImport(c *router.Context) {
|
||||
|
||||
func (s *Server) handleOPMLExport(c *router.Context) {
|
||||
if c.Req.Method == "GET" {
|
||||
filename := fmt.Sprintf("subscriptions_%s.opml", time.Now().Format("2006-01-02_15-04-05"))
|
||||
c.Out.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
c.Out.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
c.Out.Header().Set("Content-Disposition", `attachment; filename="subscriptions.opml"`)
|
||||
|
||||
doc := opml.Folder{}
|
||||
|
||||
feedsByFolderID := make(map[int64][]*model.Feed)
|
||||
feedsByFolderID := make(map[int64][]*storage.Feed)
|
||||
for _, feed := range s.db.ListFeeds() {
|
||||
feed := feed
|
||||
if feed.FolderId == nil {
|
||||
doc.Feeds = append(doc.Feeds, opml.Feed{
|
||||
Title: feed.Title,
|
||||
@@ -533,10 +497,6 @@ func (s *Server) handlePageCrawl(c *router.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if isInternalFromURL(url) {
|
||||
log.Printf("attempt to access internal IP %s from %s", url, c.Req.RemoteAddr)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := worker.GetBody(url)
|
||||
if err != nil {
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/nkanaev/yarr/src/storage"
|
||||
"github.com/nkanaev/yarr/src/storage/model"
|
||||
)
|
||||
|
||||
func TestStatic(t *testing.T) {
|
||||
@@ -80,8 +79,8 @@ func TestFeedIcons(t *testing.T) {
|
||||
log.SetOutput(io.Discard)
|
||||
db, _ := storage.New(":memory:")
|
||||
icon := []byte("test")
|
||||
feed := db.CreateFeed(model.CreateFeedParams{})
|
||||
db.UpdateFeed(feed.Id, model.UpdateFeedParams{Icon: model.SetNullable(&icon)})
|
||||
feed := db.CreateFeed("", "", "", "", nil)
|
||||
db.UpdateFeedIcon(feed.Id, &icon)
|
||||
log.SetOutput(os.Stderr)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
@@ -2,10 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/nkanaev/yarr/src/storage"
|
||||
@@ -14,9 +11,9 @@ import (
|
||||
|
||||
type Server struct {
|
||||
Addr string
|
||||
db storage.Storage
|
||||
db *storage.Storage
|
||||
worker *worker.Worker
|
||||
cache map[string]any
|
||||
cache map[string]interface{}
|
||||
cache_mutex *sync.Mutex
|
||||
|
||||
BasePath string
|
||||
@@ -29,12 +26,12 @@ type Server struct {
|
||||
KeyFile string
|
||||
}
|
||||
|
||||
func NewServer(db storage.Storage, addr string) *Server {
|
||||
func NewServer(db *storage.Storage, addr string) *Server {
|
||||
return &Server{
|
||||
db: db,
|
||||
Addr: addr,
|
||||
worker: worker.NewWorker(db),
|
||||
cache: make(map[string]any),
|
||||
cache: make(map[string]interface{}),
|
||||
cache_mutex: &sync.Mutex{},
|
||||
}
|
||||
}
|
||||
@@ -48,38 +45,22 @@ func (h *Server) GetAddr() string {
|
||||
}
|
||||
|
||||
func (s *Server) Start() {
|
||||
refreshRate := s.db.GetSettings().RefreshRate
|
||||
refreshRate := s.db.GetSettingsValueInt64("refresh_rate")
|
||||
s.worker.FindFavicons()
|
||||
s.worker.StartFeedCleaner()
|
||||
s.worker.SetRefreshRate(refreshRate)
|
||||
if refreshRate > 0 {
|
||||
s.worker.RefreshFeeds()
|
||||
}
|
||||
|
||||
var ln net.Listener
|
||||
httpserver := &http.Server{Addr: s.Addr, Handler: s.handler()}
|
||||
|
||||
var err error
|
||||
|
||||
if path, isUnix := strings.CutPrefix(s.Addr, "unix:"); isUnix {
|
||||
err = os.Remove(path)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
}
|
||||
ln, err = net.Listen("unix", path)
|
||||
} else {
|
||||
ln, err = net.Listen("tcp", s.Addr)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
httpserver := &http.Server{Handler: s.handler()}
|
||||
if s.CertFile != "" && s.KeyFile != "" {
|
||||
err = httpserver.ServeTLS(ln, s.CertFile, s.KeyFile)
|
||||
ln.Close()
|
||||
err = httpserver.ListenAndServeTLS(s.CertFile, s.KeyFile)
|
||||
} else {
|
||||
err = httpserver.Serve(ln)
|
||||
err = httpserver.ListenAndServe()
|
||||
}
|
||||
|
||||
if err != http.ErrServerClosed {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func isInternalFromURL(urlStr string) bool {
|
||||
parsedURL, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
host := parsedURL.Host
|
||||
|
||||
// Handle "host:port" format
|
||||
if strings.Contains(host, ":") {
|
||||
host, _, err = net.SplitHostPort(host)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast()
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package server
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsInternalFromURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
url string
|
||||
expected bool
|
||||
}{
|
||||
{"http://192.168.1.1:8080", true},
|
||||
{"http://10.0.0.5", true},
|
||||
{"http://172.16.0.1", true},
|
||||
{"http://172.31.255.255", true},
|
||||
{"http://172.32.0.1", false}, // outside private range
|
||||
{"http://127.0.0.1", true},
|
||||
{"http://127.0.0.1:7000", true},
|
||||
{"http://127.0.0.1:7000/secret", true},
|
||||
{"http://169.254.0.5", true},
|
||||
{"http://localhost", true}, // resolves to 127.0.0.1
|
||||
{"http://8.8.8.8", false},
|
||||
{"http://google.com", false}, // resolves to public IPs
|
||||
{"invalid-url", false}, // invalid format
|
||||
{"", false}, // empty string
|
||||
}
|
||||
for _, test := range tests {
|
||||
result := isInternalFromURL(test.url)
|
||||
if result != test.expected {
|
||||
t.Errorf("isInternalFromURL(%q) = %v; want %v", test.url, result, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
209
src/storage/feed.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
)
|
||||
|
||||
type Feed struct {
|
||||
Id int64 `json:"id"`
|
||||
FolderId *int64 `json:"folder_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Link string `json:"link"`
|
||||
FeedLink string `json:"feed_link"`
|
||||
Icon *[]byte `json:"icon,omitempty"`
|
||||
HasIcon bool `json:"has_icon"`
|
||||
}
|
||||
|
||||
func (s *Storage) CreateFeed(title, description, link, feedLink string, folderId *int64) *Feed {
|
||||
if title == "" {
|
||||
title = feedLink
|
||||
}
|
||||
row := s.db.QueryRow(`
|
||||
insert into feeds (title, description, link, feed_link, folder_id)
|
||||
values (?, ?, ?, ?, ?)
|
||||
on conflict (feed_link) do update set folder_id = ?
|
||||
returning id`,
|
||||
title, description, link, feedLink, folderId,
|
||||
folderId,
|
||||
)
|
||||
|
||||
var id int64
|
||||
err := row.Scan(&id)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return nil
|
||||
}
|
||||
return &Feed{
|
||||
Id: id,
|
||||
Title: title,
|
||||
Description: description,
|
||||
Link: link,
|
||||
FeedLink: feedLink,
|
||||
FolderId: folderId,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Storage) DeleteFeed(feedId int64) bool {
|
||||
result, err := s.db.Exec(`delete from feeds where id = ?`, feedId)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return false
|
||||
}
|
||||
nrows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
if err != sql.ErrNoRows {
|
||||
log.Print(err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return nrows == 1
|
||||
}
|
||||
|
||||
func (s *Storage) RenameFeed(feedId int64, newTitle string) bool {
|
||||
_, err := s.db.Exec(`update feeds set title = ? where id = ?`, newTitle, feedId)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Storage) UpdateFeedFolder(feedId int64, newFolderId *int64) bool {
|
||||
_, err := s.db.Exec(`update feeds set folder_id = ? where id = ?`, newFolderId, feedId)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Storage) UpdateFeedIcon(feedId int64, icon *[]byte) bool {
|
||||
_, err := s.db.Exec(`update feeds set icon = ? where id = ?`, icon, feedId)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Storage) ListFeeds() []Feed {
|
||||
result := make([]Feed, 0)
|
||||
rows, err := s.db.Query(`
|
||||
select id, folder_id, title, description, link, feed_link,
|
||||
ifnull(length(icon), 0) > 0 as has_icon
|
||||
from feeds
|
||||
order by title collate nocase
|
||||
`)
|
||||
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,
|
||||
&f.HasIcon,
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return result
|
||||
}
|
||||
result = append(result, f)
|
||||
}
|
||||
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(`
|
||||
select
|
||||
id, folder_id, title, link, feed_link,
|
||||
icon, ifnull(icon, '') != '' as has_icon
|
||||
from feeds where id = ?
|
||||
`, id).Scan(
|
||||
&f.Id, &f.FolderId, &f.Title, &f.Link, &f.FeedLink,
|
||||
&f.Icon, &f.HasIcon,
|
||||
)
|
||||
if err != nil {
|
||||
if err != sql.ErrNoRows {
|
||||
log.Print(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return &f
|
||||
}
|
||||
|
||||
func (s *Storage) ResetFeedErrors() {
|
||||
if _, err := s.db.Exec(`delete from feed_errors`); err != nil {
|
||||
log.Print(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Storage) SetFeedError(feedID int64, lastError error) {
|
||||
_, err := s.db.Exec(`
|
||||
insert into feed_errors (feed_id, error)
|
||||
values (?, ?)
|
||||
on conflict (feed_id) do update set error = excluded.error`,
|
||||
feedID, lastError.Error(),
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Storage) GetFeedErrors() map[int64]string {
|
||||
errors := make(map[int64]string)
|
||||
|
||||
rows, err := s.db.Query(`select feed_id, error from feed_errors`)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return errors
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var error string
|
||||
if err = rows.Scan(&id, &error); err != nil {
|
||||
log.Print(err)
|
||||
}
|
||||
errors[id] = error
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
87
src/storage/feed_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateFeed(t *testing.T) {
|
||||
db := testDB()
|
||||
feed1 := db.CreateFeed("title", "", "http://example.com", "http://example.com/feed.xml", nil)
|
||||
if feed1 == nil || feed1.Id == 0 {
|
||||
t.Fatal("expected feed")
|
||||
}
|
||||
feed2 := db.GetFeed(feed1.Id)
|
||||
if feed2 == nil || !reflect.DeepEqual(feed1, feed2) {
|
||||
t.Fatal("invalid feed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFeedSameLink(t *testing.T) {
|
||||
db := testDB()
|
||||
feed1 := db.CreateFeed("title", "", "", "http://example1.com/feed.xml", nil)
|
||||
if feed1 == nil || feed1.Id == 0 {
|
||||
t.Fatal("expected feed")
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
db.CreateFeed("title", "", "", "http://example2.com/feed.xml", nil)
|
||||
}
|
||||
|
||||
feed2 := db.CreateFeed("title", "", "http://example.com", "http://example1.com/feed.xml", nil)
|
||||
if feed1.Id != feed2.Id {
|
||||
t.Fatalf("expected the same feed.\nwant: %#v\nhave: %#v", feed1, feed2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFeed(t *testing.T) {
|
||||
db := testDB()
|
||||
if db.GetFeed(100500) != nil {
|
||||
t.Fatal("cannot get nonexistent feed")
|
||||
}
|
||||
|
||||
feed1 := db.CreateFeed("feed 1", "", "http://example1.com", "http://example1.com/feed.xml", nil)
|
||||
feed2 := db.CreateFeed("feed 2", "", "http://example2.com", "http://example2.com/feed.xml", nil)
|
||||
feeds := db.ListFeeds()
|
||||
if !reflect.DeepEqual(feeds, []Feed{*feed1, *feed2}) {
|
||||
t.Fatalf("invalid feed list: %#v", feeds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeed(t *testing.T) {
|
||||
db := testDB()
|
||||
feed1 := db.CreateFeed("feed 1", "", "http://example1.com", "http://example1.com/feed.xml", nil)
|
||||
folder := db.CreateFolder("test")
|
||||
icon := []byte("icon")
|
||||
|
||||
db.RenameFeed(feed1.Id, "newtitle")
|
||||
db.UpdateFeedFolder(feed1.Id, &folder.Id)
|
||||
db.UpdateFeedIcon(feed1.Id, &icon)
|
||||
|
||||
feed2 := db.GetFeed(feed1.Id)
|
||||
if feed2.Title != "newtitle" {
|
||||
t.Error("invalid title")
|
||||
}
|
||||
if feed2.FolderId == nil || *feed2.FolderId != folder.Id {
|
||||
t.Error("invalid folder")
|
||||
}
|
||||
if !feed2.HasIcon || string(*feed2.Icon) != "icon" {
|
||||
t.Error("invalid icon")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFeed(t *testing.T) {
|
||||
db := testDB()
|
||||
feed1 := db.CreateFeed("title", "", "http://example.com", "http://example.com/feed.xml", nil)
|
||||
|
||||
if db.DeleteFeed(100500) {
|
||||
t.Error("cannot delete what does not exist")
|
||||
}
|
||||
|
||||
if !db.DeleteFeed(feed1.Id) {
|
||||
t.Fatal("did not delete existing feed")
|
||||
}
|
||||
if db.GetFeed(feed1.Id) != nil {
|
||||
t.Fatal("feed still exists")
|
||||
}
|
||||
}
|
||||
72
src/storage/folder.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"log"
|
||||
)
|
||||
|
||||
type Folder struct {
|
||||
Id int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
IsExpanded bool `json:"is_expanded"`
|
||||
}
|
||||
|
||||
func (s *Storage) CreateFolder(title string) *Folder {
|
||||
expanded := true
|
||||
row := s.db.QueryRow(`
|
||||
insert into folders (title, is_expanded) values (?, ?)
|
||||
on conflict (title) do update set title = ?
|
||||
returning id`,
|
||||
title, expanded,
|
||||
// provide title again so that we can extract row id
|
||||
title,
|
||||
)
|
||||
var id int64
|
||||
err := row.Scan(&id)
|
||||
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return nil
|
||||
}
|
||||
return &Folder{Id: id, Title: title, IsExpanded: expanded}
|
||||
}
|
||||
|
||||
func (s *Storage) DeleteFolder(folderId int64) bool {
|
||||
_, err := s.db.Exec(`delete from folders where id = ?`, folderId)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
}
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Storage) RenameFolder(folderId int64, newTitle string) bool {
|
||||
_, err := s.db.Exec(`update folders set title = ? where id = ?`, newTitle, folderId)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Storage) ToggleFolderExpanded(folderId int64, isExpanded bool) bool {
|
||||
_, err := s.db.Exec(`update folders set is_expanded = ? where id = ?`, isExpanded, folderId)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Storage) ListFolders() []Folder {
|
||||
result := make([]Folder, 0, 0)
|
||||
rows, err := s.db.Query(`
|
||||
select id, title, is_expanded
|
||||
from folders
|
||||
order by title collate nocase
|
||||
`)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return result
|
||||
}
|
||||
for rows.Next() {
|
||||
var f Folder
|
||||
err = rows.Scan(&f.Id, &f.Title, &f.IsExpanded)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return result
|
||||
}
|
||||
result = append(result, f)
|
||||
}
|
||||
return result
|
||||
}
|
||||
73
src/storage/http.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HTTPState struct {
|
||||
FeedID int64
|
||||
LastRefreshed time.Time
|
||||
|
||||
LastModified string
|
||||
Etag string
|
||||
}
|
||||
|
||||
func (s *Storage) ListHTTPStates() map[int64]HTTPState {
|
||||
result := make(map[int64]HTTPState)
|
||||
rows, err := s.db.Query(`select feed_id, last_refreshed, last_modified, etag from http_states`)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return result
|
||||
}
|
||||
for rows.Next() {
|
||||
var state HTTPState
|
||||
err = rows.Scan(
|
||||
&state.FeedID,
|
||||
&state.LastRefreshed,
|
||||
&state.LastModified,
|
||||
&state.Etag,
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return result
|
||||
}
|
||||
result[state.FeedID] = state
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Storage) GetHTTPState(feedID int64) *HTTPState {
|
||||
row := s.db.QueryRow(`
|
||||
select feed_id, last_refreshed, last_modified, etag
|
||||
from http_states where feed_id = ?
|
||||
`, feedID)
|
||||
|
||||
if row == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var state HTTPState
|
||||
row.Scan(
|
||||
&state.FeedID,
|
||||
&state.LastRefreshed,
|
||||
&state.LastModified,
|
||||
&state.Etag,
|
||||
)
|
||||
return &state
|
||||
}
|
||||
|
||||
func (s *Storage) SetHTTPState(feedID int64, lastModified, etag string) {
|
||||
_, err := s.db.Exec(`
|
||||
insert into http_states (feed_id, last_modified, etag, last_refreshed)
|
||||
values (?, ?, ?, datetime())
|
||||
on conflict (feed_id) do update set last_modified = ?, etag = ?, last_refreshed = datetime()`,
|
||||
// insert
|
||||
feedID, lastModified, etag,
|
||||
// upsert
|
||||
lastModified, etag,
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
}
|
||||
}
|
||||
442
src/storage/item.go
Normal file
@@ -0,0 +1,442 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nkanaev/yarr/src/content/htmlutil"
|
||||
)
|
||||
|
||||
type ItemStatus int
|
||||
|
||||
const (
|
||||
UNREAD ItemStatus = 0
|
||||
READ ItemStatus = 1
|
||||
STARRED ItemStatus = 2
|
||||
)
|
||||
|
||||
var StatusRepresentations = map[ItemStatus]string{
|
||||
UNREAD: "unread",
|
||||
READ: "read",
|
||||
STARRED: "starred",
|
||||
}
|
||||
|
||||
var StatusValues = map[string]ItemStatus{
|
||||
"unread": UNREAD,
|
||||
"read": READ,
|
||||
"starred": STARRED,
|
||||
}
|
||||
|
||||
func (s ItemStatus) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(StatusRepresentations[s])
|
||||
}
|
||||
|
||||
func (s *ItemStatus) UnmarshalJSON(b []byte) error {
|
||||
var str string
|
||||
if err := json.Unmarshal(b, &str); err != nil {
|
||||
return err
|
||||
}
|
||||
*s = StatusValues[str]
|
||||
return nil
|
||||
}
|
||||
|
||||
type Item struct {
|
||||
Id int64 `json:"id"`
|
||||
GUID string `json:"guid"`
|
||||
FeedId int64 `json:"feed_id"`
|
||||
Title string `json:"title"`
|
||||
Link string `json:"link"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Date time.Time `json:"date"`
|
||||
Status ItemStatus `json:"status"`
|
||||
ImageURL *string `json:"image"`
|
||||
AudioURL *string `json:"podcast_url"`
|
||||
}
|
||||
|
||||
type ItemFilter struct {
|
||||
FolderID *int64
|
||||
FeedID *int64
|
||||
Status *ItemStatus
|
||||
Search *string
|
||||
After *int64
|
||||
IDs *[]int64
|
||||
SinceID *int64
|
||||
MaxID *int64
|
||||
Before *time.Time
|
||||
}
|
||||
|
||||
type MarkFilter struct {
|
||||
FolderID *int64
|
||||
FeedID *int64
|
||||
|
||||
Before *time.Time
|
||||
}
|
||||
|
||||
type ItemList []Item
|
||||
|
||||
func (list ItemList) Len() int {
|
||||
return len(list)
|
||||
}
|
||||
|
||||
func (list ItemList) SortKey(i int) string {
|
||||
return list[i].Date.Format(time.RFC3339) + "::" + list[i].GUID
|
||||
}
|
||||
|
||||
func (list ItemList) Less(i, j int) bool {
|
||||
return list.SortKey(i) < list.SortKey(j)
|
||||
}
|
||||
|
||||
func (list ItemList) Swap(i, j int) {
|
||||
list[i], list[j] = list[j], list[i]
|
||||
}
|
||||
|
||||
|
||||
func (s *Storage) CreateItems(items []Item) bool {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return false
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
itemsSorted := ItemList(items)
|
||||
sort.Sort(itemsSorted)
|
||||
|
||||
for _, item := range itemsSorted {
|
||||
_, err = tx.Exec(`
|
||||
insert into items (
|
||||
guid, feed_id, title, link, date,
|
||||
content, image, podcast_url,
|
||||
date_arrived, status
|
||||
)
|
||||
values (?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f', ?), ?, ?, ?, ?, ?)
|
||||
on conflict (feed_id, guid) do nothing`,
|
||||
item.GUID, item.FeedId, item.Title, item.Link, item.Date,
|
||||
item.Content, item.ImageURL, item.AudioURL,
|
||||
now, UNREAD,
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
if err = tx.Rollback(); err != nil {
|
||||
log.Print(err)
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
log.Print(err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func listQueryPredicate(filter ItemFilter, newestFirst bool) (string, []interface{}) {
|
||||
cond := make([]string, 0)
|
||||
args := make([]interface{}, 0)
|
||||
if filter.FolderID != nil {
|
||||
cond = append(cond, "i.feed_id in (select id from feeds where folder_id = ?)")
|
||||
args = append(args, *filter.FolderID)
|
||||
}
|
||||
if filter.FeedID != nil {
|
||||
cond = append(cond, "i.feed_id = ?")
|
||||
args = append(args, *filter.FeedID)
|
||||
}
|
||||
if filter.Status != nil {
|
||||
cond = append(cond, "i.status = ?")
|
||||
args = append(args, *filter.Status)
|
||||
}
|
||||
if filter.Search != nil {
|
||||
words := strings.Fields(*filter.Search)
|
||||
terms := make([]string, len(words))
|
||||
for idx, word := range words {
|
||||
terms[idx] = word + "*"
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if filter.IDs != nil && len(*filter.IDs) > 0 {
|
||||
qmarks := make([]string, len(*filter.IDs))
|
||||
idargs := make([]interface{}, len(*filter.IDs))
|
||||
for i, id := range *filter.IDs {
|
||||
qmarks[i] = "?"
|
||||
idargs[i] = id
|
||||
}
|
||||
cond = append(cond, "i.id in ("+strings.Join(qmarks, ",")+")")
|
||||
args = append(args, idargs...)
|
||||
}
|
||||
if filter.SinceID != nil {
|
||||
cond = append(cond, "i.id > ?")
|
||||
args = append(args, filter.SinceID)
|
||||
}
|
||||
if filter.MaxID != nil {
|
||||
cond = append(cond, "i.id < ?")
|
||||
args = append(args, filter.MaxID)
|
||||
}
|
||||
if filter.Before != nil {
|
||||
cond = append(cond, "i.date < ?")
|
||||
args = append(args, filter.Before)
|
||||
}
|
||||
|
||||
predicate := "1"
|
||||
if len(cond) > 0 {
|
||||
predicate = strings.Join(cond, " and ")
|
||||
}
|
||||
|
||||
return predicate, args
|
||||
}
|
||||
|
||||
func (s *Storage) CountItems(filter ItemFilter) int {
|
||||
predicate, args := listQueryPredicate(filter, false)
|
||||
|
||||
var count int
|
||||
query := fmt.Sprintf(`
|
||||
select count(*)
|
||||
from items
|
||||
where %s
|
||||
`, predicate)
|
||||
err := s.db.QueryRow(query, args...).Scan(&count)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return 0
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (s *Storage) ListItems(filter ItemFilter, limit int, newestFirst bool, withContent bool) []Item {
|
||||
predicate, args := listQueryPredicate(filter, newestFirst)
|
||||
result := make([]Item, 0, 0)
|
||||
|
||||
order := "date desc, id desc"
|
||||
if !newestFirst {
|
||||
order = "date asc, id asc"
|
||||
}
|
||||
if filter.IDs != nil || filter.SinceID != nil {
|
||||
order = "i.id asc"
|
||||
}
|
||||
if filter.MaxID != nil {
|
||||
order = "i.id desc"
|
||||
}
|
||||
|
||||
selectCols := "i.id, i.guid, i.feed_id, i.title, i.link, i.date, i.status, i.image, i.podcast_url"
|
||||
if withContent {
|
||||
selectCols += ", i.content"
|
||||
} else {
|
||||
selectCols += ", '' as content"
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
select %s
|
||||
from items i
|
||||
where %s
|
||||
order by %s
|
||||
limit %d
|
||||
`, selectCols, predicate, order, limit)
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return result
|
||||
}
|
||||
for rows.Next() {
|
||||
var x Item
|
||||
err = rows.Scan(
|
||||
&x.Id, &x.GUID, &x.FeedId,
|
||||
&x.Title, &x.Link, &x.Date,
|
||||
&x.Status, &x.ImageURL, &x.AudioURL, &x.Content,
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return result
|
||||
}
|
||||
result = append(result, x)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Storage) GetItem(id int64) *Item {
|
||||
i := &Item{}
|
||||
err := s.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.id = ?
|
||||
`, id).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.Print(err)
|
||||
return nil
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
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,
|
||||
Before: filter.Before,
|
||||
}, false)
|
||||
query := fmt.Sprintf(`
|
||||
update items as i set status = %d
|
||||
where %s and i.status != %d
|
||||
`, READ, predicate, STARRED)
|
||||
_, err := s.db.Exec(query, args...)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
}
|
||||
return err == nil
|
||||
}
|
||||
|
||||
type FeedStat struct {
|
||||
FeedId int64 `json:"feed_id"`
|
||||
UnreadCount int64 `json:"unread"`
|
||||
StarredCount int64 `json:"starred"`
|
||||
}
|
||||
|
||||
func (s *Storage) FeedStats() []FeedStat {
|
||||
result := make([]FeedStat, 0)
|
||||
rows, err := s.db.Query(fmt.Sprintf(`
|
||||
select
|
||||
feed_id,
|
||||
sum(case status when %d then 1 else 0 end),
|
||||
sum(case status when %d then 1 else 0 end)
|
||||
from items
|
||||
group by feed_id
|
||||
`, UNREAD, STARRED))
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return result
|
||||
}
|
||||
for rows.Next() {
|
||||
stat := FeedStat{}
|
||||
rows.Scan(&stat.FeedId, &stat.UnreadCount, &stat.StarredCount)
|
||||
result = append(result, stat)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Storage) SyncSearch() {
|
||||
rows, err := s.db.Query(`
|
||||
select id, title, content
|
||||
from items
|
||||
where search_rowid is null;
|
||||
`)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]Item, 0)
|
||||
for rows.Next() {
|
||||
var item Item
|
||||
rows.Scan(&item.Id, &item.Title, &item.Content)
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
result, err := s.db.Exec(`
|
||||
insert into search (title, description, content) values (?, "", ?)`,
|
||||
item.Title, htmlutil.ExtractText(item.Content),
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return
|
||||
}
|
||||
if numrows, err := result.RowsAffected(); err == nil && numrows == 1 {
|
||||
if rowId, err := result.LastInsertId(); err == nil {
|
||||
s.db.Exec(
|
||||
`update items set search_rowid = ? where id = ?`,
|
||||
rowId, item.Id,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(`
|
||||
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
|
||||
}
|
||||
|
||||
feedLimits := make(map[int64]int64, 0)
|
||||
for rows.Next() {
|
||||
var feedId, limit int64
|
||||
rows.Scan(&feedId, &limit, nil)
|
||||
feedLimits[feedId] = limit
|
||||
}
|
||||
|
||||
for feedId, limit := range feedLimits {
|
||||
result, err := s.db.Exec(`
|
||||
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,
|
||||
limit,
|
||||
time.Now().UTC().Add(-time.Hour*time.Duration(24*itemsKeepDays)),
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return
|
||||
}
|
||||
numDeleted, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return
|
||||
}
|
||||
if numDeleted > 0 {
|
||||
log.Printf("Deleted %d old items (feed: %d)", numDeleted, feedId)
|
||||
}
|
||||
}
|
||||
}
|
||||
330
src/storage/item_test.go
Normal file
@@ -0,0 +1,330 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"log"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
/*
|
||||
- folder1
|
||||
- feed11
|
||||
- item111 (unread)
|
||||
- item112 (read)
|
||||
- item113 (starred)
|
||||
- feed12
|
||||
- item121 (unread)
|
||||
- item122 (read)
|
||||
- folder2
|
||||
- feed21
|
||||
- item211 (read)
|
||||
- item212 (starred)
|
||||
- feed01
|
||||
- item011 (unread)
|
||||
- item012 (read)
|
||||
- item013 (starred)
|
||||
*/
|
||||
|
||||
type testItemScope struct {
|
||||
feed11, feed12 *Feed
|
||||
feed21, feed01 *Feed
|
||||
folder1, folder2 *Folder
|
||||
}
|
||||
|
||||
func testItemsSetup(db *Storage) testItemScope {
|
||||
folder1 := db.CreateFolder("folder1")
|
||||
folder2 := db.CreateFolder("folder2")
|
||||
|
||||
feed11 := db.CreateFeed("feed11", "", "", "http://test.com/feed11.xml", &folder1.Id)
|
||||
feed12 := db.CreateFeed("feed12", "", "", "http://test.com/feed12.xml", &folder1.Id)
|
||||
feed21 := db.CreateFeed("feed21", "", "", "http://test.com/feed21.xml", &folder2.Id)
|
||||
feed01 := db.CreateFeed("feed01", "", "", "http://test.com/feed01.xml", nil)
|
||||
|
||||
now := time.Now()
|
||||
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)}, // 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)}, // read
|
||||
// feed21
|
||||
{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)}, // 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)
|
||||
|
||||
return testItemScope{
|
||||
feed11: feed11,
|
||||
feed12: feed12,
|
||||
feed21: feed21,
|
||||
feed01: feed01,
|
||||
folder1: folder1,
|
||||
folder2: folder2,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return guids
|
||||
}
|
||||
|
||||
func TestListItems(t *testing.T) {
|
||||
db := testDB()
|
||||
scope := testItemsSetup(db)
|
||||
|
||||
// filter by folder_id
|
||||
|
||||
have := getItemGuids(db.ListItems(ItemFilter{FolderID: &scope.folder1.Id}, 10, false, false))
|
||||
want := []string{"item111", "item112", "item113", "item121", "item122"}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
have = getItemGuids(db.ListItems(ItemFilter{FolderID: &scope.folder2.Id}, 10, false, false))
|
||||
want = []string{"item211", "item212"}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// filter by feed_id
|
||||
|
||||
have = getItemGuids(db.ListItems(ItemFilter{FeedID: &scope.feed11.Id}, 10, false, false))
|
||||
want = []string{"item111", "item112", "item113"}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
have = getItemGuids(db.ListItems(ItemFilter{FeedID: &scope.feed01.Id}, 10, false, false))
|
||||
want = []string{"item011", "item012", "item013"}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// filter by status
|
||||
|
||||
var starred ItemStatus = STARRED
|
||||
have = getItemGuids(db.ListItems(ItemFilter{Status: &starred}, 10, false, false))
|
||||
want = []string{"item113", "item212", "item013"}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
var unread ItemStatus = UNREAD
|
||||
have = getItemGuids(db.ListItems(ItemFilter{Status: &unread}, 10, false, false))
|
||||
want = []string{"item111", "item121", "item011"}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// limit
|
||||
|
||||
have = getItemGuids(db.ListItems(ItemFilter{}, 2, false, false))
|
||||
want = []string{"item111", "item112"}
|
||||
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 = getItemGuids(db.ListItems(ItemFilter{Search: &search1}, 4, true, false))
|
||||
want = []string{"item111"}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// sort by date
|
||||
have = getItemGuids(db.ListItems(ItemFilter{}, 4, true, false))
|
||||
want = []string{"item013", "item012", "item011", "item212"}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
func TestListItemsPaginated(t *testing.T) {
|
||||
db := testDB()
|
||||
testItemsSetup(db)
|
||||
|
||||
item012 := getItem(db, "item012")
|
||||
item121 := getItem(db, "item121")
|
||||
|
||||
// all, newest first
|
||||
have := getItemGuids(db.ListItems(ItemFilter{After: &item012.Id}, 3, true, false))
|
||||
want := []string{"item011", "item212", "item211"}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// unread, newest first
|
||||
unread := UNREAD
|
||||
have = getItemGuids(db.ListItems(ItemFilter{After: &item012.Id, Status: &unread}, 3, true, false))
|
||||
want = []string{"item011", "item121", "item111"}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// starred, oldest first
|
||||
starred := STARRED
|
||||
have = getItemGuids(db.ListItems(ItemFilter{After: &item121.Id, Status: &starred}, 3, false, false))
|
||||
want = []string{"item212", "item013"}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkItemsRead(t *testing.T) {
|
||||
// NOTE: starred items must not be marked as read
|
||||
var read ItemStatus = READ
|
||||
|
||||
db1 := testDB()
|
||||
testItemsSetup(db1)
|
||||
db1.MarkItemsRead(MarkFilter{})
|
||||
have := getItemGuids(db1.ListItems(ItemFilter{Status: &read}, 10, false, false))
|
||||
want := []string{
|
||||
"item111", "item112", "item121", "item122",
|
||||
"item211", "item011", "item012",
|
||||
}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
db2 := testDB()
|
||||
scope2 := testItemsSetup(db2)
|
||||
db2.MarkItemsRead(MarkFilter{FolderID: &scope2.folder1.Id})
|
||||
have = getItemGuids(db2.ListItems(ItemFilter{Status: &read}, 10, false, false))
|
||||
want = []string{
|
||||
"item111", "item112", "item121", "item122",
|
||||
"item211", "item012",
|
||||
}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
db3 := testDB()
|
||||
scope3 := testItemsSetup(db3)
|
||||
db3.MarkItemsRead(MarkFilter{FeedID: &scope3.feed11.Id})
|
||||
have = getItemGuids(db3.ListItems(ItemFilter{Status: &read}, 10, false, false))
|
||||
want = []string{
|
||||
"item111", "item112", "item122",
|
||||
"item211", "item012",
|
||||
}
|
||||
if !reflect.DeepEqual(have, want) {
|
||||
t.Logf("want: %#v", want)
|
||||
t.Logf("have: %#v", have)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteOldItems(t *testing.T) {
|
||||
extraItems := 10
|
||||
|
||||
now := time.Now().UTC()
|
||||
db := testDB()
|
||||
feed := db.CreateFeed("feed", "", "", "http://test.com/feed11.xml", nil)
|
||||
|
||||
items := make([]Item, 0)
|
||||
for i := 0; i < itemsKeepSize+extraItems; i++ {
|
||||
istr := strconv.Itoa(i)
|
||||
items = append(items, Item{
|
||||
GUID: istr,
|
||||
FeedId: feed.Id,
|
||||
Title: istr,
|
||||
Date: now.Add(time.Hour * time.Duration(i)),
|
||||
})
|
||||
}
|
||||
db.CreateItems(items)
|
||||
|
||||
db.SetFeedSize(feed.Id, itemsKeepSize)
|
||||
var feedSize int
|
||||
err := db.db.QueryRow(
|
||||
`select size from feed_sizes where feed_id = ?`, feed.Id,
|
||||
).Scan(&feedSize)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if feedSize != itemsKeepSize {
|
||||
t.Fatalf(
|
||||
"expected feed size to get updated\nwant: %d\nhave: %d",
|
||||
itemsKeepSize+extraItems,
|
||||
feedSize,
|
||||
)
|
||||
}
|
||||
|
||||
// expire only the first 3 articles
|
||||
_, err = db.db.Exec(
|
||||
`update items set date_arrived = ?
|
||||
where id in (select id from items limit 3)`,
|
||||
now.Add(-time.Hour*time.Duration(itemsKeepDays*24)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db.DeleteOldItems()
|
||||
feedItems := db.ListItems(ItemFilter{FeedID: &feed.Id}, 1000, false, false)
|
||||
if len(feedItems) != len(items)-3 {
|
||||
t.Fatalf(
|
||||
"invalid number of old items kept\nwant: %d\nhave: %d",
|
||||
len(items)-3,
|
||||
len(feedItems),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package sqlite
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
@@ -16,21 +16,13 @@ var migrations = []func(*sql.Tx) error{
|
||||
m06_fill_missing_dates,
|
||||
m07_add_feed_size,
|
||||
m08_normalize_datetime,
|
||||
m09_change_item_index,
|
||||
m10_add_item_medialinks,
|
||||
m11_add_item_last_arrived,
|
||||
m12_remove_feed_sizes,
|
||||
m13_consolidate_feed_states,
|
||||
m14_upgrade_fts5,
|
||||
}
|
||||
|
||||
var maxVersion = int64(len(migrations))
|
||||
|
||||
func migrate(db *sql.DB) error {
|
||||
var version int64
|
||||
if err := db.QueryRow("pragma user_version").Scan(&version); err != nil {
|
||||
return err
|
||||
}
|
||||
db.QueryRow("pragma user_version").Scan(&version)
|
||||
|
||||
if version >= maxVersion {
|
||||
return nil
|
||||
@@ -294,10 +286,7 @@ func m08_normalize_datetime(tx *sql.Tx) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`update items set date_arrived = :date_arrived where id = :id;`,
|
||||
sql.Named("date_arrived", dateArrived.UTC()),
|
||||
sql.Named("id", id),
|
||||
)
|
||||
_, err = tx.Exec(`update items set date_arrived = ? where id = ?;`, dateArrived.UTC(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -305,118 +294,3 @@ func m08_normalize_datetime(tx *sql.Tx) error {
|
||||
_, err = tx.Exec(`update items set date = strftime('%Y-%m-%d %H:%M:%f', date);`)
|
||||
return err
|
||||
}
|
||||
|
||||
func m09_change_item_index(tx *sql.Tx) error {
|
||||
sql := `
|
||||
drop index if exists idx_item_status;
|
||||
create index if not exists idx_item__date_id_status on items(date,id,status);
|
||||
`
|
||||
_, err := tx.Exec(sql)
|
||||
return err
|
||||
}
|
||||
|
||||
func m10_add_item_medialinks(tx *sql.Tx) error {
|
||||
sql := `
|
||||
alter table items add column media_links json;
|
||||
update items set media_links = case
|
||||
when coalesce(image, '') != '' and coalesce(podcast_url, '') != ''
|
||||
then json_array(json_object('type', 'image', 'url', image), json_object('type', 'audio', 'url', podcast_url))
|
||||
|
||||
when coalesce(image, '') != ''
|
||||
then json_array(json_object('type', 'image', 'url', image))
|
||||
|
||||
when coalesce(podcast_url, '') != ''
|
||||
then json_array(json_object('type', 'audio', 'url', podcast_url))
|
||||
|
||||
else null
|
||||
end;
|
||||
alter table items drop column image;
|
||||
alter table items drop column podcast_url;
|
||||
`
|
||||
_, err := tx.Exec(sql)
|
||||
return err
|
||||
}
|
||||
|
||||
func m11_add_item_last_arrived(tx *sql.Tx) error {
|
||||
sql := `alter table items add column last_arrived datetime`
|
||||
_, err := tx.Exec(sql)
|
||||
return err
|
||||
}
|
||||
|
||||
func m12_remove_feed_sizes(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`drop table if exists feed_sizes`)
|
||||
return err
|
||||
}
|
||||
|
||||
func m13_consolidate_feed_states(tx *sql.Tx) error {
|
||||
sql := `
|
||||
create table feed_states (
|
||||
feed_id references feeds(id) on delete cascade unique
|
||||
, last_refreshed datetime not null default 0
|
||||
, last_error string not null default ''
|
||||
|
||||
, http_lmod string not null default ''
|
||||
, http_etag string not null default ''
|
||||
);
|
||||
|
||||
insert into feed_states (
|
||||
feed_id
|
||||
, last_refreshed
|
||||
, last_error
|
||||
, http_lmod
|
||||
, http_etag
|
||||
)
|
||||
select
|
||||
f.id
|
||||
, coalesce(h.last_refreshed, 0)
|
||||
, coalesce(e.error, '')
|
||||
, coalesce(h.last_modified, '')
|
||||
, coalesce(h.etag, '')
|
||||
from feeds f
|
||||
left join http_states h on f.id = h.feed_id
|
||||
left join feed_errors e on f.id = e.feed_id
|
||||
where h.feed_id is not null or e.feed_id is not null;
|
||||
|
||||
drop table http_states;
|
||||
drop table feed_errors;
|
||||
`
|
||||
_, err := tx.Exec(sql)
|
||||
return err
|
||||
}
|
||||
|
||||
func m14_upgrade_fts5(tx *sql.Tx) error {
|
||||
sql := `
|
||||
-- 1. Drop old FTS4 table and trigger
|
||||
drop table if exists search;
|
||||
drop trigger if exists del_item_search;
|
||||
|
||||
-- 2. Remove search_rowid from items
|
||||
drop index if exists idx_item_search_rowid;
|
||||
alter table items drop column search_rowid;
|
||||
|
||||
-- 3. Create FTS5 virtual table
|
||||
create virtual table search using fts5(
|
||||
title, content,
|
||||
content='items',
|
||||
content_rowid='id',
|
||||
tokenize='unicode61'
|
||||
);
|
||||
|
||||
-- 4. Create triggers for automatic FTS sync
|
||||
create trigger items_ai after insert on items begin
|
||||
insert into search(rowid, title, content) values (new.id, new.title, strip_html(new.content));
|
||||
end;
|
||||
create trigger items_ad after delete on items begin
|
||||
insert into search(search, rowid, title, content) values('delete', old.id, old.title, strip_html(old.content));
|
||||
end;
|
||||
create trigger items_au after update on items begin
|
||||
insert into search(search, rowid, title, content) values('delete', old.id, old.title, strip_html(old.content));
|
||||
insert into search(rowid, title, content) values (new.id, new.title, strip_html(new.content));
|
||||
end;
|
||||
|
||||
-- 5. Populate FTS5 table with existing data
|
||||
insert into search(rowid, title, content) select id, title, strip_html(content) from items;
|
||||
`
|
||||
_, err := tx.Exec(sql)
|
||||
return err
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Feed struct {
|
||||
Id int64 `json:"id"`
|
||||
FolderId *int64 `json:"folder_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Link string `json:"link"`
|
||||
FeedLink string `json:"feed_link"`
|
||||
Icon *[]byte `json:"icon,omitempty"`
|
||||
HasIcon bool `json:"has_icon"`
|
||||
}
|
||||
|
||||
type CreateFeedParams struct {
|
||||
Title string
|
||||
Description string
|
||||
Link string
|
||||
FeedLink string
|
||||
FolderID *int64
|
||||
}
|
||||
|
||||
type Item struct {
|
||||
Id int64 `json:"id"`
|
||||
GUID string `json:"guid"`
|
||||
FeedId int64 `json:"feed_id"`
|
||||
Title string `json:"title"`
|
||||
Link string `json:"link"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Date time.Time `json:"date"`
|
||||
Status ItemStatus `json:"status"`
|
||||
MediaLinks MediaLinks `json:"media_links"`
|
||||
}
|
||||
|
||||
type ItemStatus int
|
||||
|
||||
const (
|
||||
UNREAD ItemStatus = 0
|
||||
READ ItemStatus = 1
|
||||
STARRED ItemStatus = 2
|
||||
)
|
||||
|
||||
|
||||
var StatusRepresentations = map[ItemStatus]string{
|
||||
UNREAD: "unread",
|
||||
READ: "read",
|
||||
STARRED: "starred",
|
||||
}
|
||||
|
||||
var StatusValues = map[string]ItemStatus{
|
||||
"unread": UNREAD,
|
||||
"read": READ,
|
||||
"starred": STARRED,
|
||||
}
|
||||
|
||||
func (s ItemStatus) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(StatusRepresentations[s])
|
||||
}
|
||||
|
||||
func (s *ItemStatus) UnmarshalJSON(b []byte) error {
|
||||
var str string
|
||||
if err := json.Unmarshal(b, &str); err != nil {
|
||||
return err
|
||||
}
|
||||
*s = StatusValues[str]
|
||||
return nil
|
||||
}
|
||||
|
||||
type MediaLink struct {
|
||||
URL string `json:"url"`
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type MediaLinks []MediaLink
|
||||
|
||||
type ItemFilter struct {
|
||||
FolderID *int64
|
||||
FeedID *int64
|
||||
Status *ItemStatus
|
||||
Search *string
|
||||
After *int64
|
||||
IDs *[]int64
|
||||
SinceID *int64
|
||||
MaxID *int64
|
||||
Before *time.Time
|
||||
}
|
||||
|
||||
type UpdateItemParams struct {
|
||||
Title *string
|
||||
Status *ItemStatus
|
||||
LastArrived *time.Time
|
||||
}
|
||||
|
||||
type MarkFilter struct {
|
||||
FolderID *int64
|
||||
FeedID *int64
|
||||
|
||||
Before *time.Time
|
||||
}
|
||||
|
||||
type Folder struct {
|
||||
Id int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
IsExpanded bool `json:"is_expanded"`
|
||||
}
|
||||
|
||||
type UpdateFolderParams struct {
|
||||
Title *string
|
||||
IsExpanded *bool
|
||||
}
|
||||
|
||||
type FeedStat struct {
|
||||
FeedId int64 `json:"feed_id"`
|
||||
UnreadCount int64 `json:"unread"`
|
||||
StarredCount int64 `json:"starred"`
|
||||
}
|
||||
|
||||
|
||||
type Settings struct {
|
||||
Filter string `json:"filter"`
|
||||
Feed string `json:"feed"`
|
||||
FeedListWidth int `json:"feed_list_width"`
|
||||
ItemListWidth int `json:"item_list_width"`
|
||||
SortNewestFirst bool `json:"sort_newest_first"`
|
||||
ThemeName string `json:"theme_name"`
|
||||
ThemeFont string `json:"theme_font"`
|
||||
ThemeSize int `json:"theme_size"`
|
||||
RefreshRate int64 `json:"refresh_rate"`
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
type UpdateSettingsParams struct {
|
||||
Filter *string `json:"filter"`
|
||||
Feed *string `json:"feed"`
|
||||
FeedListWidth *int `json:"feed_list_width"`
|
||||
ItemListWidth *int `json:"item_list_width"`
|
||||
SortNewestFirst *bool `json:"sort_newest_first"`
|
||||
ThemeName *string `json:"theme_name"`
|
||||
ThemeFont *string `json:"theme_font"`
|
||||
ThemeSize *int `json:"theme_size"`
|
||||
RefreshRate *int64 `json:"refresh_rate"`
|
||||
Language *string `json:"language"`
|
||||
}
|
||||
|
||||
func (s Settings) Map() map[string]any {
|
||||
return map[string]any{
|
||||
"filter": s.Filter,
|
||||
"feed": s.Feed,
|
||||
"feed_list_width": s.FeedListWidth,
|
||||
"item_list_width": s.ItemListWidth,
|
||||
"sort_newest_first": s.SortNewestFirst,
|
||||
"theme_name": s.ThemeName,
|
||||
"theme_font": s.ThemeFont,
|
||||
"theme_size": s.ThemeSize,
|
||||
"refresh_rate": s.RefreshRate,
|
||||
"language": s.Language,
|
||||
}
|
||||
}
|
||||
|
||||
func SettingsDefault() Settings {
|
||||
return Settings{
|
||||
Filter: "",
|
||||
Feed: "",
|
||||
FeedListWidth: 300,
|
||||
ItemListWidth: 300,
|
||||
SortNewestFirst: true,
|
||||
ThemeName: "light",
|
||||
ThemeFont: "",
|
||||
ThemeSize: 1,
|
||||
RefreshRate: 0,
|
||||
Language: "en",
|
||||
}
|
||||
}
|
||||
|
||||
type FeedState struct {
|
||||
FeedID int64
|
||||
LastRefreshed time.Time
|
||||
LastError string
|
||||
HTTPLastModified string
|
||||
HTTPEtag string
|
||||
}
|
||||
|
||||
type UpdateFeedStateParams struct {
|
||||
LastRefreshed *time.Time
|
||||
LastError *string
|
||||
HTTPLastModified *string
|
||||
HTTPEtag *string
|
||||
}
|
||||
|
||||
type UpdateFeedParams struct {
|
||||
Title *string
|
||||
FeedLink *string
|
||||
FolderID Nullable[int64]
|
||||
Icon Nullable[[]byte]
|
||||
}
|
||||
|
||||
type Nullable[T any] struct {
|
||||
Set bool
|
||||
Value *T
|
||||
}
|
||||
|
||||
func SetNullable[T any](v *T) Nullable[T] {
|
||||
return Nullable[T]{Set: true, Value: v}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
|
||||
"github.com/nkanaev/yarr/src/storage/model"
|
||||
)
|
||||
|
||||
func (s *PostgresStorage) CreateFeed(params model.CreateFeedParams) *model.Feed {
|
||||
title := params.Title
|
||||
if title == "" {
|
||||
title = params.FeedLink
|
||||
}
|
||||
row := s.db.QueryRow(`
|
||||
insert into feeds (title, description, link, feed_link, folder_id)
|
||||
values ($1, $2, $3, $4, $5)
|
||||
on conflict (feed_link) do update set folder_id = $5
|
||||
returning id`,
|
||||
title,
|
||||
params.Description,
|
||||
params.Link,
|
||||
params.FeedLink,
|
||||
params.FolderID,
|
||||
)
|
||||
|
||||
var id int64
|
||||
err := row.Scan(&id)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return nil
|
||||
}
|
||||
return &model.Feed{
|
||||
Id: id,
|
||||
Title: title,
|
||||
Description: params.Description,
|
||||
Link: params.Link,
|
||||
FeedLink: params.FeedLink,
|
||||
FolderId: params.FolderID,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PostgresStorage) DeleteFeed(feedId int64) bool {
|
||||
result, err := s.db.Exec(`delete from feeds where id = $1`, feedId)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return false
|
||||
}
|
||||
nrows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return false
|
||||
}
|
||||
return nrows == 1
|
||||
}
|
||||
|
||||
func (s *PostgresStorage) UpdateFeed(feedId int64, params model.UpdateFeedParams) (bool, error) {
|
||||
_, err := s.db.Exec(`
|
||||
update feeds set
|
||||
title = coalesce($2, title),
|
||||
feed_link = coalesce($3, feed_link),
|
||||
folder_id = case when $4 then $5 else folder_id end,
|
||||
icon = case when $6 then $7 else icon end
|
||||
where id = $1
|
||||
`,
|
||||
feedId,
|
||||
params.Title,
|
||||
params.FeedLink,
|
||||
params.FolderID.Set,
|
||||
params.FolderID.Value,
|
||||
params.Icon.Set,
|
||||
params.Icon.Value,
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStorage) ListFeeds() []model.Feed {
|
||||
result := make([]model.Feed, 0)
|
||||
rows, err := s.db.Query(`
|
||||
select id, folder_id, title, description, link, feed_link,
|
||||
coalesce(length(icon), 0) > 0 as has_icon
|
||||
from feeds
|
||||
order by lower(title)
|
||||
`)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return result
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var f model.Feed
|
||||
err = rows.Scan(
|
||||
&f.Id,
|
||||
&f.FolderId,
|
||||
&f.Title,
|
||||
&f.Description,
|
||||
&f.Link,
|
||||
&f.FeedLink,
|
||||
&f.HasIcon,
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return result
|
||||
}
|
||||
result = append(result, f)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *PostgresStorage) GetFeed(id int64) *model.Feed {
|
||||
var f model.Feed
|
||||
err := s.db.QueryRow(`
|
||||
select
|
||||
id, folder_id, title, link, feed_link,
|
||||
icon, coalesce(length(icon), 0) > 0 as has_icon
|
||||
from feeds where id = $1
|
||||
`, id).Scan(
|
||||
&f.Id, &f.FolderId, &f.Title, &f.Link, &f.FeedLink,
|
||||
&f.Icon, &f.HasIcon,
|
||||
)
|
||||
if err != nil {
|
||||
if err != sql.ErrNoRows {
|
||||
log.Print(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return &f
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/nkanaev/yarr/src/storage/model"
|
||||
)
|
||||
|
||||
func (s *PostgresStorage) ListFeedStates() ([]model.FeedState, error) {
|
||||
rows, err := s.db.Query(`
|
||||
select
|
||||
feed_id
|
||||
, last_refreshed
|
||||
, last_error
|
||||
, http_lmod
|
||||
, http_etag
|
||||
from feed_states
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
states := make([]model.FeedState, 0)
|
||||
for rows.Next() {
|
||||
var state model.FeedState
|
||||
err := rows.Scan(
|
||||
&state.FeedID,
|
||||
&state.LastRefreshed,
|
||||
&state.LastError,
|
||||
&state.HTTPLastModified,
|
||||
&state.HTTPEtag,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
states = append(states, state)
|
||||
}
|
||||
return states, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStorage) GetFeedState(feedID int64) (*model.FeedState, error) {
|
||||
var state model.FeedState
|
||||
err := s.db.QueryRow(`
|
||||
select
|
||||
feed_id
|
||||
, last_refreshed
|
||||
, last_error
|
||||
, http_lmod
|
||||
, http_etag
|
||||
from feed_states where feed_id = $1
|
||||
`, feedID).Scan(
|
||||
&state.FeedID,
|
||||
&state.LastRefreshed,
|
||||
&state.LastError,
|
||||
&state.HTTPLastModified,
|
||||
&state.HTTPEtag,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStorage) UpdateFeedState(feedID int64, params model.UpdateFeedStateParams) (bool, error) {
|
||||
lastError := params.LastError
|
||||
if lastError != nil && *lastError == "" {
|
||||
lastError = nil
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(`
|
||||
insert into feed_states (
|
||||
feed_id
|
||||
, last_refreshed
|
||||
, last_error
|
||||
, http_lmod
|
||||
, http_etag
|
||||
)
|
||||
values (
|
||||
$1
|
||||
, coalesce($2, '1970-01-01 00:00:00+00'::timestamptz)
|
||||
, coalesce($3, '')
|
||||
, coalesce($4, '')
|
||||
, coalesce($5, '')
|
||||
)
|
||||
on conflict (feed_id) do update set
|
||||
last_refreshed = coalesce($2, feed_states.last_refreshed),
|
||||
last_error = coalesce($3, feed_states.last_error),
|
||||
http_lmod = coalesce($4, feed_states.http_lmod),
|
||||
http_etag = coalesce($5, feed_states.http_etag)
|
||||
`,
|
||||
feedID,
|
||||
params.LastRefreshed,
|
||||
params.LastError,
|
||||
params.HTTPLastModified,
|
||||
params.HTTPEtag,
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/nkanaev/yarr/src/storage/model"
|
||||
)
|
||||
|
||||
func (s *PostgresStorage) CreateFolder(title string) *model.Folder {
|
||||
expanded := true
|
||||
row := s.db.QueryRow(`
|
||||
insert into folders (title, is_expanded) values ($1, $2)
|
||||
on conflict (title) do update set title = $1
|
||||
returning id`,
|
||||
title,
|
||||
expanded,
|
||||
)
|
||||
var id int64
|
||||
err := row.Scan(&id)
|
||||
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return nil
|
||||
}
|
||||
return &model.Folder{Id: id, Title: title, IsExpanded: expanded}
|
||||
}
|
||||
|
||||
func (s *PostgresStorage) DeleteFolder(folderId int64) bool {
|
||||
_, err := s.db.Exec(`delete from folders where id = $1`, folderId)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
}
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *PostgresStorage) UpdateFolder(folderId int64, params model.UpdateFolderParams) (bool, error) {
|
||||
_, err := s.db.Exec(`
|
||||
update folders set
|
||||
title = coalesce($2, title),
|
||||
is_expanded = coalesce($3, is_expanded)
|
||||
where id = $1
|
||||
`,
|
||||
folderId,
|
||||
params.Title,
|
||||
params.IsExpanded,
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStorage) ListFolders() []model.Folder {
|
||||
result := make([]model.Folder, 0)
|
||||
rows, err := s.db.Query(`
|
||||
select id, title, is_expanded
|
||||
from folders
|
||||
order by lower(title)
|
||||
`)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return result
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var f model.Folder
|
||||
err = rows.Scan(&f.Id, &f.Title, &f.IsExpanded)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return result
|
||||
}
|
||||
result = append(result, f)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nkanaev/yarr/src/content/htmlutil"
|
||||
"github.com/nkanaev/yarr/src/storage/model"
|
||||
)
|
||||
|
||||
type MediaLinks model.MediaLinks
|
||||
|
||||
func (m *MediaLinks) Scan(src any) error {
|
||||
switch data := src.(type) {
|
||||
case []byte:
|
||||
return json.Unmarshal(data, m)
|
||||
case string:
|
||||
return json.Unmarshal([]byte(data), m)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m MediaLinks) Value() (driver.Value, error) {
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
func (s *PostgresStorage) CreateItems(items []model.Item) bool {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return false
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
slices.SortStableFunc(items, func(a, b model.Item) int {
|
||||
sa := a.Date.Format(time.RFC3339) + "::" + a.GUID
|
||||
sb := b.Date.Format(time.RFC3339) + "::" + b.GUID
|
||||
return cmp.Compare(sa, sb)
|
||||
})
|
||||
|
||||
for _, item := range items {
|
||||
searchText := item.Title + " " + htmlutil.ExtractText(item.Content)
|
||||
_, err = tx.Exec(`
|
||||
insert into items (
|
||||
guid, feed_id, title, link, date,
|
||||
content, media_links,
|
||||
date_arrived, last_arrived, status,
|
||||
search
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7,
|
||||
$8, $9, $10,
|
||||
to_tsvector('simple', $11)
|
||||
)
|
||||
on conflict (feed_id, guid) do update set
|
||||
last_arrived = excluded.last_arrived`,
|
||||
item.GUID,
|
||||
item.FeedId,
|
||||
item.Title,
|
||||
item.Link,
|
||||
item.Date,
|
||||
item.Content,
|
||||
MediaLinks(item.MediaLinks),
|
||||
now,
|
||||
now,
|
||||
item.Status,
|
||||
searchText,
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
if err = tx.Rollback(); err != nil {
|
||||
log.Print(err)
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
log.Print(err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func listQueryPredicate(filter model.ItemFilter, newestFirst bool) (string, []any) {
|
||||
cond := make([]string, 0)
|
||||
args := make([]any, 0)
|
||||
n := 0
|
||||
|
||||
next := func() int {
|
||||
n++
|
||||
return n
|
||||
}
|
||||
|
||||
if filter.FolderID != nil {
|
||||
cond = append(cond, fmt.Sprintf("i.feed_id in (select id from feeds where folder_id = $%d)", next()))
|
||||
args = append(args, *filter.FolderID)
|
||||
}
|
||||
if filter.FeedID != nil {
|
||||
cond = append(cond, fmt.Sprintf("i.feed_id = $%d", next()))
|
||||
args = append(args, *filter.FeedID)
|
||||
}
|
||||
if filter.Status != nil {
|
||||
cond = append(cond, fmt.Sprintf("i.status = $%d", next()))
|
||||
args = append(args, *filter.Status)
|
||||
}
|
||||
if filter.Search != nil {
|
||||
words := strings.Fields(*filter.Search)
|
||||
terms := make([]string, len(words))
|
||||
for idx, word := range words {
|
||||
terms[idx] = word + ":*"
|
||||
}
|
||||
|
||||
cond = append(cond, fmt.Sprintf(
|
||||
"i.search @@ to_tsquery('simple', $%d)", next(),
|
||||
))
|
||||
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 = $%d)",
|
||||
compare, next(),
|
||||
))
|
||||
args = append(args, *filter.After)
|
||||
}
|
||||
if filter.IDs != nil && len(*filter.IDs) > 0 {
|
||||
placeholders := make([]string, len(*filter.IDs))
|
||||
for i, id := range *filter.IDs {
|
||||
placeholders[i] = fmt.Sprintf("$%d", next())
|
||||
args = append(args, id)
|
||||
}
|
||||
cond = append(cond, "i.id in ("+strings.Join(placeholders, ",")+")")
|
||||
}
|
||||
if filter.SinceID != nil {
|
||||
cond = append(cond, fmt.Sprintf("i.id > $%d", next()))
|
||||
args = append(args, filter.SinceID)
|
||||
}
|
||||
if filter.MaxID != nil {
|
||||
cond = append(cond, fmt.Sprintf("i.id < $%d", next()))
|
||||
args = append(args, filter.MaxID)
|
||||
}
|
||||
if filter.Before != nil {
|
||||
cond = append(cond, fmt.Sprintf("i.date < $%d", next()))
|
||||
args = append(args, filter.Before)
|
||||
}
|
||||
|
||||
predicate := "true"
|
||||
if len(cond) > 0 {
|
||||
predicate = strings.Join(cond, " and ")
|
||||
}
|
||||
|
||||
return predicate, args
|
||||
}
|
||||
|
||||
func (s *PostgresStorage) CountItems() int {
|
||||
var count int
|
||||
err := s.db.QueryRow(`select count(*) from items`).Scan(&count)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return 0
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (s *PostgresStorage) ListItems(
|
||||
filter model.ItemFilter,
|
||||
limit int,
|
||||
newestFirst bool,
|
||||
withContent bool,
|
||||
) []model.Item {
|
||||
predicate, args := listQueryPredicate(filter, newestFirst)
|
||||
result := make([]model.Item, 0)
|
||||
|
||||
order := "date desc, id desc"
|
||||
if !newestFirst {
|
||||
order = "date asc, id asc"
|
||||
}
|
||||
if filter.IDs != nil || filter.SinceID != nil {
|
||||
order = "i.id asc"
|
||||
}
|
||||
if filter.MaxID != nil {
|
||||
order = "i.id desc"
|
||||
}
|
||||
|
||||
selectCols := "i.id, i.guid, i.feed_id, i.title, i.link, i.date, i.status, i.media_links"
|
||||
if withContent {
|
||||
selectCols += ", i.content"
|
||||
} else {
|
||||
selectCols += ", '' as content"
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
select %s
|
||||
from items i
|
||||
where %s
|
||||
order by %s
|
||||
limit %d
|
||||
`, selectCols, predicate, order, limit)
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return result
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var x model.Item
|
||||
err = rows.Scan(
|
||||
&x.Id, &x.GUID, &x.FeedId,
|
||||
&x.Title, &x.Link, &x.Date,
|
||||
&x.Status, (*MediaLinks)(&x.MediaLinks), &x.Content,
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return result
|
||||
}
|
||||
result = append(result, x)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *PostgresStorage) GetItem(id int64) *model.Item {
|
||||
i := &model.Item{}
|
||||
err := s.db.QueryRow(`
|
||||
select
|
||||
i.id, i.guid, i.feed_id, i.title, i.link, i.content,
|
||||
i.date, i.status, i.media_links
|
||||
from items i
|
||||
where i.id = $1
|
||||
`, id).Scan(
|
||||
&i.Id, &i.GUID, &i.FeedId, &i.Title, &i.Link, &i.Content,
|
||||
&i.Date, &i.Status, (*MediaLinks)(&i.MediaLinks),
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return nil
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func (s *PostgresStorage) UpdateItem(id int64, params model.UpdateItemParams) bool {
|
||||
sets := make([]string, 0)
|
||||
args := make([]any, 0)
|
||||
n := 0
|
||||
|
||||
if params.Title != nil {
|
||||
n++
|
||||
sets = append(sets, fmt.Sprintf("title = $%d", n))
|
||||
args = append(args, *params.Title)
|
||||
n++
|
||||
sets = append(sets, fmt.Sprintf("search = to_tsvector('simple', $%d || ' ' || coalesce((select i2.content from items i2 where i2.id = $%d), ''))", n-1, n))
|
||||
args = append(args, id)
|
||||
}
|
||||
if params.Status != nil {
|
||||
n++
|
||||
sets = append(sets, fmt.Sprintf("status = $%d", n))
|
||||
args = append(args, *params.Status)
|
||||
}
|
||||
if params.LastArrived != nil {
|
||||
n++
|
||||
sets = append(sets, fmt.Sprintf("last_arrived = $%d", n))
|
||||
args = append(args, *params.LastArrived)
|
||||
}
|
||||
if len(sets) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
n++
|
||||
args = append(args, id)
|
||||
query := fmt.Sprintf("update items set %s where id = $%d", strings.Join(sets, ", "), n)
|
||||
_, err := s.db.Exec(query, args...)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *PostgresStorage) DeleteItem(id int64) bool {
|
||||
_, err := s.db.Exec(`delete from items where id = $1`, id)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *PostgresStorage) UpdateItemStatus(item_id int64, status model.ItemStatus) bool {
|
||||
_, err := s.db.Exec(`update items set status = $2 where id = $1`,
|
||||
item_id,
|
||||
status,
|
||||
)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *PostgresStorage) MarkItemsRead(filter model.MarkFilter) bool {
|
||||
predicate, args := listQueryPredicate(model.ItemFilter{
|
||||
FolderID: filter.FolderID,
|
||||
FeedID: filter.FeedID,
|
||||
Before: filter.Before,
|
||||
}, false)
|
||||
query := fmt.Sprintf(`
|
||||
update items as i set status = %d
|
||||
where %s and i.status != %d
|
||||
`, model.READ, predicate, model.STARRED)
|
||||
_, err := s.db.Exec(query, args...)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
}
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *PostgresStorage) FeedStats() []model.FeedStat {
|
||||
result := make([]model.FeedStat, 0)
|
||||
rows, err := s.db.Query(fmt.Sprintf(`
|
||||
select
|
||||
feed_id,
|
||||
sum(case status when %d then 1 else 0 end),
|
||||
sum(case status when %d then 1 else 0 end)
|
||||
from items
|
||||
group by feed_id
|
||||
`, model.UNREAD, model.STARRED))
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return result
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
stat := model.FeedStat{}
|
||||
rows.Scan(&stat.FeedId, &stat.UnreadCount, &stat.StarredCount)
|
||||
result = append(result, stat)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
var (
|
||||
itemsKeepSize = 50
|
||||
itemsKeepDays = 90
|
||||
)
|
||||
|
||||
func (s *PostgresStorage) DeleteOldItems() {
|
||||
keepDaysLimit := fmt.Sprintf("-%d days", itemsKeepDays)
|
||||
result, err := s.db.Exec(`
|
||||
delete from items
|
||||
where id in (
|
||||
select id
|
||||
from (
|
||||
select
|
||||
id,
|
||||
row_number() over (partition by feed_id order by date desc) as rn,
|
||||
last_arrived,
|
||||
max(last_arrived) over (partition by feed_id) as max_la
|
||||
from items
|
||||
where status != $1
|
||||
) sub
|
||||
where rn > $2
|
||||
and last_arrived < max_la + $3::interval
|
||||
)`,
|
||||
model.STARRED,
|
||||
itemsKeepSize,
|
||||
keepDaysLimit,
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return
|
||||
}
|
||||
numDeleted, err := result.RowsAffected()
|
||||
if err == nil && numDeleted > 0 {
|
||||
log.Printf("Deleted %d old items", numDeleted)
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
var migrations = []func(*sql.Tx) error{
|
||||
m01_initial,
|
||||
}
|
||||
|
||||
var maxVersion = int64(len(migrations))
|
||||
|
||||
func migrate(db *sql.DB) error {
|
||||
if _, err := db.Exec(
|
||||
`create table if not exists schema_version (version bigint primary key)`,
|
||||
); err != nil {
|
||||
return fmt.Errorf("create schema_version table: %w", err)
|
||||
}
|
||||
|
||||
var version int64
|
||||
err := db.QueryRow(
|
||||
`select coalesce(max(version), 0) from schema_version`,
|
||||
).Scan(&version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read schema version: %w", err)
|
||||
}
|
||||
|
||||
if version >= maxVersion {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("db version is %d. migrating to %d", version, maxVersion)
|
||||
|
||||
for v := version + 1; v <= maxVersion; v++ {
|
||||
log.Printf("[migration:%d] starting", v)
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("migration %d begin tx: %w", v, err)
|
||||
}
|
||||
|
||||
if err := migrations[v-1](tx); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("migration %d: %w", v, err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(
|
||||
`insert into schema_version (version) values ($1)
|
||||
on conflict do nothing`, v,
|
||||
); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("migration %d record version: %w", v, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("migration %d commit: %w", v, err)
|
||||
}
|
||||
|
||||
log.Printf("[migration:%d] done", v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func m01_initial(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
create table if not exists folders (
|
||||
id bigserial primary key,
|
||||
title text not null,
|
||||
is_expanded boolean not null default false
|
||||
);
|
||||
create unique index if not exists idx_folder_title on folders(title);
|
||||
|
||||
create table if not exists feeds (
|
||||
id bigserial primary key,
|
||||
folder_id bigint references folders(id) on delete set null,
|
||||
title text not null,
|
||||
description text,
|
||||
link text,
|
||||
feed_link text not null,
|
||||
icon bytea
|
||||
);
|
||||
create index if not exists idx_feed_folder_id on feeds(folder_id);
|
||||
create unique index if not exists idx_feed_feed_link on feeds(feed_link);
|
||||
|
||||
create table if not exists items (
|
||||
id bigserial primary key,
|
||||
guid text not null,
|
||||
feed_id bigint not null references feeds(id) on delete cascade,
|
||||
title text,
|
||||
link text,
|
||||
content text,
|
||||
date timestamptz,
|
||||
date_arrived timestamptz,
|
||||
last_arrived timestamptz,
|
||||
status integer,
|
||||
media_links jsonb,
|
||||
search tsvector
|
||||
);
|
||||
create index if not exists idx_item_feed_id on items(feed_id);
|
||||
create index if not exists idx_item__date_id_status on items(date, id, status);
|
||||
create unique index if not exists idx_item_guid on items(feed_id, guid);
|
||||
create index if not exists idx_item_search on items using gin(search);
|
||||
|
||||
create table if not exists settings (
|
||||
key text primary key,
|
||||
val jsonb
|
||||
);
|
||||
|
||||
create table if not exists feed_states (
|
||||
feed_id bigint primary key references feeds(id) on delete cascade,
|
||||
last_refreshed timestamptz not null default '1970-01-01 00:00:00+00',
|
||||
last_error text not null default '',
|
||||
http_lmod text not null default '',
|
||||
http_etag text not null default ''
|
||||
);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
|
||||
"github.com/nkanaev/yarr/src/storage/model"
|
||||
)
|
||||
|
||||
func (s *PostgresStorage) GetSettings() model.Settings {
|
||||
result := model.SettingsDefault()
|
||||
rows, err := s.db.Query(`select key, val from settings;`)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return result
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var key string
|
||||
var val []byte
|
||||
rows.Scan(&key, &val)
|
||||
|
||||
switch key {
|
||||
case "filter":
|
||||
json.Unmarshal(val, &result.Filter)
|
||||
case "feed":
|
||||
json.Unmarshal(val, &result.Feed)
|
||||
case "feed_list_width":
|
||||
json.Unmarshal(val, &result.FeedListWidth)
|
||||
case "item_list_width":
|
||||
json.Unmarshal(val, &result.ItemListWidth)
|
||||
case "sort_newest_first":
|
||||
json.Unmarshal(val, &result.SortNewestFirst)
|
||||
case "theme_name":
|
||||
json.Unmarshal(val, &result.ThemeName)
|
||||
case "theme_font":
|
||||
json.Unmarshal(val, &result.ThemeFont)
|
||||
case "theme_size":
|
||||
json.Unmarshal(val, &result.ThemeSize)
|
||||
case "refresh_rate":
|
||||
json.Unmarshal(val, &result.RefreshRate)
|
||||
case "language":
|
||||
json.Unmarshal(val, &result.Language)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *PostgresStorage) UpdateSettings(params model.UpdateSettingsParams) bool {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return false
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
update := func(key string, val any) error {
|
||||
valEncoded, err := json.Marshal(val)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`
|
||||
insert into settings (key, val) values ($1, $2)
|
||||
on conflict (key) do update set val = $2`,
|
||||
key,
|
||||
valEncoded,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
var errs []error
|
||||
if params.Filter != nil {
|
||||
errs = append(errs, update("filter", *params.Filter))
|
||||
}
|
||||
if params.Feed != nil {
|
||||
errs = append(errs, update("feed", *params.Feed))
|
||||
}
|
||||
if params.FeedListWidth != nil {
|
||||
errs = append(errs, update("feed_list_width", *params.FeedListWidth))
|
||||
}
|
||||
if params.ItemListWidth != nil {
|
||||
errs = append(errs, update("item_list_width", *params.ItemListWidth))
|
||||
}
|
||||
if params.SortNewestFirst != nil {
|
||||
errs = append(errs, update("sort_newest_first", *params.SortNewestFirst))
|
||||
}
|
||||
if params.ThemeName != nil {
|
||||
errs = append(errs, update("theme_name", *params.ThemeName))
|
||||
}
|
||||
if params.ThemeFont != nil {
|
||||
errs = append(errs, update("theme_font", *params.ThemeFont))
|
||||
}
|
||||
if params.ThemeSize != nil {
|
||||
errs = append(errs, update("theme_size", *params.ThemeSize))
|
||||
}
|
||||
if params.RefreshRate != nil {
|
||||
errs = append(errs, update("refresh_rate", *params.RefreshRate))
|
||||
}
|
||||
if params.Language != nil {
|
||||
errs = append(errs, update("language", *params.Language))
|
||||
}
|
||||
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Print(err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
type PostgresStorage struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func New(connStr string) (*PostgresStorage, error) {
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := migrate(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Print("connected to postgres")
|
||||
return &PostgresStorage{db: db}, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStorage) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
94
src/storage/settings.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
)
|
||||
|
||||
func settingsDefaults() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"filter": "",
|
||||
"feed": "",
|
||||
"feed_list_width": 300,
|
||||
"item_list_width": 300,
|
||||
"sort_newest_first": true,
|
||||
"theme_name": "light",
|
||||
"theme_font": "",
|
||||
"theme_size": 1,
|
||||
"refresh_rate": 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Storage) GetSettingsValue(key string) interface{} {
|
||||
row := s.db.QueryRow(`select val from settings where key=?`, key)
|
||||
if row == nil {
|
||||
return settingsDefaults()[key]
|
||||
}
|
||||
var val []byte
|
||||
row.Scan(&val)
|
||||
if len(val) == 0 {
|
||||
return nil
|
||||
}
|
||||
var valDecoded interface{}
|
||||
if err := json.Unmarshal([]byte(val), &valDecoded); err != nil {
|
||||
log.Print(err)
|
||||
return nil
|
||||
}
|
||||
return valDecoded
|
||||
}
|
||||
|
||||
func (s *Storage) GetSettingsValueInt64(key string) int64 {
|
||||
val := s.GetSettingsValue(key)
|
||||
if val != nil {
|
||||
if fval, ok := val.(float64); ok {
|
||||
return int64(fval)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *Storage) GetSettings() map[string]interface{} {
|
||||
result := settingsDefaults()
|
||||
rows, err := s.db.Query(`select key, val from settings;`)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return result
|
||||
}
|
||||
for rows.Next() {
|
||||
var key string
|
||||
var val []byte
|
||||
var valDecoded interface{}
|
||||
|
||||
rows.Scan(&key, &val)
|
||||
if err = json.Unmarshal([]byte(val), &valDecoded); err != nil {
|
||||
log.Print(err)
|
||||
continue
|
||||
}
|
||||
result[key] = valDecoded
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Storage) UpdateSettings(kv map[string]interface{}) bool {
|
||||
defaults := settingsDefaults()
|
||||
for key, val := range kv {
|
||||
if defaults[key] == nil {
|
||||
continue
|
||||
}
|
||||
valEncoded, err := json.Marshal(val)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return false
|
||||
}
|
||||
_, err = s.db.Exec(`
|
||||
insert into settings (key, val) values (?, ?)
|
||||
on conflict (key) do update set val=?`,
|
||||
key, valEncoded, valEncoded,
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
|
||||
"github.com/nkanaev/yarr/src/storage/model"
|
||||
)
|
||||
|
||||
func (s *SQLiteStorage) CreateFeed(params model.CreateFeedParams) *model.Feed {
|
||||
title := params.Title
|
||||
if title == "" {
|
||||
title = params.FeedLink
|
||||
}
|
||||
row := s.db.QueryRow(`
|
||||
insert into feeds (title, description, link, feed_link, folder_id)
|
||||
values (:title, :description, :link, :feed_link, :folder_id)
|
||||
on conflict (feed_link) do update set folder_id = :folder_id
|
||||
returning id`,
|
||||
sql.Named("title", title),
|
||||
sql.Named("description", params.Description),
|
||||
sql.Named("link", params.Link),
|
||||
sql.Named("feed_link", params.FeedLink),
|
||||
sql.Named("folder_id", params.FolderID),
|
||||
)
|
||||
|
||||
var id int64
|
||||
err := row.Scan(&id)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return nil
|
||||
}
|
||||
return &model.Feed{
|
||||
Id: id,
|
||||
Title: title,
|
||||
Description: params.Description,
|
||||
Link: params.Link,
|
||||
FeedLink: params.FeedLink,
|
||||
FolderId: params.FolderID,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) DeleteFeed(feedId int64) bool {
|
||||
result, err := s.db.Exec(`delete from feeds where id = :id`, sql.Named("id", feedId))
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return false
|
||||
}
|
||||
nrows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
if err != sql.ErrNoRows {
|
||||
log.Print(err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return nrows == 1
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) UpdateFeed(feedId int64, params model.UpdateFeedParams) (bool, error) {
|
||||
_, err := s.db.Exec(`
|
||||
update feeds set
|
||||
title = coalesce(:title, title),
|
||||
feed_link = coalesce(:feed_link, feed_link),
|
||||
folder_id = case when :update_folder_id then :folder_id else folder_id end,
|
||||
icon = case when :update_icon then :icon else icon end
|
||||
where id = :id
|
||||
`,
|
||||
sql.Named("id", feedId),
|
||||
sql.Named("title", params.Title),
|
||||
sql.Named("feed_link", params.FeedLink),
|
||||
sql.Named("update_folder_id", params.FolderID.Set),
|
||||
sql.Named("folder_id", params.FolderID.Value),
|
||||
sql.Named("update_icon", params.Icon.Set),
|
||||
sql.Named("icon", params.Icon.Value),
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) ListFeeds() []model.Feed {
|
||||
result := make([]model.Feed, 0)
|
||||
rows, err := s.db.Query(`
|
||||
select id, folder_id, title, description, link, feed_link,
|
||||
ifnull(length(icon), 0) > 0 as has_icon
|
||||
from feeds
|
||||
order by title collate nocase
|
||||
`)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return result
|
||||
}
|
||||
for rows.Next() {
|
||||
var f model.Feed
|
||||
err = rows.Scan(
|
||||
&f.Id,
|
||||
&f.FolderId,
|
||||
&f.Title,
|
||||
&f.Description,
|
||||
&f.Link,
|
||||
&f.FeedLink,
|
||||
&f.HasIcon,
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return result
|
||||
}
|
||||
result = append(result, f)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) GetFeed(id int64) *model.Feed {
|
||||
var f model.Feed
|
||||
err := s.db.QueryRow(`
|
||||
select
|
||||
id, folder_id, title, link, feed_link,
|
||||
icon, ifnull(icon, '') != '' as has_icon
|
||||
from feeds where id = :id
|
||||
`, sql.Named("id", id)).Scan(
|
||||
&f.Id, &f.FolderId, &f.Title, &f.Link, &f.FeedLink,
|
||||
&f.Icon, &f.HasIcon,
|
||||
)
|
||||
if err != nil {
|
||||
if err != sql.ErrNoRows {
|
||||
log.Print(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return &f
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/nkanaev/yarr/src/storage/model"
|
||||
)
|
||||
|
||||
func (s *SQLiteStorage) ListFeedStates() ([]model.FeedState, error) {
|
||||
rows, err := s.db.Query(`
|
||||
select
|
||||
feed_id
|
||||
, last_refreshed
|
||||
, last_error
|
||||
, http_lmod
|
||||
, http_etag
|
||||
from feed_states
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
states := make([]model.FeedState, 0)
|
||||
for rows.Next() {
|
||||
var state model.FeedState
|
||||
err := rows.Scan(
|
||||
&state.FeedID,
|
||||
&state.LastRefreshed,
|
||||
&state.LastError,
|
||||
&state.HTTPLastModified,
|
||||
&state.HTTPEtag,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
states = append(states, state)
|
||||
}
|
||||
return states, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) GetFeedState(feedID int64) (*model.FeedState, error) {
|
||||
var state model.FeedState
|
||||
err := s.db.QueryRow(`
|
||||
select
|
||||
feed_id
|
||||
, last_refreshed
|
||||
, last_error
|
||||
, http_lmod
|
||||
, http_etag
|
||||
from feed_states where feed_id = :id
|
||||
`, sql.Named("id", feedID)).Scan(
|
||||
&state.FeedID,
|
||||
&state.LastRefreshed,
|
||||
&state.LastError,
|
||||
&state.HTTPLastModified,
|
||||
&state.HTTPEtag,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) UpdateFeedState(feedID int64, params model.UpdateFeedStateParams) (bool, error) {
|
||||
lastError := params.LastError
|
||||
if lastError != nil && *lastError == "" {
|
||||
lastError = nil
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(`
|
||||
insert into feed_states (
|
||||
feed_id
|
||||
, last_refreshed
|
||||
, last_error
|
||||
, http_lmod
|
||||
, http_etag
|
||||
)
|
||||
values (
|
||||
:id
|
||||
, coalesce(:last_refreshed, 0)
|
||||
, coalesce(:last_error, '')
|
||||
, coalesce(:http_lmod, '')
|
||||
, coalesce(:http_etag, '')
|
||||
)
|
||||
on conflict (feed_id) do update set
|
||||
last_refreshed = coalesce(:last_refreshed, last_refreshed),
|
||||
last_error = coalesce(:last_error, last_error),
|
||||
http_lmod = coalesce(:http_lmod, http_lmod),
|
||||
http_etag = coalesce(:http_etag, http_etag)
|
||||
`,
|
||||
sql.Named("id", feedID),
|
||||
sql.Named("last_refreshed", params.LastRefreshed),
|
||||
sql.Named("last_error", params.LastError),
|
||||
sql.Named("http_lmod", params.HTTPLastModified),
|
||||
sql.Named("http_etag", params.HTTPEtag),
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
|
||||
"github.com/nkanaev/yarr/src/storage/model"
|
||||
)
|
||||
|
||||
func (s *SQLiteStorage) CreateFolder(title string) *model.Folder {
|
||||
expanded := true
|
||||
row := s.db.QueryRow(`
|
||||
insert into folders (title, is_expanded) values (:title, :is_expanded)
|
||||
on conflict (title) do update set title = :title
|
||||
returning id`,
|
||||
sql.Named("title", title),
|
||||
sql.Named("is_expanded", expanded),
|
||||
)
|
||||
var id int64
|
||||
err := row.Scan(&id)
|
||||
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return nil
|
||||
}
|
||||
return &model.Folder{Id: id, Title: title, IsExpanded: expanded}
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) DeleteFolder(folderId int64) bool {
|
||||
_, err := s.db.Exec(`delete from folders where id = :id`, sql.Named("id", folderId))
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
}
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) UpdateFolder(folderId int64, params model.UpdateFolderParams) (bool, error) {
|
||||
_, err := s.db.Exec(`
|
||||
update folders set
|
||||
title = coalesce(:title, title),
|
||||
is_expanded = coalesce(:is_expanded, is_expanded)
|
||||
where id = :id
|
||||
`,
|
||||
sql.Named("id", folderId),
|
||||
sql.Named("title", params.Title),
|
||||
sql.Named("is_expanded", params.IsExpanded),
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) ListFolders() []model.Folder {
|
||||
result := make([]model.Folder, 0)
|
||||
rows, err := s.db.Query(`
|
||||
select id, title, is_expanded
|
||||
from folders
|
||||
order by title collate nocase
|
||||
`)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return result
|
||||
}
|
||||
for rows.Next() {
|
||||
var f model.Folder
|
||||
err = rows.Scan(&f.Id, &f.Title, &f.IsExpanded)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return result
|
||||
}
|
||||
result = append(result, f)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nkanaev/yarr/src/storage/model"
|
||||
)
|
||||
|
||||
type MediaLinks model.MediaLinks
|
||||
|
||||
func (m *MediaLinks) Scan(src any) error {
|
||||
switch data := src.(type) {
|
||||
case []byte:
|
||||
return json.Unmarshal(data, m)
|
||||
case string:
|
||||
return json.Unmarshal([]byte(data), m)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m MediaLinks) Value() (driver.Value, error) {
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) CreateItems(items []model.Item) bool {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return false
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
slices.SortStableFunc(items, func(a, b model.Item) int {
|
||||
sa := a.Date.Format(time.RFC3339) + "::" + a.GUID
|
||||
sb := b.Date.Format(time.RFC3339) + "::" + b.GUID
|
||||
return cmp.Compare(sa, sb)
|
||||
})
|
||||
|
||||
for _, item := range items {
|
||||
_, err = tx.Exec(`
|
||||
insert into items (
|
||||
guid, feed_id, title, link, date,
|
||||
content, media_links,
|
||||
date_arrived, last_arrived, status
|
||||
)
|
||||
values (
|
||||
:guid, :feed_id, :title, :link, strftime('%Y-%m-%d %H:%M:%f', :date),
|
||||
:content, :media_links,
|
||||
:date_arrived, :last_arrived, :status
|
||||
)
|
||||
on conflict (feed_id, guid) do update set
|
||||
last_arrived = :last_arrived`,
|
||||
sql.Named("guid", item.GUID),
|
||||
sql.Named("feed_id", item.FeedId),
|
||||
sql.Named("title", item.Title),
|
||||
sql.Named("link", item.Link),
|
||||
sql.Named("date", item.Date),
|
||||
sql.Named("content", item.Content),
|
||||
sql.Named("media_links", MediaLinks(item.MediaLinks)),
|
||||
sql.Named("date_arrived", now),
|
||||
sql.Named("last_arrived", now),
|
||||
sql.Named("status", item.Status),
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
if err = tx.Rollback(); err != nil {
|
||||
log.Print(err)
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
log.Print(err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func listQueryPredicate(filter model.ItemFilter, newestFirst bool) (string, []any) {
|
||||
cond := make([]string, 0)
|
||||
args := make([]any, 0)
|
||||
if filter.FolderID != nil {
|
||||
cond = append(cond, "i.feed_id in (select id from feeds where folder_id = :folder_id)")
|
||||
args = append(args, sql.Named("folder_id", *filter.FolderID))
|
||||
}
|
||||
if filter.FeedID != nil {
|
||||
cond = append(cond, "i.feed_id = :feed_id")
|
||||
args = append(args, sql.Named("feed_id", *filter.FeedID))
|
||||
}
|
||||
if filter.Status != nil {
|
||||
cond = append(cond, "i.status = :status")
|
||||
args = append(args, sql.Named("status", *filter.Status))
|
||||
}
|
||||
if filter.Search != nil {
|
||||
words := strings.Fields(*filter.Search)
|
||||
terms := make([]string, len(words))
|
||||
for idx, word := range words {
|
||||
terms[idx] = word + "*"
|
||||
}
|
||||
|
||||
cond = append(
|
||||
cond,
|
||||
"i.id in (select rowid as id from search where search match :search)",
|
||||
)
|
||||
args = append(args, sql.Named("search", 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 = :after_id)",
|
||||
compare,
|
||||
),
|
||||
)
|
||||
args = append(args, sql.Named("after_id", *filter.After))
|
||||
}
|
||||
if filter.IDs != nil && len(*filter.IDs) > 0 {
|
||||
qmarks := make([]string, len(*filter.IDs))
|
||||
for i, id := range *filter.IDs {
|
||||
name := fmt.Sprintf("id%d", i)
|
||||
qmarks[i] = ":" + name
|
||||
args = append(args, sql.Named(name, id))
|
||||
}
|
||||
cond = append(cond, "i.id in ("+strings.Join(qmarks, ",")+")")
|
||||
}
|
||||
if filter.SinceID != nil {
|
||||
cond = append(cond, "i.id > :since_id")
|
||||
args = append(args, sql.Named("since_id", filter.SinceID))
|
||||
}
|
||||
if filter.MaxID != nil {
|
||||
cond = append(cond, "i.id < :max_id")
|
||||
args = append(args, sql.Named("max_id", filter.MaxID))
|
||||
}
|
||||
if filter.Before != nil {
|
||||
cond = append(cond, "i.date < :before")
|
||||
args = append(args, sql.Named("before", filter.Before))
|
||||
}
|
||||
|
||||
predicate := "1"
|
||||
if len(cond) > 0 {
|
||||
predicate = strings.Join(cond, " and ")
|
||||
}
|
||||
|
||||
return predicate, args
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) CountItems() int {
|
||||
var count int
|
||||
err := s.db.QueryRow(`select count(*) from items`).Scan(&count)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return 0
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) ListItems(
|
||||
filter model.ItemFilter,
|
||||
limit int,
|
||||
newestFirst bool,
|
||||
withContent bool,
|
||||
) []model.Item {
|
||||
predicate, args := listQueryPredicate(filter, newestFirst)
|
||||
result := make([]model.Item, 0)
|
||||
|
||||
order := "date desc, id desc"
|
||||
if !newestFirst {
|
||||
order = "date asc, id asc"
|
||||
}
|
||||
if filter.IDs != nil || filter.SinceID != nil {
|
||||
order = "i.id asc"
|
||||
}
|
||||
if filter.MaxID != nil {
|
||||
order = "i.id desc"
|
||||
}
|
||||
|
||||
selectCols := "i.id, i.guid, i.feed_id, i.title, i.link, i.date, i.status, i.media_links"
|
||||
if withContent {
|
||||
selectCols += ", i.content"
|
||||
} else {
|
||||
selectCols += ", '' as content"
|
||||
}
|
||||
query := fmt.Sprintf(`
|
||||
select %s
|
||||
from items i
|
||||
where %s
|
||||
order by %s
|
||||
limit %d
|
||||
`, selectCols, predicate, order, limit)
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return result
|
||||
}
|
||||
for rows.Next() {
|
||||
var x model.Item
|
||||
err = rows.Scan(
|
||||
&x.Id, &x.GUID, &x.FeedId,
|
||||
&x.Title, &x.Link, &x.Date,
|
||||
&x.Status, (*MediaLinks)(&x.MediaLinks), &x.Content,
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return result
|
||||
}
|
||||
result = append(result, x)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) GetItem(id int64) *model.Item {
|
||||
i := &model.Item{}
|
||||
err := s.db.QueryRow(`
|
||||
select
|
||||
i.id, i.guid, i.feed_id, i.title, i.link, i.content,
|
||||
i.date, i.status, i.media_links
|
||||
from items i
|
||||
where i.id = :id
|
||||
`, sql.Named("id", id)).Scan(
|
||||
&i.Id, &i.GUID, &i.FeedId, &i.Title, &i.Link, &i.Content,
|
||||
&i.Date, &i.Status, (*MediaLinks)(&i.MediaLinks),
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return nil
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) UpdateItem(id int64, params model.UpdateItemParams) bool {
|
||||
sets := make([]string, 0)
|
||||
args := make([]any, 0)
|
||||
if params.Title != nil {
|
||||
sets = append(sets, "title = :title")
|
||||
args = append(args, sql.Named("title", *params.Title))
|
||||
}
|
||||
if params.Status != nil {
|
||||
sets = append(sets, "status = :status")
|
||||
args = append(args, sql.Named("status", *params.Status))
|
||||
}
|
||||
if params.LastArrived != nil {
|
||||
sets = append(sets, "last_arrived = :last_arrived")
|
||||
args = append(args, sql.Named("last_arrived", *params.LastArrived))
|
||||
}
|
||||
if len(sets) == 0 {
|
||||
return true
|
||||
}
|
||||
args = append(args, sql.Named("id", id))
|
||||
query := fmt.Sprintf("update items set %s where id = :id", strings.Join(sets, ", "))
|
||||
_, err := s.db.Exec(query, args...)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) DeleteItem(id int64) bool {
|
||||
_, err := s.db.Exec(`delete from items where id = :id`, sql.Named("id", id))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) UpdateItemStatus(item_id int64, status model.ItemStatus) bool {
|
||||
_, err := s.db.Exec(`update items set status = :status where id = :id`,
|
||||
sql.Named("status", status),
|
||||
sql.Named("id", item_id),
|
||||
)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) MarkItemsRead(filter model.MarkFilter) bool {
|
||||
predicate, args := listQueryPredicate(model.ItemFilter{
|
||||
FolderID: filter.FolderID,
|
||||
FeedID: filter.FeedID,
|
||||
Before: filter.Before,
|
||||
}, false)
|
||||
query := fmt.Sprintf(`
|
||||
update items as i set status = %d
|
||||
where %s and i.status != %d
|
||||
`, model.READ, predicate, model.STARRED)
|
||||
_, err := s.db.Exec(query, args...)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
}
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) FeedStats() []model.FeedStat {
|
||||
result := make([]model.FeedStat, 0)
|
||||
rows, err := s.db.Query(fmt.Sprintf(`
|
||||
select
|
||||
feed_id,
|
||||
sum(case status when %d then 1 else 0 end),
|
||||
sum(case status when %d then 1 else 0 end)
|
||||
from items
|
||||
group by feed_id
|
||||
`, model.UNREAD, model.STARRED))
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return result
|
||||
}
|
||||
for rows.Next() {
|
||||
stat := model.FeedStat{}
|
||||
rows.Scan(&stat.FeedId, &stat.UnreadCount, &stat.StarredCount)
|
||||
result = append(result, stat)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
var (
|
||||
itemsKeepSize = 50
|
||||
itemsKeepDays = 90
|
||||
)
|
||||
|
||||
// Delete old articles from the database to cleanup space.
|
||||
//
|
||||
// The rules:
|
||||
// - Never delete starred entries.
|
||||
// - Keep at least 50 latest items for each feed.
|
||||
// - Delete entries older than 90 days relative to the latest arrived item in the same feed.
|
||||
func (s *SQLiteStorage) DeleteOldItems() {
|
||||
result, err := s.db.Exec(`
|
||||
delete from items
|
||||
where id in (
|
||||
select id
|
||||
from (
|
||||
select
|
||||
id,
|
||||
row_number() over (partition by feed_id order by date desc) as rn,
|
||||
last_arrived,
|
||||
max(last_arrived) over (partition by feed_id) as max_la
|
||||
from items
|
||||
where status != :starred_status
|
||||
)
|
||||
where rn > :keep_size
|
||||
and last_arrived < datetime(max_la, :keep_days_limit)
|
||||
)`,
|
||||
sql.Named("starred_status", model.STARRED),
|
||||
sql.Named("keep_size", itemsKeepSize),
|
||||
sql.Named("keep_days_limit", fmt.Sprintf("-%d days", itemsKeepDays)),
|
||||
)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return
|
||||
}
|
||||
numDeleted, err := result.RowsAffected()
|
||||
if err == nil && numDeleted > 0 {
|
||||
log.Printf("Deleted %d old items", numDeleted)
|
||||
|
||||
if _, err := s.db.Exec("vacuum"); err != nil {
|
||||
log.Print(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log"
|
||||
|
||||
"github.com/nkanaev/yarr/src/storage/model"
|
||||
)
|
||||
|
||||
func (s *SQLiteStorage) GetSettings() model.Settings {
|
||||
result := model.SettingsDefault()
|
||||
rows, err := s.db.Query(`select key, val from settings;`)
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return result
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var key string
|
||||
var val []byte
|
||||
rows.Scan(&key, &val)
|
||||
|
||||
switch key {
|
||||
case "filter":
|
||||
json.Unmarshal(val, &result.Filter)
|
||||
case "feed":
|
||||
json.Unmarshal(val, &result.Feed)
|
||||
case "feed_list_width":
|
||||
json.Unmarshal(val, &result.FeedListWidth)
|
||||
case "item_list_width":
|
||||
json.Unmarshal(val, &result.ItemListWidth)
|
||||
case "sort_newest_first":
|
||||
json.Unmarshal(val, &result.SortNewestFirst)
|
||||
case "theme_name":
|
||||
json.Unmarshal(val, &result.ThemeName)
|
||||
case "theme_font":
|
||||
json.Unmarshal(val, &result.ThemeFont)
|
||||
case "theme_size":
|
||||
json.Unmarshal(val, &result.ThemeSize)
|
||||
case "refresh_rate":
|
||||
json.Unmarshal(val, &result.RefreshRate)
|
||||
case "language":
|
||||
json.Unmarshal(val, &result.Language)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *SQLiteStorage) UpdateSettings(params model.UpdateSettingsParams) bool {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return false
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
update := func(key string, val any) error {
|
||||
valEncoded, err := json.Marshal(val)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`
|
||||
insert into settings (key, val) values (:key, :val)
|
||||
on conflict (key) do update set val=:val`,
|
||||
sql.Named("key", key),
|
||||
sql.Named("val", valEncoded),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
var errs []error
|
||||
if params.Filter != nil {
|
||||
errs = append(errs, update("filter", *params.Filter))
|
||||
}
|
||||
if params.Feed != nil {
|
||||
errs = append(errs, update("feed", *params.Feed))
|
||||
}
|
||||
if params.FeedListWidth != nil {
|
||||
errs = append(errs, update("feed_list_width", *params.FeedListWidth))
|
||||
}
|
||||
if params.ItemListWidth != nil {
|
||||
errs = append(errs, update("item_list_width", *params.ItemListWidth))
|
||||
}
|
||||
if params.SortNewestFirst != nil {
|
||||
errs = append(errs, update("sort_newest_first", *params.SortNewestFirst))
|
||||
}
|
||||
if params.ThemeName != nil {
|
||||
errs = append(errs, update("theme_name", *params.ThemeName))
|
||||
}
|
||||
if params.ThemeFont != nil {
|
||||
errs = append(errs, update("theme_font", *params.ThemeFont))
|
||||
}
|
||||
if params.ThemeSize != nil {
|
||||
errs = append(errs, update("theme_size", *params.ThemeSize))
|
||||
}
|
||||
if params.RefreshRate != nil {
|
||||
errs = append(errs, update("refresh_rate", *params.RefreshRate))
|
||||
}
|
||||
if params.Language != nil {
|
||||
errs = append(errs, update("language", *params.Language))
|
||||
}
|
||||
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
log.Print(err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Print(err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||