Merge pull request #1472 from booklore-app/develop

Merge develop into master for release
This commit is contained in:
Aditya Chandel
2025-10-28 12:42:10 -06:00
committed by GitHub
66 changed files with 1411 additions and 253 deletions

View File

@@ -7,7 +7,6 @@
[![Join us on Discord](https://img.shields.io/badge/Chat-Discord-5865F2?logo=discord&style=flat)](https://discord.gg/Ee5hd458Uz)
[![Open Collective backers and sponsors](https://img.shields.io/opencollective/all/booklore?label=Open%20Collective&logo=opencollective&color=7FADF2)](https://opencollective.com/booklore)
[![Venmo](https://img.shields.io/badge/Venmo-Donate-008CFF?logo=venmo)](https://venmo.com/AdityaChandel)
> 🚨 **Important Announcement:**
> Docker images have moved to new repositories:
> - Docker Hub: `https://hub.docker.com/r/booklore/booklore`
@@ -230,11 +229,10 @@ For detailed setup instructions and configuration examples:
- ✨ Want to contribute? [Check out CONTRIBUTING.md](https://github.com/adityachandelgit/BookLore/blob/master/CONTRIBUTING.md)
- 💬 **Join our Discord**: [Click here to chat with the community](https://discord.gg/Ee5hd458Uz)
## 👨‍💻 Contributors & Developers
## 📊 Repository Activity
Thanks to all the amazing people who contribute to Booklore.
![Alt](https://repobeats.axiom.co/api/embed/44a04220bfc5136e7064181feb07d5bf0e59e27e.svg "Repobeats analytics image")
[![Contributors List](https://contrib.rocks/image?repo=adityachandelgit/BookLore)](https://github.com/adityachandelgit/BookLore/graphs/contributors)
## ⭐ Star History
@@ -246,6 +244,12 @@ Thanks to all the amazing people who contribute to Booklore.
</picture>
</a>
## 👨‍💻 Contributors & Developers
Thanks to all the amazing people who contribute to Booklore.
[![Contributors List](https://contrib.rocks/image?repo=adityachandelgit/BookLore)](https://github.com/adityachandelgit/BookLore/graphs/contributors)
## ⚖️ License
* [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)

View File

@@ -15,12 +15,14 @@ import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.Objects;
import java.util.regex.Pattern;
@Aspect
@Component
@RequiredArgsConstructor
public class BookAccessAspect {
private static final Pattern NUMERIC_PATTERN = Pattern.compile("\\d+");
private final AuthenticationService authenticationService;
private final BookRepository bookRepository;
@@ -60,7 +62,7 @@ public class BookAccessAspect {
Object arg = args[i];
if (arg instanceof Long) {
return (Long) arg;
} else if (arg instanceof String str && str.matches("\\d+")) {
} else if (arg instanceof String str && NUMERIC_PATTERN.matcher(str).matches()) {
return Long.valueOf(str);
}
}

View File

@@ -13,12 +13,14 @@ import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.Objects;
import java.util.regex.Pattern;
@Aspect
@Component
@RequiredArgsConstructor
public class LibraryAccessAspect {
private static final Pattern NUMERIC_PATTERN = Pattern.compile("\\d+");
private final AuthenticationService authenticationService;
@Before("@annotation(com.adityachandel.booklore.config.security.annotation.CheckLibraryAccess)")
@@ -52,7 +54,7 @@ public class LibraryAccessAspect {
Object arg = args[i];
if (arg instanceof Long) {
return (Long) arg;
} else if (arg instanceof String str && str.matches("\\d+")) {
} else if (arg instanceof String str && NUMERIC_PATTERN.matcher(str).matches()) {
return Long.parseLong(str);
}
}

View File

@@ -75,6 +75,11 @@ public class WebSocketAuthInterceptor implements ChannelInterceptor {
log.debug("Username extracted from JWT is null or empty");
}
if (!appSettingService.getAppSettings().isOidcEnabled()) {
log.debug("OIDC is disabled, skipping OIDC token validation");
return null;
}
JWTClaimsSet claims = dynamicOidcJwtProcessor.getProcessor().process(token, null);
if (claims == null) {
log.debug("OIDC token processing returned null claims");

View File

@@ -30,6 +30,7 @@ import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
@Slf4j
@RequiredArgsConstructor
@@ -38,6 +39,7 @@ import java.util.Set;
@Tag(name = "Kobo Integration", description = "Endpoints for Kobo device and library integration")
public class KoboController {
private static final Pattern KOBO_V1_PRODUCTS_NEXTREAD_PATTERN = Pattern.compile(".*/v1/products/\\d+/nextread.*");
private String token;
private final KoboServerProxy koboServerProxy;
private final KoboInitializationService koboInitializationService;
@@ -195,7 +197,7 @@ public class KoboController {
if (path.contains("/v1/analytics/event")) {
return ResponseEntity.ok().build();
}
if (path.matches(".*/v1/products/\\d+/nextread.*")) {
if (KOBO_V1_PRODUCTS_NEXTREAD_PATTERN.matcher(path).matches()) {
return ResponseEntity.ok().build();
}
return koboServerProxy.proxyCurrentRequest(body, false);

View File

@@ -7,10 +7,13 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import java.util.regex.Pattern;
@Mapper(componentModel = "spring")
public interface KoboReadingStateMapper {
ObjectMapper objectMapper = new ObjectMapper();
Pattern PATTERN = Pattern.compile("^\"|\"$");
@Mapping(target = "currentBookmarkJson", expression = "java(toJson(dto.getCurrentBookmark()))")
@Mapping(target = "statisticsJson", expression = "java(toJson(dto.getStatistics()))")
@@ -48,6 +51,6 @@ public interface KoboReadingStateMapper {
default String cleanString(String value) {
if (value == null) return null;
return value.replaceAll("^\"|\"$", "");
return PATTERN.matcher(value).replaceAll("");
}
}

View File

@@ -62,10 +62,8 @@ public class BookLoreUserTransformer {
case SIDEBAR_LIBRARY_SORTING -> userSettings.setSidebarLibrarySorting(objectMapper.readValue(value, SidebarSortOption.class));
case SIDEBAR_SHELF_SORTING -> userSettings.setSidebarShelfSorting(objectMapper.readValue(value, SidebarSortOption.class));
case ENTITY_VIEW_PREFERENCES -> userSettings.setEntityViewPreferences(objectMapper.readValue(value, BookLoreUser.UserSettings.EntityViewPreferences.class));
case TABLE_COLUMN_PREFERENCE -> userSettings.setTableColumnPreference(
objectMapper.readValue(value, new TypeReference<>() {
})
);
case TABLE_COLUMN_PREFERENCE -> userSettings.setTableColumnPreference(objectMapper.readValue(value, new TypeReference<>() {}));
case DASHBOARD_CONFIG -> userSettings.setDashboardConfig(objectMapper.readValue(value, BookLoreUser.UserSettings.DashboardConfig.class));
}
} else {
switch (settingKey) {

View File

@@ -52,6 +52,7 @@ public class BookLoreUser {
public String filterSortingMode;
public String metadataCenterViewMode;
public boolean koReaderEnabled;
public DashboardConfig dashboardConfig;
@Data
@Builder
@@ -162,5 +163,29 @@ public class BookLoreUser {
Global, Individual
}
}
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public static class DashboardConfig {
private List<ScrollerConfig> scrollers;
}
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public static class ScrollerConfig {
private String id;
private String type;
private String title;
private boolean enabled;
private int order;
private int maxItems;
private Long magicShelfId;
private String sortField;
private String sortDirection;
}
}
}

View File

@@ -17,6 +17,7 @@ import lombok.NoArgsConstructor;
public class TaskCreateRequest {
private String taskId;
private TaskType taskType;
private boolean triggeredByCron = false;
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "taskType", include = JsonTypeInfo.As.EXTERNAL_PROPERTY)
@JsonSubTypes({

View File

@@ -13,6 +13,7 @@ public enum UserSettingKey {
SIDEBAR_SHELF_SORTING("sidebarShelfSorting", true),
ENTITY_VIEW_PREFERENCES("entityViewPreferences", true),
TABLE_COLUMN_PREFERENCE("tableColumnPreference", true),
DASHBOARD_CONFIG("dashboardConfig", true),
FILTER_SORTING_MODE("filterSortingMode", false),
METADATA_CENTER_VIEW_MODE("metadataCenterViewMode", false);

View File

@@ -61,6 +61,9 @@ public class BookLoreUserEntity {
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
private Set<UserSettingEntity> settings = new HashSet<>();
@OneToOne(mappedBy = "bookLoreUser", cascade = CascadeType.ALL, orphanRemoval = true)
private KoreaderUserEntity koreaderUser;
@PrePersist
public void prePersist() {
this.createdAt = LocalDateTime.now();

View File

@@ -36,7 +36,7 @@ public class KoreaderUserEntity {
@Column(name = "sync_enabled", nullable = false)
private boolean syncEnabled = false;
@ManyToOne(fetch = FetchType.LAZY)
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "booklore_user_id")
private BookLoreUserEntity bookLoreUser;

View File

@@ -110,6 +110,11 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
@Query("DELETE FROM BookEntity b WHERE b.deleted IS TRUE")
int deleteAllSoftDeleted();
@Modifying
@Transactional
@Query("DELETE FROM BookEntity b WHERE b.deleted IS TRUE AND b.deletedAt < :cutoffDate")
int deleteSoftDeletedBefore(@Param("cutoffDate") Instant cutoffDate);
@Query("SELECT COUNT(b) FROM BookEntity b WHERE b.deleted = TRUE")
long countAllSoftDeleted();

View File

@@ -2,6 +2,7 @@ package com.adityachandel.booklore.repository;
import com.adityachandel.booklore.model.entity.MetadataFetchJobEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
@@ -13,6 +14,10 @@ public interface MetadataFetchJobRepository extends JpaRepository<MetadataFetchJ
int deleteAllByCompletedAtBefore(Instant cutoff);
@Modifying
@Query("DELETE FROM MetadataFetchJobEntity")
int deleteAllRecords();
@Query("SELECT COUNT(m) FROM MetadataFetchJobEntity m")
long countAll();

View File

@@ -28,6 +28,7 @@ import java.awt.image.BufferedImage;
import java.io.*;
import java.time.Instant;
import java.util.*;
import java.util.regex.Pattern;
import static com.adityachandel.booklore.util.FileService.truncate;
@@ -36,6 +37,10 @@ import static com.adityachandel.booklore.util.FileService.truncate;
@Service
public class CbxProcessor extends AbstractFileProcessor implements BookFileProcessor {
private static final Pattern UNDERSCORE_HYPHEN_PATTERN = Pattern.compile("[_\\-]");
private static final Pattern IMAGE_EXTENSION_PATTERN = Pattern.compile(".*\\.(jpg|jpeg|png|webp)");
private static final Pattern IMAGE_EXTENSION_CASE_INSENSITIVE_PATTERN = Pattern.compile("(?i).*\\.(jpg|jpeg|png|webp)");
private static final Pattern CBX_FILE_EXTENSION_PATTERN = Pattern.compile("(?i)\\.cb[rz7]$");
private final BookMetadataRepository bookMetadataRepository;
private final CbxMetadataExtractor cbxMetadataExtractor;
@@ -104,7 +109,7 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce
private Optional<BufferedImage> extractFirstImageFromZip(File file) {
try (ZipFile zipFile = new ZipFile(file)) {
return Collections.list(zipFile.getEntries()).stream()
.filter(e -> !e.isDirectory() && e.getName().matches("(?i).*\\.(jpg|jpeg|png|webp)"))
.filter(e -> !e.isDirectory() && IMAGE_EXTENSION_CASE_INSENSITIVE_PATTERN.matcher(e.getName()).matches())
.min(Comparator.comparing(ZipArchiveEntry::getName))
.map(entry -> {
try (InputStream is = zipFile.getInputStream(entry)) {
@@ -125,7 +130,7 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce
List<SevenZArchiveEntry> imageEntries = new ArrayList<>();
SevenZArchiveEntry entry;
while ((entry = sevenZFile.getNextEntry()) != null) {
if (!entry.isDirectory() && entry.getName().matches("(?i).*\\.(jpg|jpeg|png|webp)")) {
if (!entry.isDirectory() && IMAGE_EXTENSION_CASE_INSENSITIVE_PATTERN.matcher(entry.getName()).matches()) {
imageEntries.add(entry);
}
}
@@ -157,7 +162,7 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce
private Optional<BufferedImage> extractFirstImageFromRar(File file) {
try (Archive archive = new Archive(file)) {
List<FileHeader> imageHeaders = archive.getFileHeaders().stream()
.filter(h -> !h.isDirectory() && h.getFileNameString().toLowerCase().matches(".*\\.(jpg|jpeg|png|webp)"))
.filter(h -> !h.isDirectory() && IMAGE_EXTENSION_PATTERN.matcher(h.getFileNameString().toLowerCase()).matches())
.sorted(Comparator.comparing(FileHeader::getFileNameString))
.toList();
@@ -210,9 +215,7 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce
private void setMetadata(BookEntity bookEntity) {
String baseName = new File(bookEntity.getFileName()).getName();
String title = baseName
.replaceAll("(?i)\\.cb[rz7]$", "")
.replaceAll("[_\\-]", " ")
String title = UNDERSCORE_HYPHEN_PATTERN.matcher(CBX_FILE_EXTENSION_PATTERN.matcher(baseName).replaceAll("")).replaceAll(" ")
.trim();
bookEntity.getMetadata().setTitle(truncate(title, 1000));
}

View File

@@ -21,12 +21,14 @@ import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@AllArgsConstructor
@Service
public class KoboEntitlementService {
private static final Pattern NON_ALPHANUMERIC_LOWERCASE_PATTERN = Pattern.compile("[^a-z0-9]");
private final KoboUrlBuilder koboUrlBuilder;
private final BookQueryService bookQueryService;
private final AppSettingService appSettingService;
@@ -167,7 +169,7 @@ public class KoboEntitlementService {
.isbn(metadata.getIsbn13() != null ? metadata.getIsbn13() : metadata.getIsbn10())
.genre(categories.isEmpty() ? null : categories.getFirst())
.slug(metadata.getTitle() != null
? metadata.getTitle().toLowerCase().replaceAll("[^a-z0-9]", "-")
? NON_ALPHANUMERIC_LOWERCASE_PATTERN.matcher(metadata.getTitle().toLowerCase()).replaceAll("-")
: null)
.coverImageId(String.valueOf(metadata.getBookId()))
.workId(String.valueOf(book.getId()))

View File

@@ -29,12 +29,14 @@ import java.time.Duration;
import java.util.Arrays;
import java.util.Collections;
import java.util.Set;
import java.util.regex.Pattern;
@Slf4j
@Component
@RequiredArgsConstructor
public class KoboServerProxy {
private static final Pattern KOBO_API_PREFIX_PATTERN = Pattern.compile("^/api/kobo/[^/]+");
private final HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofMinutes(1)).build();
private final ObjectMapper objectMapper;
private final BookloreSyncTokenGenerator bookloreSyncTokenGenerator;
@@ -56,7 +58,7 @@ public class KoboServerProxy {
public ResponseEntity<JsonNode> proxyCurrentRequest(Object body, boolean includeSyncToken) {
HttpServletRequest request = RequestUtils.getCurrentRequest();
String path = request.getRequestURI().replaceFirst("^/api/kobo/[^/]+", "");
String path = KOBO_API_PREFIX_PATTERN.matcher(request.getRequestURI()).replaceFirst("");
BookloreSyncToken syncToken = null;
if (includeSyncToken) {

View File

@@ -16,6 +16,7 @@ import java.util.Arrays;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.List;
@@ -39,7 +40,10 @@ import org.w3c.dom.NodeList;
@Component
public class CbxMetadataExtractor implements FileMetadataExtractor {
@Override
private static final Pattern LEADING_ZEROS_PATTERN = Pattern.compile("^0+");
private static final Pattern COMMA_SEMICOLON_PATTERN = Pattern.compile("[,;]");
@Override
public BookMetadata extractMetadata(File file) {
String baseName = FilenameUtils.getBaseName(file.getName());
String lowerName = file.getName().toLowerCase();
@@ -215,7 +219,7 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
if (value == null) {
return new HashSet<>();
}
return Arrays.stream(value.split("[,;]"))
return Arrays.stream(COMMA_SEMICOLON_PATTERN.split(value))
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toSet());
@@ -777,8 +781,8 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
if (Character.isDigit(c1) && Character.isDigit(c2)) {
int i1 = i; while (i1 < n1 && Character.isDigit(s1.charAt(i1))) i1++;
int j1 = j; while (j1 < n2 && Character.isDigit(s2.charAt(j1))) j1++;
String num1 = s1.substring(i, i1).replaceFirst("^0+", "");
String num2 = s2.substring(j, j1).replaceFirst("^0+", "");
String num1 = LEADING_ZEROS_PATTERN.matcher(s1.substring(i, i1)).replaceFirst("");
String num2 = LEADING_ZEROS_PATTERN.matcher(s2.substring(j, j1)).replaceFirst("");
int cmp = Integer.compare(num1.isEmpty() ? 0 : Integer.parseInt(num1), num2.isEmpty() ? 0 : Integer.parseInt(num2));
if (cmp != 0) return cmp;
i = i1; j = j1;

View File

@@ -33,6 +33,7 @@ import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.*;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@Component
@@ -40,6 +41,9 @@ import java.util.stream.Collectors;
public class PdfMetadataExtractor implements FileMetadataExtractor {
private static final Pattern COMMA_AMPERSAND_PATTERN = Pattern.compile("[,&]");
private static final Pattern ISBN_CLEANUP_PATTERN = Pattern.compile("[^0-9Xx]");
@Override
public byte[] extractCover(File file) {
try (PDDocument pdf = Loader.loadPDF(file)) {
@@ -139,7 +143,7 @@ public class PdfMetadataExtractor implements FileMetadataExtractor {
if (!identifiers.isEmpty()) {
String isbn = identifiers.get("isbn");
if (StringUtils.isNotBlank(isbn)) {
isbn = isbn.replaceAll("[^0-9Xx]", "");
isbn = ISBN_CLEANUP_PATTERN.matcher(isbn).replaceAll("");
if (isbn.length() == 10) {
metadataBuilder.isbn10(isbn);
} else if (isbn.length() == 13) {
@@ -273,7 +277,7 @@ public class PdfMetadataExtractor implements FileMetadataExtractor {
private Set<String> parseAuthors(String authorString) {
if (authorString == null) return Collections.emptySet();
return Arrays.stream(authorString.split("[,&]"))
return Arrays.stream(COMMA_AMPERSAND_PATTERN.split(authorString))
.map(String::trim)
.filter(StringUtils::isNotBlank)
.collect(Collectors.toSet());

View File

@@ -34,6 +34,12 @@ public class AmazonBookParser implements BookParser {
private static final int COUNT_DETAILED_METADATA_TO_GET = 3;
private static final String BASE_BOOK_URL_SUFFIX = "/dp/";
private static final Pattern NON_DIGIT_PATTERN = Pattern.compile("[^\\d]");
private static final Pattern SERIES_FORMAT_PATTERN = Pattern.compile("Book \\d+ of \\d+");
private static final Pattern SERIES_FORMAT_WITH_DECIMAL_PATTERN = Pattern.compile("Book \\d+(\\.\\d+)? of \\d+");
private static final Pattern PARENTHESES_WITH_WHITESPACE_PATTERN = Pattern.compile("\\s*\\(.*?\\)");
private static final Pattern NON_ALPHANUMERIC_PATTERN = Pattern.compile("[^a-zA-Z0-9]");
private static final Pattern DP_SEPARATOR_PATTERN = Pattern.compile("/dp/");
private final AppSettingService appSettingService;
@Override
@@ -142,7 +148,7 @@ public class AmazonBookParser implements BookParser {
}
private String extractAsinFromUrl(String url) {
String[] parts = url.split("/dp/");
String[] parts = DP_SEPARATOR_PATTERN.split(url);
if (parts.length > 1) {
String[] asinParts = parts[1].split("/");
return asinParts[0];
@@ -207,7 +213,7 @@ public class AmazonBookParser implements BookParser {
String title = fetchMetadataRequest.getTitle();
if (title != null && !title.isEmpty()) {
String cleanedTitle = Arrays.stream(title.split(" "))
.map(word -> word.replaceAll("[^a-zA-Z0-9]", "").trim())
.map(word -> NON_ALPHANUMERIC_PATTERN.matcher(word).replaceAll("").trim())
.filter(word -> !word.isEmpty())
.collect(Collectors.joining(" "));
searchTerm.append(cleanedTitle);
@@ -215,7 +221,7 @@ public class AmazonBookParser implements BookParser {
String filename = BookUtils.cleanAndTruncateSearchTerm(BookUtils.cleanFileName(book.getFileName()));
if (!filename.isEmpty()) {
String cleanedFilename = Arrays.stream(filename.split(" "))
.map(word -> word.replaceAll("[^a-zA-Z0-9]", "").trim())
.map(word -> NON_ALPHANUMERIC_PATTERN.matcher(word).replaceAll("").trim())
.filter(word -> !word.isEmpty())
.collect(Collectors.joining(" "));
searchTerm.append(cleanedFilename);
@@ -228,7 +234,7 @@ public class AmazonBookParser implements BookParser {
searchTerm.append(" ");
}
String cleanedAuthor = Arrays.stream(author.split(" "))
.map(word -> word.replaceAll("[^a-zA-Z0-9]", "").trim())
.map(word -> NON_ALPHANUMERIC_PATTERN.matcher(word).replaceAll("").trim())
.filter(word -> !word.isEmpty())
.collect(Collectors.joining(" "));
searchTerm.append(cleanedAuthor);
@@ -339,7 +345,7 @@ public class AmazonBookParser implements BookParser {
Element publisherSpan = boldText.nextElementSibling();
if (publisherSpan != null) {
String fullPublisher = publisherSpan.text().trim();
return fullPublisher.split(";")[0].trim().replaceAll("\\s*\\(.*?\\)", "").trim();
return PARENTHESES_WITH_WHITESPACE_PATTERN.matcher(fullPublisher.split(";")[0].trim()).replaceAll("").trim();
}
}
}
@@ -384,7 +390,7 @@ public class AmazonBookParser implements BookParser {
Element bookDetailsLabel = doc.selectFirst("#rpi-attribute-book_details-series .rpi-attribute-label span");
if (bookDetailsLabel != null) {
String bookAndTotal = bookDetailsLabel.text();
if (bookAndTotal.matches("Book \\d+(\\.\\d+)? of \\d+")) {
if (SERIES_FORMAT_WITH_DECIMAL_PATTERN.matcher(bookAndTotal).matches()) {
String[] parts = bookAndTotal.split(" ");
return Float.parseFloat(parts[1]);
}
@@ -402,7 +408,7 @@ public class AmazonBookParser implements BookParser {
Element bookDetailsLabel = doc.selectFirst("#rpi-attribute-book_details-series .rpi-attribute-label span");
if (bookDetailsLabel != null) {
String bookAndTotal = bookDetailsLabel.text();
if (bookAndTotal.matches("Book \\d+ of \\d+")) {
if (SERIES_FORMAT_PATTERN.matcher(bookAndTotal).matches()) {
String[] parts = bookAndTotal.split(" ");
return Integer.parseInt(parts[3]);
}
@@ -575,7 +581,7 @@ public class AmazonBookParser implements BookParser {
Element reviewCountElement = reviewDiv.getElementById("acrCustomerReviewText");
if (reviewCountElement != null) {
String reviewCountRaw = reviewCountElement.text().split(" ")[0];
String reviewCountClean = reviewCountRaw.replaceAll("[^\\d]", "");
String reviewCountClean = NON_DIGIT_PATTERN.matcher(reviewCountRaw).replaceAll("");
if (!reviewCountClean.isEmpty()) {
return Integer.parseInt(reviewCountClean);
}
@@ -613,7 +619,7 @@ public class AmazonBookParser implements BookParser {
String pageCountText = pageCountElements.first().text();
if (!pageCountText.isEmpty()) {
try {
String cleanedPageCount = pageCountText.replaceAll("[^\\d]", "");
String cleanedPageCount = NON_DIGIT_PATTERN.matcher(pageCountText).replaceAll("");
return Integer.parseInt(cleanedPageCount);
} catch (NumberFormatException e) {
log.warn("Error parsing page count: {}, error: {}", pageCountText, e.getMessage());

View File

@@ -38,6 +38,9 @@ public class DoubanBookParser implements BookParser {
private static final int COUNT_DETAILED_METADATA_TO_GET = 3;
private static final String BASE_BOOK_URL = "https://book.douban.com/subject/";
private static final Pattern NON_DIGIT_PATTERN = Pattern.compile("[^\\d]");
private static final Pattern NON_ALPHANUMERIC_CJK_PATTERN = Pattern.compile("[^a-zA-Z0-9\\u4e00-\\u9fff]");
private static final Pattern SLASH_SEPARATOR_PATTERN = Pattern.compile(" / ");
private final AppSettingService appSettingService;
@Override
@@ -173,7 +176,7 @@ public class DoubanBookParser implements BookParser {
if (abstractText != null && !abstractText.isEmpty()) {
// Parse abstract: "author0 / author1 / author 2 / ... / publisher / date (YYYY-MM or YYYY-MM-DD) / price"
String[] parts = abstractText.split(" / ");
String[] parts = SLASH_SEPARATOR_PATTERN.split(abstractText);
if (parts.length >= 4) {
// Authors are all parts except the last three (publisher, date, price)
authors = Arrays.stream(parts, 0, parts.length - 3)
@@ -297,7 +300,7 @@ public class DoubanBookParser implements BookParser {
String title = fetchMetadataRequest.getTitle();
if (title != null && !title.isEmpty()) {
String cleanedTitle = Arrays.stream(title.split(" "))
.map(word -> word.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fff]", "").trim())
.map(word -> NON_ALPHANUMERIC_CJK_PATTERN.matcher(word).replaceAll("").trim())
.filter(word -> !word.isEmpty())
.collect(Collectors.joining(" "));
searchTerm.append(cleanedTitle);
@@ -305,7 +308,7 @@ public class DoubanBookParser implements BookParser {
String filename = BookUtils.cleanAndTruncateSearchTerm(BookUtils.cleanFileName(book.getFileName()));
if (!filename.isEmpty()) {
String cleanedFilename = Arrays.stream(filename.split(" "))
.map(word -> word.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fff]", "").trim())
.map(word -> NON_ALPHANUMERIC_CJK_PATTERN.matcher(word).replaceAll("").trim())
.filter(word -> !word.isEmpty())
.collect(Collectors.joining(" "));
searchTerm.append(cleanedFilename);
@@ -318,7 +321,7 @@ public class DoubanBookParser implements BookParser {
searchTerm.append(" ");
}
String cleanedAuthor = Arrays.stream(author.split(" "))
.map(word -> word.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fff]", "").trim())
.map(word -> NON_ALPHANUMERIC_CJK_PATTERN.matcher(word).replaceAll("").trim())
.filter(word -> !word.isEmpty())
.collect(Collectors.joining(" "));
searchTerm.append(cleanedAuthor);
@@ -408,7 +411,7 @@ public class DoubanBookParser implements BookParser {
Node next = span.nextSibling();
if (next instanceof TextNode) {
String isbn = ((TextNode) next).text().trim();
String digits = isbn.replaceAll("[^\\d]", "");
String digits = NON_DIGIT_PATTERN.matcher(isbn).replaceAll("");
if (digits.length() == 10) {
return digits;
}
@@ -431,7 +434,7 @@ public class DoubanBookParser implements BookParser {
Node next = span.nextSibling();
if (next instanceof TextNode) {
String isbn = ((TextNode) next).text().trim();
String digits = isbn.replaceAll("[^\\d]", "");
String digits = NON_DIGIT_PATTERN.matcher(isbn).replaceAll("");
if (digits.length() == 13) {
return digits;
}
@@ -623,7 +626,7 @@ public class DoubanBookParser implements BookParser {
try {
Element reviewCountElement = doc.selectFirst("#interest_sectl .rating_people span");
if (reviewCountElement != null) {
String reviewCountRaw = reviewCountElement.text().replaceAll("[^\\d]", "");
String reviewCountRaw = NON_DIGIT_PATTERN.matcher(reviewCountElement.text()).replaceAll("");
if (!reviewCountRaw.isEmpty()) {
return Integer.parseInt(reviewCountRaw);
}
@@ -659,7 +662,7 @@ public class DoubanBookParser implements BookParser {
String pageText = ((TextNode) next).text().trim();
if (!pageText.isEmpty()) {
try {
return Integer.parseInt(pageText.replaceAll("[^\\d]", ""));
return Integer.parseInt(NON_DIGIT_PATTERN.matcher(pageText).replaceAll(""));
} catch (NumberFormatException e) {
log.warn("Error parsing page count: {}", pageText);
}

View File

@@ -39,6 +39,7 @@ public class GoodReadsParser implements BookParser {
private static final String BASE_BOOK_URL = "https://www.goodreads.com/book/show/";
private static final String BASE_ISBN_URL = "https://www.goodreads.com/book/isbn/";
private static final int COUNT_DETAILED_METADATA_TO_GET = 3;
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
private final AppSettingService appSettingService;
@Override
@@ -447,9 +448,9 @@ public class GoodReadsParser implements BookParser {
// Author fuzzy match if author provided
if (queryAuthor != null && !queryAuthor.isBlank()) {
List<String> queryAuthorTokens = List.of(queryAuthor.toLowerCase().split("\\s+"));
List<String> queryAuthorTokens = List.of(WHITESPACE_PATTERN.split(queryAuthor.toLowerCase()));
boolean matches = authors.stream()
.flatMap(a -> Arrays.stream(a.toLowerCase().split("\\s+")))
.flatMap(a -> Arrays.stream(WHITESPACE_PATTERN.split(a.toLowerCase())))
.anyMatch(actual -> {
for (String query : queryAuthorTokens) {
int score = fuzzyScore.fuzzyScore(actual, query);

View File

@@ -23,6 +23,7 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@Slf4j
@@ -30,7 +31,11 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public class GoogleParser implements BookParser {
private static final Pattern FOUR_DIGIT_YEAR_PATTERN = Pattern.compile("\\d{4}");
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
private static final Pattern SPECIAL_CHARACTERS_PATTERN = Pattern.compile("[.,\\-\\[\\]{}()!@#$%^&*_=+|~`<>?/\";:]");
private final ObjectMapper objectMapper;
private final HttpClient httpClient = HttpClient.newHttpClient();
private static final String GOOGLE_BOOKS_API_URL = "https://www.googleapis.com/books/v1/volumes";
@Override
@@ -57,13 +62,12 @@ public class GoogleParser implements BookParser {
log.info("Google Books API URL (ISBN): {}", uri);
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(uri)
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
return parseGoogleBooksApiResponse(response.body());
@@ -87,13 +91,12 @@ public class GoogleParser implements BookParser {
log.info("Google Books API URL: {}", uri);
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(uri)
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
return parseGoogleBooksApiResponse(response.body());
@@ -171,7 +174,7 @@ public class GoogleParser implements BookParser {
.orElse(null));
if (searchTerm != null) {
searchTerm = searchTerm.replaceAll("[.,\\-\\[\\]{}()!@#$%^&*_=+|~`<>?/\";:]", "").trim();
searchTerm = SPECIAL_CHARACTERS_PATTERN.matcher(searchTerm).replaceAll("").trim();
searchTerm = truncateToMaxLength(searchTerm, 60);
}
@@ -183,7 +186,7 @@ public class GoogleParser implements BookParser {
}
private String truncateToMaxLength(String input, int maxLength) {
String[] words = input.split("\\s+");
String[] words = WHITESPACE_PATTERN.split(input);
StringBuilder truncated = new StringBuilder();
for (String word : words) {
@@ -197,7 +200,7 @@ public class GoogleParser implements BookParser {
public LocalDate parseDate(String input) {
try {
if (input.matches("\\d{4}")) {
if (FOUR_DIGIT_YEAR_PATTERN.matcher(input).matches()) {
return LocalDate.of(Integer.parseInt(input), 1, 1);
}
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");

View File

@@ -18,6 +18,7 @@ import java.time.LocalDate;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@Slf4j
@@ -25,6 +26,7 @@ import java.util.stream.Collectors;
@AllArgsConstructor
public class HardcoverParser implements BookParser {
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
private final HardcoverBookSearchService hardcoverBookSearchService;
@Override
@@ -57,9 +59,9 @@ public class HardcoverParser implements BookParser {
if (doc.getAuthorNames() == null || doc.getAuthorNames().isEmpty()) return false;
List<String> actualAuthorTokens = doc.getAuthorNames().stream()
.flatMap(name -> List.of(name.toLowerCase().split("\\s+")).stream())
.flatMap(name -> List.of(WHITESPACE_PATTERN.split(name.toLowerCase())).stream())
.toList();
List<String> searchAuthorTokens = List.of(searchAuthor.toLowerCase().split("\\s+"));
List<String> searchAuthorTokens = List.of(WHITESPACE_PATTERN.split(searchAuthor.toLowerCase()));
for (String actual : actualAuthorTokens) {
for (String query : searchAuthorTokens) {

View File

@@ -1,9 +1,13 @@
package com.adityachandel.booklore.service.metadata.parser;
import java.util.regex.Pattern;
public class ParserUtils {
private static final Pattern NON_DIGIT_PATTERN = Pattern.compile("[^0-9]");
public static String cleanIsbn(String isbn) {
if (isbn == null) return null;
return isbn.replaceAll("[^0-9]", "");
return NON_DIGIT_PATTERN.matcher(isbn).replaceAll("");
}
}

View File

@@ -31,6 +31,7 @@ import java.util.Comparator;
import java.util.Enumeration;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;
@@ -39,6 +40,8 @@ import java.util.zip.ZipOutputStream;
@Component
public class CbxMetadataWriter implements MetadataWriter {
private static final Pattern VALID_FILENAME_PATTERN = Pattern.compile("^[\\w./\\\\-]+$");
@Override
public void writeMetadataToFile(File file, BookMetadataEntity metadata, String thumbnailUrl, MetadataClearFlags clearFlags) {
Path backup = null;
@@ -462,7 +465,7 @@ public class CbxMetadataWriter implements MetadataWriter {
private boolean isSafeExecutable(String exec) {
if (exec == null || exec.isBlank()) return false;
// allow word chars, dot, slash, backslash, dash and underscore (no spaces or shell metas)
return exec.matches("^[\\w./\\\\-]+$");
return VALID_FILENAME_PATTERN.matcher(exec).matches();
}
private static String stripExtension(String filename) {

View File

@@ -55,8 +55,9 @@ public class EpubMetadataWriter implements MetadataWriter {
Path tempDir = null;
try {
tempDir = Files.createTempDirectory("epub_edit_" + UUID.randomUUID());
ZipFile zipFile = new ZipFile(epubFile);
zipFile.extractAll(tempDir.toString());
try (ZipFile zipFile = new ZipFile(epubFile)) {
zipFile.extractAll(tempDir.toString());
}
File opfFile = findOpfFile(tempDir.toFile());
if (opfFile == null) {
@@ -171,7 +172,9 @@ public class EpubMetadataWriter implements MetadataWriter {
transformer.transform(new DOMSource(opfDoc), new StreamResult(opfFile));
File tempEpub = new File(epubFile.getParentFile(), epubFile.getName() + ".tmp");
addFolderContentsToZip(new ZipFile(tempEpub), tempDir.toFile(), tempDir.toFile());
try (ZipFile tempZipFile = new ZipFile(tempEpub)) {
addFolderContentsToZip(tempZipFile, tempDir.toFile(), tempDir.toFile());
}
if (!epubFile.delete()) throw new IOException("Could not delete original EPUB");
if (!tempEpub.renameTo(epubFile)) throw new IOException("Could not rename temp EPUB");
@@ -260,7 +263,9 @@ public class EpubMetadataWriter implements MetadataWriter {
try {
File epubFile = new File(bookEntity.getFullFilePath().toUri());
tempDir = Files.createTempDirectory("epub_cover_" + UUID.randomUUID());
new ZipFile(epubFile).extractAll(tempDir.toString());
try (ZipFile zipFile = new ZipFile(epubFile)) {
zipFile.extractAll(tempDir.toString());
}
File opfFile = findOpfFile(tempDir.toFile());
if (opfFile == null) {
@@ -282,7 +287,9 @@ public class EpubMetadataWriter implements MetadataWriter {
transformer.transform(new DOMSource(opfDoc), new StreamResult(opfFile));
File tempEpub = new File(epubFile.getParentFile(), epubFile.getName() + ".tmp");
addFolderContentsToZip(new ZipFile(tempEpub), tempDir.toFile(), tempDir.toFile());
try (ZipFile tempZipFile = new ZipFile(tempEpub)) {
addFolderContentsToZip(tempZipFile, tempDir.toFile(), tempDir.toFile());
}
if (!epubFile.delete()) throw new IOException("Could not delete original EPUB");
if (!tempEpub.renameTo(epubFile)) throw new IOException("Could not rename temp EPUB");
@@ -308,7 +315,9 @@ public class EpubMetadataWriter implements MetadataWriter {
try {
File epubFile = new File(bookEntity.getFullFilePath().toUri());
tempDir = Files.createTempDirectory("epub_cover_url_" + UUID.randomUUID());
new ZipFile(epubFile).extractAll(tempDir.toString());
try (ZipFile zipFile = new ZipFile(epubFile)) {
zipFile.extractAll(tempDir.toString());
}
File opfFile = findOpfFile(tempDir.toFile());
if (opfFile == null) {
@@ -335,7 +344,9 @@ public class EpubMetadataWriter implements MetadataWriter {
transformer.transform(new DOMSource(opfDoc), new StreamResult(opfFile));
File tempEpub = new File(epubFile.getParentFile(), epubFile.getName() + ".tmp");
addFolderContentsToZip(new ZipFile(tempEpub), tempDir.toFile(), tempDir.toFile());
try (ZipFile tempZipFile = new ZipFile(tempEpub)) {
addFolderContentsToZip(tempZipFile, tempDir.toFile(), tempDir.toFile());
}
if (!epubFile.delete()) throw new IOException("Could not delete original EPUB");
if (!tempEpub.renameTo(epubFile)) throw new IOException("Could not rename temp EPUB");

View File

@@ -127,46 +127,48 @@ public class AppMigrationService {
try {
if (Files.exists(thumbsDir)) {
Files.walk(thumbsDir)
.filter(Files::isRegularFile)
.forEach(path -> {
try {
// Load original image
BufferedImage originalImage = ImageIO.read(path.toFile());
if (originalImage == null) {
log.warn("Skipping non-image file: {}", path);
return;
try (var stream = Files.walk(thumbsDir)) {
stream.filter(Files::isRegularFile)
.forEach(path -> {
try {
// Load original image
BufferedImage originalImage = ImageIO.read(path.toFile());
if (originalImage == null) {
log.warn("Skipping non-image file: {}", path);
return;
}
// Extract bookId from folder structure
Path relative = thumbsDir.relativize(path); // e.g., "11/f.jpg"
String bookId = relative.getParent().toString(); // "11"
Path bookDir = imagesDir.resolve(bookId);
Files.createDirectories(bookDir);
// Copy original to cover.jpg
Path coverFile = bookDir.resolve("cover.jpg");
ImageIO.write(originalImage, "jpg", coverFile.toFile());
// Resize and save thumbnail.jpg
BufferedImage resized = fileService.resizeImage(originalImage, 250, 350);
Path thumbnailFile = bookDir.resolve("thumbnail.jpg");
ImageIO.write(resized, "jpg", thumbnailFile.toFile());
log.debug("Processed book {}: cover={} thumbnail={}", bookId, coverFile, thumbnailFile);
} catch (IOException e) {
log.error("Error processing file {}", path, e);
throw new UncheckedIOException(e);
}
// Extract bookId from folder structure
Path relative = thumbsDir.relativize(path); // e.g., "11/f.jpg"
String bookId = relative.getParent().toString(); // "11"
Path bookDir = imagesDir.resolve(bookId);
Files.createDirectories(bookDir);
// Copy original to cover.jpg
Path coverFile = bookDir.resolve("cover.jpg");
ImageIO.write(originalImage, "jpg", coverFile.toFile());
// Resize and save thumbnail.jpg
BufferedImage resized = fileService.resizeImage(originalImage, 250, 350);
Path thumbnailFile = bookDir.resolve("thumbnail.jpg");
ImageIO.write(resized, "jpg", thumbnailFile.toFile());
log.debug("Processed book {}: cover={} thumbnail={}", bookId, coverFile, thumbnailFile);
} catch (IOException e) {
log.error("Error processing file {}", path, e);
throw new UncheckedIOException(e);
}
});
});
}
// Delete old thumbs directory
log.info("Deleting old thumbs directory: {}", thumbsDir);
Files.walk(thumbsDir)
.sorted(Comparator.reverseOrder())
.map(Path::toFile)
.forEach(File::delete);
try (var stream = Files.walk(thumbsDir)) {
stream.sorted(Comparator.reverseOrder())
.map(Path::toFile)
.forEach(File::delete);
}
}
} catch (IOException e) {
log.error("Error during migration populateCoversAndResizeThumbnails", e);

View File

@@ -13,6 +13,7 @@ import lombok.RequiredArgsConstructor;
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.compress.archivers.zip.ZipArchiveEntry;
import org.apache.pdfbox.io.IOUtils;
import org.springframework.stereotype.Service;
@@ -20,6 +21,8 @@ import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
@@ -131,24 +134,43 @@ public class CbxReaderService {
}
private void extractZipArchive(Path cbzPath, Path targetDir) throws IOException {
try (InputStream fis = Files.newInputStream(cbzPath);
ZipInputStream zis = new ZipInputStream(fis)) {
String[] encodingsToTry = {"UTF-8", "Shift_JIS", "ISO-8859-1", "CP437", "MS932"};
ZipEntry entry;
for (String encoding : encodingsToTry) {
try {
extractZipWithEncoding(cbzPath, targetDir, Charset.forName(encoding));
return;
} catch (IllegalArgumentException | java.util.zip.ZipException e) {
log.debug("Failed to extract with encoding {}: {}", encoding, e.getMessage());
}
}
while ((entry = zis.getNextEntry()) != null) {
throw new IOException("Unable to extract ZIP archive with any supported encoding");
}
private void extractZipWithEncoding(Path cbzPath, Path targetDir, Charset charset) throws IOException {
try (org.apache.commons.compress.archivers.zip.ZipFile zipFile =
org.apache.commons.compress.archivers.zip.ZipFile.builder()
.setPath(cbzPath)
.setCharset(charset)
.get()) {
var entries = zipFile.getEntries();
while (entries.hasMoreElements()) {
ZipArchiveEntry entry = entries.nextElement();
if (!entry.isDirectory() && isImageFile(entry.getName())) {
String fileName = extractFileNameFromPath(entry.getName());
Path target = targetDir.resolve(fileName);
Files.copy(zis, target, StandardCopyOption.REPLACE_EXISTING);
try (InputStream in = zipFile.getInputStream(entry)) {
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
}
}
zis.closeEntry();
}
}
}
private void extract7zArchive(Path cb7Path, Path targetDir) throws IOException {
try (SevenZFile sevenZFile = new SevenZFile(cb7Path.toFile())) {
try (SevenZFile sevenZFile = SevenZFile.builder().setPath(cb7Path).get()) {
SevenZArchiveEntry entry;
while ((entry = sevenZFile.getNextEntry()) != null) {
if (!entry.isDirectory() && isImageFile(entry.getName())) {
@@ -164,11 +186,13 @@ public class CbxReaderService {
private void copySevenZEntry(SevenZFile sevenZFile, OutputStream out, long size) throws IOException {
byte[] buffer = new byte[8192];
long read = 0;
int count;
while (read < size && (count = sevenZFile.read(buffer, 0, (int) Math.min(buffer.length, size - read))) > 0) {
out.write(buffer, 0, count);
read += count;
long remaining = size;
while (remaining > 0) {
int toRead = (int) Math.min(buffer.length, remaining);
int read = sevenZFile.read(buffer, 0, toRead);
if (read == -1) break;
out.write(buffer, 0, read);
remaining -= read;
}
}
@@ -306,20 +330,42 @@ public class CbxReaderService {
}
private long estimateCbzArchiveSize(Path cbxPath) throws IOException {
try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(cbxPath))) {
ZipEntry entry;
String[] encodingsToTry = {"UTF-8", "Shift_JIS", "ISO-8859-1", "CP437", "MS932"};
for (String encoding : encodingsToTry) {
try {
return estimateCbzWithEncoding(cbxPath, Charset.forName(encoding));
} catch (IllegalArgumentException | java.util.zip.ZipException e) {
log.debug("Failed to estimate with encoding {}: {}", encoding, e.getMessage());
}
}
log.warn("Unable to estimate archive size for {} with any supported encoding", cbxPath);
return Long.MAX_VALUE;
}
private long estimateCbzWithEncoding(Path cbxPath, Charset charset) throws IOException {
try (org.apache.commons.compress.archivers.zip.ZipFile zipFile =
org.apache.commons.compress.archivers.zip.ZipFile.builder()
.setPath(cbxPath)
.setCharset(charset)
.get()) {
long total = 0;
while ((entry = zis.getNextEntry()) != null) {
var entries = zipFile.getEntries();
while (entries.hasMoreElements()) {
ZipArchiveEntry entry = entries.nextElement();
if (!entry.isDirectory() && isImageFile(entry.getName())) {
total += entry.getCompressedSize();
long size = entry.getSize();
total += (size >= 0) ? size : entry.getCompressedSize();
}
}
return total;
return total > 0 ? total : Long.MAX_VALUE;
}
}
private long estimateCb7ArchiveSize(Path cbxPath) throws IOException {
try (SevenZFile sevenZFile = new SevenZFile(cbxPath.toFile())) {
try (SevenZFile sevenZFile = SevenZFile.builder().setPath(cbxPath).get()) {
SevenZArchiveEntry entry;
long total = 0;
while ((entry = sevenZFile.getNextEntry()) != null) {
@@ -346,4 +392,4 @@ public class CbxReaderService {
private long mbToBytes(int mb) {
return mb * 1024L * 1024L;
}
}
}

View File

@@ -21,6 +21,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.List;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -30,6 +31,7 @@ import java.util.stream.Stream;
public class PdfReaderService {
private static final String CACHE_INFO_FILENAME = ".cache-info";
private static final Pattern NON_DIGIT_PATTERN = Pattern.compile("\\D+");
private final BookRepository bookRepository;
private final AppSettingService appSettingService;
@@ -123,7 +125,7 @@ public class PdfReaderService {
private int extractPageNumber(String filename) {
try {
return Integer.parseInt(filename.replaceAll("\\D+", ""));
return Integer.parseInt(NON_DIGIT_PATTERN.matcher(filename).replaceAll(""));
} catch (Exception e) {
return -1;
}

View File

@@ -9,11 +9,15 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.regex.Pattern;
@RequiredArgsConstructor
@Service
public class BookSimilarityService {
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
private static final Pattern NON_ALPHANUMERIC_EXCEPT_SPACE_PATTERN = Pattern.compile("[^a-z0-9 ]");
@Getter
public enum SimilarityWeight {
TITLE(1.5),
@@ -93,7 +97,7 @@ public class BookSimilarityService {
Map<String, Integer> vector = new HashMap<>();
if (text == null || text.isBlank()) return vector;
String[] tokens = text.toLowerCase().replaceAll("[^a-z0-9 ]", "").split("\\s+");
String[] tokens = WHITESPACE_PATTERN.split(NON_ALPHANUMERIC_EXCEPT_SPACE_PATTERN.matcher(text.toLowerCase()).replaceAll(""));
for (String token : tokens) {
vector.put(token, vector.getOrDefault(token, 0) + 1);
}

View File

@@ -11,6 +11,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@Slf4j
@@ -20,6 +21,8 @@ public class BookVectorService {
private static final ObjectMapper objectMapper = new ObjectMapper();
private static final int VECTOR_DIMENSION = 128;
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
private static final Pattern NON_ALPHANUMERIC_EXCEPT_SPACE_PATTERN = Pattern.compile("[^a-z0-9\\s]");
public double[] generateEmbedding(BookEntity book) {
if (book.getMetadata() == null) {
@@ -63,9 +66,7 @@ public class BookVectorService {
}
private void addTextFeatures(Map<String, Double> features, String prefix, String text, double weight) {
String[] words = text.toLowerCase()
.replaceAll("[^a-z0-9\\s]", " ")
.split("\\s+");
String[] words = WHITESPACE_PATTERN.split(NON_ALPHANUMERIC_EXCEPT_SPACE_PATTERN.matcher(text.toLowerCase()).replaceAll(" "));
Arrays.stream(words)
.filter(w -> w.length() > 3)

View File

@@ -16,12 +16,14 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.regex.Pattern;
@Service
@Slf4j
@AllArgsConstructor
public class TaskCronService {
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
private final TaskCronConfigurationRepository repository;
private final AuthenticationService authService;
@@ -76,7 +78,7 @@ public class TaskCronService {
if (cronExpression == null || cronExpression.trim().isEmpty()) {
throw new APIException("Cron expression is required", HttpStatus.BAD_REQUEST);
}
String[] fields = cronExpression.trim().split("\\s+");
String[] fields = WHITESPACE_PATTERN.split(cronExpression.trim());
if (fields.length != 6) {
throw new APIException("Invalid cron expression format. Expected 6 fields (second minute hour day month day-of-week)", HttpStatus.BAD_REQUEST);
}

View File

@@ -144,6 +144,7 @@ public class TaskService {
TaskCreateRequest request = TaskCreateRequest.builder()
.taskType(taskType)
.triggeredByCron(true)
.build();
runAsSystemUser(request);

View File

@@ -18,12 +18,14 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.regex.Pattern;
@Slf4j
@Service
@AllArgsConstructor
public class UserProvisioningService {
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
private final AppProperties appProperties;
private final UserRepository userRepository;
private final LibraryRepository libraryRepository;
@@ -141,7 +143,7 @@ public class UserProvisioningService {
if (groupsContent.startsWith("[") && groupsContent.endsWith("]")) {
groupsContent = groupsContent.substring(1, groupsContent.length() - 1);
}
List<String> groupsList = Arrays.asList(groupsContent.split("\\s+"));
List<String> groupsList = Arrays.asList(WHITESPACE_PATTERN.split(groupsContent));
isAdmin = groupsList.contains(appProperties.getRemoteAuth().getAdminGroup());
log.debug("Remote-Auth: user {} will be admin: {}", username, isAdmin);
}

View File

@@ -10,6 +10,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.UUID;
@Component
@@ -29,9 +30,15 @@ public class DeletedBooksCleanupTask implements Task {
log.info("{}: Task started", getTaskType());
try {
int deletedCount = bookRepository.deleteAllSoftDeleted();
log.info("{}: Removed {} deleted books", getTaskType(), deletedCount);
int deletedCount;
if (request.isTriggeredByCron()) {
Instant cutoff = Instant.now().minus(7, ChronoUnit.DAYS);
deletedCount = bookRepository.deleteSoftDeletedBefore(cutoff);
log.info("{}: Removed {} deleted books older than {}", getTaskType(), deletedCount, cutoff);
} else {
deletedCount = bookRepository.deleteAllSoftDeleted();
log.info("{}: Removed all {} deleted books (on-demand execution)", getTaskType(), deletedCount);
}
builder.status(TaskStatus.COMPLETED);
} catch (Exception e) {
log.error("{}: Error cleaning up deleted books", getTaskType(), e);

View File

@@ -8,6 +8,7 @@ import com.adityachandel.booklore.task.TaskStatus;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
@@ -21,6 +22,7 @@ public class TempFetchedMetadataCleanupTask implements Task {
private final MetadataFetchJobRepository metadataFetchJobRepository;
@Override
@Transactional
public TaskCreateResponse execute(TaskCreateRequest request) {
TaskCreateResponse.TaskCreateResponseBuilder builder = TaskCreateResponse.builder()
.taskId(UUID.randomUUID().toString())
@@ -30,9 +32,15 @@ public class TempFetchedMetadataCleanupTask implements Task {
log.info("{}: Task started", getTaskType());
try {
Instant cutoff = Instant.now().minus(5, ChronoUnit.DAYS);
int deleted = metadataFetchJobRepository.deleteAllByCompletedAtBefore(cutoff);
log.info("{}: Removed {} metadata fetch jobs older than {}", getTaskType(), deleted, cutoff);
int deleted;
if (request.isTriggeredByCron()) {
Instant cutoff = Instant.now().minus(3, ChronoUnit.DAYS);
deleted = metadataFetchJobRepository.deleteAllByCompletedAtBefore(cutoff);
log.info("{}: Removed {} metadata fetch jobs older than {}", getTaskType(), deleted, cutoff);
} else {
deleted = metadataFetchJobRepository.deleteAllRecords();
log.info("{}: Removed all {} metadata fetch jobs (on-demand execution)", getTaskType(), deleted);
}
builder.status(TaskStatus.COMPLETED);
} catch (Exception e) {

View File

@@ -1,13 +1,19 @@
package com.adityachandel.booklore.util;
import java.util.regex.Pattern;
public class BookUtils {
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
private static final Pattern SPECIAL_CHARACTERS_PATTERN = Pattern.compile("[.,\\-\\[\\]{}()!@#$%^&*_=+|~`<>?/\";:]");
private static final Pattern PARENTHESES_WITH_OPTIONAL_SPACE_PATTERN = Pattern.compile("\\s?\\(.*?\\)");
public static String cleanFileName(String fileName) {
if (fileName == null) {
return null;
}
fileName = fileName.replace("(Z-Library)", "").trim();
fileName = fileName.replaceAll("\\s?\\(.*?\\)", "").trim(); // Remove the author name inside parentheses (e.g. (Jon Yablonski))
fileName = PARENTHESES_WITH_OPTIONAL_SPACE_PATTERN.matcher(fileName).replaceAll("").trim(); // Remove the author name inside parentheses (e.g. (Jon Yablonski))
int dotIndex = fileName.lastIndexOf('.'); // Remove the file extension (e.g., .pdf, .docx)
if (dotIndex > 0) {
fileName = fileName.substring(0, dotIndex).trim();
@@ -16,9 +22,9 @@ public class BookUtils {
}
public static String cleanAndTruncateSearchTerm(String term) {
term = term.replaceAll("[.,\\-\\[\\]{}()!@#$%^&*_=+|~`<>?/\";:]", "").trim();
term = SPECIAL_CHARACTERS_PATTERN.matcher(term).replaceAll("").trim();
if (term.length() > 60) {
String[] words = term.split("\\s+");
String[] words = WHITESPACE_PATTERN.split(term);
StringBuilder truncated = new StringBuilder();
for (String word : words) {
if (truncated.length() + word.length() + 1 > 60) break;

View File

@@ -12,6 +12,10 @@ import java.util.regex.Pattern;
public class PathPatternResolver {
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
private static final Pattern FILE_EXTENSION_PATTERN = Pattern.compile(".*\\.[a-zA-Z0-9]+$");
private static final Pattern CONTROL_CHARACTER_PATTERN = Pattern.compile("[\\p{Cntrl}]");
public static String resolvePattern(BookEntity book, String pattern) {
String currentFilename = book.getFileName() != null ? book.getFileName().trim() : "";
return resolvePattern(book.getMetadata(), pattern, currentFilename);
@@ -150,7 +154,7 @@ public class PathPatternResolver {
result = values.getOrDefault("currentFilename", "untitled");
}
boolean hasExtension = result.matches(".*\\.[a-zA-Z0-9]+$");
boolean hasExtension = FILE_EXTENSION_PATTERN.matcher(result).matches();
boolean explicitlySetExtension = pattern.contains("{extension}");
if (!explicitlySetExtension && !hasExtension && !extension.isBlank()) {
@@ -162,10 +166,8 @@ public class PathPatternResolver {
private static String sanitize(String input) {
if (input == null) return "";
return input
.replaceAll("[\\\\/:*?\"<>|]", "")
.replaceAll("[\\p{Cntrl}]", "")
.replaceAll("\\s+", " ")
return WHITESPACE_PATTERN.matcher(CONTROL_CHARACTER_PATTERN.matcher(input
.replaceAll("[\\\\/:*?\"<>|]", "")).replaceAll("")).replaceAll(" ")
.trim();
}

View File

@@ -8,10 +8,13 @@ import org.springframework.stereotype.Component;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.regex.Pattern;
@Component
@Slf4j
public class KoboUrlBuilder {
private static final Pattern IP_ADDRESS_PATTERN = Pattern.compile("\\d+\\.\\d+\\.\\d+\\.\\d+");
@Value("${server.port}")
private int serverPort;
@@ -32,7 +35,7 @@ public class KoboUrlBuilder {
try {
int port = Integer.parseInt(xfPort);
if (host.matches("\\d+\\.\\d+\\.\\d+\\.\\d+") || "localhost".equals(host)) {
if (IP_ADDRESS_PATTERN.matcher(host).matches() || "localhost".equals(host)) {
builder.port(port);
}
log.info("Applied X-Forwarded-Port: {}", port);

View File

@@ -112,6 +112,10 @@ function getMatchScoreRangeFilters(score?: number | null): { id: string; name: s
return match ? [{id: match.id, name: match.label, sortIndex: match.sortIndex}] : [];
}
function getBookTypeFilter(book: Book): { id: string; name: string }[] {
return book.bookType ? [{id: book.bookType, name: book.bookType}] : [];
}
export const readStatusLabels: Record<ReadStatus, string> = {
[ReadStatus.UNREAD]: 'Unread',
[ReadStatus.READING]: 'Reading',
@@ -175,15 +179,16 @@ export class BookFilterComponent implements OnInit, OnDestroy {
publisher: 'Publisher',
readStatus: 'Read Status',
personalRating: 'Personal Rating',
publishedDate: 'Published Year',
matchScore: 'Metadata Match Score',
language: 'Language',
bookType: 'Book Type',
shelfStatus: 'Shelf Status',
fileSize: 'File Size',
pageCount: 'Page Count',
amazonRating: 'Amazon Rating',
goodreadsRating: 'Goodreads Rating',
hardcoverRating: 'Hardcover Rating',
publishedDate: 'Published Year',
fileSize: 'File Size',
shelfStatus: 'Shelf Status',
pageCount: 'Page Count',
language: 'Language'
};
private destroy$ = new Subject<void>();
@@ -200,10 +205,22 @@ export class BookFilterComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.destroy$))
.subscribe(([sortMode]) => {
this.filterStreams = {
author: this.getFilterStream((book: Book) => book.metadata?.authors!.map(name => ({id: name, name})) || [], 'id', 'name'),
category: this.getFilterStream((book: Book) => book.metadata?.categories!.map(name => ({id: name, name})) || [], 'id', 'name'),
series: this.getFilterStream((book) => (book.metadata?.seriesName ? [{id: book.metadata.seriesName, name: book.metadata.seriesName}] : []), 'id', 'name'),
publisher: this.getFilterStream((book) => (book.metadata?.publisher ? [{id: book.metadata.publisher, name: book.metadata.publisher}] : []), 'id', 'name'),
author: this.getFilterStream(
(book: Book) => Array.isArray(book.metadata?.authors) ? book.metadata.authors.map(name => ({id: name, name})) : [],
'id', 'name'
),
category: this.getFilterStream(
(book: Book) => Array.isArray(book.metadata?.categories) ? book.metadata.categories.map(name => ({id: name, name})) : [],
'id', 'name'
),
series: this.getFilterStream(
(book) => (book.metadata?.seriesName ? [{id: book.metadata.seriesName, name: book.metadata.seriesName}] : []),
'id', 'name'
),
publisher: this.getFilterStream(
(book) => (book.metadata?.publisher ? [{id: book.metadata.publisher, name: book.metadata.publisher}] : []),
'id', 'name'
),
readStatus: this.getFilterStream((book: Book) => {
let status = book.readStatus;
if (status == null || !(status in readStatusLabels)) {
@@ -211,18 +228,25 @@ export class BookFilterComponent implements OnInit, OnDestroy {
}
return [{id: status, name: getReadStatusName(status)}];
}, 'id', 'name'),
mood: this.getFilterStream((book: Book) => book.metadata?.moods!.map(name => ({id: name, name})) || [], 'id', 'name'),
tag: this.getFilterStream((book: Book) => book.metadata?.tags!.map(name => ({id: name, name})) || [], 'id', 'name'),
matchScore: this.getFilterStream((book: Book) => getMatchScoreRangeFilters(book.metadataMatchScore), 'id', 'name', 'sortIndex'),
personalRating: this.getFilterStream((book: Book) => getRatingRangeFilters10(book.metadata?.personalRating!), 'id', 'name', 'sortIndex'),
publishedDate: this.getFilterStream(extractPublishedYearFilter, 'id', 'name'),
matchScore: this.getFilterStream((book: Book) => getMatchScoreRangeFilters(book.metadataMatchScore), 'id', 'name', 'sortIndex'),
mood: this.getFilterStream(
(book: Book) => Array.isArray(book.metadata?.moods) ? book.metadata.moods.map(name => ({id: name, name})) : [],
'id', 'name'
),
tag: this.getFilterStream(
(book: Book) => Array.isArray(book.metadata?.tags) ? book.metadata.tags.map(name => ({id: name, name})) : [],
'id', 'name'
),
language: this.getFilterStream(getLanguageFilter, 'id', 'name'),
bookType: this.getFilterStream(getBookTypeFilter, 'id', 'name'),
shelfStatus: this.getFilterStream(getShelfStatusFilter, 'id', 'name'),
fileSize: this.getFilterStream((book: Book) => getFileSizeRangeFilters(book.fileSizeKb), 'id', 'name', 'sortIndex'),
pageCount: this.getFilterStream((book: Book) => getPageCountRangeFilters(book.metadata?.pageCount!), 'id', 'name', 'sortIndex'),
amazonRating: this.getFilterStream((book: Book) => getRatingRangeFilters(book.metadata?.amazonRating!), 'id', 'name', 'sortIndex'),
goodreadsRating: this.getFilterStream((book: Book) => getRatingRangeFilters(book.metadata?.goodreadsRating!), 'id', 'name', 'sortIndex'),
hardcoverRating: this.getFilterStream((book: Book) => getRatingRangeFilters(book.metadata?.hardcoverRating!), 'id', 'name', 'sortIndex'),
shelfStatus: this.getFilterStream(getShelfStatusFilter, 'id', 'name'),
publishedDate: this.getFilterStream(extractPublishedYearFilter, 'id', 'name'),
language: this.getFilterStream(getLanguageFilter, 'id', 'name'),
fileSize: this.getFilterStream((book: Book) => getFileSizeRangeFilters(book.fileSizeKb), 'id', 'name', 'sortIndex'),
pageCount: this.getFilterStream((book: Book) => getPageCountRangeFilters(book.metadata?.pageCount!), 'id', 'name', 'sortIndex'),
};
this.filterTypes = Object.keys(this.filterStreams);

View File

@@ -105,6 +105,8 @@ export class SideBarFilter implements BookFilter {
return filterValues.includes(book.metadata?.language);
case 'matchScore':
return filterValues.some(range => isMatchScoreInRange(book.metadataMatchScore, range));
case 'bookType':
return filterValues.includes(book.bookType);
default:
return false;
}

View File

@@ -24,6 +24,7 @@ export class SortService {
publisher: (book) => book.metadata?.publisher || null,
pageCount: (book) => book.metadata?.pageCount || null,
rating: (book) => book.metadata?.rating || null,
personalRating: (book) => book.metadata?.personalRating || null,
reviewCount: (book) => book.metadata?.reviewCount || null,
amazonRating: (book) => book.metadata?.amazonRating || null,
amazonReviewCount: (book) => book.metadata?.amazonReviewCount || null,

View File

@@ -1,14 +1,17 @@
<div class="dashboard-scroller-container">
<h2 class="dashboard-scroller-title">{{ title }}</h2>
<h2 class="dashboard-scroller-title">
{{ title }}
@if (isMagicShelf) {
<i class="pi pi-sparkles magic-shelf-icon"></i>
}
</h2>
@if (!books || books.length === 0) {
<div class="dashboard-scroller-no-books">
<div class="empty-state-icon">
<i class="pi pi-book"></i>
</div>
<p>
{{ bookListType === 'lastRead' ? "You haven't read any books yet! Start your reading journey." : "No books have been added to BookLore yet! Add your first book to get started." }}
</p>
<p>No books found for this scroller.</p>
</div>
}
@@ -19,7 +22,7 @@
[infiniteScrollThrottle]="50"
#scrollContainer
[horizontal]="true">
@for (book of books; track book) {
@for (book of books; track book.id) {
<div class="dashboard-scroller-card">
<app-book-card [book]="book" [isCheckboxEnabled]="false"></app-book-card>
</div>

View File

@@ -146,6 +146,15 @@
}
}
.magic-shelf-icon {
margin-left: 0.5rem;
color: var(--primary-color) !important;
background: none !important;
-webkit-background-clip: unset !important;
-webkit-text-fill-color: var(--primary-color) !important;
background-clip: unset !important;
}
@media (max-width: 767px) {
.dashboard-scroller-card {

View File

@@ -1,9 +1,10 @@
import {Component, ElementRef, Input, OnChanges, OnInit, ViewChild} from '@angular/core';
import {Component, ElementRef, Input, ViewChild} from '@angular/core';
import {BookCardComponent} from '../../../book/components/book-browser/book-card/book-card.component';
import {InfiniteScrollDirective} from 'ngx-infinite-scroll';
import {ProgressSpinnerModule} from 'primeng/progressspinner';
import {Book} from '../../../book/model/book.model';
import {ScrollerType} from '../../models/dashboard-config.model';
@Component({
selector: 'app-dashboard-scroller',
@@ -18,9 +19,10 @@ import {Book} from '../../../book/model/book.model';
})
export class DashboardScrollerComponent {
@Input() bookListType: 'lastRead' | null = null;
@Input() title: string = 'Last Read Books';
@Input() bookListType: ScrollerType | null = null;
@Input() title!: string;
@Input() books!: Book[] | null;
@Input() isMagicShelf: boolean = false;
@ViewChild('scrollContainer') scrollContainer!: ElementRef;
}

View File

@@ -0,0 +1,141 @@
<div class="dashboard-settings">
<div class="settings-info">
<h3 class="settings-info-title">
<i class="pi pi-info-circle"></i>
Dashboard Configuration
</h3>
<p class="settings-info-text">
Customize your dashboard by adding up to 5 scrollers. Each scroller can display different book collections.
Choose from Continue Reading, Recently Added, Random Discovery, or create custom views using Magic Shelves.
Enable/disable, reorder, and configure the number of items shown in each scroller.
</p>
</div>
<div class="add-scroller-wrapper">
<p-button
label="Add Scroller"
icon="pi pi-plus"
size="small"
(click)="addScroller()"
[disabled]="config.scrollers.length >= 5">
</p-button>
</div>
@for (scroller of config.scrollers; track scroller.id; let i = $index) {
<div class="scroller-item">
<div class="scroller-content">
<div class="scroller-controls-left">
<p-checkbox [(ngModel)]="scroller.enabled" [binary]="true"></p-checkbox>
</div>
<div class="scroller-fields">
<div class="scroller-field">
<label>Type</label>
<p-select
fluid
[options]="availableScrollerTypes"
[(ngModel)]="scroller.type"
(onChange)="onScrollerTypeChange(scroller)"
placeholder="Select type"
appendTo="body">
</p-select>
</div>
<div class="scroller-field" [style.visibility]="scroller.type === 'magicShelf' ? 'visible' : 'hidden'">
<label>Magic Shelf</label>
<p-select
fluid
[options]="magicShelves$ | async"
[(ngModel)]="scroller.magicShelfId"
placeholder="Select magic shelf"
appendTo="body">
</p-select>
</div>
<div class="scroller-field" [style.visibility]="scroller.type === 'magicShelf' ? 'visible' : 'hidden'">
<label>Sort Field</label>
<p-select
fluid
[options]="sortFieldOptions"
[(ngModel)]="scroller.sortField"
placeholder="Select field"
appendTo="body">
</p-select>
</div>
<div class="scroller-field" [style.visibility]="scroller.type === 'magicShelf' ? 'visible' : 'hidden'">
<label>Direction</label>
<p-select
fluid
[options]="sortDirectionOptions"
[(ngModel)]="scroller.sortDirection"
placeholder="Select direction"
appendTo="body">
</p-select>
</div>
<div class="scroller-field">
<label>Max Items</label>
<p-inputNumber
fluid
[(ngModel)]="scroller.maxItems"
[min]="MIN_ITEMS"
[max]="MAX_ITEMS"
[showButtons]="true">
</p-inputNumber>
</div>
</div>
<div class="scroller-controls-right">
<p-button
icon="pi pi-arrow-up"
outlined
rounded
severity="info"
size="small"
[disabled]="i === 0"
(click)="moveUp(i)">
</p-button>
<p-button
icon="pi pi-arrow-down"
outlined
rounded
severity="info"
size="small"
[disabled]="i === config.scrollers.length - 1"
(click)="moveDown(i)">
</p-button>
<p-button
icon="pi pi-trash"
outlined
rounded
size="small"
severity="danger"
(click)="removeScroller(i)"
[disabled]="config.scrollers.length === 1">
</p-button>
</div>
</div>
</div>
}
<div class="footer-actions">
<p-button
label="Reset to Default"
severity="warn"
text
(click)="resetToDefault()">
</p-button>
<p-button
label="Cancel"
outlined
severity="secondary"
(click)="cancel()">
</p-button>
<p-button
label="Save"
severity="success"
(click)="save()">
</p-button>
</div>
</div>

View File

@@ -0,0 +1,166 @@
.dashboard-settings {
width: 1000px;
max-width: 1200px;
min-height: 300px;
padding: 2rem 1rem 0 1rem;
margin: 0 auto;
@media (max-width: 768px) {
width: 100%;
max-width: 100%;
padding: 3rem 1rem 0 1rem;
box-sizing: border-box;
}
}
.scroller-item {
border: var(--card-border);
transition: all 0.2s;
border-color: var(--p-content-border-color);
background: var(--p-content-background);
padding: 1rem;
margin-bottom: 0.75rem;
border-radius: 0.375rem;
@media (max-width: 768px) {
padding: 0.75rem !important;
}
}
.scroller-content {
display: flex;
align-items: flex-end;
gap: 0.75rem;
@media (max-width: 768px) {
flex-direction: column;
align-items: stretch;
gap: 1rem;
}
}
.scroller-controls-left {
display: flex;
align-items: center;
padding-bottom: 0.65rem;
@media (max-width: 768px) {
padding-bottom: 0;
align-self: flex-start;
}
}
.scroller-controls-right {
display: flex;
gap: 0.5rem;
padding-bottom: 0.25rem;
@media (max-width: 768px) {
padding-bottom: 0;
align-self: flex-end;
}
}
.scroller-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--p-content-border-color);
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
}
.scroller-fields {
display: grid;
grid-template-columns: minmax(120px, 1fr) minmax(150px, 1.5fr) minmax(120px, 1fr) minmax(100px, 1fr) 90px;
gap: 0.75rem;
flex: 1;
@media (max-width: 768px) {
grid-template-columns: 1fr;
gap: 0.75rem;
}
@media (max-width: 640px) {
gap: 0.5rem;
}
}
.scroller-field {
label {
display: block;
margin-bottom: 0.5rem;
}
}
label {
color: var(--text-color-secondary);
font-size: 0.875rem;
font-weight: 500;
}
.footer-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--p-content-border-color);
@media (max-width: 768px) {
flex-wrap: wrap;
justify-content: stretch;
::ng-deep button {
flex: 1;
}
}
}
.settings-info {
border: 1px solid var(--p-primary-300);
margin-bottom: 2rem;
padding: 0.75rem;
border-radius: 0.375rem;
@media (max-width: 768px) {
padding: 0.75rem !important;
}
}
.settings-info-title {
color: var(--p-primary-600);
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 0.5rem;
i {
margin-right: 0.25rem;
}
@media (max-width: 768px) {
font-size: 1rem;
}
}
.settings-info-text {
margin: 0;
line-height: 1.5;
font-size: 0.875rem;
color: var(--text-color-secondary);
@media (max-width: 768px) {
font-size: 0.813rem;
}
}
.add-scroller-wrapper {
margin-bottom: 1rem;
}

View File

@@ -0,0 +1,179 @@
import {Component, inject, OnInit} from '@angular/core';
import {CommonModule} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {DynamicDialogRef} from 'primeng/dynamicdialog';
import {ButtonModule} from 'primeng/button';
import {CheckboxModule} from 'primeng/checkbox';
import {InputTextModule} from 'primeng/inputtext';
import {SelectModule} from 'primeng/select';
import {InputNumberModule} from 'primeng/inputnumber';
import {DashboardConfig, ScrollerConfig, ScrollerType} from '../../models/dashboard-config.model';
import {DashboardConfigService} from '../../services/dashboard-config.service';
import {MagicShelfService} from '../../../magic-shelf/service/magic-shelf.service';
import {map} from 'rxjs/operators';
export const MAX_SCROLLERS = 5;
export const DEFAULT_MAX_ITEMS = 20;
export const MIN_ITEMS = 10;
export const MAX_ITEMS = 20;
@Component({
selector: 'app-dashboard-settings',
standalone: true,
imports: [
CommonModule,
FormsModule,
ButtonModule,
CheckboxModule,
InputTextModule,
SelectModule,
InputNumberModule
],
templateUrl: './dashboard-settings.component.html',
styleUrls: ['./dashboard-settings.component.scss']
})
export class DashboardSettingsComponent implements OnInit {
private configService = inject(DashboardConfigService);
private dialogRef = inject(DynamicDialogRef);
private magicShelfService = inject(MagicShelfService);
config!: DashboardConfig;
availableScrollerTypes = [
{label: 'Continue Reading', value: ScrollerType.LAST_READ},
{label: 'Recently Added', value: ScrollerType.LATEST_ADDED},
{label: 'Discover Something New', value: ScrollerType.RANDOM},
{label: 'Magic Shelf', value: ScrollerType.MAGIC_SHELF}
];
magicShelves$ = this.magicShelfService.shelvesState$.pipe(
map(state => (state.shelves || []).map(shelf => ({
label: shelf.name,
value: shelf.id!
})))
);
sortFieldOptions = [
{label: 'Title', value: 'title'},
{label: 'Title + Series', value: 'titleSeries'},
{label: 'Date Added', value: 'addedOn'},
{label: 'Author', value: 'author'},
{label: 'Personal Rating', value: 'personalRating'},
{label: 'Publisher', value: 'publisher'},
{label: 'Published Date', value: 'publishedDate'},
{label: 'Last Read', value: 'lastReadTime'},
{label: 'Pages', value: 'pageCount'}
];
sortDirectionOptions = [
{label: 'Ascending', value: 'asc'},
{label: 'Descending', value: 'desc'}
];
private magicShelvesMap = new Map<number, string>();
readonly MIN_ITEMS = MIN_ITEMS;
readonly MAX_ITEMS = MAX_ITEMS;
ngOnInit(): void {
this.configService.config$.subscribe(config => {
this.config = JSON.parse(JSON.stringify(config));
});
this.magicShelfService.shelvesState$.subscribe(state => {
this.magicShelvesMap.clear();
(state.shelves || []).forEach(shelf => {
if (shelf.id) {
this.magicShelvesMap.set(shelf.id, shelf.name);
}
});
});
}
getScrollerTitle(scroller: ScrollerConfig): string {
if (scroller.type === ScrollerType.MAGIC_SHELF && scroller.magicShelfId) {
return this.magicShelvesMap.get(scroller.magicShelfId) || 'Magic Shelf';
}
switch (scroller.type) {
case ScrollerType.LAST_READ:
return 'Continue Reading';
case ScrollerType.LATEST_ADDED:
return 'Recently Added';
case ScrollerType.RANDOM:
return 'Discover Something New';
default:
return 'Scroller';
}
}
addScroller(): void {
if (this.config.scrollers.length >= MAX_SCROLLERS) {
return;
}
const newId = (Math.max(...this.config.scrollers.map((s: ScrollerConfig) => parseInt(s.id)), 0) + 1).toString();
this.config.scrollers.push({
id: newId,
type: ScrollerType.LATEST_ADDED,
title: '',
enabled: true,
order: this.config.scrollers.length + 1,
maxItems: DEFAULT_MAX_ITEMS
});
}
removeScroller(index: number): void {
if (this.config.scrollers.length <= 1) {
return;
}
this.config.scrollers.splice(index, 1);
this.updateOrder();
}
onScrollerTypeChange(scroller: ScrollerConfig): void {
if (scroller.type === ScrollerType.MAGIC_SHELF) {
scroller.magicShelfId = undefined;
} else {
delete scroller.magicShelfId;
}
}
moveUp(index: number): void {
if (index > 0) {
[this.config.scrollers[index], this.config.scrollers[index - 1]] =
[this.config.scrollers[index - 1], this.config.scrollers[index]];
this.updateOrder();
}
}
moveDown(index: number): void {
if (index < this.config.scrollers.length - 1) {
[this.config.scrollers[index], this.config.scrollers[index + 1]] =
[this.config.scrollers[index + 1], this.config.scrollers[index]];
this.updateOrder();
}
}
private updateOrder(): void {
this.config.scrollers.forEach((scroller, index) => {
scroller.order = index + 1;
});
}
save(): void {
this.config.scrollers.forEach(scroller => {
scroller.title = this.getScrollerTitle(scroller);
});
this.configService.saveConfig(this.config);
this.dialogRef.close();
}
cancel(): void {
this.dialogRef.close();
}
resetToDefault(): void {
this.configService.resetToDefault();
this.dialogRef.close();
}
}

View File

@@ -38,21 +38,30 @@
}
</div>
} @else {
<app-dashboard-scroller
[books]="lastReadBooks$ | async"
[bookListType]="'lastRead'"
[title]="'Continue Reading'">
</app-dashboard-scroller>
<div class="dashboard-settings-button">
<p-button
icon="pi pi-cog"
severity="info"
rounded
text
[pTooltip]="'Customize Dashboard'"
tooltipPosition="left"
(click)="openDashboardSettings()">
</p-button>
</div>
<app-dashboard-scroller
[books]="latestAddedBooks$ | async"
[title]="'Recently Added'">
</app-dashboard-scroller>
<app-dashboard-scroller
[books]="randomBooks$ | async"
[title]="'Discover Something New'">
</app-dashboard-scroller>
@if (dashboardConfig$ | async; as config) {
@for (scroller of config.scrollers; track scroller.id) {
@if (scroller.enabled) {
<app-dashboard-scroller
[bookListType]="scroller.type"
[title]="scroller.title"
[isMagicShelf]="scroller.type === ScrollerType.MAGIC_SHELF"
[books]="getBooksForScroller(scroller) | async">
</app-dashboard-scroller>
}
}
}
}
</div>
</div>

View File

@@ -79,3 +79,35 @@
::ng-deep .p-dialog-mask {
backdrop-filter: blur(3px);
}
.dashboard-settings-button {
position: fixed;
top: 6rem;
right: 2rem;
z-index: 100;
::ng-deep .p-button {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(10px);
background: rgba(var(--p-surface-900-rgb), 0.8);
border: 1px solid var(--p-content-border-color);
&:hover {
background: rgba(var(--p-surface-800-rgb), 0.9);
transform: scale(1.05);
transition: all 0.2s ease;
}
}
}
@media (max-width: 768px) {
.dashboard-settings-button {
top: 5rem;
right: 1rem;
::ng-deep .p-button {
width: 2.5rem;
height: 2.5rem;
}
}
}

View File

@@ -1,9 +1,8 @@
import {Component, inject, OnInit} from '@angular/core';
import {LibraryCreatorComponent} from '../../../library-creator/library-creator.component';
import {DialogService, DynamicDialogRef} from 'primeng/dynamicdialog';
import {DynamicDialogRef} from 'primeng/dynamicdialog';
import {LibraryService} from '../../../book/service/library.service';
import {Observable} from 'rxjs';
import {map} from 'rxjs/operators';
import {map, shareReplay, switchMap} from 'rxjs/operators';
import {Button} from 'primeng/button';
import {AsyncPipe} from '@angular/common';
import {DashboardScrollerComponent} from '../dashboard-scroller/dashboard-scroller.component';
@@ -12,6 +11,18 @@ import {BookState} from '../../../book/model/state/book-state.model';
import {Book, ReadStatus} from '../../../book/model/book.model';
import {UserService} from '../../../settings/user-management/user.service';
import {ProgressSpinner} from 'primeng/progressspinner';
import {TooltipModule} from 'primeng/tooltip';
import {DashboardConfigService} from '../../services/dashboard-config.service';
import {ScrollerConfig, ScrollerType} from '../../models/dashboard-config.model';
import {DashboardSettingsComponent} from '../dashboard-settings/dashboard-settings.component';
import {MagicShelfService} from '../../../magic-shelf/service/magic-shelf.service';
import {BookRuleEvaluatorService} from '../../../magic-shelf/service/book-rule-evaluator.service';
import {GroupRule} from '../../../magic-shelf/component/magic-shelf-component';
import {DialogLauncherService} from '../../../../shared/services/dialog-launcher.service';
import {SortService} from '../../../book/service/sort.service';
import {SortDirection, SortOption} from '../../../book/model/sort.model';
const DEFAULT_MAX_ITEMS = 20;
@Component({
selector: 'app-main-dashboard',
@@ -21,70 +32,168 @@ import {ProgressSpinner} from 'primeng/progressspinner';
Button,
DashboardScrollerComponent,
AsyncPipe,
ProgressSpinner
ProgressSpinner,
TooltipModule
],
providers: [DialogService],
standalone: true
})
export class MainDashboardComponent implements OnInit {
ref: DynamicDialogRef | undefined | null;
private bookService = inject(BookService);
private dialogService = inject(DialogService);
private dialogLauncher = inject(DialogLauncherService);
protected userService = inject(UserService);
private dashboardConfigService = inject(DashboardConfigService);
private magicShelfService = inject(MagicShelfService);
private ruleEvaluatorService = inject(BookRuleEvaluatorService);
private sortService = inject(SortService);
bookState$ = this.bookService.bookState$;
dashboardConfig$ = this.dashboardConfigService.config$;
lastReadBooks$: Observable<Book[]> | undefined;
latestAddedBooks$: Observable<Book[]> | undefined;
randomBooks$: Observable<Book[]> | undefined;
private scrollerBooksCache = new Map<string, Observable<Book[]>>();
isLibrariesEmpty$: Observable<boolean> = inject(LibraryService).libraryState$.pipe(
map(state => !state.libraries || state.libraries.length === 0)
);
ScrollerType = ScrollerType;
ngOnInit(): void {
this.dashboardConfig$.subscribe(() => {
this.scrollerBooksCache.clear();
});
this.lastReadBooks$ = this.bookService.bookState$.pipe(
map((state: BookState) => (
(state.books || []).filter(book => book.lastReadTime && (book.readStatus === ReadStatus.READING || book.readStatus === ReadStatus.RE_READING || book.readStatus === ReadStatus.PAUSED))
.sort((a, b) => {
const aTime = new Date(a.lastReadTime!).getTime();
const bTime = new Date(b.lastReadTime!).getTime();
return bTime - aTime;
})
.slice(0, 25)
))
);
this.magicShelfService.shelvesState$.subscribe(() => {
this.scrollerBooksCache.clear();
});
}
this.latestAddedBooks$ = this.bookService.bookState$.pipe(
map((state: BookState) => (
(state.books || [])
.filter(book => book.addedOn)
.sort((a, b) => {
const aTime = new Date(a.addedOn!).getTime();
const bTime = new Date(b.addedOn!).getTime();
return bTime - aTime;
})
.slice(0, 25)
))
);
this.randomBooks$ = this.bookService.bookState$.pipe(
map((state: BookState) => this.getRandomBooks(state.books || [], 15))
private getLastReadBooks(maxItems: number, sortBy?: string): Observable<Book[]> {
return this.bookService.bookState$.pipe(
map((state: BookState) => {
let books = (state.books || []).filter(book => book.lastReadTime);
books = books.sort((a, b) => {
const aTime = new Date(a.lastReadTime!).getTime();
const bTime = new Date(b.lastReadTime!).getTime();
return bTime - aTime;
});
return books.slice(0, maxItems);
})
);
}
private getRandomBooks(books: Book[], count: number): Book[] {
const shuffled = books.sort(() => 0.5 - Math.random());
return shuffled.slice(0, count);
private getLatestAddedBooks(maxItems: number, sortBy?: string): Observable<Book[]> {
return this.bookService.bookState$.pipe(
map((state: BookState) => {
let books = (state.books || []).filter(book => book.addedOn);
books = books.sort((a, b) => {
const aTime = new Date(a.addedOn!).getTime();
const bTime = new Date(b.addedOn!).getTime();
return bTime - aTime;
});
return books.slice(0, maxItems);
})
);
}
private getRandomBooks(maxItems: number, sortBy?: string): Observable<Book[]> {
return this.bookService.bookState$.pipe(
map((state: BookState) => {
return this.shuffleBooks(state.books || [], maxItems);
})
);
}
private getMagicShelfBooks(shelfId: number, maxItems?: number, sortBy?: string): Observable<Book[]> {
return this.magicShelfService.getShelf(shelfId).pipe(
switchMap((shelf) => {
if (!shelf) return this.bookService.bookState$.pipe(map(() => []));
let group: GroupRule;
try {
group = JSON.parse(shelf.filterJson);
} catch (e) {
console.error('Invalid filter JSON', e);
return this.bookService.bookState$.pipe(map(() => []));
}
return this.bookService.bookState$.pipe(
map((state: BookState) => {
let filteredBooks = (state.books || []).filter((book) =>
this.ruleEvaluatorService.evaluateGroup(book, group)
);
return maxItems ? filteredBooks.slice(0, maxItems) : filteredBooks;
})
);
})
);
}
getBooksForScroller(config: ScrollerConfig): Observable<Book[]> {
if (!this.scrollerBooksCache.has(config.id)) {
let books$: Observable<Book[]>;
switch (config.type) {
case ScrollerType.LAST_READ:
books$ = this.getLastReadBooks(config.maxItems || DEFAULT_MAX_ITEMS);
break;
case ScrollerType.LATEST_ADDED:
books$ = this.getLatestAddedBooks(config.maxItems || DEFAULT_MAX_ITEMS);
break;
case ScrollerType.RANDOM:
books$ = this.getRandomBooks(config.maxItems || DEFAULT_MAX_ITEMS);
break;
case ScrollerType.MAGIC_SHELF:
books$ = this.getMagicShelfBooks(config.magicShelfId!, config.maxItems).pipe(
map(books => {
if (config.sortField && config.sortDirection) {
const sortOption = this.createSortOption(config.sortField, config.sortDirection);
return this.sortService.applySort(books, sortOption);
}
return books;
})
);
break;
default:
books$ = this.bookService.bookState$.pipe(map(() => []));
}
this.scrollerBooksCache.set(config.id, books$.pipe(shareReplay(1)));
}
return this.scrollerBooksCache.get(config.id)!;
}
private createSortOption(field: string, direction: string): SortOption {
return {
field: field,
direction: direction === 'asc' ? SortDirection.ASCENDING : SortDirection.DESCENDING,
label: ''
};
}
private shuffleBooks(books: Book[], maxItems: number): Book[] {
const shuffled = [...books];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled.slice(0, maxItems);
}
openDashboardSettings(): void {
this.ref = this.dialogLauncher.open({
component: DashboardSettingsComponent,
header: 'Configure Dashboard',
showHeader: false
});
}
createNewLibrary() {
this.ref = this.dialogService.open(LibraryCreatorComponent, {
header: 'Create New Library',
modal: true,
closable: true
});
this.dialogLauncher.openLibraryCreatorDialog();
}
}

View File

@@ -0,0 +1,32 @@
import {DEFAULT_MAX_ITEMS} from '../components/dashboard-settings/dashboard-settings.component';
export enum ScrollerType {
LAST_READ = 'lastRead',
LATEST_ADDED = 'latestAdded',
RANDOM = 'random',
MAGIC_SHELF = 'magicShelf'
}
export interface ScrollerConfig {
id: string;
type: ScrollerType;
title: string;
enabled: boolean;
order: number;
maxItems: number;
magicShelfId?: number;
sortField?: string;
sortDirection?: string;
}
export interface DashboardConfig {
scrollers: ScrollerConfig[];
}
export const DEFAULT_DASHBOARD_CONFIG: DashboardConfig = {
scrollers: [
{id: '1', type: ScrollerType.LAST_READ, title: 'Continue Reading', enabled: true, order: 1, maxItems: DEFAULT_MAX_ITEMS},
{id: '2', type: ScrollerType.LATEST_ADDED, title: 'Recently Added', enabled: true, order: 2, maxItems: DEFAULT_MAX_ITEMS},
{id: '3', type: ScrollerType.RANDOM, title: 'Discover Something New', enabled: true, order: 3, maxItems: DEFAULT_MAX_ITEMS}
]
};

View File

@@ -0,0 +1,65 @@
import {Injectable} from '@angular/core';
import {BehaviorSubject, Observable} from 'rxjs';
import {DashboardConfig, DEFAULT_DASHBOARD_CONFIG, ScrollerType} from '../models/dashboard-config.model';
import {UserService} from '../../settings/user-management/user.service';
import {filter, take} from 'rxjs/operators';
import {MagicShelfService} from '../../magic-shelf/service/magic-shelf.service';
@Injectable({
providedIn: 'root'
})
export class DashboardConfigService {
private configSubject = new BehaviorSubject<DashboardConfig>(DEFAULT_DASHBOARD_CONFIG);
public config$: Observable<DashboardConfig> = this.configSubject.asObservable();
constructor(private userService: UserService, private magicShelfService: MagicShelfService) {
this.userService.userState$
.pipe(
filter(userState => !!userState?.user && userState.loaded),
take(1)
)
.subscribe(userState => {
const dashboardConfig = userState.user?.userSettings?.dashboardConfig as DashboardConfig;
if (dashboardConfig) {
this.configSubject.next(dashboardConfig);
}
});
this.magicShelfService.shelvesState$.subscribe(state => {
const currentConfig = this.configSubject.value;
let updated = false;
currentConfig.scrollers.forEach(scroller => {
if (scroller.type === ScrollerType.MAGIC_SHELF && scroller.magicShelfId) {
const shelf = state.shelves?.find(s => s.id === scroller.magicShelfId);
if (shelf && scroller.title !== shelf.name) {
scroller.title = shelf.name;
updated = true;
}
}
});
if (updated) {
this.configSubject.next({...currentConfig});
const user = this.userService.getCurrentUser();
if (user) {
this.userService.updateUserSetting(user.id, 'dashboardConfig', currentConfig);
}
}
});
}
saveConfig(config: DashboardConfig): void {
this.configSubject.next(config);
const user = this.userService.getCurrentUser();
if (user) {
this.userService.updateUserSetting(user.id, 'dashboardConfig', config);
}
}
resetToDefault(): void {
this.saveConfig(DEFAULT_DASHBOARD_CONFIG);
}
}

View File

@@ -93,7 +93,7 @@
<p-multiSelect [options]="libraryOptions" formControlName="value" display="chip" class="w-full" appendTo="body" placeholder="Select Libraries"></p-multiSelect>
} @else {
<div class="w-full">
<p-autoComplete [formControl]="ruleCtrl.get('value')" [multiple]="true" [typeahead]="false" [dropdown]="false" [forceSelection]="false" class="w-full" placeholder="Enter values (press Enter or comma)" (onBlur)="onAutoCompleteBlur(ruleCtrl.get('value'), $event)"></p-autoComplete>
<p-autoComplete [formControl]="ruleCtrl.get('value')" [multiple]="true" [typeahead]="false" [dropdown]="false" [forceSelection]="false" class="w-full" placeholder="Enter values (press Enter)" (onBlur)="onAutoCompleteBlur(ruleCtrl.get('value'), $event)"></p-autoComplete>
</div>
}
} @else if (ruleCtrl.get('field')?.value === 'readStatus') {

View File

@@ -59,6 +59,7 @@ export type RuleField =
| 'fileSize'
| 'readStatus'
| 'dateFinished'
| 'lastReadTime'
| 'metadataScore'
| 'moods'
| 'tags';
@@ -110,6 +111,7 @@ const FIELD_CONFIGS: Record<RuleField, FullFieldConfig> = {
library: {label: 'Library'},
readStatus: {label: 'Read Status'},
dateFinished: {label: 'Date Finished', type: 'date'},
lastReadTime: {label: 'Last Read Time', type: 'date'},
metadataScore: {label: 'Metadata Score', type: 'decimal', max: 100},
title: {label: 'Title'},
authors: {label: 'Authors'},

View File

@@ -90,21 +90,37 @@ export class BookRuleEvaluatorService {
return value !== ruleVal;
case 'contains':
if (Array.isArray(value)) {
if (typeof ruleVal !== 'string') return false;
return value.some(v => String(v).includes(ruleVal));
}
if (typeof value !== 'string') return false;
if (typeof ruleVal !== 'string') return false;
return value.includes(ruleVal);
case 'does_not_contain':
if (Array.isArray(value)) {
if (typeof ruleVal !== 'string') return true;
return value.every(v => !String(v).includes(ruleVal));
}
if (typeof value !== 'string') return true;
if (typeof ruleVal !== 'string') return true;
return !value.includes(ruleVal);
case 'starts_with':
if (Array.isArray(value)) {
if (typeof ruleVal !== 'string') return false;
return value.some(v => String(v).startsWith(ruleVal));
}
if (typeof value !== 'string') return false;
if (typeof ruleVal !== 'string') return false;
return value.startsWith(ruleVal);
case 'ends_with':
if (Array.isArray(value)) {
if (typeof ruleVal !== 'string') return false;
return value.some(v => String(v).endsWith(ruleVal));
}
if (typeof value !== 'string') return false;
if (typeof ruleVal !== 'string') return false;
return value.endsWith(ruleVal);
@@ -204,6 +220,8 @@ export class BookRuleEvaluatorService {
return book.metadata?.publishedDate ? new Date(book.metadata.publishedDate) : null;
case 'dateFinished':
return book.dateFinished ? new Date(book.dateFinished) : null;
case 'lastReadTime':
return book.lastReadTime ? new Date(book.lastReadTime) : null;
case 'seriesName':
return book.metadata?.seriesName?.toLowerCase() ?? null;
case 'seriesNumber':

View File

@@ -287,7 +287,7 @@
<div class="flex flex-col gap-1 md:basis-[15%]">
<label class="text-sm" for="publishedDate">Publish Date</label>
<div class="flex justify-between items-center gap-2">
<input pSize="small" pInputText id="publishedDate" formControlName="publishedDate" class="w-full min-w-32"/>
<input pSize="small" pInputText id="publishedDate" formControlName="publishedDate" placeholder="YYYY-MM-DD" class="w-full min-w-32"/>
@if (!book.metadata!['publishedDateLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('publishedDate')" severity="success"></p-button>
}

View File

@@ -71,8 +71,11 @@
@if (metadataForm.get(field.lockedKey)?.value) {
<p-button icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock(field.controlName)" severity="warn"></p-button>
}
<input pSize="small" fluid pInputText id="{{field.controlName}}" formControlName="{{field.controlName}}" class="input"
[disabled]="metadataForm.get(field.lockedKey)?.value"/>
@if (field.controlName === 'publishedDate') {
<input pSize="small" fluid pInputText id="{{field.controlName}}" formControlName="{{field.controlName}}" class="input" placeholder="YYYY-MM-DD"/>
} @else {
<input pSize="small" fluid pInputText id="{{field.controlName}}" formControlName="{{field.controlName}}" class="input"/>
}
<p-button
[icon]="isValueSaved(field.controlName) ? 'pi pi-check' : (hoveredFields[field.controlName] && isValueCopied(field.controlName) ? 'pi pi-times' : 'pi pi-arrow-left')"
[outlined]="true"
@@ -111,8 +114,7 @@
[suggestions]="getFiltered(field.controlName)"
(completeMethod)="filterItems($event, field.controlName)"
(onKeyUp)="onAutoCompleteKeyUp(field.controlName, $event)"
(onSelect)="onAutoCompleteSelect(field.controlName, $event)"
[disabled]="metadataForm.get(field.lockedKey)?.value">
(onSelect)="onAutoCompleteSelect(field.controlName, $event)">
</p-autoComplete>
</div>
<p-button
@@ -150,8 +152,7 @@
@if (metadataForm.get(field.lockedKey)?.value) {
<p-button icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock(field.controlName)" severity="warn"></p-button>
}
<textarea rows="3" pTextarea id="{{field.controlName}}" formControlName="{{field.controlName}}" class="input"
[disabled]="metadataForm.get(field.lockedKey)?.value"></textarea>
<textarea rows="3" pTextarea id="{{field.controlName}}" formControlName="{{field.controlName}}" class="input"></textarea>
<p-button
[icon]="isValueSaved(field.controlName) ? 'pi pi-check' : (hoveredFields[field.controlName] && isValueCopied(field.controlName) ? 'pi pi-times' : 'pi pi-arrow-left')"
[outlined]="true"
@@ -179,8 +180,7 @@
@if (metadataForm.get(field.lockedKey)?.value) {
<p-button icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock(field.controlName)" severity="warn"></p-button>
}
<input pInputText pSize="small" id="{{field.controlName}}" formControlName="{{field.controlName}}" class="input"
[disabled]="metadataForm.get(field.lockedKey)?.value"/>
<input pInputText pSize="small" id="{{field.controlName}}" formControlName="{{field.controlName}}" class="input"/>
<p-button
[icon]="isValueSaved(field.controlName) ? 'pi pi-check' : (hoveredFields[field.controlName] && isValueCopied(field.controlName) ? 'pi pi-times' : 'pi pi-arrow-left')"
[outlined]="true"

View File

@@ -5,6 +5,7 @@ import {API_CONFIG} from '../../../core/config/api-config';
import {Library} from '../../book/model/library.model';
import {catchError, distinctUntilChanged, finalize, shareReplay, tap} from 'rxjs/operators';
import {AuthService} from '../../../shared/service/auth.service';
import {DashboardConfig} from '../../dashboard/models/dashboard-config.model';
export interface EntityViewPreferences {
global: EntityViewPreference;
@@ -130,6 +131,7 @@ export interface UserSettings {
metadataCenterViewMode: 'route' | 'dialog';
entityViewPreferences: EntityViewPreferences;
tableColumnPreference?: TableColumnPreference[];
dashboardConfig?: DashboardConfig;
koReaderEnabled: boolean;
}
@@ -218,17 +220,17 @@ export class UserService {
private fetchMyself(): Observable<User> {
return this.http.get<User>(`${this.userUrl}/me`).pipe(
tap(user => this.userStateSubject.next({ user, loaded: true, error: null })),
tap(user => this.userStateSubject.next({user, loaded: true, error: null})),
catchError(err => {
const curr = this.userStateSubject.value;
this.userStateSubject.next({ user: curr.user, loaded: true, error: err.message });
this.userStateSubject.next({user: curr.user, loaded: true, error: err.message});
throw err;
})
);
}
public setInitialUser(user: User): void {
this.userStateSubject.next({ user, loaded: true, error: null });
this.userStateSubject.next({user, loaded: true, error: null});
}
getCurrentUser(): User | null {

View File

@@ -0,0 +1,125 @@
import {Component, Input} from '@angular/core';
@Component({
standalone: true,
template: ''
})
export class CoverGeneratorComponent {
@Input() title: string = '';
@Input() author: string = '';
private wrapText(text: string, maxLineLength: number): string[] {
const words = text.split(' ');
const lines: string[] = [];
let currentLine = '';
words.forEach(word => {
if (word.length > maxLineLength) {
if (currentLine.length > 0) {
lines.push(currentLine);
currentLine = '';
}
lines.push(word);
} else if (currentLine.length + word.length + 1 > maxLineLength) {
lines.push(currentLine);
currentLine = word;
} else {
currentLine += (currentLine.length > 0 ? ' ' : '') + word;
}
});
if (currentLine.length > 0) {
lines.push(currentLine);
}
return lines;
}
private truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + '...';
}
private calculateTitleFontSize(lineCount: number): number {
if (lineCount <= 2) return 36;
if (lineCount === 3) return 30;
return 24;
}
private calculateAuthorFontSize(lineCount: number): number {
if (lineCount <= 2) return 28;
return 22;
}
generateCover(): string {
const maxTitleLength = 60;
const maxAuthorLength = 40;
const truncatedTitle = this.truncateText(this.title, maxTitleLength);
const truncatedAuthor = this.truncateText(this.author, maxAuthorLength);
const maxLineLength = 12;
const maxTitleLines = 4;
const maxAuthorLines = 3;
let titleLines = this.wrapText(truncatedTitle, maxLineLength);
let authorLines = this.wrapText(truncatedAuthor, maxLineLength);
if (titleLines.length > maxTitleLines) {
titleLines = titleLines.slice(0, maxTitleLines);
titleLines[maxTitleLines - 1] = this.truncateText(titleLines[maxTitleLines - 1], maxLineLength);
}
if (authorLines.length > maxAuthorLines) {
authorLines = authorLines.slice(0, maxAuthorLines);
authorLines[maxAuthorLines - 1] = this.truncateText(authorLines[maxAuthorLines - 1], maxLineLength);
}
const titleFontSize = this.calculateTitleFontSize(titleLines.length);
const authorFontSize = this.calculateAuthorFontSize(authorLines.length);
const titleLineHeight = titleFontSize * 1.2;
const titlePadding = 15;
const titleBoxHeight = titleLines.length * titleLineHeight + (titlePadding * 2);
const titleElements = titleLines.map((line, index) => {
const y = 40 + titlePadding + titleFontSize + (index * titleLineHeight);
return `<text x="20" y="${y}" font-family="serif" font-size="${titleFontSize}" fill="#000000">${line}</text>`;
}).join('\n');
const titleWithBackground = `
<rect x="0" y="40" width="100%" height="${titleBoxHeight}" fill="url(#titleGradient)" />
${titleElements}
`;
const authorLineHeight = authorFontSize * 1.2;
const authorStartY = 330 - (authorLines.length - 1) * authorLineHeight;
const authorElements = authorLines.map((line, index) => {
const y = authorStartY + index * authorLineHeight;
return `<text x="230" y="${y}" text-anchor="end" font-family="sans-serif" font-size="${authorFontSize}" fill="#000000">${line}</text>`;
}).join('\n');
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="250" height="350" viewBox="0 0 250 350">
<defs>
<linearGradient id="titleGradient" >
<stop style="stop-color:#dddddd;stop-opacity:1;" offset="0" id="stop1" />
<stop style="stop-color:#dfdfdf;stop-opacity:0.6;" offset="1" id="stop2" />
</linearGradient>
<linearGradient id="pageGradient">
<stop style="stop-color:#557766;stop-opacity:1;" offset="0" id="stop8" />
<stop style="stop-color:#669988;stop-opacity:1;" offset="1" id="stop9" />
</linearGradient>
</defs>
<rect width="100%" height="100%" fill="url(#pageGradient)" />
${titleWithBackground}
${authorElements}
</svg>
`;
const base64 = btoa(encodeURIComponent(svg).replace(/%([0-9A-F]{2})/g, (match, p1) => {
return String.fromCharCode(parseInt(p1, 16));
}));
return `data:image/svg+xml;base64,${base64}`;
}
}

View File

@@ -45,7 +45,7 @@
}
</li>
<li>
@if (userState.user?.permissions?.canManipulateLibrary || userState.user?.permissions?.admin) {
@if (userState.user?.permissions?.canUpload || userState.user?.permissions?.admin) {
<a class="topbar-item" (click)="openFileUploadDialog()" pTooltip="Upload Book" tooltipPosition="bottom">
<i class="pi pi-upload text-surface-100"></i>
</a>
@@ -167,15 +167,6 @@
Create Library
</button>
</li>
<li>
<button
class="flex items-center gap-2 w-full text-left p-2 hover:bg-surface-200 dark:hover:bg-surface-700 rounded"
(click)="openFileUploadDialog(); mobileMenu.hide()"
>
<i class="pi pi-upload text-surface-100"></i>
Upload Book
</button>
</li>
<li>
<button
class="flex items-center gap-2 w-full text-left p-2 hover:bg-surface-200 dark:hover:bg-surface-700 rounded"
@@ -186,6 +177,17 @@
</button>
</li>
}
@if (userState.user?.permissions?.canUpload || userState.user?.permissions?.admin) {
<li>
<button
class="flex items-center gap-2 w-full text-left p-2 hover:bg-surface-200 dark:hover:bg-surface-700 rounded"
(click)="openFileUploadDialog(); mobileMenu.hide()"
>
<i class="pi pi-upload text-surface-100"></i>
Upload Book
</button>
</li>
}
}
<li>
<button

View File

@@ -1,6 +1,8 @@
import {inject, Injectable} from '@angular/core';
import {API_CONFIG} from '../../core/config/api-config';
import {AuthService} from './auth.service';
import {BookService} from '../../features/book/service/book.service';
import {CoverGeneratorComponent} from '../components/cover-generator/cover-generator.component';
@Injectable({
providedIn: 'root'
@@ -9,6 +11,7 @@ export class UrlHelperService {
private readonly baseUrl = API_CONFIG.BASE_URL;
private readonly mediaBaseUrl = `${this.baseUrl}/api/v1/media`;
private authService = inject(AuthService);
private bookService = inject(BookService);
private getToken(): string | null {
return this.authService.getOidcAccessToken() || this.authService.getInternalAccessToken();
@@ -20,13 +23,33 @@ export class UrlHelperService {
}
getThumbnailUrl(bookId: number, coverUpdatedOn?: string): string {
if (!coverUpdatedOn) return 'assets/images/missing-cover.jpg';
if (!coverUpdatedOn) {
const book = this.bookService.getBookByIdFromState(bookId);
if (book && book.metadata) {
const coverGenerator = new CoverGeneratorComponent();
coverGenerator.title = book.metadata.title || '';
coverGenerator.author = (book.metadata.authors || []).join(', ');
return coverGenerator.generateCover();
} else {
return 'assets/images/missing-cover.jpg';
}
}
const url = `${this.mediaBaseUrl}/book/${bookId}/thumbnail?${coverUpdatedOn}`;
return this.appendToken(url);
}
getCoverUrl(bookId: number, coverUpdatedOn?: string): string {
if (!coverUpdatedOn) return 'assets/images/missing-cover.jpg';
if (!coverUpdatedOn) {
const book = this.bookService.getBookByIdFromState(bookId);
if (book && book.metadata) {
const coverGenerator = new CoverGeneratorComponent();
coverGenerator.title = book.metadata.title || '';
coverGenerator.author = (book.metadata.authors || []).join(', ');
return coverGenerator.generateCover();
} else {
return 'assets/images/missing-cover.jpg';
}
}
const url = `${this.mediaBaseUrl}/book/${bookId}/cover?${coverUpdatedOn}`;
return this.appendToken(url);
}

View File

@@ -13,16 +13,14 @@ export class DialogLauncherService {
dialogService = inject(DialogService);
open(options: { component: any; header: string; top?: string; width?: string }): DynamicDialogRef | null {
open(options: { component: any; header: string; top?: string; width?: string; showHeader?: boolean }): DynamicDialogRef | null {
const isMobile = window.innerWidth <= 768;
const {component, header, top, width} = options;
const {component, header, top, width, showHeader = true} = options;
return this.dialogService.open(component, {
header,
showHeader,
modal: true,
closable: true,
contentStyle: {
overflowY: 'hidden',
},
style: {
position: 'absolute',
...(top ? {top} : {}),

View File

@@ -12,6 +12,29 @@ html {
Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
}
* {
&::-webkit-scrollbar {
width: 12px;
}
&::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
border-radius: 10px;
}
&::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.5);
border-radius: 10px;
&:hover {
background: rgba(128, 128, 128, 0.7);
}
}
scrollbar-width: thin;
scrollbar-color: rgba(128, 128, 128, 0.5) rgba(0, 0, 0, 0.1);
}
.p-toast {
top: 4rem !important;
}

View File

@@ -15,6 +15,7 @@ http {
server {
listen ${BOOKLORE_PORT};
listen [::]:${BOOKLORE_PORT};
# Set the root directory for the server (Angular app)
root /usr/share/nginx/html;