mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
feat: add annotation notebook with server-side pagination (#2736)
Co-authored-by: acx10 <acx10@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
package org.booklore.controller;
|
||||
|
||||
import org.booklore.model.dto.NotebookBookOption;
|
||||
import org.booklore.model.dto.NotebookEntry;
|
||||
import org.booklore.service.book.NotebookService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/notebook")
|
||||
@Tag(name = "Notebook", description = "Endpoints for the annotation notebook view")
|
||||
public class NotebookController {
|
||||
|
||||
private final NotebookService notebookService;
|
||||
|
||||
@Operation(summary = "Get paginated notebook entries")
|
||||
@GetMapping
|
||||
public Page<NotebookEntry> getNotebookEntries(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size,
|
||||
@RequestParam(required = false) Set<String> types,
|
||||
@RequestParam(required = false) Long bookId,
|
||||
@RequestParam(required = false) String search,
|
||||
@RequestParam(defaultValue = "desc") String sort) {
|
||||
return notebookService.getNotebookEntries(page, size, types, bookId, search, sort);
|
||||
}
|
||||
|
||||
@Operation(summary = "Get all notebook entries for export")
|
||||
@GetMapping("/export")
|
||||
public List<NotebookEntry> exportNotebookEntries(
|
||||
@RequestParam(required = false) Set<String> types,
|
||||
@RequestParam(required = false) Long bookId,
|
||||
@RequestParam(required = false) String search,
|
||||
@RequestParam(defaultValue = "desc") String sort) {
|
||||
return notebookService.getAllNotebookEntries(types, bookId, search, sort);
|
||||
}
|
||||
|
||||
@Operation(summary = "Get books that have annotations")
|
||||
@GetMapping("/books")
|
||||
public List<NotebookBookOption> getBooksWithAnnotations(
|
||||
@RequestParam(required = false) String search) {
|
||||
return notebookService.getBooksWithAnnotations(search);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.booklore.model.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class NotebookBookOption {
|
||||
private Long bookId;
|
||||
private String bookTitle;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.booklore.model.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class NotebookEntry {
|
||||
private Long id;
|
||||
private String type;
|
||||
private Long bookId;
|
||||
private String bookTitle;
|
||||
private String text;
|
||||
private String note;
|
||||
private String color;
|
||||
private String style;
|
||||
private String chapterTitle;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -31,4 +31,7 @@ public interface AnnotationRepository extends JpaRepository<AnnotationEntity, Lo
|
||||
long countByBookIdAndUserId(Long bookId, Long userId);
|
||||
|
||||
void deleteByBookIdAndUserId(Long bookId, Long userId);
|
||||
|
||||
@Query("SELECT a FROM AnnotationEntity a JOIN FETCH a.book b JOIN FETCH b.metadata WHERE a.userId = :userId ORDER BY a.createdAt DESC")
|
||||
List<AnnotationEntity> findByUserIdOrderByCreatedAtDesc(@Param("userId") Long userId);
|
||||
}
|
||||
|
||||
@@ -29,4 +29,7 @@ public interface BookMarkRepository extends JpaRepository<BookMarkEntity, Long>
|
||||
|
||||
// New: count bookmarks per book
|
||||
long countByBookIdAndUserId(Long bookId, Long userId);
|
||||
|
||||
@Query("SELECT b FROM BookMarkEntity b JOIN FETCH b.book bk JOIN FETCH bk.metadata WHERE b.userId = :userId ORDER BY b.createdAt DESC")
|
||||
List<BookMarkEntity> findByUserIdOrderByCreatedAtDesc(@Param("userId") Long userId);
|
||||
}
|
||||
|
||||
@@ -31,4 +31,7 @@ public interface BookNoteV2Repository extends JpaRepository<BookNoteV2Entity, Lo
|
||||
long countByBookIdAndUserId(Long bookId, Long userId);
|
||||
|
||||
void deleteByBookIdAndUserId(Long bookId, Long userId);
|
||||
|
||||
@Query("SELECT n FROM BookNoteV2Entity n JOIN FETCH n.book b JOIN FETCH b.metadata WHERE n.userId = :userId ORDER BY n.createdAt DESC")
|
||||
List<BookNoteV2Entity> findByUserIdOrderByCreatedAtDesc(@Param("userId") Long userId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package org.booklore.repository;
|
||||
|
||||
import org.booklore.model.entity.AnnotationEntity;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.Repository;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public interface NotebookEntryRepository extends Repository<AnnotationEntity, Long> {
|
||||
|
||||
String ENTRIES_UNION =
|
||||
"SELECT a.id, 'HIGHLIGHT' AS type, a.user_id, a.book_id, bm.title AS book_title, " +
|
||||
"a.text, a.note, a.color, a.style, a.chapter_title, a.created_at, a.updated_at " +
|
||||
"FROM annotations a JOIN book_metadata bm ON bm.book_id = a.book_id " +
|
||||
"UNION ALL " +
|
||||
"SELECT n.id, 'NOTE' AS type, n.user_id, n.book_id, bm.title AS book_title, " +
|
||||
"n.selected_text, n.note_content, n.color, NULL, n.chapter_title, n.created_at, n.updated_at " +
|
||||
"FROM book_notes_v2 n JOIN book_metadata bm ON bm.book_id = n.book_id " +
|
||||
"UNION ALL " +
|
||||
"SELECT b.id, 'BOOKMARK' AS type, b.user_id, b.book_id, bm.title AS book_title, " +
|
||||
"b.title, b.notes, b.color, NULL, NULL, b.created_at, b.updated_at " +
|
||||
"FROM book_marks b JOIN book_metadata bm ON bm.book_id = b.book_id";
|
||||
|
||||
String ENTRIES_FILTER =
|
||||
" WHERE t.user_id = :userId AND t.type IN (:types)" +
|
||||
" AND (:bookId IS NULL OR t.book_id = :bookId)" +
|
||||
" AND (:search IS NULL" +
|
||||
" OR t.text LIKE :search ESCAPE '\\\\'" +
|
||||
" OR t.note LIKE :search ESCAPE '\\\\'" +
|
||||
" OR t.book_title LIKE :search ESCAPE '\\\\'" +
|
||||
" OR t.chapter_title LIKE :search ESCAPE '\\\\')";
|
||||
|
||||
interface EntryProjection {
|
||||
Long getId();
|
||||
String getType();
|
||||
Long getBookId();
|
||||
String getBookTitle();
|
||||
String getText();
|
||||
String getNote();
|
||||
String getColor();
|
||||
String getStyle();
|
||||
String getChapterTitle();
|
||||
LocalDateTime getCreatedAt();
|
||||
LocalDateTime getUpdatedAt();
|
||||
}
|
||||
|
||||
interface BookProjection {
|
||||
Long getBookId();
|
||||
String getBookTitle();
|
||||
}
|
||||
|
||||
@Query(value = "SELECT t.id, t.type, t.book_id AS bookId, t.book_title AS bookTitle, " +
|
||||
"t.text, t.note, t.color, t.style, t.chapter_title AS chapterTitle, " +
|
||||
"t.created_at AS createdAt, t.updated_at AS updatedAt " +
|
||||
"FROM (" + ENTRIES_UNION + ") t" + ENTRIES_FILTER,
|
||||
countQuery = "SELECT COUNT(*) FROM (" + ENTRIES_UNION + ") t" + ENTRIES_FILTER,
|
||||
nativeQuery = true)
|
||||
Page<EntryProjection> findEntries(@Param("userId") Long userId,
|
||||
@Param("types") Set<String> types,
|
||||
@Param("bookId") Long bookId,
|
||||
@Param("search") String search,
|
||||
Pageable pageable);
|
||||
|
||||
@Query(value = "SELECT DISTINCT t.book_id AS bookId, t.book_title AS bookTitle FROM (" +
|
||||
"SELECT a.book_id, bm.title AS book_title FROM annotations a " +
|
||||
"JOIN book_metadata bm ON bm.book_id = a.book_id WHERE a.user_id = :userId " +
|
||||
"UNION " +
|
||||
"SELECT n.book_id, bm.title AS book_title FROM book_notes_v2 n " +
|
||||
"JOIN book_metadata bm ON bm.book_id = n.book_id WHERE n.user_id = :userId " +
|
||||
"UNION " +
|
||||
"SELECT b.book_id, bm.title AS book_title FROM book_marks b " +
|
||||
"JOIN book_metadata bm ON bm.book_id = b.book_id WHERE b.user_id = :userId" +
|
||||
") t WHERE (:search IS NULL OR t.book_title LIKE :search ESCAPE '\\\\') " +
|
||||
"ORDER BY t.book_title",
|
||||
nativeQuery = true)
|
||||
List<BookProjection> findBooksWithAnnotations(@Param("userId") Long userId,
|
||||
@Param("search") String search,
|
||||
Pageable pageable);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package org.booklore.service.book;
|
||||
|
||||
import org.booklore.config.security.service.AuthenticationService;
|
||||
import org.booklore.model.dto.NotebookBookOption;
|
||||
import org.booklore.model.dto.NotebookEntry;
|
||||
import org.booklore.repository.NotebookEntryRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class NotebookService {
|
||||
|
||||
private static final int EXPORT_LIMIT = 50_000;
|
||||
private static final int BOOK_OPTIONS_LIMIT = 50;
|
||||
|
||||
private final NotebookEntryRepository repository;
|
||||
private final AuthenticationService authenticationService;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Page<NotebookEntry> getNotebookEntries(int page, int size, Set<String> types, Long bookId,
|
||||
String search, String sort) {
|
||||
Long userId = authenticationService.getAuthenticatedUser().getId();
|
||||
Pageable pageable = PageRequest.of(page, size, toSort(sort));
|
||||
return repository.findEntries(userId, types, bookId, wrapSearch(search), pageable)
|
||||
.map(NotebookService::toDto);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<NotebookEntry> getAllNotebookEntries(Set<String> types, Long bookId, String search, String sort) {
|
||||
Long userId = authenticationService.getAuthenticatedUser().getId();
|
||||
Pageable pageable = PageRequest.of(0, EXPORT_LIMIT, toSort(sort));
|
||||
return repository.findEntries(userId, types, bookId, wrapSearch(search), pageable)
|
||||
.map(NotebookService::toDto)
|
||||
.getContent();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<NotebookBookOption> getBooksWithAnnotations(String search) {
|
||||
Long userId = authenticationService.getAuthenticatedUser().getId();
|
||||
return repository.findBooksWithAnnotations(userId, wrapSearch(search), Pageable.ofSize(BOOK_OPTIONS_LIMIT))
|
||||
.stream()
|
||||
.map(p -> new NotebookBookOption(p.getBookId(), p.getBookTitle()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private String wrapSearch(String search) {
|
||||
if (search == null || search.isBlank()) return null;
|
||||
return "%" + escapeLike(search) + "%";
|
||||
}
|
||||
|
||||
private static String escapeLike(String input) {
|
||||
return input.trim()
|
||||
.replace("\\", "\\\\")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_");
|
||||
}
|
||||
|
||||
private static Sort toSort(String sort) {
|
||||
return "asc".equalsIgnoreCase(sort)
|
||||
? Sort.by("createdAt").ascending()
|
||||
: Sort.by("createdAt").descending();
|
||||
}
|
||||
|
||||
private static NotebookEntry toDto(NotebookEntryRepository.EntryProjection p) {
|
||||
return NotebookEntry.builder()
|
||||
.id(p.getId())
|
||||
.type(p.getType())
|
||||
.bookId(p.getBookId())
|
||||
.bookTitle(p.getBookTitle())
|
||||
.text(p.getText())
|
||||
.note(p.getNote())
|
||||
.color(p.getColor())
|
||||
.style(p.getStyle())
|
||||
.chapterTitle(p.getChapterTitle())
|
||||
.createdAt(p.getCreatedAt())
|
||||
.updatedAt(p.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
CREATE INDEX IF NOT EXISTS idx_annotations_user_created ON annotations (user_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_book_notes_v2_user_created ON book_notes_v2 (user_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_book_marks_user_created ON book_marks (user_id, created_at);
|
||||
Reference in New Issue
Block a user