From 2b6cc4be7cf7d8b1f451febf97ff64f1890dae8f Mon Sep 17 00:00:00 2001 From: acx10 Date: Thu, 29 Jan 2026 14:44:38 -0700 Subject: [PATCH] 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. --- .../booklore/mobile/dto/MobileBookDetail.java | 80 ++++++ .../mobile/mapper/MobileBookMapper.java | 249 ++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileBookDetail.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/mobile/mapper/MobileBookMapper.java diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileBookDetail.java b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileBookDetail.java new file mode 100644 index 000000000..b60400ec0 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileBookDetail.java @@ -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 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 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 shelves; + private Float readProgress; + private String primaryFileType; + private List fileTypes; + private List 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; + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mobile/mapper/MobileBookMapper.java b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/mapper/MobileBookMapper.java new file mode 100644 index 000000000..29d2b7df8 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/mapper/MobileBookMapper.java @@ -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 mapAuthors(Set authors) { + if (authors == null || authors.isEmpty()) { + return Collections.emptyList(); + } + return authors.stream() + .map(AuthorEntity::getName) + .collect(Collectors.toList()); + } + + @Named("mapCategories") + default Set mapCategories(Set 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 mapShelves(Set 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 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 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(); + } +}