95 Commits
v1.0 ... v1.2

Author SHA1 Message Date
Nazar Kanaev
4f1e029d0b v1.2 2021-02-11 20:57:20 +00:00
Nazar Kanaev
9b93a959e5 fix: deleting item when all feeds is selected causes ui crash 2021-02-11 20:56:27 +00:00
Nazar Kanaev
68d269658f mobile/tablet layout tweaks 2021-02-11 20:37:30 +00:00
Nazar Kanaev
875b87b0d6 non-draggable in mobile/tablet resolution 2021-02-11 20:37:30 +00:00
Nazar Kanaev
3e79cd7944 todo entry 2021-02-05 11:15:07 +00:00
Nazar Kanaev
0ea8972ede update todo 2021-02-03 21:30:25 +00:00
Nazar Kanaev
a7113addd0 public todo 2021-02-03 21:24:06 +00:00
Nazar Kanaev
84df847898 platform notes 2021-02-03 21:18:09 +00:00
Nazar Kanaev
333d9373dd break long words 2021-02-01 21:19:17 +00:00
Nazar Kanaev
eb90c5b9aa attribution 2021-01-29 13:33:31 +00:00
Nazar Kanaev
3371e1afff add changelog 2021-01-27 15:46:57 +00:00
Nazar Kanaev
8aafb1b729 open urls with basepath 2021-01-27 15:46:38 +00:00
hcl
1a0db29aa6 prevent route leak 2021-01-25 21:02:29 +00:00
hcl
52073e7e81 make route handling better 2021-01-25 21:02:29 +00:00
hcl
4f79c919f0 Add non-root url path support 2021-01-25 21:02:29 +00:00
Nazar Kanaev
63b265fa04 move hacking.md 2021-01-19 23:35:39 +00:00
Nazar Kanaev
6b01d9d7b9 linux installation guide 2021-01-19 23:35:29 +00:00
Nazar Kanaev
20a0a6724a open flag 2021-01-18 23:16:13 +00:00
Nazar Kanaev
e79cb9e6e0 done 2021-01-07 12:06:40 +00:00
Nazar Kanaev
d7ddcc04b5 show feed errors 2021-01-07 12:06:14 +00:00
Nazar Kanaev
99684a4b2f store feed errors 2021-01-07 11:32:42 +00:00
Nazar Kanaev
6a6153ca48 fix unsafe methods 2021-01-04 14:46:36 +00:00
Nazar Kanaev
23a4ff3af6 https 2021-01-04 14:40:25 +00:00
Nazar Kanaev
d6c2ba5812 simple csrf protection 2021-01-04 14:15:28 +00:00
Nazar Kanaev
fa0237b546 remove todo 2021-01-04 14:04:48 +00:00
Nazar Kanaev
9fcaad6b2f hmac-based auth 2021-01-04 14:03:51 +00:00
Nazar Kanaev
edc7d56219 log db file 2021-01-04 11:51:31 +00:00
Nazar Kanaev
d0a2b80ecc logout ui 2020-12-16 16:49:35 +00:00
Nazar Kanaev
e2d80af81d login 2020-12-16 16:24:50 +00:00
Nazar Kanaev
eccd383c1c rename param auth -> auth-file 2020-12-16 15:58:32 +00:00
Nazar Kanaev
db7a178a8d remove fever code 2020-12-16 00:02:45 +00:00
Nazar Kanaev
62e0caa950 drop fever support 2020-12-15 23:49:26 +00:00
Nazar Kanaev
46d8c98aff remove slash 2020-11-10 23:42:17 +00:00
Nazar Kanaev
05634ebdb7 rename skipauth -> manualauth 2020-11-10 23:34:38 +00:00
Nazar Kanaev
0e2da62081 login page 2020-11-03 21:54:55 +00:00
Nazar Kanaev
94d1659ad5 remove prints 2020-11-03 21:51:16 +00:00
Nazar Kanaev
0745c92e9a auth parameter 2020-11-03 20:58:26 +00:00
Nazar Kanaev
7c06952a7d rename vars 2020-11-03 20:42:54 +00:00
Nazar Kanaev
e2d8ca3506 fever api fixes 2020-11-01 15:57:52 +00:00
Nazar Kanaev
4f20f537c0 remove todo 2020-11-01 15:51:15 +00:00
Nazar Kanaev
a0b42b27b3 fever: handle invalid requests 2020-11-01 15:50:52 +00:00
Nazar Kanaev
288fa3979a refactoring 2020-11-01 15:49:04 +00:00
Nazar Kanaev
e4cc96ef09 fever api feed last_updated_on_time 2020-11-01 15:45:58 +00:00
Nazar Kanaev
60a947f131 fever api last_refreshed_on_time 2020-11-01 15:33:24 +00:00
Nazar Kanaev
0226c8da23 fever item endpoint max_id parameter 2020-11-01 15:27:25 +00:00
Nazar Kanaev
b0364087ad fever write api [wip] 2020-10-25 16:29:26 +00:00
Nazar Kanaev
40a9773beb fever hot links stub 2020-10-22 22:30:44 +01:00
Nazar Kanaev
790a275443 fever with_ids fix 2020-10-22 22:24:50 +01:00
Nazar Kanaev
9b9addf3e6 fever items (with_ids, since_id) 2020-10-20 22:52:53 +01:00
Nazar Kanaev
57d2437e9c fever favicons endpoint 2020-10-20 21:57:02 +01:00
Nazar Kanaev
a13aea478e attack of the monster feeds from the future 2020-10-20 21:29:36 +01:00
Nazar Kanaev
6def522f38 http states field for future health check 2020-10-20 20:59:35 +01:00
Nazar Kanaev
6a63d49823 go fmt 2020-10-20 20:54:05 +01:00
Nazar Kanaev
b766cb4ac5 minor settings fix 2020-10-20 20:52:09 +01:00
Nazar Kanaev
32ab1fefa9 change http state storage 2020-10-20 20:48:01 +01:00
Nazar Kanaev
70761c47eb minor ui fix 2020-10-20 19:10:25 +01:00
Nazar Kanaev
6a09d52b85 add missing fever endpoints 2020-10-19 22:05:38 +01:00
Nazar Kanaev
fcaf23d6bc initial fever api 2020-10-19 21:59:14 +01:00
Nazar Kanaev
54c2a6458d fix deleting feeds 2020-10-18 15:40:06 +01:00
Nazar Kanaev
05032ec428 search for favicon right after adding new feed 2020-10-17 22:29:20 +01:00
Nazar Kanaev
e24b905adc ignore all feeds with 4xx and 5xx errors 2020-10-17 13:40:00 +01:00
Nazar Kanaev
f27d0c4cd7 conditional http get 2020-10-17 13:27:12 +01:00
Nazar Kanaev
11a2aa2b4a always run sql init script on start 2020-10-17 12:50:46 +01:00
Nazar Kanaev
2eee8baa26 store http state 2020-10-17 12:47:45 +01:00
Nazar Kanaev
0949ffc027 use minute interval 2020-10-17 12:41:40 +01:00
Nazar Kanaev
286538c5d0 break long words 2020-10-16 14:58:22 +01:00
Nazar Kanaev
78844def40 refresh rate 2020-10-13 22:16:24 +01:00
Nazar Kanaev
e17ce0fb31 refresh rate ui 2020-10-12 20:42:30 +01:00
Yang Bin
55a1c297be fix docker image issues
- use alpine as image base
- add ca-certificates to fix https failed
- define default db file
- expose http port
2020-10-09 22:32:57 +01:00
nkanaev
9bb7ae7902 Update hacking.md 2020-10-06 12:22:01 +01:00
Nazar Kanaev
bcab24ebfa v1.1 2020-10-05 22:27:09 +01:00
Nazar Kanaev
dd058e1637 autogenerate version 2020-10-05 21:59:53 +01:00
nkanaev
63f624251d Update readme.md 2020-10-05 16:16:04 +01:00
Nazar Kanaev
05ee99b4d9 update build.yml 2020-10-05 15:39:19 +01:00
Nazar Kanaev
d5cc9149e3 do not refresh items after feed is deselected 2020-10-03 21:33:35 +01:00
Nazar Kanaev
eb2029132c oopsies 2020-10-03 20:54:51 +01:00
Nazar Kanaev
492e77fd25 done 2020-10-03 13:01:22 +01:00
Nazar Kanaev
b8aa15d554 tablet & mobile layouts 2020-10-03 12:57:30 +01:00
Nazar Kanaev
ee0b440b7b bug fix 2020-10-02 22:14:01 +01:00
Nazar Kanaev
72da9df5ac space 2020-10-02 21:15:55 +01:00
Nazar Kanaev
9872bf84f0 truncate choice text 2020-10-02 20:09:03 +01:00
Nazar Kanaev
6222761dd3 fix favicon fetch bug 2020-10-02 16:49:34 +01:00
Nazar Kanaev
71cc8929ad remove log 2020-10-02 15:18:57 +01:00
Nazar Kanaev
8007853a9a tweak timeouts 2020-10-02 15:17:59 +01:00
Nazar Kanaev
ffc506371c default build 2020-10-02 12:38:33 +01:00
Nazar Kanaev
00fed5e0cf show addr in logs 2020-10-02 12:37:50 +01:00
Nazar Kanaev
6937c349f0 rename assets go file 2020-10-01 21:57:34 +01:00
Nazar Kanaev
bac136603b move icons to platform package 2020-10-01 21:53:48 +01:00
Nazar Kanaev
693b5bcb8d handle server error 2020-09-25 19:23:38 +01:00
Nazar Kanaev
9c7d95f632 tweak 2020-09-25 11:59:08 +01:00
Nazar Kanaev
b8adb3fa2f fluid width 2020-09-25 11:32:15 +01:00
nkanaev
5652b240dc Update hacking.md 2020-09-25 00:05:57 +01:00
nkanaev
c1c0ffab01 Update hacking.md 2020-09-24 23:55:23 +01:00
Nazar Kanaev
349a8980f2 hacking 2020-09-24 23:54:01 +01:00
nkanaev
0bd117804d Update build.yml 2020-09-24 23:28:38 +01:00
37 changed files with 1096 additions and 244 deletions

