From 72c1e8dd297a2ac7e5c4bd72c17986da2db55077 Mon Sep 17 00:00:00 2001 From: Gauthier Roebroeck Date: Wed, 12 Jul 2023 13:28:07 +0800 Subject: [PATCH] feat: display komga.org website announcements within the app Closes: #1149 --- komga-webui/src/locales/en.json | 5 + komga-webui/src/main.ts | 3 +- .../src/plugins/komga-announcements.plugin.ts | 17 ++++ komga-webui/src/router.ts | 6 ++ .../services/komga-announcements.service.ts | 36 +++++++ komga-webui/src/store.ts | 13 +++ komga-webui/src/types/json-feed.ts | 28 ++++++ komga-webui/src/views/AnnouncementsView.vue | 99 +++++++++++++++++++ komga-webui/src/views/HomeView.vue | 15 ++- komga-webui/src/views/SettingsHolder.vue | 7 ++ komga/build.gradle.kts | 1 + .../V20230711173700__read_announcements.sql | 7 ++ .../domain/persistence/KomgaUserRepository.kt | 3 + .../komga/infrastructure/jooq/KomgaUserDao.kt | 14 +++ .../api/rest/AnnouncementController.kt | 76 ++++++++++++++ .../interfaces/api/rest/dto/JsonFeedDto.kt | 39 ++++++++ .../api/rest/AnnouncementControllerTest.kt | 91 +++++++++++++++++ 17 files changed, 456 insertions(+), 4 deletions(-) create mode 100644 komga-webui/src/plugins/komga-announcements.plugin.ts create mode 100644 komga-webui/src/services/komga-announcements.service.ts create mode 100644 komga-webui/src/types/json-feed.ts create mode 100644 komga-webui/src/views/AnnouncementsView.vue create mode 100644 komga/src/flyway/resources/db/migration/sqlite/V20230711173700__read_announcements.sql create mode 100644 komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/AnnouncementController.kt create mode 100644 komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/JsonFeedDto.kt create mode 100644 komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/AnnouncementControllerTest.kt diff --git a/komga-webui/src/locales/en.json b/komga-webui/src/locales/en.json index 90e5fb0c8..db39eb68d 100644 --- a/komga-webui/src/locales/en.json +++ b/komga-webui/src/locales/en.json @@ -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", diff --git a/komga-webui/src/main.ts b/komga-webui/src/main.ts index d655cf062..60963ac2e 100644 --- a/komga-webui/src/main.ts +++ b/komga-webui/src/main.ts @@ -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 diff --git a/komga-webui/src/plugins/komga-announcements.plugin.ts b/komga-webui/src/plugins/komga-announcements.plugin.ts new file mode 100644 index 000000000..ba103d612 --- /dev/null +++ b/komga-webui/src/plugins/komga-announcements.plugin.ts @@ -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; + } +} diff --git a/komga-webui/src/router.ts b/komga-webui/src/router.ts index 28d756cdb..d9d870b54 100644 --- a/komga-webui/src/router.ts +++ b/komga-webui/src/router.ts @@ -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'), + }, ], }, { diff --git a/komga-webui/src/services/komga-announcements.service.ts b/komga-webui/src/services/komga-announcements.service.ts new file mode 100644 index 000000000..90dfef682 --- /dev/null +++ b/komga-webui/src/services/komga-announcements.service.ts @@ -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 { + 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) + } + } +} diff --git a/komga-webui/src/store.ts b/komga-webui/src/store.ts index f4739cb01..abbbfd73d 100644 --- a/komga-webui/src/store.ts +++ b/komga-webui/src/store.ts @@ -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 diff --git a/komga-webui/src/types/json-feed.ts b/komga-webui/src/types/json-feed.ts new file mode 100644 index 000000000..546742fbf --- /dev/null +++ b/komga-webui/src/types/json-feed.ts @@ -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, + _komga?: KomgaExtensionDto, +} + +export interface AuthorDto { + name?: string, + url?: string, +} + +export interface KomgaExtensionDto { + read: boolean, +} diff --git a/komga-webui/src/views/AnnouncementsView.vue b/komga-webui/src/views/AnnouncementsView.vue new file mode 100644 index 000000000..4482a96cf --- /dev/null +++ b/komga-webui/src/views/AnnouncementsView.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/komga-webui/src/views/HomeView.vue b/komga-webui/src/views/HomeView.vue index a1f6581c6..81a40f63e 100644 --- a/komga-webui/src/views/HomeView.vue +++ b/komga-webui/src/views/HomeView.vue @@ -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" > @@ -135,7 +135,14 @@ mdi-cog - {{ $t('server_settings.server_settings') }} + + {{ $t('server_settings.server_settings') }} + @@ -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: { diff --git a/komga-webui/src/views/SettingsHolder.vue b/komga-webui/src/views/SettingsHolder.vue index f8d29cfaf..58323967e 100644 --- a/komga-webui/src/views/SettingsHolder.vue +++ b/komga-webui/src/views/SettingsHolder.vue @@ -4,6 +4,13 @@ {{ $t('users.users') }} {{ $t('server.tab_title') }} {{ $t('metrics.title') }} + + {{ $t('announcements.tab_title') }} + diff --git a/komga/build.gradle.kts b/komga/build.gradle.kts index 6dc19284e..b187865cc 100644 --- a/komga/build.gradle.kts +++ b/komga/build.gradle.kts @@ -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") diff --git a/komga/src/flyway/resources/db/migration/sqlite/V20230711173700__read_announcements.sql b/komga/src/flyway/resources/db/migration/sqlite/V20230711173700__read_announcements.sql new file mode 100644 index 000000000..2adca4728 --- /dev/null +++ b/komga/src/flyway/resources/db/migration/sqlite/V20230711173700__read_announcements.sql @@ -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) +); diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/KomgaUserRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/KomgaUserRepository.kt index 5ad0b40ea..4cf734459 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/KomgaUserRepository.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/KomgaUserRepository.kt @@ -17,4 +17,7 @@ interface KomgaUserRepository { fun delete(userId: String) fun deleteAll() + + fun findAnnouncementIdsReadByUserId(userId: String): Set + fun saveAnnouncementIdsRead(user: KomgaUser, announcementIds: Set) } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/KomgaUserDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/KomgaUserDao.kt index fc2c52ca3..022b7248d 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/KomgaUserDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/KomgaUserDao.kt @@ -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) { + 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 = + 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) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/AnnouncementController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/AnnouncementController.kt new file mode 100644 index 000000000..66d317607 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/AnnouncementController.kt @@ -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() + + @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, + ) { + 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(""" = 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 = emptySet(), + @field:JsonProperty("_komga") + val komgaExtension: KomgaExtensionDto?, + ) + + data class AuthorDto( + val name: String?, + val url: String?, + ) + + data class KomgaExtensionDto( + val read: Boolean, + ) +} diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/AnnouncementControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/AnnouncementControllerTest.kt new file mode 100644 index 000000000..397d2985f --- /dev/null +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/rest/AnnouncementControllerTest.kt @@ -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 through configuration + Another link here. + A normal link. + """.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( + """here""", + """link""", + ) + .doesNotContain( + """