feat: display komga.org website announcements within the app

Closes: #1149
This commit is contained in:
Gauthier Roebroeck 2023-07-12 13:28:07 +08:00
parent 223aea531d
commit 72c1e8dd29
17 changed files with 456 additions and 4 deletions

View file

@ -21,6 +21,11 @@
"account_settings": "Account Settings",
"change_password": "change password"
},
"announcements": {
"mark_all_read": "Mark all as read",
"mark_read": "Mark as read",
"tab_title": "Announcements"
},
"authentication_activity": {
"datetime": "Date Time",
"email": "Email",

View file

@ -28,6 +28,7 @@ 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 komgaAnnouncements from './plugins/komga-announcements.plugin'
import vuetify from './plugins/vuetify'
import logger from './plugins/logger.plugin'
import './public-path'
@ -72,7 +73,7 @@ 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.use(komgaAnnouncements, {http: Vue.prototype.$http})
Vue.config.productionTip = false

View file

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

View file

@ -94,6 +94,12 @@ const router = new Router({
beforeEnter: adminGuard,
component: () => import(/* webpackChunkName: "metrics" */ './views/Metrics.vue'),
},
{
path: '/settings/announcements',
name: 'announcements',
beforeEnter: adminGuard,
component: () => import(/* webpackChunkName: "announcements" */ './views/AnnouncementsView.vue'),
},
],
},
{

View file

@ -0,0 +1,36 @@
import {AxiosInstance} from 'axios'
import {JsonFeedDto} from '@/types/json-feed'
const API_ANNOUNCEMENTS = '/api/v1/announcements'
export default class KomgaAnnouncementsService {
private http: AxiosInstance
constructor(http: AxiosInstance) {
this.http = http
}
async getAnnouncements(): Promise<JsonFeedDto> {
try {
return (await this.http.get(API_ANNOUNCEMENTS)).data
} catch (e) {
let msg = 'An error occurred while trying to retrieve announcements'
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
async markAnnouncementsRead(announcementIds: string[]) {
try {
await this.http.put(API_ANNOUNCEMENTS, announcementIds)
} catch (e) {
let msg = 'An error occurred while trying to mark announcements as read'
if (e.response.data.message) {
msg += `: ${e.response.data.message}`
}
throw new Error(msg)
}
}
}

View file

@ -6,6 +6,7 @@ import createPersistedState from 'vuex-persistedstate'
import {persistedModule} from './plugins/persisted-state'
import {LibraryDto} from '@/types/komga-libraries'
import {ReadListDto} from '@/types/komga-readlists'
import {ItemDto, JsonFeedDto} from '@/types/json-feed'
Vue.use(Vuex)
@ -50,6 +51,15 @@ export default new Vuex.Store({
deleteSeriesDialog: false,
booksToCheck: 0,
announcements: {} as JsonFeedDto,
},
getters: {
getUnreadAnnouncementsCount: (state) => (): number => {
return state.announcements?.items
.filter((value: ItemDto) => false == value._komga?.read)
.length || 0
},
},
mutations: {
// Collections
@ -139,6 +149,9 @@ export default new Vuex.Store({
setDeleteSeriesDialog(state, dialog) {
state.deleteSeriesDialog = dialog
},
setAnnouncements(state, announcements) {
state.announcements = announcements
},
},
actions: {
// collections

View file

@ -0,0 +1,28 @@
export interface JsonFeedDto {
version: string,
title: string,
home_page_url?: string,
description?: string,
items: ItemDto[],
}
export interface ItemDto {
id: string,
url?: string,
title?: string,
summary?: string,
content_html?: string,
date_modified?: Date,
author: AuthorDto,
tags: Set<string>,
_komga?: KomgaExtensionDto,
}
export interface AuthorDto {
name?: string,
url?: string,
}
export interface KomgaExtensionDto {
read: boolean,
}

View file

@ -0,0 +1,99 @@
<template>
<v-container fluid class="pa-6">
<div v-for="(item, index) in $store.state.announcements.items" :key="item.id">
<v-row justify="space-between" align="center">
<v-col cols="auto">
<div class="ml-n2">
<v-badge
dot
inline
left
:value="item._komga.read ? 0 : 1"
color="warning"
>
<a :href="item.url" target="_blank" class="text-h3 font-weight-medium link-underline">{{ item.title }}</a>
<v-icon
x-small
color="grey"
class="ps-1"
>
mdi-open-in-new
</v-icon>
</v-badge>
</div>
<div class="mt-2 subtitle-1">
{{ new Intl.DateTimeFormat($i18n.locale, {dateStyle: 'long'}).format(item.date_modified) }}
</div>
</v-col>
<v-col cols="auto">
<v-tooltip :left="!$vuetify.rtl" :right="$vuetify.rtl">
<template v-slot:activator="{ on }">
<v-btn icon elevation="5" color="success" v-on="on" :disabled="item._komga.read"
@click="markRead(item.id)">
<v-icon>mdi-check</v-icon>
</v-btn>
</template>
{{ $t('announcements.mark_read') }}
</v-tooltip>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<div v-html="item.content_html"></div>
</v-col>
</v-row>
<v-divider class="my-8" v-if="index != $store.state.announcements.items.length - 1"/>
</div>
<v-tooltip :left="!$vuetify.rtl" :right="$vuetify.rtl">
<template v-slot:activator="{ on }">
<v-fab-transition>
<v-btn v-if="$store.getters.getUnreadAnnouncementsCount()"
color="success"
fab
bottom
right
fixed
elevation="10"
v-on="on"
@click="markAllRead"
>
<v-icon>mdi-check-all</v-icon>
</v-btn>
</v-fab-transition>
</template>
{{ $t('announcements.mark_all_read') }}
</v-tooltip>
</v-container>
</template>
<script lang="ts">
import Vue from 'vue'
import {ItemDto} from '@/types/json-feed'
export default Vue.extend({
name: 'AnnouncementsView',
mounted() {
this.loadData()
},
methods: {
async loadData() {
this.$komgaAnnouncements.getAnnouncements()
.then(x => this.$store.commit('setAnnouncements', x))
},
markRead(announcementId: string) {
this.$komgaAnnouncements.markAnnouncementsRead([announcementId])
.then(() => this.loadData())
},
markAllRead() {
this.$komgaAnnouncements.markAnnouncementsRead(this.$store.state.announcements.items.map((x: ItemDto) => x.id))
.then(() => this.loadData())
},
},
})
</script>
<style scoped>
</style>

View file

@ -7,8 +7,8 @@
dot
offset-x="15"
offset-y="20"
:value="drawerVisible ? 0 : booksToCheck"
color="accent"
:value="drawerVisible ? 0 : booksToCheck + $store.getters.getUnreadAnnouncementsCount()"
:color="booksToCheck ? 'accent' : 'warning'"
class="ms-n3"
>
<v-app-bar-nav-icon @click.stop="toggleDrawer"/>
@ -135,7 +135,14 @@
<v-icon>mdi-cog</v-icon>
</v-list-item-action>
<v-list-item-content>
<v-list-item-title>{{ $t('server_settings.server_settings') }}</v-list-item-title>
<v-badge
dot
inline
:value="$store.getters.getUnreadAnnouncementsCount()"
color="warning"
>
<v-list-item-title>{{ $t('server_settings.server_settings') }}</v-list-item-title>
</v-badge>
</v-list-item-content>
</v-list-item>
@ -238,6 +245,8 @@ export default Vue.extend({
this.info = await this.$actuator.getInfo()
this.$komgaBooks.getBooks(undefined, {size: 0} as PageRequest, undefined, [MediaStatus.ERROR, MediaStatus.UNSUPPORTED])
.then(x => this.$store.commit('setBooksToCheck', x.totalElements))
this.$komgaAnnouncements.getAnnouncements()
.then(x => this.$store.commit('setAnnouncements', x))
}
},
computed: {

View file

@ -4,6 +4,13 @@
<v-tab :to="{name: 'settings-users'}">{{ $t('users.users') }}</v-tab>
<v-tab :to="{name: 'settings-server'}">{{ $t('server.tab_title') }}</v-tab>
<v-tab :to="{name: 'metrics'}">{{ $t('metrics.title') }}</v-tab>
<v-tab :to="{name: 'announcements'}">
<v-badge
dot
:value="$store.getters.getUnreadAnnouncementsCount()"
color="warning"
>{{ $t('announcements.tab_title') }}</v-badge>
</v-tab>
</v-tabs>
<router-view/>
</div>

View file

@ -45,6 +45,7 @@ dependencies {
implementation(platform("org.springframework.boot:spring-boot-dependencies:3.1.1"))
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-security")

View file

@ -0,0 +1,7 @@
create table ANNOUNCEMENTS_READ
(
USER_ID varchar NOT NULL,
ANNOUNCEMENT_ID varchar NOT NULL,
PRIMARY KEY (USER_ID, ANNOUNCEMENT_ID),
FOREIGN KEY (USER_ID) references USER (ID)
);

View file

@ -17,4 +17,7 @@ interface KomgaUserRepository {
fun delete(userId: String)
fun deleteAll()
fun findAnnouncementIdsReadByUserId(userId: String): Set<String>
fun saveAnnouncementIdsRead(user: KomgaUser, announcementIds: Set<String>)
}

View file

@ -6,6 +6,7 @@ import org.gotson.komga.domain.model.ContentRestrictions
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.persistence.KomgaUserRepository
import org.gotson.komga.jooq.Tables
import org.gotson.komga.jooq.tables.records.AnnouncementsReadRecord
import org.jooq.DSLContext
import org.jooq.Record
import org.jooq.ResultQuery
@ -22,6 +23,7 @@ class KomgaUserDao(
private val u = Tables.USER
private val ul = Tables.USER_LIBRARY_SHARING
private val us = Tables.USER_SHARING
private val ar = Tables.ANNOUNCEMENTS_READ
override fun count(): Long = dsl.fetchCount(u).toLong()
@ -128,6 +130,10 @@ class KomgaUserDao(
insertSharingRestrictions(user)
}
override fun saveAnnouncementIdsRead(user: KomgaUser, announcementIds: Set<String>) {
dsl.batchStore(announcementIds.map { AnnouncementsReadRecord(user.id, it) }).execute()
}
private fun insertSharedLibraries(user: KomgaUser) {
user.sharedLibrariesIds.forEach {
dsl.insertInto(ul)
@ -155,6 +161,7 @@ class KomgaUserDao(
@Transactional
override fun delete(userId: String) {
dsl.deleteFrom(ar).where(ar.USER_ID.equal(userId)).execute()
dsl.deleteFrom(us).where(us.USER_ID.equal(userId)).execute()
dsl.deleteFrom(ul).where(ul.USER_ID.equal(userId)).execute()
dsl.deleteFrom(u).where(u.ID.equal(userId)).execute()
@ -162,11 +169,18 @@ class KomgaUserDao(
@Transactional
override fun deleteAll() {
dsl.deleteFrom(ar).execute()
dsl.deleteFrom(us).execute()
dsl.deleteFrom(ul).execute()
dsl.deleteFrom(u).execute()
}
override fun findAnnouncementIdsReadByUserId(userId: String): Set<String> =
dsl.select(ar.ANNOUNCEMENT_ID)
.from(ar)
.where(ar.USER_ID.eq(userId))
.fetchSet(ar.ANNOUNCEMENT_ID)
override fun existsByEmailIgnoreCase(email: String): Boolean =
dsl.fetchExists(
dsl.selectFrom(u)

View file

@ -0,0 +1,76 @@
package org.gotson.komga.interfaces.api.rest
import com.github.benmanes.caffeine.cache.Caffeine
import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.persistence.KomgaUserRepository
import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.interfaces.api.rest.dto.JsonFeedDto
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.server.ResponseStatusException
import java.util.concurrent.TimeUnit
private const val website = "https://komga.org"
@RestController
@RequestMapping("api/v1/announcements", produces = [MediaType.APPLICATION_JSON_VALUE])
class AnnouncementController(
private val userRepository: KomgaUserRepository,
) {
private val webClient = WebClient.create("$website/blog/feed.json")
private val cache = Caffeine.newBuilder()
.expireAfterAccess(1, TimeUnit.DAYS)
.build<String, JsonFeedDto>()
@GetMapping
@PreAuthorize("hasRole('$ROLE_ADMIN')")
fun getAnnouncements(
@AuthenticationPrincipal principal: KomgaPrincipal,
): JsonFeedDto {
return cache.get("announcements") { fetchWebsiteAnnouncements()?.let { replaceLinks(it) } }
?.let { feed ->
val read = userRepository.findAnnouncementIdsReadByUserId(principal.user.id)
feed.copy(items = feed.items.map { item -> item.copy(komgaExtension = JsonFeedDto.KomgaExtensionDto(read.contains(item.id))) })
}
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@PreAuthorize("hasRole('$ROLE_ADMIN')")
@PutMapping
@ResponseStatus(HttpStatus.NO_CONTENT)
fun markAnnouncementsRead(
@AuthenticationPrincipal principal: KomgaPrincipal,
@RequestBody announcementIds: Set<String>,
) {
userRepository.saveAnnouncementIdsRead(principal.user, announcementIds)
}
fun fetchWebsiteAnnouncements(): JsonFeedDto? {
val response = webClient.get()
.retrieve()
.toEntity(JsonFeedDto::class.java)
.block()
return response?.body
}
// while waiting for https://github.com/facebook/docusaurus/issues/9136
fun replaceLinks(feed: JsonFeedDto): JsonFeedDto = feed.copy(
items = feed.items.map {
it.copy(
contentHtml =
it.contentHtml?.replace("""<a href="/""", """<a href="$website/"""),
)
},
)
}

View file

@ -0,0 +1,39 @@
package org.gotson.komga.interfaces.api.rest.dto
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import java.time.OffsetDateTime
@JsonIgnoreProperties(ignoreUnknown = true)
data class JsonFeedDto(
val version: String,
val title: String,
@field:JsonProperty("home_page_url")
val homePageUrl: String?,
val description: String?,
val items: List<ItemDto> = emptyList(),
) {
data class ItemDto(
val id: String,
val url: String?,
val title: String?,
val summary: String?,
@field:JsonProperty("content_html")
val contentHtml: String?,
@field:JsonProperty("date_modified")
val dateModified: OffsetDateTime?,
val author: AuthorDto?,
val tags: Set<String> = emptySet(),
@field:JsonProperty("_komga")
val komgaExtension: KomgaExtensionDto?,
)
data class AuthorDto(
val name: String?,
val url: String?,
)
data class KomgaExtensionDto(
val read: Boolean,
)
}

View file

@ -0,0 +1,91 @@
package org.gotson.komga.interfaces.api.rest
import com.ninjasquad.springmockk.SpykBean
import io.mockk.every
import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.interfaces.api.rest.dto.JsonFeedDto
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.ZoneOffset
@SpringBootTest
@AutoConfigureMockMvc(printOnlyOnFailure = false)
class AnnouncementControllerTest(
@Autowired private val mockMvc: MockMvc,
) {
@SpykBean
private lateinit var announcementController: AnnouncementController
private val mockFeed = JsonFeedDto(
"https://jsonfeed.org/version/1",
"Announcements",
"https://komga.org/blog",
"Latest Komga announcements",
listOf(
JsonFeedDto.ItemDto(
"https://komga.org/blog/prepare-v1",
"https://komga.org/blog/prepare-v1",
"Prepare for v1.0.0",
"The future v1.0.0 will bring some breaking changes, this guide will help you to prepare for the next major version.",
"""
You can still change the port <a href="/docs/installation/configuration#server_port--serverport-port">through configuration</a>
Another link <a href="/blog/post/">here</a>.
A normal <a href="https://google.com">link</a>.
""".trimIndent(),
OffsetDateTime.of(
LocalDate.of(2023, 3, 21).atStartOfDay(),
ZoneOffset.UTC,
),
JsonFeedDto.AuthorDto("gotson", "https://github.com/gotson"),
setOf(
"breaking change",
"upgrade",
"komga",
),
null,
),
),
)
@Test
@WithMockCustomUser(roles = [ROLE_ADMIN])
fun `when getting announcements multiple times then the server announcements are only fetched once`() {
every { announcementController.fetchWebsiteAnnouncements() } returns mockFeed
repeat(2) {
mockMvc.get("/api/v1/announcements")
.andExpect {
status { isOk() }
jsonPath("$.items.length()") { value(1) }
jsonPath("$.items[0].date_modified") { value("2023-03-21T00:00:00Z") }
}
}
verify(exactly = 1) { announcementController.fetchWebsiteAnnouncements() }
}
@Test
fun `given json feed with relative links when replacing then links are replaced`() {
val feed = announcementController.replaceLinks(mockFeed)
assertThat(feed.items.first().contentHtml)
.contains(
"""<a href="https://komga.org/docs/installation/""",
"""<a href="https://komga.org/blog/post/">here</a>""",
"""<a href="https://google.com">link</a>""",
)
.doesNotContain(
"""<a href="/docs/installation/""",
"""<a href="/blog/post/>here</a>""",
)
}
}