View File

@@ -2,7 +2,7 @@ name: build
on:
push:
branches: [master]
tags: [v*]
jobs:
build_macos:
@@ -11,23 +11,42 @@ jobs:
steps:
- {name: "Checkout", uses: actions/checkout@v2}
- {name: "Checkout gofeed", uses: actions/checkout@v2, with: {repository: nkanaev/gofeed, path: gofeed}}
- name: Cache Go Modules
uses: actions/cache@v2
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: "Build"
run: |
go version
make build_macos
cd _output/macos && zip -r yarr-macos.zip yarr.app
- {name: "Upload", uses: actions/upload-artifact@v2, with: {name: macos, path: _output/macos/yarr-macos.zip}}
run: make build_macos
- name: Upload
uses: actions/upload-artifact@v2
with:
name: macos
path: _output/macos/yarr.app
build_windows:
name: Build for Windows
runs-on: windows-2019
steps:
- {name: "Checkout", uses: actions/checkout@v2}
- {name: "Checkout gofeed", uses: actions/checkout@v2, with: {repository: nkanaev/gofeed, path: gofeed}}
- name: Cache Go Modules
uses: actions/cache@v2
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: "Build"
run: |
go version
make build_windows
- {name: "Upload", uses: actions/upload-artifact@v2, with: {name: windows, path: _output/windows/yarr.exe}}
run: make build_windows
- name: Upload
uses: actions/upload-artifact@v2
with:
name: windows
path: _output/windows/yarr.exe
build_linux:
name: Build for Linux
runs-on: ubuntu-18.04
@@ -35,9 +54,73 @@ jobs:
- {name: "Checkout", uses: actions/checkout@v2}
- {name: "Checkout gofeed", uses: actions/checkout@v2, with: {repository: nkanaev/gofeed, path: gofeed}}
- {name: "Setup Go", uses: actions/setup-go@v2, with: {go-version: '^1.14'}}
- name: Cache Go Modules
uses: actions/cache@v2
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: "Build"
run: make build_linux
- name: Upload
uses: actions/upload-artifact@v2
with:
name: linux
path: _output/linux/yarr
create_release:
name: Create Release
runs-on: ubuntu-latest
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@v2
with:
path: .
- name: Preparation
run: |
go version
make build_linux
cd _output/linux && zip -r yarr-linux.zip yarr
- {name: "Upload", uses: actions/upload-artifact@v2, with: {name: linux, path: _output/linux/yarr-linux.zip}}
ls -R
chmod u+x macos/Contents/MacOS/yarr
chmod u+x linux/yarr
mv macos yarr.app && zip -r yarr-macos.zip yarr.app
mv windows/yarr.exe . && zip yarr-windows.zip yarr.exe
mv linux/yarr . && zip yarr-linux.zip yarr
- name: Upload MacOS
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./yarr-macos.zip
asset_name: yarr-${{ github.ref }}-macos64.zip
asset_content_type: application/zip
- name: Upload Windows
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./yarr-windows.zip
asset_name: yarr-${{ github.ref }}-windows32.zip
asset_content_type: application/zip
- name: Upload Linux
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./yarr-linux.zip
asset_name: yarr-${{ github.ref }}-linux32.zip
asset_content_type: application/zip

3
.gitignore vendored
View File

@@ -1,6 +1,7 @@
/server/assets_bundle.go
/server/assets.go
/gofeed
/_output
/yarr
*.db
*.syso
versioninfo.rc

View File

@@ -1,26 +0,0 @@
1 VERSIONINFO
FILEVERSION 1,0,0,0
PRODUCTVERSION 1,0,0,0
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "080904E4"
BEGIN
VALUE "CompanyName", "Old MacDonald's Farm"
VALUE "FileDescription", "Yet another RSS reader"
VALUE "FileVersion", "1.0"
VALUE "InternalName", "yarr"
VALUE "LegalCopyright", "nkanaev"
VALUE "OriginalFilename", "yarr.exe"
VALUE "ProductName", "yarr"
VALUE "ProductVersion", "1.0"
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x809, 1252
END
END
1 ICON "icon.ico"

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-alert-circle"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>

After

Width:  |  Height:  |  Size: 356 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-left"><polyline points="15 18 9 12 15 6"></polyline></svg>

After

Width:  |  Height:  |  Size: 270 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-log-out"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>

After

Width:  |  Height:  |  Size: 367 B

View File

