mirror of
https://github.com/nkanaev/yarr.git
synced 2026-06-25 17:45:15 +00:00
Compare commits
13 Commits
a18ed04193
...
v2.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05d57a2dcd | ||
|
|
6db9a4b556 | ||
|
|
c90c40aba1 | ||
|
|
41faa8c088 | ||
|
|
c447372fe2 | ||
|
|
2f39fcc6f6 | ||
|
|
21c7f9a4a4 | ||
|
|
14b06dcbaf | ||
|
|
3a75e61c7d | ||
|
|
8fb7702e6d | ||
|
|
6202451c7c | ||
|
|
9e46014787 | ||
|
|
2de9772e4b |
7
.github/workflows/build-docker.yml
vendored
7
.github/workflows/build-docker.yml
vendored
@@ -3,6 +3,8 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -37,6 +39,11 @@ jobs:
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=schedule
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=raw,value=bleeding,enable=${{ github.ref_name == 'master' }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
|
||||
24
.github/workflows/build.yml
vendored
24
.github/workflows/build.yml
vendored
@@ -9,14 +9,14 @@ on:
|
||||
jobs:
|
||||
build_macos:
|
||||
name: Build for MacOS
|
||||
runs-on: macos-13
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v4
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '^1.23'
|
||||
go-version-file: 'go.mod'
|
||||
- name: Build arm64 gui
|
||||
uses: ./.github/actions/prepare
|
||||
with:
|
||||
@@ -44,14 +44,14 @@ jobs:
|
||||
|
||||
build_windows:
|
||||
name: Build for Windows
|
||||
runs-on: windows-2022
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v4
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '^1.23'
|
||||
go-version-file: 'go.mod'
|
||||
- name: Build amd64 gui
|
||||
uses: ./.github/actions/prepare
|
||||
with:
|
||||
@@ -67,15 +67,15 @@ jobs:
|
||||
out: out/windows_arm64_gui/yarr.exe
|
||||
|
||||
build_multi_cli:
|
||||
name: Build for Windows/MacOS/Linux CLI
|
||||
name: Build for Windows/Linux (CLI)
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v4
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '^1.23'
|
||||
go-version-file: 'go.mod'
|
||||
- name: Setup Zig
|
||||
uses: mlugg/setup-zig@v1
|
||||
with:
|
||||
|
||||
9
.github/workflows/test.yml
vendored
9
.github/workflows/test.yml
vendored
@@ -6,14 +6,17 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
YARR_POSTGRES_TEST_IMAGE: postgres:17-alpine
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '^1.18'
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: Run tests
|
||||
run: make test
|
||||
|
||||
@@ -73,7 +73,7 @@ func main() {
|
||||
flag.Parse()
|
||||
|
||||
if ver {
|
||||
fmt.Printf("v%s (%s)\n", Version, GitHash)
|
||||
fmt.Printf("%s (%s)\n", Version, GitHash)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
# upcoming
|
||||
|
||||
- (new) initial PostgreSQL support
|
||||
- (new) i18n: English, Chinese, French, German, Japanese, Portuguese, Russian, Spanish
|
||||
- (fix) articles not resetting immediately after feed/filter selection (thank to @scratchmex for the report)
|
||||
- (fix) crash on empty article list with article is selected (thanks to @rksvc)
|
||||
- (fix) invalid article title in RSS feeds with media containing titles (thanks to @bwwu-git for the report)
|
||||
- (fix) missing image enclosures in certain RSS feeds (thanks to @palinek for the report)
|
||||
- (fix) parsing namespaced legacy RSS feeds (thanks to @f100024)
|
||||
- (fix) marking feeds read in Fever API (thanks to @weskoop)
|
||||
- (etc) systray improvements for macOS
|
||||
|
||||
# v2.6 (2025-11-24)
|
||||
|
||||
|
||||
@@ -51,8 +51,9 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
# Replace dots with commas for version_comma
|
||||
version_comma="${version//./,}"
|
||||
# Strip leading 'v' and replace dots with commas for version_comma
|
||||
version_num="${version#v}"
|
||||
version_comma="${version_num//./,}"
|
||||
|
||||
# Use a here document for the template with ENDFILE delimiter
|
||||
cat <<ENDFILE > "$outfile"
|
||||
|
||||
4
go.mod
4
go.mod
@@ -1,8 +1,6 @@
|
||||
module github.com/nkanaev/yarr
|
||||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.23.5
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
fyne.io/systray v1.12.0
|
||||
|
||||
2
makefile
2
makefile
@@ -1,4 +1,4 @@
|
||||
VERSION=2.6
|
||||
VERSION=$(shell git describe --exact-match --tags HEAD 2>/dev/null || echo bleeding)
|
||||
GITHASH=$(shell git rev-parse --short=8 HEAD)
|
||||
|
||||
GO_TAGS = sqlite_foreign_keys sqlite_json sqlite_fts5
|
||||
|
||||
@@ -126,17 +126,19 @@
|
||||
{{ $t('shortcuts') }}
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">ᚨ / 𐎠 / 𑖀</header>
|
||||
<div class="d-flex">
|
||||
<button
|
||||
v-for="lang in languages"
|
||||
class="dropdown-item text-center"
|
||||
:aria-label="lang.name"
|
||||
:title="lang.name"
|
||||
:class="{active: language==lang.code}"
|
||||
@click.stop="changeLanguage(lang.code)">
|
||||
{{ lang.code }}
|
||||
</button>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">A / あ / 文</header>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<button
|
||||
v-for="lang in languages"
|
||||
class="dropdown-item text-center col-3 px-0"
|
||||
:aria-label="lang.name"
|
||||
:title="lang.name"
|
||||
:class="{active: language==lang.code}"
|
||||
@click.stop="changeLanguage(lang.code)">
|
||||
{{ lang.code }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown-divider" v-if="authenticated"></div>
|
||||
<button class="dropdown-item" v-if="authenticated" @click="logout()">
|
||||
@@ -193,7 +195,7 @@
|
||||
</div>
|
||||
<div class="p-2 toolbar d-flex align-items-center border-top flex-shrink-0" v-if="loading.feeds">
|
||||
<span class="icon loading mx-2"></span>
|
||||
<span class="text-truncate cursor-default noselect">{{ $t('refreshing') }} ({{ loading.feeds }} {{ $t('left') }})</span>
|
||||
<span class="text-truncate cursor-default noselect">{{ $t('refreshing_progress', {count: loading.feeds}) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- item list -->
|
||||
@@ -453,6 +455,7 @@
|
||||
</div>
|
||||
<!-- external -->
|
||||
<script src="./static/javascripts/vue.min.js"></script>
|
||||
<script src="./static/javascripts/fluent.js"></script>
|
||||
<!-- internal -->
|
||||
<script src="./static/javascripts/i18n.js"></script>
|
||||
<script src="./static/javascripts/api.js"></script>
|
||||
|
||||
@@ -276,8 +276,13 @@ var vm = new Vue({
|
||||
'language': s.language,
|
||||
'languages': [
|
||||
{code: 'en', name: 'English' },
|
||||
{code: 'zh', name: '简体中文'},
|
||||
{code: 'de', name: 'Deutsch'},
|
||||
{code: 'es', name: 'Español'},
|
||||
{code: 'fr', name: 'Français'},
|
||||
{code: 'ja', name: '日本語'},
|
||||
{code: 'pt', name: 'Português'},
|
||||
{code: 'ru', name: 'Русский'},
|
||||
{code: 'zh', name: '简体中文'},
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -555,7 +560,7 @@ var vm = new Vue({
|
||||
})
|
||||
},
|
||||
moveFeedToNewFolder: function(feed) {
|
||||
var title = prompt('Enter folder name:')
|
||||
var title = prompt(this.$t('prompt_folder_name'))
|
||||
if (!title) return
|
||||
api.folders.create({'title': title}).then(function(folder) {
|
||||
api.feeds.update(feed.id, {folder_id: folder.id}).then(function() {
|
||||
@@ -566,7 +571,7 @@ var vm = new Vue({
|
||||
})
|
||||
},
|
||||
createNewFeedFolder: function() {
|
||||
var title = prompt('Enter folder name:')
|
||||
var title = prompt(this.$t('prompt_folder_name'))
|
||||
if (!title) return
|
||||
api.folders.create({'title': title}).then(function(result) {
|
||||
vm.refreshFeeds().then(function() {
|
||||
@@ -579,7 +584,7 @@ var vm = new Vue({
|
||||
})
|
||||
},
|
||||
renameFolder: function(folder) {
|
||||
var newTitle = prompt('Enter new title', folder.title)
|
||||
var newTitle = prompt(this.$t('prompt_new_title'), folder.title)
|
||||
if (newTitle) {
|
||||
api.folders.update(folder.id, {title: newTitle}).then(function() {
|
||||
folder.title = newTitle
|
||||
@@ -590,7 +595,7 @@ var vm = new Vue({
|
||||
}
|
||||
},
|
||||
deleteFolder: function(folder) {
|
||||
if (confirm('Are you sure you want to delete ' + folder.title + '?')) {
|
||||
if (confirm(this.$t('confirm_delete', {name: folder.title}))) {
|
||||
api.folders.delete(folder.id).then(function() {
|
||||
vm.feedSelected = null
|
||||
vm.refreshStats()
|
||||
@@ -599,7 +604,7 @@ var vm = new Vue({
|
||||
}
|
||||
},
|
||||
updateFeedLink: function(feed) {
|
||||
var newLink = prompt('Enter feed link', feed.feed_link)
|
||||
var newLink = prompt(this.$t('prompt_feed_link'), feed.feed_link)
|
||||
if (newLink) {
|
||||
api.feeds.update(feed.id, {feed_link: newLink}).then(function() {
|
||||
feed.feed_link = newLink
|
||||
@@ -607,7 +612,7 @@ var vm = new Vue({
|
||||
}
|
||||
},
|
||||
renameFeed: function(feed) {
|
||||
var newTitle = prompt('Enter new title', feed.title)
|
||||
var newTitle = prompt(this.$t('prompt_new_title'), feed.title)
|
||||
if (newTitle) {
|
||||
api.feeds.update(feed.id, {title: newTitle}).then(function() {
|
||||
feed.title = newTitle
|
||||
@@ -615,7 +620,7 @@ var vm = new Vue({
|
||||
}
|
||||
},
|
||||
deleteFeed: function(feed) {
|
||||
if (confirm('Are you sure you want to delete ' + feed.title + '?')) {
|
||||
if (confirm(this.$t('confirm_delete', {name: feed.title}))) {
|
||||
api.feeds.delete(feed.id).then(function() {
|
||||
vm.feedSelected = null
|
||||
vm.refreshStats()
|
||||
|
||||
1238
src/assets/javascripts/fluent.js
Normal file
1238
src/assets/javascripts/fluent.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,371 +2,715 @@
|
||||
const translations = {
|
||||
"unread": {
|
||||
"en": "Unread",
|
||||
"de": "Ungelesene",
|
||||
"fr": "Non lus",
|
||||
"es": "No leídos",
|
||||
"ja": "未読",
|
||||
"pt": "Não lidos",
|
||||
"zh": "未读",
|
||||
"ru": "Непрочитанные"
|
||||
},
|
||||
"starred": {
|
||||
"en": "Starred",
|
||||
"de": "Markierte",
|
||||
"fr": "Favoris",
|
||||
"es": "Destacados",
|
||||
"ja": "スター付き",
|
||||
"pt": "Favoritos",
|
||||
"zh": "星标",
|
||||
"ru": "Избранные"
|
||||
},
|
||||
"all": {
|
||||
"en": "All",
|
||||
"de": "Alle",
|
||||
"fr": "Tout",
|
||||
"es": "Todo",
|
||||
"ja": "すべて",
|
||||
"pt": "Tudo",
|
||||
"zh": "全部",
|
||||
"ru": "Все"
|
||||
},
|
||||
"settings": {
|
||||
"en": "Settings",
|
||||
"de": "Einstellungen",
|
||||
"fr": "Paramètres",
|
||||
"es": "Ajustes",
|
||||
"ja": "設定",
|
||||
"pt": "Configurações",
|
||||
"zh": "设置",
|
||||
"ru": "Настройки"
|
||||
},
|
||||
"new_feed": {
|
||||
"en": "New Feed",
|
||||
"de": "Neuer Feed",
|
||||
"fr": "Nouveau flux",
|
||||
"es": "Nueva fuente",
|
||||
"ja": "新規フィード",
|
||||
"pt": "Novo feed",
|
||||
"zh": "新建订阅",
|
||||
"ru": "Новая лента"
|
||||
},
|
||||
"refresh_feeds": {
|
||||
"en": "Refresh Feeds",
|
||||
"de": "Feeds aktualisieren",
|
||||
"fr": "Actualiser les flux",
|
||||
"es": "Actualizar fuentes",
|
||||
"ja": "フィードを更新",
|
||||
"pt": "Atualizar feeds",
|
||||
"zh": "刷新订阅",
|
||||
"ru": "Обновить ленты"
|
||||
},
|
||||
"theme": {
|
||||
"en": "Theme",
|
||||
"de": "Design",
|
||||
"fr": "Thème",
|
||||
"es": "Tema",
|
||||
"ja": "テーマ",
|
||||
"pt": "Tema",
|
||||
"zh": "主题",
|
||||
"ru": "Тема"
|
||||
},
|
||||
"auto_refresh": {
|
||||
"en": "Auto Refresh",
|
||||
"de": "Automatisch aktualisieren",
|
||||
"fr": "Actualisation automatique",
|
||||
"es": "Actualización automática",
|
||||
"ja": "自動更新",
|
||||
"pt": "Atualização automática",
|
||||
"zh": "自动刷新",
|
||||
"ru": "Автообновление"
|
||||
},
|
||||
"show_first": {
|
||||
"en": "Show first",
|
||||
"de": "Zuerst anzeigen",
|
||||
"fr": "Afficher d'abord",
|
||||
"es": "Mostrar primero",
|
||||
"ja": "表示順",
|
||||
"pt": "Mostrar primeiro",
|
||||
"zh": "优先显示",
|
||||
"ru": "Сначала"
|
||||
},
|
||||
"new": {
|
||||
"en": "New",
|
||||
"de": "Neue",
|
||||
"fr": "Récents",
|
||||
"es": "Nuevos",
|
||||
"ja": "新しい順",
|
||||
"pt": "Novos",
|
||||
"zh": "最新",
|
||||
"ru": "Новые"
|
||||
},
|
||||
"old": {
|
||||
"en": "Old",
|
||||
"de": "Alte",
|
||||
"fr": "Anciens",
|
||||
"es": "Antiguos",
|
||||
"ja": "古い順",
|
||||
"pt": "Antigos",
|
||||
"zh": "最旧",
|
||||
"ru": "Старые"
|
||||
},
|
||||
"subscriptions": {
|
||||
"en": "Subscriptions",
|
||||
"de": "Abonnements",
|
||||
"fr": "Abonnements",
|
||||
"es": "Suscripciones",
|
||||
"ja": "購読管理",
|
||||
"pt": "Assinaturas",
|
||||
"zh": "订阅管理",
|
||||
"ru": "Подписки"
|
||||
},
|
||||
"import": {
|
||||
"en": "Import",
|
||||
"de": "Importieren",
|
||||
"fr": "Importer",
|
||||
"es": "Importar",
|
||||
"ja": "インポート",
|
||||
"pt": "Importar",
|
||||
"zh": "导入",
|
||||
"ru": "Импорт"
|
||||
},
|
||||
"export": {
|
||||
"en": "Export",
|
||||
"de": "Exportieren",
|
||||
"fr": "Exporter",
|
||||
"es": "Exportar",
|
||||
"ja": "エクスポート",
|
||||
"pt": "Exportar",
|
||||
"zh": "导出",
|
||||
"ru": "Экспорт"
|
||||
},
|
||||
"shortcuts": {
|
||||
"en": "Shortcuts",
|
||||
"de": "Tastenkürzel",
|
||||
"fr": "Raccourcis",
|
||||
"es": "Atajos",
|
||||
"ja": "ショートカット",
|
||||
"pt": "Atalhos",
|
||||
"zh": "快捷键",
|
||||
"ru": "Горячие клавиши"
|
||||
},
|
||||
"log_out": {
|
||||
"en": "Log out",
|
||||
"de": "Abmelden",
|
||||
"fr": "Déconnexion",
|
||||
"es": "Cerrar sesión",
|
||||
"ja": "ログアウト",
|
||||
"pt": "Sair",
|
||||
"zh": "登出",
|
||||
"ru": "Выйти"
|
||||
},
|
||||
"all_unread": {
|
||||
"en": "All Unread",
|
||||
"de": "Alle ungelesenen",
|
||||
"fr": "Tous les non lus",
|
||||
"es": "Todos los no leídos",
|
||||
"ja": "すべての未読",
|
||||
"pt": "Todos os não lidos",
|
||||
"zh": "全部未读",
|
||||
"ru": "Все непрочитанные"
|
||||
},
|
||||
"all_starred": {
|
||||
"en": "All Starred",
|
||||
"de": "Alle markierten",
|
||||
"fr": "Tous les favoris",
|
||||
"es": "Todos los destacados",
|
||||
"ja": "すべてのスター付き",
|
||||
"pt": "Todos os favoritos",
|
||||
"zh": "全部星标",
|
||||
"ru": "Все избранные"
|
||||
},
|
||||
"all_feeds": {
|
||||
"en": "All Feeds",
|
||||
"de": "Alle Feeds",
|
||||
"fr": "Tous les flux",
|
||||
"es": "Todas las fuentes",
|
||||
"ja": "すべてのフィード",
|
||||
"pt": "Todos os feeds",
|
||||
"zh": "全部订阅",
|
||||
"ru": "Все ленты"
|
||||
},
|
||||
"refreshing": {
|
||||
"en": "Refreshing",
|
||||
"zh": "正在刷新",
|
||||
"ru": "Обновление"
|
||||
},
|
||||
"left": {
|
||||
"en": "left",
|
||||
"zh": "剩余",
|
||||
"ru": "осталось"
|
||||
"refreshing_progress": {
|
||||
"en": "Refreshing ({ $count } left)",
|
||||
"de": "Aktualisiere ({ $count } übrig)",
|
||||
"fr": "Actualisation ({ $count } restantes)",
|
||||
"es": "Actualizando ({ $count } restantes)",
|
||||
"ja": "更新中(残り{ $count })",
|
||||
"pt": "Atualizando ({ $count } restantes)",
|
||||
"zh": "正在刷新(剩余{ $count })",
|
||||
"ru": "Обновление: осталось { $count }"
|
||||
},
|
||||
"show_feeds": {
|
||||
"en": "Show Feeds",
|
||||
"de": "Feeds anzeigen",
|
||||
"fr": "Afficher les flux",
|
||||
"es": "Mostrar fuentes",
|
||||
"ja": "フィードを表示",
|
||||
"pt": "Mostrar feeds",
|
||||
"zh": "显示订阅",
|
||||
"ru": "Показать ленты"
|
||||
},
|
||||
"mark_all_read": {
|
||||
"en": "Mark All Read",
|
||||
"de": "Alle als gelesen markieren",
|
||||
"fr": "Tout marquer comme lu",
|
||||
"es": "Marcar todo como leído",
|
||||
"ja": "すべて既読にする",
|
||||
"pt": "Marcar todos como lidos",
|
||||
"zh": "全部标记为已读",
|
||||
"ru": "Отметить все как прочитанные"
|
||||
},
|
||||
"feed_settings": {
|
||||
"en": "Feed Settings",
|
||||
"de": "Feed-Einstellungen",
|
||||
"fr": "Paramètres du flux",
|
||||
"es": "Ajustes de fuente",
|
||||
"ja": "フィード設定",
|
||||
"pt": "Configurações do feed",
|
||||
"zh": "订阅设置",
|
||||
"ru": "Настройки ленты"
|
||||
},
|
||||
"folder_settings": {
|
||||
"en": "Folder Settings",
|
||||
"de": "Ordner-Einstellungen",
|
||||
"fr": "Paramètres du dossier",
|
||||
"es": "Ajustes de carpeta",
|
||||
"ja": "フォルダ設定",
|
||||
"pt": "Configurações da pasta",
|
||||
"zh": "文件夹设置",
|
||||
"ru": "Настройки папки"
|
||||
},
|
||||
"website": {
|
||||
"en": "Website",
|
||||
"de": "Webseite",
|
||||
"fr": "Site web",
|
||||
"es": "Sitio web",
|
||||
"ja": "ウェブサイト",
|
||||
"pt": "Site",
|
||||
"zh": "网站",
|
||||
"ru": "Сайт"
|
||||
},
|
||||
"feed_link": {
|
||||
"en": "Feed Link",
|
||||
"de": "Feed-Link",
|
||||
"fr": "Lien du flux",
|
||||
"es": "Enlace de la fuente",
|
||||
"ja": "フィードリンク",
|
||||
"pt": "Link do feed",
|
||||
"zh": "订阅链接",
|
||||
"ru": "Ссылка на ленту"
|
||||
},
|
||||
"rename": {
|
||||
"en": "Rename",
|
||||
"de": "Umbenennen",
|
||||
"fr": "Renommer",
|
||||
"es": "Renombrar",
|
||||
"ja": "名前変更",
|
||||
"pt": "Renomear",
|
||||
"zh": "重命名",
|
||||
"ru": "Переименовать"
|
||||
},
|
||||
"change_link": {
|
||||
"en": "Change Link",
|
||||
"de": "Link ändern",
|
||||
"fr": "Changer le lien",
|
||||
"es": "Cambiar enlace",
|
||||
"ja": "リンク変更",
|
||||
"pt": "Alterar link",
|
||||
"zh": "修改链接",
|
||||
"ru": "Изменить ссылку"
|
||||
},
|
||||
"move_to": {
|
||||
"en": "Move to...",
|
||||
"de": "Verschieben nach...",
|
||||
"fr": "Déplacer vers...",
|
||||
"es": "Mover a...",
|
||||
"ja": "移動...",
|
||||
"pt": "Mover para...",
|
||||
"zh": "移动到...",
|
||||
"ru": "Переместить в..."
|
||||
},
|
||||
"new_folder": {
|
||||
"en": "new folder",
|
||||
"de": "neuer Ordner",
|
||||
"fr": "nouveau dossier",
|
||||
"es": "nueva carpeta",
|
||||
"ja": "新規フォルダ",
|
||||
"pt": "nova pasta",
|
||||
"zh": "新建文件夹",
|
||||
"ru": "новая папка"
|
||||
},
|
||||
"delete": {
|
||||
"en": "Delete",
|
||||
"de": "Löschen",
|
||||
"fr": "Supprimer",
|
||||
"es": "Eliminar",
|
||||
"ja": "削除",
|
||||
"pt": "Excluir",
|
||||
"zh": "删除",
|
||||
"ru": "Удалить"
|
||||
},
|
||||
"mark_starred": {
|
||||
"en": "Mark Starred",
|
||||
"de": "Als markiert kennzeichnen",
|
||||
"fr": "Marquer comme favori",
|
||||
"es": "Marcar como destacado",
|
||||
"ja": "スターを付ける",
|
||||
"pt": "Marcar como favorito",
|
||||
"zh": "标记星标",
|
||||
"ru": "Пометить избранным"
|
||||
},
|
||||
"mark_unread": {
|
||||
"en": "Mark Unread",
|
||||
"de": "Als ungelesen kennzeichnen",
|
||||
"fr": "Marquer comme non lu",
|
||||
"es": "Marcar como no leído",
|
||||
"ja": "未読にする",
|
||||
"pt": "Marcar como não lido",
|
||||
"zh": "标记未读",
|
||||
"ru": "Пометить непрочитанным"
|
||||
},
|
||||
"appearance": {
|
||||
"en": "Appearance",
|
||||
"de": "Darstellung",
|
||||
"fr": "Apparence",
|
||||
"es": "Apariencia",
|
||||
"ja": "表示設定",
|
||||
"pt": "Aparência",
|
||||
"zh": "外观",
|
||||
"ru": "Внешний вид"
|
||||
},
|
||||
"read_here": {
|
||||
"en": "Read Here",
|
||||
"de": "Hier lesen",
|
||||
"fr": "Lire ici",
|
||||
"es": "Leer aquí",
|
||||
"ja": "ここで読む",
|
||||
"pt": "Ler aqui",
|
||||
"zh": "在此阅读",
|
||||
"ru": "Читать здесь"
|
||||
},
|
||||
"open_link": {
|
||||
"en": "Open Link",
|
||||
"de": "Link öffnen",
|
||||
"fr": "Ouvrir le lien",
|
||||
"es": "Abrir enlace",
|
||||
"ja": "リンクを開く",
|
||||
"pt": "Abrir link",
|
||||
"zh": "打开链接",
|
||||
"ru": "Открыть ссылку"
|
||||
},
|
||||
"previous_article": {
|
||||
"en": "Previous Article",
|
||||
"de": "Vorheriger Artikel",
|
||||
"fr": "Article précédent",
|
||||
"es": "Artículo anterior",
|
||||
"ja": "前の記事",
|
||||
"pt": "Artigo anterior",
|
||||
"zh": "上一篇",
|
||||
"ru": "Предыдущая статья"
|
||||
},
|
||||
"next_article": {
|
||||
"en": "Next Article",
|
||||
"de": "Nächster Artikel",
|
||||
"fr": "Article suivant",
|
||||
"es": "Artículo siguiente",
|
||||
"ja": "次の記事",
|
||||
"pt": "Próximo artigo",
|
||||
"zh": "下一篇",
|
||||
"ru": "Следующая статья"
|
||||
},
|
||||
"close_article": {
|
||||
"en": "Close Article",
|
||||
"de": "Artikel schließen",
|
||||
"fr": "Fermer l'article",
|
||||
"es": "Cerrar artículo",
|
||||
"ja": "記事を閉じる",
|
||||
"pt": "Fechar artigo",
|
||||
"zh": "关闭文章",
|
||||
"ru": "Закрыть статью"
|
||||
},
|
||||
"untitled": {
|
||||
"en": "untitled",
|
||||
"de": "unbenannt",
|
||||
"fr": "sans titre",
|
||||
"es": "sin título",
|
||||
"ja": "無題",
|
||||
"pt": "sem título",
|
||||
"zh": "无标题",
|
||||
"ru": "без названия"
|
||||
},
|
||||
"sans_serif": {
|
||||
"en": "sans-serif",
|
||||
"de": "serifenlos",
|
||||
"fr": "sans empattement",
|
||||
"es": "sans-serif",
|
||||
"ja": "ゴシック体",
|
||||
"pt": "sem serifa",
|
||||
"zh": "无衬线",
|
||||
"ru": "sans-serif"
|
||||
},
|
||||
"serif": {
|
||||
"en": "serif",
|
||||
"de": "Serife",
|
||||
"fr": "empattement",
|
||||
"es": "serifa",
|
||||
"ja": "明朝体",
|
||||
"pt": "com serifa",
|
||||
"zh": "衬线",
|
||||
"ru": "serif"
|
||||
},
|
||||
"monospace": {
|
||||
"en": "monospace",
|
||||
"de": "monospace",
|
||||
"fr": "monospace",
|
||||
"es": "monoespacio",
|
||||
"ja": "等幅",
|
||||
"pt": "monoespaçada",
|
||||
"zh": "等宽",
|
||||
"ru": "monospace"
|
||||
},
|
||||
"url": {
|
||||
"en": "URL",
|
||||
"de": "URL",
|
||||
"fr": "URL",
|
||||
"es": "URL",
|
||||
"ja": "URL",
|
||||
"pt": "URL",
|
||||
"zh": "网址",
|
||||
"ru": "URL"
|
||||
},
|
||||
"folder": {
|
||||
"en": "Folder",
|
||||
"de": "Ordner",
|
||||
"fr": "Dossier",
|
||||
"es": "Carpeta",
|
||||
"ja": "フォルダ",
|
||||
"pt": "Pasta",
|
||||
"zh": "文件夹",
|
||||
"ru": "Папка"
|
||||
},
|
||||
"add": {
|
||||
"en": "Add",
|
||||
"de": "Hinzufügen",
|
||||
"fr": "Ajouter",
|
||||
"es": "Añadir",
|
||||
"ja": "追加",
|
||||
"pt": "Adicionar",
|
||||
"zh": "添加",
|
||||
"ru": "Добавить"
|
||||
},
|
||||
"keyboard_shortcuts": {
|
||||
"en": "Keyboard Shortcuts",
|
||||
"de": "Tastenkürzel",
|
||||
"fr": "Raccourcis clavier",
|
||||
"es": "Atajos de teclado",
|
||||
"ja": "キーボードショートカット",
|
||||
"pt": "Atalhos do teclado",
|
||||
"zh": "键盘快捷键",
|
||||
"ru": "Горячие клавиши"
|
||||
},
|
||||
"multiple_feeds_found": {
|
||||
"en": "Multiple feeds found. Choose one below:",
|
||||
"de": "Mehrere Feeds gefunden. Bitte wählen Sie einen aus:",
|
||||
"fr": "Plusieurs flux trouvés. Choisissez-en un ci-dessous :",
|
||||
"es": "Múltiples fuentes encontradas. Elija una:",
|
||||
"ja": "複数のフィードが見つかりました。以下から選択してください:",
|
||||
"pt": "Múltiplos feeds encontrados. Escolha um abaixo:",
|
||||
"zh": "找到多个订阅源,请选择一个:",
|
||||
"ru": "Найдено несколько лент. Выберите одну:"
|
||||
},
|
||||
"cancel": {
|
||||
"en": "cancel",
|
||||
"de": "abbrechen",
|
||||
"fr": "annuler",
|
||||
"es": "cancelar",
|
||||
"ja": "キャンセル",
|
||||
"pt": "cancelar",
|
||||
"zh": "取消",
|
||||
"ru": "отмена"
|
||||
},
|
||||
"kb_show_filters": {
|
||||
"en": "show unread / starred / all feeds",
|
||||
"de": "ungelesene / markierte / alle Feeds anzeigen",
|
||||
"fr": "afficher les flux non lus / favoris / tous",
|
||||
"es": "mostrar fuentes no leídas / destacadas / todas",
|
||||
"ja": "未読/スター付き/すべてのフィードを表示",
|
||||
"pt": "mostrar feeds não lidos / favoritos / todos",
|
||||
"zh": "显示未读/星标/全部订阅",
|
||||
"ru": "показать непрочитанные / избранные / все ленты"
|
||||
},
|
||||
"kb_focus_search": {
|
||||
"en": "focus the search bar",
|
||||
"de": "Suchleiste fokussieren",
|
||||
"fr": "focus sur la barre de recherche",
|
||||
"es": "enfocar la barra de búsqueda",
|
||||
"ja": "検索バーにフォーカス",
|
||||
"pt": "focar na barra de pesquisa",
|
||||
"zh": "聚焦搜索栏",
|
||||
"ru": "фокус на строку поиска"
|
||||
},
|
||||
"kb_next_prev_article": {
|
||||
"en": "next / prev article",
|
||||
"de": "nächster / vorheriger Artikel",
|
||||
"fr": "article suivant / précédent",
|
||||
"es": "artículo siguiente / anterior",
|
||||
"ja": "次の/前の記事",
|
||||
"pt": "próximo / artigo anterior",
|
||||
"zh": "下一篇/上一篇文章",
|
||||
"ru": "следующая / предыдущая статья"
|
||||
},
|
||||
"kb_next_prev_feed": {
|
||||
"en": "next / prev feed",
|
||||
"de": "nächster / vorheriger Feed",
|
||||
"fr": "flux suivant / précédent",
|
||||
"es": "fuente siguiente / anterior",
|
||||
"ja": "次の/前のフィード",
|
||||
"pt": "próximo / feed anterior",
|
||||
"zh": "下一个/上一个订阅",
|
||||
"ru": "следующая / предыдущая лента"
|
||||
},
|
||||
"kb_close_article": {
|
||||
"en": "close article",
|
||||
"de": "Artikel schließen",
|
||||
"fr": "fermer l'article",
|
||||
"es": "cerrar artículo",
|
||||
"ja": "記事を閉じる",
|
||||
"pt": "fechar artigo",
|
||||
"zh": "关闭文章",
|
||||
"ru": "закрыть статью"
|
||||
},
|
||||
"kb_mark_all_read": {
|
||||
"en": "mark all read",
|
||||
"de": "alle als gelesen markieren",
|
||||
"fr": "tout marquer comme lu",
|
||||
"es": "marcar todo como leído",
|
||||
"ja": "すべて既読にする",
|
||||
"pt": "marcar todos como lidos",
|
||||
"zh": "全部标记为已读",
|
||||
"ru": "отметить все как прочитанные"
|
||||
},
|
||||
"kb_mark_read": {
|
||||
"en": "mark read / unread",
|
||||
"de": "als gelesen / ungelesen markieren",
|
||||
"fr": "marquer comme lu / non lu",
|
||||
"es": "marcar como leído / no leído",
|
||||
"ja": "既読/未読を切り替え",
|
||||
"pt": "marcar como lido / não lido",
|
||||
"zh": "标记已读/未读",
|
||||
"ru": "отметить как прочитанное / непрочитанное"
|
||||
},
|
||||
"kb_mark_starred": {
|
||||
"en": "mark starred / unstarred",
|
||||
"de": "als markiert / nicht markiert kennzeichnen",
|
||||
"fr": "marquer comme favori / non favori",
|
||||
"es": "marcar como destacado / no destacado",
|
||||
"ja": "スターを付ける/外す",
|
||||
"pt": "marcar como favorito / não favorito",
|
||||
"zh": "标记星标/取消星标",
|
||||
"ru": "пометить избранным / убрать из избранного"
|
||||
},
|
||||
"kb_open_link": {
|
||||
"en": "open link",
|
||||
"de": "Link öffnen",
|
||||
"fr": "ouvrir le lien",
|
||||
"es": "abrir enlace",
|
||||
"ja": "リンクを開く",
|
||||
"pt": "abrir link",
|
||||
"zh": "打开链接",
|
||||
"ru": "открыть ссылку"
|
||||
},
|
||||
"kb_read_here": {
|
||||
"en": "read here",
|
||||
"de": "hier lesen",
|
||||
"fr": "lire ici",
|
||||
"es": "leer aquí",
|
||||
"ja": "ここで読む",
|
||||
"pt": "ler aqui",
|
||||
"zh": "在此阅读",
|
||||
"ru": "читать здесь"
|
||||
},
|
||||
"kb_scroll_content": {
|
||||
"en": "scroll content forward / backward",
|
||||
"de": "Inhalt vorwärts / rückwärts scrollen",
|
||||
"fr": "faire défiler le contenu avant / arrière",
|
||||
"es": "desplazar contenido hacia adelante / atrás",
|
||||
"ja": "コンテンツを前/後にスクロール",
|
||||
"pt": "rolar conteúdo para frente / trás",
|
||||
"zh": "向前/向后滚动内容",
|
||||
"ru": "прокрутка вперед / назад"
|
||||
},
|
||||
"prompt_folder_name": {
|
||||
"en": "Enter folder name:",
|
||||
"de": "Ordnernamen eingeben:",
|
||||
"fr": "Entrez le nom du dossier :",
|
||||
"es": "Introduzca el nombre de la carpeta:",
|
||||
"ja": "フォルダ名を入力してください:",
|
||||
"pt": "Digite o nome da pasta:",
|
||||
"zh": "请输入文件夹名称:",
|
||||
"ru": "Введите имя папки:"
|
||||
},
|
||||
"prompt_new_title": {
|
||||
"en": "Enter new title",
|
||||
"de": "Neuen Titel eingeben",
|
||||
"fr": "Entrez un nouveau titre",
|
||||
"es": "Introduzca un nuevo título",
|
||||
"ja": "新しいタイトルを入力してください",
|
||||
"pt": "Digite o novo título",
|
||||
"zh": "请输入新标题",
|
||||
"ru": "Введите новый заголовок"
|
||||
},
|
||||
"prompt_feed_link": {
|
||||
"en": "Enter feed link",
|
||||
"de": "Feed-Link eingeben",
|
||||
"fr": "Entrez le lien du flux",
|
||||
"es": "Introduzca el enlace de la fuente",
|
||||
"ja": "フィードリンクを入力してください",
|
||||
"pt": "Digite o link do feed",
|
||||
"zh": "请输入订阅链接",
|
||||
"ru": "Введите ссылку на ленту"
|
||||
},
|
||||
"confirm_delete_folder": {
|
||||
"en": "Are you sure you want to delete",
|
||||
"zh": "确定要删除",
|
||||
"ru": "Вы уверены, что хотите удалить"
|
||||
},
|
||||
"confirm_delete_feed": {
|
||||
"en": "Are you sure you want to delete",
|
||||
"zh": "确定要删除",
|
||||
"ru": "Вы уверены, что хотите удалить"
|
||||
"confirm_delete": {
|
||||
"en": "Are you sure you want to delete { $name }?",
|
||||
"de": "Möchten Sie { $name } wirklich löschen?",
|
||||
"fr": "Voulez-vous vraiment supprimer { $name } ?",
|
||||
"es": "¿Está seguro de que quiere eliminar { $name }?",
|
||||
"ja": "{ $name }を削除してもよろしいですか?",
|
||||
"pt": "Tem certeza que deseja excluir { $name }?",
|
||||
"zh": "确定要删除{ $name }?",
|
||||
"ru": "Вы уверены, что хотите удалить { $name }?"
|
||||
},
|
||||
"alert_no_feeds": {
|
||||
"en": "No feeds found at the given url.",
|
||||
"de": "Keine Feeds unter der angegebenen URL gefunden.",
|
||||
"fr": "Aucun flux trouvé à cette URL.",
|
||||
"es": "No se encontraron fuentes en la URL proporcionada.",
|
||||
"ja": "指定されたURLにフィードが見つかりませんでした。",
|
||||
"pt": "Nenhum feed encontrado no URL fornecido.",
|
||||
"zh": "在指定的网址未找到订阅源。",
|
||||
"ru": "Лент по данному адресу не найдено."
|
||||
},
|
||||
"login": {
|
||||
"en": "Login",
|
||||
"de": "Anmelden",
|
||||
"fr": "Connexion",
|
||||
"es": "Iniciar sesión",
|
||||
"ja": "ログイン",
|
||||
"pt": "Entrar",
|
||||
"zh": "登录",
|
||||
"ru": "Вход"
|
||||
},
|
||||
"login_error": {
|
||||
"en": "Invalid username or password",
|
||||
"de": "Ungültiger Benutzername oder Passwort",
|
||||
"fr": "Nom d'utilisateur ou mot de passe invalide",
|
||||
"es": "Nombre de usuario o contraseña inválidos",
|
||||
"ja": "ユーザー名またはパスワードが無効です",
|
||||
"pt": "Nome de usuário ou senha inválidos",
|
||||
"zh": "用户名或密码错误",
|
||||
"ru": "Неверное имя пользователя или пароль"
|
||||
},
|
||||
"username": {
|
||||
"en": "Username",
|
||||
"de": "Benutzername",
|
||||
"fr": "Nom d'utilisateur",
|
||||
"es": "Nombre de usuario",
|
||||
"ja": "ユーザー名",
|
||||
"pt": "Nome de usuário",
|
||||
"zh": "用户名",
|
||||
"ru": "Имя пользователя"
|
||||
},
|
||||
"password": {
|
||||
"en": "Password",
|
||||
"de": "Passwort",
|
||||
"fr": "Mot de passe",
|
||||
"es": "Contraseña",
|
||||
"ja": "パスワード",
|
||||
"pt": "Senha",
|
||||
"zh": "密码",
|
||||
"ru": "Пароль"
|
||||
},
|
||||
};
|
||||
class i18n {
|
||||
constructor() {
|
||||
this.lang = 'en'
|
||||
}
|
||||
setLang(lang) {
|
||||
this.lang = lang
|
||||
}
|
||||
$t(code) {
|
||||
return translations[code][this.lang]
|
||||
}
|
||||
function ftlFrom(lang) {
|
||||
return Object.entries(translations)
|
||||
.map(([key, langs]) => `${key} = ${langs[lang]}`)
|
||||
.join('\n')
|
||||
}
|
||||
exports.i18n = {
|
||||
install(Vue, opts) {
|
||||
const x = new i18n();
|
||||
Vue.prototype.$t = x.$t
|
||||
Vue.prototype.$setLang = x.setLang
|
||||
install(Vue) {
|
||||
let bundle = null
|
||||
Vue.prototype.$setLang = function (lang) {
|
||||
const ftl = ftlFrom(lang)
|
||||
const resource = new FluentBundle.FluentResource(ftl)
|
||||
bundle = new FluentBundle.FluentBundle(lang)
|
||||
bundle.addResource(resource)
|
||||
}
|
||||
Vue.prototype.$t = function (code, args) {
|
||||
if (!bundle) return
|
||||
const msg = bundle.getMessage(code)
|
||||
if (!msg || !msg.value) return
|
||||
return bundle.formatPattern(msg.value, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
})(window)
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<link rel="alternate icon" href="./static/graphicarts/favicon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<style>
|
||||
[v-cloak] { display: none }
|
||||
form {
|
||||
max-width: 300px;
|
||||
margin: 0 auto;
|
||||
@@ -23,21 +24,33 @@
|
||||
</style>
|
||||
</head>
|
||||
<body class="theme-{% .settings.theme_name %}">
|
||||
<form action="" method="post">
|
||||
<img src="./static/graphicarts/anchor.svg" alt="">
|
||||
{% if .error %}
|
||||
<div class="text-danger text-center my-3">{% .error %}</div>
|
||||
{% end %}
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input name="username" class="form-control" id="username" autocomplete="off"
|
||||
value="{% if .username %}{% .username %}{% end %}" required autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input name="password" class="form-control" id="password" type="password" required>
|
||||
</div>
|
||||
<button class="btn btn-block btn-default" type="submit">Login</button>
|
||||
</form>
|
||||
<div id="app" v-cloak>
|
||||
<form action="" method="post">
|
||||
<img src="./static/graphicarts/anchor.svg" alt="">
|
||||
<div class="text-danger text-center my-3" v-if="hasError">{{ $t('login_error') }}</div>
|
||||
<div class="form-group">
|
||||
<label for="username">{{ $t('username') }}</label>
|
||||
<input name="username" class="form-control" id="username" autocomplete="off"
|
||||
value="{% if .username %}{% .username %}{% end %}" required autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">{{ $t('password') }}</label>
|
||||
<input name="password" class="form-control" id="password" type="password" required>
|
||||
</div>
|
||||
<button class="btn btn-block btn-default" type="submit">{{ $t('login') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
<script src="./static/javascripts/vue.min.js"></script>
|
||||
<script src="./static/javascripts/fluent.js"></script>
|
||||
<script src="./static/javascripts/i18n.js"></script>
|
||||
<script>
|
||||
Vue.use(i18n)
|
||||
new Vue({
|
||||
data: { hasError: {% .hasError %} },
|
||||
created: function () {
|
||||
this.$setLang('{% .settings.language %}')
|
||||
}
|
||||
}).$mount('#app')
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -46,13 +46,14 @@ func (m *Middleware) Handler(c *router.Context) {
|
||||
} else {
|
||||
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]any{
|
||||
"username": username,
|
||||
"error": "Invalid username/password",
|
||||
"hasError": true,
|
||||
"settings": m.DB.GetSettings().Map(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]any{
|
||||
"hasError": false,
|
||||
"settings": m.DB.GetSettings().Map(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -291,14 +291,12 @@ func TestListItemsPaginated(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestMarkItemsRead(t *testing.T) {
|
||||
// NOTE: starred items must not be marked as read
|
||||
func TestMarkAllItemsRead(t *testing.T) {
|
||||
var read model.ItemStatus = model.READ
|
||||
|
||||
dbtest(t, func(t *testing.T, db1 storage.Storage) {
|
||||
testItemsSetup(db1)
|
||||
db1.MarkItemsRead(model.MarkFilter{})
|
||||
have := getItemGuids(db1.ListItems(model.ItemFilter{Status: &read}, 10, false, false))
|
||||
dbtest(t, func(t *testing.T, db storage.Storage) {
|
||||
testItemsSetup(db)
|
||||
db.MarkItemsRead(model.MarkFilter{})
|
||||
have := getItemGuids(db.ListItems(model.ItemFilter{Status: &read}, 10, false, false))
|
||||
want := []string{
|
||||
"item111", "item112", "item121", "item122",
|
||||
"item211", "item011", "item012",
|
||||
@@ -309,11 +307,14 @@ func TestMarkItemsRead(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
dbtest(t, func(t *testing.T, db2 storage.Storage) {
|
||||
scope2 := testItemsSetup(db2)
|
||||
db2.MarkItemsRead(model.MarkFilter{FolderID: &scope2.folder1.Id})
|
||||
have := getItemGuids(db2.ListItems(model.ItemFilter{Status: &read}, 10, false, false))
|
||||
func TestMarkItemsReadByFolder(t *testing.T) {
|
||||
var read model.ItemStatus = model.READ
|
||||
dbtest(t, func(t *testing.T, db storage.Storage) {
|
||||
scope := testItemsSetup(db)
|
||||
db.MarkItemsRead(model.MarkFilter{FolderID: &scope.folder1.Id})
|
||||
have := getItemGuids(db.ListItems(model.ItemFilter{Status: &read}, 10, false, false))
|
||||
want := []string{
|
||||
"item111", "item112", "item121", "item122",
|
||||
"item211", "item012",
|
||||
@@ -324,11 +325,14 @@ func TestMarkItemsRead(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
dbtest(t, func(t *testing.T, db3 storage.Storage) {
|
||||
scope3 := testItemsSetup(db3)
|
||||
db3.MarkItemsRead(model.MarkFilter{FeedID: &scope3.feed11.Id})
|
||||
have := getItemGuids(db3.ListItems(model.ItemFilter{Status: &read}, 10, false, false))
|
||||
func TestMarkItemsReadByFeed(t *testing.T) {
|
||||
var read model.ItemStatus = model.READ
|
||||
dbtest(t, func(t *testing.T, db storage.Storage) {
|
||||
scope := testItemsSetup(db)
|
||||
db.MarkItemsRead(model.MarkFilter{FeedID: &scope.feed11.Id})
|
||||
have := getItemGuids(db.ListItems(model.ItemFilter{Status: &read}, 10, false, false))
|
||||
want := []string{
|
||||
"item111", "item112", "item122",
|
||||
"item211", "item012",
|
||||
|
||||
@@ -1,30 +1,32 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
"github.com/nkanaev/yarr/src/storage"
|
||||
)
|
||||
|
||||
func dbtest(t *testing.T, testcase func(t *testing.T, db storage.Storage)) {
|
||||
t.Parallel()
|
||||
testurls := map[string]string{
|
||||
"sqlite": ":memory:",
|
||||
}
|
||||
|
||||
if pgUrl := os.Getenv("YARR_POSTGRES_TEST_URL"); pgUrl != "" {
|
||||
dburl, cleanup, err := createPostgresDB(pgUrl)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create postgres test database: %v", err)
|
||||
}
|
||||
if pgImage := os.Getenv("YARR_POSTGRES_TEST_IMAGE"); pgImage != "" {
|
||||
dburl, cleanup := startPostgresContainer(t, pgImage)
|
||||
t.Cleanup(cleanup)
|
||||
testurls["postgres"] = dburl
|
||||
} else if !testing.Short() {
|
||||
t.Fatalf("YARR_POSTGRES_TEST_IMAGE not set; use -short to skip docker tests")
|
||||
}
|
||||
|
||||
for testname, url := range testurls {
|
||||
@@ -38,49 +40,72 @@ func dbtest(t *testing.T, testcase func(t *testing.T, db storage.Storage)) {
|
||||
}
|
||||
}
|
||||
|
||||
func createPostgresDB(pgUrl string) (string, func(), error) {
|
||||
u, err := url.Parse(pgUrl)
|
||||
func startPostgresContainer(t *testing.T, image string) (string, func()) {
|
||||
// database credentials
|
||||
dbUser := "testuser"
|
||||
dbPass := "password"
|
||||
dbName := "yarrtest"
|
||||
|
||||
// generate unique container name
|
||||
testHash := sha256.Sum256([]byte(t.Name()))
|
||||
containerName := fmt.Sprintf("yarr-test-pg-%x-%d", testHash[:8], time.Now().UnixNano())
|
||||
|
||||
cmd := exec.Command(
|
||||
"docker", "run", "-d", "--rm",
|
||||
"--name", containerName,
|
||||
"-p", "0:5432",
|
||||
"-e", "POSTGRES_USER="+dbUser,
|
||||
"-e", "POSTGRES_PASSWORD="+dbPass,
|
||||
"-e", "POSTGRES_DB="+dbName,
|
||||
image,
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
t.Fatalf("failed to start postgres container: %v\n%s", err, string(out))
|
||||
}
|
||||
|
||||
u.Path = "/postgres"
|
||||
adminConnStr := u.String()
|
||||
|
||||
adminDB, err := sql.Open("postgres", adminConnStr)
|
||||
// retrieve the host port assigned by docker
|
||||
portCmd := exec.Command("docker", "port", containerName, "5432/tcp")
|
||||
portOut, err := portCmd.Output()
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("admin connect: %w", err)
|
||||
t.Fatalf("failed to get container port: %v", err)
|
||||
}
|
||||
parts := strings.Split(strings.TrimSpace(string(portOut)), ":")
|
||||
dbPort := parts[len(parts)-1]
|
||||
|
||||
b := make([]byte, 4)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
adminDB.Close()
|
||||
return "", nil, fmt.Errorf("generate suffix: %w", err)
|
||||
}
|
||||
// build connection string
|
||||
pgUrl := fmt.Sprintf(
|
||||
"postgres://%s:%s@localhost:%s/%s?sslmode=disable",
|
||||
dbUser,
|
||||
dbPass,
|
||||
dbPort,
|
||||
dbName,
|
||||
)
|
||||
|
||||
testDBName := "yarr_test_" + hex.EncodeToString(b)
|
||||
|
||||
if _, err := adminDB.Exec(fmt.Sprintf(`CREATE DATABASE "%s"`, testDBName)); err != nil {
|
||||
adminDB.Close()
|
||||
return "", nil, fmt.Errorf("create database: %w", err)
|
||||
}
|
||||
adminDB.Close()
|
||||
|
||||
u.Path = "/" + testDBName
|
||||
testURL := u.String()
|
||||
|
||||
cleanup := func() {
|
||||
dropDB, err := sql.Open("postgres", adminConnStr)
|
||||
// wait up to 15 seconds for the container to accept connections
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
db, err := sql.Open("postgres", pgUrl)
|
||||
if err != nil {
|
||||
return
|
||||
continue
|
||||
}
|
||||
defer dropDB.Close()
|
||||
dropDB.Exec(fmt.Sprintf(
|
||||
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '%s' AND pid <> pg_backend_pid()`,
|
||||
testDBName,
|
||||
))
|
||||
dropDB.Exec(fmt.Sprintf(`DROP DATABASE IF EXISTS "%s"`, testDBName))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
err = db.PingContext(ctx)
|
||||
cancel()
|
||||
db.Close()
|
||||
if err == nil {
|
||||
goto ready
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for postgres container to be ready")
|
||||
|
||||
return testURL, cleanup, nil
|
||||
ready:
|
||||
// return connection url and a cleanup function that stops the container
|
||||
return pgUrl, func() {
|
||||
stop := exec.Command("docker", "stop", containerName)
|
||||
if err := stop.Run(); err != nil {
|
||||
t.Logf("failed to stop container %s: %v", containerName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user