feat(mobile-api): add updatedAt to progress DTOs for conflict detection

Add updatedAt timestamp field to EpubProgress, PdfProgress, and CbxProgress
DTOs to support progress conflict detection in the mobile app.

The mobile app uses this timestamp to detect when progress has changed on the
server (e.g., from reading in the web UI) while the app was offline with
local progress, enabling automatic conflict resolution.
This commit is contained in:
acx10
2026-01-29 14:44:38 -07:00
parent 0c56b7b720
commit 2b6cc4be7c
2 changed files with 329 additions and 0 deletions

View File

@@ -0,0 +1,80 @@
package com.adityachandel.booklore.mobile.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
/**
* Full DTO for book detail view on mobile.
* Contains all fields needed for the book detail screen.
*/
@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class MobileBookDetail {
// Basic fields (same as MobileBookSummary)
private Long id;
private String title;
private List<String> authors;
private String thumbnailUrl;
private String readStatus;
private Integer personalRating;
private String seriesName;
private Float seriesNumber;
private Long libraryId;
private Instant addedOn;
private Instant lastReadTime;
// Additional detail fields
private String subtitle;
private String description;
private Set<String> categories;
private String publisher;
private LocalDate publishedDate;
private Integer pageCount;
private String isbn13;
private String language;
private Double goodreadsRating;
private Integer goodreadsReviewCount;
private String libraryName;
private List<MobileShelfSummary> shelves;
private Float readProgress;
private String primaryFileType;
private List<String> fileTypes;
private List<MobileBookFile> files;
// Reading position progress for resume
private EpubProgress epubProgress;
private PdfProgress pdfProgress;
private CbxProgress cbxProgress;
@Data
@Builder
public static class EpubProgress {
private String cfi;
private String href;
private Float percentage;
private Instant updatedAt;
}
@Data
@Builder
public static class PdfProgress {
private Integer page;
private Float percentage;
private Instant updatedAt;
}
@Data
@Builder
public static class CbxProgress {
private Integer page;
private Float percentage;
private Instant updatedAt;
}
}

View File