@@ -6,11 +6,12 @@
<link rel="stylesheet" href="./static/stylesheets/bootstrap.min.css">
<link rel="stylesheet" href="./static/stylesheets/app.css">
<link rel="icon shortcut" href="./static/graphicarts/anchor.png">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
</head>
<body class="theme-light">
<div class="wrapper d-flex vh-100" id="app" v-cloak>
<div id="app" class="d-flex" :class="{'feed-selected': feedSelected !== null, 'item-selected': itemSelected !== null}" v-cloak>
<!-- feed list -->
<div class="vh-100 position-relative d-flex flex-column border-right flex-shrink-0" :style="{width: feedListWidth+'px'}">
<div id="col-feed-list" class="vh-100 position-relative d-flex flex-column border-right flex-shrink-0" :style="{width: feedListWidth+'px'}">
<drag :width="feedListWidth" @resize="resizeFeedList"></drag>
<div class="p-2 toolbar d-flex align-items-center">
<div class="icon mx-2">{% inline "anchor.svg" %}</div>
@@ -55,7 +56,18 @@
<span class="icon mr-1">{% inline "rotate-cw.svg" %}</span>
Refresh Feeds
</b-dropdown-item-button>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-header>Refresh</b-dropdown-header>
<b-dropdown-item-button @click.stop="refreshRate = min" v-for="min in [0, 60]">
<span class="icon mr-1" :class="{invisible: refreshRate != min}">{% inline "check.svg" %}</span>
<span v-if="min == 0">Manually</span>
<span v-if="min == 60">Every hour</span>
</b-dropdown-item-button>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-header>Sort by</b-dropdown-header>
<b-dropdown-item-button @click.stop="itemSortNewestFirst=true">
<span class="icon mr-1" :class="{invisible: !itemSortNewestFirst}">{% inline "check.svg" %}</span>
@@ -72,16 +84,21 @@
id="opml-import"
@change="importOPML"
name="opml"
style="opacity: 0; width: 1px; height: 0; position: absolute;">
style="opacity: 0; width: 1px; height: 0; position: absolute; z-index: -1;">
<label class="dropdown-item mb-0 cursor-pointer" for="opml-import">
<span class="icon mr-1">{% inline "download.svg" %}</span>
Import
</label>
</b-dropdown-form>
<b-dropdown-item href="/opml/export">
<b-dropdown-item href="./opml/export">
<span class="icon mr-1">{% inline "upload.svg" %}</span>
Export
</b-dropdown-item>
<b-dropdown-divider v-if="authenticated"></b-dropdown-divider>
<b-dropdown-item-button v-if="authenticated" @click="logout()">
<span class="icon mr-1">{% inline "log-out.svg" %}</span>
Log out
</b-dropdown-item-button>
</b-dropdown>
</div>
<div class="p-2 overflow-auto border-top flex-grow-1">
@@ -92,7 +109,7 @@
<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>
<span class="counter text-right">{{ filteredTotalStats }}</span>
</div>
</label>
<div v-for="folder in foldersWithFeeds">
@@ -108,7 +125,7 @@
{% inline "chevron-right.svg" %}
</span>
<span class="flex-fill text-left text-truncate">{{ folder.title }}</span>
<span class="counter text-right">{{filteredFolderStats[folder.id] || ''}}</span>
<span class="counter text-right">{{ filteredFolderStats[folder.id] || '' }}</span>
</div>
</label>
<div v-show="!folder.id || folder.is_expanded" class="mt-1" :class="{'pl-3': folder.id}">
@@ -120,9 +137,9 @@
<input type="radio" name="feed" :value="'feed:'+feed.id" v-model="feedSelected">
<div class="selectgroup-label d-flex align-items-center w-100">
<span class="icon mr-2" v-if="!feed.has_icon">{% inline "rss.svg" %}</span>
<span class="icon mr-2" v-else><img v-lazy="'/api/feeds/'+feed.id+'/icon'" alt=""></span>
<span class="icon mr-2" v-else><img v-lazy="'./api/feeds/'+feed.id+'/icon'" alt=""></span>
<span class="flex-fill text-left text-truncate">{{ feed.title }}</span>
<span class="counter text-right">{{filteredFeedStats[feed.id] || ''}}</span>
<span class="counter text-right">{{ filteredFeedStats[feed.id] || '' }}</span>
</div>
</label>
</div>
@@ -130,13 +147,18 @@
</div>
<div class="p-2 toolbar d-flex align-items-center border-top flex-shrink-0" v-if="loading.feeds">
<span class="icon loading mx-2"></span>
<span class="text-truncate cursor-default noselect">Refreshing ({{loading.feeds}} left)</span>
<span class="text-truncate cursor-default noselect">Refreshing ({{ loading.feeds }} left)</span>
</div>
</div>
<!-- item list -->
<div class="vh-100 position-relative d-flex flex-column border-right flex-shrink-0" :style="{width: itemListWidth+'px'}">
<div id="col-item-list" class="vh-100 position-relative d-flex flex-column border-right flex-shrink-0" :style="{width: itemListWidth+'px'}">
<drag :width="itemListWidth" @resize="resizeItemList"></drag>
<div class="px-2 toolbar d-flex align-items-center">
<button class="toolbar-item mr-2 d-block d-md-none"
@click="feedSelected = null"
v-b-tooltip.hover.bottom="'Show Feeds'">
<span class="icon">{% inline "chevron-left.svg" %}</span>
</button>
<div class="input-icon flex-grow-1">
<span class="icon">{% inline "search.svg" %}</span>
<input class="d-block toolbar-search" type="" v-model="itemSearch">
@@ -159,18 +181,18 @@
<span class="icon icon-small mr-1" v-if="item.status=='starred'">{% inline "star-full.svg" %}</span>
</transition>
<small class="flex-fill text-truncate mr-1">
{{feedsById[item.feed_id].title}}
{{ feedsById[item.feed_id].title }}
</small>
<small class="flex-shrink-0"><relative-time :val="item.date"/></small>
</div>
<div>{{item.title || 'untitled'}}</div>
<div>{{ item.title || 'untitled' }}</div>
</div>
</label>
<button class="btn btn-link btn-block loading my-3" v-if="itemsPage.cur < itemsPage.num"></button>
</div>
</div>
<!-- item show -->
<div class="vh-100 d-flex flex-column w-100" style="min-width: 0;">
<div id="col-item" class="vh-100 d-flex flex-column w-100" style="min-width: 0;">
<div class="toolbar px-2 d-flex align-items-center" v-if="itemSelected">
<button class="toolbar-item"
@click="toggleItemStarred(itemSelectedDetails)"
@@ -244,7 +266,7 @@
ref="content"
class="content px-4 pt-3 pb-5 border-top overflow-auto"
:style="{'font-family': theme.font, 'font-size': theme.size + 'rem'}">
<h1><b>{{itemSelectedDetails.title}}</b></h1>
<h1><b>{{ itemSelectedDetails.title }}</b></h1>
<div class="text-muted">
<div>{{ feedsById[itemSelectedDetails.feed_id].title }}</div>
<time>{{ formatDate(itemSelectedDetails.date) }}</time>
@@ -278,8 +300,8 @@
<label class="selectgroup" v-for="choice in feedNewChoice">
<input type="radio" name="feedToAdd" :value="choice.url" v-model="feedNewChoiceSelected">
<div class="selectgroup-label">
<div>{{ choice.title }}</div>
<div :class="{light: choice.title}">{{ choice.url }}</div>
<div class="text-truncate">{{ choice.title }}</div>
<div class="text-truncate" :class="{light: choice.title}">{{ choice.url }}</div>
</div>
</label>
</div>
@@ -299,7 +321,7 @@
<template v-slot:button-content>
<span class="icon">{% inline "more-vertical.svg" %}</span>
</template>
<b-dropdown-header>{{folder.title}}</b-dropdown-header>
<b-dropdown-header>{{ folder.title }}</b-dropdown-header>
<b-dropdown-item @click.prevent="renameFolder(folder)">Rename</b-dropdown-item>
<b-dropdown-divider></b-dropdown-divider>
<b-dropdown-item class="dropdown-danger"
@@ -312,15 +334,20 @@
<div v-for="feed in folder.feeds" class="list-row d-flex align-items-center" :key="feed.id">
<div class="w-100 text-truncate">
<span class="icon mr-2" v-if="!feed.has_icon">{% inline "rss.svg" %}</span>
<span class="icon mr-2" v-else><img v-lazy="'/api/feeds/'+feed.id+'/icon'" alt=""></span>
<span class="icon mr-2" v-else><img v-lazy="'./api/feeds/'+feed.id+'/icon'" alt=""></span>
{{ feed.title }}
</div>
<span class="icon flex-shrink-0 mx-2"
v-b-tooltip.hover.top="feed_errors[feed.id]"
v-if="feed_errors[feed.id]">
{% inline "alert-circle.svg" %}
</span>
<div class="flex-shrink-0">
<b-dropdown right no-caret lazy variant="link" class="settings-dropdown" toggle-class="text-decoration-none">
<template v-slot:button-content>
<span class="icon">{% inline "more-vertical.svg" %}</span>
</template>
<b-dropdown-header>{{feed.title}}</b-dropdown-header>
<b-dropdown-header>{{ feed.title }}</b-dropdown-header>
<b-dropdown-item :href="feed.link" target="_blank" v-if="feed.link">Visit Website</b-dropdown-item>
<b-dropdown-divider v-if="feed.link"></b-dropdown-divider>
<b-dropdown-item @click.prevent="renameFeed(feed)">Rename</b-dropdown-item>

View File

