mirror of
https://github.com/gotson/komga.git
synced 2025-12-16 13:33:49 +01:00
feat(api): new history endpoint to retrieve historical events
This commit is contained in:
parent
1a8249732d
commit
88f7f57a5d
13 changed files with 285 additions and 5 deletions
|
|
@ -0,0 +1,17 @@
|
|||
CREATE TABLE HISTORICAL_EVENT
|
||||
(
|
||||
ID varchar PRIMARY KEY,
|
||||
TYPE varchar NOT NULL,
|
||||
BOOK_ID varchar NULL,
|
||||
SERIES_ID varchar NULL,
|
||||
TIMESTAMP datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE HISTORICAL_EVENT_PROPERTIES
|
||||
(
|
||||
ID varchar NOT NULL,
|
||||
KEY varchar NOT NULL,
|
||||
VALUE varchar NOT NULL,
|
||||
PRIMARY KEY (ID, KEY),
|
||||
FOREIGN KEY (ID) REFERENCES HISTORICAL_EVENT (ID)
|
||||
);
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
package org.gotson.komga.domain.model
|
||||
|
||||
import com.github.f4b6a3.tsid.TsidCreator
|
||||
import java.nio.file.Path
|
||||
import java.time.LocalDateTime
|
||||
|
||||
sealed class HistoricalEvent(
|
||||
val type: String,
|
||||
val bookId: String? = null,
|
||||
val seriesId: String? = null,
|
||||
val properties: Map<String, String> = emptyMap(),
|
||||
val timestamp: LocalDateTime = LocalDateTime.now(),
|
||||
val id: String = TsidCreator.getTsid256().toString(),
|
||||
) {
|
||||
class BookFileDeleted(book: Book, reason: String) : HistoricalEvent(
|
||||
type = "BookFileDeleted",
|
||||
bookId = book.id,
|
||||
seriesId = book.seriesId,
|
||||
properties = mapOf(
|
||||
"reason" to reason,
|
||||
"name" to book.path.toString(),
|
||||
),
|
||||
)
|
||||
|
||||
class SeriesFolderDeleted(seriesId: String, seriesPath: Path, reason: String) : HistoricalEvent(
|
||||
type = "SeriesFolderDeleted",
|
||||
seriesId = seriesId,
|
||||
properties = mapOf(
|
||||
"reason" to reason,
|
||||
"name" to seriesPath.toString(),
|
||||
),
|
||||
) {
|
||||
constructor(series: Series, reason: String) : this(series.id, series.path, reason)
|
||||
}
|
||||
|
||||
class BookConverted(book: Book, previous: Book) : HistoricalEvent(
|
||||
type = "BookConverted",
|
||||
bookId = book.id,
|
||||
seriesId = book.seriesId,
|
||||
properties = mapOf(
|
||||
"name" to book.path.toString(),
|
||||
"former file" to previous.path.toString(),
|
||||
),
|
||||
)
|
||||
|
||||
class BookImported(book: Book, series: Series, source: Path, upgrade: Boolean) : HistoricalEvent(
|
||||
type = "BookImported",
|
||||
bookId = book.id,
|
||||
seriesId = series.id,
|
||||
properties = mapOf(
|
||||
"name" to book.path.toString(),
|
||||
"source" to source.toString(),
|
||||
"upgrade" to if (upgrade) "Yes" else "No",
|
||||
),
|
||||
)
|
||||
|
||||
class DuplicatePageDeleted(book: Book, page: BookPageNumbered) : HistoricalEvent(
|
||||
type = "DuplicatePageDeleted",
|
||||
bookId = book.id,
|
||||
seriesId = book.seriesId,
|
||||
properties = mapOf(
|
||||
"name" to book.path.toString(),
|
||||
"page number" to page.pageNumber.toString(),
|
||||
"page file name" to page.fileName,
|
||||
"page file hash" to page.fileHash,
|
||||
"page file size" to page.fileSize.toString(),
|
||||
"page media type" to page.mediaType,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package org.gotson.komga.domain.persistence
|
||||
|
||||
import org.gotson.komga.domain.model.HistoricalEvent
|
||||
|
||||
interface HistoricalEventRepository {
|
||||
fun insert(event: HistoricalEvent)
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import org.gotson.komga.domain.model.Book
|
|||
import org.gotson.komga.domain.model.BookConversionException
|
||||
import org.gotson.komga.domain.model.BookWithMedia
|
||||
import org.gotson.komga.domain.model.DomainEvent
|
||||
import org.gotson.komga.domain.model.HistoricalEvent
|
||||
import org.gotson.komga.domain.model.Library
|
||||
import org.gotson.komga.domain.model.Media
|
||||
import org.gotson.komga.domain.model.MediaNotReadyException
|
||||
|
|
@ -16,6 +17,7 @@ import org.gotson.komga.domain.model.MediaType
|
|||
import org.gotson.komga.domain.model.MediaUnsupportedException
|
||||
import org.gotson.komga.domain.model.restoreHashFrom
|
||||
import org.gotson.komga.domain.persistence.BookRepository
|
||||
import org.gotson.komga.domain.persistence.HistoricalEventRepository
|
||||
import org.gotson.komga.domain.persistence.LibraryRepository
|
||||
import org.gotson.komga.domain.persistence.MediaRepository
|
||||
import org.gotson.komga.infrastructure.language.notEquals
|
||||
|
|
@ -45,6 +47,7 @@ class BookConverter(
|
|||
private val libraryRepository: LibraryRepository,
|
||||
private val transactionTemplate: TransactionTemplate,
|
||||
private val eventPublisher: EventPublisher,
|
||||
private val historicalEventRepository: HistoricalEventRepository,
|
||||
) {
|
||||
|
||||
private val convertibleTypes = listOf(MediaType.RAR_4.value)
|
||||
|
|
@ -140,8 +143,10 @@ class BookConverter(
|
|||
throw e
|
||||
}
|
||||
|
||||
if (book.path.deleteIfExists())
|
||||
if (book.path.deleteIfExists()) {
|
||||
logger.info { "Deleted old file: ${book.path}" }
|
||||
historicalEventRepository.insert(HistoricalEvent.BookFileDeleted(book, "File was deleted after conversion to CBZ"))
|
||||
}
|
||||
|
||||
val mediaWithHashes = convertedMedia.copy(pages = convertedMedia.pages.restoreHashFrom(media.pages))
|
||||
|
||||
|
|
@ -150,6 +155,7 @@ class BookConverter(
|
|||
mediaRepository.update(mediaWithHashes)
|
||||
}
|
||||
|
||||
historicalEventRepository.insert(HistoricalEvent.BookConverted(convertedBook, book))
|
||||
eventPublisher.publishEvent(DomainEvent.BookUpdated(convertedBook))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import org.gotson.komga.domain.model.Book
|
|||
import org.gotson.komga.domain.model.CodedException
|
||||
import org.gotson.komga.domain.model.CopyMode
|
||||
import org.gotson.komga.domain.model.DomainEvent
|
||||
import org.gotson.komga.domain.model.HistoricalEvent
|
||||
import org.gotson.komga.domain.model.Media
|
||||
import org.gotson.komga.domain.model.PathContainedInPath
|
||||
import org.gotson.komga.domain.model.Series
|
||||
|
|
@ -14,6 +15,7 @@ import org.gotson.komga.domain.model.Sidecar
|
|||
import org.gotson.komga.domain.model.withCode
|
||||
import org.gotson.komga.domain.persistence.BookMetadataRepository
|
||||
import org.gotson.komga.domain.persistence.BookRepository
|
||||
import org.gotson.komga.domain.persistence.HistoricalEventRepository
|
||||
import org.gotson.komga.domain.persistence.LibraryRepository
|
||||
import org.gotson.komga.domain.persistence.MediaRepository
|
||||
import org.gotson.komga.domain.persistence.ReadListRepository
|
||||
|
|
@ -56,6 +58,7 @@ class BookImporter(
|
|||
private val sidecarRepository: SidecarRepository,
|
||||
private val eventPublisher: EventPublisher,
|
||||
private val taskEmitter: TaskEmitter,
|
||||
private val historicalEventRepository: HistoricalEventRepository,
|
||||
) {
|
||||
|
||||
fun importBook(sourceFile: Path, series: Series, copyMode: CopyMode, destinationName: String? = null, upgradeBookId: String? = null): Book {
|
||||
|
|
@ -90,6 +93,7 @@ class BookImporter(
|
|||
logger.info { "Deleting existing file: ${bookToUpgrade.path}" }
|
||||
try {
|
||||
bookToUpgrade.path.deleteExisting()
|
||||
historicalEventRepository.insert(HistoricalEvent.BookFileDeleted(bookToUpgrade, "File was deleted to import an upgrade"))
|
||||
deletedUpgradedFile = true
|
||||
} catch (e: NoSuchFileException) {
|
||||
logger.warn { "Could not delete upgraded book: ${bookToUpgrade.path}" }
|
||||
|
|
@ -184,8 +188,10 @@ class BookImporter(
|
|||
}
|
||||
|
||||
// delete upgraded book file on disk if it has not been replaced earlier
|
||||
if (!deletedUpgradedFile && bookToUpgrade.path.deleteIfExists())
|
||||
if (!deletedUpgradedFile && bookToUpgrade.path.deleteIfExists()) {
|
||||
logger.info { "Deleted existing file: ${bookToUpgrade.path}" }
|
||||
historicalEventRepository.insert(HistoricalEvent.BookFileDeleted(bookToUpgrade, "File was deleted to import an upgrade"))
|
||||
}
|
||||
|
||||
// delete upgraded book
|
||||
bookLifecycle.deleteOne(bookToUpgrade)
|
||||
|
|
@ -206,6 +212,7 @@ class BookImporter(
|
|||
sidecarRepository.save(importedBook.libraryId, destSidecar)
|
||||
}
|
||||
|
||||
historicalEventRepository.insert(HistoricalEvent.BookImported(importedBook, series, sourceFile, upgradeBookId != null))
|
||||
eventPublisher.publishEvent(DomainEvent.BookImported(importedBook, sourceFile.toUri().toURL(), success = true))
|
||||
|
||||
return importedBook
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import org.gotson.komga.domain.model.Book
|
|||
import org.gotson.komga.domain.model.BookPageContent
|
||||
import org.gotson.komga.domain.model.BookWithMedia
|
||||
import org.gotson.komga.domain.model.DomainEvent
|
||||
import org.gotson.komga.domain.model.HistoricalEvent
|
||||
import org.gotson.komga.domain.model.ImageConversionException
|
||||
import org.gotson.komga.domain.model.KomgaUser
|
||||
import org.gotson.komga.domain.model.MarkSelectedPreference
|
||||
|
|
@ -15,6 +16,7 @@ import org.gotson.komga.domain.model.ReadProgress
|
|||
import org.gotson.komga.domain.model.ThumbnailBook
|
||||
import org.gotson.komga.domain.persistence.BookMetadataRepository
|
||||
import org.gotson.komga.domain.persistence.BookRepository
|
||||
import org.gotson.komga.domain.persistence.HistoricalEventRepository
|
||||
import org.gotson.komga.domain.persistence.LibraryRepository
|
||||
import org.gotson.komga.domain.persistence.MediaRepository
|
||||
import org.gotson.komga.domain.persistence.ReadListRepository
|
||||
|
|
@ -50,6 +52,7 @@ class BookLifecycle(
|
|||
private val eventPublisher: EventPublisher,
|
||||
private val transactionTemplate: TransactionTemplate,
|
||||
private val hasher: Hasher,
|
||||
private val historicalEventRepository: HistoricalEventRepository,
|
||||
) {
|
||||
|
||||
fun analyzeAndPersist(book: Book): Boolean {
|
||||
|
|
@ -327,13 +330,19 @@ class BookLifecycle(
|
|||
.mapNotNull { it.url?.toURI()?.toPath() }
|
||||
.filter { it.exists() && it.isWritable() }
|
||||
|
||||
if (book.path.deleteIfExists()) logger.info { "Deleted file: ${book.path}" }
|
||||
if (book.path.deleteIfExists()) {
|
||||
logger.info { "Deleted file: ${book.path}" }
|
||||
historicalEventRepository.insert(HistoricalEvent.BookFileDeleted(book, "File was deleted by user request"))
|
||||
}
|
||||
thumbnails.forEach {
|
||||
if (it.deleteIfExists()) logger.info { "Deleted file: $it" }
|
||||
}
|
||||
|
||||
if (book.path.parent.listDirectoryEntries().isEmpty())
|
||||
if (book.path.parent.deleteIfExists()) logger.info { "Deleted directory: ${book.path.parent}" }
|
||||
if (book.path.parent.deleteIfExists()) {
|
||||
logger.info { "Deleted directory: ${book.path.parent}" }
|
||||
historicalEventRepository.insert(HistoricalEvent.SeriesFolderDeleted(book.seriesId, book.path.parent, "Folder was deleted because it was empty"))
|
||||
}
|
||||
|
||||
softDeleteMany(listOf(book))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import org.gotson.komga.domain.model.BookConversionException
|
|||
import org.gotson.komga.domain.model.BookPageNumbered
|
||||
import org.gotson.komga.domain.model.BookWithMedia
|
||||
import org.gotson.komga.domain.model.DomainEvent
|
||||
import org.gotson.komga.domain.model.HistoricalEvent
|
||||
import org.gotson.komga.domain.model.Media
|
||||
import org.gotson.komga.domain.model.MediaNotReadyException
|
||||
import org.gotson.komga.domain.model.MediaType
|
||||
|
|
@ -17,6 +18,7 @@ import org.gotson.komga.domain.model.MediaUnsupportedException
|
|||
import org.gotson.komga.domain.model.PageHash
|
||||
import org.gotson.komga.domain.model.restoreHashFrom
|
||||
import org.gotson.komga.domain.persistence.BookRepository
|
||||
import org.gotson.komga.domain.persistence.HistoricalEventRepository
|
||||
import org.gotson.komga.domain.persistence.LibraryRepository
|
||||
import org.gotson.komga.domain.persistence.MediaRepository
|
||||
import org.gotson.komga.domain.persistence.PageHashRepository
|
||||
|
|
@ -44,6 +46,7 @@ class BookPageEditor(
|
|||
private val pageHashRepository: PageHashRepository,
|
||||
private val transactionTemplate: TransactionTemplate,
|
||||
private val eventPublisher: EventPublisher,
|
||||
private val historicalEventRepository: HistoricalEventRepository,
|
||||
) {
|
||||
private val convertibleTypes = listOf(MediaType.ZIP.value)
|
||||
|
||||
|
|
@ -151,6 +154,7 @@ class BookPageEditor(
|
|||
.forEach { pageHashRepository.update(it.copy(deleteCount = it.deleteCount + 1)) }
|
||||
}
|
||||
|
||||
pagesToDelete.forEach { historicalEventRepository.insert(HistoricalEvent.DuplicatePageDeleted(book, it)) }
|
||||
eventPublisher.publishEvent(DomainEvent.BookUpdated(newBook))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import org.gotson.komga.domain.model.BookMetadata
|
|||
import org.gotson.komga.domain.model.BookMetadataAggregation
|
||||
import org.gotson.komga.domain.model.BookMetadataPatchCapability
|
||||
import org.gotson.komga.domain.model.DomainEvent
|
||||
import org.gotson.komga.domain.model.HistoricalEvent
|
||||
import org.gotson.komga.domain.model.KomgaUser
|
||||
import org.gotson.komga.domain.model.Library
|
||||
import org.gotson.komga.domain.model.MarkSelectedPreference
|
||||
|
|
@ -21,6 +22,7 @@ import org.gotson.komga.domain.model.ThumbnailSeries
|
|||
import org.gotson.komga.domain.persistence.BookMetadataAggregationRepository
|
||||
import org.gotson.komga.domain.persistence.BookMetadataRepository
|
||||
import org.gotson.komga.domain.persistence.BookRepository
|
||||
import org.gotson.komga.domain.persistence.HistoricalEventRepository
|
||||
import org.gotson.komga.domain.persistence.LibraryRepository
|
||||
import org.gotson.komga.domain.persistence.MediaRepository
|
||||
import org.gotson.komga.domain.persistence.ReadProgressRepository
|
||||
|
|
@ -59,6 +61,7 @@ class SeriesLifecycle(
|
|||
private val taskEmitter: TaskEmitter,
|
||||
private val eventPublisher: EventPublisher,
|
||||
private val transactionTemplate: TransactionTemplate,
|
||||
private val historicalEventRepository: HistoricalEventRepository,
|
||||
) {
|
||||
|
||||
private val whitespacePattern = """\s+""".toRegex()
|
||||
|
|
@ -305,7 +308,10 @@ class SeriesLifecycle(
|
|||
}
|
||||
|
||||
if (series.path.exists() && series.path.listDirectoryEntries().isEmpty())
|
||||
if (series.path.deleteIfExists()) logger.info { "Deleted directory: ${series.path}" }
|
||||
if (series.path.deleteIfExists()) {
|
||||
logger.info { "Deleted directory: ${series.path}" }
|
||||
historicalEventRepository.insert(HistoricalEvent.SeriesFolderDeleted(series, "Folder was deleted because it was empty"))
|
||||
}
|
||||
|
||||
softDeleteMany(listOf(series))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
package org.gotson.komga.infrastructure.jooq
|
||||
|
||||
import org.gotson.komga.domain.model.HistoricalEvent
|
||||
import org.gotson.komga.domain.persistence.HistoricalEventRepository
|
||||
import org.gotson.komga.jooq.Tables
|
||||
import org.jooq.DSLContext
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Component
|
||||
class HistoricalEventDao(
|
||||
private val dsl: DSLContext,
|
||||
) : HistoricalEventRepository {
|
||||
|
||||
private val e = Tables.HISTORICAL_EVENT
|
||||
private val ep = Tables.HISTORICAL_EVENT_PROPERTIES
|
||||
|
||||
@Transactional
|
||||
override fun insert(event: HistoricalEvent) {
|
||||
dsl.insertInto(e)
|
||||
.set(e.ID, event.id)
|
||||
.set(e.TYPE, event.type)
|
||||
.set(e.BOOK_ID, event.bookId)
|
||||
.set(e.SERIES_ID, event.seriesId)
|
||||
.set(e.TIMESTAMP, event.timestamp)
|
||||
.execute()
|
||||
|
||||
if (event.properties.isNotEmpty()) {
|
||||
dsl.batch(
|
||||
dsl.insertInto(ep, ep.ID, ep.KEY, ep.VALUE)
|
||||
.values(null as String?, null, null),
|
||||
).also { step ->
|
||||
event.properties.forEach { (key, value) ->
|
||||
step.bind(event.id, key, value)
|
||||
}
|
||||
}.execute()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package org.gotson.komga.infrastructure.jooq
|
||||
|
||||
import org.gotson.komga.interfaces.api.persistence.HistoricalEventDtoRepository
|
||||
import org.gotson.komga.interfaces.api.rest.dto.HistoricalEventDto
|
||||
import org.gotson.komga.jooq.Tables
|
||||
import org.jooq.DSLContext
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.PageImpl
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@Component
|
||||
class HistoricalEventDtoDao(
|
||||
private val dsl: DSLContext,
|
||||
) : HistoricalEventDtoRepository {
|
||||
|
||||
private val e = Tables.HISTORICAL_EVENT
|
||||
private val ep = Tables.HISTORICAL_EVENT_PROPERTIES
|
||||
|
||||
private val sorts = mapOf(
|
||||
"type" to e.TYPE,
|
||||
"bookId" to e.BOOK_ID,
|
||||
"seriesId" to e.SERIES_ID,
|
||||
"timestamp" to e.TIMESTAMP,
|
||||
)
|
||||
|
||||
override fun findAll(pageable: Pageable): Page<HistoricalEventDto> {
|
||||
val count = dsl.fetchCount(e)
|
||||
|
||||
val orderBy = pageable.sort.toOrderBy(sorts)
|
||||
|
||||
val items = dsl.selectFrom(e)
|
||||
.orderBy(orderBy)
|
||||
.apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) }
|
||||
.map { er ->
|
||||
val epr = dsl.selectFrom(ep).where(ep.ID.eq(er.id)).fetch()
|
||||
HistoricalEventDto(
|
||||
type = er.type,
|
||||
timestamp = er.timestamp,
|
||||
bookId = er.bookId,
|
||||
seriesId = er.seriesId,
|
||||
properties = epr.filterNot { it.key == null }.associate { it.key to it.value },
|
||||
)
|
||||
}
|
||||
|
||||
val pageSort = if (orderBy.isNotEmpty()) pageable.sort else Sort.unsorted()
|
||||
return PageImpl(
|
||||
items,
|
||||
if (pageable.isPaged) PageRequest.of(pageable.pageNumber, pageable.pageSize, pageSort)
|
||||
else PageRequest.of(0, maxOf(count, 20), pageSort),
|
||||
count.toLong(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package org.gotson.komga.interfaces.api.persistence
|
||||
|
||||
import org.gotson.komga.interfaces.api.rest.dto.HistoricalEventDto
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
|
||||
interface HistoricalEventDtoRepository {
|
||||
fun findAll(pageable: Pageable): Page<HistoricalEventDto>
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package org.gotson.komga.interfaces.api.rest
|
||||
|
||||
import io.swagger.v3.oas.annotations.Parameter
|
||||
import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam
|
||||
import org.gotson.komga.interfaces.api.persistence.HistoricalEventDtoRepository
|
||||
import org.gotson.komga.interfaces.api.rest.dto.HistoricalEventDto
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("api/v1/history", produces = [MediaType.APPLICATION_JSON_VALUE])
|
||||
class HistoricalEventController(
|
||||
private val historicalEventDtoRepository: HistoricalEventDtoRepository,
|
||||
) {
|
||||
|
||||
@GetMapping
|
||||
@PageableAsQueryParam
|
||||
fun getAll(
|
||||
@Parameter(hidden = true) page: Pageable,
|
||||
): Page<HistoricalEventDto> {
|
||||
val sort =
|
||||
if (page.sort.isSorted) page.sort
|
||||
else Sort.by(Sort.Order.desc("timestamp"))
|
||||
|
||||
val pageRequest = PageRequest.of(
|
||||
page.pageNumber,
|
||||
page.pageSize,
|
||||
sort,
|
||||
)
|
||||
|
||||
return historicalEventDtoRepository.findAll(pageRequest)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package org.gotson.komga.interfaces.api.rest.dto
|
||||
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class HistoricalEventDto(
|
||||
val type: String,
|
||||
val timestamp: LocalDateTime,
|
||||
val bookId: String?,
|
||||
val seriesId: String?,
|
||||
val properties: Map<String, String>,
|
||||
)
|
||||
Loading…
Reference in a new issue