feat: add annotation notebook with server-side pagination (#2736)

Co-authored-by: acx10 <acx10@users.noreply.github.com>
This commit is contained in:
ACX
2026-02-13 14:43:31 -07:00
committed by GitHub
parent bf04a1f4db
commit 54f633faad
22 changed files with 1274 additions and 2 deletions

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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);