mirror of
https://github.com/booklore-app/booklore.git
synced 2025-12-23 22:28:11 -05:00
feat(opds): allow user to set sorting for opds feed in settings (#1824)
* feat(opds): allow user to set sorting for opds feed in settings * patch(opds): re-add search normalization * patch(opds): add series to feedid determination --------- Co-authored-by: WorldTeacher <admin@theprivateserver.de>
This commit is contained in:
@@ -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);
|
||||
}}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<Book> applySortOrder(Page<Book> booksPage, OpdsSortOrder sortOrder) {
|
||||
if (sortOrder == null || sortOrder == OpdsSortOrder.RECENT) {
|
||||
return booksPage; // Already sorted by addedOn DESC from repository
|
||||
}
|
||||
|
||||
List<Book> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Book> 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,11 +380,15 @@ 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<Book> booksPage = opdsBookService.getRecentBooksPage(userId, page - 1, size);
|
||||
|
||||
// Apply user's preferred sort order
|
||||
booksPage = opdsBookService.applySortOrder(booksPage, sortOrder);
|
||||
|
||||
var feed = new StringBuilder("""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/terms/" xmlns:opds="http://opds-spec.org/2010/catalog" xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/">
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<Book> 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<Book> 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("</feed>");
|
||||
@@ -196,6 +200,7 @@ class OpdsFeedServiceTest {
|
||||
|
||||
Page<Book> 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<Book> 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("</feed>");
|
||||
|
||||
@@ -113,6 +113,12 @@
|
||||
<span>Username</span>
|
||||
</div>
|
||||
</th>
|
||||
<th>
|
||||
<div class="header-content">
|
||||
<i class="pi pi-sort-alt"></i>
|
||||
<span>Sort Order</span>
|
||||
</div>
|
||||
</th>
|
||||
<th>
|
||||
<div class="header-content">
|
||||
<i class="pi pi-key"></i>
|
||||
@@ -137,6 +143,47 @@
|
||||
<span class="username">{{ user.username }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@if (editingUserId === user.id) {
|
||||
<div class="flex items-center gap-2">
|
||||
<p-select
|
||||
[options]="sortOrderOptions"
|
||||
[(ngModel)]="editingSortOrder"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
[style]="{width: '180px'}"
|
||||
size="small"
|
||||
[appendTo]="'body'">
|
||||
</p-select>
|
||||
<p-button
|
||||
icon="pi pi-check"
|
||||
severity="success"
|
||||
size="small"
|
||||
[text]="true"
|
||||
(onClick)="saveSortOrder(user)">
|
||||
</p-button>
|
||||
<p-button
|
||||
icon="pi pi-times"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
[text]="true"
|
||||
(onClick)="cancelEdit()">
|
||||
</p-button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="sort-order-badge">{{ getSortOrderLabel(user.sortOrder) }}</span>
|
||||
<p-button
|
||||
icon="pi pi-pencil"
|
||||
severity="info"
|
||||
size="small"
|
||||
[text]="true"
|
||||
(onClick)="startEdit(user)"
|
||||
pTooltip="Edit sort order">
|
||||
</p-button>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<p-password
|
||||
@@ -171,7 +218,7 @@
|
||||
</ng-template>
|
||||
<ng-template pTemplate="emptymessage">
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
<td colspan="4">
|
||||
<div class="empty-message">
|
||||
<i class="pi pi-users"></i>
|
||||
<p class="empty-title">No users found</p>
|
||||
@@ -211,6 +258,23 @@
|
||||
[(ngModel)]="newUser.password"
|
||||
placeholder="Enter password"/>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="sortOrder">Default Sort Order</label>
|
||||
<p-select
|
||||
id="sortOrder"
|
||||
[options]="sortOrderOptions"
|
||||
[(ngModel)]="newUser.sortOrder"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
[style]="{width: '100%'}"
|
||||
placeholder="Select sort order"
|
||||
[appendTo]="'body'">
|
||||
</p-select>
|
||||
<small class="form-hint">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
This will determine how books appear in the OPDS feed for this user
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<ng-template pTemplate="footer">
|
||||
<div class="dialog-actions">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<void>();
|
||||
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();
|
||||
|
||||
@@ -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<OpdsUserV2>(this.baseUrl, user);
|
||||
}
|
||||
|
||||
updateUser(id: number, sortOrder: OpdsSortOrder): Observable<OpdsUserV2> {
|
||||
return this.http.patch<OpdsUserV2>(`${this.baseUrl}/${id}`, { sortOrder });
|
||||
}
|
||||
|
||||
deleteCredential(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseUrl}/${id}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user