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(); + } +}