Add support for fb2 books (#1757)

This commit is contained in:
Dmitry Manannikov
2025-12-13 20:16:56 -08:00
committed by GitHub
parent 0486a4f070
commit 708e851e0b
12 changed files with 1408 additions and 8 deletions

View File

@@ -13,7 +13,8 @@ public enum BookFileExtension {
EPUB("epub", BookFileType.EPUB),
CBZ("cbz", BookFileType.CBX),
CBR("cbr", BookFileType.CBX),
CB7("cb7", BookFileType.CBX);
CB7("cb7", BookFileType.CBX),
FB2("fb2", BookFileType.FB2);
private final String extension;
private final BookFileType type;

View File

@@ -1,5 +1,5 @@
package com.adityachandel.booklore.model.enums;
public enum BookFileType {
PDF, EPUB, CBX
PDF, EPUB, CBX, FB2
}

View File

@@ -0,0 +1,143 @@
package com.adityachandel.booklore.service.fileprocessor;
import com.adityachandel.booklore.mapper.BookMapper;
import com.adityachandel.booklore.model.dto.BookMetadata;
import com.adityachandel.booklore.model.dto.settings.LibraryFile;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.BookMetadataEntity;
import com.adityachandel.booklore.model.enums.BookFileType;
import com.adityachandel.booklore.repository.BookAdditionalFileRepository;
import com.adityachandel.booklore.repository.BookMetadataRepository;
import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.service.book.BookCreatorService;
import com.adityachandel.booklore.service.metadata.MetadataMatchService;
import com.adityachandel.booklore.service.metadata.extractor.Fb2MetadataExtractor;
import com.adityachandel.booklore.util.FileService;
import com.adityachandel.booklore.util.FileUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static com.adityachandel.booklore.util.FileService.truncate;
@Slf4j
@Service
public class Fb2Processor extends AbstractFileProcessor implements BookFileProcessor {
private final Fb2MetadataExtractor fb2MetadataExtractor;
private final BookMetadataRepository bookMetadataRepository;
public Fb2Processor(BookRepository bookRepository,
BookAdditionalFileRepository bookAdditionalFileRepository,
BookCreatorService bookCreatorService,
BookMapper bookMapper,
FileService fileService,
BookMetadataRepository bookMetadataRepository,
MetadataMatchService metadataMatchService,
Fb2MetadataExtractor fb2MetadataExtractor) {
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService);
this.fb2MetadataExtractor = fb2MetadataExtractor;
this.bookMetadataRepository = bookMetadataRepository;
}
@Override
public BookEntity processNewFile(LibraryFile libraryFile) {
BookEntity bookEntity = bookCreatorService.createShellBook(libraryFile, BookFileType.FB2);
setBookMetadata(bookEntity);
if (generateCover(bookEntity)) {
FileService.setBookCoverPath(bookEntity.getMetadata());
}
return bookEntity;
}
@Override
public boolean generateCover(BookEntity bookEntity) {
try {
File fb2File = new File(FileUtils.getBookFullPath(bookEntity));
byte[] coverData = fb2MetadataExtractor.extractCover(fb2File);
if (coverData == null || coverData.length == 0) {
log.warn("No cover image found in FB2 '{}'", bookEntity.getFileName());
return false;
}
boolean saved = saveCoverImage(coverData, bookEntity.getId());
bookEntity.getMetadata().setCoverUpdatedOn(Instant.now());
bookMetadataRepository.save(bookEntity.getMetadata());
return saved;
} catch (Exception e) {
log.error("Error generating cover for FB2 '{}': {}", bookEntity.getFileName(), e.getMessage(), e);
return false;
}
}
@Override
public List<BookFileType> getSupportedTypes() {
return List.of(BookFileType.FB2);
}
private void setBookMetadata(BookEntity bookEntity) {
File bookFile = new File(bookEntity.getFullFilePath().toUri());
BookMetadata fb2Metadata = fb2MetadataExtractor.extractMetadata(bookFile);
if (fb2Metadata == null) return;
BookMetadataEntity metadata = bookEntity.getMetadata();
metadata.setTitle(truncate(fb2Metadata.getTitle(), 1000));
metadata.setSubtitle(truncate(fb2Metadata.getSubtitle(), 1000));
metadata.setDescription(truncate(fb2Metadata.getDescription(), 2000));
metadata.setPublisher(truncate(fb2Metadata.getPublisher(), 1000));
metadata.setPublishedDate(fb2Metadata.getPublishedDate());
metadata.setSeriesName(truncate(fb2Metadata.getSeriesName(), 1000));
metadata.setSeriesNumber(fb2Metadata.getSeriesNumber());
metadata.setSeriesTotal(fb2Metadata.getSeriesTotal());
metadata.setIsbn13(truncate(fb2Metadata.getIsbn13(), 64));
metadata.setIsbn10(truncate(fb2Metadata.getIsbn10(), 64));
metadata.setPageCount(fb2Metadata.getPageCount());
String lang = fb2Metadata.getLanguage();
metadata.setLanguage(truncate((lang == null || "UND".equalsIgnoreCase(lang)) ? "en" : lang, 1000));
metadata.setAsin(truncate(fb2Metadata.getAsin(), 20));
metadata.setPersonalRating(fb2Metadata.getPersonalRating());
metadata.setAmazonRating(fb2Metadata.getAmazonRating());
metadata.setAmazonReviewCount(fb2Metadata.getAmazonReviewCount());
metadata.setGoodreadsId(truncate(fb2Metadata.getGoodreadsId(), 100));
metadata.setGoodreadsRating(fb2Metadata.getGoodreadsRating());
metadata.setGoodreadsReviewCount(fb2Metadata.getGoodreadsReviewCount());
metadata.setHardcoverId(truncate(fb2Metadata.getHardcoverId(), 100));
metadata.setHardcoverRating(fb2Metadata.getHardcoverRating());
metadata.setHardcoverReviewCount(fb2Metadata.getHardcoverReviewCount());
metadata.setGoogleId(truncate(fb2Metadata.getGoogleId(), 100));
metadata.setComicvineId(truncate(fb2Metadata.getComicvineId(), 100));
bookCreatorService.addAuthorsToBook(fb2Metadata.getAuthors(), bookEntity);
if (fb2Metadata.getCategories() != null) {
Set<String> validSubjects = fb2Metadata.getCategories().stream()
.filter(s -> s != null && !s.isBlank() && s.length() <= 100 && !s.contains("\n") && !s.contains("\r") && !s.contains(" "))
.collect(Collectors.toSet());
bookCreatorService.addCategoriesToBook(validSubjects, bookEntity);
}
}
private boolean saveCoverImage(byte[] coverData, long bookId) throws Exception {
BufferedImage originalImage = ImageIO.read(new ByteArrayInputStream(coverData));
try {
return fileService.saveCoverImages(originalImage, bookId);
} finally {
if (originalImage != null) {
originalImage.flush(); // Release resources after processing
}
}
}
}

View File

@@ -0,0 +1,362 @@
package com.adityachandel.booklore.service.metadata.extractor;
import com.adityachandel.booklore.model.dto.BookMetadata;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.time.LocalDate;
import java.util.Base64;
import java.util.HashSet;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.GZIPInputStream;
@Slf4j
@Component
public class Fb2MetadataExtractor implements FileMetadataExtractor {
private static final String FB2_NAMESPACE = "http://www.gribuser.ru/xml/fictionbook/2.0";
private static final Pattern YEAR_PATTERN = Pattern.compile("\\d{4}");
private static final Pattern ISBN_PATTERN = Pattern.compile("\\d{9}[\\dXx]");
@Override
public byte[] extractCover(File file) {
try (InputStream inputStream = getInputStream(file)) {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setNamespaceAware(true);
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
DocumentBuilder builder = dbf.newDocumentBuilder();
Document doc = builder.parse(inputStream);
// Look for cover image in binary elements
NodeList binaries = doc.getElementsByTagNameNS(FB2_NAMESPACE, "binary");
for (int i = 0; i < binaries.getLength(); i++) {
Element binary = (Element) binaries.item(i);
String id = binary.getAttribute("id");
if (id != null && id.toLowerCase().contains("cover")) {
String contentType = binary.getAttribute("content-type");
if (contentType != null && contentType.startsWith("image/")) {
String base64Data = binary.getTextContent().trim();
return Base64.getDecoder().decode(base64Data);
}
}
}
// If no cover found by name, try to find the first referenced image in title-info
Element titleInfo = getFirstElementByTagNameNS(doc, FB2_NAMESPACE, "title-info");
if (titleInfo != null) {
NodeList coverPages = titleInfo.getElementsByTagNameNS(FB2_NAMESPACE, "coverpage");
if (coverPages.getLength() > 0) {
Element coverPage = (Element) coverPages.item(0);
NodeList images = coverPage.getElementsByTagNameNS(FB2_NAMESPACE, "image");
if (images.getLength() > 0) {
Element image = (Element) images.item(0);
String href = image.getAttributeNS("http://www.w3.org/1999/xlink", "href");
if (href != null && href.startsWith("#")) {
String imageId = href.substring(1);
// Find the binary with this ID
for (int i = 0; i < binaries.getLength(); i++) {
Element binary = (Element) binaries.item(i);
if (imageId.equals(binary.getAttribute("id"))) {
String base64Data = binary.getTextContent().trim();
return Base64.getDecoder().decode(base64Data);
}
}
}
}
}
}
return null;
} catch (Exception e) {
log.warn("Failed to extract cover from FB2: {}", file.getName(), e);
return null;
}
}
@Override
public BookMetadata extractMetadata(File file) {
try (InputStream inputStream = getInputStream(file)) {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setNamespaceAware(true);
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
DocumentBuilder builder = dbf.newDocumentBuilder();
Document doc = builder.parse(inputStream);
BookMetadata.BookMetadataBuilder metadataBuilder = BookMetadata.builder();
Set<String> authors = new HashSet<>();
Set<String> categories = new HashSet<>();
// Extract title-info (main metadata section)
Element titleInfo = getFirstElementByTagNameNS(doc, FB2_NAMESPACE, "title-info");
if (titleInfo != null) {
extractTitleInfo(titleInfo, metadataBuilder, authors, categories);
}
// Extract publish-info (publisher, year, ISBN)
Element publishInfo = getFirstElementByTagNameNS(doc, FB2_NAMESPACE, "publish-info");
if (publishInfo != null) {
extractPublishInfo(publishInfo, metadataBuilder);
}
// Extract document-info (optional metadata)
Element documentInfo = getFirstElementByTagNameNS(doc, FB2_NAMESPACE, "document-info");
if (documentInfo != null) {
extractDocumentInfo(documentInfo, metadataBuilder);
}
metadataBuilder.authors(authors);
metadataBuilder.categories(categories);
return metadataBuilder.build();
} catch (Exception e) {
log.warn("Failed to extract metadata from FB2: {}", file.getName(), e);
return null;
}
}
private void extractTitleInfo(Element titleInfo, BookMetadata.BookMetadataBuilder builder,
Set<String> authors, Set<String> categories) {
// Extract genres (categories)
NodeList genres = titleInfo.getElementsByTagNameNS(FB2_NAMESPACE, "genre");
for (int i = 0; i < genres.getLength(); i++) {
String genre = genres.item(i).getTextContent().trim();
if (StringUtils.isNotBlank(genre)) {
categories.add(genre);
}
}
// Extract authors
NodeList authorNodes = titleInfo.getElementsByTagNameNS(FB2_NAMESPACE, "author");
for (int i = 0; i < authorNodes.getLength(); i++) {
Element author = (Element) authorNodes.item(i);
String authorName = extractPersonName(author);
if (StringUtils.isNotBlank(authorName)) {
authors.add(authorName);
}
}
// Extract book title
Element bookTitle = getFirstElementByTagNameNS(titleInfo, FB2_NAMESPACE, "book-title");
if (bookTitle != null) {
builder.title(bookTitle.getTextContent().trim());
}
// Extract annotation (description)
Element annotation = getFirstElementByTagNameNS(titleInfo, FB2_NAMESPACE, "annotation");
if (annotation != null) {
String description = extractTextFromElement(annotation);
if (StringUtils.isNotBlank(description)) {
builder.description(description);
}
}
// Extract keywords (additional categories/tags)
Element keywords = getFirstElementByTagNameNS(titleInfo, FB2_NAMESPACE, "keywords");
if (keywords != null) {
String keywordsText = keywords.getTextContent().trim();
if (StringUtils.isNotBlank(keywordsText)) {
for (String keyword : keywordsText.split("[,;]")) {
String trimmed = keyword.trim();
if (StringUtils.isNotBlank(trimmed)) {
categories.add(trimmed);
}
}
}
}
// Extract date
Element date = getFirstElementByTagNameNS(titleInfo, FB2_NAMESPACE, "date");
if (date != null) {
String dateValue = date.getAttribute("value");
if (StringUtils.isBlank(dateValue)) {
dateValue = date.getTextContent().trim();
}
LocalDate publishedDate = parseDate(dateValue);
if (publishedDate != null) {
builder.publishedDate(publishedDate);
}
}
// Extract language
Element lang = getFirstElementByTagNameNS(titleInfo, FB2_NAMESPACE, "lang");
if (lang != null) {
builder.language(lang.getTextContent().trim());
}
// Extract sequence (series information)
Element sequence = getFirstElementByTagNameNS(titleInfo, FB2_NAMESPACE, "sequence");
if (sequence != null) {
String seriesName = sequence.getAttribute("name");
if (StringUtils.isNotBlank(seriesName)) {
builder.seriesName(seriesName.trim());
}
String seriesNumber = sequence.getAttribute("number");
if (StringUtils.isNotBlank(seriesNumber)) {
try {
builder.seriesNumber(Float.parseFloat(seriesNumber));
} catch (NumberFormatException e) {
log.debug("Failed to parse series number: {}", seriesNumber);
}
}
}
}
private void extractPublishInfo(Element publishInfo, BookMetadata.BookMetadataBuilder builder) {
// Extract publisher
Element publisher = getFirstElementByTagNameNS(publishInfo, FB2_NAMESPACE, "publisher");
if (publisher != null) {
builder.publisher(publisher.getTextContent().trim());
}
// Extract publication year
Element year = getFirstElementByTagNameNS(publishInfo, FB2_NAMESPACE, "year");
if (year != null) {
String yearText = year.getTextContent().trim();
Matcher matcher = YEAR_PATTERN.matcher(yearText);
if (matcher.find()) {
try {
int yearValue = Integer.parseInt(matcher.group());
builder.publishedDate(LocalDate.of(yearValue, 1, 1));
} catch (NumberFormatException e) {
log.debug("Failed to parse year: {}", yearText);
}
}
}
// Extract ISBN
Element isbn = getFirstElementByTagNameNS(publishInfo, FB2_NAMESPACE, "isbn");
if (isbn != null) {
String isbnText = isbn.getTextContent().trim().replaceAll("[^0-9Xx]", "");
if (isbnText.length() == 13) {
builder.isbn13(isbnText);
} else if (isbnText.length() == 10) {
builder.isbn10(isbnText);
} else if (ISBN_PATTERN.matcher(isbnText).find()) {
// Extract the first valid ISBN pattern found
Matcher matcher = ISBN_PATTERN.matcher(isbnText);
if (matcher.find()) {
builder.isbn10(matcher.group());
}
}
}
}
private void extractDocumentInfo(Element documentInfo, BookMetadata.BookMetadataBuilder builder) {
// Extract document ID (can be used as an identifier)
Element id = getFirstElementByTagNameNS(documentInfo, FB2_NAMESPACE, "id");
if (id != null) {
// Could potentially map this to a custom identifier field if needed
log.debug("FB2 document ID: {}", id.getTextContent().trim());
}
}
private String extractPersonName(Element personElement) {
Element firstName = getFirstElementByTagNameNS(personElement, FB2_NAMESPACE, "first-name");
Element middleName = getFirstElementByTagNameNS(personElement, FB2_NAMESPACE, "middle-name");
Element lastName = getFirstElementByTagNameNS(personElement, FB2_NAMESPACE, "last-name");
Element nickname = getFirstElementByTagNameNS(personElement, FB2_NAMESPACE, "nickname");
StringBuilder name = new StringBuilder();
if (firstName != null) {
name.append(firstName.getTextContent().trim());
}
if (middleName != null) {
if (name.length() > 0) name.append(" ");
name.append(middleName.getTextContent().trim());
}
if (lastName != null) {
if (name.length() > 0) name.append(" ");
name.append(lastName.getTextContent().trim());
}
// If no name parts found, try nickname
if (name.length() == 0 && nickname != null) {
name.append(nickname.getTextContent().trim());
}
return name.toString();
}
private String extractTextFromElement(Element element) {
StringBuilder text = new StringBuilder();
NodeList children = element.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
Node child = children.item(i);
if (child.getNodeType() == Node.TEXT_NODE) {
text.append(child.getTextContent().trim()).append(" ");
} else if (child.getNodeType() == Node.ELEMENT_NODE) {
Element childElement = (Element) child;
if ("p".equals(childElement.getLocalName())) {
text.append(childElement.getTextContent().trim()).append("\n\n");
} else {
text.append(extractTextFromElement(childElement));
}
}
}
return text.toString().trim();
}
private LocalDate parseDate(String dateString) {
if (StringUtils.isBlank(dateString)) {
return null;
}
try {
// Try parsing ISO date format (YYYY-MM-DD)
if (dateString.matches("\\d{4}-\\d{2}-\\d{2}")) {
return LocalDate.parse(dateString);
}
// Try extracting year only
Matcher matcher = YEAR_PATTERN.matcher(dateString);
if (matcher.find()) {
int year = Integer.parseInt(matcher.group());
return LocalDate.of(year, 1, 1);
}
} catch (Exception e) {
log.debug("Failed to parse date: {}", dateString, e);
}
return null;
}
private Element getFirstElementByTagNameNS(Node parent, String namespace, String localName) {
NodeList nodes;
if (parent instanceof Document) {
nodes = ((Document) parent).getElementsByTagNameNS(namespace, localName);
} else if (parent instanceof Element) {
nodes = ((Element) parent).getElementsByTagNameNS(namespace, localName);
} else {
return null;
}
return nodes.getLength() > 0 ? (Element) nodes.item(0) : null;
}
private InputStream getInputStream(File file) throws Exception {
FileInputStream fis = new FileInputStream(file);
// Check if file is gzipped (FB2 files can be .fb2 or .fb2.zip/.fb2.gz)
if (file.getName().toLowerCase().endsWith(".gz")) {
return new GZIPInputStream(fis);
}
return fis;
}
}

