feat(api): new history endpoint to retrieve historical events

This commit is contained in:
Gauthier Roebroeck 2022-02-18 18:01:35 +08:00
parent 1a8249732d
commit 88f7f57a5d
13 changed files with 285 additions and 5 deletions

View file

@ -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)
);

View file

@ -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,
),
)
}

View file

@ -0,0 +1,7 @@
package org.gotson.komga.domain.persistence
import org.gotson.komga.domain.model.HistoricalEvent
interface HistoricalEventRepository {
fun insert(event: HistoricalEvent)
}

View file

@ -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))
}

View file

@ -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

View file

@ -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))
}

View file

@ -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))
}
}

View file

@ -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))
}

View file

@ -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()
}
}
}

View file

@ -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(),
)
}
}

View file

@ -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>
}

View file

@ -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)
}
}

View file

@ -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>,
)