@@ -2,9 +2,12 @@
(function() {
var api = function(method, endpoint, data) {
var headers = {'Content-Type': 'application/json'}
if (['post', 'put', 'delete'].indexOf(method) !== -1)
headers['x-requested-by'] = 'yarr'
return fetch(endpoint, {
method: method,
headers: {'Content-Type': 'application/json'},
headers: headers,
body: JSON.stringify(data),
})
}
@@ -23,71 +26,74 @@
window.api = {
feeds: {
list: function() {
return api('get', '/api/feeds').then(json)
return api('get', './api/feeds').then(json)
},
create: function(data) {
return api('post', '/api/feeds', data).then(json)
return api('post', './api/feeds', data).then(json)
},
update: function(id, data) {
return api('put', '/api/feeds/' + id, data)
return api('put', './api/feeds/' + id, data)
},
delete: function(id) {
return api('delete', '/api/feeds/' + id)
return api('delete', './api/feeds/' + id)
},
list_items: function(id) {
return api('get', '/api/feeds/' + id + '/items').then(json)
return api('get', './api/feeds/' + id + '/items').then(json)
},
refresh: function() {
return api('post', '/api/feeds/refresh')
return api('post', './api/feeds/refresh')
},
list_errors: function() {
return api('get', './api/feeds/errors').then(json)
},
},
folders: {
list: function() {
return api('get', '/api/folders').then(json)
return api('get', './api/folders').then(json)
},
create: function(data) {
return api('post', '/api/folders', data).then(json)
return api('post', './api/folders', data).then(json)
},
update: function(id, data) {
return api('put', '/api/folders/' + id, data)
return api('put', './api/folders/' + id, data)
},
delete: function(id) {
return api('delete', '/api/folders/' + id)
return api('delete', './api/folders/' + id)
},
list_items: function(id) {
return api('get', '/api/folders/' + id + '/items').then(json)
return api('get', './api/folders/' + id + '/items').then(json)
}
},
items: {
list: function(query) {
return api('get', '/api/items' + param(query)).then(json)
return api('get', './api/items' + param(query)).then(json)
},
update: function(id, data) {
return api('put', '/api/items/' + id, data)
return api('put', './api/items/' + id, data)
},
mark_read: function(query) {
return api('put', '/api/items' + param(query))
return api('put', './api/items' + param(query))
},
},
settings: {
get: function() {
return api('get', '/api/settings').then(json)
return api('get', './api/settings').then(json)
},
update: function(data) {
return api('put', '/api/settings', data)
return api('put', './api/settings', data)
},
},
status: function() {
return api('get', '/api/status').then(json)
return api('get', './api/status').then(json)
},
upload_opml: function(form) {
return fetch('/opml/import', {
return fetch('./opml/import', {
method: 'post',
body: new FormData(form),
})
},
crawl: function(url) {
return fetch('/page?url=' + url).then(function(res) {
return fetch('./page?url=' + url).then(function(res) {
return res.text()
})
}

View File

@@ -2,6 +2,11 @@
var TITLE = document.title
function authenticated() {
return /auth=.+/g.test(document.cookie)
}
var FONTS = [
"Arial",
"Courier New",
@@ -80,14 +85,21 @@ Vue.component('drag', {
function dateRepr(d) {
var sec = (new Date().getTime() - d.getTime()) / 1000
var neg = sec < 0
var out = ''
sec = Math.abs(sec)
if (sec < 2700) // less than 45 minutes
return Math.round(sec / 60) + 'm'
out = Math.round(sec / 60) + 'm'
else if (sec < 86400) // less than 24 hours
return Math.round(sec / 3600) + 'h'
out = Math.round(sec / 3600) + 'h'
else if (sec < 604800) // less than a week
return Math.round(sec / 86400) + 'd'
out = Math.round(sec / 86400) + 'd'
else
return d.toLocaleDateString(undefined, {year: "numeric", month: "long", day: "numeric"})
out = d.toLocaleDateString(undefined, {year: "numeric", month: "long", day: "numeric"})
if (neg) return '-' + out
return out
}
Vue.component('relative-time', {
@@ -100,7 +112,7 @@ Vue.component('relative-time', {
'interval': null,
}
},
template: '<time :datetime="val">{{formatted}}</time>',
template: '<time :datetime="val">{{ formatted }}</time>',
mounted: function() {
this.interval = setInterval(function() {
this.formatted = dateRepr(this.date)
@@ -126,11 +138,11 @@ var vm = new Vue({
},
data: function() {
return {
'filterSelected': null,
'filterSelected': undefined,
'folders': [],
'feeds': [],
'feedSelected': null,
'feedListWidth': null,
'feedSelected': undefined,
'feedListWidth': undefined,
'feedNewChoice': [],
'feedNewChoiceSelected': '',
'items': [],
@@ -142,8 +154,8 @@ var vm = new Vue({
'itemSelectedDetails': {},
'itemSelectedReadability': '',
'itemSearch': '',
'itemSortNewestFirst': null,
'itemListWidth': null,
'itemSortNewestFirst': undefined,
'itemListWidth': undefined,
'filteredFeedStats': {},
'filteredFolderStats': {},
@@ -163,6 +175,9 @@ var vm = new Vue({
'font': '',
'size': 1,
},
'refreshRate': undefined,
'authenticated': authenticated(),
'feed_errors': {},
}
},
computed: {
@@ -229,13 +244,13 @@ var vm = new Vue({
}, 500),
},
'filterSelected': function(newVal, oldVal) {
if (oldVal === null) return // do nothing, initial setup
if (oldVal === undefined) return // do nothing, initial setup
api.settings.update({filter: newVal}).then(this.refreshItems.bind(this))
this.itemSelected = null
this.computeStats()
},
'feedSelected': function(newVal, oldVal) {
if (oldVal === null) return // do nothing, initial setup
if (oldVal === undefined) return // do nothing, initial setup
api.settings.update({feed: newVal}).then(this.refreshItems.bind(this))
this.itemSelected = null
if (this.$refs.itemlist) this.$refs.itemlist.scrollTop = 0
@@ -259,17 +274,21 @@ var vm = new Vue({
this.refreshItems()
}, 500),
'itemSortNewestFirst': function(newVal, oldVal) {
if (oldVal === null) return
if (oldVal === undefined) return // do nothing, initial setup
api.settings.update({sort_newest_first: newVal}).then(this.refreshItems.bind(this))
},
'feedListWidth': debounce(function(newVal, oldVal) {
if (oldVal === null) return
if (oldVal === undefined) return // do nothing, initial setup
api.settings.update({feed_list_width: newVal})
}, 1000),
'itemListWidth': debounce(function(newVal, oldVal) {
if (oldVal === null) return
if (oldVal === undefined) return // do nothing, initial setup
api.settings.update({item_list_width: newVal})
}, 1000),
'refreshRate': function(newVal, oldVal) {
if (oldVal === undefined) return // do nothing, initial setup
api.settings.update({refresh_rate: newVal})
},
},
methods: {
refreshStats: function(loopMode) {
@@ -318,9 +337,14 @@ var vm = new Vue({
})
},
refreshItems: function() {
if (this.feedSelected === null) {
vm.items = []
vm.itemsPage = {'cur': 1, 'num': 1}
return
}
var query = this.getItemsQuery()
this.loading.items = true
api.items.list(query).then(function(data) {
return api.items.list(query).then(function(data) {
vm.items = data.list
vm.itemsPage = data.page
vm.loading.items = false
@@ -420,10 +444,12 @@ var vm = new Vue({
deleteFeed: function(feed) {
if (confirm('Are you sure you want to delete ' + feed.title + '?')) {
api.feeds.delete(feed.id).then(function() {
if (vm.feedSelected === 'feed:'+feed.id) {
vm.items = []
vm.feedSelected = ''
}
// unselect feed to prevent reading properties of null in template
var isSelected = !vm.feedSelected
|| (vm.feedSelected === 'feed:'+feed.id
|| (feed.folder_id && vm.feedSelected === 'folder:'+feed.folder_id));
if (isSelected) vm.feedSelected = null
vm.refreshStats()
vm.refreshFeeds()
})
@@ -505,6 +531,12 @@ var vm = new Vue({
showSettings: function(settings) {
this.settings = settings
this.$bvModal.show('settings-modal')
if (settings === 'manage') {
api.feeds.list_errors().then(function(errors) {
vm.feed_errors = errors
})
}
},
resizeFeedList: function(width) {
this.feedListWidth = Math.min(Math.max(200, width), 700)
@@ -562,6 +594,7 @@ api.settings.get().then(function(data) {
vm.theme.name = data.theme_name
vm.theme.font = data.theme_font
vm.theme.size = data.theme_size
vm.refreshRate = data.refresh_rate
vm.refreshItems()
vm.$mount('#app')
})

38
assets/login.html Normal file
View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>yarr!</title>
<link rel="stylesheet" href="./static/stylesheets/bootstrap.min.css">
<link rel="stylesheet" href="./static/stylesheets/app.css">
<link rel="icon shortcut" href="./static/graphicarts/anchor.png">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<style>
form {
max-width: 300px;
margin: 0 auto;
padding: 1rem;
}
form img {
width: 4rem;
height: 4rem;
display: block;
margin: 3rem auto;
}
</style>
</head>
<body>
<form action="" method="post">
<img src="./static/graphicarts/anchor.svg" alt="">
<div class="form-group">
<label for="username">Username</label>
<input name="username" class="form-control" id="username" autocomplete="off">
</div>
<div class="form-group">
<label for="password">Password</label>
<input name="password" class="form-control" id="password" type="password">
</div>
<button class="btn btn-block btn-default" type="submit">Login</button>
</form>
</body>
</html>

View File

@@ -6,12 +6,6 @@ body {
font-size: 15px !important;
}
.wrapper {
max-width: 1440px;
margin: 0 auto;
overflow-x: hidden;
}
/* bootstrap customizations */
.btn-link {
@@ -183,6 +177,7 @@ select.form-control:not([multiple]):not([size]) {
.selectgroup-label {
padding: .375rem .5rem;
border-radius: 4px;
overflow-wrap: break-word;
}
.selectgroup-label:hover {
@@ -408,6 +403,7 @@ select.form-control:not([multiple]):not([size]) {
/* content */
.content {
overflow-wrap: break-word;
line-height: 1.5;
}
@@ -418,6 +414,7 @@ select.form-control:not([multiple]):not([size]) {
.content pre {
color: inherit;
overflow-x: scroll;
}
.content a {
@@ -430,10 +427,6 @@ select.form-control:not([multiple]):not([size]) {
padding-left: 1rem;
}
.content pre {
overflow-x: scroll;
}
.content h1 {
font-size: 1.8rem;
}
@@ -519,3 +512,87 @@ a,
opacity: 0;
margin: 0 !important;
}
/* responsive layout
tablet:
none selected: show feed list & item list
feed selected: show feed list & item list
item selected: show item
mobile:
none selected: show feed list
feed selected: show item list
item selected: show item
*/
@media (min-width: 768px) and (max-width: 991.98px) {
#app #col-feed-list {
width: 35% !important;
}
#app #col-item-list {
width: 65% !important;
border-right-width: 0 !important;
}
#app #col-item {
display: none !important;
}
#app.item-selected #col-feed-list {
display: none !important;
}
#app.item-selected #col-item-list {
display: none !important;
}
#app.item-selected #col-item {
display: flex !important;
}
}
@media (max-width: 767.98px) {
#app #col-feed-list {
width: 100% !important;
border-right-width: 0 !important;
}
#app #col-item-list {
width: 100% !important;
display: none !important;
border-right-width: 0 !important;
}
#app #col-item {
width: 100% !important;
display: none !important;
}
#app.feed-selected #col-feed-list {
display: none !important;
}
#app.feed-selected #col-item-list {
display: flex !important;
}
#app.item-selected #col-feed-list {
display: none !important;
}
#app.item-selected #col-item-list {
display: none !important;
}
#app.item-selected #col-item {
display: flex !important;
}
}
/* styles for both mobile & tablet layout */
@media (max-width: 991.98px) {
.drag {
cursor: default;
}
.toolbar {
min-height: 3rem !important;
max-height: 3rem !important;
}
.toolbar-item,
.toolbar-search {
padding: .5rem;
}
}

21
doc/changelog.txt Normal file
View File

@@ -0,0 +1,21 @@
# upcoming
- (new) autorefresh rate
- (new) reduced bandwidth usage via stateful http headers `last-modified/etag`
- (new) show feed errors in feed management modal
- (new) `-open` flag for automatically opening the server url
- (new) `-base` flag for serving urls under non-root path (thanks to @hcl)
- (new) `-auth-file` flag for authentication
- (new) `-cert-file` & `-key-file` flags for TLS
- (fix) wrapping long words in the ui to prevent vertical scroll
# v1.1 (2020-10-05)
- (new) responsive design
- (fix) server crash on favicon fetch timeout (reported by @minioin)
- (fix) handling byte order marks in feeds (reported by @ilaer)
- (fix) deleting a feed raises exception in the ui if the feed's items are shown.
# v1.0 (2020-09-24)
Initial Release

28
doc/hacking.md Normal file
View File

@@ -0,0 +1,28 @@
# hacking
## build
Install `Go >= 1.14` and `gcc`. Get the source code:
```sh
git clone https://github.com/nkanaev/yarr.git
git clone https://github.com/nkanaev/gofeed.git
mv gofeed yarr
cd yarr
```
Then:
```sh
# create a binary for the host os
make build_macos # -> _output/macos/yarr.app
make build_linux # -> _output/linux/yarr
make build_windows # -> _output/windows/yarr.exe
# ... or run locally (for testing & hacking)
go run main.go # starts a server at http://localhost:7070
```
## code of conduct
Be excellent to each other. Party on, dudes!

15
doc/install.md Normal file
View File

@@ -0,0 +1,15 @@
# Linux desktop
Grab the latest linux binary, then run:
```
$ sudo mv /path/to/yarr /usr/local/bin
$ sudo tee /usr/local/share/applications/yarr.desktop >/dev/null <<EOF
[Desktop Entry]
Name=yarr
Exec=yarr -open
Icon=rss
Type=Application
Categories=Internet;
EOF
```

70
doc/platform.md Normal file
View File

@@ -0,0 +1,70 @@
Incomplete & inaccurate platform-specific notes.
# MacOS Icon
The format for desktop apps is [.icns][icns].
AFAIK, the format is not open (even though it had been [reverse-engineered][icns-re]),
and I couldn't find any 3rd party tool that'd fully support it.
The easiest way for creating icon file is either via `Xcode`,
or by using built-in `iconutil` command that ships with MacOS.
The steps are provided below:
$ sips -s format png --resampleWidth 1024 source.png --out /path/to/icons/icon_512x512@2x.png
$ sips -s format png --resampleWidth 512 source.png --out /path/to/icons/icon_512x512.png
$ sips -s format png --resampleWidth 256 source.png --out /path/to/icons/icon_256x256.png
$ sips -s format png --resampleWidth 128 source.png --out /path/to/icons/icon_128x128.png
$ sips -s format png --resampleWidth 64 source.png --out /path/to/icons/icon_32x32@2x.png
$ sips -s format png --resampleWidth 32 source.png --out /path/to/icons/icon_32x32.png
$ sips -s format png --resampleWidth 16 source.png --out /path/to/icons/icon_16x16.png
$ iconutil -c icns /path/to/icons -o icon.icns
[icns]: https://en.wikipedia.org/wiki/Apple_Icon_Image_format
[icns-re]: https://www.macdisk.com/maciconen.php#RLE
# Windows Icon
Terminology:
- coff: precursor to pe format (portable executable). pe is an extension of coff.
- manifest: xml file with platform requirements needed during runtime
- https://docs.microsoft.com/en-us/windows/win32/sbscs/application-manifests
- https://www.samlogic.net/articles/manifest.htm
- rc: dsl file that describes the application metadata & resources
- https://docs.microsoft.com/en-gb/windows/win32/menurc/about-resource-files
- https://github.com/josephspurrier/goversioninfo/blob/master/testdata/rc/versioninfo.rc (sample rc)
Windows Icons are directly embedded to the binary.
To do so one needs to provide `.syso` file prior to compiling Go code,
which will be passed to the linker. So, basically `.syso` is any
[object file][obj-file] that the linker understands.
More info here: [ticket][syso-ticket] & [commit][syso-commit].
Note to self: running `go build main.go` [won't embed][syso-quirk]
.syso file if it isn't located in a package directory.
Tools to create `.syso` files:
- [windres][windres]: ships with mingw (gnu tools for windows)
- [rsrc][rsrc]: written in Go, wasn't considered at the time
due to the critical bug with icon alignment
- [goversioninfo][goversioninfo]: rsrc wrapper
with manifest file creation via json
[obj-file]: https://en.wikipedia.org/wiki/Object_file
[syso-linker]: https://github.com/golang/go/issues/23278#issuecomment-354567634
[syso-ticket]: https://github.com/golang/go/issues/1552
[syso-commit]: https://github.com/golang/go/commit/b0996334
[syso-quirk]: https://github.com/golang/go/issues/16090
[mingw]: https://en.wikipedia.org/wiki/MinGW
[coff]: https://en.wikipedia.org/wiki/COFF
[windres]: https://sourceware.org/binutils/docs/binutils/windres.html
[rsrs]: https://github.com/akavel/rsrc
[rsrc-bug]: https://github.com/akavel/rsrc/issues/12
[goversioninfo]: github.com/josephspurrier/goversioninfo
[winicon-guide]: https://docs.microsoft.com/en-us/windows/win32/uxguide/vis-icons#size-requirements
[res-vs-coff]: http://www.mingw.org/wiki/MS_resource_compiler
[versioninfo-resource]: https://docs.microsoft.com/en-us/windows/win32/menurc/versioninfo-resource

11
doc/todo.txt Normal file
View File

@@ -0,0 +1,11 @@
- add: switch to `embed` once 1.16 is out
https://tip.golang.org/pkg/embed/
- add: enclosures (podcasts etc)
https://github.com/shagr4th/yarr/commits/master
- fix: moving newly added feed makes it disappear
- fix: broken base link in https://applieddivinitystudies.com/
- fix: migrate to cascade delete
https://sqlite.org/foreignkeys.html#fk_actions
- fix: loading items (by scrolling down) is glitching while feeds are refreshing
- doc: self-hosting instructions (including certificates)
- etc: test gofeed against real-world feeds, compare results with https://pypi.org/project/feedparser/

View File

@@ -1,9 +1,12 @@
FROM golang:1.15 AS build
RUN apt install gcc -y
FROM golang:alpine AS build
RUN apk add build-base git
WORKDIR /src
COPY . .
RUN make build_linux
FROM ubuntu:20.04
COPY --from=build /src/_output/linux/yarr /usr/bin/yarr
ENTRYPOINT ["/usr/bin/yarr", "-addr", "0.0.0.0:7070"]
FROM alpine:latest
RUN apk add --no-cache ca-certificates && \
update-ca-certificates
COPY --from=build /src/_output/linux/yarr /usr/local/bin/yarr
EXPOSE 7070
CMD ["/usr/local/bin/yarr", "-addr", "0.0.0.0:7070", "-db", "/data/yarr.db"]

82
main.go
View File

@@ -1,32 +1,49 @@
package main
import (
"bufio"
"flag"
"fmt"
"github.com/nkanaev/yarr/server"
"github.com/nkanaev/yarr/storage"
"github.com/nkanaev/yarr/platform"
"log"
"os"
"path/filepath"
"strings"
"github.com/nkanaev/yarr/platform"
"github.com/nkanaev/yarr/server"
"github.com/nkanaev/yarr/storage"
sdopen "github.com/skratchdot/open-golang/open"
)
var Version string = "v0.0"
var Version string = "0.0"
var GitHash string = "unknown"
func main() {
var addr, storageFile string
var ver bool
var addr, db, authfile, certfile, keyfile string
var ver, open bool
flag.StringVar(&addr, "addr", "127.0.0.1:7070", "address to run server on")
flag.StringVar(&storageFile, "db", "", "storage file path")
flag.StringVar(&authfile, "auth-file", "", "path to a file containing username:password")
flag.StringVar(&server.BasePath, "base", "", "base path of the service url")
flag.StringVar(&certfile, "cert-file", "", "path to cert file for https")
flag.StringVar(&keyfile, "key-file", "", "path to key file for https")
flag.StringVar(&db, "db", "", "storage file path")
flag.BoolVar(&ver, "version", false, "print application version")
flag.BoolVar(&open, "open", false, "open the server in browser")
flag.Parse()
if ver {
fmt.Printf("%s (%s)\n", Version, GitHash)
fmt.Printf("v%s (%s)\n", Version, GitHash)
return
}
if server.BasePath != "" && !strings.HasPrefix(server.BasePath, "/") {
server.BasePath = "/" + server.BasePath
}
if server.BasePath != "" && strings.HasSuffix(server.BasePath, "/") {
server.BasePath = strings.TrimSuffix(server.BasePath, "/")
}
logger := log.New(os.Stdout, "", log.Ldate|log.Ltime|log.Lshortfile)
configPath, err := os.UserConfigDir()
@@ -34,19 +51,60 @@ func main() {
logger.Fatal("Failed to get config dir: ", err)
}
if storageFile == "" {
if db == "" {
storagePath := filepath.Join(configPath, "yarr")
if err := os.MkdirAll(storagePath, 0755); err != nil {
logger.Fatal("Failed to create app config dir: ", err)
}
storageFile = filepath.Join(storagePath, "storage.db")
db = filepath.Join(storagePath, "storage.db")
}
db, err := storage.New(storageFile, logger)
logger.Printf("using db file %s", db)
var username, password string
if authfile != "" {
f, err := os.Open(authfile)
if err != nil {
logger.Fatal("Failed to open auth file: ", err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
parts := strings.Split(line, ":")
if len(parts) != 2 {
logger.Fatalf("Invalid auth: %v (expected `username:password`)", line)
}
username = parts[0]
password = parts[1]
break
}
}
if (certfile != "" || keyfile != "") && (certfile == "" || keyfile == "") {
logger.Fatalf("Both cert & key files are required")
}
store, err := storage.New(db, logger)
if err != nil {
logger.Fatal("Failed to initialise database: ", err)
}
srv := server.New(db, logger, addr)
srv := server.New(store, logger, addr)
if certfile != "" && keyfile != "" {
srv.CertFile = certfile
srv.KeyFile = keyfile
}
if username != "" && password != "" {
srv.Username = username
srv.Password = password
}
logger.Printf("starting server at %s", srv.GetAddr())
if open {
sdopen.Run(srv.GetAddr())
}
platform.Start(srv)
}

View File

@@ -1,4 +1,4 @@
VERSION=v1.0
VERSION=1.2
GITHASH=$(shell git rev-parse --short=8 HEAD)
ASSETS = assets/javascripts/* assets/stylesheets/* assets/graphicarts/* assets/index.html
@@ -9,10 +9,14 @@ GO_LDFLAGS := $(GO_LDFLAGS) -X 'main.Version=$(VERSION)' -X 'main.GitHash=$(GITH
default: bundle
server/assets_bundle.go: $(ASSETS)
server/assets.go: $(ASSETS)
go run scripts/bundle_assets.go >/dev/null
bundle: server/assets_bundle.go
bundle: server/assets.go
build_default: bundle
mkdir -p _output
go build -tags "sqlite_foreign_keys release" -ldflags="$(GO_LDFLAGS)" -o _output/yarr main.go
build_macos: bundle
set GOOS=darwin
@@ -20,7 +24,7 @@ build_macos: bundle
mkdir -p _output/macos
go build -tags "sqlite_foreign_keys release macos" -ldflags="$(GO_LDFLAGS)" -o _output/macos/yarr main.go
cp artwork/icon.png _output/macos/icon.png
go run scripts/package_macos.go _output/macos
go run scripts/package_macos.go -outdir _output/macos -version "$(VERSION)"
build_linux: bundle
set GOOS=linux
@@ -32,5 +36,6 @@ build_windows: bundle
set GOOS=windows
set GOARCH=386
mkdir -p _output/windows
go run scripts/generate_versioninfo.go -version "$(VERSION)" -outfile artwork/versioninfo.rc
windres -i artwork/versioninfo.rc -O coff -o platform/versioninfo.syso
go build -tags "sqlite_foreign_keys release windows" -ldflags="$(GO_LDFLAGS) -H windowsgui" -o _output/windows/yarr.exe main.go

View File

@@ -10,7 +10,7 @@ import (
func Start(s *server.Handler) {
systrayOnReady := func() {
systray.SetIcon(server.Icon)
systray.SetIcon(Icon)
menuOpen := systray.AddMenuItem("Open", "")
systray.AddSeparator()
@@ -20,7 +20,7 @@ func Start(s *server.Handler) {
for {
select {
case <-menuOpen.ClickedCh:
open.Run("http://" + s.Addr)
open.Run(s.GetAddr())
case <-menuQuit.ClickedCh:
systray.Quit()
}

View File

@@ -1,8 +1,8 @@
// +build !windows
// +build macos
// File generated by 2goarray v0.1.0 (http://github.com/cratonica/2goarray)
package server
package platform
var Icon []byte = []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,

View File

@@ -2,7 +2,7 @@
// File generated by 2goarray v0.1.0 (http://github.com/cratonica/2goarray)
package server
package platform
var Icon []byte = []byte{
0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00,

View File

@@ -1,4 +1,4 @@
# yarr (beta)
# yarr
yet another rss reader.
@@ -9,35 +9,8 @@ yet another rss reader.
The goal of the project is to provide a desktop application accessible via web browser.
Longer-term plans include a self-hosted solution for individuals.
There are plans to add support for mobile & tablet resolutions.
Support for 3rd-party applications (via Fever API) is being considered.
## build
Install `Go >= 1.14` and `gcc`, then run:
```sh
git clone https://github.com/nkanaev/yarr.git
git clone https://github.com/nkanaev/gofeed.git
mv gofeed yarr
cd yarr && make build_macos
```
## plans
- test across 3 platforms (macos, linux, windows)
- prebuilt binaries
- GUI-less mode (no tray icon)
- feeds health checker
- mobile & tablet layout
- parameters (`--[no]-gui`, `--addr`, ...)
- Fever API support
- keyboard navigation
[download](https://github.com/nkanaev/yarr/releases/latest)
## credits
[Feather](http://feathericons.com/) for icons.
## code of conduct
Be excellent to each other. Party on, dudes!

View File

@@ -92,5 +92,5 @@ func main() {
var buf bytes.Buffer
template := template.Must(template.New("code").Parse(code_template))
template.Execute(&buf, assets)
ioutil.WriteFile("server/assets_bundle.go", buf.Bytes(), 0644)
ioutil.WriteFile("server/assets.go", buf.Bytes(), 0644)
}

View File

@@ -0,0 +1,48 @@
package main
import (
"io/ioutil"
"flag"
"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)
}

View File

@@ -1,13 +1,15 @@
package main
import (
"os"
"path"
"flag"
"fmt"
"io/ioutil"
"os/exec"
"strconv"
"log"
"os"
"os/exec"
"path"
"strconv"
"strings"
)
var plist = `<?xml version="1.0" encoding="UTF-8"?>
@@ -21,7 +23,7 @@ var plist = `<?xml version="1.0" encoding="UTF-8"?>
<key>CFBundleIdentifier</key>
<string>nkanaev.yarr</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<string>VERSION</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleExecutable</key>
@@ -35,11 +37,6 @@ var plist = `<?xml version="1.0" encoding="UTF-8"?>
<key>NSHighResolutionCapable</key>
<string>True</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>LSMinimumSystemVersion</key>
<string>10.13</string>
<key>LSUIElement</key>
@@ -59,7 +56,11 @@ func run(cmd ...string) {
}
func main() {
outdir := os.Args[1]
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")
@@ -74,7 +75,7 @@ func main() {
f, _ := ioutil.ReadFile(path.Join(outdir, outfile))
ioutil.WriteFile(path.Join(binDir, outfile), f, 0755)
ioutil.WriteFile(plistFile, []byte(plist), 0644)
ioutil.WriteFile(plistFile, []byte(strings.Replace(plist, "VERSION", version, 1)), 0644)
ioutil.WriteFile(pkginfoFile, []byte("APPL????"), 0644)
iconFile := path.Join(outdir, "icon.png")

51
server/auth.go Normal file
View File

@@ -0,0 +1,51 @@
package server
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"net/http"
"strings"
"time"
)
func userIsAuthenticated(req *http.Request, username, password string) bool {
cookie, _ := req.Cookie("auth")
if cookie == nil {
return false
}
parts := strings.Split(cookie.Value, ":")
if len(parts) != 2 || !stringsEqual(parts[0], username) {
return false
}
return stringsEqual(parts[1], secret(username, password))
}
func userAuthenticate(rw http.ResponseWriter, username, password string) {
expires := time.Now().Add(time.Hour * 24 * 7) // 1 week
var cookiePath string
if BasePath != "" {
cookiePath = BasePath
} else {
cookiePath = "/"
}
cookie := http.Cookie{
Name: "auth",
Value: username + ":" + secret(username, password),
Expires: expires,
Path: cookiePath,
}
http.SetCookie(rw, &cookie)
}
func stringsEqual(p1, p2 string) bool {
return subtle.ConstantTimeCompare([]byte(p1), []byte(p2)) == 1
}
func secret(msg, key string) string {
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(msg))
src := mac.Sum(nil)
return hex.EncodeToString(src)
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/mmcdole/gofeed"
"github.com/nkanaev/yarr/storage"
"io/ioutil"
"net"
"net/http"
"net/url"
"time"
@@ -46,6 +47,17 @@ func (c *Client) get(url string) (*http.Response, error) {
return c.httpClient.Do(req)
}
func (c *Client) getConditional(url, lastModified, etag string) (*http.Response, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("If-Modified-Since", lastModified)
req.Header.Set("If-None-Match", etag)
return c.httpClient.Do(req)
}
var defaultClient *Client
func searchFeedLinks(html []byte, siteurl string) ([]FeedSource, error) {
@@ -157,13 +169,16 @@ func findFavicon(websiteUrl, feedUrl string) (*[]byte, error) {
}
if len(websiteUrl) != 0 {
base, err := url.Parse(websiteUrl)
if err != nil {
return nil, err
}
res, err := defaultClient.get(websiteUrl)
if err != nil {
return nil, err
}
defer res.Body.Close()
doc, err := goquery.NewDocumentFromReader(res.Body)
base, err := url.Parse(websiteUrl)
if err != nil {
return nil, err
}
@@ -239,16 +254,37 @@ func convertItems(items []*gofeed.Item, feed storage.Feed) []storage.Item {
return result
}
func listItems(f storage.Feed) ([]storage.Item, error) {
res, err := defaultClient.get(f.FeedLink)
func listItems(f storage.Feed, db *storage.Storage) ([]storage.Item, error) {
var res *http.Response
var err error
httpState := db.GetHTTPState(f.Id)
if httpState != nil {
res, err = defaultClient.getConditional(f.FeedLink, httpState.LastModified, httpState.Etag)
} else {
res, err = defaultClient.get(f.FeedLink)
}
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode == 404 {
errmsg := fmt.Sprintf("Failed to list feed items for %s (status: 404)", f.FeedLink)
if res.StatusCode/100 == 4 || res.StatusCode/100 == 5 {
errmsg := fmt.Sprintf("Failed to list feed items for %s (status: %d)", f.FeedLink, res.StatusCode)
return nil, errors.New(errmsg)
}
if res.StatusCode == 304 {
return nil, nil
}
lastModified := res.Header.Get("Last-Modified")
etag := res.Header.Get("Etag")
if lastModified != "" || etag != "" {
db.SetHTTPState(f.Id, lastModified, etag)
}
feedparser := gofeed.NewParser()
feed, err := feedparser.Parse(res.Body)
if err != nil {
@@ -258,10 +294,16 @@ func listItems(f storage.Feed) ([]storage.Item, error) {
}
func init() {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DisableKeepAlives = true
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
}).DialContext,
DisableKeepAlives: true,
TLSHandshakeTimeout: time.Second * 10,
}
httpClient := &http.Client{
Timeout: time.Second * 5,
Timeout: time.Second * 30,
Transport: transport,
}
defaultClient = &Client{

View File

@@ -2,9 +2,9 @@ package server
import (
"bytes"
"encoding/json"
"encoding/base64"
"compress/gzip"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/nkanaev/yarr/storage"
"html"
@@ -22,14 +22,16 @@ import (
)
var routes []Route = []Route{
p("/", IndexHandler),
p("/static/*path", StaticHandler),
p("/", IndexHandler).ManualAuth(),
p("/static/*path", StaticHandler).ManualAuth(),
p("/api/status", StatusHandler),
p("/api/folders", FolderListHandler),
p("/api/folders/:id", FolderHandler),
p("/api/feeds", FeedListHandler),
p("/api/feeds/find", FeedHandler),
p("/api/feeds/refresh", FeedRefreshHandler),
p("/api/feeds/errors", FeedErrorsHandler),
p("/api/feeds/:id/icon", FeedIconHandler),
p("/api/feeds/:id", FeedHandler),
p("/api/items", ItemListHandler),
@@ -42,7 +44,7 @@ var routes []Route = []Route{
type asset struct {
etag string
body string // base64(gzip(content))
body string // base64(gzip(content))
gzipped *[]byte
decoded *string
}
@@ -89,6 +91,35 @@ type ItemUpdateForm struct {
}
func IndexHandler(rw http.ResponseWriter, req *http.Request) {
h := handler(req)
if h.requiresAuth() && !userIsAuthenticated(req, h.Username, h.Password) {
if req.Method == "POST" {
username := req.FormValue("username")
password := req.FormValue("password")
if stringsEqual(username, h.Username) && stringsEqual(password, h.Password) {
userAuthenticate(rw, username, password)
http.Redirect(rw, req, req.URL.Path, http.StatusFound)
return
}
}
if assets != nil {
asset := assets["login.html"]
rw.Header().Set("Content-Type", "text/html")
rw.Header().Set("Content-Encoding", "gzip")
rw.Write(*asset.gzip())
return
} else {
f, err := os.Open("assets/login.html")
if err != nil {
handler(req).log.Print(err)
return
}
io.Copy(rw, f)
return
}
}
if assets != nil {
asset := assets["index.html"]
@@ -199,6 +230,11 @@ func FeedRefreshHandler(rw http.ResponseWriter, req *http.Request) {
}
}
func FeedErrorsHandler(rw http.ResponseWriter, req *http.Request) {
errors := db(req).GetFeedErrors()
writeJSON(rw, errors)
}
func FeedIconHandler(rw http.ResponseWriter, req *http.Request) {
id, err := strconv.ParseInt(Vars(req)["id"], 10, 64)
if err != nil {
@@ -243,6 +279,15 @@ func FeedListHandler(rw http.ResponseWriter, req *http.Request) {
form.FolderID,
)
db(req).CreateItems(convertItems(feed.Items, *storedFeed))
icon, err := findFavicon(storedFeed.Link, storedFeed.FeedLink)
if icon != nil {
db(req).UpdateFeedIcon(storedFeed.Id, icon)
}
if err != nil {
handler(req).log.Printf("Failed to find favicon for %s (%d): %s", storedFeed.FeedLink, storedFeed.Id, err)
}
writeJSON(rw, map[string]string{"status": "success"})
} else if sources != nil {
writeJSON(rw, map[string]interface{}{"status": "multiple", "choice": sources})
@@ -348,7 +393,7 @@ func ItemListHandler(rw http.ResponseWriter, req *http.Request) {
})
} else if req.Method == "PUT" {
query := req.URL.Query()
filter := storage.ItemFilter{}
filter := storage.MarkFilter{}
if folderID, err := strconv.ParseInt(query.Get("folder_id"), 10, 64); err == nil {
filter.FolderID = &folderID
}
@@ -372,6 +417,9 @@ func SettingsHandler(rw http.ResponseWriter, req *http.Request) {
return
}
if db(req).UpdateSettings(settings) {
if _, ok := settings["refresh_rate"]; ok {
handler(req).refreshRate <- db(req).GetSettingsValueInt64("refresh_rate")
}
rw.WriteHeader(http.StatusOK)
} else {
rw.WriteHeader(http.StatusBadRequest)

View File

@@ -2,8 +2,8 @@ package server
import (
"encoding/json"
"net/http"
"log"
"net/http"
)
func writeJSON(rw http.ResponseWriter, data interface{}) {

View File

@@ -5,10 +5,18 @@ import (
"regexp"
)
var BasePath string = ""
type Route struct {
url string
urlRegex *regexp.Regexp
handler func(http.ResponseWriter, *http.Request)
url string
urlRegex *regexp.Regexp
handler func(http.ResponseWriter, *http.Request)
manualAuth bool
}
func (r Route) ManualAuth() Route {
r.manualAuth = true
return r
}
func p(path string, handler func(http.ResponseWriter, *http.Request)) Route {
@@ -27,13 +35,13 @@ func p(path string, handler func(http.ResponseWriter, *http.Request)) Route {
}
}
func getRoute(req *http.Request) (*Route, map[string]string) {
func getRoute(reqPath string) (*Route, map[string]string) {
vars := make(map[string]string)
for _, route := range routes {
if route.urlRegex.MatchString(req.URL.Path) {
matches := route.urlRegex.FindStringSubmatchIndex(req.URL.Path)
if route.urlRegex.MatchString(reqPath) {
matches := route.urlRegex.FindStringSubmatchIndex(reqPath)
for i, key := range route.urlRegex.SubexpNames()[1:] {
vars[key] = req.URL.Path[matches[i*2+2]:matches[i*2+3]]
vars[key] = reqPath[matches[i*2+2]:matches[i*2+3]]
}
return &route, vars
}

View File

@@ -2,45 +2,100 @@ package server
import (
"context"
"github.com/nkanaev/yarr/storage"
"log"
"net/http"
"runtime"
"strings"
"sync/atomic"
"time"
"github.com/nkanaev/yarr/storage"
)
type Handler struct {
Addr string
db *storage.Storage
log *log.Logger
feedQueue chan storage.Feed
queueSize *int32
Addr string
db *storage.Storage
log *log.Logger
feedQueue chan storage.Feed
queueSize *int32
refreshRate chan int64
// auth
Username string
Password string
// https
CertFile string
KeyFile string
}
func New(db *storage.Storage, logger *log.Logger, addr string) *Handler {
queueSize := int32(0)
return &Handler{
db: db,
log: logger,
feedQueue: make(chan storage.Feed, 3000),
queueSize: &queueSize,
Addr: addr,
db: db,
log: logger,
feedQueue: make(chan storage.Feed, 3000),
queueSize: &queueSize,
Addr: addr,
refreshRate: make(chan int64),
}
}
func (h *Handler) GetAddr() string {
proto := "http"
if h.CertFile != "" && h.KeyFile != "" {
proto = "https"
}
return proto + "://" + h.Addr + BasePath
}
func (h *Handler) Start() {
h.startJobs()
s := &http.Server{Addr: h.Addr, Handler: h}
s.ListenAndServe()
var err error
if h.CertFile != "" && h.KeyFile != "" {
err = s.ListenAndServeTLS(h.CertFile, h.KeyFile)
} else {
err = s.ListenAndServe()
}
if err != http.ErrServerClosed {
h.log.Fatal(err)
}
}
func unsafeMethod(method string) bool {
return method == "POST" || method == "PUT" || method == "DELETE"
}
func (h Handler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
route, vars := getRoute(req)
reqPath := req.URL.Path
if BasePath != "" {
if !strings.HasPrefix(reqPath, BasePath) {
rw.WriteHeader(http.StatusNotFound)
return
}
reqPath = strings.TrimPrefix(req.URL.Path, BasePath)
if reqPath == "" {
http.Redirect(rw, req, BasePath+"/", http.StatusFound)
return
}
}
route, vars := getRoute(reqPath)
if route == nil {
rw.WriteHeader(http.StatusNotFound)
return
}
if h.requiresAuth() && !route.manualAuth {
if unsafeMethod(req.Method) && req.Header.Get("X-Requested-By") != "yarr" {
rw.WriteHeader(http.StatusUnauthorized)
return
}
if !userIsAuthenticated(req, h.Username, h.Password) {
rw.WriteHeader(http.StatusUnauthorized)
return
}
}
ctx := context.WithValue(req.Context(), ctxHandler, &h)
ctx = context.WithValue(ctx, ctxVars, vars)
route.handler(rw, req.WithContext(ctx))
@@ -50,11 +105,11 @@ func (h *Handler) startJobs() {
delTicker := time.NewTicker(time.Hour * 24)
syncSearchChannel := make(chan bool, 10)
var syncSearchTimer *time.Timer // TODO: should this be atomic?
var syncSearchTimer *time.Timer // TODO: should this be atomic?
syncSearch := func() {
if syncSearchTimer == nil {
syncSearchTimer = time.AfterFunc(time.Second * 2, func() {
syncSearchTimer = time.AfterFunc(time.Second*2, func() {
syncSearchChannel <- true
})
} else {
@@ -66,27 +121,27 @@ func (h *Handler) startJobs() {
for {
select {
case feed := <-h.feedQueue:
items, err := listItems(feed)
items, err := listItems(feed, h.db)
atomic.AddInt32(h.queueSize, -1)
if err != nil {
h.log.Printf("Failed to fetch %s (%d): %s", feed.FeedLink, feed.Id, err)
h.db.SetFeedError(feed.Id, err)
continue
}
h.db.CreateItems(items)
syncSearch()
if !feed.HasIcon {
h.log.Println("searching favicon for:", feed.Link, feed.FeedLink)
icon, err := findFavicon(feed.Link, feed.FeedLink)
if icon != nil {
h.db.UpdateFeedIcon(feed.Id, icon)
}
if err != nil {
h.log.Print(err)
h.log.Printf("Failed to search favicon for %s (%s): %s", feed.Link, feed.FeedLink, err)
}
}
case <- delTicker.C:
case <-delTicker.C:
h.db.DeleteOldItems()
case <- syncSearchChannel:
case <-syncSearchChannel:
h.db.SyncSearch()
}
}
@@ -101,10 +156,42 @@ func (h *Handler) startJobs() {
}
go h.db.DeleteOldItems()
go h.db.SyncSearch()
//h.fetchAllFeeds()
go func() {
var refreshTicker *time.Ticker
refreshTick := make(<-chan time.Time)
for {
select {
case <-refreshTick:
h.fetchAllFeeds()
case val := <-h.refreshRate:
if refreshTicker != nil {
refreshTicker.Stop()
if val == 0 {
refreshTick = make(<-chan time.Time)
}
}
if val > 0 {
refreshTicker = time.NewTicker(time.Duration(val) * time.Minute)
refreshTick = refreshTicker.C
}
}
}
}()
refreshRate := h.db.GetSettingsValueInt64("refresh_rate")
h.refreshRate <- refreshRate
if refreshRate > 0 {
h.fetchAllFeeds()
}
}
func (h Handler) requiresAuth() bool {
return h.Username != "" && h.Password != ""
}
func (h *Handler) fetchAllFeeds() {
h.log.Print("Refreshing all feeds")
h.db.ResetFeedErrors()
for _, feed := range h.db.ListFeeds() {
h.fetchFeed(feed)
}

View File

@@ -132,3 +132,41 @@ func (s *Storage) GetFeed(id int64) *Feed {
}
return nil
}
func (s *Storage) ResetFeedErrors() {
if _, err := s.db.Exec(`delete from feed_errors`); err != nil {
s.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 {
s.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 {
s.log.Print(err)
return errors
}
for rows.Next() {
var id int64
var error string
if err = rows.Scan(&id, &error); err != nil {
s.log.Print(err)
}
errors[id] = error
}
return errors
}

72
storage/http.go Normal file
View File

@@ -0,0 +1,72 @@
package storage
import (
"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 {
s.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 {
s.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 {
s.log.Print(err)
}
}

View File

@@ -3,10 +3,10 @@ package storage
import (
"encoding/json"
"fmt"
xhtml "golang.org/x/net/html"
"html"
"strings"
"time"
xhtml "golang.org/x/net/html"
)
type ItemStatus int
@@ -64,6 +64,11 @@ type ItemFilter struct {
Search *string
}
type MarkFilter struct {
FolderID *int64
FeedID *int64
}
func (s *Storage) CreateItems(items []Item) bool {
tx, err := s.db.Begin()
if err != nil {
@@ -144,16 +149,19 @@ func listQueryPredicate(filter ItemFilter) (string, []interface{}) {
if len(cond) > 0 {
predicate = strings.Join(cond, " and ")
}
return predicate, args
}
func (s *Storage) ListItems(filter ItemFilter, offset, limit int, newestFirst bool) []Item {
predicate, args := listQueryPredicate(filter)
result := make([]Item, 0, 0)
order := "desc"
order := "date desc"
if !newestFirst {
order = "asc"
order = "date asc"
}
query := fmt.Sprintf(`
select
i.id, i.guid, i.feed_id, i.title, i.link, i.description,
@@ -161,7 +169,7 @@ func (s *Storage) ListItems(filter ItemFilter, offset, limit int, newestFirst bo
from items i
join feeds f on f.id = i.feed_id
where %s
order by i.date %s
order by %s
limit %d offset %d
`, predicate, order, limit, offset)
rows, err := s.db.Query(query, args...)
@@ -215,7 +223,7 @@ func (s *Storage) UpdateItemStatus(item_id int64, status ItemStatus) bool {
return err == nil
}
func (s *Storage) MarkItemsRead(filter ItemFilter) bool {
func (s *Storage) MarkItemsRead(filter MarkFilter) bool {
cond := make([]string, 0)
args := make([]interface{}, 0)
@@ -356,7 +364,7 @@ func (s *Storage) DeleteOldItems() {
delete from items where feed_id = ? and status != ? and date_arrived < ?`,
feedId,
STARRED,
time.Now().Add(-time.Hour*24*90), // 90 days
time.Now().Add(-time.Hour*24*90), // 90 days
)
if err != nil {
s.log.Print(err)

View File

@@ -4,14 +4,15 @@ import "encoding/json"
func settingsDefaults() map[string]interface{} {
return map[string]interface{}{
"filter": "",
"feed": "",
"feed_list_width": 300,
"item_list_width": 300,
"filter": "",
"feed": "",
"feed_list_width": 300,
"item_list_width": 300,
"sort_newest_first": true,
"theme_name": "light",
"theme_font": "",
"theme_size": 1,
"theme_name": "light",
"theme_font": "",
"theme_size": 1,
"refresh_rate": 0,
}
}
@@ -22,6 +23,9 @@ func (s *Storage) GetSettingsValue(key string) interface{} {
}
var val []byte
row.Scan(&val)
if len(val) == 0 {
return nil
}
var valDecoded interface{}
if err := json.Unmarshal([]byte(val), &valDecoded); err != nil {
s.log.Print(err)
@@ -30,6 +34,16 @@ func (s *Storage) GetSettingsValue(key string) interface{} {
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;`)

View File

@@ -61,6 +61,20 @@ create virtual table if not exists search using fts4(title, description, content
create trigger if not exists del_item_search after delete on items begin
delete from search where rowid = old.search_rowid;
end;
create table if not exists http_states (
feed_id references feeds(id) unique,
last_refreshed datetime not null,
-- http header fields --
last_modified string not null,
etag string not null
);
create table if not exists feed_errors (
feed_id references feeds(id) unique,
error string
);
`
type Storage struct {
@@ -69,11 +83,8 @@ type Storage struct {
}
func New(path string, logger *log.Logger) (*Storage, error) {
initialize := false
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
initialize = true
} else {
if !os.IsNotExist(err) {
return nil, err
}
}
@@ -85,10 +96,8 @@ func New(path string, logger *log.Logger) (*Storage, error) {
db.SetMaxOpenConns(1)
if initialize {
if _, err := db.Exec(initQuery); err != nil {
return nil, err
}
if _, err := db.Exec(initQuery); err != nil {
return nil, err
}
return &Storage{db: db, log: logger}, nil
}