├── gradle.properties ├── settings.gradle.kts ├── src ├── jelu-ui │ ├── .env.development │ ├── .gitignore │ ├── public │ │ ├── favicon.ico │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── apple-touch-icon.png │ │ ├── android-chrome-192x192.png │ │ └── android-chrome-512x512.png │ ├── src │ │ ├── model │ │ │ ├── Pair.ts │ │ │ ├── oauth-client-dto.ts │ │ │ ├── Quote.ts │ │ │ ├── Role.ts │ │ │ ├── MetadataError.ts │ │ │ ├── Tag.ts │ │ │ ├── JeluError.ts │ │ │ ├── LibraryFilter.ts │ │ │ ├── Shelf.ts │ │ │ ├── PluginInfo.ts │ │ │ ├── MetadataRequest.ts │ │ │ ├── autocomplete-wrapper.ts │ │ │ ├── ServerSettings.ts │ │ │ ├── DirectoryListing.ts │ │ │ ├── custom-list.ts │ │ │ ├── ImportConfiguration.ts │ │ │ ├── Author.ts │ │ │ ├── YearStats.ts │ │ │ ├── Series.ts │ │ │ ├── UserMessage.ts │ │ │ ├── WikipediaSearchResult.ts │ │ │ ├── BookQuote.ts │ │ │ ├── Page.ts │ │ │ ├── Review.ts │ │ │ ├── Metadata.ts │ │ │ ├── WikipediaPageResult.ts │ │ │ ├── User.ts │ │ │ ├── ReadingEvent.ts │ │ │ └── Book.ts │ │ ├── assets │ │ │ ├── logo.png │ │ │ ├── atwriter.ttf │ │ │ ├── placeholder_asset.jpg │ │ │ ├── placeholer_author.jpg │ │ │ └── placeholder_asset_bak.png │ │ ├── env.d.ts │ │ ├── declarations.d.ts │ │ ├── components │ │ │ ├── FormField.vue │ │ │ ├── ClosableBadge.vue │ │ │ ├── ProfilePage.vue │ │ │ ├── QuotesDisplay.vue │ │ │ ├── ReviewDetail.vue │ │ │ ├── DataAdmin.vue │ │ │ ├── BookQuotes.vue │ │ │ ├── SortFilterBar.vue │ │ │ ├── BookReviews.vue │ │ │ ├── ReviewBookCard.vue │ │ │ └── AdminBase.vue │ │ ├── utils │ │ │ └── StringUtils.ts │ │ ├── urls.ts │ │ ├── composables │ │ │ ├── dates.ts │ │ │ ├── events.ts │ │ │ ├── sort.ts │ │ │ ├── pagination.ts │ │ │ └── bulkEdition.ts │ │ └── main.ts │ ├── tsconfig.json │ ├── vuex.d.ts │ ├── index.html │ ├── eslint.config.mjs │ ├── vite.config.mts │ ├── package.json │ └── tailwind.config.mjs ├── test │ ├── resources │ │ ├── csv-import │ │ │ ├── isbns-import.txt │ │ │ ├── goodreads_library_export_one_line.csv │ │ │ ├── goodreads_library_export_one_line_modified.csv │ │ │ ├── goodreads-duplicate-events.csv │ │ │ ├── goodreads_library_export-2022.csv │ │ │ └── goodreads1.csv │ │ ├── test-cover.jpg │ │ ├── metadata │ │ │ ├── pg72155-images.epub │ │ │ ├── pg72155-images-3.epub │ │ │ └── Unknown Author - La femme du bois - Abraham Merritt.epub │ │ ├── csv-export │ │ │ └── expected.csv │ │ └── application-test.yml │ └── kotlin │ │ └── io │ │ └── github │ │ └── bayang │ │ └── jelu │ │ ├── JeluApplicationTests.kt │ │ ├── service │ │ ├── LifeCycleServiceTest.kt │ │ ├── metadata │ │ │ └── FileMetadataServiceTest.kt │ │ └── UserServiceTest.kt │ │ └── TestHelpers.kt └── main │ ├── kotlin │ └── io │ │ └── github │ │ └── bayang │ │ └── jelu │ │ ├── dto │ │ ├── PluginInfo.kt │ │ ├── Role.kt │ │ ├── MetadataError.kt │ │ ├── LibraryFilter.kt │ │ ├── QuoteDto.kt │ │ ├── LoginHistoryInfoDto.kt │ │ ├── LifeCycleDto.kt │ │ ├── ShelfDto.kt │ │ ├── CustomListDto.kt │ │ ├── ServerSettingsDto.kt │ │ ├── ReadStatsDto.kt │ │ ├── WikipediaSearchResultElement.kt │ │ ├── BookQuoteDto.kt │ │ ├── UserMessageDto.kt │ │ ├── ReviewDto.kt │ │ ├── UserDto.kt │ │ ├── ImportDto.kt │ │ ├── WikipediaPageResult.kt │ │ ├── MetadataDto.kt │ │ └── ReadingEventDto.kt │ │ ├── utils │ │ ├── FileUtils.kt │ │ ├── PluginInfoComparator.kt │ │ ├── StringUtils.kt │ │ ├── DateUtils.kt │ │ └── ImageUtils.kt │ │ ├── service │ │ ├── quotes │ │ │ └── IQuoteProvider.kt │ │ ├── metadata │ │ │ ├── providers │ │ │ │ ├── IMetaDataProvider.kt │ │ │ │ ├── DebugMetadataProvider.kt │ │ │ │ └── Wikidata.kt │ │ │ ├── OpfTagsConstants.kt │ │ │ ├── PluginInfoHolder.kt │ │ │ ├── FetchMetadataService.kt │ │ │ └── WikipediaService.kt │ │ ├── LifeCycleService.kt │ │ ├── ShelfService.kt │ │ ├── DownloadService.kt │ │ ├── UserMessageService.kt │ │ ├── BookQuoteService.kt │ │ ├── ReviewService.kt │ │ ├── ImportService.kt │ │ ├── ReadingEventService.kt │ │ └── AppLifecycleAware.kt │ │ ├── errors │ │ ├── JeluValidationException.kt │ │ ├── JeluException.kt │ │ └── JeluAuthenticationException.kt │ │ ├── controllers │ │ ├── IndexController.kt │ │ ├── OAuth2Controller.kt │ │ ├── ServerSettingsController.kt │ │ ├── ShelvesController.kt │ │ ├── QuotesController.kt │ │ ├── UserMessagesController.kt │ │ └── ImportController.kt │ │ ├── config │ │ ├── UserAgentWebAuthenticationDetailsSource.kt │ │ ├── LuceneConfiguration.kt │ │ ├── SmartHttpSessionIdResolver.kt │ │ ├── OpenApiConfig.kt │ │ ├── UserAgentWebAuthenticationDetails.kt │ │ ├── GlobalConfig.kt │ │ ├── LdapConfig.kt │ │ ├── AuthHeaderFilter.kt │ │ ├── JeluProperties.kt │ │ └── WebMvcConfig.kt │ │ ├── dao │ │ ├── LifeCycleRepository.kt │ │ ├── LifeCycleTable.kt │ │ ├── ShelfTable.kt │ │ ├── TagTable.kt │ │ ├── SeriesRatingTable.kt │ │ ├── ShelfRepository.kt │ │ ├── UserTable.kt │ │ ├── CustomListTable.kt │ │ ├── BookQuoteTable.kt │ │ ├── UserMessageTable.kt │ │ ├── ReviewTable.kt │ │ ├── UserRepository.kt │ │ └── ReadingEventTable.kt │ │ ├── JeluApplication.kt │ │ ├── search │ │ ├── MultiLingualNGramAnalyzer.kt │ │ └── MultiLingualAnalyzer.kt │ │ └── security │ │ └── oauth2 │ │ └── GithubOAuth2UserService.kt │ ├── resources │ ├── META-INF │ │ └── spring.factories │ ├── banner.txt │ ├── application-ddl.yml │ ├── application.yml │ ├── users.ldif │ └── application-dev.yml │ └── java │ └── io │ └── github │ └── bayang │ └── jelu │ └── dialect │ ├── SqliteDialect.java │ └── SqliteDialectProvider.java ├── .editorconfig ├── screenshots ├── embed.png ├── book-list.png ├── home-page.png ├── author-page.png ├── review_modal.jpg ├── book-detail-1.png ├── auto-import-empty.png ├── auto-import-filled.png ├── book-detail-events.png ├── auto-import-edit-result.png └── auto-import-preview-result.png ├── .husky ├── pre-commit └── commit-msg ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── crowdin.yml ├── ci ├── prepare-release.sh ├── docker-common.sh ├── publish-dockerhub.sh └── prepare-dockerhub.sh ├── BUILD.md ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore └── LICENSE /gradle.properties: -------------------------------------------------------------------------------- 1 | version=0.75.2 2 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "jelu" 2 | -------------------------------------------------------------------------------- /src/jelu-ui/.env.development: -------------------------------------------------------------------------------- 1 | VITE_API_URL=http://localhost:11111/api/v1 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [{*.kts,*.kt}] 2 | ktlint_standard_no-wildcard-imports = disabled 3 | -------------------------------------------------------------------------------- /src/jelu-ui/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | -------------------------------------------------------------------------------- /screenshots/embed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bayang/jelu/HEAD/screenshots/embed.png -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | ./gradlew ktlintCheck 5 | -------------------------------------------------------------------------------- /screenshots/book-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bayang/jelu/HEAD/screenshots/book-list.png -------------------------------------------------------------------------------- /screenshots/home-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bayang/jelu/HEAD/screenshots/home-page.png -------------------------------------------------------------------------------- /src/test/resources/csv-import/isbns-import.txt: -------------------------------------------------------------------------------- 1 | 978-2-38163-047-2 2 | 0153527692 3 | 123abc 4 | -------------------------------------------------------------------------------- /screenshots/author-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bayang/jelu/HEAD/screenshots/author-page.png -------------------------------------------------------------------------------- /screenshots/review_modal.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bayang/jelu/HEAD/screenshots/review_modal.jpg -------------------------------------------------------------------------------- /screenshots/book-detail-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bayang/jelu/HEAD/screenshots/book-detail-1.png -------------------------------------------------------------------------------- /src/jelu-ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bayang/jelu/HEAD/src/jelu-ui/public/favicon.ico -------------------------------------------------------------------------------- /src/jelu-ui/src/model/Pair.ts: -------------------------------------------------------------------------------- 1 | export interface Pair { 2 | name: string, 3 | value: string, 4 | } 5 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bayang/jelu/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /screenshots/auto-import-empty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bayang/jelu/HEAD/screenshots/auto-import-empty.png -------------------------------------------------------------------------------- /src/jelu-ui/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bayang/jelu/HEAD/src/jelu-ui/src/assets/logo.png -------------------------------------------------------------------------------- /src/test/resources/test-cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bayang/jelu/HEAD/src/test/resources/test-cover.jpg -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /screenshots/auto-import-filled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bayang/jelu/HEAD/screenshots/auto-import-filled.png -------------------------------------------------------------------------------- /screenshots/book-detail-events.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bayang/jelu/HEAD/screenshots/book-detail-events.png -------------------------------------------------------------------------------- /src/jelu-ui/src/assets/atwriter.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bayang/jelu/HEAD/src/jelu-ui/src/assets/atwriter.ttf -------------------------------------------------------------------------------- /src/jelu-ui/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bayang/jelu/HEAD/src/jelu-ui/public/favicon-16x16.png -------------------------------------------------------------------------------- /src/jelu-ui/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bayang/jelu/HEAD/src/jelu-ui/public/favicon-32x32.png -------------------------------------------------------------------------------- /screenshots/auto-import-edit-result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bayang/jelu/HEAD/screenshots/auto-import-edit-result.png -------------------------------------------------------------------------------- /src/jelu-ui/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bayang/jelu/HEAD/src/jelu-ui/public/apple-touch-icon.png -------------------------------------------------------------------------------- /screenshots/auto-import-preview-result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bayang/jelu/HEAD/screenshots/auto-import-preview-result.png -------------------------------------------------------------------------------- /src/jelu-ui/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bayang/jelu/HEAD/src/jelu-ui/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /src/jelu-ui/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bayang/jelu/HEAD/src/jelu-ui/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /src/jelu-ui/src/assets/placeholder_asset.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bayang/jelu/HEAD/src/jelu-ui/src/assets/placeholder_asset.jpg -------------------------------------------------------------------------------- /src/jelu-ui/src/assets/placeholer_author.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bayang/jelu/HEAD/src/jelu-ui/src/assets/placeholer_author.jpg -------------------------------------------------------------------------------- /src/jelu-ui/src/model/oauth-client-dto.ts: -------------------------------------------------------------------------------- 1 | export interface OAuth2ClientDto { 2 | name: string, 3 | registrationId: string, 4 | } 5 | -------------------------------------------------------------------------------- /src/jelu-ui/src/assets/placeholder_asset_bak.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bayang/jelu/HEAD/src/jelu-ui/src/assets/placeholder_asset_bak.png -------------------------------------------------------------------------------- /src/test/resources/metadata/pg72155-images.epub: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bayang/jelu/HEAD/src/test/resources/metadata/pg72155-images.epub -------------------------------------------------------------------------------- /src/test/resources/metadata/pg72155-images-3.epub: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bayang/jelu/HEAD/src/test/resources/metadata/pg72155-images-3.epub -------------------------------------------------------------------------------- /src/jelu-ui/src/model/Quote.ts: -------------------------------------------------------------------------------- 1 | export interface Quote { 2 | content: string, 3 | author?: string, 4 | origin?: string, 5 | link?: string, 6 | } 7 | -------------------------------------------------------------------------------- /crowdin.yml: -------------------------------------------------------------------------------- 1 | files: 2 | - source: /src/jelu-ui/src/locales/en.json 3 | translation: /src/jelu-ui/src/locales/%two_letters_code%.json 4 | commit_message: '[skip ci]' 5 | -------------------------------------------------------------------------------- /src/jelu-ui/src/model/Role.ts: -------------------------------------------------------------------------------- 1 | export enum Role { 2 | AUTHOR = 'AUTHOR', 3 | TRANSLATOR = 'TRANSLATOR', 4 | NARRATOR = 'NARRATOR', 5 | ANY = 'ANY' 6 | } 7 | -------------------------------------------------------------------------------- /src/jelu-ui/src/model/MetadataError.ts: -------------------------------------------------------------------------------- 1 | export enum MetadataError { 2 | EXIT_CODE_NOT_ZERO = 'EXIT_CODE_NOT_ZERO', 3 | EXCEPTION_CAUGHT = 'EXCEPTION_CAUGHT', 4 | } 5 | -------------------------------------------------------------------------------- /src/jelu-ui/src/model/Tag.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface Tag { 3 | id?: string, 4 | creationDate?: string, 5 | name: string, 6 | modificationDate?: string, 7 | } 8 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dto/PluginInfo.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dto 2 | 3 | data class PluginInfo( 4 | val name: String, 5 | val order: Int, 6 | ) 7 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/spring.factories: -------------------------------------------------------------------------------- 1 | org.springframework.data.jdbc.repository.config.DialectResolver$JdbcDialectProvider=io.github.bayang.jelu.dialect.SqliteDialectProvider -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dto/Role.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dto 2 | 3 | enum class Role { 4 | AUTHOR, 5 | TRANSLATOR, 6 | NARRATOR, 7 | ANY, 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dto/MetadataError.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dto 2 | 3 | enum class MetadataError { 4 | EXIT_CODE_NOT_ZERO, 5 | EXCEPTION_CAUGHT, 6 | } 7 | -------------------------------------------------------------------------------- /src/jelu-ui/src/model/JeluError.ts: -------------------------------------------------------------------------------- 1 | export class JeluError extends Error{ 2 | 3 | constructor(message: string) { 4 | super(message); 5 | this.message = message; 6 | } 7 | } -------------------------------------------------------------------------------- /src/jelu-ui/src/model/LibraryFilter.ts: -------------------------------------------------------------------------------- 1 | export enum LibraryFilter { 2 | ONLY_USER_BOOKS = 'ONLY_USER_BOOKS', 3 | ONLY_NON_USER_BOOKS = 'ONLY_NON_USER_BOOKS', 4 | ANY = 'ANY' 5 | } 6 | -------------------------------------------------------------------------------- /src/jelu-ui/src/model/Shelf.ts: -------------------------------------------------------------------------------- 1 | export interface Shelf { 2 | id?: string, 3 | creationDate?: string, 4 | modificationDate?: Date, 5 | name: string, 6 | targetId: string 7 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dto/LibraryFilter.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dto 2 | 3 | enum class LibraryFilter { 4 | ONLY_USER_BOOKS, 5 | ONLY_NON_USER_BOOKS, 6 | ANY, 7 | } 8 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/utils/FileUtils.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.utils 2 | 3 | fun imageName(title: String, bookId: String, extension: String): String = "$title-$bookId.$extension" 4 | -------------------------------------------------------------------------------- /src/test/resources/metadata/Unknown Author - La femme du bois - Abraham Merritt.epub: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bayang/jelu/HEAD/src/test/resources/metadata/Unknown Author - La femme du bois - Abraham Merritt.epub -------------------------------------------------------------------------------- /src/jelu-ui/src/model/PluginInfo.ts: -------------------------------------------------------------------------------- 1 | export interface PluginInfo { 2 | name: string, 3 | order: number, 4 | } 5 | export interface PluginInfoOrder { 6 | name: string, 7 | order: number, 8 | enabled: boolean 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dto/QuoteDto.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dto 2 | 3 | data class QuoteDto( 4 | var content: String, 5 | var author: String?, 6 | var origin: String?, 7 | var link: String?, 8 | ) 9 | -------------------------------------------------------------------------------- /src/jelu-ui/src/model/MetadataRequest.ts: -------------------------------------------------------------------------------- 1 | import { PluginInfo } from "./PluginInfo"; 2 | 3 | export interface MetadataRequest { 4 | title?: string, 5 | isbn?:string, 6 | authors?: string, 7 | plugins?: Array, 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dto/LoginHistoryInfoDto.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dto 2 | 3 | data class LoginHistoryInfoDto( 4 | val ip: String?, 5 | val userAgent: String?, 6 | val source: String?, 7 | val date: String?, 8 | ) 9 | -------------------------------------------------------------------------------- /src/jelu-ui/src/model/autocomplete-wrapper.ts: -------------------------------------------------------------------------------- 1 | import { Author } from "./Author"; 2 | import { SeriesOrder } from "./Series"; 3 | import { Tag } from "./Tag"; 4 | 5 | export interface Wrapper { 6 | label: string, 7 | value: Author|Tag|SeriesOrder 8 | } 9 | -------------------------------------------------------------------------------- /src/main/resources/banner.txt: -------------------------------------------------------------------------------- 1 | 2 | ,--. ,------. ,--. ,--. ,--. 3 | | | | .---' | | | | | | 4 | ,--. | | | `--, | | | | | | 5 | | '-' / | `---. | '--. ' '-' ' 6 | `-----' `------' `-----' `-----' 7 | 8 | version : ${application.version} 9 | -------------------------------------------------------------------------------- /src/main/resources/application-ddl.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | liquibase: 3 | enabled: false 4 | drop-first: false 5 | datasource: 6 | url: jdbc:sqlite:${jelu.database.path}/exposed.db?foreign_keys=on; 7 | driver-class-name: org.sqlite.JDBC 8 | exposed: 9 | generate-ddl: true 10 | -------------------------------------------------------------------------------- /ci/prepare-release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Arguments: 3 | # 1: next version 4 | # 2: channel 5 | 6 | # Build jar 7 | ./gradlew copyWebDist 8 | ./gradlew assemble 9 | #./gradlew generateOpenApiDocs 10 | 11 | # Prepare Dockerhub release 12 | source "$(dirname "$0")/prepare-dockerhub.sh" $1 $2 13 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dto/LifeCycleDto.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dto 2 | 3 | import java.time.Instant 4 | 5 | data class LifeCycleDto( 6 | val id: Long, 7 | val creationDate: Instant?, 8 | val modificationDate: Instant?, 9 | val seriesMigrated: Boolean, 10 | ) 11 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /src/jelu-ui/src/model/ServerSettings.ts: -------------------------------------------------------------------------------- 1 | import { PluginInfo } from "./PluginInfo"; 2 | 3 | export interface ServerSettings { 4 | metadataFetchEnabled: boolean, 5 | metadataFetchCalibreEnabled: boolean, 6 | appVersion: string, 7 | ldapEnabled: boolean, 8 | metadataPlugins: Array 9 | } 10 | -------------------------------------------------------------------------------- /src/test/kotlin/io/github/bayang/jelu/JeluApplicationTests.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu 2 | 3 | import org.junit.jupiter.api.Test 4 | import org.springframework.boot.test.context.SpringBootTest 5 | 6 | @SpringBootTest 7 | class JeluApplicationTests { 8 | 9 | @Test 10 | fun contextLoads() { 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /ci/docker-common.sh: -------------------------------------------------------------------------------- 1 | # Arguments 2 | # 1: next version 3 | # 2: channel 4 | 5 | export DOCKER_CLI_EXPERIMENTAL=enabled 6 | PLATFORMS=linux/amd64,linux/arm/v7,linux/arm64/v8 7 | 8 | if [ -z "$2" ]; then 9 | DOCKER_CHANNEL="latest" 10 | else 11 | DOCKER_CHANNEL=$2 12 | fi 13 | 14 | echo "DockerHub channel: $DOCKER_CHANNEL" 15 | -------------------------------------------------------------------------------- /src/jelu-ui/src/model/DirectoryListing.ts: -------------------------------------------------------------------------------- 1 | export interface DirectoryListing { 2 | parent?: string, 3 | directories: Array, 4 | } 5 | export interface Path { 6 | type: string, 7 | name: string, 8 | path: string 9 | } 10 | export interface DirectoryRequest { 11 | path: string, 12 | reason: string, 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/service/quotes/IQuoteProvider.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.service.quotes 2 | 3 | import io.github.bayang.jelu.dto.QuoteDto 4 | import reactor.core.publisher.Mono 5 | 6 | interface IQuoteProvider { 7 | 8 | fun quotes(query: String?): Mono> 9 | 10 | fun random(): Mono> 11 | } 12 | -------------------------------------------------------------------------------- /src/jelu-ui/src/model/custom-list.ts: -------------------------------------------------------------------------------- 1 | export interface CustomList { 2 | id?: string, 3 | name: string, 4 | tags: string, 5 | public: boolean, 6 | actionable: boolean, 7 | creationDate?: string, 8 | modificationDate?: string, 9 | } 10 | 11 | export interface CustomListRemoveDto{ 12 | books: Array, 13 | tags: Array, 14 | } 15 | -------------------------------------------------------------------------------- /src/jelu-ui/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | declare module '*.vue' { 5 | import { DefineComponent } from 'vue' 6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types 7 | const component: DefineComponent<{}, {}, any> 8 | export default component 9 | } 10 | -------------------------------------------------------------------------------- /src/jelu-ui/src/model/ImportConfiguration.ts: -------------------------------------------------------------------------------- 1 | export interface ImportConfigurationDto{ 2 | shouldFetchMetadata: boolean, 3 | shouldFetchCovers: boolean, 4 | importSource: ImportSource 5 | } 6 | 7 | export enum ImportSource { 8 | GOODREADS = 'GOODREADS', 9 | STORYGRAPH = 'STORYGRAPH', 10 | LIBRARYTHING = 'LIBRARYTHING', 11 | ISBN_LIST = 'ISBN_LIST' 12 | } 13 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/errors/JeluValidationException.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.errors 2 | 3 | class JeluValidationException : java.lang.Exception { 4 | constructor() : super() 5 | constructor(message: String) : super(message) 6 | constructor(message: String, cause: Throwable) : super(message, cause) 7 | constructor(cause: Throwable) : super(cause) 8 | } 9 | -------------------------------------------------------------------------------- /ci/publish-dockerhub.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Arguments: 3 | # 1: next version 4 | # 2: channel 5 | 6 | source "$(dirname "$0")/docker-common.sh" $1 $2 7 | 8 | # Push docker images (built previously) 9 | docker buildx build \ 10 | --platform $PLATFORMS \ 11 | --tag wabayang/jelu:$DOCKER_CHANNEL \ 12 | --tag wabayang/jelu:$1 \ 13 | --file ./Dockerfile . \ 14 | --push 15 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/errors/JeluException.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.errors 2 | 3 | import java.lang.Exception 4 | 5 | class JeluException : Exception { 6 | constructor() : super() 7 | constructor(message: String) : super(message) 8 | constructor(message: String, cause: Throwable) : super(message, cause) 9 | constructor(cause: Throwable) : super(cause) 10 | } 11 | -------------------------------------------------------------------------------- /ci/prepare-dockerhub.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Arguments: 3 | # 1: next version 4 | # 2: channel 5 | 6 | source "$(dirname "$0")/docker-common.sh" $1 $2 7 | 8 | # Unpack fat jar 9 | ./gradlew unpack 10 | 11 | # Build docker images (no push) 12 | docker buildx build \ 13 | --platform $PLATFORMS \ 14 | --tag wabayang/jelu:$DOCKER_CHANNEL \ 15 | --tag wabayang/jelu:$1 \ 16 | --file ./Dockerfile . 17 | -------------------------------------------------------------------------------- /src/jelu-ui/src/declarations.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'v-tooltip'; 2 | 3 | declare module 'vuejs-sidebar-menu'; 4 | 5 | declare module 'theme-change'; 6 | 7 | declare module '@kangc/v-md-editor'; 8 | declare module '@kangc/v-md-editor/lib/theme/github.js'; 9 | declare module '@kangc/v-md-editor/lib/lang/en-US'; 10 | declare module '@kangc/v-md-editor/lib/preview'; 11 | 12 | declare module '@teckel/vue-barcode-reader'; 13 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dto/ShelfDto.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dto 2 | 3 | import java.time.Instant 4 | import java.util.UUID 5 | 6 | data class ShelfDto( 7 | val id: UUID?, 8 | val creationDate: Instant?, 9 | val modificationDate: Instant?, 10 | val name: String, 11 | val targetId: UUID, 12 | ) 13 | data class CreateShelfDto( 14 | val name: String, 15 | val targetId: UUID, 16 | ) 17 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/errors/JeluAuthenticationException.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.errors 2 | 3 | import java.lang.Exception 4 | 5 | class JeluAuthenticationException : Exception { 6 | constructor() : super() 7 | constructor(message: String) : super(message) 8 | constructor(message: String, cause: Throwable) : super(message, cause) 9 | constructor(cause: Throwable) : super(cause) 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/controllers/IndexController.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.controllers 2 | 3 | import org.springframework.stereotype.Controller 4 | import org.springframework.ui.Model 5 | import org.springframework.web.bind.annotation.GetMapping 6 | 7 | @Controller 8 | class IndexController { 9 | @GetMapping("/") 10 | fun index(model: Model): String { 11 | return "/index.html" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/utils/PluginInfoComparator.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.utils 2 | 3 | import io.github.bayang.jelu.dto.PluginInfo 4 | 5 | class PluginInfoComparator { 6 | 7 | companion object : Comparator { 8 | override fun compare(a: PluginInfo, b: PluginInfo): Int = when { 9 | a.order != b.order -> b.order - a.order 10 | else -> a.name.compareTo(b.name) 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/jelu-ui/src/model/Author.ts: -------------------------------------------------------------------------------- 1 | export interface Author { 2 | id?: string, 3 | creationDate?: string, 4 | name: string, 5 | modificationDate?: string, 6 | biography?: string, 7 | dateOfBirth?: Date, 8 | dateOfDeath?: Date, 9 | image?: string, 10 | notes?: string, 11 | officialPage?: string, 12 | wikipediaPage?: string, 13 | goodreadsPage?: string, 14 | twitterPage?: string, 15 | facebookPage?: string, 16 | instagramPage?: string, 17 | } 18 | -------------------------------------------------------------------------------- /src/jelu-ui/src/model/YearStats.ts: -------------------------------------------------------------------------------- 1 | export interface YearStats { 2 | dropped: number, 3 | finished: number, 4 | year: number, 5 | pageCount: number 6 | } 7 | 8 | export interface MonthStats { 9 | dropped: number, 10 | finished: number, 11 | year: number, 12 | month: number, 13 | pageCount: number 14 | } 15 | 16 | export interface TotalsStats { 17 | read: number, 18 | unread: number, 19 | dropped: number, 20 | total: number, 21 | } 22 | -------------------------------------------------------------------------------- /src/jelu-ui/src/model/Series.ts: -------------------------------------------------------------------------------- 1 | export interface SeriesOrder { 2 | seriesId?: string, 3 | name: string, 4 | numberInSeries? : number|null, 5 | } 6 | export interface Series { 7 | id?: string, 8 | creationDate?: string, 9 | name: string, 10 | modificationDate?: string, 11 | avgRating?: number, 12 | userRating?: number, 13 | description?: string, 14 | } 15 | export interface SeriesUpdate { 16 | name?: string, 17 | description?: string, 18 | rating?: number, 19 | } 20 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dto/CustomListDto.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dto 2 | 3 | import java.time.Instant 4 | import java.util.UUID 5 | 6 | data class CustomListDto( 7 | val id: UUID?, 8 | val name: String, 9 | val tags: String, 10 | val public: Boolean, 11 | val actionable: Boolean, 12 | val creationDate: Instant?, 13 | val modificationDate: Instant?, 14 | ) 15 | 16 | data class CustomListRemoveDto( 17 | val books: List, 18 | val tags: List, 19 | ) 20 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dto/ServerSettingsDto.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dto 2 | 3 | data class ServerSettingsDto( 4 | /** 5 | * Is there any metadata provider activated at all 6 | */ 7 | val metadataFetchEnabled: Boolean, 8 | /** 9 | * Is the calibre metadata provider activated 10 | */ 11 | val metadataFetchCalibreEnabled: Boolean, 12 | val appVersion: String, 13 | val ldapEnabled: Boolean, 14 | val metadataPlugins: List = listOf(), 15 | ) 16 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/service/metadata/providers/IMetaDataProvider.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.service.metadata.providers 2 | 3 | import io.github.bayang.jelu.dto.MetadataDto 4 | import io.github.bayang.jelu.dto.MetadataRequestDto 5 | import java.util.Optional 6 | 7 | interface IMetaDataProvider { 8 | fun fetchMetadata( 9 | metadataRequestDto: MetadataRequestDto, 10 | config: Map = mapOf(), 11 | ): Optional? 12 | 13 | fun name(): String 14 | } 15 | -------------------------------------------------------------------------------- /src/test/resources/csv-export/expected.csv: -------------------------------------------------------------------------------- 1 | Title,Author,ISBN,Publisher,Date Read,Shelves,Bookshelves,read_dates,tags,authors,isbn10,isbn13,owned,dropped_dates,currently_reading 2 | book1,test author,9781566199094,test-publisher,2022/02/10,currently-reading,sciencefiction fantasy,,"science fiction,fantasy","test author,author2 name",1566199093,9781566199094,,"2020/02/10,2021/02/10",2022/02/10 3 | book2,test author,9781566199094,test-publisher,2022/02/10,,,"2020/02/10,2021/02/10,2022/02/10",,test author,1566199093,9781566199094,true,, 4 | -------------------------------------------------------------------------------- /src/test/resources/csv-import/goodreads_library_export_one_line.csv: -------------------------------------------------------------------------------- 1 | Book Id,Title,Author,Author l-f,Additional Authors,ISBN,ISBN13,My Rating,Average Rating,Publisher,Binding,Number of Pages,Year Published,Original Publication Year,Date Read,Date Added,Bookshelves,Bookshelves with positions,Exclusive Shelf,My Review,Spoiler,Private Notes,Read Count,Owned Copies 2 | 58574687,Epidemie,Åsa Ericsdotter,"Ericsdotter, Åsa",,"=""3038802018""","=""9783038802013""",0,3.63,,Paperback,,2018,2016,,2021/11/09,to-read,to-read (#3),to-read,,,,0,1 3 | -------------------------------------------------------------------------------- /src/test/resources/csv-import/goodreads_library_export_one_line_modified.csv: -------------------------------------------------------------------------------- 1 | Book Id,Title,Author,Author l-f,Additional Authors,ISBN,ISBN13,My Rating,Average Rating,Publisher,Binding,Number of Pages,Year Published,Original Publication Year,Date Read,Date Added,Bookshelves,Bookshelves with positions,Exclusive Shelf,My Review,Spoiler,Private Notes,Read Count,Owned Copies 2 | 58574687,Epidemie,Åsa Ericsdotter,"Ericsdotter, Åsa",,"=""3038802018""","=""9783038802013""",0,3.63,,Paperback,,2015,2016,,2021/11/09,to-read,to-read (#3),to-read,,,,0,1 3 | -------------------------------------------------------------------------------- /src/test/resources/application-test.yml: -------------------------------------------------------------------------------- 1 | jelu: 2 | database: 3 | path: ${java.io.tmpdir} 4 | files: 5 | images: ${java.io.tmpdir} 6 | imports: ${java.io.tmpdir} 7 | resizeImages: false 8 | spring: 9 | liquibase: 10 | enabled: true 11 | drop-first: false 12 | change-log: classpath:liquibase.xml 13 | datasource: 14 | url: jdbc:sqlite::memory:?foreign_keys=on; 15 | username: test 16 | password: test 17 | driver-class-name: org.sqlite.JDBC 18 | generate-unique-name: true 19 | exposed: 20 | show-sql: true 21 | -------------------------------------------------------------------------------- /src/jelu-ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "useDefineForClassFields": true, 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "strict": true, 8 | "jsx": "preserve", 9 | "sourceMap": true, 10 | "resolveJsonModule": true, 11 | "esModuleInterop": true, 12 | "lib": ["esnext", "dom"], 13 | "skipLibCheck": true, 14 | "types": ["@intlify/unplugin-vue-i18n/messages"] 15 | }, 16 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "vuex.d.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /src/jelu-ui/src/model/UserMessage.ts: -------------------------------------------------------------------------------- 1 | export interface UserMessage { 2 | id?: string, 3 | creationDate?: string, 4 | modificationDate?: Date, 5 | category: MessageCategory, 6 | message?: string, 7 | link?: string, 8 | read?: boolean 9 | } 10 | 11 | export interface UpdateUserMessage { 12 | category?: MessageCategory, 13 | message?: string, 14 | link?: string, 15 | read?: boolean 16 | } 17 | 18 | export enum MessageCategory { 19 | SUCCESS = 'SUCCESS', 20 | INFO = 'INFO', 21 | WARNING = 'WARNING', 22 | ERROR = 'ERROR' 23 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/config/UserAgentWebAuthenticationDetailsSource.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.config 2 | 3 | import jakarta.servlet.http.HttpServletRequest 4 | import org.springframework.security.web.authentication.WebAuthenticationDetailsSource 5 | import org.springframework.stereotype.Component 6 | 7 | @Component 8 | class UserAgentWebAuthenticationDetailsSource : WebAuthenticationDetailsSource() { 9 | override fun buildDetails(context: HttpServletRequest): UserAgentWebAuthenticationDetails = 10 | UserAgentWebAuthenticationDetails(context) 11 | } 12 | -------------------------------------------------------------------------------- /src/jelu-ui/src/model/WikipediaSearchResult.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | export interface WikipediaSearchResultElement { 4 | id: number, 5 | key: string, 6 | title: string, 7 | excerpt: string, 8 | description?: string, 9 | matchedTitle?: string, 10 | thumbnail?: Thumbnail 11 | } 12 | 13 | export interface Thumbnail { 14 | mimetype: string, 15 | size?: string, 16 | width: number, 17 | height: number, 18 | duration: number, 19 | url: string 20 | } 21 | 22 | export interface WikipediaSearchResult { 23 | pages: Array 24 | } 25 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dto/ReadStatsDto.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dto 2 | 3 | data class YearStatsDto( 4 | val dropped: Int = 0, 5 | val finished: Int = 0, 6 | val year: Int, 7 | val pageCount: Int = 0, 8 | ) 9 | 10 | data class MonthStatsDto( 11 | val dropped: Int = 0, 12 | val finished: Int = 0, 13 | val year: Int, 14 | val month: Int, 15 | val pageCount: Int = 0, 16 | ) 17 | 18 | data class TotalsStatsDto( 19 | val read: Long = 0, 20 | val unread: Long = 0, 21 | val dropped: Long = 0, 22 | val total: Long = 0, 23 | ) 24 | -------------------------------------------------------------------------------- /BUILD.md: -------------------------------------------------------------------------------- 1 | # building Jelu locally 2 | 3 | ## prerequisites 4 | 5 | - java 17 6 | - Jelu code source 7 | 8 | ## building 9 | 10 | just `cd` in the root of the repo and run : 11 | 12 | ```shell 13 | ./gradlew copyWebDist && ./gradlew assemble 14 | ``` 15 | 16 | Gradlew is included in the sources so you don't need anything else. 17 | 18 | Those commands will build the frontend and then the backend including the fat jar. 19 | 20 | You will find the jar in `build/libs`, it will be named `jelu-.jar` 21 | 22 | The jar is the same as the one that is published in github releases. 23 | 24 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dao/LifeCycleRepository.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dao 2 | 3 | import io.github.bayang.jelu.utils.nowInstant 4 | import org.springframework.stereotype.Repository 5 | 6 | @Repository 7 | class LifeCycleRepository { 8 | 9 | fun findLifeCycle(): LifeCycle { 10 | return LifeCycle.all().first() 11 | } 12 | 13 | fun setSeriesMigrated(newValue: Boolean): LifeCycle { 14 | val lifeCycle = findLifeCycle() 15 | lifeCycle.seriesMigrated = newValue 16 | lifeCycle.modificationDate = nowInstant() 17 | return lifeCycle 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/jelu-ui/src/model/BookQuote.ts: -------------------------------------------------------------------------------- 1 | import { Visibility } from "./Review" 2 | 3 | export interface BookQuote { 4 | id: string, 5 | creationDate: string, 6 | modificationDate: Date, 7 | text: string, 8 | user: string, 9 | book: string, 10 | visibility: Visibility, 11 | position?: string 12 | } 13 | 14 | export interface CreateBookQuoteDto { 15 | text: string, 16 | bookId: string, 17 | visibility: Visibility, 18 | position?: string 19 | } 20 | 21 | export interface UpdateBookQuoteDto { 22 | text?: string, 23 | visibility?: Visibility, 24 | position?: string 25 | } 26 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/utils/StringUtils.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.utils 2 | 3 | import com.github.slugify.Slugify 4 | import org.jsoup.Jsoup 5 | import org.jsoup.safety.Safelist 6 | 7 | private val whitelist: Safelist = Safelist.basic().addTags("h1", "h2", "h3", "h4", "h5", "h6") 8 | 9 | private val slugifier: Slugify = Slugify.builder().build() 10 | 11 | fun sanitizeHtml(input: String?): String { 12 | if (input.isNullOrBlank()) { 13 | return "" 14 | } 15 | return Jsoup.clean(input.trim(), whitelist) 16 | } 17 | 18 | fun slugify(input: String): String = slugifier.slugify(input) 19 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/service/metadata/OpfTagsConstants.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.service.metadata 2 | 3 | const val METADATA: String = "metadata" 4 | const val GUIDE: String = "guide" 5 | const val IDENTIFIER: String = "identifier" 6 | const val TITLE: String = "title" 7 | const val CREATOR: String = "creator" 8 | const val DATE: String = "date" 9 | const val DESCRIPTION: String = "description" 10 | const val PUBLISHER: String = "publisher" 11 | const val SUBJECT: String = "subject" 12 | const val LANGUAGE: String = "language" 13 | const val META: String = "meta" 14 | const val SOURCE: String = "source" 15 | -------------------------------------------------------------------------------- /src/jelu-ui/src/components/FormField.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 23 | 24 | 27 | -------------------------------------------------------------------------------- /src/jelu-ui/src/model/Page.ts: -------------------------------------------------------------------------------- 1 | export interface Page { 2 | content: T[], 3 | pageable: Pageable, 4 | last: boolean, 5 | totalPages: number, 6 | totalElements: number, 7 | first: boolean, 8 | size: number, 9 | number: number, 10 | sort: Sort, 11 | numberOfElements: number, 12 | empty: boolean 13 | } 14 | 15 | export interface Pageable { 16 | sort: Sort, 17 | pageNumber: number, 18 | pageSize: number, 19 | offset: number, 20 | paged: boolean, 21 | unpaged: boolean, 22 | } 23 | 24 | export interface Sort { 25 | sorted: boolean, 26 | empty: boolean, 27 | unsorted: boolean, 28 | } 29 | -------------------------------------------------------------------------------- /src/jelu-ui/src/utils/StringUtils.ts: -------------------------------------------------------------------------------- 1 | import { ReadingEventType } from "../model/ReadingEvent"; 2 | 3 | export class StringUtils { 4 | 5 | public static isNotBlank(param: string|null|undefined): boolean { 6 | return !this.isBlank(param) 7 | } 8 | 9 | public static isBlank(param: string|null|undefined): boolean { 10 | if (param !== undefined && param !== null && param.trim().length > 0) { 11 | return false; 12 | } 13 | return true; 14 | } 15 | 16 | public static readingEventTypeForValue(val: string): ReadingEventType { 17 | return ReadingEventType[val as keyof typeof ReadingEventType]; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/jelu-ui/vuex.d.ts: -------------------------------------------------------------------------------- 1 | import { RouteLocationNormalized } from 'vue-router'; 2 | import { Store } from 'vuex'; 3 | import { ServerSettings } from './src/model/ServerSettings'; 4 | import { User } from './src/model/User'; 5 | import { Shelf } from './src/model/Shelf' 6 | 7 | declare module '@vue/runtime-core' { 8 | // declare your own store states 9 | interface State { 10 | isLogged: boolean, 11 | isInitialSetup : boolean, 12 | user : User| null, 13 | serverSettings: ServerSettings, 14 | route: RouteLocationNormalized | null, 15 | shelves: Array 16 | } 17 | 18 | // provide typings for `this.$store` 19 | interface ComponentCustomProperties { 20 | $store: Store 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/jelu-ui/src/model/Review.ts: -------------------------------------------------------------------------------- 1 | export interface Review { 2 | id: string, 3 | creationDate: string, 4 | modificationDate: Date, 5 | reviewDate: Date, 6 | text: string, 7 | rating: number, 8 | user: string, 9 | book: string, 10 | visibility: Visibility 11 | } 12 | 13 | export interface CreateReviewDto { 14 | reviewDate?: Date, 15 | text: string, 16 | rating: number, 17 | bookId: string, 18 | visibility: Visibility 19 | } 20 | 21 | export interface UpdateReviewDto { 22 | reviewDate?: Date, 23 | text?: string, 24 | rating?: number, 25 | visibility?: Visibility 26 | } 27 | 28 | export enum Visibility { 29 | PUBLIC = 'PUBLIC', 30 | PRIVATE = 'PRIVATE' 31 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/JeluApplication.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu 2 | 3 | import io.github.bayang.jelu.config.JeluProperties 4 | import org.springframework.boot.autoconfigure.SpringBootApplication 5 | import org.springframework.boot.context.properties.EnableConfigurationProperties 6 | import org.springframework.boot.runApplication 7 | import org.springframework.scheduling.annotation.EnableAsync 8 | import org.springframework.scheduling.annotation.EnableScheduling 9 | 10 | @SpringBootApplication 11 | @EnableConfigurationProperties(JeluProperties::class) 12 | @EnableAsync 13 | @EnableScheduling 14 | class JeluApplication 15 | 16 | fun main(args: Array) { 17 | runApplication(*args) 18 | } 19 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/service/LifeCycleService.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.service 2 | 3 | import io.github.bayang.jelu.dao.LifeCycleRepository 4 | import io.github.bayang.jelu.dto.LifeCycleDto 5 | import org.springframework.stereotype.Component 6 | import org.springframework.transaction.annotation.Transactional 7 | 8 | @Component 9 | class LifeCycleService( 10 | private val lifeCycleRepository: LifeCycleRepository, 11 | ) { 12 | 13 | @Transactional 14 | fun getLifeCycle(): LifeCycleDto = lifeCycleRepository.findLifeCycle().toLifeCycleDto() 15 | 16 | @Transactional 17 | fun setSeriesMigrated(newValue: Boolean = true): LifeCycleDto = lifeCycleRepository.setSeriesMigrated(newValue).toLifeCycleDto() 18 | } 19 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dto/WikipediaSearchResultElement.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dto 2 | 3 | import com.fasterxml.jackson.annotation.JsonAlias 4 | 5 | data class WikipediaSearchResultElement( 6 | 7 | val id: Long, 8 | val key: String, 9 | val title: String, 10 | val excerpt: String, 11 | val description: String?, 12 | @JsonAlias("matched_title") 13 | val matchedTitle: String?, 14 | val thumbnail: Thumbnail?, 15 | ) 16 | 17 | data class Thumbnail( 18 | val mimetype: String, 19 | val size: String?, 20 | val width: Int, 21 | val height: Int, 22 | val duration: Int, 23 | val url: String, 24 | ) 25 | 26 | data class WikipediaSearchResult( 27 | val pages: List, 28 | ) 29 | -------------------------------------------------------------------------------- /src/jelu-ui/src/urls.ts: -------------------------------------------------------------------------------- 1 | class Urls { 2 | 3 | public MODE: string; 4 | 5 | public BASE_URL: string; 6 | 7 | public API_URL: string 8 | 9 | constructor() { 10 | if (import.meta.env.DEV) { 11 | this.MODE = "dev" 12 | this.BASE_URL = import.meta.env.VITE_API_URL as string 13 | this.API_URL = this.BASE_URL 14 | } 15 | else { 16 | this.MODE = "prod" 17 | this.BASE_URL = window.location.origin.endsWith("/") ? window.location.origin.slice(0, -1) : window.location.origin 18 | this.API_URL = this.BASE_URL + "/api/v1" 19 | } 20 | console.log(`running in ${this.MODE} mode at ${this.BASE_URL} and ${this.API_URL}`) 21 | } 22 | 23 | } 24 | export default new Urls() 25 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dto/BookQuoteDto.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dto 2 | 3 | import io.github.bayang.jelu.dao.Visibility 4 | import java.time.Instant 5 | import java.util.UUID 6 | 7 | data class BookQuoteDto( 8 | val id: UUID?, 9 | val creationDate: Instant?, 10 | val modificationDate: Instant?, 11 | val text: String, 12 | val visibility: Visibility, 13 | val user: UUID?, 14 | val book: UUID?, 15 | val position: String?, 16 | ) 17 | data class UpdateBookQuoteDto( 18 | val text: String?, 19 | val visibility: Visibility?, 20 | val position: String?, 21 | ) 22 | data class CreateBookQuoteDto( 23 | val text: String, 24 | val visibility: Visibility, 25 | val bookId: UUID, 26 | val position: String?, 27 | ) 28 | -------------------------------------------------------------------------------- /src/jelu-ui/src/model/Metadata.ts: -------------------------------------------------------------------------------- 1 | import { MetadataError } from './MetadataError'; 2 | 3 | export interface Metadata { 4 | title?: string, 5 | isbn10?:string, 6 | isbn13?: string, 7 | summary?: string, 8 | image?: string, 9 | publisher?: string, 10 | pageCount?: number, 11 | publishedDate?: string, 12 | authors: Array, 13 | tags: Array, 14 | series?: string, 15 | numberInSeries?: number, 16 | language?: string, 17 | googleId?: string, 18 | amazonId?: string, 19 | goodreadsId?: string, 20 | librarythingId?: string, 21 | isfdbId?: string, 22 | openlibraryId?: string, 23 | noosfereId?: string, 24 | inventaireId?: string, 25 | errorType?: MetadataError, 26 | pluginErrorMessage?: string, 27 | } 28 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dto/UserMessageDto.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dto 2 | 3 | import io.github.bayang.jelu.dao.MessageCategory 4 | import java.time.Instant 5 | import java.util.UUID 6 | 7 | data class CreateUserMessageDto( 8 | val message: String?, 9 | val link: String?, 10 | val category: MessageCategory, 11 | ) 12 | 13 | data class UpdateUserMessageDto( 14 | val message: String?, 15 | val link: String?, 16 | val category: MessageCategory?, 17 | val read: Boolean?, 18 | ) 19 | 20 | data class UserMessageDto( 21 | val id: UUID?, 22 | val message: String?, 23 | val link: String?, 24 | val category: MessageCategory, 25 | val read: Boolean, 26 | val creationDate: Instant?, 27 | val modificationDate: Instant?, 28 | ) 29 | -------------------------------------------------------------------------------- /src/jelu-ui/src/model/WikipediaPageResult.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface WikipediaPageResult { 3 | type: string, 4 | title: string, 5 | displayTitle: string, 6 | wikibaseItem: string, 7 | pageId: number, 8 | lang: string, 9 | description?: string, 10 | extract: string, 11 | extractHtml: string, 12 | contentUrls: ContentUrlList, 13 | thumbnail: PageThumbnail, 14 | originalImage: PageThumbnail 15 | } 16 | 17 | export interface PageThumbnail { 18 | source: string, 19 | width: number, 20 | height: number 21 | } 22 | 23 | export interface ContentUrlList { 24 | desktop: ContentUrl, 25 | mobile: ContentUrl 26 | } 27 | 28 | export interface ContentUrl { 29 | page: string, 30 | revisions: string, 31 | edit: string, 32 | talk: string 33 | } 34 | -------------------------------------------------------------------------------- /src/jelu-ui/src/model/User.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | id?: string, 3 | creationDate?: string, 4 | login: string, 5 | isAdmin: boolean, 6 | modificationDate?: string, 7 | provider?: Provider 8 | } 9 | export interface UserAuthentication { 10 | user: User, 11 | token?: string 12 | } 13 | export interface CreateUser { 14 | login: string, 15 | password: string, 16 | isAdmin: boolean, 17 | provider?: Provider 18 | } 19 | export interface UpdateUser { 20 | password: string, 21 | isAdmin?: boolean, 22 | provider?: Provider 23 | } 24 | export enum Provider { 25 | LDAP = 'LDAP', 26 | JELU_DB = 'JELU_DB', 27 | PROXY = 'PROXY' 28 | } 29 | export interface LoginHistoryInfo { 30 | ip?: string, 31 | userAgent?: string, 32 | source?: string, 33 | date?: string, 34 | } 35 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: bayang 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a bayang IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /src/jelu-ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Jelu 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/utils/DateUtils.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.utils 2 | 3 | import io.github.bayang.jelu.dto.ReadingEventDto 4 | import java.time.Instant 5 | import java.time.LocalDate 6 | import java.time.OffsetDateTime 7 | import java.time.ZoneId 8 | import java.time.format.DateTimeFormatter 9 | 10 | fun nowInstant(): Instant = OffsetDateTime.now(ZoneId.systemDefault()).toInstant() 11 | 12 | fun toInstant(date: LocalDate): Instant = date.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant() 13 | 14 | fun lastEventDate(dto: ReadingEventDto): Instant? { 15 | return dto.endDate ?: dto.startDate 16 | } 17 | 18 | fun stringFormat(instant: Instant?): String { 19 | if (instant == null) { 20 | return "" 21 | } 22 | return instant.atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ISO_DATE_TIME) 23 | } 24 | -------------------------------------------------------------------------------- /src/jelu-ui/src/model/ReadingEvent.ts: -------------------------------------------------------------------------------- 1 | import { UserBook } from "./Book"; 2 | 3 | export interface ReadingEvent { 4 | id?: string, 5 | creationDate?: string, 6 | modificationDate?: Date, 7 | eventType: ReadingEventType, 8 | startDate?: Date, 9 | endDate?: Date, 10 | } 11 | 12 | export interface CreateReadingEvent { 13 | eventDate?: Date, 14 | bookId?: string, 15 | eventType: ReadingEventType, 16 | startDate?: Date, 17 | } 18 | 19 | export interface ReadingEventWithUserBook { 20 | id?: string, 21 | creationDate?: string, 22 | modificationDate?: Date, 23 | eventType: ReadingEventType, 24 | userBook: UserBook, 25 | startDate?: Date, 26 | endDate?: Date, 27 | } 28 | 29 | export enum ReadingEventType { 30 | FINISHED = 'FINISHED', 31 | DROPPED = 'DROPPED', 32 | CURRENTLY_READING = 'CURRENTLY_READING', 33 | NONE = 'NONE' 34 | } 35 | 36 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dto/ReviewDto.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dto 2 | 3 | import io.github.bayang.jelu.dao.Visibility 4 | import java.time.Instant 5 | import java.util.UUID 6 | 7 | data class ReviewDto( 8 | val id: UUID?, 9 | val creationDate: Instant?, 10 | val modificationDate: Instant?, 11 | val reviewDate: Instant?, 12 | val text: String, 13 | val rating: Double, 14 | val visibility: Visibility, 15 | val user: UUID?, 16 | val book: UUID?, 17 | ) 18 | data class UpdateReviewDto( 19 | val reviewDate: Instant?, 20 | val text: String?, 21 | val rating: Double?, 22 | val visibility: Visibility?, 23 | ) 24 | data class CreateReviewDto( 25 | val reviewDate: Instant?, 26 | val text: String, 27 | val rating: Double, 28 | val visibility: Visibility, 29 | val bookId: UUID, 30 | ) 31 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dto/UserDto.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dto 2 | 3 | import io.github.bayang.jelu.dao.Provider 4 | import java.time.Instant 5 | import java.util.UUID 6 | 7 | data class UserDto( 8 | val id: UUID?, 9 | val creationDate: Instant?, 10 | val login: String, 11 | val password: String?, 12 | val modificationDate: Instant?, 13 | val isAdmin: Boolean, 14 | val provider: Provider = Provider.JELU_DB, 15 | ) 16 | data class CreateUserDto( 17 | val login: String, 18 | val password: String, 19 | val isAdmin: Boolean, 20 | val provider: Provider = Provider.JELU_DB, 21 | ) 22 | data class UpdateUserDto( 23 | val password: String, 24 | val isAdmin: Boolean?, 25 | val provider: Provider?, 26 | ) 27 | data class AuthenticationDto( 28 | val user: UserDto, 29 | val token: String?, 30 | ) 31 | -------------------------------------------------------------------------------- /src/jelu-ui/src/components/ClosableBadge.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 34 | 35 | 38 | -------------------------------------------------------------------------------- /src/jelu-ui/src/composables/dates.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | 3 | export default function useDates() { 4 | 5 | function formatDateString(dateString: string | null | undefined): string { 6 | if (dateString != null) { 7 | const date = dayjs(dateString) 8 | return date.format('D MMMM YYYY') 9 | } 10 | return '' 11 | } 12 | 13 | function formatDate(date: Date | null | undefined): string { 14 | if (date != null) { 15 | return dayjs(date).format('D MMMM YYYY') 16 | } 17 | return '' 18 | } 19 | 20 | function stringToDate(dateString: string | null | undefined): Date|null { 21 | if (dateString != null) { 22 | return dayjs(dateString).toDate() 23 | } 24 | return null 25 | } 26 | 27 | return { 28 | formatDate, 29 | formatDateString, 30 | stringToDate 31 | } 32 | } 33 | 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | .gradle 3 | build/ 4 | !gradle/wrapper/gradle-wrapper.jar 5 | !**/src/main/**/build/ 6 | !**/src/test/**/build/ 7 | 8 | ### STS ### 9 | .apt_generated 10 | .classpath 11 | .factorypath 12 | .project 13 | .settings 14 | .springBeans 15 | .sts4-cache 16 | bin/ 17 | !**/src/main/**/bin/ 18 | !**/src/test/**/bin/ 19 | 20 | ### IntelliJ IDEA ### 21 | .idea 22 | *.iws 23 | *.iml 24 | *.ipr 25 | out/ 26 | !**/src/main/**/out/ 27 | !**/src/test/**/out/ 28 | 29 | ### NetBeans ### 30 | /nbproject/private/ 31 | /nbbuild/ 32 | /dist/ 33 | /nbdist/ 34 | /.nb-gradle/ 35 | 36 | ### VS Code ### 37 | .vscode/ 38 | 39 | memory:myDb 40 | 41 | src/main/resources/public/** 42 | 43 | segments_p 44 | write.lock 45 | *.cfe 46 | *.cfs 47 | *.si 48 | *.liv 49 | /_8q_Lucene99_0.doc 50 | /_8q_Lucene99_0.pos 51 | /_8q_Lucene99_0.tim 52 | /_8q_Lucene99_0.tip 53 | /_8q_Lucene99_0.tmd 54 | /_8q.fdm 55 | /_8q.fdt 56 | /_8q.fdx 57 | /_8q.fnm 58 | /_8q.nvd 59 | /_8q.nvm 60 | /segments_c8 61 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/controllers/OAuth2Controller.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.controllers 2 | 3 | import org.springframework.http.MediaType 4 | import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository 5 | import org.springframework.web.bind.annotation.RequestMapping 6 | import org.springframework.web.bind.annotation.RestController 7 | 8 | @RestController 9 | @RequestMapping("api/v1/oauth2", produces = [MediaType.APPLICATION_JSON_VALUE]) 10 | class OAuth2Controller( 11 | clientRegistrationRepository: InMemoryClientRegistrationRepository?, 12 | ) { 13 | val registrationIds = 14 | clientRegistrationRepository?.map { 15 | OAuth2ClientDto(it.clientName, it.registrationId) 16 | } ?: emptyList() 17 | 18 | @RequestMapping("providers") 19 | fun getProviders() = registrationIds 20 | } 21 | 22 | data class OAuth2ClientDto( 23 | val name: String, 24 | val registrationId: String, 25 | ) 26 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dto/ImportDto.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dto 2 | 3 | import io.github.bayang.jelu.dao.ImportSource 4 | 5 | class ImportDto { 6 | 7 | var goodreadsId: String? = null 8 | var librarythingId: String? = null 9 | var title: String? = null 10 | var authors: String? = null 11 | var isbn10: String? = null 12 | var isbn13: String? = null 13 | var publisher: String? = null 14 | var numberOfPages: Int? = null 15 | var publishedDate: String? = null 16 | var readDates: String? = null 17 | var tags: MutableSet = mutableSetOf() 18 | var personalNotes: String? = null 19 | var readCount: Int? = null 20 | var owned: Boolean? = null 21 | var importSource: ImportSource? = null 22 | var review: String? = null 23 | var rating: Int? = null 24 | } 25 | data class ImportConfigurationDto( 26 | var shouldFetchMetadata: Boolean, 27 | var shouldFetchCovers: Boolean, 28 | var importSource: ImportSource, 29 | ) 30 | -------------------------------------------------------------------------------- /src/test/kotlin/io/github/bayang/jelu/service/LifeCycleServiceTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.service 2 | 3 | import org.junit.jupiter.api.Assertions 4 | import org.junit.jupiter.api.Test 5 | import org.junit.jupiter.api.TestInstance 6 | import org.springframework.beans.factory.annotation.Autowired 7 | import org.springframework.boot.test.context.SpringBootTest 8 | 9 | @SpringBootTest 10 | @TestInstance(TestInstance.Lifecycle.PER_CLASS) 11 | class LifeCycleServiceTest( 12 | @Autowired private val lifeCycleService: LifeCycleService, 13 | ) { 14 | 15 | @Test 16 | fun testGetLifeCycle() { 17 | var lifeCycle = lifeCycleService.getLifeCycle() 18 | Assertions.assertNotNull(lifeCycle) 19 | Assertions.assertEquals(1000, lifeCycle.id) 20 | Assertions.assertTrue(lifeCycle.seriesMigrated) 21 | 22 | lifeCycle = lifeCycleService.setSeriesMigrated() 23 | Assertions.assertEquals(1000, lifeCycle.id) 24 | Assertions.assertTrue(lifeCycle.seriesMigrated) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/jelu-ui/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from '@eslint/js'; 4 | // import /* jestPlugin */ from 'eslint-plugin-jest'; 5 | import vuePlugin from 'eslint-plugin-vue' 6 | import tseslint from 'typescript-eslint'; 7 | 8 | import globals from 'globals' 9 | 10 | export default tseslint.config( 11 | { 12 | // config with just ignores is the replacement for `.eslintignore` 13 | ignores: ['**/build/**', '**/dist/**', '*.d.ts'], 14 | }, 15 | { 16 | extends: [ 17 | eslint.configs.recommended, 18 | ...tseslint.configs.recommended, 19 | ...vuePlugin.configs['flat/recommended'], 20 | ], 21 | files: ['**/*.{ts,vue}'], 22 | languageOptions: { 23 | ecmaVersion: 'latest', 24 | sourceType: 'module', 25 | globals: globals.browser, 26 | parserOptions: { 27 | parser: tseslint.parser, 28 | }, 29 | }, 30 | rules: { 31 | // your rules 32 | "no-unused-vars": "off", 33 | "@typescript-eslint/no-unused-vars": "warn", 34 | "prefer-const": ["warn", {"destructuring": "all"}] 35 | }, 36 | }, 37 | ); 38 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/utils/ImageUtils.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.utils 2 | 3 | import io.github.oshai.kotlinlogging.KotlinLogging 4 | import net.coobird.thumbnailator.Thumbnails 5 | import net.coobird.thumbnailator.name.Rename 6 | import java.io.File 7 | 8 | private val logger = KotlinLogging.logger {} 9 | 10 | fun resizeImage(originalFile: File) { 11 | var backup: File? = null 12 | try { 13 | backup = originalFile.copyTo(File("${originalFile.absolutePath}.bak"), true) 14 | Thumbnails.of(originalFile) 15 | .allowOverwrite(true) 16 | .useOriginalFormat() 17 | .size(500, 500) 18 | .keepAspectRatio(true) 19 | .toFiles(Rename.NO_CHANGE) 20 | try { 21 | backup.delete() 22 | } catch (e: Exception) { 23 | /* noop */ 24 | } 25 | } catch (e: Exception) { 26 | logger.error(e) { "Failed to resize image ${originalFile.name}" } 27 | if (backup != null && backup.exists()) { 28 | backup.copyTo(File(originalFile.absolutePath), true) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/service/metadata/providers/DebugMetadataProvider.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.service.metadata.providers 2 | 3 | import io.github.bayang.jelu.dto.MetadataDto 4 | import io.github.bayang.jelu.dto.MetadataRequestDto 5 | import io.github.bayang.jelu.service.metadata.PluginInfoHolder 6 | import io.github.oshai.kotlinlogging.KotlinLogging 7 | import org.springframework.stereotype.Service 8 | import java.util.Optional 9 | 10 | private val logger = KotlinLogging.logger {} 11 | 12 | @Service 13 | class DebugMetadataProvider : IMetaDataProvider { 14 | 15 | override fun fetchMetadata( 16 | metadataRequestDto: MetadataRequestDto, 17 | config: Map, 18 | ): Optional { 19 | logger.debug { 20 | "debug plugin called with isbn ${metadataRequestDto.isbn}, title ${metadataRequestDto.title}, " + 21 | "authors ${metadataRequestDto.authors}, config $config, plugins ${metadataRequestDto.plugins}" 22 | } 23 | return Optional.empty() 24 | } 25 | 26 | override fun name(): String = PluginInfoHolder.jelu_debug 27 | } 28 | -------------------------------------------------------------------------------- /src/jelu-ui/src/composables/events.ts: -------------------------------------------------------------------------------- 1 | import { useI18n } from 'vue-i18n'; 2 | import { ReadingEventType } from '../model/ReadingEvent'; 3 | 4 | 5 | export default function useEvents() { 6 | const { t } = useI18n({ 7 | inheritLocale: true, 8 | useScope: 'global' 9 | }) 10 | 11 | const eventClass = (type: ReadingEventType) => { 12 | if (type === ReadingEventType.FINISHED) { 13 | return "badge-info"; 14 | } else if (type === ReadingEventType.DROPPED) { 15 | return "badge-error"; 16 | } else if ( 17 | type === ReadingEventType.CURRENTLY_READING 18 | ) { 19 | return "badge-success"; 20 | } else return ""; 21 | }; 22 | 23 | const eventLabel = (type: ReadingEventType) => { 24 | if (type === ReadingEventType.FINISHED) { 25 | return t('reading_events.finished'); 26 | } else if (type === ReadingEventType.DROPPED) { 27 | return t('reading_events.dropped'); 28 | } else if (type === ReadingEventType.CURRENTLY_READING) { 29 | return t('reading_events.reading'); 30 | } else return ""; 31 | }; 32 | 33 | return { 34 | eventClass, 35 | eventLabel 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | jelu: 2 | database: 3 | path: ${user.home}/.jelu/database/ 4 | files: 5 | images: '${user.home}/.jelu/files/images/' 6 | imports: '${user.home}/.jelu/files/imports/' 7 | session: 8 | duration: 604800 #7 days 9 | server: 10 | port: 11111 11 | spring: 12 | web: 13 | resources: 14 | add-mappings: false 15 | mvc: 16 | throw-exception-if-no-handler-found: true 17 | liquibase: 18 | enabled: true 19 | drop-first: false 20 | change-log: classpath:liquibase.xml 21 | datasource: 22 | url: jdbc:sqlite:${jelu.database.path}/jelu.db?foreign_keys=on; 23 | username: jelu_user 24 | password: mypass1234 25 | driver-class-name: org.sqlite.JDBC 26 | exposed: 27 | generate-ddl: false 28 | show-sql: false 29 | servlet: 30 | multipart: 31 | enabled: true 32 | location: ${java.io.tmpdir} 33 | max-request-size: 10MB 34 | max-file-size: 10MB 35 | logging: 36 | logback.rollingpolicy.max-history: 10 37 | file: 38 | name: ${jelu.database.path}/jelu.log 39 | # name: \${user.home}/.jelu/jelu.log 40 | level: 41 | io.github.bayang.jelu: DEBUG 42 | -------------------------------------------------------------------------------- /src/main/resources/users.ldif: -------------------------------------------------------------------------------- 1 | dn: ou=groups,dc=springframework,dc=org 2 | objectclass: top 3 | objectclass: organizationalUnit 4 | ou: groups 5 | 6 | dn: ou=people,dc=springframework,dc=org 7 | objectclass: top 8 | objectclass: organizationalUnit 9 | ou: people 10 | 11 | dn: uid=admin,ou=people,dc=springframework,dc=org 12 | objectclass: top 13 | objectclass: person 14 | objectclass: organizationalPerson 15 | objectclass: inetOrgPerson 16 | cn: Rod Johnson 17 | sn: Johnson 18 | uid: admin 19 | userPassword: password 20 | 21 | dn: uid=user,ou=people,dc=springframework,dc=org 22 | objectclass: top 23 | objectclass: person 24 | objectclass: organizationalPerson 25 | objectclass: inetOrgPerson 26 | cn: Dianne Emu 27 | sn: Emu 28 | uid: user 29 | userPassword: password 30 | 31 | dn: cn=user,ou=groups,dc=springframework,dc=org 32 | objectclass: top 33 | objectclass: groupOfNames 34 | cn: user 35 | member: uid=admin,ou=people,dc=springframework,dc=org 36 | member: uid=user,ou=people,dc=springframework,dc=org 37 | 38 | dn: cn=admin,ou=groups,dc=springframework,dc=org 39 | objectclass: top 40 | objectclass: groupOfNames 41 | cn: admin 42 | member: uid=admin,ou=people,dc=springframework,dc=org -------------------------------------------------------------------------------- /src/jelu-ui/src/composables/sort.ts: -------------------------------------------------------------------------------- 1 | import { useRouteQuery } from '@vueuse/router'; 2 | import { ref, Ref, watch } from 'vue'; 3 | import { useRoute } from 'vue-router'; 4 | 5 | export default function useSort(defaultSort: string) { 6 | const route = useRoute() 7 | console.log(route.query) 8 | const sortQuery: Ref = useRouteQuery('sort', defaultSort) 9 | 10 | const {field, order} = splitVal(sortQuery.value) 11 | 12 | const sortOrder = ref(order) 13 | 14 | const sortBy = ref(field) 15 | 16 | watch([sortBy, sortOrder], (newVal, oldVal) => { 17 | console.log("sort " + newVal + " " + oldVal) 18 | if (newVal !== oldVal) { 19 | sortQuery.value = newVal.join(",") 20 | } 21 | }) 22 | 23 | const sortOrderUpdated = (newval: string) => { 24 | console.log('sortOrderUpdated ' + newval) 25 | sortOrder.value = newval 26 | } 27 | 28 | return { 29 | sortQuery, 30 | sortOrder, 31 | sortBy, 32 | sortOrderUpdated 33 | } 34 | } 35 | 36 | const splitVal = (input: string) => { 37 | const ret = input.split(",") 38 | return {"field" :ret[0], "order" : ret[1]} 39 | } -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dto/WikipediaPageResult.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dto 2 | 3 | import com.fasterxml.jackson.annotation.JsonAlias 4 | 5 | data class WikipediaPageResult( 6 | val type: String, 7 | val title: String, 8 | @JsonAlias("displaytitle") 9 | val displayTitle: String?, 10 | @JsonAlias("wikibase_item") 11 | val wikibaseItem: String?, 12 | @JsonAlias("pageid") 13 | val pageId: Long, 14 | val lang: String?, 15 | val description: String?, 16 | val extract: String, 17 | @JsonAlias("extract_html") 18 | val extractHtml: String, 19 | @JsonAlias("content_urls") 20 | val contentUrls: ContentUrlList, 21 | val thumbnail: PageThumbnail?, 22 | @JsonAlias("originalimage") 23 | val originalImage: PageThumbnail?, 24 | ) 25 | 26 | data class PageThumbnail( 27 | val source: String, 28 | val width: Int, 29 | val height: Int, 30 | ) 31 | 32 | data class ContentUrlList( 33 | val desktop: ContentUrl, 34 | val mobile: ContentUrl, 35 | ) 36 | 37 | data class ContentUrl( 38 | val page: String, 39 | val revisions: String, 40 | val edit: String, 41 | val talk: String, 42 | ) 43 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dao/LifeCycleTable.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dao 2 | 3 | import io.github.bayang.jelu.dto.LifeCycleDto 4 | import org.jetbrains.exposed.dao.LongEntity 5 | import org.jetbrains.exposed.dao.LongEntityClass 6 | import org.jetbrains.exposed.dao.id.EntityID 7 | import org.jetbrains.exposed.dao.id.LongIdTable 8 | import org.jetbrains.exposed.sql.Column 9 | import org.jetbrains.exposed.sql.javatime.timestamp 10 | 11 | object LifeCycleTable : LongIdTable("lifecycle") { 12 | val creationDate = timestamp("creation_date") 13 | val modificationDate = timestamp("modification_date") 14 | val seriesMigrated: Column = bool("series_migrated") 15 | } 16 | class LifeCycle(id: EntityID) : LongEntity(id) { 17 | companion object : LongEntityClass(LifeCycleTable) 18 | var creationDate by LifeCycleTable.creationDate 19 | var modificationDate by LifeCycleTable.modificationDate 20 | var seriesMigrated by LifeCycleTable.seriesMigrated 21 | 22 | fun toLifeCycleDto(): LifeCycleDto = LifeCycleDto( 23 | creationDate = this.creationDate, 24 | modificationDate = this.modificationDate, 25 | seriesMigrated = this.seriesMigrated, 26 | id = this.id.value, 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dto/MetadataDto.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dto 2 | 3 | data class MetadataDto( 4 | var title: String? = null, 5 | var isbn10: String? = null, 6 | var isbn13: String? = null, 7 | var summary: String? = null, 8 | var image: String? = null, 9 | var publisher: String? = null, 10 | var pageCount: Int? = null, 11 | var publishedDate: String? = null, 12 | var authors: MutableSet = mutableSetOf(), 13 | var tags: MutableSet = mutableSetOf(), 14 | var series: String? = null, 15 | var numberInSeries: Double? = null, 16 | var language: String? = null, 17 | var googleId: String? = null, 18 | var amazonId: String? = null, 19 | var goodreadsId: String? = null, 20 | var librarythingId: String? = null, 21 | var isfdbId: String? = null, 22 | var openlibraryId: String? = null, 23 | var inventaireId: String? = null, 24 | var noosfereId: String? = null, 25 | var errorType: MetadataError? = null, 26 | var pluginErrorMessage: String? = null, 27 | ) 28 | data class MetadataRequestDto( 29 | val isbn: String? = null, 30 | val title: String? = null, 31 | val authors: String? = null, 32 | val plugins: List? = null, 33 | ) 34 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/config/LuceneConfiguration.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.config 2 | 3 | import io.github.bayang.jelu.search.MultiLingualAnalyzer 4 | import io.github.bayang.jelu.search.MultiLingualNGramAnalyzer 5 | import org.apache.lucene.store.ByteBuffersDirectory 6 | import org.apache.lucene.store.Directory 7 | import org.apache.lucene.store.FSDirectory 8 | import org.springframework.context.annotation.Bean 9 | import org.springframework.context.annotation.Configuration 10 | import org.springframework.context.annotation.Profile 11 | import java.nio.file.Paths 12 | 13 | @Configuration 14 | class LuceneConfiguration( 15 | private val jeluProperties: JeluProperties, 16 | ) { 17 | 18 | @Bean 19 | fun indexAnalyzer() = 20 | with(jeluProperties.lucene.indexAnalyzer) { 21 | MultiLingualNGramAnalyzer(minGram, maxGram, preserveOriginal) 22 | } 23 | 24 | @Bean 25 | fun searchAnalyzer() = 26 | MultiLingualAnalyzer() 27 | 28 | @Bean 29 | @Profile("test") 30 | fun memoryDirectory(): Directory = 31 | ByteBuffersDirectory() 32 | 33 | @Bean 34 | @Profile("!test") 35 | fun diskDirectory(): Directory = 36 | FSDirectory.open(Paths.get(jeluProperties.lucene.dataDirectory)) 37 | } 38 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/service/metadata/providers/Wikidata.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.service.metadata.providers 2 | 3 | /** 4 | * see https://www.wikidata.org/wiki/Property:P212 5 | */ 6 | class Wikidata { 7 | companion object { 8 | const val PREFIX = "wdt:" 9 | const val TITLE = "${PREFIX}P1476" 10 | const val ISBN13 = "${PREFIX}P212" 11 | const val ISBN10 = "${PREFIX}P957" 12 | const val EDITION_OR_TRANSLATION = "${PREFIX}P629" 13 | const val PUBLISHER = "${PREFIX}P123" 14 | const val PUBLICATION_DATE = "${PREFIX}P577" 15 | const val NB_PAGES = "${PREFIX}P1104" 16 | const val GOODREADS_ID = "${PREFIX}P2969" 17 | const val LANGUAGE_OF_WORK_OR_NAME = "${PREFIX}P407" 18 | const val AUTHOR = "${PREFIX}P50" 19 | const val GENRE = "${PREFIX}P136" 20 | const val SERIES = "${PREFIX}P179" 21 | const val OPEN_LIBRARY_ID = "${PREFIX}P648" 22 | const val MAIN_SUBJECT = "${PREFIX}P921" 23 | const val LIBRARYTHING_WORK_ID = "${PREFIX}P1085" 24 | const val ISFDB_TITLE_ID = "${PREFIX}P1274" 25 | const val GOODREADS_WORK_ID = "${PREFIX}P8383" 26 | const val NOOSFERE_BOOK_ID = "${PREFIX}P5571" 27 | const val SERIES_ORDINAL = "${PREFIX}P1545" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dto/ReadingEventDto.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dto 2 | 3 | import io.github.bayang.jelu.dao.ReadingEventType 4 | import java.time.Instant 5 | import java.util.* 6 | 7 | data class ReadingEventDto( 8 | val id: UUID?, 9 | val creationDate: Instant?, 10 | val modificationDate: Instant?, 11 | val eventType: ReadingEventType, 12 | val userBook: UserBookWithoutEventsDto, 13 | val startDate: Instant?, 14 | val endDate: Instant?, 15 | ) 16 | data class ReadingEventWithoutUserBookDto( 17 | val id: UUID?, 18 | val creationDate: Instant?, 19 | val modificationDate: Instant?, 20 | val eventType: ReadingEventType, 21 | val startDate: Instant?, 22 | val endDate: Instant?, 23 | ) 24 | data class CreateReadingEventWithUserInfoDto( 25 | val eventType: ReadingEventType, 26 | val bookId: UUID, 27 | val userId: UUID?, 28 | ) 29 | data class CreateReadingEventDto( 30 | val eventType: ReadingEventType, 31 | val bookId: UUID?, 32 | val eventDate: Instant?, 33 | val startDate: Instant?, 34 | ) 35 | data class UpdateReadingEventDto( 36 | val eventType: ReadingEventType, 37 | val eventDate: Instant?, 38 | val startDate: Instant?, 39 | ) 40 | enum class ReadingEventTypeFilter { 41 | FINISHED, 42 | DROPPED, 43 | CURRENTLY_READING, 44 | NONE, 45 | } 46 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/search/MultiLingualNGramAnalyzer.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.search 2 | 3 | import org.apache.lucene.analysis.LowerCaseFilter 4 | import org.apache.lucene.analysis.TokenStream 5 | import org.apache.lucene.analysis.Tokenizer 6 | import org.apache.lucene.analysis.cjk.CJKBigramFilter 7 | import org.apache.lucene.analysis.cjk.CJKWidthFilter 8 | import org.apache.lucene.analysis.miscellaneous.ASCIIFoldingFilter 9 | import org.apache.lucene.analysis.ngram.NGramTokenFilter 10 | import org.apache.lucene.analysis.standard.StandardTokenizer 11 | 12 | /** 13 | * see https://github.com/gotson/komga/tree/master/komga/src/main/kotlin/org/gotson/komga/infrastructure/search 14 | * for all the lucene related stuff 15 | */ 16 | class MultiLingualNGramAnalyzer(private val minGram: Int, private val maxGram: Int, private val preserveOriginal: Boolean) : MultiLingualAnalyzer() { 17 | override fun createComponents(fieldName: String): TokenStreamComponents { 18 | val source: Tokenizer = StandardTokenizer() 19 | // run the widthfilter first before bigramming, it sometimes combines characters. 20 | var filter: TokenStream = CJKWidthFilter(source) 21 | filter = LowerCaseFilter(filter) 22 | filter = CJKBigramFilter(filter) 23 | filter = NGramTokenFilter(filter, minGram, maxGram, preserveOriginal) 24 | filter = ASCIIFoldingFilter(filter) 25 | return TokenStreamComponents(source, filter) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/controllers/ServerSettingsController.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.controllers 2 | 3 | import io.github.bayang.jelu.config.JeluProperties 4 | import io.github.bayang.jelu.dto.ServerSettingsDto 5 | import io.github.bayang.jelu.service.metadata.PluginInfoHolder 6 | import io.swagger.v3.oas.annotations.Operation 7 | import org.springframework.boot.info.BuildProperties 8 | import org.springframework.web.bind.annotation.GetMapping 9 | import org.springframework.web.bind.annotation.RequestMapping 10 | import org.springframework.web.bind.annotation.RestController 11 | 12 | @RestController 13 | @RequestMapping("/api/v1") 14 | class ServerSettingsController( 15 | private val pluginInfoHolder: PluginInfoHolder, 16 | private val properties: JeluProperties, 17 | private val buildProperties: BuildProperties, 18 | ) { 19 | 20 | @Operation(description = "Get the capabilities configured for this server, eg : is the metadata binary installed etc...") 21 | @GetMapping(path = ["/server-settings"]) 22 | fun getServerSettings(): ServerSettingsDto { 23 | val plugins = pluginInfoHolder.plugins() 24 | return ServerSettingsDto( 25 | metadataFetchEnabled = plugins.isNotEmpty(), 26 | metadataFetchCalibreEnabled = pluginInfoHolder.calibreEnabled(), 27 | buildProperties.version, 28 | ldapEnabled = properties.auth.ldap.enabled, 29 | metadataPlugins = plugins, 30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dao/ShelfTable.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dao 2 | 3 | import io.github.bayang.jelu.dto.ShelfDto 4 | import org.jetbrains.exposed.dao.UUIDEntity 5 | import org.jetbrains.exposed.dao.UUIDEntityClass 6 | import org.jetbrains.exposed.dao.id.EntityID 7 | import org.jetbrains.exposed.dao.id.UUIDTable 8 | import org.jetbrains.exposed.sql.Column 9 | import org.jetbrains.exposed.sql.ReferenceOption 10 | import org.jetbrains.exposed.sql.javatime.timestamp 11 | import java.util.UUID 12 | 13 | object ShelfTable : UUIDTable("shelf") { 14 | val creationDate = timestamp("creation_date") 15 | val modificationDate = timestamp("modification_date") 16 | val user = reference("user", UserTable, onDelete = ReferenceOption.CASCADE) 17 | val name: Column = varchar("name", 5000) 18 | val targetId: Column = uuid("target_id") 19 | } 20 | class Shelf(id: EntityID) : UUIDEntity(id) { 21 | companion object : UUIDEntityClass(ShelfTable) 22 | var creationDate by ShelfTable.creationDate 23 | var modificationDate by ShelfTable.modificationDate 24 | var user by User referencedOn ShelfTable.user 25 | var name by ShelfTable.name 26 | var targetId by ShelfTable.targetId 27 | 28 | fun toShelfDto(): ShelfDto = ShelfDto( 29 | id = this.id.value, 30 | name = this.name, 31 | creationDate = this.creationDate, 32 | modificationDate = this.modificationDate, 33 | targetId = this.targetId, 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/service/ShelfService.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.service 2 | 3 | import io.github.bayang.jelu.dao.ShelfRepository 4 | import io.github.bayang.jelu.dto.CreateShelfDto 5 | import io.github.bayang.jelu.dto.ShelfDto 6 | import io.github.bayang.jelu.dto.UserDto 7 | import io.github.bayang.jelu.errors.JeluValidationException 8 | import org.springframework.stereotype.Component 9 | import org.springframework.transaction.annotation.Transactional 10 | import java.util.UUID 11 | 12 | @Component 13 | class ShelfService( 14 | private val shelfRepository: ShelfRepository, 15 | ) { 16 | 17 | @Transactional 18 | fun save(createShelfDto: CreateShelfDto, user: UserDto): ShelfDto { 19 | val userShelves = find(user, null, null) 20 | if (userShelves.size >= 10) { 21 | throw JeluValidationException("Maximum number of shelves reaches") 22 | } 23 | return shelfRepository.save(createShelfDto, user).toShelfDto() 24 | } 25 | 26 | @Transactional 27 | fun find( 28 | user: UserDto?, 29 | name: String?, 30 | targetId: UUID?, 31 | ): List { 32 | return shelfRepository.find(user, name, targetId).map { it.toShelfDto() } 33 | } 34 | 35 | @Transactional 36 | fun findById( 37 | id: UUID, 38 | ): ShelfDto = shelfRepository.findById(id).toShelfDto() 39 | 40 | @Transactional 41 | fun delete(shelfId: UUID) { 42 | shelfRepository.delete(shelfId) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/search/MultiLingualAnalyzer.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.search 2 | 3 | import org.apache.lucene.analysis.Analyzer 4 | import org.apache.lucene.analysis.LowerCaseFilter 5 | import org.apache.lucene.analysis.TokenStream 6 | import org.apache.lucene.analysis.Tokenizer 7 | import org.apache.lucene.analysis.cjk.CJKBigramFilter 8 | import org.apache.lucene.analysis.cjk.CJKWidthFilter 9 | import org.apache.lucene.analysis.miscellaneous.ASCIIFoldingFilter 10 | import org.apache.lucene.analysis.standard.StandardTokenizer 11 | 12 | /** 13 | * see https://github.com/gotson/komga/tree/master/komga/src/main/kotlin/org/gotson/komga/infrastructure/search 14 | * for all the lucene related stuff 15 | */ 16 | open class MultiLingualAnalyzer : Analyzer() { 17 | override fun createComponents(fieldName: String): TokenStreamComponents { 18 | val source: Tokenizer = StandardTokenizer() 19 | // run the widthfilter first before bigramming, it sometimes combines characters. 20 | var filter: TokenStream = CJKWidthFilter(source) 21 | filter = LowerCaseFilter(filter) 22 | filter = CJKBigramFilter(filter) 23 | filter = ASCIIFoldingFilter(filter) 24 | return TokenStreamComponents(source, filter) 25 | } 26 | 27 | override fun normalize(fieldName: String?, `in`: TokenStream): TokenStream { 28 | var filter: TokenStream = CJKWidthFilter(`in`) 29 | filter = LowerCaseFilter(filter) 30 | filter = ASCIIFoldingFilter(filter) 31 | return filter 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/config/SmartHttpSessionIdResolver.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.config 2 | 3 | import jakarta.servlet.http.HttpServletRequest 4 | import jakarta.servlet.http.HttpServletResponse 5 | import org.springframework.session.web.http.CookieHttpSessionIdResolver 6 | import org.springframework.session.web.http.CookieSerializer 7 | import org.springframework.session.web.http.HeaderHttpSessionIdResolver 8 | import org.springframework.session.web.http.HttpSessionIdResolver 9 | 10 | class SmartHttpSessionIdResolver( 11 | private val sessionHeaderName: String, 12 | cookieSerializer: CookieSerializer, 13 | ) : HttpSessionIdResolver { 14 | private val cookie = CookieHttpSessionIdResolver().apply { setCookieSerializer(cookieSerializer) } 15 | private val header = HeaderHttpSessionIdResolver(sessionHeaderName) 16 | 17 | override fun resolveSessionIds(request: HttpServletRequest): List = request.getResolver().resolveSessionIds(request) 18 | 19 | override fun setSessionId( 20 | request: HttpServletRequest, 21 | response: HttpServletResponse, 22 | sessionId: String, 23 | ) { 24 | request.getResolver().setSessionId(request, response, sessionId) 25 | } 26 | 27 | override fun expireSession( 28 | request: HttpServletRequest, 29 | response: HttpServletResponse, 30 | ) { 31 | request.getResolver().expireSession(request, response) 32 | } 33 | 34 | private fun HttpServletRequest.getResolver() = if (this.getHeader(sessionHeaderName) != null) header else cookie 35 | } 36 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/config/OpenApiConfig.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.config 2 | 3 | import io.swagger.v3.oas.models.Components 4 | import io.swagger.v3.oas.models.ExternalDocumentation 5 | import io.swagger.v3.oas.models.OpenAPI 6 | import io.swagger.v3.oas.models.info.Info 7 | import io.swagger.v3.oas.models.info.License 8 | import io.swagger.v3.oas.models.security.SecurityScheme 9 | import io.swagger.v3.oas.models.servers.Server 10 | import org.springframework.context.annotation.Bean 11 | import org.springframework.context.annotation.Configuration 12 | 13 | @Configuration 14 | class OpenApiConfig { 15 | 16 | @Bean 17 | fun openApi(): OpenAPI { 18 | val server = Server() 19 | server.url = "/" 20 | return OpenAPI() 21 | .info( 22 | Info() 23 | .title("Jelu API") 24 | .version("v1.0") 25 | .license(License().name("MIT").url("https://github.com/bayang/jelu/blob/main/LICENSE")), 26 | ) 27 | .externalDocs( 28 | ExternalDocumentation() 29 | .description("jelu documentation") 30 | .url("https://github.com/bayang/jelu"), 31 | ) 32 | .components( 33 | Components() 34 | .addSecuritySchemes( 35 | "basicAuth", 36 | SecurityScheme().type(SecurityScheme.Type.HTTP).scheme("basic"), 37 | ), 38 | ) 39 | .servers(mutableListOf(server)) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dao/TagTable.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dao 2 | 3 | import io.github.bayang.jelu.dto.TagDto 4 | import org.jetbrains.exposed.dao.UUIDEntity 5 | import org.jetbrains.exposed.dao.UUIDEntityClass 6 | import org.jetbrains.exposed.dao.id.EntityID 7 | import org.jetbrains.exposed.dao.id.UUIDTable 8 | import org.jetbrains.exposed.sql.Column 9 | import org.jetbrains.exposed.sql.ReferenceOption 10 | import org.jetbrains.exposed.sql.Table 11 | import org.jetbrains.exposed.sql.javatime.timestamp 12 | import java.util.UUID 13 | 14 | object TagTable : UUIDTable("tag") { 15 | val name: Column = varchar("name", 1000) 16 | val creationDate = timestamp("creation_date") 17 | val modificationDate = timestamp("modification_date") 18 | } 19 | class Tag(id: EntityID) : UUIDEntity(id) { 20 | companion object : UUIDEntityClass(TagTable) 21 | var name by TagTable.name 22 | var creationDate by TagTable.creationDate 23 | var modificationDate by TagTable.modificationDate 24 | fun toTagDto(): TagDto = 25 | TagDto( 26 | id = this.id.value, 27 | creationDate = this.creationDate, 28 | modificationDate = this.modificationDate, 29 | name = this.name, 30 | ) 31 | } 32 | object BookTags : Table(name = "book_tags") { 33 | val book = reference("book", BookTable, fkName = "fk_booktags_book_id", onDelete = ReferenceOption.CASCADE) 34 | val tag = reference("tag", TagTable, fkName = "fk_booktags_tag_id", onDelete = ReferenceOption.CASCADE) 35 | override val primaryKey = PrimaryKey(book, tag, name = "pk_booktag_act") 36 | } 37 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dao/SeriesRatingTable.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dao 2 | 3 | import io.github.bayang.jelu.dto.SeriesRatingDto 4 | import org.jetbrains.exposed.dao.UUIDEntity 5 | import org.jetbrains.exposed.dao.UUIDEntityClass 6 | import org.jetbrains.exposed.dao.id.EntityID 7 | import org.jetbrains.exposed.dao.id.UUIDTable 8 | import org.jetbrains.exposed.sql.Column 9 | import org.jetbrains.exposed.sql.ReferenceOption 10 | import org.jetbrains.exposed.sql.javatime.timestamp 11 | import java.util.UUID 12 | 13 | object SeriesRatingTable : UUIDTable("series_rating") { 14 | val creationDate = timestamp("creation_date") 15 | val modificationDate = timestamp("modification_date") 16 | val user = reference("user", UserTable, onDelete = ReferenceOption.CASCADE) 17 | val series = reference("series", SeriesTable, onDelete = ReferenceOption.CASCADE) 18 | val rating: Column = double(name = "rating") 19 | } 20 | class SeriesRating(id: EntityID) : UUIDEntity(id) { 21 | companion object : UUIDEntityClass(SeriesRatingTable) 22 | var creationDate by SeriesRatingTable.creationDate 23 | var modificationDate by SeriesRatingTable.modificationDate 24 | var user by User referencedOn SeriesRatingTable.user 25 | var series by Series referencedOn SeriesRatingTable.series 26 | var rating by SeriesRatingTable.rating 27 | 28 | fun toSeriesRatingDto(): SeriesRatingDto = SeriesRatingDto( 29 | creationDate = this.creationDate, 30 | modificationDate = this.modificationDate, 31 | rating = this.rating, 32 | userId = this.user.id.value, 33 | seriesId = this.series.id.value, 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/service/DownloadService.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.service 2 | 3 | import io.github.bayang.jelu.utils.imageName 4 | import io.github.oshai.kotlinlogging.KotlinLogging 5 | import org.apache.commons.io.FilenameUtils 6 | import org.springframework.stereotype.Service 7 | import java.io.File 8 | import java.io.FileOutputStream 9 | import java.net.URL 10 | import java.nio.channels.Channels 11 | import java.nio.channels.FileChannel 12 | import java.nio.channels.ReadableByteChannel 13 | 14 | private val logger = KotlinLogging.logger {} 15 | 16 | @Service 17 | class DownloadService { 18 | 19 | fun download(sourceUrl: String, title: String, bookId: String, targetFolder: String): String { 20 | try { 21 | val url: URL = URL(sourceUrl) 22 | logger.debug { "path ${url.path} file ${url.file}" } 23 | val conn = url.openConnection() 24 | conn.setRequestProperty("User-Agent", "jelu-app") 25 | val stream = conn.getInputStream() 26 | var readableByteChannel: ReadableByteChannel = Channels.newChannel(stream) 27 | val filename: String = imageName(title, bookId, FilenameUtils.getExtension(url.path)) 28 | val targetFile: File = File(targetFolder, filename) 29 | val fileOutputStream: FileOutputStream = FileOutputStream(targetFile) 30 | val channel: FileChannel = fileOutputStream.channel 31 | channel.transferFrom(readableByteChannel, 0, Long.MAX_VALUE) 32 | return filename 33 | } catch (e: Exception) { 34 | logger.error("failed to download file from $sourceUrl", e) 35 | throw e 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/jelu-ui/src/components/ProfilePage.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 54 | 55 | 57 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/service/UserMessageService.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.service 2 | 3 | import io.github.bayang.jelu.dao.MessageCategory 4 | import io.github.bayang.jelu.dao.UserMessageRepository 5 | import io.github.bayang.jelu.dto.CreateUserMessageDto 6 | import io.github.bayang.jelu.dto.UpdateUserMessageDto 7 | import io.github.bayang.jelu.dto.UserDto 8 | import io.github.bayang.jelu.dto.UserMessageDto 9 | import org.springframework.data.domain.Page 10 | import org.springframework.data.domain.Pageable 11 | import org.springframework.stereotype.Component 12 | import org.springframework.transaction.annotation.Transactional 13 | import java.util.UUID 14 | 15 | @Component 16 | class UserMessageService(private val userMessageRepository: UserMessageRepository) { 17 | 18 | @Transactional 19 | fun save(createUserMessageDto: CreateUserMessageDto, user: UserDto): UserMessageDto { 20 | return userMessageRepository.save(createUserMessageDto, user).toUserMessageDto() 21 | } 22 | 23 | @Transactional 24 | fun find( 25 | user: UserDto, 26 | read: Boolean?, 27 | messageCategories: List?, 28 | pageable: Pageable, 29 | ): Page { 30 | return userMessageRepository.find(user, read, messageCategories, pageable).map { it.toUserMessageDto() } 31 | } 32 | 33 | @Transactional 34 | fun update(userMessageId: UUID, updateDto: UpdateUserMessageDto): UserMessageDto { 35 | return userMessageRepository.update(userMessageId, updateDto).toUserMessageDto() 36 | } 37 | 38 | @Transactional 39 | fun delete(userMessageId: UUID) { 40 | userMessageRepository.delete(userMessageId) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dao/ShelfRepository.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dao 2 | 3 | import io.github.bayang.jelu.dto.CreateShelfDto 4 | import io.github.bayang.jelu.dto.UserDto 5 | import io.github.bayang.jelu.utils.nowInstant 6 | import io.github.oshai.kotlinlogging.KotlinLogging 7 | import org.jetbrains.exposed.sql.andWhere 8 | import org.jetbrains.exposed.sql.selectAll 9 | import org.springframework.stereotype.Repository 10 | import java.time.Instant 11 | import java.util.UUID 12 | 13 | private val logger = KotlinLogging.logger {} 14 | 15 | @Repository 16 | class ShelfRepository { 17 | 18 | fun save(createShelfDto: CreateShelfDto, user: UserDto): Shelf { 19 | val instant: Instant = nowInstant() 20 | return Shelf.new { 21 | this.creationDate = instant 22 | this.modificationDate = instant 23 | this.user = User[user.id!!] 24 | this.name = createShelfDto.name 25 | this.targetId = createShelfDto.targetId 26 | } 27 | } 28 | 29 | fun find( 30 | user: UserDto?, 31 | name: String?, 32 | targetId: UUID?, 33 | ): List { 34 | val query = ShelfTable.selectAll() 35 | user?.let { 36 | query.andWhere { ShelfTable.user eq user.id } 37 | } 38 | name?.let { 39 | query.andWhere { ShelfTable.name like(formatLike(name)) } 40 | } 41 | targetId?.let { 42 | query.andWhere { ShelfTable.targetId eq(targetId) } 43 | } 44 | return Shelf.wrapRows(query).toList() 45 | } 46 | 47 | fun findById( 48 | id: UUID, 49 | ): Shelf = Shelf[id] 50 | 51 | fun delete(shelfId: UUID) { 52 | Shelf[shelfId].delete() 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/test/resources/csv-import/goodreads-duplicate-events.csv: -------------------------------------------------------------------------------- 1 | Book Id,Title,Author,Author l-f,Additional Authors,ISBN,ISBN13,My Rating,Average Rating,Publisher,Binding,Number of Pages,Year Published,Original Publication Year,Date Read,Date Added,Bookshelves,Bookshelves with positions,Exclusive Shelf,My Review,Spoiler,Private Notes,Read Count,Recommended For,Recommended By,Owned Copies,Original Purchase Date,Original Purchase Location,Condition,Condition Description,BCID 2 | 70561,The Gulag Archipelago 1918–1956 (Abridged),Aleksandr Solzhenitsyn,"Solzhenitsyn, Aleksandr",Edward E. Ericson Jr.,"=""0060007761""","=""9780060007768""",0,4.31,HarperCollins,Paperback,472,2002,1973,,2019/01/29,currently-reading,currently-reading (#1),currently-reading,,,,1,,,0,,,,, 3 | 30659,Meditations,Marcus Aurelius,"Aurelius, Marcus","Martin Hammond, Albert Wittstock, عادل مصطفى, Simone Mooij-Valk, Diskin Clay","=""0140449337""","=""9780140449334""",5,4.24,Penguin Books,Paperback,303,2006,180,2020/11/15,2020/06/25,,,read,,,,1,,,0,,,,, 4 | 51893,Thus Spoke Zarathustra,Friedrich Nietzsche,"Nietzsche, Friedrich",Walter Kaufmann,"=""""","=""""",0,4.06,Penguin Books,Paperback,327,1978,1883,,2020/02/25,to-read,to-read (#42),to-read,,,,0,,,0,,,,, 5 | 511228,Cowl,Neal Asher,"Asher, Neal",,"=""0765352796""","=""9780765352798""",3,3.78,Tor Science Fiction,Paperback,432,2006,2004,2020/02/23,2019/11/29,,,read,,,,1,,,0,,,,, 6 | 28335698,"Tiamat's Wrath (The Expanse, #8)",James S.A. Corey,"Corey, James S.A.",,"=""0316332879""","=""9780316332873""",4,4.57,Orbit Books,Hardcover,534,2019,2019,,2019/02/13,,,read,,,,1,,,0,,,,, 7 | 35606041,"A Little Hatred (The Age of Madness, #1)",Joe Abercrombie,"Abercrombie, Joe",,"=""031618716X""","=""9780316187169""",4,4.45,Orbit,Hardcover,480,2019,2019,2020/02/09,2020/01/31,,,read,,,,1,,,0,,,,, -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dao/UserTable.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dao 2 | 3 | import io.github.bayang.jelu.dto.UserDto 4 | import org.jetbrains.exposed.dao.UUIDEntity 5 | import org.jetbrains.exposed.dao.UUIDEntityClass 6 | import org.jetbrains.exposed.dao.id.EntityID 7 | import org.jetbrains.exposed.dao.id.UUIDTable 8 | import org.jetbrains.exposed.sql.Column 9 | import org.jetbrains.exposed.sql.javatime.timestamp 10 | import java.util.* 11 | 12 | object UserTable : UUIDTable("user") { 13 | val creationDate = timestamp("creation_date") 14 | val modificationDate = timestamp("modification_date") 15 | val login: Column = varchar("login", 50) 16 | val password: Column = varchar("password", 1000) 17 | val isAdmin: Column = bool("is_admin") 18 | val provider = enumerationByName("provider", 200, Provider::class) 19 | } 20 | 21 | class User(id: EntityID) : UUIDEntity(id) { 22 | fun toUserDto(): UserDto = UserDto( 23 | id = this.id.value, 24 | creationDate = this.creationDate, 25 | modificationDate = this.modificationDate, 26 | login = this.login, 27 | password = "****", 28 | isAdmin = this.isAdmin, 29 | provider = this.provider, 30 | ) 31 | 32 | companion object : UUIDEntityClass(UserTable) 33 | var creationDate by UserTable.creationDate 34 | var modificationDate by UserTable.modificationDate 35 | var login by UserTable.login 36 | var password by UserTable.password 37 | var isAdmin by UserTable.isAdmin 38 | var provider by UserTable.provider 39 | val userBooks by UserBook referrersOn UserBookTable.book 40 | } 41 | 42 | enum class Provider { 43 | LDAP, 44 | JELU_DB, 45 | PROXY, 46 | OIDC, 47 | } 48 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/service/BookQuoteService.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.service 2 | 3 | import io.github.bayang.jelu.dao.BookQuoteRepository 4 | import io.github.bayang.jelu.dao.Visibility 5 | import io.github.bayang.jelu.dto.BookQuoteDto 6 | import io.github.bayang.jelu.dto.CreateBookQuoteDto 7 | import io.github.bayang.jelu.dto.UpdateBookQuoteDto 8 | import io.github.bayang.jelu.dto.UserDto 9 | import org.springframework.data.domain.Page 10 | import org.springframework.data.domain.Pageable 11 | import org.springframework.stereotype.Component 12 | import org.springframework.transaction.annotation.Transactional 13 | import java.util.UUID 14 | 15 | @Component 16 | class BookQuoteService( 17 | private val bookQuoteRepository: BookQuoteRepository, 18 | ) { 19 | 20 | @Transactional 21 | fun save(bookQuoteDto: CreateBookQuoteDto, user: UserDto): BookQuoteDto { 22 | return bookQuoteRepository.save(bookQuoteDto, user).toBookQuoteDto() 23 | } 24 | 25 | @Transactional 26 | fun findById(bookQuoteId: UUID): BookQuoteDto = bookQuoteRepository.findById(bookQuoteId).toBookQuoteDto() 27 | 28 | @Transactional 29 | fun find( 30 | userId: UUID?, 31 | bookId: UUID?, 32 | visibility: Visibility?, 33 | pageable: Pageable, 34 | ): Page { 35 | return bookQuoteRepository.find(userId, bookId, visibility, pageable).map { it.toBookQuoteDto() } 36 | } 37 | 38 | @Transactional 39 | fun update(bookQuoteId: UUID, updateBookQuoteDto: UpdateBookQuoteDto): BookQuoteDto { 40 | return bookQuoteRepository.update(bookQuoteId, updateBookQuoteDto).toBookQuoteDto() 41 | } 42 | 43 | @Transactional 44 | fun delete(bookQuoteId: UUID) = bookQuoteRepository.delete(bookQuoteId) 45 | } 46 | -------------------------------------------------------------------------------- /src/jelu-ui/src/components/QuotesDisplay.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 69 | 70 | 72 | -------------------------------------------------------------------------------- /src/jelu-ui/vite.config.mts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { defineConfig } from 'vite' 3 | import vue from '@vitejs/plugin-vue' 4 | import { VitePWA } from 'vite-plugin-pwa' 5 | import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite' 6 | import tailwindcss from "@tailwindcss/vite"; 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig({ 10 | // publicDir: "assets", 11 | 12 | // base : 'http://localhost:11111/', 13 | plugins: [ 14 | vue(), 15 | VueI18nPlugin({ 16 | include: path.resolve(__dirname, './src/locales/**') 17 | }), 18 | VitePWA({ 19 | includeAssets: ['favicon.ico', 'apple-touch-icon.png'], 20 | registerType: 'autoUpdate', 21 | useCredentials: true, 22 | strategies: "generateSW", 23 | workbox: { 24 | maximumFileSizeToCacheInBytes: 4000000 25 | }, 26 | injectRegister: "script-defer", 27 | manifest: { 28 | name: 'Jelu', 29 | short_name: 'Jelu', 30 | description: 'Jelu read tracker app', 31 | theme_color: '#f7f5d1', 32 | icons: [ 33 | { 34 | src: 'android-chrome-192x192.png', 35 | sizes: '192x192', 36 | type: 'image/png', 37 | }, 38 | { 39 | src: 'android-chrome-512x512.png', 40 | sizes: '512x512', 41 | type: 'image/png', 42 | } 43 | ] 44 | } 45 | }), 46 | tailwindcss(), 47 | ], 48 | server : { 49 | proxy : { 50 | '/files/': 'http://localhost:11111/', 51 | '/exports/': 'http://localhost:11111/' 52 | } 53 | }, 54 | build: { 55 | // emptyOutDir: true, 56 | // rollupOptions: { 57 | // external: [ 58 | // /files/ 59 | // ] 60 | // } 61 | } 62 | }) 63 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/service/ReviewService.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.service 2 | 3 | import io.github.bayang.jelu.dao.ReviewRepository 4 | import io.github.bayang.jelu.dao.Visibility 5 | import io.github.bayang.jelu.dto.CreateReviewDto 6 | import io.github.bayang.jelu.dto.ReviewDto 7 | import io.github.bayang.jelu.dto.UpdateReviewDto 8 | import io.github.bayang.jelu.dto.UserDto 9 | import org.springframework.data.domain.Page 10 | import org.springframework.data.domain.Pageable 11 | import org.springframework.stereotype.Component 12 | import org.springframework.transaction.annotation.Transactional 13 | import java.time.LocalDate 14 | import java.util.UUID 15 | 16 | @Component 17 | class ReviewService( 18 | private val reviewRepository: ReviewRepository, 19 | private val bookService: BookService, 20 | ) { 21 | 22 | @Transactional 23 | fun save(reviewDto: CreateReviewDto, user: UserDto): ReviewDto { 24 | return reviewRepository.save(reviewDto, user).toReviewDto() 25 | } 26 | 27 | @Transactional 28 | fun findById(reviewId: UUID): ReviewDto = reviewRepository.findById(reviewId).toReviewDto() 29 | 30 | @Transactional 31 | fun find( 32 | userId: UUID?, 33 | bookId: UUID?, 34 | visibility: Visibility?, 35 | after: LocalDate?, 36 | before: LocalDate?, 37 | pageable: Pageable, 38 | ): Page { 39 | return reviewRepository.find(userId, bookId, visibility, after, before, pageable).map { it.toReviewDto() } 40 | } 41 | 42 | @Transactional 43 | fun update(reviewId: UUID, updateReviewDto: UpdateReviewDto): ReviewDto { 44 | return reviewRepository.update(reviewId, updateReviewDto).toReviewDto() 45 | } 46 | 47 | @Transactional 48 | fun delete(reviewId: UUID) = reviewRepository.delete(reviewId) 49 | } 50 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dao/CustomListTable.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dao 2 | 3 | import io.github.bayang.jelu.dto.CustomListDto 4 | import org.jetbrains.exposed.dao.UUIDEntity 5 | import org.jetbrains.exposed.dao.UUIDEntityClass 6 | import org.jetbrains.exposed.dao.id.EntityID 7 | import org.jetbrains.exposed.dao.id.UUIDTable 8 | import org.jetbrains.exposed.sql.Column 9 | import org.jetbrains.exposed.sql.ReferenceOption 10 | import org.jetbrains.exposed.sql.javatime.timestamp 11 | import java.util.UUID 12 | 13 | object CustomListTable : UUIDTable("custom_list") { 14 | val creationDate = timestamp("creation_date") 15 | val modificationDate = timestamp("modification_date") 16 | val user = reference("user", UserTable, onDelete = ReferenceOption.CASCADE) 17 | val name: Column = varchar("name", 5000) 18 | val tags: Column = varchar("tags", 5000) 19 | val public: Column = bool("public") 20 | val actionable: Column = bool("actionable") 21 | } 22 | class CustomList(id: EntityID) : UUIDEntity(id) { 23 | companion object : UUIDEntityClass(CustomListTable) 24 | var creationDate by CustomListTable.creationDate 25 | var modificationDate by CustomListTable.modificationDate 26 | var user by User referencedOn CustomListTable.user 27 | var name by CustomListTable.name 28 | var tags by CustomListTable.tags 29 | var public by CustomListTable.public 30 | var actionable by CustomListTable.actionable 31 | 32 | fun toCustomListDto(): CustomListDto = CustomListDto( 33 | id = this.id.value, 34 | name = this.name, 35 | creationDate = this.creationDate, 36 | modificationDate = this.modificationDate, 37 | tags = this.tags, 38 | public = this.public, 39 | actionable = this.actionable, 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /src/jelu-ui/src/components/ReviewDetail.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 60 | 61 | 63 | -------------------------------------------------------------------------------- /src/jelu-ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jelu-ui", 3 | "version": "0.75.2", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite --debug", 7 | "build": "vite build", 8 | "serve": "vite preview --debug", 9 | "tsc": "vue-tsc --noEmit", 10 | "install-build": "npm ci && vite build" 11 | }, 12 | "dependencies": { 13 | "@kangc/v-md-editor": "2.3.18", 14 | "@mdi/font": "7.4.47", 15 | "@oruga-ui/oruga-next": "0.11.6", 16 | "@oruga-ui/theme-oruga": "0.7.1", 17 | "@splidejs/splide": "4.1.4", 18 | "@splidejs/vue-splide": "0.6.12", 19 | "@vueuse/core": "14.1.0", 20 | "@vueuse/router": "14.1.0", 21 | "@w0s/isbn-verify": "3.1.2", 22 | "axios": "1.13.2", 23 | "chart.js": "4.5.1", 24 | "daisyui": "5.0.35", 25 | "dayjs": "1.11.19", 26 | "floating-vue": "5.2.2", 27 | "sweetalert2": "11.26.10", 28 | "theme-change": "2.5.0", 29 | "vue": "3.5.26", 30 | "vue-avatar-sdh": "1.0.3", 31 | "vue-chartjs": "5.3.3", 32 | "vue-i18n": "11.2.2", 33 | "vue-qrcode-reader": "5.7.3", 34 | "vue-router": "4.6.4", 35 | "vue3-datepicker": "0.4.0", 36 | "vuedraggable": "4.1.0", 37 | "vuejs-sidebar-menu": "1.0.0", 38 | "vuex": "4.0.2" 39 | }, 40 | "devDependencies": { 41 | "@eslint/js": "9.39.2", 42 | "@intlify/unplugin-vue-i18n": "11.0.3", 43 | "@tailwindcss/line-clamp": "0.4.4", 44 | "@tailwindcss/typography": "0.5.19", 45 | "@tailwindcss/vite": "4.1.11", 46 | "@types/qs": "6.14.0", 47 | "@vitejs/plugin-vue": "6.0.3", 48 | "autoprefixer": "10.4.14", 49 | "eslint": "9.39.2", 50 | "eslint-plugin-vue": "10.6.2", 51 | "postcss": "8.4.21", 52 | "qs": "6.14.0", 53 | "tailwindcss": "4.1.18", 54 | "typescript": "5.8.3", 55 | "typescript-eslint": "8.49.0", 56 | "vite": "6.4.1", 57 | "vite-plugin-pwa": "1.2.0", 58 | "vue-tsc": "3.1.8" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/service/metadata/PluginInfoHolder.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.service.metadata 2 | 3 | import io.github.bayang.jelu.config.JeluProperties 4 | import io.github.bayang.jelu.dto.PluginInfo 5 | import io.github.bayang.jelu.utils.PluginInfoComparator 6 | import org.springframework.stereotype.Service 7 | 8 | @Service 9 | class PluginInfoHolder( 10 | private val properties: JeluProperties, 11 | ) { 12 | 13 | companion object { 14 | const val calibre = "calibre" 15 | const val jelu_debug = "jelu-debug" 16 | } 17 | 18 | private var pluginsList: List = listOf() 19 | private var pluginsComputed = false 20 | private var calibreComputed = false 21 | private var _calibreEnabled = false 22 | 23 | fun plugins(): List { 24 | // return cached computation 25 | if (pluginsComputed) return pluginsList 26 | // compute plugins list and cache it 27 | val pluginInfoList: List? = 28 | properties.metadataProviders?.filter { it.isEnabled }?.map { PluginInfo(name = it.name, order = it.order) } 29 | ?.toList() 30 | val plugins = mutableListOf() 31 | if (calibreEnabled()) { 32 | plugins.add(PluginInfo(name = calibre, order = properties.metadata.calibre.order)) 33 | } 34 | if (!pluginInfoList.isNullOrEmpty()) { 35 | plugins.addAll(pluginInfoList) 36 | } 37 | plugins.sortWith(PluginInfoComparator) 38 | pluginsList = plugins 39 | pluginsComputed = true 40 | return pluginsList 41 | } 42 | 43 | fun calibreEnabled(): Boolean { 44 | if (calibreComputed) return _calibreEnabled 45 | _calibreEnabled = !properties.metadata.calibre.path.isNullOrEmpty() 46 | calibreComputed = true 47 | return _calibreEnabled 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/main/java/io/github/bayang/jelu/dialect/SqliteDialect.java: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dialect; 2 | 3 | import org.springframework.data.relational.core.dialect.AbstractDialect; 4 | import org.springframework.data.relational.core.dialect.LimitClause; 5 | import org.springframework.data.relational.core.dialect.LockClause; 6 | import org.springframework.data.relational.core.sql.LockOptions; 7 | 8 | /** 9 | * Provides SQLite-specific {@code limit} and {@code offset} clauses for constructing SQL statements 10 | */ 11 | public class SqliteDialect extends AbstractDialect { 12 | public static final SqliteDialect INSTANCE = new SqliteDialect(); 13 | 14 | private static final LimitClause LIMIT_CLAUSE = new LimitClause() { 15 | @Override 16 | public String getLimit(long limit) { 17 | return "LIMIT " + limit; 18 | } 19 | 20 | @Override 21 | public String getOffset(long offset) { 22 | return "OFFSET " + offset; 23 | } 24 | 25 | @Override 26 | public String getLimitOffset(long limit, long offset) { 27 | return this.getLimit(limit) + " " + getOffset(offset); 28 | } 29 | 30 | @Override 31 | public Position getClausePosition() { 32 | return Position.AFTER_ORDER_BY; 33 | } 34 | }; 35 | 36 | private static final LockClause LOCK_CLAUSE = new LockClause() { 37 | @Override 38 | public String getLock(LockOptions lockOptions) { 39 | return ""; 40 | } 41 | 42 | @Override 43 | public Position getClausePosition() { 44 | return Position.AFTER_ORDER_BY; 45 | } 46 | }; 47 | 48 | protected SqliteDialect() {} 49 | 50 | @Override 51 | public LimitClause limit() { 52 | return LIMIT_CLAUSE; 53 | } 54 | 55 | @Override 56 | public LockClause lock() { 57 | return LOCK_CLAUSE; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/jelu-ui/src/composables/pagination.ts: -------------------------------------------------------------------------------- 1 | import { useRouteQuery } from '@vueuse/router'; 2 | import { computed, Ref, ref, watch } from 'vue'; 3 | import { useRoute } from 'vue-router'; 4 | import { useMagicKeys } from '@vueuse/core' 5 | 6 | const keys = useMagicKeys() 7 | const shiftLeft = keys['Shift+Left'] 8 | const shiftRight = keys['Shift+Right'] 9 | 10 | export default function usePagination(pageSize = 24) { 11 | const route = useRoute() 12 | console.log(route.query) 13 | const page: Ref = useRouteQuery('page', '1') 14 | const total: Ref = ref(0) 15 | const perPage: Ref = ref(pageSize) 16 | const getPageIsLoading: Ref = ref(false) 17 | const pageCount = computed(() => { 18 | return Math.ceil(total.value / perPage.value) 19 | }) 20 | const updatePage = (newVal: number) => { 21 | if (newVal < 1 || newVal > pageCount.value) { 22 | return 23 | } 24 | page.value = newVal.toString(10) 25 | getPageIsLoading.value = true 26 | } 27 | const updatePageLoading = (newVal: boolean) => { 28 | getPageIsLoading.value = newVal 29 | } 30 | // despite what oruga doc says, if page is a string, it returns page +1 31 | // on clicking next, so for page 1, 1+1 eq 11 ... and so on 32 | // instead of 1+1 eq 2, so return a number prop 33 | const pageAsNumber = computed(() => Number.parseInt(page.value)) 34 | 35 | watch(shiftLeft, (v) => { 36 | if (v) { 37 | updatePage(pageAsNumber.value - 1) 38 | } 39 | }) 40 | 41 | watch(shiftRight, (v) => { 42 | if (v) { 43 | updatePage(pageAsNumber.value + 1) 44 | } 45 | }) 46 | 47 | return { 48 | total, 49 | page, 50 | pageAsNumber, 51 | perPage, 52 | updatePage, 53 | getPageIsLoading, 54 | updatePageLoading, 55 | pageCount 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/jelu-ui/src/components/DataAdmin.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 62 | 63 | 65 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/service/ImportService.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.service 2 | 3 | import io.github.bayang.jelu.dao.ImportEntity 4 | import io.github.bayang.jelu.dao.ImportRepository 5 | import io.github.bayang.jelu.dao.ProcessingStatus 6 | import io.github.bayang.jelu.dto.ImportDto 7 | import org.springframework.stereotype.Component 8 | import org.springframework.transaction.annotation.Transactional 9 | import java.util.UUID 10 | 11 | @Component 12 | class ImportService(private val importRepository: ImportRepository) { 13 | 14 | @Transactional 15 | fun save( 16 | entity: ImportDto, 17 | processingStatus: ProcessingStatus, 18 | userId: UUID, 19 | shouldFetchMetadata: Boolean, 20 | ) { 21 | importRepository.save(entity, processingStatus, userId, shouldFetchMetadata) 22 | } 23 | 24 | @Transactional 25 | fun updateStatus( 26 | oldStatus: ProcessingStatus, 27 | newStatus: ProcessingStatus, 28 | userId: UUID, 29 | ): Int = importRepository.updateStatus(oldStatus, newStatus, userId) 30 | 31 | @Transactional 32 | fun getByprocessingStatusAndUser( 33 | processingStatus: ProcessingStatus, 34 | userId: UUID, 35 | ): List = importRepository.getByprocessingStatusAndUser(processingStatus, userId) 36 | 37 | @Transactional 38 | fun updateStatus(entityId: UUID, newStatus: ProcessingStatus): Int = importRepository.updateStatus(entityId, newStatus) 39 | 40 | @Transactional 41 | fun countByprocessingStatusAndUser( 42 | processingStatus: ProcessingStatus, 43 | userId: UUID, 44 | ): Long = importRepository.countByprocessingStatusAndUser(processingStatus, userId) 45 | 46 | @Transactional 47 | fun deleteByprocessingStatusAndUser( 48 | processingStatus: ProcessingStatus, 49 | userId: UUID, 50 | ): Int = importRepository.deleteByprocessingStatusAndUser(processingStatus, userId) 51 | } 52 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dao/BookQuoteTable.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dao 2 | 3 | import io.github.bayang.jelu.dto.BookQuoteDto 4 | import org.jetbrains.exposed.dao.UUIDEntity 5 | import org.jetbrains.exposed.dao.UUIDEntityClass 6 | import org.jetbrains.exposed.dao.id.EntityID 7 | import org.jetbrains.exposed.dao.id.UUIDTable 8 | import org.jetbrains.exposed.sql.Column 9 | import org.jetbrains.exposed.sql.ReferenceOption 10 | import org.jetbrains.exposed.sql.javatime.timestamp 11 | import java.util.UUID 12 | 13 | object BookQuoteTable : UUIDTable("book_quote") { 14 | val creationDate = timestamp("creation_date") 15 | val modificationDate = timestamp("modification_date") 16 | val user = reference("user", UserTable, onDelete = ReferenceOption.CASCADE) 17 | val book = reference("book", BookTable, onDelete = ReferenceOption.CASCADE) 18 | val text: Column = varchar("text", 500000) 19 | val visibility = enumerationByName("visibility", 200, Visibility::class) 20 | val position: Column = varchar("position", 300).nullable() 21 | } 22 | class BookQuote(id: EntityID) : UUIDEntity(id) { 23 | companion object : UUIDEntityClass(BookQuoteTable) 24 | var creationDate by BookQuoteTable.creationDate 25 | var modificationDate by BookQuoteTable.modificationDate 26 | var user by User referencedOn BookQuoteTable.user 27 | var book by Book referencedOn BookQuoteTable.book 28 | var text by BookQuoteTable.text 29 | var visibility by BookQuoteTable.visibility 30 | var position by BookQuoteTable.position 31 | 32 | fun toBookQuoteDto(): BookQuoteDto = BookQuoteDto( 33 | id = this.id.value, 34 | creationDate = this.creationDate, 35 | modificationDate = this.modificationDate, 36 | text = this.text, 37 | visibility = this.visibility, 38 | user = this.user.id.value, 39 | book = this.book.id.value, 40 | position = this.position, 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/jelu-ui/src/composables/bulkEdition.ts: -------------------------------------------------------------------------------- 1 | import { useOruga } from "@oruga-ui/oruga-next"; 2 | import { Ref, ref } from 'vue'; 3 | import BulkEditModal from "../components/BulkEditModal.vue"; 4 | 5 | type VoidFunc = () => void; 6 | 7 | export default function useBulkEdition(onModalClosed: VoidFunc) { 8 | 9 | const oruga = useOruga(); 10 | const showSelect = ref(false) 11 | const selectAll = ref(false) 12 | const checkedCards: Ref> = ref([]) 13 | 14 | const cardChecked = (id: string | null, checked: boolean) => { 15 | console.log(`received ${id}, checked ? ${checked}`) 16 | if (id != null) { 17 | if (checked) { 18 | if (!checkedCards.value.includes(id)) { 19 | checkedCards.value.push(id) 20 | } 21 | } else { 22 | checkedCards.value.forEach((item, index) => { 23 | if (item === id) checkedCards.value.splice(index, 1); 24 | }); 25 | } 26 | } 27 | console.log(`ids ${checkedCards.value}`) 28 | } 29 | 30 | function modalClosed() { 31 | console.log("modal closed from bulk composable") 32 | onModalClosed() 33 | } 34 | 35 | const toggleEdit = (ids: Array) => { 36 | if (ids != null) { 37 | console.log("ids") 38 | console.log(ids) 39 | oruga.modal.open({ 40 | component: BulkEditModal, 41 | trapFocus: true, 42 | active: true, 43 | canCancel: ['x', 'button', 'outside'], 44 | scroll: 'clip', 45 | props: { 46 | "ids" : ids, 47 | }, 48 | onClose: modalClosed 49 | }); 50 | } 51 | } 52 | 53 | return { 54 | showSelect, 55 | selectAll, 56 | checkedCards, 57 | cardChecked, 58 | toggleEdit, 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/service/metadata/FetchMetadataService.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.service.metadata 2 | 3 | import io.github.bayang.jelu.dto.MetadataDto 4 | import io.github.bayang.jelu.dto.MetadataRequestDto 5 | import io.github.bayang.jelu.service.metadata.providers.IMetaDataProvider 6 | import io.github.bayang.jelu.utils.PluginInfoComparator 7 | import io.github.oshai.kotlinlogging.KotlinLogging 8 | import org.springframework.stereotype.Service 9 | import java.util.Optional 10 | 11 | private val logger = KotlinLogging.logger {} 12 | 13 | @Service 14 | class FetchMetadataService( 15 | private val providers: List, 16 | private val pluginInfoHolder: PluginInfoHolder, 17 | ) { 18 | 19 | fun fetchMetadata( 20 | metadataRequestDto: MetadataRequestDto, 21 | config: Map = mapOf(), 22 | ): MetadataDto { 23 | var pluginsToUse = if (metadataRequestDto.plugins.isNullOrEmpty()) pluginInfoHolder.plugins() else metadataRequestDto.plugins 24 | pluginsToUse = pluginsToUse.toMutableList() 25 | // pluginInfoHolder sorts plugins, but plugins received via metadataRequestDto 26 | // might not be sorted 27 | pluginsToUse.sortWith(PluginInfoComparator) 28 | logger.trace { "plugins to use : $pluginsToUse" } 29 | for (plugin in pluginsToUse) { 30 | logger.trace { "fetching provider for plugin ${plugin.name} with order ${plugin.order} " } 31 | val provider = providers.find { plugin.name.equals(it.name(), true) } 32 | if (provider != null) { 33 | val res: Optional? = provider.fetchMetadata(metadataRequestDto, config) 34 | if (res != null && res.isPresent) { 35 | return res.get() 36 | } 37 | } else { 38 | logger.warn { "could not find provider for plugin info ${plugin.name}" } 39 | } 40 | } 41 | return MetadataDto() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dao/UserMessageTable.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dao 2 | 3 | import io.github.bayang.jelu.dto.UserMessageDto 4 | import org.jetbrains.exposed.dao.UUIDEntity 5 | import org.jetbrains.exposed.dao.UUIDEntityClass 6 | import org.jetbrains.exposed.dao.id.EntityID 7 | import org.jetbrains.exposed.dao.id.UUIDTable 8 | import org.jetbrains.exposed.sql.Column 9 | import org.jetbrains.exposed.sql.ReferenceOption 10 | import org.jetbrains.exposed.sql.javatime.timestamp 11 | import java.util.UUID 12 | 13 | object UserMessageTable : UUIDTable("user_message") { 14 | val creationDate = timestamp("creation_date") 15 | val modificationDate = timestamp("modification_date") 16 | val user = reference("user", UserTable, onDelete = ReferenceOption.CASCADE) 17 | val messageCategory = enumerationByName("category", 200, MessageCategory::class) 18 | val message: Column = varchar("message", 50000).nullable() 19 | val link: Column = varchar("link", 50000).nullable() 20 | val read: Column = bool("read") 21 | } 22 | class UserMessage(id: EntityID) : UUIDEntity(id) { 23 | companion object : UUIDEntityClass(UserMessageTable) 24 | var creationDate by UserMessageTable.creationDate 25 | var modificationDate by UserMessageTable.modificationDate 26 | var user by User referencedOn UserMessageTable.user 27 | var messageCategory by UserMessageTable.messageCategory 28 | var message by UserMessageTable.message 29 | var link by UserMessageTable.link 30 | var read by UserMessageTable.read 31 | 32 | fun toUserMessageDto(): UserMessageDto = UserMessageDto( 33 | id = this.id.value, 34 | message = this.message, 35 | creationDate = this.creationDate, 36 | modificationDate = this.modificationDate, 37 | link = this.link, 38 | read = this.read, 39 | category = this.messageCategory, 40 | ) 41 | } 42 | enum class MessageCategory { 43 | SUCCESS, 44 | INFO, 45 | WARNING, 46 | ERROR, 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/io/github/bayang/jelu/dialect/SqliteDialectProvider.java: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dialect; 2 | 3 | import org.springframework.data.jdbc.repository.config.DialectResolver; 4 | import org.springframework.data.relational.core.dialect.Dialect; 5 | import org.springframework.jdbc.core.JdbcOperations; 6 | 7 | import java.sql.Connection; 8 | import java.sql.DatabaseMetaData; 9 | import java.sql.SQLException; 10 | import java.util.Locale; 11 | import java.util.Optional; 12 | 13 | /** 14 | * Provides a {@link SqliteDialect} from {@link JdbcOperations} using {@link DialectResolver} for Spring Data JDBC. 15 | * Dialect resolution uses Spring's {@code spring.factories} to determine available extensions 16 | */ 17 | public class SqliteDialectProvider implements DialectResolver.JdbcDialectProvider { 18 | @Override 19 | public Optional getDialect(JdbcOperations operations) { 20 | return Optional.ofNullable(operations.execute(this::createDialectIfConnectedToSqliteElseNull)); 21 | } 22 | 23 | private Dialect createDialectIfConnectedToSqliteElseNull(Connection connection) throws SQLException { 24 | return is(connection).connectedToSqlite() ? SqliteDialect.INSTANCE : null; 25 | } 26 | 27 | private static ConnectionWrapper is(Connection connection) { 28 | return new ConnectionWrapper(connection); 29 | } 30 | 31 | private static class ConnectionWrapper { 32 | private static final String PRODUCT_NAME_SQLITE = "sqlite"; 33 | 34 | private final Connection connection; 35 | 36 | private ConnectionWrapper(Connection connection) { 37 | this.connection = connection; 38 | } 39 | 40 | private boolean connectedToSqlite() throws SQLException { 41 | return getProductName().contains(PRODUCT_NAME_SQLITE); 42 | } 43 | 44 | private String getProductName() throws SQLException { 45 | DatabaseMetaData metaData = connection.getMetaData(); 46 | return metaData.getDatabaseProductName().toLowerCase(Locale.ENGLISH); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dao/ReviewTable.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dao 2 | 3 | import io.github.bayang.jelu.dto.ReviewDto 4 | import org.jetbrains.exposed.dao.UUIDEntity 5 | import org.jetbrains.exposed.dao.UUIDEntityClass 6 | import org.jetbrains.exposed.dao.id.EntityID 7 | import org.jetbrains.exposed.dao.id.UUIDTable 8 | import org.jetbrains.exposed.sql.Column 9 | import org.jetbrains.exposed.sql.ReferenceOption 10 | import org.jetbrains.exposed.sql.javatime.timestamp 11 | import java.util.UUID 12 | 13 | object ReviewTable : UUIDTable("review") { 14 | val creationDate = timestamp("creation_date") 15 | val modificationDate = timestamp("modification_date") 16 | val reviewDate = timestamp("review_date") 17 | val user = reference("user", UserTable, onDelete = ReferenceOption.CASCADE) 18 | val book = reference("book", BookTable, onDelete = ReferenceOption.CASCADE) 19 | val text: Column = varchar("text", 500000) 20 | val rating: Column = double(name = "rating") 21 | val visibility = enumerationByName("visibility", 200, Visibility::class) 22 | } 23 | class Review(id: EntityID) : UUIDEntity(id) { 24 | companion object : UUIDEntityClass(ReviewTable) 25 | var creationDate by ReviewTable.creationDate 26 | var modificationDate by ReviewTable.modificationDate 27 | var reviewDate by ReviewTable.reviewDate 28 | var user by User referencedOn ReviewTable.user 29 | var book by Book referencedOn ReviewTable.book 30 | var text by ReviewTable.text 31 | var rating by ReviewTable.rating 32 | var visibility by ReviewTable.visibility 33 | 34 | fun toReviewDto(): ReviewDto = ReviewDto( 35 | id = this.id.value, 36 | creationDate = this.creationDate, 37 | modificationDate = this.modificationDate, 38 | reviewDate = this.reviewDate, 39 | text = this.text, 40 | visibility = this.visibility, 41 | rating = this.rating, 42 | user = this.user.id.value, 43 | book = this.book.id.value, 44 | ) 45 | } 46 | enum class Visibility { 47 | PUBLIC, 48 | PRIVATE, 49 | } 50 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/service/ReadingEventService.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.service 2 | 3 | import io.github.bayang.jelu.dao.ReadingEventRepository 4 | import io.github.bayang.jelu.dao.ReadingEventType 5 | import io.github.bayang.jelu.dto.CreateReadingEventDto 6 | import io.github.bayang.jelu.dto.ReadingEventDto 7 | import io.github.bayang.jelu.dto.UpdateReadingEventDto 8 | import io.github.bayang.jelu.dto.UserDto 9 | import org.springframework.data.domain.Pageable 10 | import org.springframework.stereotype.Component 11 | import org.springframework.transaction.annotation.Transactional 12 | import java.time.LocalDate 13 | import java.util.UUID 14 | 15 | @Component 16 | class ReadingEventService(private val readingEventRepository: ReadingEventRepository) { 17 | 18 | @Transactional 19 | fun findAll( 20 | eventTypes: List?, 21 | userId: UUID?, 22 | bookId: UUID?, 23 | startedAfter: LocalDate?, 24 | startedBefore: LocalDate?, 25 | endedAfter: LocalDate?, 26 | endedBefore: LocalDate?, 27 | pageable: Pageable, 28 | ) = 29 | readingEventRepository.findAll(eventTypes, userId, bookId, startedAfter, startedBefore, endedAfter, endedBefore, pageable).map { it.toReadingEventDto() } 30 | 31 | @Transactional 32 | fun findYears(eventTypes: List?, userId: UUID?, bookId: UUID?) = 33 | readingEventRepository.findYears(eventTypes, userId, bookId) 34 | 35 | @Transactional 36 | fun save(createReadingEventDto: CreateReadingEventDto, user: UserDto): ReadingEventDto { 37 | return readingEventRepository.save(createReadingEventDto, user).toReadingEventDto() 38 | } 39 | 40 | @Transactional 41 | fun updateReadingEvent(readingEventId: UUID, updateReadingEventDto: UpdateReadingEventDto): ReadingEventDto = 42 | readingEventRepository.updateReadingEvent(readingEventId, updateReadingEventDto).toReadingEventDto() 43 | 44 | @Transactional 45 | fun deleteReadingEventById(eventId: UUID) { 46 | readingEventRepository.deleteReadingEventById(eventId) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/config/UserAgentWebAuthenticationDetails.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.config 2 | 3 | import com.fasterxml.jackson.annotation.JsonAutoDetect 4 | import com.fasterxml.jackson.annotation.JsonCreator 5 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties 6 | import com.fasterxml.jackson.annotation.JsonProperty 7 | import com.fasterxml.jackson.annotation.JsonTypeInfo 8 | import jakarta.servlet.http.HttpServletRequest 9 | import org.springframework.http.HttpHeaders 10 | import org.springframework.security.web.authentication.WebAuthenticationDetails 11 | 12 | class UserAgentWebAuthenticationDetails : WebAuthenticationDetails { 13 | 14 | var userAgent: String = "" 15 | 16 | constructor(remoteAddress: String?, sessionId: String?, userAgent: String?) : super(remoteAddress, sessionId) { 17 | if (userAgent != null) { 18 | this.userAgent = userAgent 19 | } 20 | } 21 | 22 | constructor(request: HttpServletRequest) : super(request) { 23 | this.userAgent = request.getHeader(HttpHeaders.USER_AGENT).orEmpty() 24 | } 25 | 26 | override fun toString(): String { 27 | val sb = StringBuilder() 28 | sb.append(javaClass.simpleName).append(" [") 29 | sb.append("RemoteIpAddress=").append(this.remoteAddress).append(", ") 30 | sb.append("UserAgent=").append(this.userAgent).append(", ") 31 | sb.append("SessionId=").append(this.sessionId).append("]") 32 | return sb.toString() 33 | } 34 | } 35 | 36 | @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY) 37 | @JsonIgnoreProperties(ignoreUnknown = true) 38 | @JsonAutoDetect( 39 | fieldVisibility = JsonAutoDetect.Visibility.ANY, 40 | getterVisibility = JsonAutoDetect.Visibility.NONE, 41 | isGetterVisibility = JsonAutoDetect.Visibility.NONE, 42 | creatorVisibility = JsonAutoDetect.Visibility.ANY, 43 | ) 44 | class UserAgentWebAuthenticationDetailsMixin @JsonCreator constructor( 45 | @JsonProperty("remoteAddress") remoteAddress: String?, 46 | @JsonProperty("sessionId") sessionId: String?, 47 | @JsonProperty("userAgent") userAgent: String?, 48 | ) 49 | -------------------------------------------------------------------------------- /src/jelu-ui/src/components/BookQuotes.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 76 | 77 | 79 | -------------------------------------------------------------------------------- /src/jelu-ui/src/model/Book.ts: -------------------------------------------------------------------------------- 1 | import {Author} from "./Author"; 2 | import { ReadingEvent, ReadingEventType } from "./ReadingEvent"; 3 | import { SeriesOrder } from "./Series"; 4 | import { Tag } from "./Tag"; 5 | 6 | export interface Book { 7 | id?: string, 8 | creationDate?: string, 9 | title: string, 10 | isbn10?:string, 11 | isbn13?: string, 12 | summary?: string, 13 | publisher?: string, 14 | image?: string|null, 15 | pageCount?: number|null, 16 | publishedDate?: string|null, 17 | modificationDate?: string, 18 | authors?: Array, 19 | translators?: Array, 20 | narrators?: Array, 21 | tags?: Array, 22 | series?: Array, 23 | googleId?: string, 24 | amazonId?: string, 25 | goodreadsId?: string, 26 | librarythingId?: string, 27 | isfdbId?: string, 28 | openlibraryId?: string, 29 | noosfereId?: string, 30 | inventaireId?: string, 31 | language?: string, 32 | userBookId?: string, 33 | userbook?: UserBook, 34 | } 35 | export interface UserBook { 36 | id?: string, 37 | lastReadingEvent?: ReadingEventType|null, 38 | lastReadingEventDate?: string, 39 | creationDate?: string, 40 | modificationDate?: string, 41 | personalNotes?: string, 42 | owned?: boolean|null, 43 | borrowed?: boolean|null, 44 | toRead?: boolean|null, 45 | book: Book, 46 | readingEvents?: Array|null, 47 | percentRead? : number|null, 48 | currentPageNumber?: number|null, 49 | avgRating?: number|null, 50 | userAvgRating?: number|null, 51 | } 52 | export interface UserBookBulkUpdate { 53 | ids: Array, 54 | toRead?: boolean, 55 | owned?: boolean, 56 | removeTags?: Array, 57 | addTags?: Array, 58 | } 59 | export interface UserBookUpdate { 60 | id?: string, 61 | lastReadingEvent?: ReadingEventType|null, 62 | lastReadingEventDate?: string, 63 | creationDate?: string, 64 | modificationDate?: string, 65 | personalNotes?: string, 66 | owned?: boolean|null, 67 | borrowed?: boolean|null, 68 | toRead?: boolean|null, 69 | book?: Book, 70 | readingEvents?: Array|null, 71 | percentRead? : number|null, 72 | currentPageNumber?: number|null, 73 | } -------------------------------------------------------------------------------- /src/jelu-ui/src/components/SortFilterBar.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 83 | 84 | 87 | -------------------------------------------------------------------------------- /src/test/resources/csv-import/goodreads_library_export-2022.csv: -------------------------------------------------------------------------------- 1 | Book Id,Title,Author,Author l-f,Additional Authors,ISBN,ISBN13,My Rating,Average Rating,Publisher,Binding,Number of Pages,Year Published,Original Publication Year,Date Read,Date Added,Bookshelves,Bookshelves with positions,Exclusive Shelf,My Review,Spoiler,Private Notes,Read Count,Owned Copies 2 | 53105154,La guerre des Gaules,Jules César,"César, Jules",,"=""""","=""9782363071200""",0,4.00,,kindle_edition,,2011,-50,2020/08/23,2020/08/23,,,read,,,,1,0 3 | 22543460,Sept secondes pour devenir un aigle,Thomas Day,"Day, Thomas",,"=""2843441218""","=""9782843441219""",5,3.29,Le Bélial',Paperback,352,2013,2013,2020/08/23,2018/10/25,,,read,,,,1,0 4 | 43216401,Contro Natura Omnibus,Mirka Andolfo,"Andolfo, Mirka",,"=""""","=""9782344032176""",0,3.83,glénat comics,Hardcover,304,2018,2021,,2020/05/09,,,read,,,,1,0 5 | 40725766,La Somme de nos folies,Shih-Li Kow,"Kow, Shih-Li",Frédéric Grellier,"=""""","=""9782843048302""",0,3.91,Éditions Zulma,Paperback,384,2018,2014,,2021/04/02,"to-read, test-aad, avec-espace","to-read (#80), test-aad (#1), avec-espace (#1)",to-read,,,,0,0 6 | 33178099,La vie des arbres,Francis Hallé,"Hallé, Francis",,"=""2227483148""","=""9782227483149""",0,4.19,Bayard Culture,Paperback,70,2011,,2020/11/11,2021/01/10,,,read,,,,1,0 7 | 55819582,La Fabrique des lendemains,Rich Larson,"Larson, Rich",Pierre-Paul Duranstanti,"=""2843449731""","=""9782843449734""",0,4.16,Le Bélial',Paperback,612,2020,2018,,2020/11/09,to-read,to-read (#74),to-read,,,,0,0 8 | 17876040,"Le Crystal des Elfes bleus (Elfes, #1)",Jean-Luc Istin,"Istin, Jean-Luc","Kyko Duarte, Diogo Saito","=""2302027191""","=""9782302027190""",0,3.84,Soleil,Hardcover,54,2013,2013,2021/04/25,2021/03/15,,,read,,,,1,0 9 | 2189436,Quartier lointain,Jirō Taniguchi,"Taniguchi, Jirō",谷口 ジロー,"=""220339644X""","=""9782203396449""",0,4.39,Casterman,Hardcover,405,2006,1998,,2019/03/21,waiting,waiting (#49),waiting,,,,0,0 10 | 10576969,Un Automne à Hanoï,Clément Baloup,"Baloup, Clément",,"=""2849530085""","=""9782849530085""",0,3.00,La Boîte à bulles,Hardcover,48,2004,2004,2021/04/25,2021/04/25,,,read,,,,1,0 11 | 2320102,Le Secret Du Chant Des Baleines,Christopher Moore,"Moore, Christopher",,"=""2070316572""","=""9782070316571""",0,3.75,Gallimard,,414,2006,2003,2021/04/21,2021/04/24,,,read,,,,1,0 12 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dao/UserRepository.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dao 2 | 3 | import io.github.bayang.jelu.dto.CreateUserDto 4 | import io.github.bayang.jelu.dto.UpdateUserDto 5 | import io.github.bayang.jelu.utils.nowInstant 6 | import io.github.oshai.kotlinlogging.KotlinLogging 7 | import org.jetbrains.exposed.sql.SizedIterable 8 | import org.jetbrains.exposed.sql.and 9 | import org.springframework.stereotype.Repository 10 | import java.time.Instant 11 | import java.util.UUID 12 | 13 | private val logger = KotlinLogging.logger {} 14 | 15 | @Repository 16 | class UserRepository { 17 | 18 | fun findAll(searchTerm: String?): SizedIterable { 19 | return if (!searchTerm.isNullOrBlank()) { 20 | User.find { UserTable.login like searchTerm } 21 | } else { 22 | User.all() 23 | } 24 | } 25 | 26 | fun deleteUser(userId: UUID) { 27 | User[userId].delete() 28 | } 29 | 30 | fun countUsers(): Long = User.count() 31 | 32 | fun findByLogin(login: String): SizedIterable = 33 | User.find { UserTable.login eq login } 34 | 35 | fun findByLoginAndProvider(login: String, provider: Provider): SizedIterable = 36 | User.find { UserTable.login eq login and(UserTable.provider eq provider) } 37 | 38 | fun findUserById(id: UUID): User = User[id] 39 | 40 | fun save(user: CreateUserDto): User { 41 | val created = User.new { 42 | login = user.login 43 | val instant: Instant = nowInstant() 44 | creationDate = instant 45 | modificationDate = instant 46 | password = user.password 47 | isAdmin = user.isAdmin 48 | provider = user.provider 49 | } 50 | return created 51 | } 52 | 53 | fun updateUser(userId: UUID, updateUserDto: UpdateUserDto): User { 54 | return User[userId].apply { 55 | this.modificationDate = nowInstant() 56 | this.password = updateUserDto.password 57 | if (updateUserDto.isAdmin != null) { 58 | this.isAdmin = updateUserDto.isAdmin 59 | } 60 | if (updateUserDto.provider != null) { 61 | this.provider = updateUserDto.provider 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/test/kotlin/io/github/bayang/jelu/service/metadata/FileMetadataServiceTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.service.metadata 2 | 3 | import io.github.bayang.jelu.errors.JeluValidationException 4 | import org.junit.jupiter.api.Assertions 5 | import org.junit.jupiter.api.Test 6 | import org.junit.jupiter.api.assertThrows 7 | import org.springframework.beans.factory.annotation.Autowired 8 | import org.springframework.boot.test.context.SpringBootTest 9 | import java.io.File 10 | 11 | @SpringBootTest 12 | class FileMetadataServiceTest(@Autowired private val fileMetadataService: FileMetadataService) { 13 | 14 | @Test 15 | fun testParseEpub() { 16 | val metadata = 17 | fileMetadataService.extractMetadata(File(this::class.java.getResource("/metadata/pg72155-images.epub").file).absolutePath) 18 | Assertions.assertEquals("Travels in Southern Abyssinia, Volume I (of 2)", metadata?.title) 19 | Assertions.assertEquals(1, metadata?.authors?.size) 20 | Assertions.assertEquals(0, metadata?.tags?.size) 21 | Assertions.assertEquals("en", metadata?.language) 22 | } 23 | 24 | @Test 25 | fun testParseEpub3() { 26 | val metadata = 27 | fileMetadataService.extractMetadata(File(this::class.java.getResource("/metadata/pg72155-images-3.epub").file).absolutePath) 28 | Assertions.assertEquals("Travels in Southern Abyssinia, Volume I (of 2)", metadata?.title) 29 | Assertions.assertEquals(1, metadata?.authors?.size) 30 | Assertions.assertEquals(0, metadata?.tags?.size) 31 | Assertions.assertEquals("en", metadata?.language) 32 | } 33 | 34 | @Test 35 | fun testParseOpfNoRole() { 36 | val metadata = fileMetadataService.extractMetadata(File(this::class.java.getResource("/metadata/content.opf").file).absolutePath) 37 | Assertions.assertNull(metadata?.googleId) 38 | Assertions.assertEquals("9782370491190", metadata?.isbn13) 39 | Assertions.assertNull(metadata?.isbn10) 40 | Assertions.assertEquals(1, metadata?.authors?.size) 41 | Assertions.assertEquals(1, metadata?.tags?.size) 42 | } 43 | 44 | @Test 45 | fun testParseUnknownExtension() { 46 | assertThrows { fileMetadataService.extractMetadata("unknown.txt") } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/resources/application-dev.yml: -------------------------------------------------------------------------------- 1 | jelu: 2 | database: 3 | path: ${user.home}/.jelu/database/ 4 | files: 5 | images: '${user.home}/.jelu/files/images/' 6 | imports: '${user.home}/.jelu/files/imports/' 7 | session: 8 | duration: 604800 #7 days 9 | metadataProviders: 10 | - is-enabled: false 11 | apiKey: "" 12 | order: -100000 13 | name: "jelu-debug" 14 | - name: "inventaireio" 15 | is-enabled: false 16 | order: 200000 17 | config: "fr" 18 | - name: "databazeknih" 19 | is-enabled: false 20 | order: 200001 21 | metadata: 22 | calibre: 23 | path: /usr/bin/fetch-ebook-metadata 24 | timeout: 30 25 | # cors.allowed-origins: 26 | # - http://127.0.0.1:5173 27 | # - http://localhost:5173 28 | # - http://localhost:3000 29 | auth: 30 | ldap: 31 | enabled: false 32 | # url: "ldap://127.0.0.1:389/dc=home,dc=lab" 33 | # userDnPatterns: 34 | # - "uid={0}" 35 | # - "cn={0},ou=users" 36 | # userSearchFilter: "(cn={0})" 37 | proxy: 38 | enabled: false 39 | adminName: "adminproxy" 40 | lucene: 41 | indexAnalyzer: 42 | minGram: 3 43 | 44 | server: 45 | port: 11111 46 | spring: 47 | web: 48 | resources: 49 | add-mappings: false 50 | mvc: 51 | throw-exception-if-no-handler-found: true 52 | liquibase: 53 | enabled: true 54 | drop-first: false 55 | change-log: classpath:liquibase.xml 56 | datasource: 57 | url: jdbc:sqlite:${jelu.database.path}/jelu.db?foreign_keys=on; 58 | username: jelu_user 59 | password: mypass1234 60 | driver-class-name: org.sqlite.JDBC 61 | exposed: 62 | generate-ddl: false 63 | show-sql: true 64 | servlet: 65 | multipart: 66 | enabled: true 67 | location: ${java.io.tmpdir} 68 | max-request-size: 10MB 69 | max-file-size: 10MB 70 | logging: 71 | logback.rollingpolicy.max-history: 10 72 | file: 73 | name: ${jelu.database.path}/jelu.log 74 | # name: \${user.home}/.jelu/jelu.log 75 | level: 76 | io.github.bayang.jelu: DEBUG 77 | io.github.bayang.jelu.service.metadata: TRACE 78 | io.github.bayang.jelu.service.quotes: DEBUG 79 | io.github.bayang.jelu.service.FileManager: TRACE 80 | io.github.bayang.jelu.config.JeluLdapUserDetailsContextMapper: TRACE 81 | org.springframework.security.ldap: TRACE 82 | -------------------------------------------------------------------------------- /src/jelu-ui/src/components/BookReviews.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 83 | 84 | 86 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/controllers/ShelvesController.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.controllers 2 | 3 | import io.github.bayang.jelu.dto.CreateShelfDto 4 | import io.github.bayang.jelu.dto.JeluUser 5 | import io.github.bayang.jelu.dto.ShelfDto 6 | import io.github.bayang.jelu.service.ShelfService 7 | import io.swagger.v3.oas.annotations.responses.ApiResponse 8 | import jakarta.validation.Valid 9 | import org.springframework.http.ResponseEntity 10 | import org.springframework.security.core.Authentication 11 | import org.springframework.web.bind.annotation.DeleteMapping 12 | import org.springframework.web.bind.annotation.GetMapping 13 | import org.springframework.web.bind.annotation.PathVariable 14 | import org.springframework.web.bind.annotation.PostMapping 15 | import org.springframework.web.bind.annotation.RequestBody 16 | import org.springframework.web.bind.annotation.RequestMapping 17 | import org.springframework.web.bind.annotation.RequestParam 18 | import org.springframework.web.bind.annotation.RestController 19 | import java.util.UUID 20 | 21 | @RestController 22 | @RequestMapping("/api/v1") 23 | class ShelvesController( 24 | private val shelvesService: ShelfService, 25 | ) { 26 | 27 | @GetMapping(path = ["/shelves"]) 28 | fun shelves( 29 | @RequestParam(name = "name", required = false) name: String?, 30 | @RequestParam(name = "targetId", required = false) targetId: UUID?, 31 | principal: Authentication, 32 | ): List { 33 | return shelvesService.find((principal.principal as JeluUser).user, name, targetId) 34 | } 35 | 36 | @GetMapping(path = ["/shelves/{id}"]) 37 | fun shelfById( 38 | @PathVariable("id") shelfId: UUID, 39 | principal: Authentication, 40 | ): ShelfDto { 41 | return shelvesService.findById(shelfId) 42 | } 43 | 44 | @PostMapping(path = ["/shelves"]) 45 | fun saveShelf( 46 | @RequestBody @Valid 47 | createShelfDto: CreateShelfDto, 48 | principal: Authentication, 49 | ): ShelfDto { 50 | return shelvesService.save(createShelfDto, (principal.principal as JeluUser).user) 51 | } 52 | 53 | @ApiResponse(responseCode = "204", description = "Deleted the shelf") 54 | @DeleteMapping(path = ["/shelves/{id}"]) 55 | fun deleteShelfById(@PathVariable("id") shelfId: UUID): ResponseEntity { 56 | shelvesService.delete(shelfId) 57 | return ResponseEntity.noContent().build() 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/service/metadata/WikipediaService.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.service.metadata 2 | 3 | import io.github.bayang.jelu.dto.WikipediaPageResult 4 | import io.github.bayang.jelu.dto.WikipediaSearchResult 5 | import jakarta.annotation.Resource 6 | import org.springframework.http.HttpStatus 7 | import org.springframework.stereotype.Service 8 | import org.springframework.web.reactive.function.client.WebClient 9 | import org.springframework.web.util.UriBuilder 10 | import reactor.core.publisher.Mono 11 | 12 | @Service 13 | class WikipediaService( 14 | @Resource(name = "restClient") val restClient: WebClient, 15 | ) { 16 | 17 | // curl https://fr.wikipedia.org/w/rest.php/v1/search/title\?q\=stefan%20platteau\&limit\=5 18 | fun search(query: String, language: String = "en", limit: Int = 10): Mono { 19 | val mono: Mono = restClient.get() 20 | .uri { uriBuilder: UriBuilder -> 21 | uriBuilder 22 | .scheme("https") 23 | .host("$language.wikipedia.org") 24 | .path("/w/rest.php/v1/search/title") 25 | .queryParam("q", query) 26 | .queryParam("limit", limit) 27 | .build() 28 | } 29 | .exchangeToMono { 30 | if (it.statusCode() == HttpStatus.OK) { 31 | it.bodyToMono(WikipediaSearchResult::class.java) 32 | } else { 33 | it.createException().flatMap { Mono.error { it } } 34 | } 35 | } 36 | return mono 37 | } 38 | 39 | fun fetchPage(pageTitle: String, language: String = "en"): Mono { 40 | val mono: Mono = restClient.get() 41 | .uri { uriBuilder: UriBuilder -> 42 | uriBuilder 43 | .scheme("https") 44 | .host("$language.wikipedia.org") 45 | .pathSegment("api", "rest_v1", "page", "summary", "{title}") 46 | .queryParam("redirect", false) 47 | .build(pageTitle) 48 | } 49 | .exchangeToMono { 50 | if (it.statusCode() == HttpStatus.OK) { 51 | it.bodyToMono(WikipediaPageResult::class.java) 52 | } else { 53 | it.createException().flatMap { Mono.error { it } } 54 | } 55 | } 56 | return mono 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/test/resources/csv-import/goodreads1.csv: -------------------------------------------------------------------------------- 1 | Book Id,Title,Author,Author l-f,Additional Authors,ISBN,ISBN13,My Rating,Average Rating,Publisher,Binding,Number of Pages,Year Published,Original Publication Year,Date Read,Date Added,Bookshelves,Bookshelves with positions,Exclusive Shelf,My Review,Spoiler,Private Notes,Read Count,Recommended For,Recommended By,Owned Copies,Original Purchase Date,Original Purchase Location,Condition,Condition Description,BCID 2 | 40725766,La somme de nos folies,Shih-Li Kow,"Kow, Shih-Li",,"=""""","=""9782843048302""",0,3.89,Éditions Zulma,Paperback,384,2018,2014,,2021/04/02,"to-read, test-aad, avec-espace","to-read (#80), test-aad (#1), avec-espace (#1)",to-read,,,,0,,,0,,,,, 3 | 33178099,La vie des arbres,Francis Hallé,"Hallé, Francis",,"=""2227483148""","=""9782227483149""",0,4.12,Bayard Culture,Paperback,70,2011,,2020/11/11,2021/01/10,,,read,,,,1,,,0,,,,,21 4 | 55819582,La Fabrique des lendemains,Rich Larson,"Larson, Rich",Pierre-Paul Duranstanti,"=""2843449731""","=""9782843449734""",0,4.16,Le Bélial',Paperback,612,2020,2018,,2020/11/09,to-read,to-read (#74),to-read,,,,0,,,0,,,,, 5 | 17876040,"Le Crystal des Elfes bleus (Elfes, #1)",Jean-Luc Istin,"Istin, Jean-Luc","Kyko Duarte, Diogo Saito","=""2302027191""","=""9782302027190""",0,3.86,Soleil,Hardcover,54,2013,2013,2021/04/25,2021/03/15,,,read,,,,1,,,0,,,,, 6 | 2189436,Quartier lointain,Jirō Taniguchi,"Taniguchi, Jirō",谷口 ジロー,"=""220339644X""","=""9782203396449""",0,4.38,Casterman,Hardcover,405,2006,1998,,2019/03/21,waiting,waiting (#49),waiting,,,,0,,,0,,,,, 7 | 10576969,Un Automne à Hanoï,Clément Baloup,"Baloup, Clément",,"=""2849530085""","=""9782849530085""",0,3.00,La Boîte à bulles,Hardcover,48,2004,2004,2021/04/25,2021/04/25,,,read,,,,1,,,0,,,,, 8 | 2320102,Le Secret Du Chant Des Baleines,Christopher Moore,"Moore, Christopher",,"=""2070316572""","=""9782070316571""",0,3.75,Gallimard,,414,2006,2003,2021/04/21,2021/04/24,,,read,,,,1,,,0,,,,, 9 | 4268810,Un Sale Boulot,Christopher Moore,"Moore, Christopher",Luc Baranger,"=""270213839X""","=""9782702138397""",0,4.01,Calmann-Lévy,Paperback,405,2007,2006,,2021/04/24,currently-reading,currently-reading (#2),currently-reading,,,,1,,,0,,,,, 10 | 19246484,"Clothes, Clothes, Clothes. Music, Music, Music. Boys, Boys, Boys.",Viv Albertine,"Albertine, Viv",,"=""0571297773""","=""9780571297771""",0,4.27,Faber & Faber,ebook,,2014,2014,2021/03/21,2021/03/13,,,read,,,,1,,,0,,,,, 11 | 17181203,Tout Corum,Michael Moorcock,"Moorcock, Michael",,"=""284172090X""","=""9782841720903""",5,4.03,L'Atalante,Paperback,862,1998,1998,,2018/10/24,,,read,,,,1,,,0,,,,, 12 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/dao/ReadingEventTable.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.dao 2 | 3 | import io.github.bayang.jelu.dto.ReadingEventDto 4 | import io.github.bayang.jelu.dto.ReadingEventWithoutUserBookDto 5 | import org.jetbrains.exposed.dao.UUIDEntity 6 | import org.jetbrains.exposed.dao.UUIDEntityClass 7 | import org.jetbrains.exposed.dao.id.EntityID 8 | import org.jetbrains.exposed.dao.id.UUIDTable 9 | import org.jetbrains.exposed.sql.ReferenceOption 10 | import org.jetbrains.exposed.sql.javatime.timestamp 11 | import java.time.Instant 12 | import java.util.* 13 | 14 | object ReadingEventTable : UUIDTable("reading_event") { 15 | val creationDate = timestamp("creation_date") 16 | val modificationDate = timestamp("modification_date") 17 | val userBook = reference("user_book", UserBookTable, onDelete = ReferenceOption.CASCADE) 18 | val eventType = enumerationByName("event_type", 200, ReadingEventType::class) 19 | val startDate = timestamp("start_date") 20 | val endDate = timestamp("end_date").nullable() 21 | } 22 | class ReadingEvent(id: EntityID) : UUIDEntity(id) { 23 | companion object : UUIDEntityClass(ReadingEventTable) 24 | var creationDate by ReadingEventTable.creationDate 25 | var modificationDate by ReadingEventTable.modificationDate 26 | var userBook by UserBook referencedOn ReadingEventTable.userBook 27 | var eventType by ReadingEventTable.eventType 28 | var startDate by ReadingEventTable.startDate 29 | var endDate by ReadingEventTable.endDate 30 | val lastEventDate: Instant 31 | get() { 32 | if (endDate != null) { 33 | return endDate as Instant 34 | } 35 | return startDate 36 | } 37 | 38 | fun toReadingEventDto(): ReadingEventDto = ReadingEventDto( 39 | id = this.id.value, 40 | creationDate = this.creationDate, 41 | modificationDate = this.modificationDate, 42 | userBook = this.userBook.toUserBookWithoutEventsDto(), 43 | eventType = this.eventType, 44 | startDate = this.startDate, 45 | endDate = this.endDate, 46 | ) 47 | fun toReadingEventWithoutUserBookDto(): ReadingEventWithoutUserBookDto = ReadingEventWithoutUserBookDto( 48 | id = this.id.value, 49 | creationDate = this.creationDate, 50 | modificationDate = this.modificationDate, 51 | eventType = this.eventType, 52 | startDate = this.startDate, 53 | endDate = this.endDate, 54 | ) 55 | } 56 | enum class ReadingEventType { 57 | FINISHED, 58 | DROPPED, 59 | CURRENTLY_READING, 60 | } 61 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/config/GlobalConfig.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.config 2 | 3 | import org.springframework.context.annotation.Bean 4 | import org.springframework.context.annotation.Configuration 5 | import org.springframework.http.HttpHeaders 6 | import org.springframework.http.HttpMethod 7 | import org.springframework.http.client.reactive.ReactorClientHttpConnector 8 | import org.springframework.http.codec.ClientCodecConfigurer 9 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder 10 | import org.springframework.security.crypto.password.PasswordEncoder 11 | import org.springframework.web.client.RestClient 12 | import org.springframework.web.cors.CorsConfiguration 13 | import org.springframework.web.cors.UrlBasedCorsConfigurationSource 14 | import org.springframework.web.reactive.function.client.ExchangeStrategies 15 | import org.springframework.web.reactive.function.client.WebClient 16 | import reactor.netty.http.client.HttpClient 17 | 18 | const val sessionHeaderName: String = "X-Auth-Token" 19 | 20 | @Configuration 21 | class GlobalConfig { 22 | 23 | @Bean("restClient") 24 | fun webClient(): WebClient { 25 | val exchange = ExchangeStrategies.builder().codecs { c: ClientCodecConfigurer -> 26 | c.defaultCodecs().maxInMemorySize(16 * 1024 * 1024) 27 | }.build() 28 | return WebClient.builder().exchangeStrategies(exchange).clientConnector( 29 | ReactorClientHttpConnector( 30 | HttpClient.create().compress(true).followRedirect(true), 31 | ), 32 | ).build() 33 | } 34 | 35 | @Bean("springRestClient") 36 | fun springRestClient(): RestClient { 37 | return RestClient.create() 38 | } 39 | 40 | @Bean("passwordEncoder") 41 | fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder() 42 | 43 | @Bean 44 | fun corsConfigurationSource(jeluProperties: JeluProperties): UrlBasedCorsConfigurationSource = 45 | UrlBasedCorsConfigurationSource().apply { 46 | registerCorsConfiguration( 47 | "/**", 48 | CorsConfiguration().applyPermitDefaultValues().apply { 49 | allowedOriginPatterns = if (jeluProperties.cors.allowedOrigins.isNullOrEmpty()) listOf("*") else jeluProperties.cors.allowedOrigins 50 | allowedMethods = HttpMethod.values().map { it.name() } 51 | allowCredentials = true 52 | addExposedHeader(HttpHeaders.CONTENT_DISPOSITION) 53 | addExposedHeader(sessionHeaderName) 54 | }, 55 | ) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/jelu-ui/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import FloatingVue from 'floating-vue' 4 | import Oruga from '@oruga-ui/oruga-next' 5 | import SidebarMenu from 'vuejs-sidebar-menu' 6 | import router from './router' 7 | import store, { key } from './store' 8 | import VueSplide from '@splidejs/vue-splide'; 9 | import VueMarkdownEditor from '@kangc/v-md-editor'; 10 | import VMdPreview from '@kangc/v-md-editor/lib/preview'; 11 | 12 | import './assets/style.css' 13 | 14 | import '@oruga-ui/theme-oruga/dist/oruga.css' 15 | 16 | import 'vuejs-sidebar-menu/dist/vuejs-sidebar-menu.css' 17 | import '@mdi/font/css/materialdesignicons.min.css' 18 | import 'floating-vue/dist/style.css' 19 | import '@splidejs/splide/dist/css/splide.min.css'; 20 | 21 | import '@kangc/v-md-editor/lib/style/base-editor.css'; 22 | import githubTheme from '@kangc/v-md-editor/lib/theme/github.js'; 23 | import '@kangc/v-md-editor/lib/theme/style/github.css'; 24 | VueMarkdownEditor.use(githubTheme) 25 | import enUS from '@kangc/v-md-editor/lib/lang/en-US'; 26 | VueMarkdownEditor.lang.use('en-US', enUS); 27 | VMdPreview.use(githubTheme); 28 | 29 | 30 | /* 31 | * All i18n resources specified in the plugin `include` option can be loaded 32 | * at once using the import syntax 33 | */ 34 | import { createI18n } from 'vue-i18n' 35 | import messages from '@intlify/unplugin-vue-i18n/messages' 36 | import { datetimeFormats } from './datetimeFormat' 37 | import { usePreferredLanguages } from '@vueuse/core' 38 | import { useLocalStorage } from '@vueuse/core' 39 | 40 | const languages = usePreferredLanguages() 41 | console.log("languages : ") 42 | console.log(languages) 43 | let preferredLanguage = 'en' 44 | if (languages.value && languages.value.length > 0) { 45 | const candidate = languages.value[0] 46 | if (candidate.length > 2) { 47 | preferredLanguage = candidate.slice(0,2) 48 | } else { 49 | preferredLanguage = candidate 50 | } 51 | } 52 | console.log(`favourite language fetched from browser is : ${preferredLanguage}`) 53 | const storedLanguage = useLocalStorage("jelu_language", preferredLanguage) 54 | console.log(`favourite language fetched from storage is : ${storedLanguage.value}`) 55 | const i18n = createI18n({ 56 | legacy: false, 57 | locale: storedLanguage.value, 58 | fallbackLocale: 'en', 59 | messages, 60 | datetimeFormats: datetimeFormats 61 | }) 62 | 63 | createApp(App) 64 | .use(i18n) 65 | .use(router) 66 | .use(store, key) 67 | .use(FloatingVue) 68 | .use(Oruga, { 69 | iconPack: 'mdi', 70 | }) 71 | .use(SidebarMenu) 72 | .use(VueSplide) 73 | .use(VueMarkdownEditor) 74 | .use(VMdPreview) 75 | .mount('#app') 76 | -------------------------------------------------------------------------------- /src/test/kotlin/io/github/bayang/jelu/TestHelpers.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu 2 | 3 | import io.github.bayang.jelu.dao.ImportSource 4 | import io.github.bayang.jelu.dao.ReadingEventType 5 | import io.github.bayang.jelu.dto.AuthorDto 6 | import io.github.bayang.jelu.dto.BookCreateDto 7 | import io.github.bayang.jelu.dto.CreateUserBookDto 8 | import io.github.bayang.jelu.dto.ImportConfigurationDto 9 | import io.github.bayang.jelu.dto.TagDto 10 | import java.time.Instant 11 | 12 | fun createUserBookDto(bookDto: BookCreateDto, lastReadingEvent: ReadingEventType? = null, lastreadingEventDate: Instant? = null, toRead: Boolean = false, owned: Boolean? = true, borrowed: Boolean = false): CreateUserBookDto { 13 | return CreateUserBookDto( 14 | personalNotes = "test personal notes\nwith a newline", 15 | lastReadingEvent = lastReadingEvent, 16 | lastReadingEventDate = lastreadingEventDate, 17 | owned = owned, 18 | toRead = toRead, 19 | percentRead = null, 20 | book = bookDto, 21 | borrowed = borrowed, 22 | currentPageNumber = null, 23 | ) 24 | } 25 | 26 | fun bookDto(title: String = "title1", withTags: Boolean = false): BookCreateDto { 27 | return BookCreateDto( 28 | id = null, 29 | title = title, 30 | isbn10 = "1566199093", 31 | isbn13 = "9781566199094 ", 32 | summary = "This is a test summary\nwith a newline", 33 | image = "", 34 | publisher = "test-publisher", 35 | pageCount = 50, 36 | publishedDate = "", 37 | authors = mutableListOf(authorDto()), 38 | tags = if (withTags) tags() else emptyList(), 39 | goodreadsId = "4321abc", 40 | googleId = "1234", 41 | librarythingId = "", 42 | language = "", 43 | amazonId = "", 44 | ) 45 | } 46 | 47 | fun authorDto(name: String = "test author"): AuthorDto { 48 | return AuthorDto(id = null, creationDate = null, modificationDate = null, name = name, image = "", dateOfBirth = "", dateOfDeath = "", biography = "author bio", facebookPage = null, goodreadsPage = null, instagramPage = null, notes = null, officialPage = null, twitterPage = null, wikipediaPage = "https://wikipedia.org") 49 | } 50 | 51 | fun tags(): List { 52 | val tags = mutableListOf() 53 | tags.add(tagDto()) 54 | tags.add(tagDto("fantasy")) 55 | return tags 56 | } 57 | 58 | fun tagDto(name: String = "science fiction"): TagDto { 59 | return TagDto(id = null, creationDate = null, modificationDate = null, name) 60 | } 61 | 62 | fun importConfigurationDto(): ImportConfigurationDto { 63 | return ImportConfigurationDto(shouldFetchMetadata = false, shouldFetchCovers = false, ImportSource.GOODREADS) 64 | } 65 | -------------------------------------------------------------------------------- /src/jelu-ui/src/components/ReviewBookCard.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 86 | 87 | 90 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/config/LdapConfig.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.config 2 | 3 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty 4 | import org.springframework.context.annotation.Bean 5 | import org.springframework.context.annotation.Configuration 6 | import org.springframework.ldap.core.support.BaseLdapPathContextSource 7 | import org.springframework.security.authentication.AuthenticationProvider 8 | import org.springframework.security.ldap.DefaultSpringSecurityContextSource 9 | import org.springframework.security.ldap.authentication.AbstractLdapAuthenticator 10 | import org.springframework.security.ldap.authentication.BindAuthenticator 11 | import org.springframework.security.ldap.authentication.LdapAuthenticationProvider 12 | import org.springframework.security.ldap.search.FilterBasedLdapUserSearch 13 | import org.springframework.security.ldap.userdetails.UserDetailsContextMapper 14 | 15 | @Configuration 16 | @ConditionalOnProperty(name = ["jelu.auth.ldap.enabled"], havingValue = "true", matchIfMissing = false) 17 | class LdapConfig( 18 | private val userDetailsContextMapper: UserDetailsContextMapper, 19 | private val properties: JeluProperties, 20 | ) { 21 | 22 | @Bean 23 | fun contextSource(): BaseLdapPathContextSource { 24 | val context: DefaultSpringSecurityContextSource = DefaultSpringSecurityContextSource(properties.auth.ldap.url) 25 | 26 | if (!properties.auth.ldap.userDn.isNullOrBlank()) { 27 | context.setUserDn(properties.auth.ldap.userDn) 28 | } 29 | if (!properties.auth.ldap.password.isNullOrBlank()) { 30 | context.setPassword(properties.auth.ldap.password) 31 | } 32 | return context 33 | } 34 | 35 | @Bean 36 | fun authenticationProvider(contextSource: BaseLdapPathContextSource): AuthenticationProvider { 37 | val authenticator: AbstractLdapAuthenticator = BindAuthenticator(contextSource) 38 | if (properties.auth.ldap.userDnPatterns.isNotEmpty()) { 39 | authenticator.setUserDnPatterns(properties.auth.ldap.userDnPatterns.toTypedArray()) 40 | } 41 | if (!properties.auth.ldap.userSearchFilter.isNullOrBlank()) { 42 | val userSearchBase = if (properties.auth.ldap.userSearchBase.isNullOrBlank()) "" else properties.auth.ldap.userSearchBase 43 | authenticator.setUserSearch( 44 | FilterBasedLdapUserSearch(userSearchBase, properties.auth.ldap.userSearchFilter, contextSource), 45 | ) 46 | } 47 | authenticator.afterPropertiesSet() 48 | val provider: LdapAuthenticationProvider = LdapAuthenticationProvider(authenticator) 49 | provider.setUserDetailsContextMapper(userDetailsContextMapper) 50 | return provider 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [ pull_request, push ] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | name: Test JDK 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: actions/setup-java@v3 12 | with: 13 | java-version: 21 14 | java-package: 'jdk' 15 | distribution: 'temurin' 16 | - uses: actions/setup-java@v3 17 | with: 18 | java-version: 17 19 | java-package: 'jdk' 20 | distribution: 'temurin' 21 | 22 | - name: Build 23 | uses: gradle/gradle-build-action@v2 24 | with: 25 | arguments: build 26 | - name: Upload Unit Test Results 27 | if: always() 28 | uses: actions/upload-artifact@v4 29 | with: 30 | name: test-results-jdk 31 | path: build/test-results/ 32 | - name: Upload Unit Test Reports 33 | if: always() 34 | uses: actions/upload-artifact@v4 35 | with: 36 | name: test-reports-jdk 37 | path: build/reports/tests/ 38 | 39 | webui: 40 | runs-on: ubuntu-latest 41 | name: Test webui builds 42 | steps: 43 | - uses: actions/checkout@v3 44 | - uses: actions/setup-java@v3 45 | with: 46 | java-version: 17 47 | java-package: 'jdk' 48 | distribution: 'temurin' 49 | - name: npm Build 50 | uses: gradle/gradle-build-action@v2 51 | with: 52 | arguments: npmBuild 53 | 54 | release: 55 | name: Semantic Release 56 | runs-on: ubuntu-latest 57 | needs: [ test, webui ] 58 | if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) 59 | steps: 60 | - uses: actions/checkout@v3 61 | - uses: actions/setup-node@v3 62 | with: 63 | node-version: '20' 64 | cache: 'npm' 65 | cache-dependency-path: | 66 | package-lock.json 67 | src/jelu-ui/package-lock.json 68 | - uses: actions/setup-java@v3 69 | with: 70 | java-version: '17' 71 | java-package: 'jdk' 72 | distribution: 'temurin' 73 | cache: 'gradle' 74 | - name: Install dependencies 75 | run: npm install --only=production 76 | - name: Set up QEMU 77 | uses: docker/setup-qemu-action@v3 78 | - name: Set up Docker Buildx 79 | uses: docker/setup-buildx-action@v3 80 | - name: Login to Docker Hub 81 | uses: docker/login-action@v3 82 | with: 83 | username: ${{ secrets.DOCKER_USERNAME }} 84 | password: ${{ secrets.DOCKER_PASSWORD }} 85 | - name: Release 86 | env: 87 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 88 | run: npx semantic-release 89 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/config/AuthHeaderFilter.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.config 2 | 3 | import io.github.bayang.jelu.dao.Provider 4 | import io.github.bayang.jelu.dto.CreateUserDto 5 | import io.github.bayang.jelu.dto.JeluUser 6 | import io.github.bayang.jelu.service.UserService 7 | import io.github.oshai.kotlinlogging.KotlinLogging 8 | import jakarta.servlet.FilterChain 9 | import jakarta.servlet.http.HttpServletRequest 10 | import jakarta.servlet.http.HttpServletResponse 11 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty 12 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken 13 | import org.springframework.security.core.context.SecurityContextHolder 14 | import org.springframework.security.web.authentication.WebAuthenticationDetailsSource 15 | import org.springframework.stereotype.Component 16 | import org.springframework.web.filter.OncePerRequestFilter 17 | 18 | private val LOGGER = KotlinLogging.logger {} 19 | 20 | @Component 21 | @ConditionalOnProperty(name = ["jelu.auth.proxy.enabled"], havingValue = "true", matchIfMissing = false) 22 | class AuthHeaderFilter( 23 | private val userService: UserService, 24 | private val properties: JeluProperties, 25 | private val userAgentWebAuthenticationDetailsSource: WebAuthenticationDetailsSource, 26 | ) : OncePerRequestFilter() { 27 | 28 | override fun doFilterInternal( 29 | request: HttpServletRequest, 30 | response: HttpServletResponse, 31 | filterChain: FilterChain, 32 | ) { 33 | val headerName = properties.auth.proxy.header 34 | val headerAuth: String? = request.getHeader(headerName) 35 | if (!headerAuth.isNullOrBlank()) { 36 | LOGGER.trace("auth header $headerAuth") 37 | val res = userService.findByLoginAndProvider(headerAuth, Provider.PROXY) 38 | val user: JeluUser = if (res.isEmpty()) { 39 | val isAdmin = properties.auth.proxy.adminName.isNotBlank() && properties.auth.proxy.adminName == headerAuth 40 | val saved = userService.save(CreateUserDto(login = headerAuth, password = "proxy", isAdmin = isAdmin, Provider.PROXY)) 41 | JeluUser(userService.findUserEntityById(saved.id!!).toUserDto()) 42 | } else { 43 | JeluUser(userService.findUserEntityById(res.first().id!!).toUserDto()) 44 | } 45 | val authentication = UsernamePasswordAuthenticationToken( 46 | user, 47 | null, 48 | user.authorities, 49 | ) 50 | authentication.details = userAgentWebAuthenticationDetailsSource.buildDetails(request) 51 | SecurityContextHolder.getContext().authentication = authentication 52 | } 53 | filterChain.doFilter(request, response) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/test/kotlin/io/github/bayang/jelu/service/UserServiceTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.service 2 | 3 | import io.github.bayang.jelu.dao.Provider 4 | import io.github.bayang.jelu.dto.CreateUserDto 5 | import io.github.bayang.jelu.dto.UpdateUserDto 6 | import io.github.bayang.jelu.errors.JeluException 7 | import org.junit.jupiter.api.Assertions 8 | import org.junit.jupiter.api.BeforeEach 9 | import org.junit.jupiter.api.Test 10 | import org.junit.jupiter.api.assertThrows 11 | import org.springframework.beans.factory.annotation.Autowired 12 | import org.springframework.boot.test.context.SpringBootTest 13 | 14 | @SpringBootTest 15 | class UserServiceTest(@Autowired private val userService: UserService) { 16 | 17 | @BeforeEach 18 | fun emptyTable() { 19 | userService.findAll(null).forEach { it.id?.let { it1 -> userService.deleteUser(it1) } } 20 | } 21 | 22 | @Test 23 | fun testFirstUserLifecycle() { 24 | Assertions.assertTrue(userService.isInitialSetup()) 25 | 26 | val noUser = userService.loadUserByUsername("login1") 27 | Assertions.assertEquals("setup", noUser.username) 28 | 29 | val created = userService.save(CreateUserDto(login = "login1", password = "password", isAdmin = true)) 30 | Assertions.assertEquals("login1", created.login) 31 | Assertions.assertTrue(created.isAdmin) 32 | Assertions.assertEquals(Provider.JELU_DB, created.provider) 33 | assertThrows { userService.save(CreateUserDto(login = "login1", password = "password2", isAdmin = true)) } 34 | Assertions.assertFalse(userService.isInitialSetup()) 35 | 36 | var found = userService.findByLogin("login1") 37 | Assertions.assertEquals("login1", found[0].login) 38 | 39 | Assertions.assertEquals(1, userService.findByLoginAndProvider(created.login, Provider.JELU_DB).size) 40 | Assertions.assertEquals(0, userService.findByLoginAndProvider(created.login, Provider.LDAP).size) 41 | 42 | val updated = userService.updateUser(created.id!!, UpdateUserDto(isAdmin = false, password = "newpass", provider = null)) 43 | Assertions.assertEquals(false, updated.isAdmin) 44 | 45 | found = userService.findByLogin("login1") 46 | Assertions.assertEquals(false, found[0].isAdmin) 47 | 48 | val createdLdap = userService.save(CreateUserDto(login = "loginldap", password = "password", isAdmin = true, Provider.LDAP)) 49 | Assertions.assertEquals("loginldap", createdLdap.login) 50 | Assertions.assertTrue(createdLdap.isAdmin) 51 | Assertions.assertEquals(Provider.LDAP, createdLdap.provider) 52 | 53 | Assertions.assertEquals(1, userService.findByLoginAndProvider(createdLdap.login, Provider.LDAP).size) 54 | Assertions.assertEquals(0, userService.findByLoginAndProvider(createdLdap.login, Provider.JELU_DB).size) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/controllers/QuotesController.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.controllers 2 | 3 | import io.github.bayang.jelu.dto.QuoteDto 4 | import io.github.bayang.jelu.service.quotes.IQuoteProvider 5 | import io.swagger.v3.oas.annotations.media.ArraySchema 6 | import io.swagger.v3.oas.annotations.media.Content 7 | import io.swagger.v3.oas.annotations.media.Schema 8 | import io.swagger.v3.oas.annotations.responses.ApiResponse 9 | import io.swagger.v3.oas.annotations.responses.ApiResponses 10 | import org.springframework.web.bind.annotation.GetMapping 11 | import org.springframework.web.bind.annotation.RequestMapping 12 | import org.springframework.web.bind.annotation.RequestParam 13 | import org.springframework.web.bind.annotation.RestController 14 | import reactor.core.publisher.Mono 15 | 16 | @RestController 17 | @RequestMapping("/api/v1") 18 | class QuotesController( 19 | private val quotesProvider: IQuoteProvider, 20 | ) { 21 | 22 | @ApiResponses( 23 | value = [ 24 | ApiResponse( 25 | responseCode = "200", 26 | description = "quotes list", 27 | content = [ 28 | ( 29 | Content( 30 | mediaType = "application/json", 31 | array = ( 32 | ArraySchema( 33 | schema = Schema( 34 | implementation = QuoteDto::class, 35 | ), 36 | ) 37 | ), 38 | ) 39 | ), 40 | ], 41 | ), 42 | ], 43 | ) 44 | @GetMapping(path = ["/quotes"]) 45 | fun quotes(@RequestParam(name = "query", required = false) query: String?): Mono> = 46 | quotesProvider.quotes(query) 47 | 48 | @ApiResponses( 49 | value = [ 50 | ApiResponse( 51 | responseCode = "200", 52 | description = "random quotes list", 53 | content = [ 54 | ( 55 | Content( 56 | mediaType = "application/json", 57 | array = ( 58 | ArraySchema( 59 | schema = Schema( 60 | implementation = QuoteDto::class, 61 | ), 62 | ) 63 | ), 64 | ) 65 | ), 66 | ], 67 | ), 68 | ], 69 | ) 70 | @GetMapping(path = ["/quotes/random"]) 71 | fun quotes(): Mono> = quotesProvider.random() 72 | } 73 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/config/JeluProperties.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.config 2 | 3 | import jakarta.validation.constraints.NotBlank 4 | import jakarta.validation.constraints.Positive 5 | import org.springframework.boot.context.properties.ConfigurationProperties 6 | import org.springframework.validation.annotation.Validated 7 | 8 | @ConfigurationProperties(prefix = "jelu") 9 | @Validated 10 | data class JeluProperties( 11 | val database: Database, 12 | val files: Files, 13 | val session: Session, 14 | val cors: Cors = Cors(), 15 | val metadata: Metadata = Metadata(Calibre(null)), 16 | val auth: Auth = Auth( 17 | Ldap(), 18 | Proxy(), 19 | ), 20 | val metadataProviders: List?, 21 | val lucene: Lucene = Lucene(indexAnalyzer = IndexAnalyzer()), 22 | ) { 23 | 24 | data class MetaDataProvider( 25 | var name: String, 26 | var isEnabled: Boolean = false, 27 | var apiKey: String?, 28 | var order: Int = -1000, 29 | var config: String? = null, 30 | ) 31 | 32 | data class Database( 33 | @get:NotBlank var path: String, 34 | ) 35 | 36 | data class Files( 37 | @get:NotBlank var images: String, 38 | @get:NotBlank var imports: String, 39 | var resizeImages: Boolean = true, 40 | ) 41 | 42 | data class Session( 43 | @get:Positive var duration: Long, 44 | ) 45 | 46 | data class Cors( 47 | var allowedOrigins: List = emptyList(), 48 | ) 49 | 50 | data class Calibre( 51 | var path: String?, 52 | var order: Int = 1000, 53 | var timeout: Int = 30, 54 | ) 55 | 56 | data class Metadata( 57 | var calibre: Calibre, 58 | ) 59 | 60 | data class Auth( 61 | var ldap: Ldap, 62 | var proxy: Proxy, 63 | var oauth2AccountCreation: Boolean = false, 64 | var oidcEmailVerification: Boolean = true, 65 | ) 66 | 67 | data class Ldap( 68 | var enabled: Boolean = false, 69 | val url: String = "", 70 | val userDnPatterns: List = emptyList(), 71 | val userSearchFilter: String = "", 72 | val userSearchBase: String = "", 73 | val userDn: String = "", 74 | val password: String = "", 75 | ) 76 | 77 | data class Proxy( 78 | var enabled: Boolean = false, 79 | val adminName: String = "", 80 | val header: String = "X-Authenticated-User", 81 | ) 82 | 83 | data class IndexAnalyzer( 84 | @get:Positive 85 | var minGram: Int = 3, 86 | @get:Positive 87 | var maxGram: Int = 10, 88 | var preserveOriginal: Boolean = true, 89 | ) 90 | 91 | data class Lucene( 92 | @get:NotBlank 93 | var dataDirectory: String = "", 94 | 95 | var indexAnalyzer: IndexAnalyzer, 96 | 97 | ) 98 | } 99 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/service/AppLifecycleAware.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.service 2 | 3 | import io.github.bayang.jelu.config.JeluProperties 4 | import io.github.bayang.jelu.search.LuceneHelper 5 | import io.github.oshai.kotlinlogging.KotlinLogging 6 | import org.springframework.context.event.ContextRefreshedEvent 7 | import org.springframework.context.event.EventListener 8 | import org.springframework.stereotype.Component 9 | import java.io.File 10 | import java.time.OffsetDateTime 11 | import java.time.ZoneId 12 | import java.time.format.DateTimeFormatter 13 | 14 | private val logger = KotlinLogging.logger {} 15 | 16 | @Component 17 | class AppLifecycleAware( 18 | private val properties: JeluProperties, 19 | private val luceneHelper: LuceneHelper, 20 | private val searchIndexService: SearchIndexService, 21 | private val lifeCycleService: LifeCycleService, 22 | private val bookService: BookService, 23 | ) { 24 | 25 | @EventListener 26 | fun onApplicationEvent(event: ContextRefreshedEvent?) { 27 | val assetsDir = File(properties.files.images) 28 | if (!assetsDir.exists()) { 29 | val created = assetsDir.mkdirs() 30 | logger.debug { "Attempt to create non existing assets dir succeeded : $created" } 31 | } 32 | val importsDir = File(properties.files.imports) 33 | if (!importsDir.exists()) { 34 | val created = importsDir.mkdirs() 35 | logger.debug { "Attempt to create non existing imports dir succeeded : $created" } 36 | } 37 | if (!luceneHelper.indexExists()) { 38 | logger.info { "Lucene index not found, trigger rebuild" } 39 | searchIndexService.rebuildIndex() 40 | } else { 41 | val indexVersion = luceneHelper.getIndexVersion() 42 | logger.info { "Lucene index version: $indexVersion" } 43 | if (indexVersion < INDEX_VERSION) { 44 | searchIndexService.upgradeIndex() 45 | searchIndexService.rebuildIndex() 46 | } 47 | } 48 | val lifeCycle = lifeCycleService.getLifeCycle() 49 | if (!lifeCycle.seriesMigrated) { 50 | val start = System.currentTimeMillis() 51 | val nowString: String = OffsetDateTime.now(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss")) 52 | logger.info { "Series data not migrated, starting migration at at $nowString" } 53 | bookService.migrateSeries() 54 | val end = System.currentTimeMillis() 55 | val deltaInSec = (end - start) / 1000 56 | logger.info { "Series data migration completed after : $deltaInSec seconds, check your data" } 57 | val seriesMigrated = lifeCycleService.setSeriesMigrated() 58 | logger.debug { "lifeCycled updated to ${seriesMigrated.seriesMigrated}" } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/jelu-ui/src/components/AdminBase.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 72 | 73 | 76 | -------------------------------------------------------------------------------- /src/jelu-ui/tailwind.config.mjs: -------------------------------------------------------------------------------- 1 | function withOpacityValue(variable) { 2 | return ({ opacityValue }) => { 3 | if (opacityValue === undefined) { 4 | return `rgb(var(${variable}))` 5 | } 6 | return `rgb(var(${variable}) / ${opacityValue})` 7 | } 8 | } 9 | 10 | /** @type {import('tailwindcss').Config} */ 11 | module.exports = { 12 | darkMode: 'class', 13 | content: [ 14 | "./index.html", 15 | "./src/App.vue", 16 | "./src/main.ts", 17 | "./src/**/*.{vue,js,ts,jsx,tsx}", 18 | ], 19 | theme: { 20 | extend: { 21 | colors: { 22 | jelu_background: withOpacityValue('--jelu_background'), 23 | jelu_background_accent: withOpacityValue('--jelu_background_accent'), 24 | jelu_background_contrast: withOpacityValue('--jelu_background_contrast'), 25 | jelu_text_primary: withOpacityValue('--jelu_text_primary'), 26 | jelu_text_secondary: withOpacityValue('--jelu_text_secondary'), 27 | jelu_text_accent: withOpacityValue('--jelu_text_accent'), 28 | jelu_overlay: 'rgba(255, 255,255, 0.3)', 29 | }, 30 | }, 31 | }, 32 | // daisyui: { 33 | // themes: [ 34 | // "light", 35 | // "dark", 36 | // { 37 | // jelu: { 38 | // primary: "#f7f5d1", 39 | // secondary: "#aaaaaa", 40 | // accent: "#8D795B", 41 | // neutral: "#404040", 42 | // "base-100": "#262429", 43 | // "info": "#6191c2", 44 | // "success": "#CEB035", 45 | // "warning": "#ffad48", 46 | // "error": "#F87272", 47 | // "--rounded-box": "0rem", // border radius rounded-box utility class, used in card and other large boxes 48 | // "--rounded-btn": "0rem", // border radius rounded-btn utility class, used in buttons and similar element 49 | // "--rounded-badge": "0rem", // border radius rounded-badge utility class, used in badges and similar 50 | // "--animation-btn": "0.25s", // duration of animation when you click on button 51 | // "--animation-input": "0.2s", // duration of animation for inputs like checkbox, toggle, radio, etc 52 | // "--btn-text-case": "uppercase", // set default text transform for buttons 53 | // "--btn-focus-scale": "0.95", // scale transform of button when you focus on it 54 | // "--border-btn": "1px", // border width of buttons 55 | // "--tab-border": "1px", // border width of tabs 56 | // "--tab-radius": "0.5rem", // border radius of tabs 57 | // }, 58 | // }, 59 | // "cupcake", "bumblebee", "emerald", "corporate", "synthwave", "retro", "cyberpunk", "valentine", "halloween", "garden", "forest", "aqua", "lofi", "pastel", "fantasy", "wireframe", "black", "luxury", "dracula", "cmyk", "autumn", "business", "acid", "lemonade", "night", "coffee", "winter" 60 | // ], 61 | // }, 62 | plugins: [ 63 | require("@tailwindcss/typography"), 64 | // require("daisyui"), 65 | ], 66 | } 67 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/config/WebMvcConfig.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.config 2 | 3 | import org.springframework.context.annotation.Configuration 4 | import org.springframework.http.CacheControl 5 | import org.springframework.web.bind.annotation.ControllerAdvice 6 | import org.springframework.web.bind.annotation.ExceptionHandler 7 | import org.springframework.web.servlet.NoHandlerFoundException 8 | import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry 9 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer 10 | import java.util.concurrent.TimeUnit 11 | 12 | const val EXPORTS_PREFIX = "/exports" 13 | 14 | @Configuration 15 | class WebMvcConfig(private val properties: JeluProperties) : WebMvcConfigurer { 16 | 17 | override fun addResourceHandlers(registry: ResourceHandlerRegistry) { 18 | // serve pictures 19 | registry.addResourceHandler("/files/**") 20 | .addResourceLocations(getExternalFilesFolderPath()) 21 | .setCacheControl(CacheControl.maxAge(7, TimeUnit.DAYS).cachePublic()) 22 | 23 | // serve export csv 24 | registry.addResourceHandler("$EXPORTS_PREFIX/**") 25 | .addResourceLocations(getExternalExportsFolderPath()) 26 | .setCacheControl(CacheControl.noCache()) 27 | 28 | registry.addResourceHandler("/assets/**") 29 | .addResourceLocations("classpath:public/assets/") 30 | .setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic()) 31 | 32 | registry 33 | .addResourceHandler( 34 | "/index.html", 35 | "/favicon.ico", 36 | "/favicon-16x16.png", 37 | "/favicon-32x32.png", 38 | "/mstile-144x144.png", 39 | "/apple-touch-icon.png", 40 | "/apple-touch-icon-180x180.png", 41 | "/android-chrome-192x192.png", 42 | "/android-chrome-512x512.png", 43 | "/manifest.json", 44 | "/registerSW.js", 45 | "/sw.js", 46 | "/manifest.webmanifest", 47 | "/site.webmanifest", 48 | ) 49 | .addResourceLocations( 50 | "classpath:public/", 51 | ) 52 | .setCacheControl(CacheControl.noStore()) 53 | } 54 | 55 | fun getExternalFilesFolderPath(): String { 56 | var suffix: String = if (properties.files.images.endsWith("/")) { "" } else { "/" } 57 | return "file:" + properties.files.images + suffix 58 | } 59 | 60 | fun getExternalExportsFolderPath(): String { 61 | var suffix: String = if (properties.files.imports.endsWith("/")) { "" } else { "/" } 62 | return "file:" + properties.files.imports + suffix 63 | } 64 | } 65 | 66 | @ControllerAdvice 67 | class Customizer { 68 | @ExceptionHandler(NoHandlerFoundException::class) 69 | fun notFound(): String { 70 | return "forward:/" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/controllers/UserMessagesController.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.controllers 2 | 3 | import io.github.bayang.jelu.config.JeluProperties 4 | import io.github.bayang.jelu.dao.MessageCategory 5 | import io.github.bayang.jelu.dto.CreateUserMessageDto 6 | import io.github.bayang.jelu.dto.JeluUser 7 | import io.github.bayang.jelu.dto.UpdateUserMessageDto 8 | import io.github.bayang.jelu.dto.UserMessageDto 9 | import io.github.bayang.jelu.service.UserMessageService 10 | import io.github.oshai.kotlinlogging.KotlinLogging 11 | import io.swagger.v3.oas.annotations.Hidden 12 | import jakarta.validation.Valid 13 | import org.springdoc.core.annotations.ParameterObject 14 | import org.springframework.data.domain.Page 15 | import org.springframework.data.domain.Pageable 16 | import org.springframework.data.domain.Sort 17 | import org.springframework.data.web.PageableDefault 18 | import org.springframework.security.core.Authentication 19 | import org.springframework.web.bind.annotation.GetMapping 20 | import org.springframework.web.bind.annotation.PathVariable 21 | import org.springframework.web.bind.annotation.PostMapping 22 | import org.springframework.web.bind.annotation.PutMapping 23 | import org.springframework.web.bind.annotation.RequestBody 24 | import org.springframework.web.bind.annotation.RequestMapping 25 | import org.springframework.web.bind.annotation.RequestParam 26 | import org.springframework.web.bind.annotation.RestController 27 | import java.util.UUID 28 | 29 | private val logger = KotlinLogging.logger {} 30 | 31 | @RestController 32 | @RequestMapping("/api/v1") 33 | class UserMessagesController( 34 | private val userMessageService: UserMessageService, 35 | private val properties: JeluProperties, 36 | ) { 37 | 38 | @GetMapping(path = ["/user-messages"]) 39 | fun userMessages( 40 | @RequestParam(name = "messageCategories", required = false) messageCategories: List?, 41 | @RequestParam(name = "read", required = false) read: Boolean?, 42 | @PageableDefault(page = 0, size = 20, direction = Sort.Direction.DESC, sort = ["modificationDate"]) @ParameterObject pageable: Pageable, 43 | principal: Authentication, 44 | ): Page = userMessageService.find((principal.principal as JeluUser).user, read, messageCategories, pageable) 45 | 46 | @PutMapping(path = ["/user-messages/{id}"]) 47 | fun updateMessage( 48 | @PathVariable("id") 49 | messageId: UUID, 50 | @RequestBody 51 | @Valid 52 | updateDto: UpdateUserMessageDto, 53 | ): UserMessageDto { 54 | return userMessageService.update(messageId, updateDto) 55 | } 56 | 57 | @Hidden 58 | @PostMapping(path = ["/user-messages"]) 59 | fun createMessage( 60 | @RequestBody @Valid 61 | createUserMessageDto: CreateUserMessageDto, 62 | principal: Authentication, 63 | ): UserMessageDto { 64 | return userMessageService.save(createUserMessageDto, (principal.principal as JeluUser).user) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/controllers/ImportController.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.controllers 2 | 3 | import io.github.bayang.jelu.config.JeluProperties 4 | import io.github.bayang.jelu.dto.ImportConfigurationDto 5 | import io.github.bayang.jelu.dto.JeluUser 6 | import io.github.bayang.jelu.service.exports.CsvExportService 7 | import io.github.bayang.jelu.service.imports.CsvImportService 8 | import io.github.oshai.kotlinlogging.KotlinLogging 9 | import io.swagger.v3.oas.annotations.Operation 10 | import io.swagger.v3.oas.annotations.responses.ApiResponse 11 | import jakarta.validation.Valid 12 | import org.apache.commons.io.FilenameUtils 13 | import org.springframework.http.HttpStatus 14 | import org.springframework.http.MediaType 15 | import org.springframework.http.ResponseEntity 16 | import org.springframework.security.core.Authentication 17 | import org.springframework.web.bind.annotation.PostMapping 18 | import org.springframework.web.bind.annotation.RequestMapping 19 | import org.springframework.web.bind.annotation.RequestPart 20 | import org.springframework.web.bind.annotation.RestController 21 | import org.springframework.web.multipart.MultipartFile 22 | import java.io.File 23 | import java.util.Locale 24 | 25 | private val logger = KotlinLogging.logger {} 26 | 27 | @RestController 28 | @RequestMapping("/api/v1") 29 | class ImportController( 30 | val csvImportService: CsvImportService, 31 | val csvExportService: CsvExportService, 32 | private val properties: JeluProperties, 33 | ) { 34 | 35 | @ApiResponse(responseCode = "201", description = "Imported the csv file") 36 | @Operation(description = "Trigger a csv import") 37 | @PostMapping(path = ["/imports"], consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) 38 | fun importCsv( 39 | principal: Authentication, 40 | @RequestPart("importConfig") @Valid importConfig: ImportConfigurationDto, 41 | @RequestPart("file") file: MultipartFile, 42 | ): ResponseEntity { 43 | val destFileName = FilenameUtils.getName(file.originalFilename) 44 | val destFile = File(properties.files.imports, destFileName) 45 | logger.debug { "target import file at ${destFile.absolutePath}" } 46 | file.transferTo(destFile) 47 | if (!destFile.exists()) { 48 | logger.error { "File ${destFile.absolutePath} not created, csv import aborted" } 49 | } 50 | csvImportService.import(destFile, (principal.principal as JeluUser).user.id!!, importConfig) 51 | return ResponseEntity.status(HttpStatus.CREATED).build() 52 | } 53 | 54 | @ApiResponse(responseCode = "201", description = "Saved the export csv request") 55 | @Operation(description = "Trigger a csv export") 56 | @PostMapping(path = ["/exports"]) 57 | fun exportCsv( 58 | principal: Authentication, 59 | locale: Locale, 60 | ): ResponseEntity { 61 | csvExportService.export((principal.principal as JeluUser).user, locale) 62 | return ResponseEntity.status(HttpStatus.CREATED).build() 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/main/kotlin/io/github/bayang/jelu/security/oauth2/GithubOAuth2UserService.kt: -------------------------------------------------------------------------------- 1 | package io.github.bayang.jelu.security.oauth2 2 | 3 | import io.github.oshai.kotlinlogging.KotlinLogging 4 | import org.springframework.core.ParameterizedTypeReference 5 | import org.springframework.http.HttpHeaders 6 | import org.springframework.http.HttpMethod 7 | import org.springframework.http.RequestEntity 8 | import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService 9 | import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest 10 | import org.springframework.security.oauth2.core.user.DefaultOAuth2User 11 | import org.springframework.security.oauth2.core.user.OAuth2User 12 | import org.springframework.web.client.RestTemplate 13 | import org.springframework.web.util.UriComponentsBuilder 14 | 15 | private val logger = KotlinLogging.logger {} 16 | 17 | class GithubOAuth2UserService : DefaultOAuth2UserService() { 18 | private val emailScopes = listOf("user:email", "user") 19 | 20 | private val parameterizedResponseType = object : ParameterizedTypeReference>>() {} 21 | 22 | override fun loadUser(userRequest: OAuth2UserRequest?): OAuth2User { 23 | requireNotNull(userRequest) { "userRequest cannot be null" } 24 | 25 | var oAuth2User = super.loadUser(userRequest) 26 | 27 | if (userRequest.clientRegistration.scopes 28 | .intersect(emailScopes) 29 | .isNotEmpty() && 30 | oAuth2User.getAttribute("email") == null 31 | ) { 32 | try { 33 | val email = 34 | RestTemplate() 35 | .exchange( 36 | RequestEntity( 37 | HttpHeaders().apply { setBearerAuth(userRequest.accessToken.tokenValue) }, 38 | HttpMethod.GET, 39 | UriComponentsBuilder.fromUriString("${userRequest.clientRegistration.providerDetails.userInfoEndpoint.uri}/emails").build().toUri(), 40 | ), 41 | parameterizedResponseType, 42 | ).body 43 | ?.let { emails -> 44 | emails 45 | .filter { it["verified"] == true } 46 | .filter { it["primary"] == true } 47 | .firstNotNullOfOrNull { it["email"].toString() } 48 | } 49 | oAuth2User = 50 | DefaultOAuth2User( 51 | oAuth2User.authorities, 52 | oAuth2User.attributes.toMutableMap().apply { put("email", email) }, 53 | userRequest.clientRegistration.providerDetails.userInfoEndpoint.userNameAttributeName, 54 | ) 55 | } catch (e: Exception) { 56 | logger.warn { "Could not retrieve emails" } 57 | } 58 | } 59 | 60 | return oAuth2User 61 | } 62 | } 63 | --------------------------------------------------------------------------------