View File

@@ -15,12 +15,14 @@ public class MetadataExtractorFactory {
private final EpubMetadataExtractor epubMetadataExtractor;
private final PdfMetadataExtractor pdfMetadataExtractor;
private final CbxMetadataExtractor cbxMetadataExtractor;
private final Fb2MetadataExtractor fb2MetadataExtractor;
public BookMetadata extractMetadata(BookFileType bookFileType, File file) {
return switch (bookFileType) {
case PDF -> pdfMetadataExtractor.extractMetadata(file);
case EPUB -> epubMetadataExtractor.extractMetadata(file);
case CBX -> cbxMetadataExtractor.extractMetadata(file);
case FB2 -> fb2MetadataExtractor.extractMetadata(file);
};
}
@@ -29,6 +31,7 @@ public class MetadataExtractorFactory {
case PDF -> pdfMetadataExtractor.extractMetadata(file);
case EPUB -> epubMetadataExtractor.extractMetadata(file);
case CBZ, CBR, CB7 -> cbxMetadataExtractor.extractMetadata(file);
case FB2 -> fb2MetadataExtractor.extractMetadata(file);
};
}
@@ -37,6 +40,7 @@ public class MetadataExtractorFactory {
case EPUB -> epubMetadataExtractor.extractCover(file);
case PDF -> pdfMetadataExtractor.extractCover(file);
case CBZ, CBR, CB7 -> cbxMetadataExtractor.extractCover(file);
case FB2 -> fb2MetadataExtractor.extractCover(file);
};
}
}

