From 9061b88efb36cfa7cd8df2d9a5b55fb058ee29f4 Mon Sep 17 00:00:00 2001 From: bayang Date: Fri, 17 Jan 2025 20:57:23 +0100 Subject: [PATCH] feat: orphan authors and series management #148 --- src/jelu-ui/src/components/AdminBase.vue | 3 +- src/jelu-ui/src/components/AuthorsAdmin.vue | 243 ++++++++++++++++++ src/jelu-ui/src/components/DataAdmin.vue | 64 +++++ src/jelu-ui/src/components/SeriesAdmin.vue | 243 ++++++++++++++++++ src/jelu-ui/src/locales/en.json | 14 +- src/jelu-ui/src/router.ts | 1 + src/jelu-ui/src/services/DataService.ts | 76 ++++++ .../jelu/controllers/BooksController.kt | 17 ++ .../github/bayang/jelu/dao/BookRepository.kt | 36 +++ .../github/bayang/jelu/service/BookService.kt | 10 + .../bayang/jelu/service/BookServiceTest.kt | 61 +++++ 11 files changed, 763 insertions(+), 5 deletions(-) create mode 100644 src/jelu-ui/src/components/AuthorsAdmin.vue create mode 100644 src/jelu-ui/src/components/DataAdmin.vue create mode 100644 src/jelu-ui/src/components/SeriesAdmin.vue diff --git a/src/jelu-ui/src/components/AdminBase.vue b/src/jelu-ui/src/components/AdminBase.vue index bae92125..ea6bbc5c 100644 --- a/src/jelu-ui/src/components/AdminBase.vue +++ b/src/jelu-ui/src/components/AdminBase.vue @@ -17,7 +17,7 @@ const store = useStore(key) const items = ref([{ name:t('settings.profile'), tooltip:t('settings.my_profile'), icon:"bx-user", href:"/profile" }, { name:t('settings.settings'), icon:"bxs-cog", href:"/profile/settings", tooltip: t('settings.settings') }, - { name:t('settings.authors'), icon:"bxs-user-account", href:"/profile/admin/authors", tooltip: t('settings.author_management') }, + { name:t('nav.data-admin'), icon:"bxs-data", href:"/profile/data", tooltip: t('nav.data-admin') }, { name:t('settings.imports'), icon:"bxs-file-plus", href:"/profile/imports", tooltip: t('settings.csv_import') }, { name:t('settings.messages'), icon:"bxs-message-alt-detail", href:"/profile/messages" }, { name:t('settings.stats'), icon:"bxs-chart", href:"/profile/stats", tooltip: t('settings.stats') }, @@ -25,7 +25,6 @@ const items = ref([{ name:t('settings.profile'), tooltip:t('settings.my_profile' ]) if (store.getters.isAdmin && store.getters.getUser != null && store.getters.getUser.provider !== Provider.PROXY) { - items.value.push({ name:t('nav.tags-admin'), icon:"bxs-purchase-tag", href:"/profile/tags", tooltip: t('nav.tags-admin') }) items.value.push({ name:t('settings.add_users'), icon:"bxs-user-plus", href:"/profile/admin/users", tooltip: t('settings.users_management') }) } diff --git a/src/jelu-ui/src/components/AuthorsAdmin.vue b/src/jelu-ui/src/components/AuthorsAdmin.vue new file mode 100644 index 00000000..4a1c3e83 --- /dev/null +++ b/src/jelu-ui/src/components/AuthorsAdmin.vue @@ -0,0 +1,243 @@ + + + + + diff --git a/src/jelu-ui/src/components/DataAdmin.vue b/src/jelu-ui/src/components/DataAdmin.vue new file mode 100644 index 00000000..54ed6ed0 --- /dev/null +++ b/src/jelu-ui/src/components/DataAdmin.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/src/jelu-ui/src/components/SeriesAdmin.vue b/src/jelu-ui/src/components/SeriesAdmin.vue new file mode 100644 index 00000000..7d53261a --- /dev/null +++ b/src/jelu-ui/src/components/SeriesAdmin.vue @@ -0,0 +1,243 @@ + + + + + diff --git a/src/jelu-ui/src/locales/en.json b/src/jelu-ui/src/locales/en.json index 6c058196..6dd8137d 100644 --- a/src/jelu-ui/src/locales/en.json +++ b/src/jelu-ui/src/locales/en.json @@ -16,7 +16,8 @@ "search": "search", "history": "history", "shelves": "shelves", - "tags-admin" : "tags management" + "tags-admin" : "tags management", + "data-admin": "data management" }, "labels": { "search": "Search", @@ -117,7 +118,13 @@ "loading": "loading", "pick_camera": "pick camera", "add_narrator": "Add a narrator", - "social_login": "sign in with {provider}" + "social_login": "sign in with {provider}", + "delete_this_series" : "Delete this series from {nb} book(s) and from database ?", + "orphan-series" : "orphan series", + "find-series": "find a series", + "delete_this_author" : "Delete this author from {nb} book(s) and from database ?", + "orphan_authors": "orphan authors", + "find-authors": "find authors" }, "settings" : { "pick_language" : "Pick your language", @@ -237,7 +244,8 @@ "author_right_description" : "Choose next the author you want to merge with the other.", "authors_merge_subtitle" : "Merge", "authors_merge_description" : "Use fields on the right author to complete the author on the left one, then merge.", - "search_message" : "Search authors to merge" + "search_message" : "Search authors to merge", + "merge_authors": "merge authors" }, "author" : { "name" : "name", diff --git a/src/jelu-ui/src/router.ts b/src/jelu-ui/src/router.ts index c1d50925..e8eb4939 100644 --- a/src/jelu-ui/src/router.ts +++ b/src/jelu-ui/src/router.ts @@ -131,6 +131,7 @@ const router = createRouter({ { path: 'messages', component: () => import(/* webpackChunkName: "recommend" */ './components/UserMessages.vue')}, { path: 'stats', component: () => import(/* webpackChunkName: "recommend" */ './components/UserStats.vue')}, { path: 'tags', component: () => import(/* webpackChunkName: "recommend" */ './components/TagsAdmin.vue')}, + { path: 'data', component: () => import(/* webpackChunkName: "recommend" */ './components/DataAdmin.vue')}, ] }, ], diff --git a/src/jelu-ui/src/services/DataService.ts b/src/jelu-ui/src/services/DataService.ts index c68d7ae3..61363ab2 100644 --- a/src/jelu-ui/src/services/DataService.ts +++ b/src/jelu-ui/src/services/DataService.ts @@ -713,6 +713,50 @@ class DataService { throw new Error("error get tag orphans " + error) } } + + getOrphanAuthors = async (page?: number, size?: number, sort?: string) => { + try { + const response = await this.apiClient.get>(`${this.API_AUTHOR}/orphans`, { + params: { + page: page, + size: size, + sort: sort, + } + }); + console.log("called orphan authors") + console.log(response) + return response.data; + } + catch (error) { + if (axios.isAxiosError(error) && error.response) { + console.log("error axios " + error.response.status + " " + error.response.data.error) + } + console.log("error author orphans " + (error as AxiosError).code) + throw new Error("error get author orphans " + error) + } + } + + getOrphanSeries = async (page?: number, size?: number, sort?: string) => { + try { + const response = await this.apiClient.get>(`${this.API_SERIES}/orphans`, { + params: { + page: page, + size: size, + sort: sort, + } + }); + console.log("called series orphans") + console.log(response) + return response.data; + } + catch (error) { + if (axios.isAxiosError(error) && error.response) { + console.log("error axios " + error.response.status + " " + error.response.data.error) + } + console.log("error series orphans " + (error as AxiosError).code) + throw new Error("error get series orphans " + error) + } + } getSeriesBooksById = async (seriesId: string, page?: number, size?: number, sort?: string, libraryFilter?: LibraryFilter) => { @@ -941,6 +985,38 @@ class DataService { throw new Error("error delete event " + error) } } + + deleteAuthor = async (authorId: string) => { + try { + const response = await this.apiClient.delete(`${this.API_AUTHOR}/${authorId}`); + console.log("delete author") + console.log(response) + return response.data; + } + catch (error) { + if (axios.isAxiosError(error) && error.response) { + console.log("error axios " + error.response.status + " " + error.response.data.error) + } + console.log("error delete author " + (error as AxiosError).code) + throw new Error("error delete author " + error) + } + } + + deleteSeries = async (seriesId: string) => { + try { + const response = await this.apiClient.delete(`${this.API_SERIES}/${seriesId}`); + console.log("delete series") + console.log(response) + return response.data; + } + catch (error) { + if (axios.isAxiosError(error) && error.response) { + console.log("error axios " + error.response.status + " " + error.response.data.error) + } + console.log("error delete series " + (error as AxiosError).code) + throw new Error("error delete series " + error) + } + } deleteTag = async (tagId: string) => { try { diff --git a/src/main/kotlin/io/github/bayang/jelu/controllers/BooksController.kt b/src/main/kotlin/io/github/bayang/jelu/controllers/BooksController.kt index c8e55e8b..d394a324 100644 --- a/src/main/kotlin/io/github/bayang/jelu/controllers/BooksController.kt +++ b/src/main/kotlin/io/github/bayang/jelu/controllers/BooksController.kt @@ -114,6 +114,13 @@ class BooksController( return ResponseEntity.noContent().build() } + @ApiResponse(responseCode = "204", description = "Deleted the series") + @DeleteMapping(path = ["/series/{seriesId}"]) + fun deleteSeriesById(@PathVariable("seriesId") seriesId: UUID): ResponseEntity { + repository.deleteSeriesById(seriesId) + return ResponseEntity.noContent().build() + } + @ApiResponse(responseCode = "204", description = "Deleted the author from the book") @DeleteMapping(path = ["/books/{bookId}/authors/{authorId}"]) fun deleteAuthorFromBook( @@ -225,6 +232,16 @@ class BooksController( @PageableDefault(page = 0, size = 20, direction = Sort.Direction.ASC, sort = ["name"]) @ParameterObject pageable: Pageable, ): Page = repository.findOrphanTags(pageable) + @GetMapping(path = ["/authors/orphans"]) + fun orphanAuthors( + @PageableDefault(page = 0, size = 20, direction = Sort.Direction.ASC, sort = ["name"]) @ParameterObject pageable: Pageable, + ): Page = repository.findOrphanAuthors(pageable) + + @GetMapping(path = ["/series/orphans"]) + fun orphanSeries( + @PageableDefault(page = 0, size = 20, direction = Sort.Direction.ASC, sort = ["name"]) @ParameterObject pageable: Pageable, + ): Page = repository.findOrphanSeries(pageable) + @GetMapping(path = ["/series"]) fun series( @RequestParam(name = "name", required = false) name: String?, diff --git a/src/main/kotlin/io/github/bayang/jelu/dao/BookRepository.kt b/src/main/kotlin/io/github/bayang/jelu/dao/BookRepository.kt index 62691e72..257f9dd1 100644 --- a/src/main/kotlin/io/github/bayang/jelu/dao/BookRepository.kt +++ b/src/main/kotlin/io/github/bayang/jelu/dao/BookRepository.kt @@ -599,6 +599,42 @@ class BookRepository( ) } + fun findOrphanAuthors(pageable: Pageable): Page { + val query = AuthorTable.join(BookAuthors, JoinType.LEFT) + .selectAll() + .groupBy(AuthorTable.name) + .having { BookAuthors.author.count() eq(0) } + + query.withDistinct(true) + val total = query.count() + query.limit(pageable.pageSize, pageable.offset) + val orders: Array, SortOrder>> = parseSorts(pageable.sort, Pair(AuthorTable.name, SortOrder.ASC_NULLS_LAST), AuthorTable) + query.orderBy(*orders) + return PageImpl( + query.map { resultRow -> Author.wrapRow(resultRow) }, + pageable, + total, + ) + } + + fun findOrphanSeries(pageable: Pageable): Page { + val query = SeriesTable.join(BookSeries, JoinType.LEFT) + .selectAll() + .groupBy(SeriesTable.name) + .having { BookSeries.series.count() eq(0) } + + query.withDistinct(true) + val total = query.count() + query.limit(pageable.pageSize, pageable.offset) + val orders: Array, SortOrder>> = parseSorts(pageable.sort, Pair(SeriesTable.name, SortOrder.ASC_NULLS_LAST), SeriesTable) + query.orderBy(*orders) + return PageImpl( + query.map { resultRow -> Series.wrapRow(resultRow) }, + pageable, + total, + ) + } + fun findAuthorBooksById(authorId: UUID, user: UserDto, pageable: Pageable, libaryFilter: LibraryFilter = LibraryFilter.ANY, role: Role = Role.ANY): Page { logger.trace { "role $role" } val booksWithSameIdAndUserHasUserbook = BookTable.join(UserBookTable, JoinType.LEFT) diff --git a/src/main/kotlin/io/github/bayang/jelu/service/BookService.kt b/src/main/kotlin/io/github/bayang/jelu/service/BookService.kt index 1251e190..d89f4e3d 100644 --- a/src/main/kotlin/io/github/bayang/jelu/service/BookService.kt +++ b/src/main/kotlin/io/github/bayang/jelu/service/BookService.kt @@ -387,6 +387,16 @@ class BookService( return bookRepository.findOrphanTags(pageable).map { tag -> tag.toTagDto() } } + @Transactional + fun findOrphanAuthors(pageable: Pageable): Page { + return bookRepository.findOrphanAuthors(pageable).map { author -> author.toAuthorDto() } + } + + @Transactional + fun findOrphanSeries(pageable: Pageable): Page { + return bookRepository.findOrphanSeries(pageable).map { series -> series.toSeriesDto() } + } + @Transactional fun findTagById(tagId: UUID, user: UserDto): TagDto { return bookRepository.findTagById(tagId).toTagDto() diff --git a/src/test/kotlin/io/github/bayang/jelu/service/BookServiceTest.kt b/src/test/kotlin/io/github/bayang/jelu/service/BookServiceTest.kt index f47aa9e0..3c084eea 100644 --- a/src/test/kotlin/io/github/bayang/jelu/service/BookServiceTest.kt +++ b/src/test/kotlin/io/github/bayang/jelu/service/BookServiceTest.kt @@ -2440,6 +2440,67 @@ class BookServiceTest( Assertions.assertEquals(2, orphanTags.totalElements) } + @Test + fun testFindOrphanAuthors() { + val createBook = bookDto(withTags = true) + val savedBook = bookService.save(createBook, null) + Assertions.assertEquals(1, savedBook.authors?.size) + var entitiesIds = luceneHelper.searchEntitiesIds("tag:fantasy", LuceneEntity.Book) + Assertions.assertEquals(1, entitiesIds?.size) + entitiesIds = luceneHelper.searchEntitiesIds("author:author", LuceneEntity.Book) + Assertions.assertEquals(1, entitiesIds?.size) + entitiesIds = luceneHelper.searchEntitiesIds("title1", LuceneEntity.Book) + Assertions.assertEquals(1, entitiesIds?.size) + val author = authorDto("jean jacques") + val res = bookService.save(author) + Assertions.assertNotNull(res) + Assertions.assertEquals(author.name, res.name) + val p = bookService.findOrphanAuthors(Pageable.ofSize(20)) + Assertions.assertEquals(1, p.totalElements) + Assertions.assertEquals(author.name, p.content[0].name) + } + + @Test + fun testFindOrphanSeries() { + val s1 = SeriesOrderDto(name = "series 1", numberInSeries = 1.0, seriesId = null) + val s2 = SeriesOrderDto(name = "series2", numberInSeries = 1.0, seriesId = null) + val res: BookDto = bookService.save( + BookCreateDto( + id = null, + title = "title1", + isbn10 = "", + isbn13 = "", + summary = "", + image = "", + publisher = "", + pageCount = 50, + publishedDate = "", + authors = emptyList(), + tags = emptyList(), + goodreadsId = "", + googleId = "", + librarythingId = "", + language = "", + amazonId = "", + series = listOf(s1, s2), + ), + null, + ) + Assertions.assertNotNull(res.id) + val found = bookService.findBookById(res.id!!) + Assertions.assertEquals(found.id, res.id) + Assertions.assertEquals(found.authors, res.authors) + Assertions.assertEquals(found.title, res.title) + Assertions.assertEquals(found.isbn10, res.isbn10) + Assertions.assertEquals(found.pageCount, res.pageCount) + Assertions.assertEquals(0, File(jeluProperties.files.images).listFiles().size) + Assertions.assertEquals(2, res.series?.size) + val orphan = bookService.saveSeries(SeriesCreateDto("series orphan", 1.3, "desc"), user()) + val r = bookService.findOrphanSeries(Pageable.ofSize(20)) + Assertions.assertEquals(1, r.totalElements) + Assertions.assertEquals(orphan.name, r.content[0].name) + } + @Test fun testFindOrphanTags() { bookService.findAllTags(null, Pageable.ofSize(20)).content.forEach {