mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
feat(comic-metadata): add extended comic metadata support (#2654)
* feat(metadata): add comic metadata support * Fix metadata saving * feat(comic-metadata): normalize comic metadata with relational tables * feat(comic-metadata): add comic metadata UI components and model --------- Co-authored-by: acx10 <acx10@users.noreply.github.com>
This commit is contained in:
@@ -15,7 +15,7 @@ import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Mapper(componentModel = "spring", uses = {BookMetadataMapper.class, ShelfMapper.class, AdditionalFileMapper.class}, unmappedTargetPolicy = ReportingPolicy.IGNORE)
|
||||
@Mapper(componentModel = "spring", uses = {BookMetadataMapper.class, ShelfMapper.class, AdditionalFileMapper.class, ComicMetadataMapper.class}, unmappedTargetPolicy = ReportingPolicy.IGNORE)
|
||||
public interface BookMapper {
|
||||
|
||||
@Mapping(source = "library.id", target = "libraryId")
|
||||
|
||||
@@ -4,7 +4,7 @@ import org.booklore.model.dto.BookMetadata;
|
||||
import org.booklore.model.entity.BookMetadataEntity;
|
||||
import org.mapstruct.*;
|
||||
|
||||
@Mapper(componentModel = "spring", uses = {AuthorMapper.class, CategoryMapper.class, MoodMapper.class, TagMapper.class}, unmappedTargetPolicy = ReportingPolicy.IGNORE)
|
||||
@Mapper(componentModel = "spring", uses = {AuthorMapper.class, CategoryMapper.class, MoodMapper.class, TagMapper.class, ComicMetadataMapper.class}, unmappedTargetPolicy = ReportingPolicy.IGNORE)
|
||||
public interface BookMetadataMapper {
|
||||
|
||||
@AfterMapping
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package org.booklore.mapper;
|
||||
|
||||
import org.booklore.model.dto.ComicMetadata;
|
||||
import org.booklore.model.entity.*;
|
||||
import org.booklore.model.enums.ComicCreatorRole;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
import org.mapstruct.Named;
|
||||
import org.mapstruct.ReportingPolicy;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
|
||||
public interface ComicMetadataMapper {
|
||||
|
||||
@Mapping(target = "characters", source = "characters", qualifiedByName = "charactersToStrings")
|
||||
@Mapping(target = "teams", source = "teams", qualifiedByName = "teamsToStrings")
|
||||
@Mapping(target = "locations", source = "locations", qualifiedByName = "locationsToStrings")
|
||||
@Mapping(target = "pencillers", source = "creatorMappings", qualifiedByName = "pencillersToStrings")
|
||||
@Mapping(target = "inkers", source = "creatorMappings", qualifiedByName = "inkersToStrings")
|
||||
@Mapping(target = "colorists", source = "creatorMappings", qualifiedByName = "coloristsToStrings")
|
||||
@Mapping(target = "letterers", source = "creatorMappings", qualifiedByName = "letterersToStrings")
|
||||
@Mapping(target = "coverArtists", source = "creatorMappings", qualifiedByName = "coverArtistsToStrings")
|
||||
@Mapping(target = "editors", source = "creatorMappings", qualifiedByName = "editorsToStrings")
|
||||
ComicMetadata toComicMetadata(ComicMetadataEntity entity);
|
||||
|
||||
@Named("charactersToStrings")
|
||||
default Set<String> charactersToStrings(Set<ComicCharacterEntity> characters) {
|
||||
if (characters == null) return Collections.emptySet();
|
||||
return characters.stream().map(ComicCharacterEntity::getName).collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@Named("teamsToStrings")
|
||||
default Set<String> teamsToStrings(Set<ComicTeamEntity> teams) {
|
||||
if (teams == null) return Collections.emptySet();
|
||||
return teams.stream().map(ComicTeamEntity::getName).collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@Named("locationsToStrings")
|
||||
default Set<String> locationsToStrings(Set<ComicLocationEntity> locations) {
|
||||
if (locations == null) return Collections.emptySet();
|
||||
return locations.stream().map(ComicLocationEntity::getName).collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@Named("pencillersToStrings")
|
||||
default Set<String> pencillersToStrings(Set<ComicCreatorMappingEntity> mappings) {
|
||||
return creatorsToStringsByRole(mappings, ComicCreatorRole.PENCILLER);
|
||||
}
|
||||
|
||||
@Named("inkersToStrings")
|
||||
default Set<String> inkersToStrings(Set<ComicCreatorMappingEntity> mappings) {
|
||||
return creatorsToStringsByRole(mappings, ComicCreatorRole.INKER);
|
||||
}
|
||||
|
||||
@Named("coloristsToStrings")
|
||||
default Set<String> coloristsToStrings(Set<ComicCreatorMappingEntity> mappings) {
|
||||
return creatorsToStringsByRole(mappings, ComicCreatorRole.COLORIST);
|
||||
}
|
||||
|
||||
@Named("letterersToStrings")
|
||||
default Set<String> letterersToStrings(Set<ComicCreatorMappingEntity> mappings) {
|
||||
return creatorsToStringsByRole(mappings, ComicCreatorRole.LETTERER);
|
||||
}
|
||||
|
||||
@Named("coverArtistsToStrings")
|
||||
default Set<String> coverArtistsToStrings(Set<ComicCreatorMappingEntity> mappings) {
|
||||
return creatorsToStringsByRole(mappings, ComicCreatorRole.COVER_ARTIST);
|
||||
}
|
||||
|
||||
@Named("editorsToStrings")
|
||||
default Set<String> editorsToStrings(Set<ComicCreatorMappingEntity> mappings) {
|
||||
return creatorsToStringsByRole(mappings, ComicCreatorRole.EDITOR);
|
||||
}
|
||||
|
||||
default Set<String> creatorsToStringsByRole(Set<ComicCreatorMappingEntity> mappings, ComicCreatorRole role) {
|
||||
if (mappings == null) return Collections.emptySet();
|
||||
return mappings.stream()
|
||||
.filter(m -> m.getRole() == role)
|
||||
.map(m -> m.getCreator().getName())
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.booklore.mapper.v2;
|
||||
|
||||
import org.booklore.mapper.ComicMetadataMapper;
|
||||
import org.booklore.mapper.ShelfMapper;
|
||||
import org.booklore.model.dto.*;
|
||||
import org.booklore.model.entity.*;
|
||||
@@ -15,7 +16,7 @@ import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Mapper(componentModel = "spring", uses = ShelfMapper.class, unmappedTargetPolicy = ReportingPolicy.IGNORE)
|
||||
@Mapper(componentModel = "spring", uses = {ShelfMapper.class, ComicMetadataMapper.class}, unmappedTargetPolicy = ReportingPolicy.IGNORE)
|
||||
public interface BookMapperV2 {
|
||||
|
||||
@Mapping(source = "library.id", target = "libraryId")
|
||||
|
||||
@@ -33,6 +33,7 @@ public class BookMetadata {
|
||||
private Boolean abridged;
|
||||
|
||||
private AudiobookMetadata audiobookMetadata;
|
||||
private ComicMetadata comicMetadata;
|
||||
|
||||
private String asin;
|
||||
private Double amazonRating;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.booklore.model.dto;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ComicMetadata {
|
||||
private String issueNumber;
|
||||
private String volumeName;
|
||||
private Integer volumeNumber;
|
||||
private String storyArc;
|
||||
private Integer storyArcNumber;
|
||||
private String alternateSeries;
|
||||
private String alternateIssue;
|
||||
|
||||
// Creators
|
||||
private Set<String> pencillers;
|
||||
private Set<String> inkers;
|
||||
private Set<String> colorists;
|
||||
private Set<String> letterers;
|
||||
private Set<String> coverArtists;
|
||||
private Set<String> editors;
|
||||
|
||||
private String imprint;
|
||||
private String format;
|
||||
private Boolean blackAndWhite;
|
||||
private Boolean manga;
|
||||
private String readingDirection;
|
||||
|
||||
// Characters, teams, locations
|
||||
private Set<String> characters;
|
||||
private Set<String> teams;
|
||||
private Set<String> locations;
|
||||
|
||||
private String webLink;
|
||||
private String notes;
|
||||
|
||||
// Locked fields
|
||||
private Boolean issueNumberLocked;
|
||||
private Boolean volumeNameLocked;
|
||||
private Boolean volumeNumberLocked;
|
||||
private Boolean storyArcLocked;
|
||||
private Boolean creatorsLocked;
|
||||
private Boolean charactersLocked;
|
||||
private Boolean teamsLocked;
|
||||
private Boolean locationsLocked;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
package org.booklore.model.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.booklore.convertor.BookRecommendationIdsListConverter;
|
||||
import org.booklore.model.dto.BookRecommendationLite;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
@@ -12,7 +12,6 @@ import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Entity
|
||||
@Getter
|
||||
@@ -96,15 +95,6 @@ public class BookEntity {
|
||||
return Paths.get(libraryPath.getPath(), primaryBookFile.getFileSubPath(), primaryBookFile.getFileName());
|
||||
}
|
||||
|
||||
public List<Path> getFullFilePaths() {
|
||||
if (libraryPath == null || libraryPath.getPath() == null || bookFiles == null || bookFiles.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return bookFiles.stream()
|
||||
.map(bookFile -> Paths.get(libraryPath.getPath(), bookFile.getFileSubPath(), bookFile.getFileName()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public BookFileEntity getPrimaryBookFile() {
|
||||
if (bookFiles == null) {
|
||||
bookFiles = new ArrayList<>();
|
||||
|
||||
@@ -331,6 +331,10 @@ public class BookMetadataEntity {
|
||||
@JsonIgnore
|
||||
private BookEntity book;
|
||||
|
||||
@OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "book_id", referencedColumnName = "book_id", insertable = false, updatable = false)
|
||||
private ComicMetadataEntity comicMetadata;
|
||||
|
||||
@ManyToMany(fetch = FetchType.LAZY, cascade = {CascadeType.PERSIST, CascadeType.MERGE})
|
||||
@JoinTable(
|
||||
name = "book_metadata_author_mapping",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.booklore.model.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Table(name = "comic_character")
|
||||
public class ComicCharacterEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "name", nullable = false, unique = true)
|
||||
private String name;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.booklore.model.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Table(name = "comic_creator")
|
||||
public class ComicCreatorEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "name", nullable = false, unique = true)
|
||||
private String name;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.booklore.model.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.booklore.model.enums.ComicCreatorRole;
|
||||
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Table(name = "comic_metadata_creator_mapping")
|
||||
public class ComicCreatorMappingEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "book_id", nullable = false)
|
||||
private ComicMetadataEntity comicMetadata;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "creator_id", nullable = false)
|
||||
private ComicCreatorEntity creator;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "role", nullable = false, length = 20)
|
||||
private ComicCreatorRole role;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.booklore.model.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Table(name = "comic_location")
|
||||
public class ComicLocationEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "name", nullable = false, unique = true)
|
||||
private String name;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package org.booklore.model.entity;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.Fetch;
|
||||
import org.hibernate.annotations.FetchMode;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Table(name = "comic_metadata")
|
||||
public class ComicMetadataEntity {
|
||||
|
||||
@Id
|
||||
@Column(name = "book_id")
|
||||
private Long bookId;
|
||||
|
||||
@OneToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "book_id", insertable = false, updatable = false)
|
||||
@JsonIgnore
|
||||
private BookMetadataEntity bookMetadata;
|
||||
|
||||
@Column(name = "issue_number")
|
||||
private String issueNumber;
|
||||
|
||||
@Column(name = "volume_name")
|
||||
private String volumeName;
|
||||
|
||||
@Column(name = "volume_number")
|
||||
private Integer volumeNumber;
|
||||
|
||||
@Column(name = "story_arc")
|
||||
private String storyArc;
|
||||
|
||||
@Column(name = "story_arc_number")
|
||||
private Integer storyArcNumber;
|
||||
|
||||
@Column(name = "alternate_series")
|
||||
private String alternateSeries;
|
||||
|
||||
@Column(name = "alternate_issue")
|
||||
private String alternateIssue;
|
||||
|
||||
@Column(name = "imprint")
|
||||
private String imprint;
|
||||
|
||||
@Column(name = "format", length = 50)
|
||||
private String format;
|
||||
|
||||
@Column(name = "black_and_white")
|
||||
@Builder.Default
|
||||
private Boolean blackAndWhite = Boolean.FALSE;
|
||||
|
||||
@Column(name = "manga")
|
||||
@Builder.Default
|
||||
private Boolean manga = Boolean.FALSE;
|
||||
|
||||
@Column(name = "reading_direction", length = 10)
|
||||
@Builder.Default
|
||||
private String readingDirection = "ltr";
|
||||
|
||||
@Column(name = "web_link")
|
||||
private String webLink;
|
||||
|
||||
@Column(name = "notes", columnDefinition = "TEXT")
|
||||
private String notes;
|
||||
|
||||
// Many-to-many relationships
|
||||
@ManyToMany(fetch = FetchType.LAZY, cascade = {CascadeType.PERSIST, CascadeType.MERGE})
|
||||
@JoinTable(
|
||||
name = "comic_metadata_character_mapping",
|
||||
joinColumns = @JoinColumn(name = "book_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "character_id"))
|
||||
@Fetch(FetchMode.SUBSELECT)
|
||||
@Builder.Default
|
||||
private Set<ComicCharacterEntity> characters = new HashSet<>();
|
||||
|
||||
@ManyToMany(fetch = FetchType.LAZY, cascade = {CascadeType.PERSIST, CascadeType.MERGE})
|
||||
@JoinTable(
|
||||
name = "comic_metadata_team_mapping",
|
||||
joinColumns = @JoinColumn(name = "book_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "team_id"))
|
||||
@Fetch(FetchMode.SUBSELECT)
|
||||
@Builder.Default
|
||||
private Set<ComicTeamEntity> teams = new HashSet<>();
|
||||
|
||||
@ManyToMany(fetch = FetchType.LAZY, cascade = {CascadeType.PERSIST, CascadeType.MERGE})
|
||||
@JoinTable(
|
||||
name = "comic_metadata_location_mapping",
|
||||
joinColumns = @JoinColumn(name = "book_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "location_id"))
|
||||
@Fetch(FetchMode.SUBSELECT)
|
||||
@Builder.Default
|
||||
private Set<ComicLocationEntity> locations = new HashSet<>();
|
||||
|
||||
@OneToMany(mappedBy = "comicMetadata", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||
@Fetch(FetchMode.SUBSELECT)
|
||||
@Builder.Default
|
||||
private Set<ComicCreatorMappingEntity> creatorMappings = new HashSet<>();
|
||||
|
||||
// Locked fields
|
||||
@Column(name = "issue_number_locked")
|
||||
@Builder.Default
|
||||
private Boolean issueNumberLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "volume_name_locked")
|
||||
@Builder.Default
|
||||
private Boolean volumeNameLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "volume_number_locked")
|
||||
@Builder.Default
|
||||
private Boolean volumeNumberLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "story_arc_locked")
|
||||
@Builder.Default
|
||||
private Boolean storyArcLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "creators_locked")
|
||||
@Builder.Default
|
||||
private Boolean creatorsLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "characters_locked")
|
||||
@Builder.Default
|
||||
private Boolean charactersLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "teams_locked")
|
||||
@Builder.Default
|
||||
private Boolean teamsLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "locations_locked")
|
||||
@Builder.Default
|
||||
private Boolean locationsLocked = Boolean.FALSE;
|
||||
|
||||
public void applyLockToAllFields(boolean lock) {
|
||||
this.issueNumberLocked = lock;
|
||||
this.volumeNameLocked = lock;
|
||||
this.volumeNumberLocked = lock;
|
||||
this.storyArcLocked = lock;
|
||||
this.creatorsLocked = lock;
|
||||
this.charactersLocked = lock;
|
||||
this.teamsLocked = lock;
|
||||
this.locationsLocked = lock;
|
||||
}
|
||||
|
||||
public boolean areAllFieldsLocked() {
|
||||
return Boolean.TRUE.equals(this.issueNumberLocked)
|
||||
&& Boolean.TRUE.equals(this.volumeNameLocked)
|
||||
&& Boolean.TRUE.equals(this.volumeNumberLocked)
|
||||
&& Boolean.TRUE.equals(this.storyArcLocked)
|
||||
&& Boolean.TRUE.equals(this.creatorsLocked)
|
||||
&& Boolean.TRUE.equals(this.charactersLocked)
|
||||
&& Boolean.TRUE.equals(this.teamsLocked)
|
||||
&& Boolean.TRUE.equals(this.locationsLocked);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.booklore.model.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Table(name = "comic_team")
|
||||
public class ComicTeamEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "name", nullable = false, unique = true)
|
||||
private String name;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.booklore.model.enums;
|
||||
|
||||
public enum ComicCreatorRole {
|
||||
PENCILLER,
|
||||
INKER,
|
||||
COLORIST,
|
||||
LETTERER,
|
||||
COVER_ARTIST,
|
||||
EDITOR
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import java.util.Set;
|
||||
public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpecificationExecutor<BookEntity> {
|
||||
Optional<BookEntity> findBookByIdAndLibraryId(long id, long libraryId);
|
||||
|
||||
@EntityGraph(attributePaths = { "metadata", "shelves", "libraryPath", "bookFiles" })
|
||||
@EntityGraph(attributePaths = { "metadata", "shelves", "libraryPath", "library", "bookFiles" })
|
||||
@Query("SELECT b FROM BookEntity b LEFT JOIN FETCH b.bookFiles bf WHERE b.id = :id AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
Optional<BookEntity> findByIdWithBookFiles(@Param("id") Long id);
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.booklore.repository;
|
||||
|
||||
import org.booklore.model.entity.ComicCharacterEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ComicCharacterRepository extends JpaRepository<ComicCharacterEntity, Long> {
|
||||
|
||||
Optional<ComicCharacterEntity> findByName(String name);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.booklore.repository;
|
||||
|
||||
import org.booklore.model.entity.ComicCreatorMappingEntity;
|
||||
import org.booklore.model.enums.ComicCreatorRole;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface ComicCreatorMappingRepository extends JpaRepository<ComicCreatorMappingEntity, Long> {
|
||||
|
||||
List<ComicCreatorMappingEntity> findByComicMetadataBookId(Long bookId);
|
||||
|
||||
List<ComicCreatorMappingEntity> findByComicMetadataBookIdAndRole(Long bookId, ComicCreatorRole role);
|
||||
|
||||
void deleteByComicMetadataBookId(Long bookId);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.booklore.repository;
|
||||
|
||||
import org.booklore.model.entity.ComicCreatorEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ComicCreatorRepository extends JpaRepository<ComicCreatorEntity, Long> {
|
||||
|
||||
Optional<ComicCreatorEntity> findByName(String name);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.booklore.repository;
|
||||
|
||||
import org.booklore.model.entity.ComicLocationEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ComicLocationRepository extends JpaRepository<ComicLocationEntity, Long> {
|
||||
|
||||
Optional<ComicLocationEntity> findByName(String name);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.booklore.repository;
|
||||
|
||||
import org.booklore.model.entity.ComicMetadataEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface ComicMetadataRepository extends JpaRepository<ComicMetadataEntity, Long> {
|
||||
|
||||
@Query("SELECT c FROM ComicMetadataEntity c WHERE c.bookId IN :bookIds")
|
||||
List<ComicMetadataEntity> findAllByBookIds(@Param("bookIds") List<Long> bookIds);
|
||||
|
||||
List<ComicMetadataEntity> findAllByStoryArcIgnoreCase(String storyArc);
|
||||
|
||||
List<ComicMetadataEntity> findAllByVolumeNameIgnoreCase(String volumeName);
|
||||
|
||||
@Query("SELECT DISTINCT c FROM ComicMetadataEntity c JOIN c.creatorMappings m WHERE m.creator.name LIKE %:name%")
|
||||
List<ComicMetadataEntity> findAllByCreatorName(@Param("name") String name);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.booklore.repository;
|
||||
|
||||
import org.booklore.model.entity.ComicTeamEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ComicTeamRepository extends JpaRepository<ComicTeamEntity, Long> {
|
||||
|
||||
Optional<ComicTeamEntity> findByName(String name);
|
||||
}
|
||||
@@ -1,21 +1,22 @@
|
||||
package org.booklore.service.book;
|
||||
|
||||
import org.booklore.model.dto.ComicMetadata;
|
||||
import org.booklore.model.dto.settings.LibraryFile;
|
||||
import org.booklore.model.entity.*;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.model.enums.ComicCreatorRole;
|
||||
import org.booklore.repository.*;
|
||||
import org.booklore.service.file.FileFingerprint;
|
||||
import org.booklore.util.FileUtils;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@AllArgsConstructor
|
||||
public class BookCreatorService {
|
||||
|
||||
private final AuthorRepository authorRepository;
|
||||
@@ -24,6 +25,38 @@ public class BookCreatorService {
|
||||
private final TagRepository tagRepository;
|
||||
private final BookRepository bookRepository;
|
||||
private final BookMetadataRepository bookMetadataRepository;
|
||||
private final ComicMetadataRepository comicMetadataRepository;
|
||||
private final ComicCharacterRepository comicCharacterRepository;
|
||||
private final ComicTeamRepository comicTeamRepository;
|
||||
private final ComicLocationRepository comicLocationRepository;
|
||||
private final ComicCreatorRepository comicCreatorRepository;
|
||||
|
||||
// Temporary storage for comic metadata DTOs during processing
|
||||
private final Map<Long, ComicMetadata> pendingComicMetadata = new ConcurrentHashMap<>();
|
||||
|
||||
public BookCreatorService(AuthorRepository authorRepository,
|
||||
CategoryRepository categoryRepository,
|
||||
MoodRepository moodRepository,
|
||||
TagRepository tagRepository,
|
||||
BookRepository bookRepository,
|
||||
BookMetadataRepository bookMetadataRepository,
|
||||
ComicMetadataRepository comicMetadataRepository,
|
||||
ComicCharacterRepository comicCharacterRepository,
|
||||
ComicTeamRepository comicTeamRepository,
|
||||
ComicLocationRepository comicLocationRepository,
|
||||
ComicCreatorRepository comicCreatorRepository) {
|
||||
this.authorRepository = authorRepository;
|
||||
this.categoryRepository = categoryRepository;
|
||||
this.moodRepository = moodRepository;
|
||||
this.tagRepository = tagRepository;
|
||||
this.bookRepository = bookRepository;
|
||||
this.bookMetadataRepository = bookMetadataRepository;
|
||||
this.comicMetadataRepository = comicMetadataRepository;
|
||||
this.comicCharacterRepository = comicCharacterRepository;
|
||||
this.comicTeamRepository = comicTeamRepository;
|
||||
this.comicLocationRepository = comicLocationRepository;
|
||||
this.comicCreatorRepository = comicCreatorRepository;
|
||||
}
|
||||
|
||||
public BookEntity createShellBook(LibraryFile libraryFile, BookFileType bookFileType) {
|
||||
Optional<BookEntity> existingBookOpt = bookRepository.findByLibraryIdAndLibraryPathIdAndFileSubPathAndFileName(
|
||||
@@ -149,5 +182,93 @@ public class BookCreatorService {
|
||||
}
|
||||
bookRepository.save(bookEntity);
|
||||
bookMetadataRepository.save(bookEntity.getMetadata());
|
||||
|
||||
// Save comic metadata if present
|
||||
ComicMetadataEntity comicMetadata = bookEntity.getMetadata().getComicMetadata();
|
||||
if (comicMetadata != null && comicMetadata.getBookId() != null) {
|
||||
comicMetadataRepository.save(comicMetadata);
|
||||
|
||||
// Populate relationships from pending DTO
|
||||
ComicMetadata comicDto = pendingComicMetadata.remove(bookEntity.getId());
|
||||
if (comicDto != null) {
|
||||
populateComicMetadataRelationships(comicMetadata, comicDto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void setComicMetadataDto(BookEntity bookEntity, ComicMetadata comicDto) {
|
||||
if (bookEntity.getId() != null && comicDto != null) {
|
||||
pendingComicMetadata.put(bookEntity.getId(), comicDto);
|
||||
}
|
||||
}
|
||||
|
||||
private void populateComicMetadataRelationships(ComicMetadataEntity comic, ComicMetadata dto) {
|
||||
// Add characters
|
||||
if (dto.getCharacters() != null && !dto.getCharacters().isEmpty()) {
|
||||
if (comic.getCharacters() == null) {
|
||||
comic.setCharacters(new HashSet<>());
|
||||
}
|
||||
dto.getCharacters().stream()
|
||||
.map(name -> truncate(name, 255))
|
||||
.map(name -> comicCharacterRepository.findByName(name)
|
||||
.orElseGet(() -> comicCharacterRepository.save(ComicCharacterEntity.builder().name(name).build())))
|
||||
.forEach(entity -> comic.getCharacters().add(entity));
|
||||
}
|
||||
|
||||
// Add teams
|
||||
if (dto.getTeams() != null && !dto.getTeams().isEmpty()) {
|
||||
if (comic.getTeams() == null) {
|
||||
comic.setTeams(new HashSet<>());
|
||||
}
|
||||
dto.getTeams().stream()
|
||||
.map(name -> truncate(name, 255))
|
||||
.map(name -> comicTeamRepository.findByName(name)
|
||||
.orElseGet(() -> comicTeamRepository.save(ComicTeamEntity.builder().name(name).build())))
|
||||
.forEach(entity -> comic.getTeams().add(entity));
|
||||
}
|
||||
|
||||
// Add locations
|
||||
if (dto.getLocations() != null && !dto.getLocations().isEmpty()) {
|
||||
if (comic.getLocations() == null) {
|
||||
comic.setLocations(new HashSet<>());
|
||||
}
|
||||
dto.getLocations().stream()
|
||||
.map(name -> truncate(name, 255))
|
||||
.map(name -> comicLocationRepository.findByName(name)
|
||||
.orElseGet(() -> comicLocationRepository.save(ComicLocationEntity.builder().name(name).build())))
|
||||
.forEach(entity -> comic.getLocations().add(entity));
|
||||
}
|
||||
|
||||
// Add creators with roles
|
||||
if (comic.getCreatorMappings() == null) {
|
||||
comic.setCreatorMappings(new HashSet<>());
|
||||
}
|
||||
addCreatorsWithRole(comic, dto.getPencillers(), ComicCreatorRole.PENCILLER);
|
||||
addCreatorsWithRole(comic, dto.getInkers(), ComicCreatorRole.INKER);
|
||||
addCreatorsWithRole(comic, dto.getColorists(), ComicCreatorRole.COLORIST);
|
||||
addCreatorsWithRole(comic, dto.getLetterers(), ComicCreatorRole.LETTERER);
|
||||
addCreatorsWithRole(comic, dto.getCoverArtists(), ComicCreatorRole.COVER_ARTIST);
|
||||
addCreatorsWithRole(comic, dto.getEditors(), ComicCreatorRole.EDITOR);
|
||||
|
||||
// Save the updated comic metadata with relationships
|
||||
comicMetadataRepository.save(comic);
|
||||
}
|
||||
|
||||
private void addCreatorsWithRole(ComicMetadataEntity comic, Set<String> names, ComicCreatorRole role) {
|
||||
if (names == null || names.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (String name : names) {
|
||||
String truncatedName = truncate(name, 255);
|
||||
ComicCreatorEntity creator = comicCreatorRepository.findByName(truncatedName)
|
||||
.orElseGet(() -> comicCreatorRepository.save(ComicCreatorEntity.builder().name(truncatedName).build()));
|
||||
|
||||
ComicCreatorMappingEntity mapping = ComicCreatorMappingEntity.builder()
|
||||
.comicMetadata(comic)
|
||||
.creator(creator)
|
||||
.role(role)
|
||||
.build();
|
||||
comic.getCreatorMappings().add(mapping);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ package org.booklore.service.fileprocessor;
|
||||
|
||||
import org.booklore.mapper.BookMapper;
|
||||
import org.booklore.model.dto.BookMetadata;
|
||||
import org.booklore.model.dto.ComicMetadata;
|
||||
import org.booklore.model.dto.settings.LibraryFile;
|
||||
import org.booklore.model.entity.BookEntity;
|
||||
import org.booklore.model.entity.BookFileEntity;
|
||||
import org.booklore.model.entity.BookMetadataEntity;
|
||||
import org.booklore.model.entity.ComicMetadataEntity;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.repository.BookAdditionalFileRepository;
|
||||
import org.booklore.repository.BookRepository;
|
||||
@@ -236,6 +238,9 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce
|
||||
if (extracted.getCategories() != null) {
|
||||
bookCreatorService.addCategoriesToBook(extracted.getCategories(), bookEntity);
|
||||
}
|
||||
if (extracted.getComicMetadata() != null) {
|
||||
saveComicMetadata(bookEntity, extracted.getComicMetadata());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to extract ComicInfo metadata for '{}': {}", bookEntity.getPrimaryBookFile().getFileName(), e.getMessage());
|
||||
// Fallback to filename-derived title
|
||||
@@ -252,5 +257,38 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce
|
||||
String title = UNDERSCORE_HYPHEN_PATTERN.matcher(baseName).replaceAll(" ").trim();
|
||||
bookEntity.getMetadata().setTitle(truncate(title, 1000));
|
||||
}
|
||||
|
||||
private void saveComicMetadata(BookEntity bookEntity, ComicMetadata comicDto) {
|
||||
Long bookId = bookEntity.getId();
|
||||
if (bookId == null) {
|
||||
log.warn("Cannot save comic metadata - book ID is null for '{}'",
|
||||
bookEntity.getPrimaryBookFile().getFileName());
|
||||
return;
|
||||
}
|
||||
|
||||
ComicMetadataEntity comic = new ComicMetadataEntity();
|
||||
comic.setBookId(bookId);
|
||||
comic.setBookMetadata(bookEntity.getMetadata());
|
||||
comic.setIssueNumber(comicDto.getIssueNumber());
|
||||
comic.setVolumeName(comicDto.getVolumeName());
|
||||
comic.setVolumeNumber(comicDto.getVolumeNumber());
|
||||
comic.setStoryArc(comicDto.getStoryArc());
|
||||
comic.setStoryArcNumber(comicDto.getStoryArcNumber());
|
||||
comic.setAlternateSeries(comicDto.getAlternateSeries());
|
||||
comic.setAlternateIssue(comicDto.getAlternateIssue());
|
||||
comic.setImprint(comicDto.getImprint());
|
||||
comic.setFormat(comicDto.getFormat());
|
||||
comic.setBlackAndWhite(comicDto.getBlackAndWhite() != null ? comicDto.getBlackAndWhite() : Boolean.FALSE);
|
||||
comic.setManga(comicDto.getManga() != null ? comicDto.getManga() : Boolean.FALSE);
|
||||
comic.setReadingDirection(comicDto.getReadingDirection() != null ? comicDto.getReadingDirection() : "ltr");
|
||||
comic.setWebLink(comicDto.getWebLink());
|
||||
comic.setNotes(comicDto.getNotes());
|
||||
|
||||
// Set on parent - relationships will be populated in saveConnections()
|
||||
bookEntity.getMetadata().setComicMetadata(comic);
|
||||
|
||||
// Store the DTO for later processing in saveConnections
|
||||
bookCreatorService.setComicMetadataDto(bookEntity, comicDto);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,12 @@ import org.booklore.model.MetadataClearFlags;
|
||||
import org.booklore.model.MetadataUpdateContext;
|
||||
import org.booklore.model.MetadataUpdateWrapper;
|
||||
import org.booklore.model.dto.BookMetadata;
|
||||
import org.booklore.model.dto.ComicMetadata;
|
||||
import org.booklore.model.dto.FileMoveResult;
|
||||
import org.booklore.model.dto.settings.MetadataPersistenceSettings;
|
||||
import org.booklore.model.entity.*;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.model.enums.ComicCreatorRole;
|
||||
import org.booklore.model.enums.MetadataReplaceMode;
|
||||
import org.booklore.repository.*;
|
||||
import org.booklore.service.appsettings.AppSettingService;
|
||||
@@ -43,6 +45,11 @@ public class BookMetadataUpdater {
|
||||
private final MoodRepository moodRepository;
|
||||
private final TagRepository tagRepository;
|
||||
private final BookRepository bookRepository;
|
||||
private final ComicMetadataRepository comicMetadataRepository;
|
||||
private final ComicCharacterRepository comicCharacterRepository;
|
||||
private final ComicTeamRepository comicTeamRepository;
|
||||
private final ComicLocationRepository comicLocationRepository;
|
||||
private final ComicCreatorRepository comicCreatorRepository;
|
||||
private final FileService fileService;
|
||||
private final MetadataMatchService metadataMatchService;
|
||||
private final AppSettingService appSettingService;
|
||||
@@ -99,6 +106,7 @@ public class BookMetadataUpdater {
|
||||
bookReviewUpdateService.updateBookReviews(newMetadata, metadata, clearFlags, mergeCategories);
|
||||
updateThumbnailIfNeeded(bookId, bookEntity, newMetadata, metadata, updateThumbnail);
|
||||
updateAudiobookMetadataIfNeeded(bookEntity, newMetadata, metadata, clearFlags, replaceMode);
|
||||
updateComicMetadataIfNeeded(newMetadata, metadata, replaceMode);
|
||||
updateLocks(newMetadata, metadata);
|
||||
|
||||
bookEntity.setMetadataUpdatedAt(Instant.now());
|
||||
@@ -362,6 +370,183 @@ public class BookMetadataUpdater {
|
||||
handleFieldUpdate(e.getAbridgedLocked(), clear.isAbridged(), m.getAbridged(), e::setAbridged, e::getAbridged, replaceMode);
|
||||
}
|
||||
|
||||
private void updateComicMetadataIfNeeded(BookMetadata m, BookMetadataEntity e, MetadataReplaceMode replaceMode) {
|
||||
ComicMetadata comicDto = m.getComicMetadata();
|
||||
if (comicDto == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ComicMetadataEntity comic = e.getComicMetadata();
|
||||
if (comic == null) {
|
||||
comic = ComicMetadataEntity.builder()
|
||||
.bookId(e.getBookId())
|
||||
.bookMetadata(e)
|
||||
.build();
|
||||
e.setComicMetadata(comic);
|
||||
}
|
||||
|
||||
ComicMetadataEntity c = comic;
|
||||
|
||||
// Update basic fields
|
||||
handleFieldUpdate(c.getIssueNumberLocked(), false, comicDto.getIssueNumber(), v -> c.setIssueNumber(nullIfBlank(v)), c::getIssueNumber, replaceMode);
|
||||
handleFieldUpdate(c.getVolumeNameLocked(), false, comicDto.getVolumeName(), v -> c.setVolumeName(nullIfBlank(v)), c::getVolumeName, replaceMode);
|
||||
handleFieldUpdate(c.getVolumeNumberLocked(), false, comicDto.getVolumeNumber(), c::setVolumeNumber, c::getVolumeNumber, replaceMode);
|
||||
handleFieldUpdate(c.getStoryArcLocked(), false, comicDto.getStoryArc(), v -> c.setStoryArc(nullIfBlank(v)), c::getStoryArc, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getStoryArcNumber(), c::setStoryArcNumber, c::getStoryArcNumber, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getAlternateSeries(), v -> c.setAlternateSeries(nullIfBlank(v)), c::getAlternateSeries, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getAlternateIssue(), v -> c.setAlternateIssue(nullIfBlank(v)), c::getAlternateIssue, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getImprint(), v -> c.setImprint(nullIfBlank(v)), c::getImprint, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getFormat(), v -> c.setFormat(nullIfBlank(v)), c::getFormat, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getBlackAndWhite(), c::setBlackAndWhite, c::getBlackAndWhite, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getManga(), c::setManga, c::getManga, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getReadingDirection(), v -> c.setReadingDirection(nullIfBlank(v)), c::getReadingDirection, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getWebLink(), v -> c.setWebLink(nullIfBlank(v)), c::getWebLink, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getNotes(), v -> c.setNotes(nullIfBlank(v)), c::getNotes, replaceMode);
|
||||
|
||||
// Update relationships if not locked
|
||||
if (!Boolean.TRUE.equals(c.getCharactersLocked())) {
|
||||
updateComicCharacters(c, comicDto.getCharacters(), replaceMode);
|
||||
}
|
||||
if (!Boolean.TRUE.equals(c.getTeamsLocked())) {
|
||||
updateComicTeams(c, comicDto.getTeams(), replaceMode);
|
||||
}
|
||||
if (!Boolean.TRUE.equals(c.getLocationsLocked())) {
|
||||
updateComicLocations(c, comicDto.getLocations(), replaceMode);
|
||||
}
|
||||
if (!Boolean.TRUE.equals(c.getCreatorsLocked())) {
|
||||
updateComicCreators(c, comicDto, replaceMode);
|
||||
}
|
||||
|
||||
// Update locks if provided
|
||||
if (comicDto.getIssueNumberLocked() != null) c.setIssueNumberLocked(comicDto.getIssueNumberLocked());
|
||||
if (comicDto.getVolumeNameLocked() != null) c.setVolumeNameLocked(comicDto.getVolumeNameLocked());
|
||||
if (comicDto.getVolumeNumberLocked() != null) c.setVolumeNumberLocked(comicDto.getVolumeNumberLocked());
|
||||
if (comicDto.getStoryArcLocked() != null) c.setStoryArcLocked(comicDto.getStoryArcLocked());
|
||||
if (comicDto.getCreatorsLocked() != null) c.setCreatorsLocked(comicDto.getCreatorsLocked());
|
||||
if (comicDto.getCharactersLocked() != null) c.setCharactersLocked(comicDto.getCharactersLocked());
|
||||
if (comicDto.getTeamsLocked() != null) c.setTeamsLocked(comicDto.getTeamsLocked());
|
||||
if (comicDto.getLocationsLocked() != null) c.setLocationsLocked(comicDto.getLocationsLocked());
|
||||
|
||||
comicMetadataRepository.save(c);
|
||||
}
|
||||
|
||||
private void updateComicCharacters(ComicMetadataEntity c, Set<String> characters, MetadataReplaceMode mode) {
|
||||
if (characters == null || characters.isEmpty()) {
|
||||
if (mode == MetadataReplaceMode.REPLACE_ALL) {
|
||||
c.getCharacters().clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (c.getCharacters() == null) {
|
||||
c.setCharacters(new HashSet<>());
|
||||
}
|
||||
if (mode == MetadataReplaceMode.REPLACE_ALL || mode == MetadataReplaceMode.REPLACE_WHEN_PROVIDED) {
|
||||
c.getCharacters().clear();
|
||||
}
|
||||
if (mode == MetadataReplaceMode.REPLACE_MISSING && !c.getCharacters().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
characters.stream()
|
||||
.map(name -> comicCharacterRepository.findByName(name)
|
||||
.orElseGet(() -> comicCharacterRepository.save(ComicCharacterEntity.builder().name(name).build())))
|
||||
.forEach(entity -> c.getCharacters().add(entity));
|
||||
}
|
||||
|
||||
private void updateComicTeams(ComicMetadataEntity c, Set<String> teams, MetadataReplaceMode mode) {
|
||||
if (teams == null || teams.isEmpty()) {
|
||||
if (mode == MetadataReplaceMode.REPLACE_ALL) {
|
||||
c.getTeams().clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (c.getTeams() == null) {
|
||||
c.setTeams(new HashSet<>());
|
||||
}
|
||||
if (mode == MetadataReplaceMode.REPLACE_ALL || mode == MetadataReplaceMode.REPLACE_WHEN_PROVIDED) {
|
||||
c.getTeams().clear();
|
||||
}
|
||||
if (mode == MetadataReplaceMode.REPLACE_MISSING && !c.getTeams().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
teams.stream()
|
||||
.map(name -> comicTeamRepository.findByName(name)
|
||||
.orElseGet(() -> comicTeamRepository.save(ComicTeamEntity.builder().name(name).build())))
|
||||
.forEach(entity -> c.getTeams().add(entity));
|
||||
}
|
||||
|
||||
private void updateComicLocations(ComicMetadataEntity c, Set<String> locations, MetadataReplaceMode mode) {
|
||||
if (locations == null || locations.isEmpty()) {
|
||||
if (mode == MetadataReplaceMode.REPLACE_ALL) {
|
||||
c.getLocations().clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (c.getLocations() == null) {
|
||||
c.setLocations(new HashSet<>());
|
||||
}
|
||||
if (mode == MetadataReplaceMode.REPLACE_ALL || mode == MetadataReplaceMode.REPLACE_WHEN_PROVIDED) {
|
||||
c.getLocations().clear();
|
||||
}
|
||||
if (mode == MetadataReplaceMode.REPLACE_MISSING && !c.getLocations().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
locations.stream()
|
||||
.map(name -> comicLocationRepository.findByName(name)
|
||||
.orElseGet(() -> comicLocationRepository.save(ComicLocationEntity.builder().name(name).build())))
|
||||
.forEach(entity -> c.getLocations().add(entity));
|
||||
}
|
||||
|
||||
private void updateComicCreators(ComicMetadataEntity c, ComicMetadata dto, MetadataReplaceMode mode) {
|
||||
if (c.getCreatorMappings() == null) {
|
||||
c.setCreatorMappings(new HashSet<>());
|
||||
}
|
||||
|
||||
boolean hasNewCreators = (dto.getPencillers() != null && !dto.getPencillers().isEmpty()) ||
|
||||
(dto.getInkers() != null && !dto.getInkers().isEmpty()) ||
|
||||
(dto.getColorists() != null && !dto.getColorists().isEmpty()) ||
|
||||
(dto.getLetterers() != null && !dto.getLetterers().isEmpty()) ||
|
||||
(dto.getCoverArtists() != null && !dto.getCoverArtists().isEmpty()) ||
|
||||
(dto.getEditors() != null && !dto.getEditors().isEmpty());
|
||||
|
||||
if (!hasNewCreators) {
|
||||
if (mode == MetadataReplaceMode.REPLACE_ALL) {
|
||||
c.getCreatorMappings().clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode == MetadataReplaceMode.REPLACE_ALL || mode == MetadataReplaceMode.REPLACE_WHEN_PROVIDED) {
|
||||
c.getCreatorMappings().clear();
|
||||
}
|
||||
if (mode == MetadataReplaceMode.REPLACE_MISSING && !c.getCreatorMappings().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
addCreatorsWithRole(c, dto.getPencillers(), ComicCreatorRole.PENCILLER);
|
||||
addCreatorsWithRole(c, dto.getInkers(), ComicCreatorRole.INKER);
|
||||
addCreatorsWithRole(c, dto.getColorists(), ComicCreatorRole.COLORIST);
|
||||
addCreatorsWithRole(c, dto.getLetterers(), ComicCreatorRole.LETTERER);
|
||||
addCreatorsWithRole(c, dto.getCoverArtists(), ComicCreatorRole.COVER_ARTIST);
|
||||
addCreatorsWithRole(c, dto.getEditors(), ComicCreatorRole.EDITOR);
|
||||
}
|
||||
|
||||
private void addCreatorsWithRole(ComicMetadataEntity comic, Set<String> names, ComicCreatorRole role) {
|
||||
if (names == null || names.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (String name : names) {
|
||||
ComicCreatorEntity creator = comicCreatorRepository.findByName(name)
|
||||
.orElseGet(() -> comicCreatorRepository.save(ComicCreatorEntity.builder().name(name).build()));
|
||||
|
||||
ComicCreatorMappingEntity mapping = ComicCreatorMappingEntity.builder()
|
||||
.comicMetadata(comic)
|
||||
.creator(creator)
|
||||
.role(role)
|
||||
.build();
|
||||
comic.getCreatorMappings().add(mapping);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateThumbnailIfNeeded(long bookId, BookEntity bookEntity, BookMetadata m, BookMetadataEntity e, boolean set) {
|
||||
if (Boolean.TRUE.equals(e.getCoverLocked())) {
|
||||
return;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
package org.booklore.service.metadata.extractor;
|
||||
|
||||
import org.booklore.model.dto.BookMetadata;
|
||||
import org.booklore.util.ArchiveUtils;
|
||||
import com.github.junrar.Archive;
|
||||
import com.github.junrar.rarfile.FileHeader;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.compress.archivers.sevenz.SevenZArchiveEntry;
|
||||
import org.apache.commons.compress.archivers.sevenz.SevenZFile;
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.booklore.model.dto.BookMetadata;
|
||||
import org.booklore.model.dto.ComicMetadata;
|
||||
import org.booklore.util.ArchiveUtils;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.NodeList;
|
||||
@@ -80,29 +81,29 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
|
||||
}
|
||||
|
||||
// CBR path (RAR)
|
||||
if (type == ArchiveUtils.ArchiveType.RAR) {
|
||||
try (Archive archive = new Archive(file)) {
|
||||
try {
|
||||
FileHeader header = findComicInfoHeader(archive);
|
||||
if (header == null) {
|
||||
if (type == ArchiveUtils.ArchiveType.RAR) {
|
||||
try (Archive archive = new Archive(file)) {
|
||||
try {
|
||||
FileHeader header = findComicInfoHeader(archive);
|
||||
if (header == null) {
|
||||
return BookMetadata.builder().title(baseName).build();
|
||||
}
|
||||
byte[] xmlBytes = readRarEntryBytes(archive, header);
|
||||
if (xmlBytes == null) {
|
||||
return BookMetadata.builder().title(baseName).build();
|
||||
}
|
||||
try (InputStream is = new ByteArrayInputStream(xmlBytes)) {
|
||||
Document document = buildSecureDocument(is);
|
||||
return mapDocumentToMetadata(document, baseName);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to extract metadata from CBR", e);
|
||||
return BookMetadata.builder().title(baseName).build();
|
||||
}
|
||||
byte[] xmlBytes = readRarEntryBytes(archive, header);
|
||||
if (xmlBytes == null) {
|
||||
return BookMetadata.builder().title(baseName).build();
|
||||
}
|
||||
try (InputStream is = new ByteArrayInputStream(xmlBytes)) {
|
||||
Document document = buildSecureDocument(is);
|
||||
return mapDocumentToMetadata(document, baseName);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to extract metadata from CBR", e);
|
||||
return BookMetadata.builder().title(baseName).build();
|
||||
} catch (Exception ignore) {
|
||||
}
|
||||
} catch (Exception ignore) {
|
||||
}
|
||||
}
|
||||
return BookMetadata.builder().title(baseName).build();
|
||||
return BookMetadata.builder().title(baseName).build();
|
||||
}
|
||||
|
||||
private ZipEntry findComicInfoEntry(ZipFile zipFile) {
|
||||
@@ -205,6 +206,137 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
|
||||
builder.categories(categories);
|
||||
}
|
||||
|
||||
// Extract comic-specific metadata
|
||||
ComicMetadata.ComicMetadataBuilder comicBuilder = ComicMetadata.builder();
|
||||
boolean hasComicFields = false;
|
||||
|
||||
String issueNumber = getTextContent(document, "Number");
|
||||
if (issueNumber != null && !issueNumber.isBlank()) {
|
||||
comicBuilder.issueNumber(issueNumber);
|
||||
hasComicFields = true;
|
||||
}
|
||||
|
||||
String volume = getTextContent(document, "Volume");
|
||||
if (volume != null && !volume.isBlank()) {
|
||||
comicBuilder.volumeName(getTextContent(document, "Series"));
|
||||
comicBuilder.volumeNumber(parseInteger(volume));
|
||||
hasComicFields = true;
|
||||
}
|
||||
|
||||
String storyArc = getTextContent(document, "StoryArc");
|
||||
if (storyArc != null && !storyArc.isBlank()) {
|
||||
comicBuilder.storyArc(storyArc);
|
||||
comicBuilder.storyArcNumber(parseInteger(getTextContent(document, "StoryArcNumber")));
|
||||
hasComicFields = true;
|
||||
}
|
||||
|
||||
String alternateSeries = getTextContent(document, "AlternateSeries");
|
||||
if (alternateSeries != null && !alternateSeries.isBlank()) {
|
||||
comicBuilder.alternateSeries(alternateSeries);
|
||||
comicBuilder.alternateIssue(getTextContent(document, "AlternateNumber"));
|
||||
hasComicFields = true;
|
||||
}
|
||||
|
||||
Set<String> pencillers = splitValues(getTextContent(document, "Penciller"));
|
||||
if (!pencillers.isEmpty()) {
|
||||
comicBuilder.pencillers(pencillers);
|
||||
hasComicFields = true;
|
||||
}
|
||||
|
||||
Set<String> inkers = splitValues(getTextContent(document, "Inker"));
|
||||
if (!inkers.isEmpty()) {
|
||||
comicBuilder.inkers(inkers);
|
||||
hasComicFields = true;
|
||||
}
|
||||
|
||||
Set<String> colorists = splitValues(getTextContent(document, "Colorist"));
|
||||
if (!colorists.isEmpty()) {
|
||||
comicBuilder.colorists(colorists);
|
||||
hasComicFields = true;
|
||||
}
|
||||
|
||||
Set<String> letterers = splitValues(getTextContent(document, "Letterer"));
|
||||
if (!letterers.isEmpty()) {
|
||||
comicBuilder.letterers(letterers);
|
||||
hasComicFields = true;
|
||||
}
|
||||
|
||||
Set<String> coverArtists = splitValues(getTextContent(document, "CoverArtist"));
|
||||
if (!coverArtists.isEmpty()) {
|
||||
comicBuilder.coverArtists(coverArtists);
|
||||
hasComicFields = true;
|
||||
}
|
||||
|
||||
Set<String> editors = splitValues(getTextContent(document, "Editor"));
|
||||
if (!editors.isEmpty()) {
|
||||
comicBuilder.editors(editors);
|
||||
hasComicFields = true;
|
||||
}
|
||||
|
||||
String imprint = getTextContent(document, "Imprint");
|
||||
if (imprint != null && !imprint.isBlank()) {
|
||||
comicBuilder.imprint(imprint);
|
||||
hasComicFields = true;
|
||||
}
|
||||
|
||||
String format = getTextContent(document, "Format");
|
||||
if (format != null && !format.isBlank()) {
|
||||
comicBuilder.format(format);
|
||||
hasComicFields = true;
|
||||
}
|
||||
|
||||
String blackAndWhite = getTextContent(document, "BlackAndWhite");
|
||||
if ("yes".equalsIgnoreCase(blackAndWhite) || "true".equalsIgnoreCase(blackAndWhite)) {
|
||||
comicBuilder.blackAndWhite(Boolean.TRUE);
|
||||
hasComicFields = true;
|
||||
}
|
||||
|
||||
String manga = getTextContent(document, "Manga");
|
||||
if (manga != null && !manga.isBlank()) {
|
||||
boolean isManga = "yes".equalsIgnoreCase(manga) || "true".equalsIgnoreCase(manga) || "yesandrighttoleft".equalsIgnoreCase(manga);
|
||||
comicBuilder.manga(isManga);
|
||||
if ("yesandrighttoleft".equalsIgnoreCase(manga)) {
|
||||
comicBuilder.readingDirection("rtl");
|
||||
} else {
|
||||
comicBuilder.readingDirection("ltr");
|
||||
}
|
||||
hasComicFields = true;
|
||||
}
|
||||
|
||||
Set<String> characters = splitValues(getTextContent(document, "Characters"));
|
||||
if (!characters.isEmpty()) {
|
||||
comicBuilder.characters(characters);
|
||||
hasComicFields = true;
|
||||
}
|
||||
|
||||
Set<String> teams = splitValues(getTextContent(document, "Teams"));
|
||||
if (!teams.isEmpty()) {
|
||||
comicBuilder.teams(teams);
|
||||
hasComicFields = true;
|
||||
}
|
||||
|
||||
Set<String> locations = splitValues(getTextContent(document, "Locations"));
|
||||
if (!locations.isEmpty()) {
|
||||
comicBuilder.locations(locations);
|
||||
hasComicFields = true;
|
||||
}
|
||||
|
||||
String web = getTextContent(document, "Web");
|
||||
if (web != null && !web.isBlank()) {
|
||||
comicBuilder.webLink(web);
|
||||
hasComicFields = true;
|
||||
}
|
||||
|
||||
String notes = getTextContent(document, "Notes");
|
||||
if (notes != null && !notes.isBlank()) {
|
||||
comicBuilder.notes(notes);
|
||||
hasComicFields = true;
|
||||
}
|
||||
|
||||
if (hasComicFields) {
|
||||
builder.comicMetadata(comicBuilder.build());
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@@ -437,62 +569,62 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
|
||||
}
|
||||
|
||||
// CBR path
|
||||
if (type == ArchiveUtils.ArchiveType.RAR) {
|
||||
try (Archive archive = new Archive(file)) {
|
||||
try {
|
||||
if (type == ArchiveUtils.ArchiveType.RAR) {
|
||||
try (Archive archive = new Archive(file)) {
|
||||
try {
|
||||
|
||||
// Try via ComicInfo.xml first
|
||||
FileHeader comicInfo = findComicInfoHeader(archive);
|
||||
if (comicInfo != null) {
|
||||
byte[] xmlBytes = readRarEntryBytes(archive, comicInfo);
|
||||
if (xmlBytes != null) {
|
||||
try (InputStream is = new ByteArrayInputStream(xmlBytes)) {
|
||||
Document document = buildSecureDocument(is);
|
||||
String imageName = findFrontCoverImageName(document);
|
||||
if (imageName != null) {
|
||||
FileHeader byName = findRarHeaderByName(archive, imageName);
|
||||
if (byName != null) {
|
||||
byte[] bytes = readRarEntryBytes(archive, byName);
|
||||
if (canDecode(bytes)) return bytes;
|
||||
}
|
||||
try {
|
||||
int index = Integer.parseInt(imageName);
|
||||
FileHeader byIndex = findRarImageHeaderByIndex(archive, index);
|
||||
if (byIndex != null) {
|
||||
byte[] bytes = readRarEntryBytes(archive, byIndex);
|
||||
// Try via ComicInfo.xml first
|
||||
FileHeader comicInfo = findComicInfoHeader(archive);
|
||||
if (comicInfo != null) {
|
||||
byte[] xmlBytes = readRarEntryBytes(archive, comicInfo);
|
||||
if (xmlBytes != null) {
|
||||
try (InputStream is = new ByteArrayInputStream(xmlBytes)) {
|
||||
Document document = buildSecureDocument(is);
|
||||
String imageName = findFrontCoverImageName(document);
|
||||
if (imageName != null) {
|
||||
FileHeader byName = findRarHeaderByName(archive, imageName);
|
||||
if (byName != null) {
|
||||
byte[] bytes = readRarEntryBytes(archive, byName);
|
||||
if (canDecode(bytes)) return bytes;
|
||||
}
|
||||
if (index > 0) {
|
||||
FileHeader offByOne = findRarImageHeaderByIndex(archive, index - 1);
|
||||
if (offByOne != null) {
|
||||
byte[] bytes = readRarEntryBytes(archive, offByOne);
|
||||
try {
|
||||
int index = Integer.parseInt(imageName);
|
||||
FileHeader byIndex = findRarImageHeaderByIndex(archive, index);
|
||||
if (byIndex != null) {
|
||||
byte[] bytes = readRarEntryBytes(archive, byIndex);
|
||||
if (canDecode(bytes)) return bytes;
|
||||
}
|
||||
if (index > 0) {
|
||||
FileHeader offByOne = findRarImageHeaderByIndex(archive, index - 1);
|
||||
if (offByOne != null) {
|
||||
byte[] bytes = readRarEntryBytes(archive, offByOne);
|
||||
if (canDecode(bytes)) return bytes;
|
||||
}
|
||||
}
|
||||
} catch (NumberFormatException ignore) {
|
||||
// ignore and continue fallback
|
||||
}
|
||||
} catch (NumberFormatException ignore) {
|
||||
// ignore and continue fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: iterate images alphabetically until a decodable one is found
|
||||
FileHeader firstImage = findFirstAlphabeticalImageHeader(archive);
|
||||
if (firstImage != null) {
|
||||
List<FileHeader> images = listRarImageHeaders(archive);
|
||||
for (FileHeader fh : images) {
|
||||
byte[] bytes = readRarEntryBytes(archive, fh);
|
||||
if (canDecode(bytes)) return bytes;
|
||||
// Fallback: iterate images alphabetically until a decodable one is found
|
||||
FileHeader firstImage = findFirstAlphabeticalImageHeader(archive);
|
||||
if (firstImage != null) {
|
||||
List<FileHeader> images = listRarImageHeaders(archive);
|
||||
for (FileHeader fh : images) {
|
||||
byte[] bytes = readRarEntryBytes(archive, fh);
|
||||
if (canDecode(bytes)) return bytes;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to extract cover image from CBR", e);
|
||||
return generatePlaceholderCover(250, 350);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to extract cover image from CBR", e);
|
||||
return generatePlaceholderCover(250, 350);
|
||||
} catch (Exception ignore) {
|
||||
}
|
||||
} catch (Exception ignore) {
|
||||
}
|
||||
}
|
||||
|
||||
return generatePlaceholderCover(250, 350);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.booklore.util;
|
||||
import lombok.experimental.UtilityClass;
|
||||
import org.booklore.model.MetadataClearFlags;
|
||||
import org.booklore.model.dto.BookMetadata;
|
||||
import org.booklore.model.dto.ComicMetadata;
|
||||
import org.booklore.model.entity.*;
|
||||
|
||||
import java.util.*;
|
||||
@@ -245,6 +246,9 @@ public class MetadataChangeDetector {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (hasComicMetadataChanges(newMeta, existingMeta)) {
|
||||
return true;
|
||||
}
|
||||
return differsLock(newMeta.getCoverLocked(), existingMeta.getCoverLocked()) || differsLock(newMeta.getAudiobookCoverLocked(), existingMeta.getAudiobookCoverLocked());
|
||||
}
|
||||
|
||||
@@ -259,6 +263,9 @@ public class MetadataChangeDetector {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (hasComicMetadataChanges(newMeta, existingMeta)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -361,4 +368,90 @@ public class MetadataChangeDetector {
|
||||
})
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private static boolean hasComicMetadataChanges(BookMetadata newMeta, BookMetadataEntity existingMeta) {
|
||||
ComicMetadata comicDto = newMeta.getComicMetadata();
|
||||
ComicMetadataEntity comicEntity = existingMeta.getComicMetadata();
|
||||
|
||||
// No comic metadata in DTO, no changes
|
||||
if (comicDto == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Comic metadata in DTO but not in entity - this is a change
|
||||
if (comicEntity == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Compare individual fields
|
||||
return !Objects.equals(normalize(comicDto.getIssueNumber()), normalize(comicEntity.getIssueNumber()))
|
||||
|| !Objects.equals(normalize(comicDto.getVolumeName()), normalize(comicEntity.getVolumeName()))
|
||||
|| !Objects.equals(comicDto.getVolumeNumber(), comicEntity.getVolumeNumber())
|
||||
|| !Objects.equals(normalize(comicDto.getStoryArc()), normalize(comicEntity.getStoryArc()))
|
||||
|| !Objects.equals(comicDto.getStoryArcNumber(), comicEntity.getStoryArcNumber())
|
||||
|| !Objects.equals(normalize(comicDto.getAlternateSeries()), normalize(comicEntity.getAlternateSeries()))
|
||||
|| !Objects.equals(normalize(comicDto.getAlternateIssue()), normalize(comicEntity.getAlternateIssue()))
|
||||
|| !Objects.equals(normalize(comicDto.getImprint()), normalize(comicEntity.getImprint()))
|
||||
|| !Objects.equals(normalize(comicDto.getFormat()), normalize(comicEntity.getFormat()))
|
||||
|| !Objects.equals(comicDto.getBlackAndWhite(), comicEntity.getBlackAndWhite())
|
||||
|| !Objects.equals(comicDto.getManga(), comicEntity.getManga())
|
||||
|| !Objects.equals(normalize(comicDto.getReadingDirection()), normalize(comicEntity.getReadingDirection()))
|
||||
|| !Objects.equals(normalize(comicDto.getWebLink()), normalize(comicEntity.getWebLink()))
|
||||
|| !Objects.equals(normalize(comicDto.getNotes()), normalize(comicEntity.getNotes()))
|
||||
|| !stringSetsEqual(comicDto.getCharacters(), extractCharacterNames(comicEntity.getCharacters()))
|
||||
|| !stringSetsEqual(comicDto.getTeams(), extractTeamNames(comicEntity.getTeams()))
|
||||
|| !stringSetsEqual(comicDto.getLocations(), extractLocationNames(comicEntity.getLocations()))
|
||||
|| hasCreatorChanges(comicDto, comicEntity);
|
||||
}
|
||||
|
||||
private static boolean stringSetsEqual(Set<String> set1, Set<String> set2) {
|
||||
if (set1 == null && (set2 == null || set2.isEmpty())) return true;
|
||||
if (set1 == null || set2 == null) return false;
|
||||
if (set1.isEmpty() && set2.isEmpty()) return true;
|
||||
return set1.equals(set2);
|
||||
}
|
||||
|
||||
private static Set<String> extractCharacterNames(Set<ComicCharacterEntity> entities) {
|
||||
if (entities == null) return Collections.emptySet();
|
||||
return entities.stream().map(ComicCharacterEntity::getName).collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private static Set<String> extractTeamNames(Set<ComicTeamEntity> entities) {
|
||||
if (entities == null) return Collections.emptySet();
|
||||
return entities.stream().map(ComicTeamEntity::getName).collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private static Set<String> extractLocationNames(Set<ComicLocationEntity> entities) {
|
||||
if (entities == null) return Collections.emptySet();
|
||||
return entities.stream().map(ComicLocationEntity::getName).collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private static boolean hasCreatorChanges(ComicMetadata dto, ComicMetadataEntity entity) {
|
||||
// For creators, we do a simplified comparison based on whether there are any creators in DTO
|
||||
boolean dtoHasCreators = (dto.getPencillers() != null && !dto.getPencillers().isEmpty())
|
||||
|| (dto.getInkers() != null && !dto.getInkers().isEmpty())
|
||||
|| (dto.getColorists() != null && !dto.getColorists().isEmpty())
|
||||
|| (dto.getLetterers() != null && !dto.getLetterers().isEmpty())
|
||||
|| (dto.getCoverArtists() != null && !dto.getCoverArtists().isEmpty())
|
||||
|| (dto.getEditors() != null && !dto.getEditors().isEmpty());
|
||||
|
||||
boolean entityHasCreators = entity.getCreatorMappings() != null && !entity.getCreatorMappings().isEmpty();
|
||||
|
||||
// If both have no creators, no change
|
||||
if (!dtoHasCreators && !entityHasCreators) return false;
|
||||
|
||||
// If one has creators and other doesn't, there's a change
|
||||
if (dtoHasCreators != entityHasCreators) return true;
|
||||
|
||||
// Both have creators - compare counts as a basic check
|
||||
int dtoCount = countNonNull(dto.getPencillers()) + countNonNull(dto.getInkers())
|
||||
+ countNonNull(dto.getColorists()) + countNonNull(dto.getLetterers())
|
||||
+ countNonNull(dto.getCoverArtists()) + countNonNull(dto.getEditors());
|
||||
|
||||
return dtoCount != entity.getCreatorMappings().size();
|
||||
}
|
||||
|
||||
private static int countNonNull(Set<String> set) {
|
||||
return set == null ? 0 : set.size();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
-- Create comic_metadata table for storing comic-specific metadata
|
||||
CREATE TABLE IF NOT EXISTS comic_metadata
|
||||
(
|
||||
book_id BIGINT PRIMARY KEY,
|
||||
issue_number VARCHAR(50),
|
||||
volume_name VARCHAR(255),
|
||||
volume_number INTEGER,
|
||||
story_arc VARCHAR(255),
|
||||
story_arc_number INTEGER,
|
||||
alternate_series VARCHAR(255),
|
||||
alternate_issue VARCHAR(50),
|
||||
imprint VARCHAR(255),
|
||||
format VARCHAR(50),
|
||||
black_and_white BOOLEAN DEFAULT FALSE,
|
||||
manga BOOLEAN DEFAULT FALSE,
|
||||
reading_direction VARCHAR(10) DEFAULT 'ltr',
|
||||
web_link VARCHAR(1000),
|
||||
notes TEXT,
|
||||
issue_number_locked BOOLEAN DEFAULT FALSE,
|
||||
volume_name_locked BOOLEAN DEFAULT FALSE,
|
||||
volume_number_locked BOOLEAN DEFAULT FALSE,
|
||||
story_arc_locked BOOLEAN DEFAULT FALSE,
|
||||
creators_locked BOOLEAN DEFAULT FALSE,
|
||||
characters_locked BOOLEAN DEFAULT FALSE,
|
||||
teams_locked BOOLEAN DEFAULT FALSE,
|
||||
locations_locked BOOLEAN DEFAULT FALSE,
|
||||
CONSTRAINT fk_comic_metadata_book FOREIGN KEY (book_id) REFERENCES book_metadata (book_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Create index for faster lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_comic_metadata_story_arc ON comic_metadata (story_arc);
|
||||
CREATE INDEX IF NOT EXISTS idx_comic_metadata_volume_name ON comic_metadata (volume_name);
|
||||
|
||||
-- Create comic_character table
|
||||
CREATE TABLE IF NOT EXISTS comic_character
|
||||
(
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
-- Create comic_team table
|
||||
CREATE TABLE IF NOT EXISTS comic_team
|
||||
(
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
-- Create comic_location table
|
||||
CREATE TABLE IF NOT EXISTS comic_location
|
||||
(
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
-- Create comic_creator table
|
||||
CREATE TABLE IF NOT EXISTS comic_creator
|
||||
(
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
-- Create mapping tables
|
||||
CREATE TABLE IF NOT EXISTS comic_metadata_character_mapping
|
||||
(
|
||||
book_id BIGINT NOT NULL,
|
||||
character_id BIGINT NOT NULL,
|
||||
PRIMARY KEY (book_id, character_id),
|
||||
CONSTRAINT fk_comic_char_mapping_book FOREIGN KEY (book_id) REFERENCES comic_metadata (book_id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_comic_char_mapping_char FOREIGN KEY (character_id) REFERENCES comic_character (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS comic_metadata_team_mapping
|
||||
(
|
||||
book_id BIGINT NOT NULL,
|
||||
team_id BIGINT NOT NULL,
|
||||
PRIMARY KEY (book_id, team_id),
|
||||
CONSTRAINT fk_comic_team_mapping_book FOREIGN KEY (book_id) REFERENCES comic_metadata (book_id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_comic_team_mapping_team FOREIGN KEY (team_id) REFERENCES comic_team (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS comic_metadata_location_mapping
|
||||
(
|
||||
book_id BIGINT NOT NULL,
|
||||
location_id BIGINT NOT NULL,
|
||||
PRIMARY KEY (book_id, location_id),
|
||||
CONSTRAINT fk_comic_loc_mapping_book FOREIGN KEY (book_id) REFERENCES comic_metadata (book_id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_comic_loc_mapping_loc FOREIGN KEY (location_id) REFERENCES comic_location (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS comic_metadata_creator_mapping
|
||||
(
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
book_id BIGINT NOT NULL,
|
||||
creator_id BIGINT NOT NULL,
|
||||
role VARCHAR(20) NOT NULL,
|
||||
CONSTRAINT fk_comic_creator_mapping_book FOREIGN KEY (book_id) REFERENCES comic_metadata (book_id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_comic_creator_mapping_creator FOREIGN KEY (creator_id) REFERENCES comic_creator (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_comic_creator_mapping_role ON comic_metadata_creator_mapping (role);
|
||||
CREATE INDEX idx_comic_creator_mapping_book ON comic_metadata_creator_mapping (book_id);
|
||||
Reference in New Issue
Block a user