feat(webui): history view

This commit is contained in:
Gauthier Roebroeck 2022-02-18 18:04:47 +08:00
parent 88f7f57a5d
commit f8bea23b2a
8 changed files with 310 additions and 2 deletions

View File

@ -42,10 +42,10 @@
},
"book_card": {
"error": "Error",
"no_release_date": "No release date",
"unknown": "To be analyzed",
"unread": "Unread",
"unsupported": "Unsupported",
"no_release_date": "No release date"
"unsupported": "Unsupported"
},
"book_import": {
"button_browse": "Browse",
@ -563,6 +563,13 @@
"HARDLINK": "Hardlink/Copy Files",
"MOVE": "Move Files"
},
"historical_event_type": {
"BookConverted": "Book converted",
"BookFileDeleted": "Book file deleted",
"BookImported": "Book imported",
"DuplicatePageDeleted": "Duplicate page deleted",
"SeriesFolderDeleted": "Series folder deleted"
},
"media_status": {
"ERROR": "Error",
"OUTDATED": "Outdated",
@ -642,6 +649,16 @@
"filter": "filter",
"sort": "sort"
},
"history": {
"header": {
"book": "Book",
"date": "Date",
"details": "Details",
"series": "Series",
"type": "Type"
},
"title": "History"
},
"home": {
"theme": "Theme",
"translation": "Translation"

View File

@ -27,6 +27,7 @@ import komgaOauth2 from './plugins/komga-oauth2.plugin'
import komgaLogin from './plugins/komga-login.plugin'
import komgaPageHashes from './plugins/komga-pagehashes.plugin'
import komgaMetrics from './plugins/komga-metrics.plugin'
import komgaHistory from './plugins/komga-history.plugin'
import vuetify from './plugins/vuetify'
import logger from './plugins/logger.plugin'
import './public-path'
@ -70,6 +71,7 @@ Vue.use(komgaOauth2, {http: Vue.prototype.$http})
Vue.use(komgaLogin, {http: Vue.prototype.$http})
Vue.use(komgaPageHashes, {http: Vue.prototype.$http})
Vue.use(komgaMetrics, {http: Vue.prototype.$http})
Vue.use(komgaHistory, {http: Vue.prototype.$http})
Vue.config.productionTip = false

View File

@ -0,0 +1,17 @@
import {AxiosInstance} from 'axios'
import _Vue from 'vue'
import KomgaHistoryService from '@/services/komga-history.service'
export default {
install(
Vue: typeof _Vue,
{http}: { http: AxiosInstance }) {
Vue.prototype.$komgaHistory = new KomgaHistoryService(http)
},
}
declare module 'vue/types/vue' {
interface Vue {
$komgaHistory: KomgaHistoryService;
}
}

View File

@ -128,6 +128,12 @@ const router = new Router({
},
],
},
{
path: '/history',
name: 'history',
component: () => import(/* webpackChunkName: "history" */ './views/HistoryView.vue'),
},
{
path: '/account',
name: 'account',

View File

@ -0,0 +1,29 @@
import {AxiosInstance} from 'axios'
import {HistoricalEventDto} from '@/types/komga-history'
const qs = require('qs')
const API_HISTORY = '/api/v1/history'
export default class KomgaHistoryService {
private http: AxiosInstance
constructor(http: AxiosInstance) {
this.http = http
}
async getAll(pageRequest?: PageRequest): Promise<Page<HistoricalEventDto>> {
try {
return (await this.http.get(API_HISTORY, {
params: pageRequest,
paramsSerializer: params => qs.stringify(params, {indices: false}),
})).data
} catch (e) {
let msg = 'An error occurred while trying to retrieve historical events'
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
}

View File

@ -0,0 +1,7 @@
export interface HistoricalEventDto {
type: string,
timestamp: string,
bookId?: string,
seriesId?: string,
properties: Record<string, string>[],
}

View File

@ -0,0 +1,221 @@
<template>
<v-container fluid class="pa-6">
<v-data-table
:headers="headers"
:items="items"
:options.sync="options"
:server-items-length="totalElements"
:loading="loading"
sort-by="timestamp"
:sort-desc="true"
multi-sort
class="elevation-1"
:footer-props="{
itemsPerPageOptions: [20, 50, 100]
}"
>
<template v-slot:item.type="{ item }">
<v-icon :title="$t(`enums.historical_event_type.${item.type}`)">{{ getIcon(item.type) }}</v-icon>
</template>
<template v-slot:item.seriesId="{ item }">
<router-link v-if="getSeries(item.seriesId)"
:to="{name: 'browse-series', params: {seriesId: item.seriesId}}"
class="link-underline"
>{{ getSeries(item.seriesId).metadata.title }}
</router-link>
<template v-else>{{ item.seriesId }}</template>
</template>
<template v-slot:item.bookId="{ item }">
<router-link v-if="getBook(item.bookId)"
:to="{name: 'browse-book', params: {bookId: item.bookId}}"
class="link-underline"
>{{ getBook(item.bookId).metadata.title }}
</router-link>
<template v-else>{{ item.bookId }}</template>
</template>
<template v-slot:item.timestamp="{ item }">
{{
new Intl.DateTimeFormat($i18n.locale, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(item.timestamp))
}}
</template>
<template v-slot:item.properties="{ item }">
<v-btn icon small @click="showDetails(item)">
<v-icon small>mdi-information</v-icon>
</v-btn>
</template>
<template v-slot:footer.prepend>
<v-btn icon @click="loadData">
<v-icon>mdi-refresh</v-icon>
</v-btn>
</template>
</v-data-table>
<v-dialog
v-model="dialogDetails"
scrollable
>
<v-card v-if="dialogDetailsItem">
<v-card-title>{{ $t(`enums.historical_event_type.${dialogDetailsItem.type}`) }}</v-card-title>
<v-card-text>
<v-simple-table>
<tbody>
<tr v-for="[key, value] in Object.entries(dialogDetailsItem.properties)" :key="key">
<td class="text-capitalize font-weight-bold">{{ key }}</td>
<td>{{ value }}</td>
</tr>
<tr v-if="getPageHash(dialogDetailsItem)">
<td class="font-weight-bold">Page</td>
<td>
<v-img
width="200"
height="300"
contain
:src="pageHashKnownThumbnailUrl(getPageHash(dialogDetailsItem))"
style="cursor: zoom-in"
/>
</td>
</tr>
</tbody>
</v-simple-table>
</v-card-text>
<v-card-actions>
<v-spacer/>
<v-btn @click="dialogDetails = false" text>{{ $t('common.close') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script lang="ts">
import Vue from 'vue'
import {HistoricalEventDto} from '@/types/komga-history'
import {SeriesDto} from '@/types/komga-series'
import {BookDto} from '@/types/komga-books'
import {pageHashKnownThumbnailUrl} from '@/functions/urls'
import {PageHashKnownDto} from '@/types/komga-pagehashes'
export default Vue.extend({
name: 'HistoryView',
data: function () {
return {
pageHashKnownThumbnailUrl,
items: [] as HistoricalEventDto[],
totalElements: 0,
loading: true,
options: {} as any,
dialogDetails: false,
dialogDetailsItem: undefined as HistoricalEventDto | undefined,
seriesCache: [] as SeriesDto[],
seriesCacheNotFound: [] as string[],
booksCache: [] as BookDto[],
booksCacheNotFound: [] as string[],
}
},
watch: {
options: {
handler() {
this.loadData()
},
deep: true,
},
},
computed: {
headers(): object[] {
return [
{text: this.$t('history.header.type').toString(), value: 'type'},
{text: this.$t('history.header.series').toString(), value: 'seriesId'},
{text: this.$t('history.header.book').toString(), value: 'bookId'},
{text: this.$t('history.header.date').toString(), value: 'timestamp'},
{text: this.$t('history.header.details').toString(), value: 'properties', sortable: false},
]
},
},
methods: {
getPageHash(item: HistoricalEventDto): PageHashKnownDto | undefined {
if (item.type !== 'DuplicatePageDeleted') return undefined
return {
hash: item.properties['page file hash' as any],
size: item.properties['page file size' as any],
mediaType: item.properties['page media type' as any],
} as any
},
getSeries(seriesId: string): SeriesDto | undefined {
return this.seriesCache.find(x => x.id === seriesId)
},
getBook(bookId: string): BookDto | undefined {
return this.booksCache.find(x => x.id === bookId)
},
showDetails(item: HistoricalEventDto) {
this.dialogDetailsItem = item
this.dialogDetails = true
},
getIcon(type: string): string {
switch (type) {
case 'BookFileDeleted':
return 'mdi-file-remove'
case 'SeriesFolderDeleted':
return 'mdi-folder-remove'
case 'DuplicatePageDeleted':
return 'mdi-book-minus'
case 'BookConverted':
return 'mdi-archive-refresh'
case 'BookImported':
return 'mdi-import'
default:
return ''
}
},
async loadData() {
this.loading = true
const {sortBy, sortDesc, page, itemsPerPage} = this.options
const pageRequest = {
page: page - 1,
size: itemsPerPage,
sort: [],
} as PageRequest
for (let i = 0; i < sortBy.length; i++) {
pageRequest.sort!!.push(`${sortBy[i]},${sortDesc[i] ? 'desc' : 'asc'}`)
}
const itemsPage = await this.$komgaHistory.getAll(pageRequest)
this.totalElements = itemsPage.totalElements
this.items = itemsPage.content
for (const seriesId of new Set(this.items.map(x => x.seriesId))) {
if (seriesId && !this.seriesCacheNotFound.includes(seriesId) && !this.getSeries(seriesId)) {
this.$komgaSeries.getOneSeries(seriesId)
.then(s => this.seriesCache.push(s))
.catch(() => this.seriesCacheNotFound.push(seriesId))
}
}
for (const bookId of new Set(this.items.map(x => x.bookId))) {
if (bookId && !this.booksCacheNotFound.includes(bookId) && !this.getBook(bookId)) {
this.$komgaBooks.getBook(bookId)
.then(b => this.booksCache.push(b))
.catch(() => this.booksCacheNotFound.push(bookId))
}
}
this.loading = false
},
},
})
</script>
<style scoped>
</style>

View File

@ -105,6 +105,15 @@
</v-list-item-content>
</v-list-item>
<v-list-item :to="{name: 'history'}" v-if="isAdmin">
<v-list-item-icon>
<v-icon>mdi-clock-time-four-outline</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>{{ $t('history.title') }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item :to="{name: 'settings'}" v-if="isAdmin">
<v-list-item-action>
<v-icon>mdi-cog</v-icon>