perf: Add RSS articles view (#622) @Larsluph

This commit is contained in:
Rémi Marseault 2023-01-27 15:05:45 +01:00 committed by GitHub
parent 5a2ed4ea99
commit f8fcafa1b1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 298 additions and 66 deletions

View file

@ -8,9 +8,12 @@
</v-card-title>
<v-card-text>
<v-form ref="feedForm" class="px-6 mt-3">
<v-container>
<v-container v-if="!hasInitialFeed">
<v-text-field v-model="feed.url" :label="$t('modals.newFeed.url')" required />
</v-container>
<v-container>
<v-text-field v-model="feed.name" :label="$t('modals.newFeed.feedName')" required />
</v-container>
</v-form>
</v-card-text>
<v-divider />
@ -30,7 +33,6 @@
</template>
<script>
import { mapGetters } from 'vuex'
import qbit from '@/services/qbit'
import { Modal } from '@/mixins'
import { mdiCancel, mdiTagPlus, mdiPencil } from '@mdi/js'
@ -43,21 +45,19 @@ export default {
initialFeed: Object
},
data: () => ({
feed: { url: '' },
feed: { url: '', name: '' },
mdiCancel,
mdiTagPlus,
mdiPencil
}),
computed: {
...mapGetters(['getSelectedFeed']),
hasInitialFeed() {
return !!(this.initialFeed && this.initialFeed.name)
return !!(this.initialFeed && this.initialFeed.name && this.initialFeed.url)
}
},
created() {
this.$store.commit('FETCH_FEEDS')
if (this.hasInitialFeed) {
this.feed = this.initialFeed
this.feed = {...this.initialFeed}
}
},
methods: {
@ -70,7 +70,7 @@ export default {
this.dialog = false
},
edit() {
qbit.editFeed(this.feed)
qbit.editFeed(this.initialFeed.name, this.feed.name)
Vue.$toast.success(this.$t('toast.feedSaved'))
this.cancel()
}

View file

@ -12,14 +12,14 @@
<v-text-field v-model="rule.name" :label="$t('modals.newRule.name')" required />
</v-container>
<v-container>
<v-text-field v-model="rule.def.mustContain" :label="$t('modals.newRule.def.mustContain')" required />
<v-text-field v-model="rule.mustContain" :label="$t('modals.newRule.def.mustContain')" required />
</v-container>
<v-container>
<v-subheader class="pa-0">
{{ $t('modals.newRule.def.affectedFeeds') }}
</v-subheader>
<template v-for="(item, index) in availableFeeds">
<v-checkbox :key="index" v-model="rule.def.affectedFeeds" hide-details :label="item.name" :value="item.url" />
<v-checkbox :key="index" v-model="rule.affectedFeeds" hide-details :label="item.name" :value="item.url" />
</template>
</v-container>
</v-form>
@ -45,7 +45,6 @@ import { mapGetters } from 'vuex'
import qbit from '@/services/qbit'
import { Modal } from '@/mixins'
import { mdiCancel, mdiTagPlus, mdiPencil } from '@mdi/js'
import Vue from 'vue'
export default {
name: 'RuleForm',
@ -56,18 +55,16 @@ export default {
data: () => ({
rule: {
name: '',
def: {
mustContain: '',
affectedFeeds: [],
enabled: true
}
mustContain: '',
affectedFeeds: [],
enabled: true
},
mdiCancel,
mdiTagPlus,
mdiPencil
}),
computed: {
...mapGetters(['getSelectedRule', 'getFeeds']),
...mapGetters(['getFeeds']),
availableFeeds() {
return this.getFeeds()
},
@ -83,7 +80,7 @@ export default {
},
methods: {
create() {
qbit.createRule(this.rule.name, this.rule.def)
qbit.createRule(this.rule)
this.cancel()
},
cancel() {
@ -91,8 +88,8 @@ export default {
this.dialog = false
},
edit() {
qbit.editRule(this.rule)
Vue.$toast.success(this.$t('toast.ruleSaved'))
qbit.renameRule(this.initialRule.name, this.rule.name)
this.$toast.success(this.$t('toast.ruleSaved'))
this.cancel()
}
}

View file

@ -32,7 +32,7 @@
</v-tooltip>
<v-tooltip bottom open-delay="400">
<template #activator="{ on }">
<v-btn small fab :text="!mobile" class="mr-0 ml-0" :aria-label="$t('navbar.topActions.removeSelected')" v-on="on" @click="removeTorrents">
<v-btn :text="!mobile" small fab class="mr-0 ml-0" :aria-label="$t('navbar.topActions.removeSelected')" v-on="on" @click="removeTorrents">
<v-icon color="grey">
{{ mdiDelete }}
</v-icon>
@ -50,6 +50,16 @@
</template>
<span>{{ $t('navbar.topActions.searchNew') }}</span>
</v-tooltip>
<v-tooltip bottom open-delay="400">
<template #activator="{ on }">
<v-btn :text="!mobile" small fab color="grey--text" class="mr-0 ml-0" :aria-label="$t('navbar.topActions.rssArticles')" v-on="on" @click="goToRss">
<v-icon color="grey">
{{ mdiRss }}
</v-icon>
</v-btn>
</template>
<span>{{ $t('navbar.topActions.rssArticles') }}</span>
</v-tooltip>
<v-tooltip bottom open-delay="400">
<template #activator="{ on }">
<v-btn small fab :text="!mobile" class="mr-0 ml-0" :aria-label="$t('navbar.topActions.openSettings')" v-on="on" @click="goToSettings">
@ -67,7 +77,18 @@
import { General } from '@/mixins'
import { mapState } from 'vuex'
import qbit from '@/services/qbit'
import { mdiSort, mdiCog, mdiCheckboxBlankOutline, mdiCheckboxMarked, mdiSearchWeb, mdiDelete, mdiPlus, mdiPlay, mdiPause } from '@mdi/js'
import {
mdiSort,
mdiCog,
mdiCheckboxBlankOutline,
mdiCheckboxMarked,
mdiSearchWeb,
mdiDelete,
mdiPlus,
mdiPlay,
mdiPause,
mdiRss
} from '@mdi/js'
export default {
name: 'TopActions',
@ -79,6 +100,7 @@ export default {
mdiSort,
mdiPlus,
mdiSearchWeb,
mdiRss,
mdiPlay,
mdiPause,
mdiDelete,
@ -105,6 +127,9 @@ export default {
addModal(name) {
this.createModal(name)
},
goToRss() {
if (this.$route.name !== 'rss') this.$router.push({ name: 'rss' })
},
goToSettings() {
if (this.$route.name !== 'settings') this.$router.push({ name: 'settings' })
}

View file

@ -8,6 +8,11 @@
<v-list-item-content>
<v-list-item-title v-text="item.name" />
</v-list-item-content>
<v-list-item-action>
<v-icon @click="editFeed(item)">
{{ mdiPencil }}
</v-icon>
</v-list-item-action>
<v-list-item-action>
<v-icon color="red" @click="deleteFeed(item)">
{{ mdiDelete }}
@ -25,17 +30,21 @@
</v-row>
</v-card>
</template>
<script>
<script lang="ts">
import { mapGetters } from 'vuex'
import qbit from '@/services/qbit'
import { mdiDelete } from '@mdi/js'
import {mdiDelete, mdiPencil} from '@mdi/js'
import { Tab, General, FullScreenModal } from '@/mixins'
import {Feed} from "@/types/vuetorrent";
import {defineComponent} from "vue";
export default {
export default defineComponent({
name: 'Feeds',
mixins: [Tab, General, FullScreenModal],
data: () => ({
mdiPencil,
mdiDelete
}),
computed: {
@ -51,7 +60,10 @@ export default {
activeMethod() {
this.$store.commit('FETCH_FEEDS')
},
deleteFeed(item) {
editFeed(item: Feed) {
this.createModal('FeedForm', { initialFeed: {url: item.url, name: item.name}})
},
deleteFeed(item: Feed) {
qbit.deleteFeed(item.name)
this.$store.commit('FETCH_FEEDS')
},
@ -59,5 +71,5 @@ export default {
this.createModal('FeedForm')
}
}
}
})
</script>

View file

@ -86,7 +86,8 @@
"pauseSelected": "Pause selected torrents",
"removeSelected": "Remove selected torrents",
"openSettings": "Open settings",
"searchNew": "Search new torrent"
"searchNew": "Search new torrent",
"rssArticles": "View RSS feed articles"
},
"sessionStats": {
"tooltip": "Since the last time qBittorrent was restarted"
@ -137,6 +138,21 @@
"action": ""
}
},
"rss": {
"title": "RSS Articles",
"columnTitle": {
"feedName": "Feed Name",
"author": "Author",
"category": "Category",
"date": "Date",
"description": "Description",
"id": "ID",
"link": "Link",
"title": "Title",
"torrentURL": "Torrent Download URL",
"actions": ""
}
},
"settings": {
"tabName": {
"VueTorrent": "VueTorrent",

View file

@ -83,7 +83,8 @@
"pauseSelected": "Mettre en pause les torrents sélectionnés",
"removeSelected": "Supprimer les torrents sélectionnés",
"openSettings": "Ouvrir les paramètres",
"searchNew": "Rechercher un nouveau torrent"
"searchNew": "Rechercher un nouveau torrent",
"rssArticles": "Voir les articles des flux RSS"
},
"sessionStats": {
"tooltip": "Depuis le dernier redémarrage de qBittorrent"
@ -133,6 +134,21 @@
"action": "Action"
}
},
"rss": {
"title": "Articles RSS",
"columnTitle": {
"feedName": "Nom du Flux",
"author": "Auteur",
"category": "Catégorie",
"date": "Date",
"description": "Description",
"id": "ID",
"link": "Lien",
"title": "Titre",
"torrentURL": "URL du torrent",
"actions": ""
}
},
"settings": {
"tabName": {
"VueTorrent": "vuetorrent",

View file

@ -1,27 +1,26 @@
import { v4 as uuidv4 } from 'uuid'
import { mapGetters } from 'vuex'
import {Component, Vue} from "vue-property-decorator";
import {defineComponent} from "vue";
@Component({
computed: mapGetters(['getTheme'])
})
export default class General extends Vue {
getTheme!: () => string
get theme() {
return this.getTheme()
}
get isMobile() {
return this.$vuetify.breakpoint.smAndDown
}
createModal(name: string, props: any) {
const component = {
component: name,
props,
guid: uuidv4()
export default defineComponent({
computed: {
...mapGetters(['getTheme']),
theme() {
return this.getTheme()
},
isMobile() {
return this.$vuetify.breakpoint.smAndDown
}
},
methods: {
createModal(name: string, props?: any) {
const component = {
component: name,
props,
guid: uuidv4()
}
this.$store.commit('ADD_MODAL', component)
this.$store.commit('ADD_MODAL', component)
}
}
}
})

View file

@ -19,6 +19,11 @@ const router = new Router({
name: 'settings',
component: () => import('./views/Settings.vue')
},
{
path: '/rss',
name: 'rss',
component: () => import('./views/RssArticles.vue')
},
{
path: '/torrent/:hash',
name: 'torrentDetail',

View file

@ -5,7 +5,7 @@ import type {
AppPreferences,
Category,
Feed,
FeedRule,
FeedRule as QbitFeedRule,
SearchJob,
SearchPlugin,
SearchStatus,
@ -15,8 +15,9 @@ import type {
Torrent
} from '@/types/qbit/models'
import type { MainDataResponse, SearchResultsResponse, TorrentPeersResponse } from '@/types/qbit/responses'
import type { AddTorrentPayload, AppPreferencesPayload, LoginPayload } from '@/types/qbit/payloads'
import type { AddTorrentPayload, AppPreferencesPayload, CreateFeedPayload, LoginPayload } from '@/types/qbit/payloads'
import type { SortOptions } from '@/types/vuetorrent'
import type {FeedRule as VtFeedRule} from '@/types/vuetorrent/rss'
import type { Priority } from '@/enums/qbit'
type Parameters = Record<string, any>
@ -153,25 +154,25 @@ export class QBitApi {
// RSS
async createFeed(url: string, path?: string): Promise<void> {
async createFeed(payload: CreateFeedPayload): Promise<void> {
await this.execute('/rss/addFeed', {
url: url,
path: path
url: payload.url,
path: payload.name
})
}
async createRule(ruleName: string, ruleDef: FeedRule) {
async createRule(rule: VtFeedRule) {
return this.execute('/rss/setRule', {
ruleName: ruleName,
ruleDef: JSON.stringify(ruleDef, ['enabled', 'mustContain', 'mustNotContain', 'useRegex', 'affectedFeeds'])
ruleName: rule.name,
ruleDef: JSON.stringify(rule, ['enabled', 'mustContain', 'mustNotContain', 'useRegex', 'affectedFeeds'])
})
}
async getFeeds(): Promise<Record<string, Feed>> {
return this.axios.get('/rss/items').then(res => res.data)
async getFeeds(withData: boolean = false): Promise<Record<string, Feed>> {
return this.axios.get('/rss/items', { params: {withData}}).then(res => res.data)
}
async getRules(): Promise<Record<string, FeedRule>> {
async getRules(): Promise<Record<string, QbitFeedRule>> {
return this.axios.get('/rss/rules').then(res => res.data)
}
@ -182,7 +183,7 @@ export class QBitApi {
})
}
async editRule(ruleName: string, newRuleName: string): Promise<void> {
async renameRule(ruleName: string, newRuleName: string): Promise<void> {
await this.execute('/rss/renameRule', {
ruleName,
newRuleName

View file

@ -78,7 +78,7 @@ export default {
},
FETCH_CATEGORIES: async (state: StoreState) => (state.categories = Object.values(await qbit.getCategories())),
FETCH_TAGS: async (state: StoreState) => (state.tags = await qbit.getAvailableTags()),
FETCH_FEEDS: async (state: StoreState) => (state.rss.feeds = Object.entries(await qbit.getFeeds()).map(([key, value]) => ({ name: key, ...value }))),
FETCH_FEEDS: async (state: StoreState) => (state.rss.feeds = Object.entries(await qbit.getFeeds(true)).map(([key, value]) => ({ name: key, ...value }))),
FETCH_RULES: async (state: StoreState) => (state.rss.rules = Object.entries(await qbit.getRules()).map(([key, value]) => ({ name: key, ...value }))),
FETCH_SEARCH_PLUGINS: async (state: StoreState) => (state.searchPlugins = await qbit.getSearchPlugins()),
SET_CURRENT_ITEM_COUNT: (state: StoreState, count: number) => (state.filteredTorrentsCount = count),

View file

@ -1,4 +1,11 @@
import {FeedArticle} from "@/types/qbit/models"
export default interface Feed {
uid: string
url: string
title?: string
lastBuildDate?: string
isLoading?: boolean
hasError?: boolean
articles?: FeedArticle[]
}

View file

@ -0,0 +1,11 @@
export interface FeedArticle {
author: string
category: string
date: string
description: string
id: string
isRead: boolean
link: string
title: string
torrentURL: string
}

View file

@ -12,6 +12,7 @@ import type SearchPlugin from './SearchPlugin'
import type SearchJob from './SearchJob'
import type SearchStatus from './SearchStatus'
import type SearchResult from './SearchResult'
import {FeedArticle} from "@/types/qbit/models/FeedArticle"
type ApplicationVersion = string
@ -26,6 +27,7 @@ export type {
TorrentFile,
TorrentProperties,
FeedRule,
FeedArticle,
Feed,
SearchPlugin,
SearchJob,

View file

@ -0,0 +1,6 @@
import type { BasePayload } from '.'
export default interface CreateFeedPayload extends BasePayload {
url: string
name?: string
}

View file

@ -2,6 +2,7 @@ import type LoginPayload from './LoginPayload'
import type AddTorrentPayload from './AddTorrentPayload'
import type PeerLogPayload from './PeerLogPayload'
import type { AppPreferencesPayload } from './AppPreferencesPayload'
import type CreateFeedPayload from "./CreateFeedPayload"
import type BasePayload from './BasePayload'
export { AppPreferencesPayload, LoginPayload, AddTorrentPayload, PeerLogPayload, BasePayload }
export { AppPreferencesPayload, LoginPayload, AddTorrentPayload, PeerLogPayload, BasePayload, CreateFeedPayload }

View file

@ -1,5 +1,12 @@
import {FeedArticle} from "@/types/qbit/models";
export default interface Feed {
name: string
uid?: string
url: string
title?: string
lastBuildDate?: string
isLoading?: boolean
hasError?: boolean
articles?: FeedArticle[]
}

View file

@ -0,0 +1,6 @@
import {FeedArticle as QbitFeedArticle} from "@/types/qbit/models"
export default interface FeedArticle extends QbitFeedArticle {
feedName: string
parsedDate: Date
}

View file

@ -0,0 +1,5 @@
import Feed from './Feed'
import FeedArticle from './FeedArticle'
import FeedRule from './FeedRule'
export {Feed, FeedArticle, FeedRule}

117
src/views/RssArticles.vue Normal file
View file

@ -0,0 +1,117 @@
<template>
<div class="px-1 px-sm-5 background noselect">
<v-row no-gutters class="grey--text" align="center" justify="center">
<v-col>
<h1 style="font-size: 1.6em !important" class="subtitle-1 ml-2">
{{ $t('modals.rss.title') }}
</h1>
</v-col>
<v-col class="align-center justify-center">
<v-card-actions class="justify-end">
<v-btn small elevation="0" @click="close">
<v-icon>{{ mdiClose }}</v-icon>
</v-btn>
</v-card-actions>
</v-col>
</v-row>
<v-row class="ma-0 pa-0">
<v-data-table
id="articlesTable"
:headers="headers"
:items="articles"
:items-per-page="15"
:search="filter"
:custom-filter="customFilter"
:sort-by.sync="sortBy"
:sort-desc.sync="reverse">
<template #top>
<v-text-field ref="filterRef" v-model="filter" label="Filter" class="mx-4" />
</template>
<template #[`item.title`]="{ item }">
<a :href="item.link" target="_blank" v-text="item.title" />
</template>
<template #[`item.parsedDate`]="{ item }">
{{ item.parsedDate.toLocaleString() }}
</template>
<template #[`item.actions`]="{ item }">
<v-icon @click="downloadTorrent(item)">
{{ mdiDownload }}
</v-icon>
</template>
</v-data-table>
</v-row>
</div>
</template>
<script lang="ts">
import {General} from "@/mixins"
import {mapState} from "vuex"
import {defineComponent} from "vue"
import {FeedArticle} from "@/types/vuetorrent/rss"
import {Feed, FeedRule} from "@/types/vuetorrent"
import {mdiClose, mdiDownload} from "@mdi/js";
type RssState = {feeds: Feed[], rules: FeedRule[]}
export default defineComponent({
name: 'RssArticles',
mixins: [General],
data() {
return {
headers: [
{text: this.$i18n.t('modals.rss.columnTitle.id'), value: 'id'},
{text: this.$i18n.t('modals.rss.columnTitle.title'), value: 'title'},
// {text: this.$i18n.t('modals.rss.columnTitle.description'), value: 'description'},
{text: this.$i18n.t('modals.rss.columnTitle.category'), value: 'category'},
{text: this.$i18n.t('modals.rss.columnTitle.author'), value: 'author'},
{text: this.$i18n.t('modals.rss.columnTitle.date'), value: 'parsedDate'},
{text: this.$i18n.t('modals.rss.columnTitle.feedName'), value: 'feedName'},
// {text: this.$i18n.t('modals.rss.columnTitle.link'), value: 'link'},
// {text: this.$i18n.t('modals.rss.columnTitle.torrentURL'), value: 'torrentURL'},
{text: this.$i18n.t('modals.rss.columnTitle.actions'), value: 'actions', sortable: false}
],
filter: '',
sortBy: "date",
reverse: true,
mdiDownload,
mdiClose
}
},
mounted() {
document.addEventListener('keydown', this.handleKeyboardShortcut)
},
created() {
this.$store.commit('FETCH_FEEDS')
},
beforeDestroy() {
document.removeEventListener('keydown', this.handleKeyboardShortcut)
},
computed: {
...mapState(['rss']),
articles(): FeedArticle[] {
const articles: FeedArticle[] = [];
(this.rss as RssState).feeds.forEach((feed: Feed) => {
feed.articles && articles.push(...feed.articles.map(article => ({feedName: feed.name, parsedDate: new Date(article.date), ...article})))
})
return articles
}
},
methods: {
close() {
this.$router.back()
},
customFilter(value: string, query: string, item?: any): boolean {
return (item as FeedArticle).title.toLowerCase().indexOf(query.toLowerCase()) !== -1
},
downloadTorrent(item: FeedArticle) {
this.createModal('AddModal', { initialMagnet: item.torrentURL })
},
handleKeyboardShortcut(e: KeyboardEvent) {
if (e.key === "Escape") {
this.close()
}
}
}
})
</script>

View file

@ -20,8 +20,7 @@ export default defineConfig(({ command, mode }) => {
rollupOptions: {
output: {
manualChunks: {
vue: ['vue', 'vue-router', 'vue-router/composables', 'vuex', 'vuex-persist'],
vuetify: ['vuetify']
vue: ['vue', 'vue-router', 'vue-router/composables', 'vuex', 'vuex-persist']
}
}
},