@@ -0,0 +1,249 @@
package com.adityachandel.booklore.mobile.mapper;
import com.adityachandel.booklore.mobile.dto.MobileBookDetail;
import com.adityachandel.booklore.mobile.dto.MobileBookFile;
import com.adityachandel.booklore.mobile.dto.MobileBookSummary;
import com.adityachandel.booklore.mobile.dto.MobileLibrarySummary;
import com.adityachandel.booklore.mobile.dto.MobileMagicShelfSummary;
import com.adityachandel.booklore.mobile.dto.MobileShelfSummary;
import com.adityachandel.booklore.model.entity.*;
import org.mapstruct.*;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface MobileBookMapper {
@Mapping(target = "id", source = "book.id")
@Mapping(target = "title", source = "book.metadata.title")
@Mapping(target = "authors", source = "book.metadata.authors", qualifiedByName = "mapAuthors")
@Mapping(target = "thumbnailUrl", source = "book", qualifiedByName = "mapThumbnailUrl")
@Mapping(target = "readStatus", source = "progress.readStatus")
@Mapping(target = "personalRating", source = "progress.personalRating")
@Mapping(target = "seriesName", source = "book.metadata.seriesName")
@Mapping(target = "seriesNumber", source = "book.metadata.seriesNumber")
@Mapping(target = "libraryId", source = "book.library.id")
@Mapping(target = "addedOn", source = "book.addedOn")
@Mapping(target = "lastReadTime", source = "progress.lastReadTime")
MobileBookSummary toSummary(BookEntity book, UserBookProgressEntity progress);
@Mapping(target = "id", source = "book.id")
@Mapping(target = "title", source = "book.metadata.title")
@Mapping(target = "authors", source = "book.metadata.authors", qualifiedByName = "mapAuthors")
@Mapping(target = "thumbnailUrl", source = "book", qualifiedByName = "mapThumbnailUrl")
@Mapping(target = "readStatus", source = "progress.readStatus")
@Mapping(target = "personalRating", source = "progress.personalRating")
@Mapping(target = "seriesName", source = "book.metadata.seriesName")
@Mapping(target = "seriesNumber", source = "book.metadata.seriesNumber")
@Mapping(target = "libraryId", source = "book.library.id")
@Mapping(target = "addedOn", source = "book.addedOn")
@Mapping(target = "lastReadTime", source = "progress.lastReadTime")
@Mapping(target = "subtitle", source = "book.metadata.subtitle")
@Mapping(target = "description", source = "book.metadata.description")
@Mapping(target = "categories", source = "book.metadata.categories", qualifiedByName = "mapCategories")
@Mapping(target = "publisher", source = "book.metadata.publisher")
@Mapping(target = "publishedDate", source = "book.metadata.publishedDate")
@Mapping(target = "pageCount", source = "book.metadata.pageCount")
@Mapping(target = "isbn13", source = "book.metadata.isbn13")
@Mapping(target = "language", source = "book.metadata.language")
@Mapping(target = "goodreadsRating", source = "book.metadata.goodreadsRating")
@Mapping(target = "goodreadsReviewCount", source = "book.metadata.goodreadsReviewCount")
@Mapping(target = "libraryName", source = "book.library.name")
@Mapping(target = "shelves", source = "book.shelves", qualifiedByName = "mapShelves")
@Mapping(target = "readProgress", source = "progress", qualifiedByName = "mapReadProgress")
@Mapping(target = "primaryFileType", source = "book", qualifiedByName = "mapPrimaryFileType")
@Mapping(target = "fileTypes", source = "book", qualifiedByName = "mapFileTypes")
@Mapping(target = "files", source = "book", qualifiedByName = "mapFiles")
@Mapping(target = "epubProgress", source = "progress", qualifiedByName = "mapEpubProgress")
@Mapping(target = "pdfProgress", source = "progress", qualifiedByName = "mapPdfProgress")
@Mapping(target = "cbxProgress", source = "progress", qualifiedByName = "mapCbxProgress")
MobileBookDetail toDetail(BookEntity book, UserBookProgressEntity progress);
@Named("mapAuthors")
default List<String> mapAuthors(Set<AuthorEntity> authors) {
if (authors == null || authors.isEmpty()) {
return Collections.emptyList();
}
return authors.stream()
.map(AuthorEntity::getName)
.collect(Collectors.toList());
}
@Named("mapCategories")
default Set<String> mapCategories(Set<CategoryEntity> categories) {
if (categories == null || categories.isEmpty()) {
return Collections.emptySet();
}
return categories.stream()
.map(CategoryEntity::getName)
.collect(Collectors.toSet());
}
@Named("mapThumbnailUrl")
default String mapThumbnailUrl(BookEntity book) {
if (book == null || book.getId() == null) {
return null;
}
return "/api/books/" + book.getId() + "/cover";
}
@Named("mapShelves")
default List<MobileShelfSummary> mapShelves(Set<ShelfEntity> shelves) {
if (shelves == null || shelves.isEmpty()) {
return Collections.emptyList();
}
return shelves.stream()
.map(this::toShelfSummary)
.collect(Collectors.toList());
}
default MobileShelfSummary toShelfSummary(ShelfEntity shelf) {
if (shelf == null) {
return null;
}
return MobileShelfSummary.builder()
.id(shelf.getId())
.name(shelf.getName())
.icon(shelf.getIcon())
.bookCount(shelf.getBookEntities() != null ? shelf.getBookEntities().size() : 0)
.publicShelf(shelf.isPublic())
.build();
}
@Named("mapReadProgress")
default Float mapReadProgress(UserBookProgressEntity progress) {
if (progress == null) {
return null;
}
// Try KoReader progress first, then Kobo progress
if (progress.getKoreaderProgressPercent() != null) {
return progress.getKoreaderProgressPercent();
}
if (progress.getKoboProgressPercent() != null) {
return progress.getKoboProgressPercent();
}
return null;
}
@Named("mapEpubProgress")
default MobileBookDetail.EpubProgress mapEpubProgress(UserBookProgressEntity progress) {
if (progress == null || progress.getEpubProgress() == null) {
return null;
}
return MobileBookDetail.EpubProgress.builder()
.cfi(progress.getEpubProgress())
.href(progress.getEpubProgressHref())
.percentage(progress.getEpubProgressPercent())
.updatedAt(progress.getLastReadTime())
.build();
}
@Named("mapPdfProgress")
default MobileBookDetail.PdfProgress mapPdfProgress(UserBookProgressEntity progress) {
if (progress == null || progress.getPdfProgress() == null) {
return null;
}
return MobileBookDetail.PdfProgress.builder()
.page(progress.getPdfProgress())
.percentage(progress.getPdfProgressPercent())
.updatedAt(progress.getLastReadTime())
.build();
}
@Named("mapCbxProgress")
default MobileBookDetail.CbxProgress mapCbxProgress(UserBookProgressEntity progress) {
if (progress == null || progress.getCbxProgress() == null) {
return null;
}
return MobileBookDetail.CbxProgress.builder()
.page(progress.getCbxProgress())
.percentage(progress.getCbxProgressPercent())
.updatedAt(progress.getLastReadTime())
.build();
}
@Named("mapPrimaryFileType")
default String mapPrimaryFileType(BookEntity book) {
if (book == null) {
return null;
}
BookFileEntity primaryFile = book.getPrimaryBookFile();
if (primaryFile != null && primaryFile.getBookType() != null) {
return primaryFile.getBookType().name();
}
return null;
}
@Named("mapFileTypes")
default List<String> mapFileTypes(BookEntity book) {
if (book == null || book.getBookFiles() == null || book.getBookFiles().isEmpty()) {
return Collections.emptyList();
}
return book.getBookFiles().stream()
.filter(bf -> bf.getBookType() != null)
.map(bf -> bf.getBookType().name())
.distinct()
.collect(Collectors.toList());
}
@Named("mapFiles")
default List<MobileBookFile> mapFiles(BookEntity book) {
if (book == null || book.getBookFiles() == null || book.getBookFiles().isEmpty()) {
return Collections.emptyList();
}
BookFileEntity primaryFile = book.getPrimaryBookFile();
Long primaryId = primaryFile != null ? primaryFile.getId() : null;
return book.getBookFiles().stream()
.filter(bf -> bf.getBookType() != null && bf.isBook())
.map(bf -> MobileBookFile.builder()
.id(bf.getId())
.bookType(bf.getBookType().name())
.fileSizeKb(bf.getFileSizeKb())
.fileName(bf.getFileName())
.isPrimary(bf.getId().equals(primaryId))
.build())
.collect(Collectors.toList());
}
default MobileLibrarySummary toLibrarySummary(LibraryEntity library, long bookCount) {
if (library == null) {
return null;
}
return MobileLibrarySummary.builder()
.id(library.getId())
.name(library.getName())
.icon(library.getIcon())
.bookCount(bookCount)
.build();
}
default MobileShelfSummary toShelfSummaryFromEntity(ShelfEntity shelf) {
if (shelf == null) {
return null;
}
return MobileShelfSummary.builder()
.id(shelf.getId())
.name(shelf.getName())
.icon(shelf.getIcon())
.bookCount(shelf.getBookEntities() != null ? shelf.getBookEntities().size() : 0)
.publicShelf(shelf.isPublic())
.build();
}
default MobileMagicShelfSummary toMagicShelfSummary(MagicShelfEntity magicShelf) {
if (magicShelf == null) {
return null;
}
return MobileMagicShelfSummary.builder()
.id(magicShelf.getId())
.name(magicShelf.getName())
.icon(magicShelf.getIcon())
.iconType(magicShelf.getIconType() != null ? magicShelf.getIconType().name() : null)
.publicShelf(magicShelf.isPublic())
.build();
}
}