View File

@@ -0,0 +1,885 @@
package com.adityachandel.booklore.service.metadata.extractor;
import com.adityachandel.booklore.model.dto.BookMetadata;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.Base64;
import static org.junit.jupiter.api.Assertions.*;
class Fb2MetadataExtractorTest {
private static final String DEFAULT_TITLE = "The Seven Poor Travellers";
private static final String DEFAULT_AUTHOR_FIRST = "Charles";
private static final String DEFAULT_AUTHOR_LAST = "Dickens";
private static final String DEFAULT_AUTHOR_FULL = "Charles Dickens";
private static final String DEFAULT_GENRE = "antique";
private static final String DEFAULT_LANGUAGE = "ru";
private static final String DEFAULT_PUBLISHER = "Test Publisher";
private static final String DEFAULT_ISBN = "9781234567890";
private static final String DEFAULT_SERIES = "Great Works";
private Fb2MetadataExtractor extractor;
@TempDir
Path tempDir;
@BeforeEach
void setUp() {
extractor = new Fb2MetadataExtractor();
}
@Nested
@DisplayName("Basic Metadata Extraction Tests")
class BasicMetadataTests {
@Test
@DisplayName("Should extract title from title-info")
void extractMetadata_withTitle_returnsTitle() throws IOException {
String fb2Content = createFb2WithTitleInfo(
DEFAULT_TITLE,
DEFAULT_AUTHOR_FIRST,
DEFAULT_AUTHOR_LAST,
DEFAULT_GENRE,
DEFAULT_LANGUAGE
);
File fb2File = createFb2File(fb2Content);
BookMetadata result = extractor.extractMetadata(fb2File);
assertNotNull(result);
assertEquals(DEFAULT_TITLE, result.getTitle());
}
@Test
@DisplayName("Should extract author name from title-info")
void extractMetadata_withAuthor_returnsAuthor() throws IOException {
String fb2Content = createFb2WithTitleInfo(
DEFAULT_TITLE,
DEFAULT_AUTHOR_FIRST,
DEFAULT_AUTHOR_LAST,
DEFAULT_GENRE,
DEFAULT_LANGUAGE
);
File fb2File = createFb2File(fb2Content);
BookMetadata result = extractor.extractMetadata(fb2File);
assertNotNull(result);
assertNotNull(result.getAuthors());
assertEquals(1, result.getAuthors().size());
assertTrue(result.getAuthors().contains(DEFAULT_AUTHOR_FULL));
}
@Test
@DisplayName("Should extract multiple authors")
void extractMetadata_withMultipleAuthors_returnsAllAuthors() throws IOException {
String fb2Content = createFb2WithMultipleAuthors();
File fb2File = createFb2File(fb2Content);
BookMetadata result = extractor.extractMetadata(fb2File);
assertNotNull(result);
assertNotNull(result.getAuthors());
assertEquals(2, result.getAuthors().size());
assertTrue(result.getAuthors().contains("Charles Dickens"));
assertTrue(result.getAuthors().contains("Jane Austen"));
}
@Test
@DisplayName("Should extract genre as category")
void extractMetadata_withGenre_returnsCategory() throws IOException {
String fb2Content = createFb2WithTitleInfo(
DEFAULT_TITLE,
DEFAULT_AUTHOR_FIRST,
DEFAULT_AUTHOR_LAST,
DEFAULT_GENRE,
DEFAULT_LANGUAGE
);
File fb2File = createFb2File(fb2Content);
BookMetadata result = extractor.extractMetadata(fb2File);
assertNotNull(result);
assertNotNull(result.getCategories());
assertTrue(result.getCategories().contains(DEFAULT_GENRE));
}
@Test
@DisplayName("Should extract multiple genres as categories")
void extractMetadata_withMultipleGenres_returnsAllCategories() throws IOException {
String fb2Content = createFb2WithMultipleGenres();
File fb2File = createFb2File(fb2Content);
BookMetadata result = extractor.extractMetadata(fb2File);
assertNotNull(result);
assertNotNull(result.getCategories());
assertTrue(result.getCategories().contains("fiction"));
assertTrue(result.getCategories().contains("drama"));
}
@Test
@DisplayName("Should extract language")
void extractMetadata_withLanguage_returnsLanguage() throws IOException {
String fb2Content = createFb2WithTitleInfo(
DEFAULT_TITLE,
DEFAULT_AUTHOR_FIRST,
DEFAULT_AUTHOR_LAST,
DEFAULT_GENRE,
DEFAULT_LANGUAGE
);
File fb2File = createFb2File(fb2Content);
BookMetadata result = extractor.extractMetadata(fb2File);
assertNotNull(result);
assertEquals(DEFAULT_LANGUAGE, result.getLanguage());
}
@Test
@DisplayName("Should extract annotation as description")
void extractMetadata_withAnnotation_returnsDescription() throws IOException {
String annotation = "This is a test book description";
String fb2Content = createFb2WithAnnotation(annotation);
File fb2File = createFb2File(fb2Content);
BookMetadata result = extractor.extractMetadata(fb2File);
assertNotNull(result);
assertNotNull(result.getDescription());
assertTrue(result.getDescription().contains(annotation));
}
}
@Nested
@DisplayName("Date Extraction Tests")
class DateExtractionTests {
@Test
@DisplayName("Should extract date from title-info")
void extractMetadata_withDate_returnsDate() throws IOException {
String fb2Content = createFb2WithDate("2024-06-15");
File fb2File = createFb2File(fb2Content);
BookMetadata result = extractor.extractMetadata(fb2File);
assertNotNull(result);
assertEquals(LocalDate.of(2024, 6, 15), result.getPublishedDate());
}
@Test
@DisplayName("Should extract year-only date")
void extractMetadata_withYearOnly_returnsDateWithJanuary1st() throws IOException {
String fb2Content = createFb2WithDate("2024");
File fb2File = createFb2File(fb2Content);
BookMetadata result = extractor.extractMetadata(fb2File);
assertNotNull(result);
assertEquals(LocalDate.of(2024, 1, 1), result.getPublishedDate());
}
@Test
@DisplayName("Should handle date with value attribute")
void extractMetadata_withDateValue_returnsDate() throws IOException {
String fb2Content = createFb2WithDateValue("2024-06-15", "June 15, 2024");
File fb2File = createFb2File(fb2Content);
BookMetadata result = extractor.extractMetadata(fb2File);
assertNotNull(result);
assertEquals(LocalDate.of(2024, 6, 15), result.getPublishedDate());
}
}
@Nested
@DisplayName("Series Metadata Tests")
class SeriesMetadataTests {
@Test
@DisplayName("Should extract series name from sequence")
void extractMetadata_withSequence_returnsSeriesName() throws IOException {
String fb2Content = createFb2WithSequence(DEFAULT_SERIES, "3");
File fb2File = createFb2File(fb2Content);
BookMetadata result = extractor.extractMetadata(fb2File);
assertNotNull(result);
assertEquals(DEFAULT_SERIES, result.getSeriesName());
}
@Test
@DisplayName("Should extract series number from sequence")
void extractMetadata_withSequence_returnsSeriesNumber() throws IOException {
String fb2Content = createFb2WithSequence(DEFAULT_SERIES, "3");
File fb2File = createFb2File(fb2Content);
BookMetadata result = extractor.extractMetadata(fb2File);
assertNotNull(result);
assertEquals(3.0f, result.getSeriesNumber(), 0.001);
}
@Test
@DisplayName("Should handle decimal series numbers")
void extractMetadata_withDecimalSequence_returnsDecimalSeriesNumber() throws IOException {
String fb2Content = createFb2WithSequence(DEFAULT_SERIES, "2.5");
File fb2File = createFb2File(fb2Content);
BookMetadata result = extractor.extractMetadata(fb2File);
assertNotNull(result);
assertEquals(2.5f, result.getSeriesNumber(), 0.001);
}
}
@Nested
@DisplayName("Publisher Info Extraction Tests")
class PublisherInfoTests {
@Test
@DisplayName("Should extract publisher from publish-info")
void extractMetadata_withPublisher_returnsPublisher() throws IOException {
String fb2Content = createFb2WithPublishInfo(DEFAULT_PUBLISHER, "2024", DEFAULT_ISBN);
File fb2File = createFb2File(fb2Content);
BookMetadata result = extractor.extractMetadata(fb2File);
assertNotNull(result);
assertEquals(DEFAULT_PUBLISHER, result.getPublisher());
}
@Test
@DisplayName("Should extract year from publish-info")
void extractMetadata_withPublishYear_returnsDate() throws IOException {
String fb2Content = createFb2WithPublishInfo(DEFAULT_PUBLISHER, "2024", null);
File fb2File = createFb2File(fb2Content);
BookMetadata result = extractor.extractMetadata(fb2File);
assertNotNull(result);
assertEquals(LocalDate.of(2024, 1, 1), result.getPublishedDate());
}
@Test
@DisplayName("Should extract ISBN-13 from publish-info")
void extractMetadata_withIsbn13_returnsIsbn13() throws IOException {
String fb2Content = createFb2WithPublishInfo(null, null, "9781234567890");
File fb2File = createFb2File(fb2Content);
BookMetadata result = extractor.extractMetadata(fb2File);
assertNotNull(result);
assertEquals("9781234567890", result.getIsbn13());
}
@Test
@DisplayName("Should extract ISBN-10 from publish-info")
void extractMetadata_withIsbn10_returnsIsbn10() throws IOException {
String fb2Content = createFb2WithPublishInfo(null, null, "1234567890");
File fb2File = createFb2File(fb2Content);
BookMetadata result = extractor.extractMetadata(fb2File);
assertNotNull(result);
assertEquals("1234567890", result.getIsbn10());
}
}
@Nested
@DisplayName("Keywords Extraction Tests")
class KeywordsTests {
@Test
@DisplayName("Should extract keywords as categories")
void extractMetadata_withKeywords_returnsCategories() throws IOException {
String fb2Content = createFb2WithKeywords("adventure, mystery, thriller");
File fb2File = createFb2File(fb2Content);
BookMetadata result = extractor.extractMetadata(fb2File);
assertNotNull(result);
assertNotNull(result.getCategories());
assertTrue(result.getCategories().contains("adventure"));
assertTrue(result.getCategories().contains("mystery"));
assertTrue(result.getCategories().contains("thriller"));
}
@Test
@DisplayName("Should handle keywords with semicolon separator")
void extractMetadata_withSemicolonKeywords_returnsCategories() throws IOException {
String fb2Content = createFb2WithKeywords("adventure; mystery; thriller");
File fb2File = createFb2File(fb2Content);
BookMetadata result = extractor.extractMetadata(fb2File);
assertNotNull(result);
assertNotNull(result.getCategories());
assertTrue(result.getCategories().contains("adventure"));
assertTrue(result.getCategories().contains("mystery"));
assertTrue(result.getCategories().contains("thriller"));
}
}
@Nested
@DisplayName("Author Name Extraction Tests")
class AuthorNameTests {
@Test
@DisplayName("Should extract author with first and last name")
void extractMetadata_withFirstAndLastName_returnsFullName() throws IOException {
String fb2Content = createFb2WithAuthorNames("John", null, "Doe", null);
File fb2File = createFb2File(fb2Content);
BookMetadata result = extractor.extractMetadata(fb2File);
assertNotNull(result);
assertTrue(result.getAuthors().contains("John Doe"));
}
@Test
@DisplayName("Should extract author with first, middle and last name")
void extractMetadata_withMiddleName_returnsFullNameWithMiddle() throws IOException {
String fb2Content = createFb2WithAuthorNames("John", "Robert", "Doe", null);
File fb2File = createFb2File(fb2Content);
BookMetadata result = extractor.extractMetadata(fb2File);
assertNotNull(result);
assertTrue(result.getAuthors().contains("John Robert Doe"));
}
@Test
@DisplayName("Should use nickname when name parts are missing")
void extractMetadata_withNicknameOnly_returnsNickname() throws IOException {
String fb2Content = createFb2WithAuthorNames(null, null, null, "WriterPro");
File fb2File = createFb2File(fb2Content);
BookMetadata result = extractor.extractMetadata(fb2File);
assertNotNull(result);
assertTrue(result.getAuthors().contains("WriterPro"));
}
}
@Nested
@DisplayName("Cover Extraction Tests")
class CoverExtractionTests {
@Test
@DisplayName("Should extract cover image from binary section")
void extractCover_withCoverImage_returnsCoverBytes() throws IOException {
byte[] imageData = createMinimalPngImage();
String fb2Content = createFb2WithCover(imageData);
File fb2File = createFb2File(fb2Content);
byte[] result = extractor.extractCover(fb2File);
assertNotNull(result);
assertTrue(result.length > 0);
}
@Test
@DisplayName("Should return null when no cover present")
void extractCover_noCover_returnsNull() throws IOException {
String fb2Content = createMinimalFb2();
File fb2File = createFb2File(fb2Content);
byte[] result = extractor.extractCover(fb2File);
assertNull(result);
}
}
@Nested
@DisplayName("Complete Metadata Extraction Test")
class CompleteMetadataTest {
@Test
@DisplayName("Should extract all metadata fields from complete FB2 with title-info")
void extractMetadata_completeFile_extractsAllFields() throws IOException {
String fb2Content = createCompleteFb2();
File fb2File = createFb2File(fb2Content);
BookMetadata result = extractor.extractMetadata(fb2File);
assertAll(
() -> assertNotNull(result, "Metadata should not be null"),
() -> assertEquals("Pride and Prejudice", result.getTitle(), "Title should be extracted"),
() -> assertNotNull(result.getAuthors(), "Authors should not be null"),
() -> assertEquals(1, result.getAuthors().size(), "Should have one author"),
() -> assertTrue(result.getAuthors().contains("Jane Austen"), "Should contain full author name"),
() -> assertNotNull(result.getCategories(), "Categories should not be null"),
() -> assertTrue(result.getCategories().contains("romance"), "Should contain genre"),
() -> assertEquals("en", result.getLanguage(), "Language should be extracted"),
() -> assertNotNull(result.getDescription(), "Description should not be null"),
() -> assertTrue(result.getDescription().contains("classic novel"), "Description should contain annotation text"),
() -> assertEquals(LocalDate.of(1813, 1, 1), result.getPublishedDate(), "Published date should be extracted"),
() -> assertEquals("T. Egerton", result.getPublisher(), "Publisher should be extracted"),
() -> assertEquals("Classic Literature Series", result.getSeriesName(), "Series name should be extracted"),
() -> assertEquals(2.0f, result.getSeriesNumber(), 0.001, "Series number should be extracted")
);
}
private String createCompleteFb2() {
return """
<?xml version="1.0" encoding="UTF-8"?>
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:xlink="http://www.w3.org/1999/xlink">
<description>
<title-info>
<genre>romance</genre>
<author>
<first-name>Jane</first-name>
<last-name>Austen</last-name>
</author>
<book-title>Pride and Prejudice</book-title>
<annotation>
<p>Pride and Prejudice is a classic novel by Jane Austen, first published in 1813. It is a romantic novel of manners that follows the character development of Elizabeth Bennet.</p>
<p>The novel deals with issues of morality, education, and marriage in the society of the landed gentry of the British Regency. Elizabeth must learn the error of making hasty judgments and come to appreciate the difference between superficial goodness and actual goodness.</p>
</annotation>
<keywords>romance, regency, england, bennet, darcy, marriage</keywords>
<date value="1813-01-01">1813</date>
<lang>en</lang>
<sequence name="Classic Literature Series" number="2"/>
</title-info>
<document-info>
<author>
<nickname>TestUser</nickname>
</author>
<date value="2024-01-01">January 1, 2024</date>
<id>TestUser_PrideAndPrejudice_12345</id>
<version>2.0</version>
</document-info>
<publish-info>
<book-name>Pride and Prejudice</book-name>
<publisher>T. Egerton</publisher>
<city>London</city>
<year>1813</year>
</publish-info>
</description>
<body>
<section>
<title>
<p>Chapter 1</p>
</title>
<p>It is a truth universally acknowledged, that a single man in possession of a good fortune, must be in want of a wife.</p>
</section>
</body>
</FictionBook>
""";
}
}
@Nested
@DisplayName("Edge Cases and Error Handling")
class EdgeCaseTests {
@Test
@DisplayName("Should handle empty FB2 file gracefully")
void extractMetadata_emptyFile_returnsNull() throws IOException {
File emptyFile = tempDir.resolve("empty.fb2").toFile();
try (FileOutputStream fos = new FileOutputStream(emptyFile)) {
fos.write("".getBytes(StandardCharsets.UTF_8));
}
BookMetadata result = extractor.extractMetadata(emptyFile);
assertNull(result);
}
@Test
@DisplayName("Should handle invalid XML gracefully")
void extractMetadata_invalidXml_returnsNull() throws IOException {
File invalidFile = tempDir.resolve("invalid.fb2").toFile();
try (FileOutputStream fos = new FileOutputStream(invalidFile)) {
fos.write("this is not valid XML".getBytes(StandardCharsets.UTF_8));
}
BookMetadata result = extractor.extractMetadata(invalidFile);
assertNull(result);
}
@Test
@DisplayName("Should handle non-existent file gracefully")
void extractMetadata_nonExistentFile_returnsNull() {
File nonExistent = new File(tempDir.toFile(), "does-not-exist.fb2");
BookMetadata result = extractor.extractMetadata(nonExistent);
assertNull(result);
}
}
// Helper methods to create FB2 test files
private String createMinimalFb2() {
return """
<?xml version="1.0" encoding="UTF-8"?>
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0">
<description>
<title-info>
<genre>fiction</genre>
<author>
<first-name>Test</first-name>
<last-name>Author</last-name>
</author>
<book-title>Test Book</book-title>
<lang>en</lang>
</title-info>
</description>
<body>
<section>
<p>Test content</p>
</section>
</body>
</FictionBook>
""";
}
private String createFb2WithTitleInfo(String title, String firstName, String lastName, String genre, String lang) {
return String.format("""
<?xml version="1.0" encoding="UTF-8"?>
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0">
<description>
<title-info>
<genre>%s</genre>
<author>
<first-name>%s</first-name>
<last-name>%s</last-name>
</author>
<book-title>%s</book-title>
<lang>%s</lang>
</title-info>
</description>
<body>
<section>
<p>Content</p>
</section>
</body>
</FictionBook>
""", genre, firstName, lastName, title, lang);
}
private String createFb2WithMultipleAuthors() {
return """
<?xml version="1.0" encoding="UTF-8"?>
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0">
<description>
<title-info>
<genre>fiction</genre>
<author>
<first-name>Charles</first-name>
<last-name>Dickens</last-name>
</author>
<author>
<first-name>Jane</first-name>
<last-name>Austen</last-name>
</author>
<book-title>Collaborative Work</book-title>
<lang>en</lang>
</title-info>
</description>
<body>
<section>
<p>Content</p>
</section>
</body>
</FictionBook>
""";
}
private String createFb2WithMultipleGenres() {
return """
<?xml version="1.0" encoding="UTF-8"?>
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0">
<description>
<title-info>
<genre>fiction</genre>
<genre>drama</genre>
<author>
<first-name>Test</first-name>
<last-name>Author</last-name>
</author>
<book-title>Multi-Genre Book</book-title>
<lang>en</lang>
</title-info>
</description>
<body>
<section>
<p>Content</p>
</section>
</body>
</FictionBook>
""";
}
private String createFb2WithAnnotation(String annotation) {
return String.format("""
<?xml version="1.0" encoding="UTF-8"?>
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0">
<description>
<title-info>
<genre>fiction</genre>
<author>
<first-name>Test</first-name>
<last-name>Author</last-name>
</author>
<book-title>Book with Annotation</book-title>
<annotation>
<p>%s</p>
</annotation>
<lang>en</lang>
</title-info>
</description>
<body>
<section>
<p>Content</p>
</section>
</body>
</FictionBook>
""", annotation);
}
private String createFb2WithDate(String date) {
return String.format("""
<?xml version="1.0" encoding="UTF-8"?>
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0">
<description>
<title-info>
<genre>fiction</genre>
<author>
<first-name>Test</first-name>
<last-name>Author</last-name>
</author>
<book-title>Book with Date</book-title>
<date>%s</date>
<lang>en</lang>
</title-info>
</description>
<body>
<section>
<p>Content</p>
</section>
</body>
</FictionBook>
""", date);
}
private String createFb2WithDateValue(String dateValue, String dateText) {
return String.format("""
<?xml version="1.0" encoding="UTF-8"?>
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0">
<description>
<title-info>
<genre>fiction</genre>
<author>
<first-name>Test</first-name>
<last-name>Author</last-name>
</author>
<book-title>Book with Date Value</book-title>
<date value="%s">%s</date>
<lang>en</lang>
</title-info>
</description>
<body>
<section>
<p>Content</p>
</section>
</body>
</FictionBook>
""", dateValue, dateText);
}
private String createFb2WithSequence(String seriesName, String seriesNumber) {
return String.format("""
<?xml version="1.0" encoding="UTF-8"?>
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0">
<description>
<title-info>
<genre>fiction</genre>
<author>
<first-name>Test</first-name>
<last-name>Author</last-name>
</author>
<book-title>Book in Series</book-title>
<sequence name="%s" number="%s"/>
<lang>en</lang>
</title-info>
</description>
<body>
<section>
<p>Content</p>
</section>
</body>
</FictionBook>
""", seriesName, seriesNumber);
}
private String createFb2WithPublishInfo(String publisher, String year, String isbn) {
StringBuilder publishInfo = new StringBuilder();
if (publisher != null) {
publishInfo.append(String.format(" <publisher>%s</publisher>\n", publisher));
}
if (year != null) {
publishInfo.append(String.format(" <year>%s</year>\n", year));
}
if (isbn != null) {
publishInfo.append(String.format(" <isbn>%s</isbn>\n", isbn));
}
return String.format("""
<?xml version="1.0" encoding="UTF-8"?>
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0">
<description>
<title-info>
<genre>fiction</genre>
<author>
<first-name>Test</first-name>
<last-name>Author</last-name>
</author>
<book-title>Book with Publish Info</book-title>
<lang>en</lang>
</title-info>
<publish-info>
%s </publish-info>
</description>
<body>
<section>
<p>Content</p>
</section>
</body>
</FictionBook>
""", publishInfo);
}
private String createFb2WithKeywords(String keywords) {
return String.format("""
<?xml version="1.0" encoding="UTF-8"?>
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0">
<description>
<title-info>
<genre>fiction</genre>
<author>
<first-name>Test</first-name>
<last-name>Author</last-name>
</author>
<book-title>Book with Keywords</book-title>
<keywords>%s</keywords>
<lang>en</lang>
</title-info>
</description>
<body>
<section>
<p>Content</p>
</section>
</body>
</FictionBook>
""", keywords);
}
private String createFb2WithAuthorNames(String firstName, String middleName, String lastName, String nickname) {
StringBuilder authorInfo = new StringBuilder();
if (firstName != null) {
authorInfo.append(String.format(" <first-name>%s</first-name>\n", firstName));
}
if (middleName != null) {
authorInfo.append(String.format(" <middle-name>%s</middle-name>\n", middleName));
}
if (lastName != null) {
authorInfo.append(String.format(" <last-name>%s</last-name>\n", lastName));
}
if (nickname != null) {
authorInfo.append(String.format(" <nickname>%s</nickname>\n", nickname));
}
return String.format("""
<?xml version="1.0" encoding="UTF-8"?>
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0">
<description>
<title-info>
<genre>fiction</genre>
<author>
%s </author>
<book-title>Book with Complex Author</book-title>
<lang>en</lang>
</title-info>
</description>
<body>
<section>
<p>Content</p>
</section>
</body>
</FictionBook>
""", authorInfo);
}
private String createFb2WithCover(byte[] imageData) {
String base64Image = Base64.getEncoder().encodeToString(imageData);
return String.format("""
<?xml version="1.0" encoding="UTF-8"?>
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:xlink="http://www.w3.org/1999/xlink">
<description>
<title-info>
<genre>fiction</genre>
<author>
<first-name>Test</first-name>
<last-name>Author</last-name>
</author>
<book-title>Book with Cover</book-title>
<coverpage>
<image xlink:href="#cover.jpg"/>
</coverpage>
<lang>en</lang>
</title-info>
</description>
<body>
<section>
<p>Content</p>
</section>
</body>
<binary id="cover.jpg" content-type="image/jpeg">%s</binary>
</FictionBook>
""", base64Image);
}
private byte[] createMinimalPngImage() {
return new byte[]{
(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
0x00, 0x00, 0x00, 0x0D,
0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01,
0x00, 0x00, 0x00, 0x01,
0x08, 0x06,
0x00, 0x00, 0x00,
(byte) 0x90, (byte) 0x77, (byte) 0x53, (byte) 0xDE,
0x00, 0x00, 0x00, 0x0A,
0x49, 0x44, 0x41, 0x54,
0x78, (byte) 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05,
0x00, 0x01,
0x0D, (byte) 0x0A, 0x2D, (byte) 0xB4,
0x00, 0x00, 0x00, 0x00,
0x49, 0x45, 0x4E, 0x44,
(byte) 0xAE, 0x42, 0x60, (byte) 0x82
};
}
private File createFb2File(String content) throws IOException {
File fb2File = tempDir.resolve("test-" + System.nanoTime() + ".fb2").toFile();
try (FileOutputStream fos = new FileOutputStream(fb2File)) {
fos.write(content.getBytes(StandardCharsets.UTF_8));
}
return fb2File;
}
}

View File

@@ -38,7 +38,7 @@
<p-button [rounded]="true" icon="pi pi-info" class="info-btn" (click)="openBookInfo(book)"></p-button>
}
<p-button [hidden]="isSeriesViewActive()" [rounded]="true" icon="pi pi-book" class="read-btn" (click)="readBook(book)"></p-button>
<p-button [hidden]="isSeriesViewActive() || !canReadBook()" [rounded]="true" icon="pi pi-book" class="read-btn" (click)="readBook(book)"></p-button>
@if (isCheckboxEnabled) {
<p-checkbox

View File

@@ -618,6 +618,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
case 'epub':
case 'mobi':
case 'azw3':
case 'fb2':
return 'pi pi-book';
case 'cbz':
case 'cbr':
@@ -641,6 +642,10 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
return this.isAdmin() || (this.userPermissions?.canEditMetadata ?? false);
}
canReadBook(): boolean {
return this.book?.bookType !== 'FB2';
}
private hasDownloadPermission(): boolean {
return this.isAdmin() || (this.userPermissions?.canDownload ?? false);
}

