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.OpdsUserV2;
|
||||||
import com.adityachandel.booklore.model.dto.request.OpdsUserV2CreateRequest;
|
import com.adityachandel.booklore.model.dto.request.OpdsUserV2CreateRequest;
|
||||||
|
import com.adityachandel.booklore.model.dto.request.OpdsUserV2UpdateRequest;
|
||||||
import com.adityachandel.booklore.service.opds.OpdsUserV2Service;
|
import com.adityachandel.booklore.service.opds.OpdsUserV2Service;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
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) {
|
@Parameter(description = "ID of the OPDS user to delete") @PathVariable Long id) {
|
||||||
service.deleteOpdsUser(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;
|
package com.adityachandel.booklore.model.dto;
|
||||||
|
|
||||||
|
import com.adityachandel.booklore.model.enums.OpdsSortOrder;
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
|
|
||||||
@@ -14,4 +15,5 @@ public class OpdsUserV2 {
|
|||||||
private String username;
|
private String username;
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
private String passwordHash;
|
private String passwordHash;
|
||||||
|
private OpdsSortOrder sortOrder;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package com.adityachandel.booklore.model.dto.request;
|
package com.adityachandel.booklore.model.dto.request;
|
||||||
|
|
||||||
|
import com.adityachandel.booklore.model.enums.OpdsSortOrder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class OpdsUserV2CreateRequest {
|
public class OpdsUserV2CreateRequest {
|
||||||
private String username;
|
private String username;
|
||||||
private String password;
|
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;
|
package com.adityachandel.booklore.model.entity;
|
||||||
|
|
||||||
|
import com.adityachandel.booklore.model.enums.OpdsSortOrder;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
|
|
||||||
@@ -28,6 +29,11 @@ public class OpdsUserV2Entity {
|
|||||||
@Column(name = "password_hash", nullable = false)
|
@Column(name = "password_hash", nullable = false)
|
||||||
private String passwordHash;
|
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)
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
private Instant createdAt;
|
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.BookEntity;
|
||||||
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
|
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
|
||||||
import com.adityachandel.booklore.model.entity.ShelfEntity;
|
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.BookOpdsRepository;
|
||||||
import com.adityachandel.booklore.repository.ShelfRepository;
|
import com.adityachandel.booklore.repository.ShelfRepository;
|
||||||
import com.adityachandel.booklore.repository.UserRepository;
|
import com.adityachandel.booklore.repository.UserRepository;
|
||||||
import com.adityachandel.booklore.service.library.LibraryService;
|
|
||||||
import com.adityachandel.booklore.util.BookUtils;
|
import com.adityachandel.booklore.util.BookUtils;
|
||||||
|
import com.adityachandel.booklore.service.library.LibraryService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
@@ -400,4 +401,170 @@ public class OpdsBookService {
|
|||||||
}
|
}
|
||||||
return dto;
|
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.Book;
|
||||||
import com.adityachandel.booklore.model.dto.Library;
|
import com.adityachandel.booklore.model.dto.Library;
|
||||||
import com.adityachandel.booklore.model.dto.MagicShelf;
|
import com.adityachandel.booklore.model.dto.MagicShelf;
|
||||||
|
import com.adityachandel.booklore.model.enums.OpdsSortOrder;
|
||||||
import com.adityachandel.booklore.service.MagicShelfService;
|
import com.adityachandel.booklore.service.MagicShelfService;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import lombok.RequiredArgsConstructor;
|
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);
|
int size = Math.min(parseLongParam(request, "size", (long) DEFAULT_PAGE_SIZE).intValue(), MAX_PAGE_SIZE);
|
||||||
|
|
||||||
Long userId = getUserId();
|
Long userId = getUserId();
|
||||||
|
OpdsSortOrder sortOrder = getSortOrder();
|
||||||
Page<Book> booksPage;
|
Page<Book> booksPage;
|
||||||
|
|
||||||
if (magicShelfId != null) {
|
if (magicShelfId != null) {
|
||||||
@@ -340,6 +342,9 @@ public class OpdsFeedService {
|
|||||||
booksPage = opdsBookService.getBooksPage(userId, query, libraryId, shelfId, page - 1, size);
|
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 feedTitle = determineFeedTitle(libraryId, shelfId, magicShelfId, author, series);
|
||||||
String feedId = determineFeedId(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) {
|
public String generateRecentFeed(HttpServletRequest request) {
|
||||||
Long userId = getUserId();
|
Long userId = getUserId();
|
||||||
|
OpdsSortOrder sortOrder = getSortOrder();
|
||||||
int page = Math.max(1, parseLongParam(request, "page", 1L).intValue());
|
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);
|
int size = Math.min(parseLongParam(request, "size", (long) DEFAULT_PAGE_SIZE).intValue(), MAX_PAGE_SIZE);
|
||||||
|
|
||||||
Page<Book> booksPage = opdsBookService.getRecentBooksPage(userId, page - 1, 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("""
|
var feed = new StringBuilder("""
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
@@ -630,4 +639,11 @@ public class OpdsFeedService {
|
|||||||
? details.getOpdsUserV2().getUserId()
|
? details.getOpdsUserV2().getUserId()
|
||||||
: null;
|
: 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.BookLoreUser;
|
||||||
import com.adityachandel.booklore.model.dto.OpdsUserV2;
|
import com.adityachandel.booklore.model.dto.OpdsUserV2;
|
||||||
import com.adityachandel.booklore.model.dto.request.OpdsUserV2CreateRequest;
|
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.BookLoreUserEntity;
|
||||||
import com.adityachandel.booklore.model.entity.OpdsUserV2Entity;
|
import com.adityachandel.booklore.model.entity.OpdsUserV2Entity;
|
||||||
import com.adityachandel.booklore.repository.OpdsUserV2Repository;
|
import com.adityachandel.booklore.repository.OpdsUserV2Repository;
|
||||||
@@ -45,6 +46,7 @@ public class OpdsUserV2Service {
|
|||||||
.user(userEntity)
|
.user(userEntity)
|
||||||
.username(request.getUsername())
|
.username(request.getUsername())
|
||||||
.passwordHash(passwordEncoder.encode(request.getPassword()))
|
.passwordHash(passwordEncoder.encode(request.getPassword()))
|
||||||
|
.sortOrder(request.getSortOrder() != null ? request.getSortOrder() : com.adityachandel.booklore.model.enums.OpdsSortOrder.RECENT)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
return mapper.toDto(opdsUserV2Repository.save(opdsUserV2));
|
return mapper.toDto(opdsUserV2Repository.save(opdsUserV2));
|
||||||
@@ -64,4 +66,17 @@ public class OpdsUserV2Service {
|
|||||||
}
|
}
|
||||||
opdsUserV2Repository.delete(user);
|
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.dto.OpdsUserV2;
|
||||||
import com.adityachandel.booklore.model.entity.ShelfEntity;
|
import com.adityachandel.booklore.model.entity.ShelfEntity;
|
||||||
import com.adityachandel.booklore.model.enums.BookFileType;
|
import com.adityachandel.booklore.model.enums.BookFileType;
|
||||||
|
import com.adityachandel.booklore.model.enums.OpdsSortOrder;
|
||||||
import com.adityachandel.booklore.service.MagicShelfService;
|
import com.adityachandel.booklore.service.MagicShelfService;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -51,6 +52,7 @@ class OpdsFeedServiceTest {
|
|||||||
OpdsUserV2 v2 = mock(OpdsUserV2.class);
|
OpdsUserV2 v2 = mock(OpdsUserV2.class);
|
||||||
when(userDetails.getOpdsUserV2()).thenReturn(v2);
|
when(userDetails.getOpdsUserV2()).thenReturn(v2);
|
||||||
when(v2.getUserId()).thenReturn(TEST_USER_ID);
|
when(v2.getUserId()).thenReturn(TEST_USER_ID);
|
||||||
|
when(v2.getSortOrder()).thenReturn(OpdsSortOrder.RECENT);
|
||||||
when(authenticationService.getOpdsUser()).thenReturn(userDetails);
|
when(authenticationService.getOpdsUser()).thenReturn(userDetails);
|
||||||
return userDetails;
|
return userDetails;
|
||||||
}
|
}
|
||||||
@@ -152,6 +154,7 @@ class OpdsFeedServiceTest {
|
|||||||
|
|
||||||
Page<Book> page = new PageImpl<>(List.of(book), PageRequest.of(0, 50), 1);
|
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.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);
|
String xml = opdsFeedService.generateCatalogFeed(request);
|
||||||
assertThat(xml).contains("Book Title");
|
assertThat(xml).contains("Book Title");
|
||||||
@@ -173,6 +176,7 @@ class OpdsFeedServiceTest {
|
|||||||
|
|
||||||
Page<Book> page = new PageImpl<>(Collections.emptyList(), PageRequest.of(0, 50), 0);
|
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.getBooksPage(any(), any(), any(), any(), anyInt(), anyInt())).thenReturn(page);
|
||||||
|
when(opdsBookService.applySortOrder(any(), any())).thenReturn(page);
|
||||||
|
|
||||||
String xml = opdsFeedService.generateCatalogFeed(request);
|
String xml = opdsFeedService.generateCatalogFeed(request);
|
||||||
assertThat(xml).contains("</feed>");
|
assertThat(xml).contains("</feed>");
|
||||||
@@ -196,6 +200,7 @@ class OpdsFeedServiceTest {
|
|||||||
|
|
||||||
Page<Book> page = new PageImpl<>(List.of(book), PageRequest.of(0, 50), 1);
|
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.getRecentBooksPage(eq(TEST_USER_ID), eq(0), eq(50))).thenReturn(page);
|
||||||
|
when(opdsBookService.applySortOrder(any(), any())).thenReturn(page);
|
||||||
|
|
||||||
String xml = opdsFeedService.generateRecentFeed(request);
|
String xml = opdsFeedService.generateRecentFeed(request);
|
||||||
assertThat(xml).contains("Recent Book");
|
assertThat(xml).contains("Recent Book");
|
||||||
@@ -214,6 +219,7 @@ class OpdsFeedServiceTest {
|
|||||||
|
|
||||||
Page<Book> page = new PageImpl<>(Collections.emptyList(), PageRequest.of(0, 50), 0);
|
Page<Book> page = new PageImpl<>(Collections.emptyList(), PageRequest.of(0, 50), 0);
|
||||||
when(opdsBookService.getRecentBooksPage(any(), anyInt(), anyInt())).thenReturn(page);
|
when(opdsBookService.getRecentBooksPage(any(), anyInt(), anyInt())).thenReturn(page);
|
||||||
|
when(opdsBookService.applySortOrder(any(), any())).thenReturn(page);
|
||||||
|
|
||||||
String xml = opdsFeedService.generateRecentFeed(request);
|
String xml = opdsFeedService.generateRecentFeed(request);
|
||||||
assertThat(xml).contains("</feed>");
|
assertThat(xml).contains("</feed>");
|
||||||
|
|||||||
@@ -113,6 +113,12 @@
|
|||||||
<span>Username</span>
|
<span>Username</span>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
|
<th>
|
||||||
|
<div class="header-content">
|
||||||
|
<i class="pi pi-sort-alt"></i>
|
||||||
|
<span>Sort Order</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
<th>
|
<th>
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<i class="pi pi-key"></i>
|
<i class="pi pi-key"></i>
|
||||||
@@ -137,6 +143,47 @@
|
|||||||
<span class="username">{{ user.username }}</span>
|
<span class="username">{{ user.username }}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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>
|
<td>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<p-password
|
<p-password
|
||||||
@@ -171,7 +218,7 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-template pTemplate="emptymessage">
|
<ng-template pTemplate="emptymessage">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3">
|
<td colspan="4">
|
||||||
<div class="empty-message">
|
<div class="empty-message">
|
||||||
<i class="pi pi-users"></i>
|
<i class="pi pi-users"></i>
|
||||||
<p class="empty-title">No users found</p>
|
<p class="empty-title">No users found</p>
|
||||||
@@ -211,6 +258,23 @@
|
|||||||
[(ngModel)]="newUser.password"
|
[(ngModel)]="newUser.password"
|
||||||
placeholder="Enter password"/>
|
placeholder="Enter password"/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<ng-template pTemplate="footer">
|
<ng-template pTemplate="footer">
|
||||||
<div class="dialog-actions">
|
<div class="dialog-actions">
|
||||||
|
|||||||
@@ -264,6 +264,17 @@
|
|||||||
font-weight: 500;
|
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 {
|
.actions-cell {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -332,6 +343,22 @@
|
|||||||
color: var(--p-text-muted-color);
|
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 {
|
.dialog-actions {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {Dialog} from 'primeng/dialog';
|
|||||||
import {FormsModule} from '@angular/forms';
|
import {FormsModule} from '@angular/forms';
|
||||||
import {ConfirmDialog} from 'primeng/confirmdialog';
|
import {ConfirmDialog} from 'primeng/confirmdialog';
|
||||||
import {ConfirmationService, MessageService} from 'primeng/api';
|
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 {catchError, filter, take, takeUntil, tap} from 'rxjs/operators';
|
||||||
import {UserService} from '../user-management/user.service';
|
import {UserService} from '../user-management/user.service';
|
||||||
import {of, Subject} from 'rxjs';
|
import {of, Subject} from 'rxjs';
|
||||||
@@ -18,6 +18,7 @@ import {ToggleSwitch} from 'primeng/toggleswitch';
|
|||||||
import {AppSettingsService} from '../../../shared/service/app-settings.service';
|
import {AppSettingsService} from '../../../shared/service/app-settings.service';
|
||||||
import {AppSettingKey} from '../../../shared/model/app-settings.model';
|
import {AppSettingKey} from '../../../shared/model/app-settings.model';
|
||||||
import {ExternalDocLinkComponent} from '../../../shared/components/external-doc-link/external-doc-link.component';
|
import {ExternalDocLinkComponent} from '../../../shared/components/external-doc-link/external-doc-link.component';
|
||||||
|
import {Select} from 'primeng/select';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-opds-settings',
|
selector: 'app-opds-settings',
|
||||||
@@ -32,7 +33,8 @@ import {ExternalDocLinkComponent} from '../../../shared/components/external-doc-
|
|||||||
TableModule,
|
TableModule,
|
||||||
Password,
|
Password,
|
||||||
ToggleSwitch,
|
ToggleSwitch,
|
||||||
ExternalDocLinkComponent
|
ExternalDocLinkComponent,
|
||||||
|
Select
|
||||||
],
|
],
|
||||||
providers: [ConfirmationService],
|
providers: [ConfirmationService],
|
||||||
templateUrl: './opds-settings.html',
|
templateUrl: './opds-settings.html',
|
||||||
@@ -52,13 +54,28 @@ export class OpdsSettings implements OnInit, OnDestroy {
|
|||||||
users: OpdsUserV2[] = [];
|
users: OpdsUserV2[] = [];
|
||||||
loading = false;
|
loading = false;
|
||||||
showCreateUserDialog = false;
|
showCreateUserDialog = false;
|
||||||
newUser: OpdsUserV2CreateRequest = {username: '', password: ''};
|
newUser: OpdsUserV2CreateRequest = {username: '', password: '', sortOrder: 'RECENT'};
|
||||||
passwordVisibility: boolean[] = [];
|
passwordVisibility: boolean[] = [];
|
||||||
hasPermission = false;
|
hasPermission = false;
|
||||||
|
|
||||||
|
editingUserId: number | null = null;
|
||||||
|
editingSortOrder: OpdsSortOrder | null = null;
|
||||||
|
|
||||||
private readonly destroy$ = new Subject<void>();
|
private readonly destroy$ = new Subject<void>();
|
||||||
dummyPassword: string = "***********************";
|
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 {
|
ngOnInit(): void {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
@@ -189,13 +206,51 @@ export class OpdsSettings implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
private resetCreateUserDialog(): void {
|
private resetCreateUserDialog(): void {
|
||||||
this.showCreateUserDialog = false;
|
this.showCreateUserDialog = false;
|
||||||
this.newUser = {username: '', password: ''};
|
this.newUser = {username: '', password: '', sortOrder: 'RECENT'};
|
||||||
}
|
}
|
||||||
|
|
||||||
private showMessage(severity: string, summary: string, detail: string): void {
|
private showMessage(severity: string, summary: string, detail: string): void {
|
||||||
this.messageService.add({severity, summary, detail});
|
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 {
|
ngOnDestroy(): void {
|
||||||
this.destroy$.next();
|
this.destroy$.next();
|
||||||
this.destroy$.complete();
|
this.destroy$.complete();
|
||||||
|
|||||||
@@ -3,15 +3,23 @@ import {HttpClient} from '@angular/common/http';
|
|||||||
import {Observable} from 'rxjs';
|
import {Observable} from 'rxjs';
|
||||||
import {API_CONFIG} from '../../../core/config/api-config';
|
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 {
|
export interface OpdsUserV2CreateRequest {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
sortOrder?: OpdsSortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpdsUserV2UpdateRequest {
|
||||||
|
sortOrder: OpdsSortOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpdsUserV2 {
|
export interface OpdsUserV2 {
|
||||||
id: number;
|
id: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
username: string;
|
username: string;
|
||||||
|
sortOrder?: OpdsSortOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
@@ -30,6 +38,10 @@ export class OpdsService {
|
|||||||
return this.http.post<OpdsUserV2>(this.baseUrl, user);
|
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> {
|
deleteCredential(id: number): Observable<void> {
|
||||||
return this.http.delete<void>(`${this.baseUrl}/${id}`);
|
return this.http.delete<void>(`${this.baseUrl}/${id}`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user