mirror of
https://github.com/nkanaev/yarr.git
synced 2026-05-09 02:33:19 +00:00
Compare commits
9 Commits
7a5f8a5e41
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76c2b9a475 | ||
|
|
14d5a6b52b | ||
|
|
6069330e92 | ||
|
|
552ebb7ad5 | ||
|
|
74e6ee8e8e | ||
|
|
167aef9ba1 | ||
|
|
ed726f26f4 | ||
|
|
760f611007 | ||
|
|
49c704037b |
@@ -51,7 +51,7 @@ func Template(path string) *template.Template {
|
|||||||
return tmpl
|
return tmpl
|
||||||
}
|
}
|
||||||
|
|
||||||
func Render(path string, writer io.Writer, data interface{}) {
|
func Render(path string, writer io.Writer, data any) {
|
||||||
tmpl := Template(path)
|
tmpl := Template(path)
|
||||||
tmpl.Execute(writer, data)
|
tmpl.Execute(writer, data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,43 +27,43 @@
|
|||||||
<button class="toolbar-item ml-1"
|
<button class="toolbar-item ml-1"
|
||||||
:class="{active: filterSelected == 'unread'}"
|
:class="{active: filterSelected == 'unread'}"
|
||||||
:aria-pressed="filterSelected == 'unread'"
|
:aria-pressed="filterSelected == 'unread'"
|
||||||
title="Unread"
|
:title="$t('unread')"
|
||||||
@click="filterSelected = 'unread'">
|
@click="filterSelected = 'unread'">
|
||||||
<span class="icon">{% inline "circle-full.svg" %}</span>
|
<span class="icon">{% inline "circle-full.svg" %}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="toolbar-item mx-1"
|
<button class="toolbar-item mx-1"
|
||||||
:class="{active: filterSelected == 'starred'}"
|
:class="{active: filterSelected == 'starred'}"
|
||||||
:aria-pressed="filterSelected == 'starred'"
|
:aria-pressed="filterSelected == 'starred'"
|
||||||
title="Starred"
|
:title="$t('starred')"
|
||||||
@click="filterSelected = 'starred'">
|
@click="filterSelected = 'starred'">
|
||||||
<span class="icon">{% inline "star-full.svg" %}</span>
|
<span class="icon">{% inline "star-full.svg" %}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="toolbar-item mr-1"
|
<button class="toolbar-item mr-1"
|
||||||
:class="{active: filterSelected == ''}"
|
:class="{active: filterSelected == ''}"
|
||||||
:aria-pressed="filterSelected == ''"
|
:aria-pressed="filterSelected == ''"
|
||||||
title="All"
|
:title="$t('all')"
|
||||||
@click="filterSelected = ''">
|
@click="filterSelected = ''">
|
||||||
<span class="icon">{% inline "assorted.svg" %}</span>
|
<span class="icon">{% inline "assorted.svg" %}</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="flex-grow-1"></div>
|
<div class="flex-grow-1"></div>
|
||||||
<dropdown class="settings-dropdown" toggle-class="btn btn-link toolbar-item px-2" ref="menuDropdown" drop="right" title="Settings">
|
<dropdown class="settings-dropdown" toggle-class="btn btn-link toolbar-item px-2" ref="menuDropdown" drop="right" :title="$t('settings')">
|
||||||
<template v-slot:button>
|
<template v-slot:button>
|
||||||
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<button class="dropdown-item" @click="showSettings('create')">
|
<button class="dropdown-item" @click="showSettings('create')">
|
||||||
<span class="icon mr-1">{% inline "plus.svg" %}</span>
|
<span class="icon mr-1">{% inline "plus.svg" %}</span>
|
||||||
New Feed
|
{{ $t('new_feed') }}
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<button class="dropdown-item" @click="fetchAllFeeds()">
|
<button class="dropdown-item" @click="fetchAllFeeds()">
|
||||||
<span class="icon mr-1">{% inline "rotate-cw.svg" %}</span>
|
<span class="icon mr-1">{% inline "rotate-cw.svg" %}</span>
|
||||||
Refresh Feeds
|
{{ $t('refresh_feeds') }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
|
|
||||||
<header class="dropdown-header" role="heading" aria-level="2">Theme</header>
|
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('theme') }}</header>
|
||||||
<div class="row text-center m-0">
|
<div class="row text-center m-0">
|
||||||
<button class="btn btn-link col-4 px-0 rounded-0"
|
<button class="btn btn-link col-4 px-0 rounded-0"
|
||||||
:class="'theme-'+t"
|
:class="'theme-'+t"
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
|
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
|
|
||||||
<header class="dropdown-header" role="heading" aria-level="2">Auto Refresh</header>
|
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('auto_refresh') }}</header>
|
||||||
<div class="row text-center m-0">
|
<div class="row text-center m-0">
|
||||||
<button class="dropdown-item col-4 px-0"
|
<button class="dropdown-item col-4 px-0"
|
||||||
@click.stop="changeRefreshRate(-1)"
|
@click.stop="changeRefreshRate(-1)"
|
||||||
@@ -97,13 +97,13 @@
|
|||||||
|
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
|
|
||||||
<header class="dropdown-header" role="heading" aria-level="2">Show first</header>
|
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('show_first') }}</header>
|
||||||
<div class="d-flex text-center">
|
<div class="d-flex text-center">
|
||||||
<button class="dropdown-item px-0" :aria-pressed="itemSortNewestFirst" :class="{active: itemSortNewestFirst}" @click.stop="itemSortNewestFirst=true">New</button>
|
<button class="dropdown-item px-0" :aria-pressed="itemSortNewestFirst" :class="{active: itemSortNewestFirst}" @click.stop="itemSortNewestFirst=true">{{ $t('new') }}</button>
|
||||||
<button class="dropdown-item px-0" :aria-pressed="!itemSortNewestFirst" :class="{active: !itemSortNewestFirst}" @click.stop="itemSortNewestFirst=false">Old</button>
|
<button class="dropdown-item px-0" :aria-pressed="!itemSortNewestFirst" :class="{active: !itemSortNewestFirst}" @click.stop="itemSortNewestFirst=false">{{ $t('old') }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<header class="dropdown-header" role="heading" aria-level="2">Subscriptions</header>
|
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('subscriptions') }}</header>
|
||||||
<form id="opml-import-form" enctype="multipart/form-data" tabindex="-1">
|
<form id="opml-import-form" enctype="multipart/form-data" tabindex="-1">
|
||||||
<input type="file"
|
<input type="file"
|
||||||
id="opml-import"
|
id="opml-import"
|
||||||
@@ -112,22 +112,31 @@
|
|||||||
style="opacity: 0; width: 1px; height: 0; position: absolute; z-index: -1;">
|
style="opacity: 0; width: 1px; height: 0; position: absolute; z-index: -1;">
|
||||||
<label class="dropdown-item mb-0 cursor-pointer" for="opml-import" @click.stop="">
|
<label class="dropdown-item mb-0 cursor-pointer" for="opml-import" @click.stop="">
|
||||||
<span class="icon mr-1">{% inline "download.svg" %}</span>
|
<span class="icon mr-1">{% inline "download.svg" %}</span>
|
||||||
Import
|
{{ $t('import') }}
|
||||||
</label>
|
</label>
|
||||||
</form>
|
</form>
|
||||||
<a class="dropdown-item" href="./opml/export">
|
<a class="dropdown-item" href="./opml/export">
|
||||||
<span class="icon mr-1">{% inline "upload.svg" %}</span>
|
<span class="icon mr-1">{% inline "upload.svg" %}</span>
|
||||||
Export
|
{{ $t('export') }}
|
||||||
</a>
|
</a>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<button class="dropdown-item" @click="showSettings('shortcuts')">
|
<button class="dropdown-item" @click="showSettings('shortcuts')">
|
||||||
<span class="icon mr-1">{% inline "help-circle.svg" %}</span>
|
<span class="icon mr-1">{% inline "help-circle.svg" %}</span>
|
||||||
Shortcuts
|
{{ $t('shortcuts') }}
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<header class="dropdown-header" role="heading" aria-level="2">ᚨ / 𐎠 / 𑖀</header>
|
||||||
|
<button
|
||||||
|
v-for="lang in languages"
|
||||||
|
class="dropdown-item"
|
||||||
|
:class="{active: language==lang.code}"
|
||||||
|
@click.stop="changeLanguage(lang.code)">
|
||||||
|
{{ lang.name }}
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-divider" v-if="authenticated"></div>
|
<div class="dropdown-divider" v-if="authenticated"></div>
|
||||||
<button class="dropdown-item" v-if="authenticated" @click="logout()">
|
<button class="dropdown-item" v-if="authenticated" @click="logout()">
|
||||||
<span class="icon mr-1">{% inline "log-out.svg" %}</span>
|
<span class="icon mr-1">{% inline "log-out.svg" %}</span>
|
||||||
Log out
|
{{ $t('log_out') }}
|
||||||
</button>
|
</button>
|
||||||
</dropdown>
|
</dropdown>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,9 +145,9 @@
|
|||||||
<input type="radio" name="feed" value="" v-model="feedSelected">
|
<input type="radio" name="feed" value="" v-model="feedSelected">
|
||||||
<div class="selectgroup-label d-flex align-items-center w-100">
|
<div class="selectgroup-label d-flex align-items-center w-100">
|
||||||
<span class="icon mr-2">{% inline "layers.svg" %}</span>
|
<span class="icon mr-2">{% inline "layers.svg" %}</span>
|
||||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='unread'">All Unread</span>
|
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='unread'">{{ $t('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=='starred'">{{ $t('all_starred') }}</span>
|
||||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected==''">All Feeds</span>
|
<span class="flex-fill text-left text-truncate" v-if="filterSelected==''">{{ $t('all_feeds') }}</span>
|
||||||
<span class="counter text-right">{{ filteredTotalStats }}</span>
|
<span class="counter text-right">{{ filteredTotalStats }}</span>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -179,7 +188,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="p-2 toolbar d-flex align-items-center border-top flex-shrink-0" v-if="loading.feeds">
|
<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="icon loading mx-2"></span>
|
||||||
<span class="text-truncate cursor-default noselect">Refreshing ({{ loading.feeds }} left)</span>
|
<span class="text-truncate cursor-default noselect">{{ $t('refreshing') }} ({{ loading.feeds }} {{ $t('left') }})</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- item list -->
|
<!-- item list -->
|
||||||
@@ -188,7 +197,7 @@
|
|||||||
<div class="px-2 toolbar d-flex align-items-center">
|
<div class="px-2 toolbar d-flex align-items-center">
|
||||||
<button class="toolbar-item mr-2 d-block d-md-none"
|
<button class="toolbar-item mr-2 d-block d-md-none"
|
||||||
@click="feedSelected = null"
|
@click="feedSelected = null"
|
||||||
title="Show Feeds">
|
:title="$t('show_feeds')">
|
||||||
<span class="icon">{% inline "chevron-left.svg" %}</span>
|
<span class="icon">{% inline "chevron-left.svg" %}</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="input-icon flex-grow-1">
|
<div class="input-icon flex-grow-1">
|
||||||
@@ -199,7 +208,7 @@
|
|||||||
<button class="toolbar-item ml-2"
|
<button class="toolbar-item ml-2"
|
||||||
@click="markItemsRead()"
|
@click="markItemsRead()"
|
||||||
v-if="filterSelected == 'unread'"
|
v-if="filterSelected == 'unread'"
|
||||||
title="Mark All Read">
|
:title="$t('mark_all_read')">
|
||||||
<span class="icon">{% inline "check.svg" %}</span>
|
<span class="icon">{% inline "check.svg" %}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -210,7 +219,7 @@
|
|||||||
<dropdown class="settings-dropdown"
|
<dropdown class="settings-dropdown"
|
||||||
toggle-class="btn btn-link toolbar-item px-2 ml-2"
|
toggle-class="btn btn-link toolbar-item px-2 ml-2"
|
||||||
drop="right"
|
drop="right"
|
||||||
title="Feed Settings"
|
:title="$t('feed_settings')"
|
||||||
v-if="current.type == 'feed'">
|
v-if="current.type == 'feed'">
|
||||||
<template v-slot:button>
|
<template v-slot:button>
|
||||||
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
||||||
@@ -218,23 +227,23 @@
|
|||||||
<header class="dropdown-header" role="heading" aria-level="2">{{ current.feed.title }}</header>
|
<header class="dropdown-header" role="heading" aria-level="2">{{ current.feed.title }}</header>
|
||||||
<a class="dropdown-item" :href="current.feed.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" v-if="current.feed.link">
|
<a class="dropdown-item" :href="current.feed.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" v-if="current.feed.link">
|
||||||
<span class="icon mr-1">{% inline "globe.svg" %}</span>
|
<span class="icon mr-1">{% inline "globe.svg" %}</span>
|
||||||
Website
|
{{ $t('website') }}
|
||||||
</a>
|
</a>
|
||||||
<a class="dropdown-item" :href="current.feed.feed_link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" v-if="current.feed.feed_link">
|
<a class="dropdown-item" :href="current.feed.feed_link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" v-if="current.feed.feed_link">
|
||||||
<span class="icon mr-1">{% inline "rss.svg" %}</span>
|
<span class="icon mr-1">{% inline "rss.svg" %}</span>
|
||||||
Feed Link
|
{{ $t('feed_link') }}
|
||||||
</a>
|
</a>
|
||||||
<div class="dropdown-divider" v-if="current.feed.link || current.feed.feed_link"></div>
|
<div class="dropdown-divider" v-if="current.feed.link || current.feed.feed_link"></div>
|
||||||
<button class="dropdown-item" @click="renameFeed(current.feed)">
|
<button class="dropdown-item" @click="renameFeed(current.feed)">
|
||||||
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
||||||
Rename
|
{{ $t('rename') }}
|
||||||
</button>
|
</button>
|
||||||
<button class="dropdown-item" @click="updateFeedLink(current.feed)" v-if="current.feed.feed_link">
|
<button class="dropdown-item" @click="updateFeedLink(current.feed)" v-if="current.feed.feed_link">
|
||||||
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
||||||
Change Link
|
{{ $t('change_link') }}
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<header class="dropdown-header" role="heading" aria-level="2">Move to...</header>
|
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('move_to') }}</header>
|
||||||
<button class="dropdown-item"
|
<button class="dropdown-item"
|
||||||
v-if="folder.id != current.feed.folder_id"
|
v-if="folder.id != current.feed.folder_id"
|
||||||
v-for="folder in folders"
|
v-for="folder in folders"
|
||||||
@@ -248,17 +257,17 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="dropdown-item text-muted" @click="moveFeedToNewFolder(current.feed)">
|
<button class="dropdown-item text-muted" @click="moveFeedToNewFolder(current.feed)">
|
||||||
<span class="icon mr-1">{% inline "folder-plus.svg" %}</span>
|
<span class="icon mr-1">{% inline "folder-plus.svg" %}</span>
|
||||||
new folder
|
{{ $t('new_folder') }}
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<button class="dropdown-item text-danger" @click.prevent="deleteFeed(current.feed)">
|
<button class="dropdown-item text-danger" @click.prevent="deleteFeed(current.feed)">
|
||||||
<span class="icon mr-1">{% inline "trash.svg" %}</span>
|
<span class="icon mr-1">{% inline "trash.svg" %}</span>
|
||||||
Delete
|
{{ $t('delete') }}
|
||||||
</button>
|
</button>
|
||||||
</dropdown>
|
</dropdown>
|
||||||
<dropdown class="settings-dropdown"
|
<dropdown class="settings-dropdown"
|
||||||
toggle-class="btn btn-link toolbar-item px-2 ml-2"
|
toggle-class="btn btn-link toolbar-item px-2 ml-2"
|
||||||
title="Folder Settings"
|
:title="$t('folder_settings')"
|
||||||
drop="right"
|
drop="right"
|
||||||
v-if="current.type == 'folder'">
|
v-if="current.type == 'folder'">
|
||||||
<template v-slot:button>
|
<template v-slot:button>
|
||||||
@@ -267,12 +276,12 @@
|
|||||||
<header class="dropdown-header" role="heading" aria-level="2">{{ current.folder.title }}</header>
|
<header class="dropdown-header" role="heading" aria-level="2">{{ current.folder.title }}</header>
|
||||||
<button class="dropdown-item" @click="renameFolder(current.folder)">
|
<button class="dropdown-item" @click="renameFolder(current.folder)">
|
||||||
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
||||||
Rename
|
{{ $t('rename') }}
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<button class="dropdown-item text-danger" @click="deleteFolder(current.folder)">
|
<button class="dropdown-item text-danger" @click="deleteFolder(current.folder)">
|
||||||
<span class="icon mr-1">{% inline "trash.svg" %}</span>
|
<span class="icon mr-1">{% inline "trash.svg" %}</span>
|
||||||
Delete
|
{{ $t('delete') }}
|
||||||
</button>
|
</button>
|
||||||
</dropdown>
|
</dropdown>
|
||||||
</div>
|
</div>
|
||||||
@@ -291,7 +300,7 @@
|
|||||||
</small>
|
</small>
|
||||||
<small class="flex-shrink-0"><relative-time v-bind:title="formatDate(item.date)" :val="item.date"/></small>
|
<small class="flex-shrink-0"><relative-time v-bind:title="formatDate(item.date)" :val="item.date"/></small>
|
||||||
</div>
|
</div>
|
||||||
<div>{{ item.title || 'untitled' }}</div>
|
<div>{{ item.title || $t('untitled') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn-link btn-block loading my-3" v-if="itemsHasMore"></button>
|
<button class="btn btn-link btn-block loading my-3" v-if="itemsHasMore"></button>
|
||||||
@@ -305,24 +314,24 @@
|
|||||||
<div class="toolbar px-2 d-flex align-items-center" v-if="itemSelectedDetails">
|
<div class="toolbar px-2 d-flex align-items-center" v-if="itemSelectedDetails">
|
||||||
<button class="toolbar-item"
|
<button class="toolbar-item"
|
||||||
@click="toggleItemStarred(itemSelectedDetails)"
|
@click="toggleItemStarred(itemSelectedDetails)"
|
||||||
title="Mark Starred">
|
:title="$t('mark_starred')">
|
||||||
<span class="icon" v-if="itemSelectedDetails.status=='starred'" >{% inline "star-full.svg" %}</span>
|
<span class="icon" v-if="itemSelectedDetails.status=='starred'" >{% inline "star-full.svg" %}</span>
|
||||||
<span class="icon" v-else-if="itemSelectedDetails.status!='starred'" >{% inline "star.svg" %}</span>
|
<span class="icon" v-else-if="itemSelectedDetails.status!='starred'" >{% inline "star.svg" %}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="toolbar-item"
|
<button class="toolbar-item"
|
||||||
title="Mark Unread"
|
:title="$t('mark_unread')"
|
||||||
@click="toggleItemRead(itemSelectedDetails)">
|
@click="toggleItemRead(itemSelectedDetails)">
|
||||||
<span class="icon" v-if="itemSelectedDetails.status=='unread'">{% inline "circle-full.svg" %}</span>
|
<span class="icon" v-if="itemSelectedDetails.status=='unread'">{% inline "circle-full.svg" %}</span>
|
||||||
<span class="icon" v-if="itemSelectedDetails.status!='unread'">{% inline "circle.svg" %}</span>
|
<span class="icon" v-if="itemSelectedDetails.status!='unread'">{% inline "circle.svg" %}</span>
|
||||||
</button>
|
</button>
|
||||||
<dropdown class="settings-dropdown" toggle-class="toolbar-item px-2" drop="center" title="Appearance">
|
<dropdown class="settings-dropdown" toggle-class="toolbar-item px-2" drop="center" :title="$t('appearance')">
|
||||||
<template v-slot:button>
|
<template v-slot:button>
|
||||||
<span class="icon">{% inline "sliders.svg" %}</span>
|
<span class="icon">{% inline "sliders.svg" %}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<button class="dropdown-item" :class="{active: !theme.font}" @click.stop="theme.font = ''">sans-serif</button>
|
<button class="dropdown-item" :class="{active: !theme.font}" @click.stop="theme.font = ''">{{ $t('sans_serif') }}</button>
|
||||||
<button class="dropdown-item font-serif" :class="{active: theme.font == 'serif'}" @click.stop="theme.font = 'serif'">serif</button>
|
<button class="dropdown-item font-serif" :class="{active: theme.font == 'serif'}" @click.stop="theme.font = 'serif'">{{ $t('serif') }}</button>
|
||||||
<button class="dropdown-item font-monospace" :class="{active: theme.font == 'monospace'}" @click.stop="theme.font = 'monospace'">monospace</button>
|
<button class="dropdown-item font-monospace" :class="{active: theme.font == 'monospace'}" @click.stop="theme.font = 'monospace'">{{ $t('monospace') }}</button>
|
||||||
|
|
||||||
<div class="d-flex text-center">
|
<div class="d-flex text-center">
|
||||||
<button class="dropdown-item" style="font-size: 0.8rem" @click.stop="incrFont(-1)">A</button>
|
<button class="dropdown-item" style="font-size: 0.8rem" @click.stop="incrFont(-1)">A</button>
|
||||||
@@ -332,20 +341,20 @@
|
|||||||
<button class="toolbar-item"
|
<button class="toolbar-item"
|
||||||
:class="{active: itemSelectedReadability}"
|
:class="{active: itemSelectedReadability}"
|
||||||
@click="toggleReadability()"
|
@click="toggleReadability()"
|
||||||
title="Read Here">
|
:title="$t('read_here')">
|
||||||
<span class="icon" :class="{'icon-loading': loading.readability}">{% inline "book-open.svg" %}</span>
|
<span class="icon" :class="{'icon-loading': loading.readability}">{% inline "book-open.svg" %}</span>
|
||||||
</button>
|
</button>
|
||||||
<a class="toolbar-item" :href="itemSelectedDetails.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" title="Open Link">
|
<a class="toolbar-item" :href="itemSelectedDetails.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" :title="$t('open_link')">
|
||||||
<span class="icon">{% inline "external-link.svg" %}</span>
|
<span class="icon">{% inline "external-link.svg" %}</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="flex-grow-1"></div>
|
<div class="flex-grow-1"></div>
|
||||||
<button class="toolbar-item" @click="navigateToItem(-1)" title="Previous Article" :disabled="!items.length || itemSelected == items[0].id">
|
<button class="toolbar-item" @click="navigateToItem(-1)" :title="$t('previous_article')" :disabled="!items.length || itemSelected == items[0].id">
|
||||||
<span class="icon">{% inline "chevron-left.svg" %}</span>
|
<span class="icon">{% inline "chevron-left.svg" %}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="toolbar-item" @click="navigateToItem(+1)" title="Next Article" :disabled="!items.length || itemSelected == items[items.length - 1].id">
|
<button class="toolbar-item" @click="navigateToItem(+1)" :title="$t('next_article')" :disabled="!items.length || itemSelected == items[items.length - 1].id">
|
||||||
<span class="icon">{% inline "chevron-right.svg" %}</span>
|
<span class="icon">{% inline "chevron-right.svg" %}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="toolbar-item" @click="itemSelected=null" title="Close Article">
|
<button class="toolbar-item" @click="itemSelected=null" :title="$t('close_article')">
|
||||||
<span class="icon">{% inline "x.svg" %}</span>
|
<span class="icon">{% inline "x.svg" %}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -355,7 +364,7 @@
|
|||||||
:class="{'font-serif': theme.font == 'serif', 'font-monospace': theme.font == 'monospace'}"
|
:class="{'font-serif': theme.font == 'serif', 'font-monospace': theme.font == 'monospace'}"
|
||||||
:style="{'font-size': theme.size + 'rem'}">
|
:style="{'font-size': theme.size + 'rem'}">
|
||||||
<div class="content-wrapper">
|
<div class="content-wrapper">
|
||||||
<h1><b>{{ itemSelectedDetails.title || 'untitled' }}</b></h1>
|
<h1><b>{{ itemSelectedDetails.title || $t('untitled') }}</b></h1>
|
||||||
<div class="text-muted">
|
<div class="text-muted">
|
||||||
<div>
|
<div>
|
||||||
<span class="cursor-pointer" @click="feedSelected = 'feed:'+(feedsById[itemSelectedDetails.feed_id] || {}).id">
|
<span class="cursor-pointer" @click="feedSelected = 'feed:'+(feedsById[itemSelectedDetails.feed_id] || {}).id">
|
||||||
@@ -384,13 +393,13 @@
|
|||||||
<span class="icon">{% inline "x.svg" %}</span>
|
<span class="icon">{% inline "x.svg" %}</span>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="settings=='create'">
|
<div v-if="settings=='create'">
|
||||||
<p class="cursor-default"><b>New Feed</b></p>
|
<p class="cursor-default"><b>{{ $t('new_feed') }}</b></p>
|
||||||
<form action="" @submit.prevent="createFeed(event)" class="mt-4">
|
<form action="" @submit.prevent="createFeed(event)" class="mt-4">
|
||||||
<label for="feed-url">URL</label>
|
<label for="feed-url">{{ $t('url') }}</label>
|
||||||
<input id="feed-url" name="url" type="url" class="form-control" required autocomplete="off" :readonly="feedNewChoice.length > 0" placeholder="https://example.com/feed" v-focus>
|
<input id="feed-url" name="url" type="url" class="form-control" required autocomplete="off" :readonly="feedNewChoice.length > 0" placeholder="https://example.com/feed" v-focus>
|
||||||
<label for="feed-folder" class="mt-3 d-block">
|
<label for="feed-folder" class="mt-3 d-block">
|
||||||
Folder
|
{{ $t('folder') }}
|
||||||
<a href="#" class="float-right text-decoration-none" @click.prevent="createNewFeedFolder()">new folder</a>
|
<a href="#" class="float-right text-decoration-none" @click.prevent="createNewFeedFolder()">{{ $t('new_folder') }}</a>
|
||||||
</label>
|
</label>
|
||||||
<select class="form-control" id="feed-folder" name="folder_id" ref="newFeedFolder">
|
<select class="form-control" id="feed-folder" name="folder_id" ref="newFeedFolder">
|
||||||
<option value="">---</option>
|
<option value="">---</option>
|
||||||
@@ -398,8 +407,8 @@
|
|||||||
</select>
|
</select>
|
||||||
<div class="mt-4" v-if="feedNewChoice.length">
|
<div class="mt-4" v-if="feedNewChoice.length">
|
||||||
<p class="mb-2">
|
<p class="mb-2">
|
||||||
Multiple feeds found. Choose one below:
|
{{ $t('multiple_feeds_found') }}
|
||||||
<a href="#" class="float-right text-decoration-none" @click.prevent="resetFeedChoice()">cancel</a>
|
<a href="#" class="float-right text-decoration-none" @click.prevent="resetFeedChoice()">{{ $t('cancel') }}</a>
|
||||||
</p>
|
</p>
|
||||||
<label class="selectgroup" v-for="choice in feedNewChoice">
|
<label class="selectgroup" v-for="choice in feedNewChoice">
|
||||||
<input type="radio" name="feedToAdd" :value="choice.url" v-model="feedNewChoiceSelected">
|
<input type="radio" name="feedToAdd" :value="choice.url" v-model="feedNewChoiceSelected">
|
||||||
@@ -409,29 +418,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-block btn-default mt-3" :class="{loading: loading.newfeed}" type="submit">Add</button>
|
<button class="btn btn-block btn-default mt-3" :class="{loading: loading.newfeed}" type="submit">{{ $t('add') }}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="settings=='shortcuts'">
|
<div v-else-if="settings=='shortcuts'">
|
||||||
<p class="cursor-default"><b>Keyboard Shortcuts</b></p>
|
<p class="cursor-default"><b>{{ $t('keyboard_shortcuts') }}</b></p>
|
||||||
|
|
||||||
<table class="table table-borderless table-sm table-compact m-0">
|
<table class="table table-borderless table-sm table-compact m-0">
|
||||||
<tr><td><kbd>1</kbd> <kbd>2</kbd> <kbd>3</kbd></td>
|
<tr><td><kbd>1</kbd> <kbd>2</kbd> <kbd>3</kbd></td>
|
||||||
<td>show unread / starred / all feeds</td></tr>
|
<td>{{ $t('kb_show_filters') }}</td></tr>
|
||||||
<tr><td><kbd>/</kbd></td> <td>focus the search bar</td></tr>
|
<tr><td><kbd>/</kbd></td> <td>{{ $t('kb_focus_search') }}</td></tr>
|
||||||
|
|
||||||
<tr><td colspan=2> </td></tr>
|
<tr><td colspan=2> </td></tr>
|
||||||
<tr><td><kbd>j</kbd> <kbd>k</kbd></td> <td>next / prev article</td></tr>
|
<tr><td><kbd>j</kbd> <kbd>k</kbd></td> <td>{{ $t('kb_next_prev_article') }}</td></tr>
|
||||||
<tr><td><kbd>l</kbd> <kbd>h</kbd></td> <td>next / prev feed</td></tr>
|
<tr><td><kbd>l</kbd> <kbd>h</kbd></td> <td>{{ $t('kb_next_prev_feed') }}</td></tr>
|
||||||
<tr><td><kbd>q</kbd></td> <td>close article</td></tr>
|
<tr><td><kbd>q</kbd></td> <td>{{ $t('kb_close_article') }}</td></tr>
|
||||||
|
|
||||||
<tr><td colspan=2> </td></tr>
|
<tr><td colspan=2> </td></tr>
|
||||||
<tr><td><kbd>R</kbd></td> <td>mark all read</td></tr>
|
<tr><td><kbd>R</kbd></td> <td>{{ $t('kb_mark_all_read') }}</td></tr>
|
||||||
<tr><td><kbd>r</kbd></td> <td>mark read / unread</td></tr>
|
<tr><td><kbd>r</kbd></td> <td>{{ $t('kb_mark_read') }}</td></tr>
|
||||||
<tr><td><kbd>s</kbd></td> <td>mark starred / unstarred</td></tr>
|
<tr><td><kbd>s</kbd></td> <td>{{ $t('kb_mark_starred') }}</td></tr>
|
||||||
<tr><td><kbd>o</kbd></td> <td>open link</td></tr>
|
<tr><td><kbd>o</kbd></td> <td>{{ $t('kb_open_link') }}</td></tr>
|
||||||
<tr><td><kbd>i</kbd></td> <td>read here</td> </tr>
|
<tr><td><kbd>i</kbd></td> <td>{{ $t('kb_read_here') }}</td> </tr>
|
||||||
<tr><td><kbd>f</kbd> <kbd>b</kbd></td> <td>scroll content forward / backward</td>
|
<tr><td><kbd>f</kbd> <kbd>b</kbd></td> <td>{{ $t('kb_scroll_content') }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -440,6 +449,7 @@
|
|||||||
<!-- external -->
|
<!-- external -->
|
||||||
<script src="./static/javascripts/vue.min.js"></script>
|
<script src="./static/javascripts/vue.min.js"></script>
|
||||||
<!-- internal -->
|
<!-- internal -->
|
||||||
|
<script src="./static/javascripts/i18n.js"></script>
|
||||||
<script src="./static/javascripts/api.js"></script>
|
<script src="./static/javascripts/api.js"></script>
|
||||||
<script src="./static/javascripts/app.js"></script>
|
<script src="./static/javascripts/app.js"></script>
|
||||||
<script src="./static/javascripts/key.js"></script>
|
<script src="./static/javascripts/key.js"></script>
|
||||||
|
|||||||
@@ -202,6 +202,8 @@ Vue.component('relative-time', {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Vue.use(i18n)
|
||||||
|
|
||||||
var vm = new Vue({
|
var vm = new Vue({
|
||||||
created: function() {
|
created: function() {
|
||||||
this.refreshStats()
|
this.refreshStats()
|
||||||
@@ -212,6 +214,7 @@ var vm = new Vue({
|
|||||||
vm.feed_errors = errors
|
vm.feed_errors = errors
|
||||||
})
|
})
|
||||||
this.updateMetaTheme(app.settings.theme_name)
|
this.updateMetaTheme(app.settings.theme_name)
|
||||||
|
this.$setLang(app.settings.language)
|
||||||
},
|
},
|
||||||
data: function() {
|
data: function() {
|
||||||
var s = app.settings
|
var s = app.settings
|
||||||
@@ -269,6 +272,13 @@ var vm = new Vue({
|
|||||||
{ title: "12h", value: 720 },
|
{ title: "12h", value: 720 },
|
||||||
{ title: "24h", value: 1440 },
|
{ title: "24h", value: 1440 },
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'language': s.language,
|
||||||
|
'languages': [
|
||||||
|
{code: 'en', name: 'English' },
|
||||||
|
{code: 'zh', name: '简体中文'},
|
||||||
|
{code: 'ru', name: 'Русский'},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -834,6 +844,11 @@ var vm = new Vue({
|
|||||||
&& !this.filteredFeedStats[feed.id]
|
&& !this.filteredFeedStats[feed.id]
|
||||||
&& (!this.itemSelectedDetails || this.itemSelectedDetails.feed_id != feed.id)
|
&& (!this.itemSelectedDetails || this.itemSelectedDetails.feed_id != feed.id)
|
||||||
},
|
},
|
||||||
|
changeLanguage(lang) {
|
||||||
|
this.$setLang(lang)
|
||||||
|
this.language = lang
|
||||||
|
api.settings.update({language: lang})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
372
src/assets/javascripts/i18n.js
Normal file
372
src/assets/javascripts/i18n.js
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
(function (exports) {
|
||||||
|
const translations = {
|
||||||
|
"unread": {
|
||||||
|
"en": "Unread",
|
||||||
|
"zh": "未读",
|
||||||
|
"ru": "Непрочитанные"
|
||||||
|
},
|
||||||
|
"starred": {
|
||||||
|
"en": "Starred",
|
||||||
|
"zh": "星标",
|
||||||
|
"ru": "Избранные"
|
||||||
|
},
|
||||||
|
"all": {
|
||||||
|
"en": "All",
|
||||||
|
"zh": "全部",
|
||||||
|
"ru": "Все"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"en": "Settings",
|
||||||
|
"zh": "设置",
|
||||||
|
"ru": "Настройки"
|
||||||
|
},
|
||||||
|
"new_feed": {
|
||||||
|
"en": "New Feed",
|
||||||
|
"zh": "新建订阅",
|
||||||
|
"ru": "Новая лента"
|
||||||
|
},
|
||||||
|
"refresh_feeds": {
|
||||||
|
"en": "Refresh Feeds",
|
||||||
|
"zh": "刷新订阅",
|
||||||
|
"ru": "Обновить ленты"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"en": "Theme",
|
||||||
|
"zh": "主题",
|
||||||
|
"ru": "Тема"
|
||||||
|
},
|
||||||
|
"auto_refresh": {
|
||||||
|
"en": "Auto Refresh",
|
||||||
|
"zh": "自动刷新",
|
||||||
|
"ru": "Автообновление"
|
||||||
|
},
|
||||||
|
"show_first": {
|
||||||
|
"en": "Show first",
|
||||||
|
"zh": "优先显示",
|
||||||
|
"ru": "Сначала"
|
||||||
|
},
|
||||||
|
"new": {
|
||||||
|
"en": "New",
|
||||||
|
"zh": "最新",
|
||||||
|
"ru": "Новые"
|
||||||
|
},
|
||||||
|
"old": {
|
||||||
|
"en": "Old",
|
||||||
|
"zh": "最旧",
|
||||||
|
"ru": "Старые"
|
||||||
|
},
|
||||||
|
"subscriptions": {
|
||||||
|
"en": "Subscriptions",
|
||||||
|
"zh": "订阅管理",
|
||||||
|
"ru": "Подписки"
|
||||||
|
},
|
||||||
|
"import": {
|
||||||
|
"en": "Import",
|
||||||
|
"zh": "导入",
|
||||||
|
"ru": "Импорт"
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"en": "Export",
|
||||||
|
"zh": "导出",
|
||||||
|
"ru": "Экспорт"
|
||||||
|
},
|
||||||
|
"shortcuts": {
|
||||||
|
"en": "Shortcuts",
|
||||||
|
"zh": "快捷键",
|
||||||
|
"ru": "Горячие клавиши"
|
||||||
|
},
|
||||||
|
"log_out": {
|
||||||
|
"en": "Log out",
|
||||||
|
"zh": "登出",
|
||||||
|
"ru": "Выйти"
|
||||||
|
},
|
||||||
|
"all_unread": {
|
||||||
|
"en": "All Unread",
|
||||||
|
"zh": "全部未读",
|
||||||
|
"ru": "Все непрочитанные"
|
||||||
|
},
|
||||||
|
"all_starred": {
|
||||||
|
"en": "All Starred",
|
||||||
|
"zh": "全部星标",
|
||||||
|
"ru": "Все избранные"
|
||||||
|
},
|
||||||
|
"all_feeds": {
|
||||||
|
"en": "All Feeds",
|
||||||
|
"zh": "全部订阅",
|
||||||
|
"ru": "Все ленты"
|
||||||
|
},
|
||||||
|
"refreshing": {
|
||||||
|
"en": "Refreshing",
|
||||||
|
"zh": "正在刷新",
|
||||||
|
"ru": "Обновление"
|
||||||
|
},
|
||||||
|
"left": {
|
||||||
|
"en": "left",
|
||||||
|
"zh": "剩余",
|
||||||
|
"ru": "осталось"
|
||||||
|
},
|
||||||
|
"show_feeds": {
|
||||||
|
"en": "Show Feeds",
|
||||||
|
"zh": "显示订阅",
|
||||||
|
"ru": "Показать ленты"
|
||||||
|
},
|
||||||
|
"mark_all_read": {
|
||||||
|
"en": "Mark All Read",
|
||||||
|
"zh": "全部标记为已读",
|
||||||
|
"ru": "Отметить все как прочитанные"
|
||||||
|
},
|
||||||
|
"feed_settings": {
|
||||||
|
"en": "Feed Settings",
|
||||||
|
"zh": "订阅设置",
|
||||||
|
"ru": "Настройки ленты"
|
||||||
|
},
|
||||||
|
"folder_settings": {
|
||||||
|
"en": "Folder Settings",
|
||||||
|
"zh": "文件夹设置",
|
||||||
|
"ru": "Настройки папки"
|
||||||
|
},
|
||||||
|
"website": {
|
||||||
|
"en": "Website",
|
||||||
|
"zh": "网站",
|
||||||
|
"ru": "Сайт"
|
||||||
|
},
|
||||||
|
"feed_link": {
|
||||||
|
"en": "Feed Link",
|
||||||
|
"zh": "订阅链接",
|
||||||
|
"ru": "Ссылка на ленту"
|
||||||
|
},
|
||||||
|
"rename": {
|
||||||
|
"en": "Rename",
|
||||||
|
"zh": "重命名",
|
||||||
|
"ru": "Переименовать"
|
||||||
|
},
|
||||||
|
"change_link": {
|
||||||
|
"en": "Change Link",
|
||||||
|
"zh": "修改链接",
|
||||||
|
"ru": "Изменить ссылку"
|
||||||
|
},
|
||||||
|
"move_to": {
|
||||||
|
"en": "Move to...",
|
||||||
|
"zh": "移动到...",
|
||||||
|
"ru": "Переместить в..."
|
||||||
|
},
|
||||||
|
"new_folder": {
|
||||||
|
"en": "new folder",
|
||||||
|
"zh": "新建文件夹",
|
||||||
|
"ru": "новая папка"
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"en": "Delete",
|
||||||
|
"zh": "删除",
|
||||||
|
"ru": "Удалить"
|
||||||
|
},
|
||||||
|
"mark_starred": {
|
||||||
|
"en": "Mark Starred",
|
||||||
|
"zh": "标记星标",
|
||||||
|
"ru": "Пометить избранным"
|
||||||
|
},
|
||||||
|
"mark_unread": {
|
||||||
|
"en": "Mark Unread",
|
||||||
|
"zh": "标记未读",
|
||||||
|
"ru": "Пометить непрочитанным"
|
||||||
|
},
|
||||||
|
"appearance": {
|
||||||
|
"en": "Appearance",
|
||||||
|
"zh": "外观",
|
||||||
|
"ru": "Внешний вид"
|
||||||
|
},
|
||||||
|
"read_here": {
|
||||||
|
"en": "Read Here",
|
||||||
|
"zh": "在此阅读",
|
||||||
|
"ru": "Читать здесь"
|
||||||
|
},
|
||||||
|
"open_link": {
|
||||||
|
"en": "Open Link",
|
||||||
|
"zh": "打开链接",
|
||||||
|
"ru": "Открыть ссылку"
|
||||||
|
},
|
||||||
|
"previous_article": {
|
||||||
|
"en": "Previous Article",
|
||||||
|
"zh": "上一篇",
|
||||||
|
"ru": "Предыдущая статья"
|
||||||
|
},
|
||||||
|
"next_article": {
|
||||||
|
"en": "Next Article",
|
||||||
|
"zh": "下一篇",
|
||||||
|
"ru": "Следующая статья"
|
||||||
|
},
|
||||||
|
"close_article": {
|
||||||
|
"en": "Close Article",
|
||||||
|
"zh": "关闭文章",
|
||||||
|
"ru": "Закрыть статью"
|
||||||
|
},
|
||||||
|
"untitled": {
|
||||||
|
"en": "untitled",
|
||||||
|
"zh": "无标题",
|
||||||
|
"ru": "без названия"
|
||||||
|
},
|
||||||
|
"sans_serif": {
|
||||||
|
"en": "sans-serif",
|
||||||
|
"zh": "无衬线",
|
||||||
|
"ru": "sans-serif"
|
||||||
|
},
|
||||||
|
"serif": {
|
||||||
|
"en": "serif",
|
||||||
|
"zh": "衬线",
|
||||||
|
"ru": "serif"
|
||||||
|
},
|
||||||
|
"monospace": {
|
||||||
|
"en": "monospace",
|
||||||
|
"zh": "等宽",
|
||||||
|
"ru": "monospace"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"en": "URL",
|
||||||
|
"zh": "网址",
|
||||||
|
"ru": "URL"
|
||||||
|
},
|
||||||
|
"folder": {
|
||||||
|
"en": "Folder",
|
||||||
|
"zh": "文件夹",
|
||||||
|
"ru": "Папка"
|
||||||
|
},
|
||||||
|
"add": {
|
||||||
|
"en": "Add",
|
||||||
|
"zh": "添加",
|
||||||
|
"ru": "Добавить"
|
||||||
|
},
|
||||||
|
"keyboard_shortcuts": {
|
||||||
|
"en": "Keyboard Shortcuts",
|
||||||
|
"zh": "键盘快捷键",
|
||||||
|
"ru": "Горячие клавиши"
|
||||||
|
},
|
||||||
|
"multiple_feeds_found": {
|
||||||
|
"en": "Multiple feeds found. Choose one below:",
|
||||||
|
"zh": "找到多个订阅源,请选择一个:",
|
||||||
|
"ru": "Найдено несколько лент. Выберите одну:"
|
||||||
|
},
|
||||||
|
"cancel": {
|
||||||
|
"en": "cancel",
|
||||||
|
"zh": "取消",
|
||||||
|
"ru": "отмена"
|
||||||
|
},
|
||||||
|
"kb_show_filters": {
|
||||||
|
"en": "show unread / starred / all feeds",
|
||||||
|
"zh": "显示未读/星标/全部订阅",
|
||||||
|
"ru": "показать непрочитанные / избранные / все ленты"
|
||||||
|
},
|
||||||
|
"kb_focus_search": {
|
||||||
|
"en": "focus the search bar",
|
||||||
|
"zh": "聚焦搜索栏",
|
||||||
|
"ru": "фокус на строку поиска"
|
||||||
|
},
|
||||||
|
"kb_next_prev_article": {
|
||||||
|
"en": "next / prev article",
|
||||||
|
"zh": "下一篇/上一篇文章",
|
||||||
|
"ru": "следующая / предыдущая статья"
|
||||||
|
},
|
||||||
|
"kb_next_prev_feed": {
|
||||||
|
"en": "next / prev feed",
|
||||||
|
"zh": "下一个/上一个订阅",
|
||||||
|
"ru": "следующая / предыдущая лента"
|
||||||
|
},
|
||||||
|
"kb_close_article": {
|
||||||
|
"en": "close article",
|
||||||
|
"zh": "关闭文章",
|
||||||
|
"ru": "закрыть статью"
|
||||||
|
},
|
||||||
|
"kb_mark_all_read": {
|
||||||
|
"en": "mark all read",
|
||||||
|
"zh": "全部标记为已读",
|
||||||
|
"ru": "отметить все как прочитанные"
|
||||||
|
},
|
||||||
|
"kb_mark_read": {
|
||||||
|
"en": "mark read / unread",
|
||||||
|
"zh": "标记已读/未读",
|
||||||
|
"ru": "отметить как прочитанное / непрочитанное"
|
||||||
|
},
|
||||||
|
"kb_mark_starred": {
|
||||||
|
"en": "mark starred / unstarred",
|
||||||
|
"zh": "标记星标/取消星标",
|
||||||
|
"ru": "пометить избранным / убрать из избранного"
|
||||||
|
},
|
||||||
|
"kb_open_link": {
|
||||||
|
"en": "open link",
|
||||||
|
"zh": "打开链接",
|
||||||
|
"ru": "открыть ссылку"
|
||||||
|
},
|
||||||
|
"kb_read_here": {
|
||||||
|
"en": "read here",
|
||||||
|
"zh": "在此阅读",
|
||||||
|
"ru": "читать здесь"
|
||||||
|
},
|
||||||
|
"kb_scroll_content": {
|
||||||
|
"en": "scroll content forward / backward",
|
||||||
|
"zh": "向前/向后滚动内容",
|
||||||
|
"ru": "прокрутка вперед / назад"
|
||||||
|
},
|
||||||
|
"prompt_folder_name": {
|
||||||
|
"en": "Enter folder name:",
|
||||||
|
"zh": "请输入文件夹名称:",
|
||||||
|
"ru": "Введите имя папки:"
|
||||||
|
},
|
||||||
|
"prompt_new_title": {
|
||||||
|
"en": "Enter new title",
|
||||||
|
"zh": "请输入新标题",
|
||||||
|
"ru": "Введите новый заголовок"
|
||||||
|
},
|
||||||
|
"prompt_feed_link": {
|
||||||
|
"en": "Enter feed link",
|
||||||
|
"zh": "请输入订阅链接",
|
||||||
|
"ru": "Введите ссылку на ленту"
|
||||||
|
},
|
||||||
|
"confirm_delete_folder": {
|
||||||
|
"en": "Are you sure you want to delete",
|
||||||
|
"zh": "确定要删除",
|
||||||
|
"ru": "Вы уверены, что хотите удалить"
|
||||||
|
},
|
||||||
|
"confirm_delete_feed": {
|
||||||
|
"en": "Are you sure you want to delete",
|
||||||
|
"zh": "确定要删除",
|
||||||
|
"ru": "Вы уверены, что хотите удалить"
|
||||||
|
},
|
||||||
|
"alert_no_feeds": {
|
||||||
|
"en": "No feeds found at the given url.",
|
||||||
|
"zh": "在指定的网址未找到订阅源。",
|
||||||
|
"ru": "Лент по данному адресу не найдено."
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"en": "Login",
|
||||||
|
"zh": "登录",
|
||||||
|
"ru": "Вход"
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"en": "Username",
|
||||||
|
"zh": "用户名",
|
||||||
|
"ru": "Имя пользователя"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"en": "Password",
|
||||||
|
"zh": "密码",
|
||||||
|
"ru": "Пароль"
|
||||||
|
},
|
||||||
|
};
|
||||||
|
class i18n {
|
||||||
|
constructor() {
|
||||||
|
this.lang = 'en'
|
||||||
|
}
|
||||||
|
setLang(lang) {
|
||||||
|
this.lang = lang
|
||||||
|
}
|
||||||
|
$t(code) {
|
||||||
|
return translations[code][this.lang]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.i18n = {
|
||||||
|
install(Vue, opts) {
|
||||||
|
const x = new i18n();
|
||||||
|
Vue.prototype.$t = x.$t
|
||||||
|
Vue.prototype.$setLang = x.setLang
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})(window)
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -225,12 +226,10 @@ func hasRequiredAttributes(tagName string, attributes []string) bool {
|
|||||||
for element, attrs := range elements {
|
for element, attrs := range elements {
|
||||||
if tagName == element {
|
if tagName == element {
|
||||||
for _, attribute := range attributes {
|
for _, attribute := range attributes {
|
||||||
for _, attr := range attrs {
|
if slices.Contains(attrs, attribute) {
|
||||||
if attr == attribute {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -285,13 +284,7 @@ func isValidIframeSource(baseURL, src string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, safeDomain := range whitelist {
|
return slices.Contains(whitelist, domain)
|
||||||
if safeDomain == domain {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTagAllowList() map[string][]string {
|
func getTagAllowList() map[string][]string {
|
||||||
@@ -355,13 +348,7 @@ func getTagAllowList() map[string][]string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func inList(needle string, haystack []string) bool {
|
func inList(needle string, haystack []string) bool {
|
||||||
for _, element := range haystack {
|
return slices.Contains(haystack, needle)
|
||||||
if element == needle {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func isBlockedTag(tagName string) bool {
|
func isBlockedTag(tagName string) bool {
|
||||||
@@ -371,13 +358,7 @@ func isBlockedTag(tagName string) bool {
|
|||||||
"style",
|
"style",
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, element := range blacklist {
|
return slices.Contains(blacklist, tagName)
|
||||||
if element == tagName {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package scraper
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/nkanaev/yarr/src/content/htmlutil"
|
"github.com/nkanaev/yarr/src/content/htmlutil"
|
||||||
@@ -22,12 +23,10 @@ func FindFeeds(body string, base string) map[string]string {
|
|||||||
isFeedLink := func(n *html.Node) bool {
|
isFeedLink := func(n *html.Node) bool {
|
||||||
if n.Type == html.ElementNode && n.Data == "link" {
|
if n.Type == html.ElementNode && n.Data == "link" {
|
||||||
t := htmlutil.Attr(n, "type")
|
t := htmlutil.Attr(n, "type")
|
||||||
for _, tt := range linkTypes {
|
if slices.Contains(linkTypes, t) {
|
||||||
if tt == t {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, node := range htmlutil.FindNodes(doc, isFeedLink) {
|
for _, node := range htmlutil.FindNodes(doc, isFeedLink) {
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ func TestRSSTitleHTMLTags(t *testing.T) {
|
|||||||
`))
|
`))
|
||||||
have := []string{feed.Items[0].Title, feed.Items[1].Title}
|
have := []string{feed.Items[0].Title, feed.Items[1].Title}
|
||||||
want := []string{"title in p", "very strong title"}
|
want := []string{"title in p", "very strong title"}
|
||||||
for i := 0; i < len(want); i++ {
|
for i := range want {
|
||||||
if want[i] != have[i] {
|
if want[i] != have[i] {
|
||||||
t.Errorf("title doesn't match\nwant: %#v\nhave: %#v\n", want[i], have[i])
|
t.Errorf("title doesn't match\nwant: %#v\nhave: %#v\n", want[i], have[i])
|
||||||
}
|
}
|
||||||
@@ -241,7 +241,7 @@ func TestRSSIsPermalink(t *testing.T) {
|
|||||||
URL: "http://example.com/posts/1",
|
URL: "http://example.com/posts/1",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for i := 0; i < len(want); i++ {
|
for i := range want {
|
||||||
if !reflect.DeepEqual(want, have) {
|
if !reflect.DeepEqual(want, have) {
|
||||||
t.Errorf("Failed to handle isPermalink\nwant: %#v\nhave: %#v\n", want[i], have[i])
|
t.Errorf("Failed to handle isPermalink\nwant: %#v\nhave: %#v\n", want[i], have[i])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func TestSafeXMLReaderPartial1(t *testing.T) {
|
|||||||
f = NewSafeXMLReader(f)
|
f = NewSafeXMLReader(f)
|
||||||
|
|
||||||
buf := make([]byte, 1)
|
buf := make([]byte, 1)
|
||||||
for i := 0; i < len(want); i++ {
|
for i := range want {
|
||||||
n, err := f.Read(buf)
|
n, err := f.Read(buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func (m *Middleware) Handler(c *router.Context) {
|
|||||||
c.Redirect(rootUrl)
|
c.Redirect(rootUrl)
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]interface{}{
|
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]any{
|
||||||
"username": username,
|
"username": username,
|
||||||
"error": "Invalid username/password",
|
"error": "Invalid username/password",
|
||||||
"settings": m.DB.GetSettings(),
|
"settings": m.DB.GetSettings(),
|
||||||
@@ -52,7 +52,7 @@ func (m *Middleware) Handler(c *router.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]interface{}{
|
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]any{
|
||||||
"settings": m.DB.GetSettings(),
|
"settings": m.DB.GetSettings(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ type FeverFavicon struct {
|
|||||||
Data string `json:"data"`
|
Data string `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeFeverJSON(c *router.Context, data map[string]interface{}, lastRefreshed int64) {
|
func writeFeverJSON(c *router.Context, data map[string]any, lastRefreshed int64) {
|
||||||
data["api_version"] = 3
|
data["api_version"] = 3
|
||||||
data["auth"] = 1
|
data["auth"] = 1
|
||||||
data["last_refreshed_on_time"] = lastRefreshed
|
data["last_refreshed_on_time"] = lastRefreshed
|
||||||
@@ -78,7 +78,7 @@ func (s *Server) feverAuth(c *router.Context) bool {
|
|||||||
if s.Username != "" && s.Password != "" {
|
if s.Username != "" && s.Password != "" {
|
||||||
apiKey := c.Req.FormValue("api_key")
|
apiKey := c.Req.FormValue("api_key")
|
||||||
apiKey = strings.ToLower(apiKey)
|
apiKey = strings.ToLower(apiKey)
|
||||||
md5HashValue := md5.Sum([]byte(fmt.Sprintf("%s:%s", s.Username, s.Password)))
|
md5HashValue := md5.Sum(fmt.Appendf(nil, "%s:%s", s.Username, s.Password))
|
||||||
hexMD5HashValue := fmt.Sprintf("%x", md5HashValue[:])
|
hexMD5HashValue := fmt.Sprintf("%x", md5HashValue[:])
|
||||||
if !auth.StringsEqual(apiKey, hexMD5HashValue) {
|
if !auth.StringsEqual(apiKey, hexMD5HashValue) {
|
||||||
return false
|
return false
|
||||||
@@ -97,7 +97,7 @@ func formHasValue(values url.Values, value string) bool {
|
|||||||
func (s *Server) handleFever(c *router.Context) {
|
func (s *Server) handleFever(c *router.Context) {
|
||||||
c.Req.ParseForm()
|
c.Req.ParseForm()
|
||||||
if !s.feverAuth(c) {
|
if !s.feverAuth(c) {
|
||||||
c.JSON(http.StatusOK, map[string]interface{}{
|
c.JSON(http.StatusOK, map[string]any{
|
||||||
"api_version": 3,
|
"api_version": 3,
|
||||||
"auth": 0,
|
"auth": 0,
|
||||||
"last_refreshed_on_time": 0,
|
"last_refreshed_on_time": 0,
|
||||||
@@ -123,7 +123,7 @@ func (s *Server) handleFever(c *router.Context) {
|
|||||||
case formHasValue(c.Req.Form, "mark"):
|
case formHasValue(c.Req.Form, "mark"):
|
||||||
s.feverMarkHandler(c)
|
s.feverMarkHandler(c)
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusOK, map[string]interface{}{
|
c.JSON(http.StatusOK, map[string]any{
|
||||||
"api_version": 3,
|
"api_version": 3,
|
||||||
"auth": 1,
|
"auth": 1,
|
||||||
"last_refreshed_on_time": getLastRefreshedOnTime(s.db.ListHTTPStates()),
|
"last_refreshed_on_time": getLastRefreshedOnTime(s.db.ListHTTPStates()),
|
||||||
@@ -168,7 +168,7 @@ func (s *Server) feverGroupsHandler(c *router.Context) {
|
|||||||
for i, folder := range folders {
|
for i, folder := range folders {
|
||||||
groups[i] = &FeverGroup{ID: folder.Id, Title: folder.Title}
|
groups[i] = &FeverGroup{ID: folder.Id, Title: folder.Title}
|
||||||
}
|
}
|
||||||
writeFeverJSON(c, map[string]interface{}{
|
writeFeverJSON(c, map[string]any{
|
||||||
"groups": groups,
|
"groups": groups,
|
||||||
"feeds_groups": feedGroups(s.db),
|
"feeds_groups": feedGroups(s.db),
|
||||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
||||||
@@ -194,7 +194,7 @@ func (s *Server) feverFeedsHandler(c *router.Context) {
|
|||||||
LastUpdated: lastUpdated,
|
LastUpdated: lastUpdated,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
writeFeverJSON(c, map[string]interface{}{
|
writeFeverJSON(c, map[string]any{
|
||||||
"feeds": feverFeeds,
|
"feeds": feverFeeds,
|
||||||
"feeds_groups": feedGroups(s.db),
|
"feeds_groups": feedGroups(s.db),
|
||||||
}, getLastRefreshedOnTime(httpStates))
|
}, getLastRefreshedOnTime(httpStates))
|
||||||
@@ -216,7 +216,7 @@ func (s *Server) feverFaviconsHandler(c *router.Context) {
|
|||||||
favicons[i] = &FeverFavicon{ID: feed.Id, Data: data}
|
favicons[i] = &FeverFavicon{ID: feed.Id, Data: data}
|
||||||
}
|
}
|
||||||
|
|
||||||
writeFeverJSON(c, map[string]interface{}{
|
writeFeverJSON(c, map[string]any{
|
||||||
"favicons": favicons,
|
"favicons": favicons,
|
||||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
||||||
}
|
}
|
||||||
@@ -280,15 +280,15 @@ func (s *Server) feverItemsHandler(c *router.Context) {
|
|||||||
|
|
||||||
totalItems := s.db.CountItems(storage.ItemFilter{})
|
totalItems := s.db.CountItems(storage.ItemFilter{})
|
||||||
|
|
||||||
writeFeverJSON(c, map[string]interface{}{
|
writeFeverJSON(c, map[string]any{
|
||||||
"items": feverItems,
|
"items": feverItems,
|
||||||
"total_items": totalItems,
|
"total_items": totalItems,
|
||||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) feverLinksHandler(c *router.Context) {
|
func (s *Server) feverLinksHandler(c *router.Context) {
|
||||||
writeFeverJSON(c, map[string]interface{}{
|
writeFeverJSON(c, map[string]any{
|
||||||
"links": make([]interface{}, 0),
|
"links": make([]any, 0),
|
||||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,7 +309,7 @@ func (s *Server) feverUnreadItemIDsHandler(c *router.Context) {
|
|||||||
}
|
}
|
||||||
itemFilter.After = &items[len(items)-1].Id
|
itemFilter.After = &items[len(items)-1].Id
|
||||||
}
|
}
|
||||||
writeFeverJSON(c, map[string]interface{}{
|
writeFeverJSON(c, map[string]any{
|
||||||
"unread_item_ids": joinInts(itemIds),
|
"unread_item_ids": joinInts(itemIds),
|
||||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
||||||
}
|
}
|
||||||
@@ -331,7 +331,7 @@ func (s *Server) feverSavedItemIDsHandler(c *router.Context) {
|
|||||||
}
|
}
|
||||||
itemFilter.After = &items[len(items)-1].Id
|
itemFilter.After = &items[len(items)-1].Id
|
||||||
}
|
}
|
||||||
writeFeverJSON(c, map[string]interface{}{
|
writeFeverJSON(c, map[string]any{
|
||||||
"saved_item_ids": joinInts(itemIds),
|
"saved_item_ids": joinInts(itemIds),
|
||||||
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
}, getLastRefreshedOnTime(s.db.ListHTTPStates()))
|
||||||
}
|
}
|
||||||
@@ -375,7 +375,10 @@ func (s *Server) feverMarkHandler(c *router.Context) {
|
|||||||
if c.Req.Form.Get("as") != "read" {
|
if c.Req.Form.Get("as") != "read" {
|
||||||
c.Out.WriteHeader(http.StatusBadRequest)
|
c.Out.WriteHeader(http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
markFilter := storage.MarkFilter{FolderID: &id}
|
markFilter := storage.MarkFilter{}
|
||||||
|
if id > 0 {
|
||||||
|
markFilter.FolderID = &id
|
||||||
|
}
|
||||||
x, _ := strconv.ParseInt(c.Req.Form.Get("before"), 10, 64)
|
x, _ := strconv.ParseInt(c.Req.Form.Get("before"), 10, 64)
|
||||||
if x > 0 {
|
if x > 0 {
|
||||||
before := time.Unix(x, 0).UTC()
|
before := time.Unix(x, 0).UTC()
|
||||||
@@ -386,7 +389,7 @@ func (s *Server) feverMarkHandler(c *router.Context) {
|
|||||||
c.Out.WriteHeader(http.StatusBadRequest)
|
c.Out.WriteHeader(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, map[string]interface{}{
|
c.JSON(http.StatusOK, map[string]any{
|
||||||
"api_version": 3,
|
"api_version": 3,
|
||||||
"auth": 1,
|
"auth": 1,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func (c *Context) Next() {
|
|||||||
c.chain[c.index](c)
|
c.chain[c.index](c)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Context) JSON(status int, data interface{}) {
|
func (c *Context) JSON(status int, data any) {
|
||||||
body, err := json.Marshal(data)
|
body, err := json.Marshal(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
@@ -35,7 +35,7 @@ func (c *Context) JSON(status int, data interface{}) {
|
|||||||
c.Out.Write([]byte("\n"))
|
c.Out.Write([]byte("\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Context) HTML(status int, tmpl *template.Template, data interface{}) {
|
func (c *Context) HTML(status int, tmpl *template.Template, data any) {
|
||||||
c.Out.Header().Set("Content-Type", "text/html")
|
c.Out.Header().Set("Content-Type", "text/html")
|
||||||
c.Out.WriteHeader(status)
|
c.Out.WriteHeader(status)
|
||||||
tmpl.Execute(c.Out, data)
|
tmpl.Execute(c.Out, data)
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ func (s *Server) handler() http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleIndex(c *router.Context) {
|
func (s *Server) handleIndex(c *router.Context) {
|
||||||
c.HTML(http.StatusOK, assets.Template("index.html"), map[string]interface{}{
|
c.HTML(http.StatusOK, assets.Template("index.html"), map[string]any{
|
||||||
"settings": s.db.GetSettings(),
|
"settings": s.db.GetSettings(),
|
||||||
"authenticated": s.Username != "" && s.Password != "",
|
"authenticated": s.Username != "" && s.Password != "",
|
||||||
})
|
})
|
||||||
@@ -82,14 +82,14 @@ func (s *Server) handleStatic(c *router.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleManifest(c *router.Context) {
|
func (s *Server) handleManifest(c *router.Context) {
|
||||||
c.JSON(http.StatusOK, map[string]interface{}{
|
c.JSON(http.StatusOK, map[string]any{
|
||||||
"$schema": "https://json.schemastore.org/web-manifest-combined.json",
|
"$schema": "https://json.schemastore.org/web-manifest-combined.json",
|
||||||
"name": "yarr!",
|
"name": "yarr!",
|
||||||
"short_name": "yarr",
|
"short_name": "yarr",
|
||||||
"description": "yet another rss reader",
|
"description": "yet another rss reader",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"start_url": "/" + strings.TrimPrefix(s.BasePath, "/"),
|
"start_url": "/" + strings.TrimPrefix(s.BasePath, "/"),
|
||||||
"icons": []map[string]interface{}{
|
"icons": []map[string]any{
|
||||||
{
|
{
|
||||||
"src": s.BasePath + "/static/graphicarts/favicon.png",
|
"src": s.BasePath + "/static/graphicarts/favicon.png",
|
||||||
"sizes": "64x64",
|
"sizes": "64x64",
|
||||||
@@ -100,7 +100,7 @@ func (s *Server) handleManifest(c *router.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleStatus(c *router.Context) {
|
func (s *Server) handleStatus(c *router.Context) {
|
||||||
c.JSON(http.StatusOK, map[string]interface{}{
|
c.JSON(http.StatusOK, map[string]any{
|
||||||
"running": s.worker.FeedsPending(),
|
"running": s.worker.FeedsPending(),
|
||||||
"stats": s.db.FeedStats(),
|
"stats": s.db.FeedStats(),
|
||||||
})
|
})
|
||||||
@@ -239,7 +239,7 @@ func (s *Server) handleFeedList(c *router.Context) {
|
|||||||
case len(result.Sources) > 0:
|
case len(result.Sources) > 0:
|
||||||
c.JSON(
|
c.JSON(
|
||||||
http.StatusOK,
|
http.StatusOK,
|
||||||
map[string]interface{}{"status": "multiple", "choice": result.Sources},
|
map[string]any{"status": "multiple", "choice": result.Sources},
|
||||||
)
|
)
|
||||||
case result.Feed != nil:
|
case result.Feed != nil:
|
||||||
feed := s.db.CreateFeed(
|
feed := s.db.CreateFeed(
|
||||||
@@ -252,12 +252,11 @@ func (s *Server) handleFeedList(c *router.Context) {
|
|||||||
items := worker.ConvertItems(result.Feed.Items, *feed)
|
items := worker.ConvertItems(result.Feed.Items, *feed)
|
||||||
if len(items) > 0 {
|
if len(items) > 0 {
|
||||||
s.db.CreateItems(items)
|
s.db.CreateItems(items)
|
||||||
s.db.SetFeedSize(feed.Id, len(items))
|
|
||||||
s.db.SyncSearch()
|
s.db.SyncSearch()
|
||||||
}
|
}
|
||||||
s.worker.FindFeedFavicon(*feed)
|
s.worker.FindFeedFavicon(*feed)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, map[string]interface{}{
|
c.JSON(http.StatusOK, map[string]any{
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"feed": feed,
|
"feed": feed,
|
||||||
})
|
})
|
||||||
@@ -279,7 +278,7 @@ func (s *Server) handleFeed(c *router.Context) {
|
|||||||
c.Out.WriteHeader(http.StatusBadRequest)
|
c.Out.WriteHeader(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
body := make(map[string]interface{})
|
body := make(map[string]any)
|
||||||
if err := json.NewDecoder(c.Req.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(c.Req.Body).Decode(&body); err != nil {
|
||||||
log.Print(err)
|
log.Print(err)
|
||||||
c.Out.WriteHeader(http.StatusBadRequest)
|
c.Out.WriteHeader(http.StatusBadRequest)
|
||||||
@@ -391,7 +390,7 @@ func (s *Server) handleItemList(c *router.Context) {
|
|||||||
items[i].Title = htmlutil.TruncateText(text, 140)
|
items[i].Title = htmlutil.TruncateText(text, 140)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, map[string]interface{}{
|
c.JSON(http.StatusOK, map[string]any{
|
||||||
"list": items,
|
"list": items,
|
||||||
"has_more": hasMore,
|
"has_more": hasMore,
|
||||||
})
|
})
|
||||||
@@ -415,7 +414,7 @@ func (s *Server) handleSettings(c *router.Context) {
|
|||||||
if c.Req.Method == "GET" {
|
if c.Req.Method == "GET" {
|
||||||
c.JSON(http.StatusOK, s.db.GetSettings())
|
c.JSON(http.StatusOK, s.db.GetSettings())
|
||||||
} else if c.Req.Method == "PUT" {
|
} else if c.Req.Method == "PUT" {
|
||||||
settings := make(map[string]interface{})
|
settings := make(map[string]any)
|
||||||
if err := json.NewDecoder(c.Req.Body).Decode(&settings); err != nil {
|
if err := json.NewDecoder(c.Req.Body).Decode(&settings); err != nil {
|
||||||
c.Out.WriteHeader(http.StatusBadRequest)
|
c.Out.WriteHeader(http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -472,7 +471,6 @@ func (s *Server) handleOPMLExport(c *router.Context) {
|
|||||||
|
|
||||||
feedsByFolderID := make(map[int64][]*storage.Feed)
|
feedsByFolderID := make(map[int64][]*storage.Feed)
|
||||||
for _, feed := range s.db.ListFeeds() {
|
for _, feed := range s.db.ListFeeds() {
|
||||||
feed := feed
|
|
||||||
if feed.FolderId == nil {
|
if feed.FolderId == nil {
|
||||||
doc.Feeds = append(doc.Feeds, opml.Feed{
|
doc.Feeds = append(doc.Feeds, opml.Feed{
|
||||||
Title: feed.Title,
|
Title: feed.Title,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ type Server struct {
|
|||||||
Addr string
|
Addr string
|
||||||
db *storage.Storage
|
db *storage.Storage
|
||||||
worker *worker.Worker
|
worker *worker.Worker
|
||||||
cache map[string]interface{}
|
cache map[string]any
|
||||||
cache_mutex *sync.Mutex
|
cache_mutex *sync.Mutex
|
||||||
|
|
||||||
BasePath string
|
BasePath string
|
||||||
@@ -34,7 +34,7 @@ func NewServer(db *storage.Storage, addr string) *Server {
|
|||||||
db: db,
|
db: db,
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
worker: worker.NewWorker(db),
|
worker: worker.NewWorker(db),
|
||||||
cache: make(map[string]interface{}),
|
cache: make(map[string]any),
|
||||||
cache_mutex: &sync.Mutex{},
|
cache_mutex: &sync.Mutex{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,16 +216,3 @@ func (s *Storage) GetFeedErrors() map[int64]string {
|
|||||||
}
|
}
|
||||||
return errors
|
return errors
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) SetFeedSize(feedId int64, size int) {
|
|
||||||
_, err := s.db.Exec(`
|
|
||||||
insert into feed_sizes (feed_id, size)
|
|
||||||
values (:feed_id, :size)
|
|
||||||
on conflict (feed_id) do update set size = excluded.size`,
|
|
||||||
sql.Named("feed_id", feedId),
|
|
||||||
sql.Named("size", size),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
log.Print(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func TestCreateFeedSameLink(t *testing.T) {
|
|||||||
t.Fatal("expected feed")
|
t.Fatal("expected feed")
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < 10; i++ {
|
for range 10 {
|
||||||
db.CreateFeed("title", "", "", "http://example2.com/feed.xml", nil)
|
db.CreateFeed("title", "", "", "http://example2.com/feed.xml", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -135,14 +135,15 @@ func (s *Storage) CreateItems(items []Item) bool {
|
|||||||
insert into items (
|
insert into items (
|
||||||
guid, feed_id, title, link, date,
|
guid, feed_id, title, link, date,
|
||||||
content, media_links,
|
content, media_links,
|
||||||
date_arrived, status
|
date_arrived, last_arrived, status
|
||||||
)
|
)
|
||||||
values (
|
values (
|
||||||
:guid, :feed_id, :title, :link, strftime('%Y-%m-%d %H:%M:%f', :date),
|
:guid, :feed_id, :title, :link, strftime('%Y-%m-%d %H:%M:%f', :date),
|
||||||
:content, :media_links,
|
:content, :media_links,
|
||||||
:date_arrived, :status
|
:date_arrived, :last_arrived, :status
|
||||||
)
|
)
|
||||||
on conflict (feed_id, guid) do nothing`,
|
on conflict (feed_id, guid) do update set
|
||||||
|
last_arrived = :last_arrived`,
|
||||||
sql.Named("guid", item.GUID),
|
sql.Named("guid", item.GUID),
|
||||||
sql.Named("feed_id", item.FeedId),
|
sql.Named("feed_id", item.FeedId),
|
||||||
sql.Named("title", item.Title),
|
sql.Named("title", item.Title),
|
||||||
@@ -151,6 +152,7 @@ func (s *Storage) CreateItems(items []Item) bool {
|
|||||||
sql.Named("content", item.Content),
|
sql.Named("content", item.Content),
|
||||||
sql.Named("media_links", item.MediaLinks),
|
sql.Named("media_links", item.MediaLinks),
|
||||||
sql.Named("date_arrived", now),
|
sql.Named("date_arrived", now),
|
||||||
|
sql.Named("last_arrived", now),
|
||||||
sql.Named("status", UNREAD),
|
sql.Named("status", UNREAD),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -169,9 +171,9 @@ func (s *Storage) CreateItems(items []Item) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func listQueryPredicate(filter ItemFilter, newestFirst bool) (string, []interface{}) {
|
func listQueryPredicate(filter ItemFilter, newestFirst bool) (string, []any) {
|
||||||
cond := make([]string, 0)
|
cond := make([]string, 0)
|
||||||
args := make([]interface{}, 0)
|
args := make([]any, 0)
|
||||||
if filter.FolderID != nil {
|
if filter.FolderID != nil {
|
||||||
cond = append(cond, "i.feed_id in (select id from feeds where folder_id = :folder_id)")
|
cond = append(cond, "i.feed_id in (select id from feeds where folder_id = :folder_id)")
|
||||||
args = append(args, sql.Named("folder_id", *filter.FolderID))
|
args = append(args, sql.Named("folder_id", *filter.FolderID))
|
||||||
@@ -247,7 +249,7 @@ func (s *Storage) CountItems(filter ItemFilter) int {
|
|||||||
var count int
|
var count int
|
||||||
query := fmt.Sprintf(`
|
query := fmt.Sprintf(`
|
||||||
select count(*)
|
select count(*)
|
||||||
from items
|
from items i
|
||||||
where %s
|
where %s
|
||||||
`, predicate)
|
`, predicate)
|
||||||
err := s.db.QueryRow(query, args...).Scan(&count)
|
err := s.db.QueryRow(query, args...).Scan(&count)
|
||||||
@@ -433,67 +435,35 @@ var (
|
|||||||
//
|
//
|
||||||
// The rules:
|
// The rules:
|
||||||
// - Never delete starred entries.
|
// - Never delete starred entries.
|
||||||
// - Keep at least the same amount of articles the feed provides (default: 50).
|
// - Keep at least 50 latest items for each feed.
|
||||||
// This prevents from deleting items for rarely updated and/or ever-growing
|
// - Delete entries older than 90 days relative to the latest arrived item in the same feed.
|
||||||
// feeds which might eventually reappear as unread.
|
|
||||||
// - Keep entries for a certain period (default: 90 days).
|
|
||||||
func (s *Storage) DeleteOldItems() {
|
func (s *Storage) DeleteOldItems() {
|
||||||
rows, err := s.db.Query(`
|
result, err := s.db.Exec(`
|
||||||
select
|
|
||||||
i.feed_id,
|
|
||||||
max(coalesce(s.size, 0), :keep_size) as max_items,
|
|
||||||
count(*) as num_items
|
|
||||||
from items i
|
|
||||||
left outer join feed_sizes s on s.feed_id = i.feed_id
|
|
||||||
where status != :starred_status
|
|
||||||
group by i.feed_id
|
|
||||||
`,
|
|
||||||
sql.Named("keep_size", itemsKeepSize),
|
|
||||||
sql.Named("starred_status", STARRED),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
log.Print(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
feedLimits := make(map[int64]int64, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
var feedId, limit int64
|
|
||||||
rows.Scan(&feedId, &limit, nil)
|
|
||||||
feedLimits[feedId] = limit
|
|
||||||
}
|
|
||||||
|
|
||||||
for feedId, limit := range feedLimits {
|
|
||||||
result, err := s.db.Exec(
|
|
||||||
`
|
|
||||||
delete from items
|
delete from items
|
||||||
where id in (
|
where id in (
|
||||||
select i.id
|
select id
|
||||||
from items i
|
from (
|
||||||
where i.feed_id = :feed_id and status != :starred_status
|
select
|
||||||
order by date desc
|
id,
|
||||||
limit -1 offset :limit
|
row_number() over (partition by feed_id order by date desc) as rn,
|
||||||
) and date_arrived < :date_limit
|
last_arrived,
|
||||||
`,
|
max(last_arrived) over (partition by feed_id) as max_la
|
||||||
sql.Named("feed_id", feedId),
|
from items
|
||||||
|
where status != :starred_status
|
||||||
|
)
|
||||||
|
where rn > :keep_size
|
||||||
|
and last_arrived < datetime(max_la, :keep_days_limit)
|
||||||
|
)`,
|
||||||
sql.Named("starred_status", STARRED),
|
sql.Named("starred_status", STARRED),
|
||||||
sql.Named("limit", limit),
|
sql.Named("keep_size", itemsKeepSize),
|
||||||
sql.Named(
|
sql.Named("keep_days_limit", fmt.Sprintf("-%d days", itemsKeepDays)),
|
||||||
"date_limit",
|
|
||||||
time.Now().UTC().Add(-time.Hour*time.Duration(24*itemsKeepDays)),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Print(err)
|
log.Print(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
numDeleted, err := result.RowsAffected()
|
numDeleted, err := result.RowsAffected()
|
||||||
if err != nil {
|
if err == nil && numDeleted > 0 {
|
||||||
log.Print(err)
|
log.Printf("Deleted %d old items", numDeleted)
|
||||||
return
|
|
||||||
}
|
|
||||||
if numDeleted > 0 {
|
|
||||||
log.Printf("Deleted %d old items (feed: %d)", numDeleted, feedId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -320,57 +321,111 @@ func TestMarkItemsRead(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDeleteOldItems(t *testing.T) {
|
func TestDeleteOldItems(t *testing.T) {
|
||||||
extraItems := 10
|
|
||||||
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
db := testDB()
|
starred := STARRED
|
||||||
feed := db.CreateFeed("feed", "", "", "http://test.com/feed11.xml", nil)
|
|
||||||
|
|
||||||
items := make([]Item, 0)
|
t.Run("keeps at least 50 items", func(t *testing.T) {
|
||||||
for i := 0; i < itemsKeepSize+extraItems; i++ {
|
db := testDB()
|
||||||
istr := strconv.Itoa(i)
|
feed := db.CreateFeed("f", "", "", "http://f.xml", nil)
|
||||||
items = append(items, Item{
|
items := make([]Item, 100)
|
||||||
GUID: istr,
|
for i := range 100 {
|
||||||
FeedId: feed.Id,
|
items[i] = Item{GUID: strconv.Itoa(i), FeedId: feed.Id, Date: now.Add(time.Duration(i) * time.Hour * 24)}
|
||||||
Title: istr,
|
|
||||||
Date: now.Add(time.Hour * time.Duration(i)),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
db.CreateItems(items)
|
db.CreateItems(items)
|
||||||
|
|
||||||
db.SetFeedSize(feed.Id, itemsKeepSize)
|
// // Set 1 recent (latest), 100 old (100 days ago)
|
||||||
var feedSize int
|
db.db.Exec(`update items set last_arrived = :la where guid = "99"`, sql.Named("la", now))
|
||||||
err := db.db.QueryRow(
|
db.db.Exec(`update items set last_arrived = :la where guid != "99"`, sql.Named("la", now.Add(-time.Hour*24*100)))
|
||||||
`select size from feed_sizes where feed_id = :feed_id`, sql.Named("feed_id", feed.Id),
|
|
||||||
).Scan(&feedSize)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if feedSize != itemsKeepSize {
|
|
||||||
t.Fatalf(
|
|
||||||
"expected feed size to get updated\nwant: %d\nhave: %d",
|
|
||||||
itemsKeepSize+extraItems,
|
|
||||||
feedSize,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// expire only the first 3 articles
|
|
||||||
_, err = db.db.Exec(
|
|
||||||
`update items set date_arrived = :date_arrived
|
|
||||||
where id in (select id from items limit 3)`,
|
|
||||||
sql.Named("date_arrived", now.Add(-time.Hour*time.Duration(itemsKeepDays*24))),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
db.DeleteOldItems()
|
db.DeleteOldItems()
|
||||||
feedItems := db.ListItems(ItemFilter{FeedID: &feed.Id}, 1000, false, false)
|
have := db.CountItems(ItemFilter{FeedID: &feed.Id})
|
||||||
if len(feedItems) != len(items)-3 {
|
if have != 50 {
|
||||||
t.Fatalf(
|
t.Errorf("expected 50 items, have %d", have)
|
||||||
"invalid number of old items kept\nwant: %d\nhave: %d",
|
|
||||||
len(items)-3,
|
|
||||||
len(feedItems),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("keeps all less than 90 days old", func(t *testing.T) {
|
||||||
|
db := testDB()
|
||||||
|
feed := db.CreateFeed("f", "", "", "http://f.xml", nil)
|
||||||
|
items := make([]Item, 100)
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
items[i] = Item{GUID: strconv.Itoa(i), FeedId: feed.Id, Date: now.Add(time.Duration(i) * time.Second)}
|
||||||
|
}
|
||||||
|
db.CreateItems(items)
|
||||||
|
|
||||||
|
// Latest item at "now"
|
||||||
|
// All others at 80 days ago (keep)
|
||||||
|
db.db.Exec(`update items set last_arrived = :la where guid = "99"`, sql.Named("la", now))
|
||||||
|
db.db.Exec(`update items set last_arrived = :la where guid != "99"`, sql.Named("la", now.Add(-time.Hour*24*80)))
|
||||||
|
|
||||||
|
db.DeleteOldItems()
|
||||||
|
have := db.CountItems(ItemFilter{FeedID: &feed.Id})
|
||||||
|
if have != 100 {
|
||||||
|
t.Errorf("expected 100 items, have %d", have)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("keeps starred", func(t *testing.T) {
|
||||||
|
db := testDB()
|
||||||
|
feed := db.CreateFeed("f", "", "", "http://f.xml", nil)
|
||||||
|
items := make([]Item, 100)
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
items[i] = Item{GUID: strconv.Itoa(i), FeedId: feed.Id, Date: now.Add(time.Duration(i) * time.Second)}
|
||||||
|
}
|
||||||
|
db.CreateItems(items)
|
||||||
|
|
||||||
|
// Set all to 100 days ago, except one recent
|
||||||
|
db.db.Exec(`update items set last_arrived = :la`, sql.Named("la", now.Add(-time.Hour*24*100)))
|
||||||
|
db.db.Exec(`update items set last_arrived = :la where guid = "99"`, sql.Named("la", now))
|
||||||
|
// Star 10 old items that would otherwise be deleted (rn > 50 and old)
|
||||||
|
db.db.Exec(`update items set status = :s where cast(guid as integer) < 10`, sql.Named("s", starred))
|
||||||
|
|
||||||
|
db.DeleteOldItems()
|
||||||
|
have := db.CountItems(ItemFilter{FeedID: &feed.Id})
|
||||||
|
// 50 (limit) + 10 (starred) = 60 items should remain.
|
||||||
|
if have != 60 {
|
||||||
|
t.Errorf("expected 60 items, have %d", have)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
func TestCreateItemsLastArrived(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
db := testDB()
|
||||||
|
defer db.db.Close()
|
||||||
|
feed := db.CreateFeed("test feed", "", "", "http://example.com/feed", nil)
|
||||||
|
|
||||||
|
item := Item{
|
||||||
|
GUID: "item1",
|
||||||
|
FeedId: feed.Id,
|
||||||
|
Title: "Title 1",
|
||||||
|
Date: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Initial creation
|
||||||
|
db.CreateItems([]Item{item})
|
||||||
|
|
||||||
|
var lastArrived1 time.Time
|
||||||
|
err := db.db.QueryRow("select last_arrived from items where guid = ?", item.GUID).Scan(&lastArrived1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Second * 10)
|
||||||
|
|
||||||
|
// 2. Update on conflict
|
||||||
|
db.CreateItems([]Item{item})
|
||||||
|
|
||||||
|
var lastArrived2 time.Time
|
||||||
|
err = db.db.QueryRow("select last_arrived from items where guid = ?", item.GUID).Scan(&lastArrived2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !lastArrived2.After(lastArrived1) {
|
||||||
|
t.Errorf("expected last_arrived to be updated. old: %v, new: %v", lastArrived1, lastArrived2)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ var migrations = []func(*sql.Tx) error{
|
|||||||
m08_normalize_datetime,
|
m08_normalize_datetime,
|
||||||
m09_change_item_index,
|
m09_change_item_index,
|
||||||
m10_add_item_medialinks,
|
m10_add_item_medialinks,
|
||||||
|
m11_add_item_last_arrived,
|
||||||
|
m12_remove_feed_sizes,
|
||||||
}
|
}
|
||||||
|
|
||||||
var maxVersion = int64(len(migrations))
|
var maxVersion = int64(len(migrations))
|
||||||
@@ -332,3 +334,14 @@ func m10_add_item_medialinks(tx *sql.Tx) error {
|
|||||||
_, err := tx.Exec(sql)
|
_, err := tx.Exec(sql)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func m11_add_item_last_arrived(tx *sql.Tx) error {
|
||||||
|
sql := `alter table items add column last_arrived datetime`
|
||||||
|
_, err := tx.Exec(sql)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func m12_remove_feed_sizes(tx *sql.Tx) error {
|
||||||
|
_, err := tx.Exec(`drop table if exists feed_sizes`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
)
|
)
|
||||||
|
|
||||||
func settingsDefaults() map[string]interface{} {
|
func settingsDefaults() map[string]any {
|
||||||
return map[string]interface{}{
|
return map[string]any{
|
||||||
"filter": "",
|
"filter": "",
|
||||||
"feed": "",
|
"feed": "",
|
||||||
"feed_list_width": 300,
|
"feed_list_width": 300,
|
||||||
@@ -17,10 +17,11 @@ func settingsDefaults() map[string]interface{} {
|
|||||||
"theme_font": "",
|
"theme_font": "",
|
||||||
"theme_size": 1,
|
"theme_size": 1,
|
||||||
"refresh_rate": 0,
|
"refresh_rate": 0,
|
||||||
|
"language": "en",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) GetSettingsValue(key string) interface{} {
|
func (s *Storage) GetSettingsValue(key string) any {
|
||||||
row := s.db.QueryRow(`select val from settings where key=:key`, sql.Named("key", key))
|
row := s.db.QueryRow(`select val from settings where key=:key`, sql.Named("key", key))
|
||||||
if row == nil {
|
if row == nil {
|
||||||
return settingsDefaults()[key]
|
return settingsDefaults()[key]
|
||||||
@@ -30,7 +31,7 @@ func (s *Storage) GetSettingsValue(key string) interface{} {
|
|||||||
if len(val) == 0 {
|
if len(val) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var valDecoded interface{}
|
var valDecoded any
|
||||||
if err := json.Unmarshal([]byte(val), &valDecoded); err != nil {
|
if err := json.Unmarshal([]byte(val), &valDecoded); err != nil {
|
||||||
log.Print(err)
|
log.Print(err)
|
||||||
return nil
|
return nil
|
||||||
@@ -48,7 +49,7 @@ func (s *Storage) GetSettingsValueInt64(key string) int64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) GetSettings() map[string]interface{} {
|
func (s *Storage) GetSettings() map[string]any {
|
||||||
result := settingsDefaults()
|
result := settingsDefaults()
|
||||||
rows, err := s.db.Query(`select key, val from settings;`)
|
rows, err := s.db.Query(`select key, val from settings;`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -58,7 +59,7 @@ func (s *Storage) GetSettings() map[string]interface{} {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var key string
|
var key string
|
||||||
var val []byte
|
var val []byte
|
||||||
var valDecoded interface{}
|
var valDecoded any
|
||||||
|
|
||||||
rows.Scan(&key, &val)
|
rows.Scan(&key, &val)
|
||||||
if err = json.Unmarshal([]byte(val), &valDecoded); err != nil {
|
if err = json.Unmarshal([]byte(val), &valDecoded); err != nil {
|
||||||
@@ -70,7 +71,7 @@ func (s *Storage) GetSettings() map[string]interface{} {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) UpdateSettings(kv map[string]interface{}) bool {
|
func (s *Storage) UpdateSettings(kv map[string]any) bool {
|
||||||
defaults := settingsDefaults()
|
defaults := settingsDefaults()
|
||||||
for key, val := range kv {
|
for key, val := range kv {
|
||||||
if defaults[key] == nil {
|
if defaults[key] == nil {
|
||||||
|
|||||||
@@ -141,7 +141,6 @@ func findFavicon(siteUrl, feedUrl string) (*[]byte, error) {
|
|||||||
func ConvertItems(items []parser.Item, feed storage.Feed) []storage.Item {
|
func ConvertItems(items []parser.Item, feed storage.Feed) []storage.Item {
|
||||||
result := make([]storage.Item, len(items))
|
result := make([]storage.Item, len(items))
|
||||||
for i, item := range items {
|
for i, item := range items {
|
||||||
item := item
|
|
||||||
mediaLinks := make(storage.MediaLinks, 0)
|
mediaLinks := make(storage.MediaLinks, 0)
|
||||||
for _, link := range item.MediaLinks {
|
for _, link := range item.MediaLinks {
|
||||||
mediaLinks = append(mediaLinks, storage.MediaLink(link))
|
mediaLinks = append(mediaLinks, storage.MediaLink(link))
|
||||||
|
|||||||
@@ -113,18 +113,17 @@ func (w *Worker) refresher(feeds []storage.Feed) {
|
|||||||
srcqueue := make(chan storage.Feed, len(feeds))
|
srcqueue := make(chan storage.Feed, len(feeds))
|
||||||
dstqueue := make(chan []storage.Item)
|
dstqueue := make(chan []storage.Item)
|
||||||
|
|
||||||
for i := 0; i < NUM_WORKERS; i++ {
|
for range NUM_WORKERS {
|
||||||
go w.worker(srcqueue, dstqueue)
|
go w.worker(srcqueue, dstqueue)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, feed := range feeds {
|
for _, feed := range feeds {
|
||||||
srcqueue <- feed
|
srcqueue <- feed
|
||||||
}
|
}
|
||||||
for i := 0; i < len(feeds); i++ {
|
for range feeds {
|
||||||
items := <-dstqueue
|
items := <-dstqueue
|
||||||
if len(items) > 0 {
|
if len(items) > 0 {
|
||||||
w.db.CreateItems(items)
|
w.db.CreateItems(items)
|
||||||
w.db.SetFeedSize(items[0].FeedId, len(items))
|
|
||||||
}
|
}
|
||||||
atomic.AddInt32(w.pending, -1)
|
atomic.AddInt32(w.pending, -1)
|
||||||
w.db.SyncSearch()
|
w.db.SyncSearch()
|
||||||
|
|||||||
Reference in New Issue
Block a user