View File

@@ -3,7 +3,7 @@ import {CbxBackgroundColor, CbxFitMode, CbxPageSpread, CbxPageViewMode, CbxScrol
import {BookReview} from '../components/book-reviews/book-review-service';
import {ZoomType} from 'ngx-extended-pdf-viewer';
export type BookType = "PDF" | "EPUB" | "CBX";
export type BookType = "PDF" | "EPUB" | "CBX" | "FB2";
export enum AdditionalFileType {
ALTERNATIVE_FORMAT = 'ALTERNATIVE_FORMAT',

View File

@@ -1,7 +1,7 @@
import {SortOption} from './sort.model';
export type LibraryScanMode = 'FILE_AS_BOOK' | 'FOLDER_AS_BOOK';
export type BookFileType = 'PDF' | 'EPUB' | 'CBX';
export type BookFileType = 'PDF' | 'EPUB' | 'CBX' | 'FB2';
export interface Library {
id?: number;

View File

@@ -459,7 +459,7 @@
<p-splitbutton label="Read" icon="pi pi-book" [model]="readItems" (onClick)="read(book.id, 'ngx')" severity="primary"/>
}
}
@if (book!.bookType !== 'PDF') {
@if (book!.bookType !== 'PDF' && book!.bookType !== 'FB2') {
<p-button label="Read" icon="pi pi-book" (onClick)="read(book?.metadata!.bookId, undefined)" severity="primary"/>
}
<p-button label="Shelf" icon="pi pi-folder" severity="secondary" outlined (onClick)="assignShelf(book.id)"></p-button>

View File

@@ -89,7 +89,7 @@
[maxFileSize]="maxFileSizeBytes"
[customUpload]="true"
[multiple]="true"
accept=".pdf,.epub,.cbz,.cbr,.cb7"
accept=".pdf,.epub,.cbz,.cbr,.cb7,.fb2"
(onSelect)="onFilesSelect($event)"
(uploadHandler)="uploadFiles($event)"
[disabled]="value === 'library' ? (!selectedLibrary || !selectedPath) : false">
@@ -176,7 +176,7 @@
</div>
<h3 class="empty-title">Drag and Drop Files</h3>
<p class="empty-description">
Supported formats: <strong>.pdf</strong>, <strong>.epub</strong>, <strong>.cbz</strong>, <strong>.cbr</strong>, <strong>.cb7</strong>
Supported formats: <strong>.pdf</strong>, <strong>.epub</strong>, <strong>.cbz</strong>, <strong>.cbr</strong>, <strong>.cb7</strong>, <strong>.fb2</strong>
<br/>
Maximum file size: 100 MB per file
</p>