diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/OpdsUserV2Controller.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/OpdsUserV2Controller.java index 3224db80..763ce9ce 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/OpdsUserV2Controller.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/OpdsUserV2Controller.java @@ -2,6 +2,7 @@ package com.adityachandel.booklore.controller; import com.adityachandel.booklore.model.dto.OpdsUserV2; import com.adityachandel.booklore.model.dto.request.OpdsUserV2CreateRequest; +import com.adityachandel.booklore.model.dto.request.OpdsUserV2UpdateRequest; import com.adityachandel.booklore.service.opds.OpdsUserV2Service; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -46,4 +47,13 @@ public class OpdsUserV2Controller { @Parameter(description = "ID of the OPDS user to delete") @PathVariable Long id) { service.deleteOpdsUser(id); } -} + + @Operation(summary = "Update OPDS user", description = "Update an OPDS user's settings by ID.") + @ApiResponse(responseCode = "200", description = "OPDS user updated successfully") + @PatchMapping("/{id}") + @PreAuthorize("@securityUtil.isAdmin() or @securityUtil.canAccessOpds()") + public OpdsUserV2 updateUser( + @Parameter(description = "ID of the OPDS user to update") @PathVariable Long id, + @Parameter(description = "OPDS user update request") @RequestBody OpdsUserV2UpdateRequest updateRequest) { + return service.updateOpdsUser(id, updateRequest); + }} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/OpdsUserV2.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/OpdsUserV2.java index 11e78ae5..3f39b710 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/OpdsUserV2.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/OpdsUserV2.java @@ -1,5 +1,6 @@ package com.adityachandel.booklore.model.dto; +import com.adityachandel.booklore.model.enums.OpdsSortOrder; import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.*; @@ -14,4 +15,5 @@ public class OpdsUserV2 { private String username; @JsonIgnore private String passwordHash; + private OpdsSortOrder sortOrder; } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/OpdsUserV2CreateRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/OpdsUserV2CreateRequest.java index 88bb8664..eb087871 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/OpdsUserV2CreateRequest.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/OpdsUserV2CreateRequest.java @@ -1,9 +1,11 @@ package com.adityachandel.booklore.model.dto.request; +import com.adityachandel.booklore.model.enums.OpdsSortOrder; import lombok.Data; @Data public class OpdsUserV2CreateRequest { private String username; private String password; + private OpdsSortOrder sortOrder; } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/OpdsUserV2UpdateRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/OpdsUserV2UpdateRequest.java new file mode 100644 index 00000000..14b3b8fa --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/OpdsUserV2UpdateRequest.java @@ -0,0 +1,10 @@ +package com.adityachandel.booklore.model.dto.request; + +import com.adityachandel.booklore.model.enums.OpdsSortOrder; +import jakarta.validation.constraints.NotNull; + +public record OpdsUserV2UpdateRequest( + @NotNull(message = "Sort order is required") + OpdsSortOrder sortOrder +) { +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/OpdsUserV2Entity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/OpdsUserV2Entity.java index 82895eb4..118364bb 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/OpdsUserV2Entity.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/OpdsUserV2Entity.java @@ -1,5 +1,6 @@ package com.adityachandel.booklore.model.entity; +import com.adityachandel.booklore.model.enums.OpdsSortOrder; import jakarta.persistence.*; import lombok.*; @@ -28,6 +29,11 @@ public class OpdsUserV2Entity { @Column(name = "password_hash", nullable = false) private String passwordHash; + @Enumerated(EnumType.STRING) + @Column(name = "sort_order", length = 20) + @Builder.Default + private OpdsSortOrder sortOrder = OpdsSortOrder.RECENT; + @Column(name = "created_at", nullable = false, updatable = false) private Instant createdAt; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/OpdsSortOrder.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/OpdsSortOrder.java new file mode 100644 index 00000000..6f5aa231 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/OpdsSortOrder.java @@ -0,0 +1,13 @@ +package com.adityachandel.booklore.model.enums; + +public enum OpdsSortOrder { + RECENT, + TITLE_ASC, + TITLE_DESC, + AUTHOR_ASC, + AUTHOR_DESC, + SERIES_ASC, + SERIES_DESC, + RATING_ASC, + RATING_DESC +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsBookService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsBookService.java index ea39f70c..82cc14e8 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsBookService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsBookService.java @@ -7,11 +7,12 @@ import com.adityachandel.booklore.model.dto.*; import com.adityachandel.booklore.model.entity.BookEntity; import com.adityachandel.booklore.model.entity.BookLoreUserEntity; import com.adityachandel.booklore.model.entity.ShelfEntity; +import com.adityachandel.booklore.model.enums.OpdsSortOrder; import com.adityachandel.booklore.repository.BookOpdsRepository; import com.adityachandel.booklore.repository.ShelfRepository; import com.adityachandel.booklore.repository.UserRepository; -import com.adityachandel.booklore.service.library.LibraryService; import com.adityachandel.booklore.util.BookUtils; +import com.adityachandel.booklore.service.library.LibraryService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -400,4 +401,170 @@ public class OpdsBookService { } return dto; } + + public Page applySortOrder(Page booksPage, OpdsSortOrder sortOrder) { + if (sortOrder == null || sortOrder == OpdsSortOrder.RECENT) { + return booksPage; // Already sorted by addedOn DESC from repository + } + + List sortedBooks = new ArrayList<>(booksPage.getContent()); + + switch (sortOrder) { + case TITLE_ASC -> sortedBooks.sort((b1, b2) -> { + String title1 = b1.getMetadata() != null && b1.getMetadata().getTitle() != null + ? b1.getMetadata().getTitle() : ""; + String title2 = b2.getMetadata() != null && b2.getMetadata().getTitle() != null + ? b2.getMetadata().getTitle() : ""; + return title1.compareToIgnoreCase(title2); + }); + case TITLE_DESC -> sortedBooks.sort((b1, b2) -> { + String title1 = b1.getMetadata() != null && b1.getMetadata().getTitle() != null + ? b1.getMetadata().getTitle() : ""; + String title2 = b2.getMetadata() != null && b2.getMetadata().getTitle() != null + ? b2.getMetadata().getTitle() : ""; + return title2.compareToIgnoreCase(title1); + }); + case AUTHOR_ASC -> sortedBooks.sort((b1, b2) -> { + String author1 = getFirstAuthor(b1); + String author2 = getFirstAuthor(b2); + return author1.compareToIgnoreCase(author2); + }); + case AUTHOR_DESC -> sortedBooks.sort((b1, b2) -> { + String author1 = getFirstAuthor(b1); + String author2 = getFirstAuthor(b2); + return author2.compareToIgnoreCase(author1); + }); + case SERIES_ASC -> sortedBooks.sort((b1, b2) -> { + String series1 = getSeriesName(b1); + String series2 = getSeriesName(b2); + boolean hasSeries1 = !series1.isEmpty(); + boolean hasSeries2 = !series2.isEmpty(); + + // Books without series come after books with series + if (!hasSeries1 && !hasSeries2) { + // Both have no series, sort by addedOn descending + return compareByAddedOn(b2, b1); + } + if (!hasSeries1) return 1; + if (!hasSeries2) return -1; + + // Both have series, sort by series name then number + int seriesComp = series1.compareToIgnoreCase(series2); + if (seriesComp != 0) return seriesComp; + return Float.compare(getSeriesNumber(b1), getSeriesNumber(b2)); + }); + case SERIES_DESC -> sortedBooks.sort((b1, b2) -> { + String series1 = getSeriesName(b1); + String series2 = getSeriesName(b2); + boolean hasSeries1 = !series1.isEmpty(); + boolean hasSeries2 = !series2.isEmpty(); + + // Books without series come after books with series + if (!hasSeries1 && !hasSeries2) { + // Both have no series, sort by addedOn descending + return compareByAddedOn(b2, b1); + } + if (!hasSeries1) return 1; + if (!hasSeries2) return -1; + + // Both have series, sort by series name then number + int seriesComp = series2.compareToIgnoreCase(series1); + if (seriesComp != 0) return seriesComp; + return Float.compare(getSeriesNumber(b2), getSeriesNumber(b1)); + }); + case RATING_ASC -> sortedBooks.sort((b1, b2) -> { + Float rating1 = calculateRating(b1); + Float rating2 = calculateRating(b2); + // Books with no rating go to the end + if (rating1 == null && rating2 == null) { + // Both have no rating, fall back to addedOn descending + return compareByAddedOn(b2, b1); + } + if (rating1 == null) return 1; + if (rating2 == null) return -1; + int ratingComp = Float.compare(rating1, rating2); // Ascending order (lowest first) + if (ratingComp != 0) return ratingComp; + // Same rating, fall back to addedOn descending + return compareByAddedOn(b2, b1); + }); + case RATING_DESC -> sortedBooks.sort((b1, b2) -> { + Float rating1 = calculateRating(b1); + Float rating2 = calculateRating(b2); + // Books with no rating go to the end + if (rating1 == null && rating2 == null) { + // Both have no rating, fall back to addedOn descending + return compareByAddedOn(b2, b1); + } + if (rating1 == null) return 1; + if (rating2 == null) return -1; + int ratingComp = Float.compare(rating2, rating1); // Descending order (highest first) + if (ratingComp != 0) return ratingComp; + // Same rating, fall back to addedOn descending + return compareByAddedOn(b2, b1); + }); + } + + return new PageImpl<>(sortedBooks, booksPage.getPageable(), booksPage.getTotalElements()); + } + + private String getFirstAuthor(Book book) { + if (book.getMetadata() != null && book.getMetadata().getAuthors() != null + && !book.getMetadata().getAuthors().isEmpty()) { + return book.getMetadata().getAuthors().iterator().next(); + } + return ""; + } + + private String getSeriesName(Book book) { + if (book.getMetadata() != null && book.getMetadata().getSeriesName() != null) { + return book.getMetadata().getSeriesName(); + } + return ""; + } + + private Float getSeriesNumber(Book book) { + if (book.getMetadata() != null && book.getMetadata().getSeriesNumber() != null) { + return book.getMetadata().getSeriesNumber(); + } + return Float.MAX_VALUE; + } + + private int compareByAddedOn(Book b1, Book b2) { + if (b1.getAddedOn() == null && b2.getAddedOn() == null) return 0; + if (b1.getAddedOn() == null) return 1; + if (b2.getAddedOn() == null) return -1; + return b1.getAddedOn().compareTo(b2.getAddedOn()); + } + + private Float calculateRating(Book book) { + if (book.getMetadata() == null) { + return null; + } + + Double hardcoverRating = book.getMetadata().getHardcoverRating(); + Double amazonRating = book.getMetadata().getAmazonRating(); + Double goodreadsRating = book.getMetadata().getGoodreadsRating(); + + double sum = 0; + int count = 0; + + if (hardcoverRating != null && hardcoverRating > 0) { + sum += hardcoverRating; + count++; + } + if (amazonRating != null && amazonRating > 0) { + sum += amazonRating; + count++; + } + if (goodreadsRating != null && goodreadsRating > 0) { + sum += goodreadsRating; + count++; + } + + if (count == 0) { + return null; + } + + return (float) (sum / count); + } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsFeedService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsFeedService.java index 3372cbce..18e80ea9 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsFeedService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsFeedService.java @@ -5,6 +5,7 @@ import com.adityachandel.booklore.config.security.userdetails.OpdsUserDetails; import com.adityachandel.booklore.model.dto.Book; import com.adityachandel.booklore.model.dto.Library; import com.adityachandel.booklore.model.dto.MagicShelf; +import com.adityachandel.booklore.model.enums.OpdsSortOrder; import com.adityachandel.booklore.service.MagicShelfService; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; @@ -328,6 +329,7 @@ public class OpdsFeedService { int size = Math.min(parseLongParam(request, "size", (long) DEFAULT_PAGE_SIZE).intValue(), MAX_PAGE_SIZE); Long userId = getUserId(); + OpdsSortOrder sortOrder = getSortOrder(); Page booksPage; if (magicShelfId != null) { @@ -340,6 +342,9 @@ public class OpdsFeedService { booksPage = opdsBookService.getBooksPage(userId, query, libraryId, shelfId, page - 1, size); } + // Apply user's preferred sort order + booksPage = opdsBookService.applySortOrder(booksPage, sortOrder); + String feedTitle = determineFeedTitle(libraryId, shelfId, magicShelfId, author, series); String feedId = determineFeedId(libraryId, shelfId, magicShelfId, author, series); @@ -375,10 +380,14 @@ public class OpdsFeedService { public String generateRecentFeed(HttpServletRequest request) { Long userId = getUserId(); + OpdsSortOrder sortOrder = getSortOrder(); int page = Math.max(1, parseLongParam(request, "page", 1L).intValue()); int size = Math.min(parseLongParam(request, "size", (long) DEFAULT_PAGE_SIZE).intValue(), MAX_PAGE_SIZE); Page booksPage = opdsBookService.getRecentBooksPage(userId, page - 1, size); + + // Apply user's preferred sort order + booksPage = opdsBookService.applySortOrder(booksPage, sortOrder); var feed = new StringBuilder(""" @@ -630,4 +639,11 @@ public class OpdsFeedService { ? details.getOpdsUserV2().getUserId() : null; } + + private OpdsSortOrder getSortOrder() { + OpdsUserDetails details = authenticationService.getOpdsUser(); + return details != null && details.getOpdsUserV2() != null && details.getOpdsUserV2().getSortOrder() != null + ? details.getOpdsUserV2().getSortOrder() + : OpdsSortOrder.RECENT; + } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsUserV2Service.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsUserV2Service.java index 222f88ea..063ad555 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsUserV2Service.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsUserV2Service.java @@ -5,6 +5,7 @@ import com.adityachandel.booklore.mapper.OpdsUserV2Mapper; import com.adityachandel.booklore.model.dto.BookLoreUser; import com.adityachandel.booklore.model.dto.OpdsUserV2; import com.adityachandel.booklore.model.dto.request.OpdsUserV2CreateRequest; +import com.adityachandel.booklore.model.dto.request.OpdsUserV2UpdateRequest; import com.adityachandel.booklore.model.entity.BookLoreUserEntity; import com.adityachandel.booklore.model.entity.OpdsUserV2Entity; import com.adityachandel.booklore.repository.OpdsUserV2Repository; @@ -45,6 +46,7 @@ public class OpdsUserV2Service { .user(userEntity) .username(request.getUsername()) .passwordHash(passwordEncoder.encode(request.getPassword())) + .sortOrder(request.getSortOrder() != null ? request.getSortOrder() : com.adityachandel.booklore.model.enums.OpdsSortOrder.RECENT) .build(); return mapper.toDto(opdsUserV2Repository.save(opdsUserV2)); @@ -64,4 +66,17 @@ public class OpdsUserV2Service { } opdsUserV2Repository.delete(user); } + + public OpdsUserV2 updateOpdsUser(Long userId, OpdsUserV2UpdateRequest request) { + BookLoreUser bookLoreUser = authenticationService.getAuthenticatedUser(); + OpdsUserV2Entity user = opdsUserV2Repository.findById(userId) + .orElseThrow(() -> new RuntimeException("User not found with ID: " + userId)); + + if (!user.getUser().getId().equals(bookLoreUser.getId())) { + throw new AccessDeniedException("You are not allowed to update this user"); + } + + user.setSortOrder(request.sortOrder()); + return mapper.toDto(opdsUserV2Repository.save(user)); + } } \ No newline at end of file diff --git a/booklore-api/src/main/resources/db/migration/V99__Add_sort_order_to_opds_user_v2.sql b/booklore-api/src/main/resources/db/migration/V99__Add_sort_order_to_opds_user_v2.sql new file mode 100644 index 00000000..e981c7f9 --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V99__Add_sort_order_to_opds_user_v2.sql @@ -0,0 +1,2 @@ +-- Add sort_order column to opds_user_v2 table +ALTER TABLE opds_user_v2 ADD COLUMN sort_order VARCHAR(20) NOT NULL DEFAULT 'RECENT'; diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/model/enums/OpdsSortOrder.java b/booklore-api/src/test/java/com/adityachandel/booklore/model/enums/OpdsSortOrder.java new file mode 100644 index 00000000..6f5aa231 --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/model/enums/OpdsSortOrder.java @@ -0,0 +1,13 @@ +package com.adityachandel.booklore.model.enums; + +public enum OpdsSortOrder { + RECENT, + TITLE_ASC, + TITLE_DESC, + AUTHOR_ASC, + AUTHOR_DESC, + SERIES_ASC, + SERIES_DESC, + RATING_ASC, + RATING_DESC +} diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/opds/OpdsFeedServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/opds/OpdsFeedServiceTest.java index 84715638..b3675d77 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/opds/OpdsFeedServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/opds/OpdsFeedServiceTest.java @@ -8,6 +8,7 @@ import com.adityachandel.booklore.model.dto.Library; import com.adityachandel.booklore.model.dto.OpdsUserV2; import com.adityachandel.booklore.model.entity.ShelfEntity; import com.adityachandel.booklore.model.enums.BookFileType; +import com.adityachandel.booklore.model.enums.OpdsSortOrder; import com.adityachandel.booklore.service.MagicShelfService; import jakarta.servlet.http.HttpServletRequest; import org.junit.jupiter.api.BeforeEach; @@ -51,6 +52,7 @@ class OpdsFeedServiceTest { OpdsUserV2 v2 = mock(OpdsUserV2.class); when(userDetails.getOpdsUserV2()).thenReturn(v2); when(v2.getUserId()).thenReturn(TEST_USER_ID); + when(v2.getSortOrder()).thenReturn(OpdsSortOrder.RECENT); when(authenticationService.getOpdsUser()).thenReturn(userDetails); return userDetails; } @@ -152,6 +154,7 @@ class OpdsFeedServiceTest { Page page = new PageImpl<>(List.of(book), PageRequest.of(0, 50), 1); when(opdsBookService.getBooksPage(eq(TEST_USER_ID), any(), any(), any(), eq(0), eq(50))).thenReturn(page); + when(opdsBookService.applySortOrder(any(), any())).thenReturn(page); String xml = opdsFeedService.generateCatalogFeed(request); assertThat(xml).contains("Book Title"); @@ -173,6 +176,7 @@ class OpdsFeedServiceTest { Page page = new PageImpl<>(Collections.emptyList(), PageRequest.of(0, 50), 0); when(opdsBookService.getBooksPage(any(), any(), any(), any(), anyInt(), anyInt())).thenReturn(page); + when(opdsBookService.applySortOrder(any(), any())).thenReturn(page); String xml = opdsFeedService.generateCatalogFeed(request); assertThat(xml).contains(""); @@ -196,6 +200,7 @@ class OpdsFeedServiceTest { Page page = new PageImpl<>(List.of(book), PageRequest.of(0, 50), 1); when(opdsBookService.getRecentBooksPage(eq(TEST_USER_ID), eq(0), eq(50))).thenReturn(page); + when(opdsBookService.applySortOrder(any(), any())).thenReturn(page); String xml = opdsFeedService.generateRecentFeed(request); assertThat(xml).contains("Recent Book"); @@ -214,6 +219,7 @@ class OpdsFeedServiceTest { Page page = new PageImpl<>(Collections.emptyList(), PageRequest.of(0, 50), 0); when(opdsBookService.getRecentBooksPage(any(), anyInt(), anyInt())).thenReturn(page); + when(opdsBookService.applySortOrder(any(), any())).thenReturn(page); String xml = opdsFeedService.generateRecentFeed(request); assertThat(xml).contains(""); diff --git a/booklore-ui/src/app/features/settings/opds-settings/opds-settings.html b/booklore-ui/src/app/features/settings/opds-settings/opds-settings.html index 02426729..870ccf0e 100644 --- a/booklore-ui/src/app/features/settings/opds-settings/opds-settings.html +++ b/booklore-ui/src/app/features/settings/opds-settings/opds-settings.html @@ -113,6 +113,12 @@ Username + +
+ + Sort Order +
+
@@ -137,6 +143,47 @@ {{ user.username }}
+ + @if (editingUserId === user.id) { +
+ + + + + + +
+ } @else { +
+ {{ getSortOrderLabel(user.sortOrder) }} + + +
+ } +
- +

No users found

@@ -211,6 +258,23 @@ [(ngModel)]="newUser.password" placeholder="Enter password"/>
+
+ + + + + + This will determine how books appear in the OPDS feed for this user + +
diff --git a/booklore-ui/src/app/features/settings/opds-settings/opds-settings.scss b/booklore-ui/src/app/features/settings/opds-settings/opds-settings.scss index fb02f36d..2c19000f 100644 --- a/booklore-ui/src/app/features/settings/opds-settings/opds-settings.scss +++ b/booklore-ui/src/app/features/settings/opds-settings/opds-settings.scss @@ -264,6 +264,17 @@ font-weight: 500; } +.sort-order-badge { + display: inline-flex; + align-items: center; + padding: 0.375rem 0.75rem; + border-radius: 1rem; + background: var(--p-primary-50); + color: var(--p-primary-700); + font-size: 0.8125rem; + font-weight: 500; +} + .actions-cell { text-align: center; } @@ -332,6 +343,22 @@ color: var(--p-text-muted-color); } } + + .form-hint { + display: flex; + align-items: flex-start; + gap: 0.375rem; + color: var(--p-text-muted-color); + font-size: 0.75rem; + line-height: 1.4; + margin-top: 0.25rem; + + .pi { + font-size: 0.625rem; + margin-top: 0.125rem; + flex-shrink: 0; + } + } } .dialog-actions { diff --git a/booklore-ui/src/app/features/settings/opds-settings/opds-settings.ts b/booklore-ui/src/app/features/settings/opds-settings/opds-settings.ts index 5a20921d..bcd09557 100644 --- a/booklore-ui/src/app/features/settings/opds-settings/opds-settings.ts +++ b/booklore-ui/src/app/features/settings/opds-settings/opds-settings.ts @@ -9,7 +9,7 @@ import {Dialog} from 'primeng/dialog'; import {FormsModule} from '@angular/forms'; import {ConfirmDialog} from 'primeng/confirmdialog'; import {ConfirmationService, MessageService} from 'primeng/api'; -import {OpdsService, OpdsUserV2, OpdsUserV2CreateRequest} from './opds.service'; +import {OpdsService, OpdsSortOrder, OpdsUserV2, OpdsUserV2CreateRequest} from './opds.service'; import {catchError, filter, take, takeUntil, tap} from 'rxjs/operators'; import {UserService} from '../user-management/user.service'; import {of, Subject} from 'rxjs'; @@ -18,6 +18,7 @@ import {ToggleSwitch} from 'primeng/toggleswitch'; import {AppSettingsService} from '../../../shared/service/app-settings.service'; import {AppSettingKey} from '../../../shared/model/app-settings.model'; import {ExternalDocLinkComponent} from '../../../shared/components/external-doc-link/external-doc-link.component'; +import {Select} from 'primeng/select'; @Component({ selector: 'app-opds-settings', @@ -32,7 +33,8 @@ import {ExternalDocLinkComponent} from '../../../shared/components/external-doc- TableModule, Password, ToggleSwitch, - ExternalDocLinkComponent + ExternalDocLinkComponent, + Select ], providers: [ConfirmationService], templateUrl: './opds-settings.html', @@ -52,13 +54,28 @@ export class OpdsSettings implements OnInit, OnDestroy { users: OpdsUserV2[] = []; loading = false; showCreateUserDialog = false; - newUser: OpdsUserV2CreateRequest = {username: '', password: ''}; + newUser: OpdsUserV2CreateRequest = {username: '', password: '', sortOrder: 'RECENT'}; passwordVisibility: boolean[] = []; hasPermission = false; + editingUserId: number | null = null; + editingSortOrder: OpdsSortOrder | null = null; + private readonly destroy$ = new Subject(); dummyPassword: string = "***********************"; + sortOrderOptions = [ + { label: 'Recently Added', value: 'RECENT' as OpdsSortOrder }, + { label: 'Title (A-Z)', value: 'TITLE_ASC' as OpdsSortOrder }, + { label: 'Title (Z-A)', value: 'TITLE_DESC' as OpdsSortOrder }, + { label: 'Author (A-Z)', value: 'AUTHOR_ASC' as OpdsSortOrder }, + { label: 'Author (Z-A)', value: 'AUTHOR_DESC' as OpdsSortOrder }, + { label: 'Series (A-Z)', value: 'SERIES_ASC' as OpdsSortOrder }, + { label: 'Series (Z-A)', value: 'SERIES_DESC' as OpdsSortOrder }, + { label: 'Rating (Low to High)', value: 'RATING_ASC' as OpdsSortOrder }, + { label: 'Rating (High to Low)', value: 'RATING_DESC' as OpdsSortOrder } + ]; + ngOnInit(): void { this.loading = true; @@ -189,13 +206,51 @@ export class OpdsSettings implements OnInit, OnDestroy { private resetCreateUserDialog(): void { this.showCreateUserDialog = false; - this.newUser = {username: '', password: ''}; + this.newUser = {username: '', password: '', sortOrder: 'RECENT'}; } private showMessage(severity: string, summary: string, detail: string): void { this.messageService.add({severity, summary, detail}); } + getSortOrderLabel(sortOrder?: OpdsSortOrder): string { + if (!sortOrder) return 'Recently Added'; + const option = this.sortOrderOptions.find(o => o.value === sortOrder); + return option ? option.label : 'Recently Added'; + } + + startEdit(user: OpdsUserV2): void { + this.editingUserId = user.id; + this.editingSortOrder = user.sortOrder || 'RECENT'; + } + + cancelEdit(): void { + this.editingUserId = null; + this.editingSortOrder = null; + } + + saveSortOrder(user: OpdsUserV2): void { + if (!this.editingSortOrder || !user.id) return; + + this.opdsService.updateUser(user.id, this.editingSortOrder).pipe( + takeUntil(this.destroy$), + catchError(err => { + console.error('Error updating sort order:', err); + this.showMessage('error', 'Error', 'Failed to update sort order'); + return of(null); + }) + ).subscribe(updatedUser => { + if (updatedUser) { + const index = this.users.findIndex(u => u.id === user.id); + if (index !== -1) { + this.users[index] = updatedUser; + } + this.showMessage('success', 'Success', 'Sort order updated successfully'); + } + this.cancelEdit(); + }); + } + ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); diff --git a/booklore-ui/src/app/features/settings/opds-settings/opds.service.ts b/booklore-ui/src/app/features/settings/opds-settings/opds.service.ts index a45bfdcd..772cb77f 100644 --- a/booklore-ui/src/app/features/settings/opds-settings/opds.service.ts +++ b/booklore-ui/src/app/features/settings/opds-settings/opds.service.ts @@ -3,15 +3,23 @@ import {HttpClient} from '@angular/common/http'; import {Observable} from 'rxjs'; import {API_CONFIG} from '../../../core/config/api-config'; +export type OpdsSortOrder = 'RECENT' | 'TITLE_ASC' | 'TITLE_DESC' | 'AUTHOR_ASC' | 'AUTHOR_DESC' | 'SERIES_ASC' | 'SERIES_DESC' | 'RATING_ASC' | 'RATING_DESC'; + export interface OpdsUserV2CreateRequest { username: string; password: string; + sortOrder?: OpdsSortOrder; +} + +export interface OpdsUserV2UpdateRequest { + sortOrder: OpdsSortOrder; } export interface OpdsUserV2 { id: number; userId: number; username: string; + sortOrder?: OpdsSortOrder; } @Injectable({ @@ -30,6 +38,10 @@ export class OpdsService { return this.http.post(this.baseUrl, user); } + updateUser(id: number, sortOrder: OpdsSortOrder): Observable { + return this.http.patch(`${this.baseUrl}/${id}`, { sortOrder }); + } + deleteCredential(id: number): Observable { return this.http.delete(`${this.baseUrl}/${id}`); }