mirror of
https://github.com/adityachandelgit/BookLore.git
synced 2026-01-06 05:59:45 -06:00
Merge pull request #1472 from booklore-app/develop
Merge develop into master for release
This commit is contained in:
12
README.md
12
README.md
@@ -7,7 +7,6 @@
|
||||
[](https://discord.gg/Ee5hd458Uz)
|
||||
[](https://opencollective.com/booklore)
|
||||
[](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.
|
||||

|
||||
|
||||
[](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.
|
||||
|
||||
[](https://github.com/adityachandelgit/BookLore/graphs/contributors)
|
||||
|
||||
## ⚖️ License
|
||||
|
||||
* [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -144,6 +144,7 @@ public class TaskService {
|
||||
|
||||
TaskCreateRequest request = TaskCreateRequest.builder()
|
||||
.taskType(taskType)
|
||||
.triggeredByCron(true)
|
||||
.build();
|
||||
|
||||
runAsSystemUser(request);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
]
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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'},
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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} : {}),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user