mirror of
https://github.com/nkanaev/yarr.git
synced 2025-09-13 09:55:36 +00:00
Compare commits
95 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
4f1e029d0b | ||
|
9b93a959e5 | ||
|
68d269658f | ||
|
875b87b0d6 | ||
|
3e79cd7944 | ||
|
0ea8972ede | ||
|
a7113addd0 | ||
|
84df847898 | ||
|
333d9373dd | ||
|
eb90c5b9aa | ||
|
3371e1afff | ||
|
8aafb1b729 | ||
|
1a0db29aa6 | ||
|
52073e7e81 | ||
|
4f79c919f0 | ||
|
63b265fa04 | ||
|
6b01d9d7b9 | ||
|
20a0a6724a | ||
|
e79cb9e6e0 | ||
|
d7ddcc04b5 | ||
|
99684a4b2f | ||
|
6a6153ca48 | ||
|
23a4ff3af6 | ||
|
d6c2ba5812 | ||
|
fa0237b546 | ||
|
9fcaad6b2f | ||
|
edc7d56219 | ||
|
d0a2b80ecc | ||
|
e2d80af81d | ||
|
eccd383c1c | ||
|
db7a178a8d | ||
|
62e0caa950 | ||
|
46d8c98aff | ||
|
05634ebdb7 | ||
|
0e2da62081 | ||
|
94d1659ad5 | ||
|
0745c92e9a | ||
|
7c06952a7d | ||
|
e2d8ca3506 | ||
|
4f20f537c0 | ||
|
a0b42b27b3 | ||
|
288fa3979a | ||
|
e4cc96ef09 | ||
|
60a947f131 | ||
|
0226c8da23 | ||
|
b0364087ad | ||
|
40a9773beb | ||
|
790a275443 | ||
|
9b9addf3e6 | ||
|
57d2437e9c | ||
|
a13aea478e | ||
|
6def522f38 | ||
|
6a63d49823 | ||
|
b766cb4ac5 | ||
|
32ab1fefa9 | ||
|
70761c47eb | ||
|
6a09d52b85 | ||
|
fcaf23d6bc | ||
|
54c2a6458d | ||
|
05032ec428 | ||
|
e24b905adc | ||
|
f27d0c4cd7 | ||
|
11a2aa2b4a | ||
|
2eee8baa26 | ||
|
0949ffc027 | ||
|
286538c5d0 | ||
|
78844def40 | ||
|
e17ce0fb31 | ||
|
55a1c297be | ||
|
9bb7ae7902 | ||
|
bcab24ebfa | ||
|
dd058e1637 | ||
|
63f624251d | ||
|
05ee99b4d9 | ||
|
d5cc9149e3 | ||
|
eb2029132c | ||
|
492e77fd25 | ||
|
b8aa15d554 | ||
|
ee0b440b7b | ||
|
72da9df5ac | ||
|
9872bf84f0 | ||
|
6222761dd3 | ||
|
71cc8929ad | ||
|
8007853a9a | ||
|
ffc506371c | ||
|
00fed5e0cf | ||
|
6937c349f0 | ||
|
bac136603b | ||
|
693b5bcb8d | ||
|
9c7d95f632 | ||
|
b8adb3fa2f | ||
|
5652b240dc | ||
|
c1c0ffab01 | ||
|
349a8980f2 | ||
|
0bd117804d |
111
.github/workflows/build.yml
vendored
111
.github/workflows/build.yml
vendored
@@ -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
3
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
/server/assets_bundle.go
|
||||
/server/assets.go
|
||||
/gofeed
|
||||
/_output
|
||||
/yarr
|
||||
*.db
|
||||
*.syso
|
||||
versioninfo.rc
|
||||
|
@@ -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"
|
||||
|
1
assets/graphicarts/alert-circle.svg
Normal file
1
assets/graphicarts/alert-circle.svg
Normal 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 |
1
assets/graphicarts/chevron-left.svg
Normal file
1
assets/graphicarts/chevron-left.svg
Normal 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 |
1
assets/graphicarts/log-out.svg
Normal file
1
assets/graphicarts/log-out.svg
Normal 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 |
@@ -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>
|
||||
|
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -12,7 +15,7 @@
|
||||
var json = function(res) {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
|
||||
var param = function(query) {
|
||||
if (!query) return ''
|
||||
return '?' + Object.keys(query).map(function(key) {
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
@@ -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
38
assets/login.html
Normal 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>
|
@@ -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
21
doc/changelog.txt
Normal 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
28
doc/hacking.md
Normal 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
15
doc/install.md
Normal 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
70
doc/platform.md
Normal 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
11
doc/todo.txt
Normal 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/
|
13
dockerfile
13
dockerfile
@@ -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
82
main.go
@@ -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)
|
||||
}
|
||||
|
13
makefile
13
makefile
@@ -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
|
||||
|
@@ -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()
|
||||
}
|
||||
|
@@ -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,
|
@@ -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,
|
31
readme.md
31
readme.md
@@ -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!
|
||||
|
@@ -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)
|
||||
}
|
||||
|
48
scripts/generate_versioninfo.go
Normal file
48
scripts/generate_versioninfo.go
Normal 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)
|
||||
}
|
@@ -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
51
server/auth.go
Normal 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)
|
||||
}
|
@@ -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{
|
||||
|
@@ -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)
|
||||
|
@@ -2,8 +2,8 @@ package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func writeJSON(rw http.ResponseWriter, data interface{}) {
|
||||
|
@@ -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
|
||||
}
|
||||
|
131
server/server.go
131
server/server.go
@@ -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)
|
||||
}
|
||||
|
@@ -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
72
storage/http.go
Normal 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)
|
||||
}
|
||||
}
|
@@ -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)
|
||||
|
@@ -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;`)
|
||||
|
@@ -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
|
||||
}
|
||||
|
Reference in New Issue
Block a user