diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mobile/controller/MobileBookController.java b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/controller/MobileBookController.java index 33c4e1243..fbf1021f9 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/mobile/controller/MobileBookController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/controller/MobileBookController.java @@ -32,7 +32,7 @@ public class MobileBookController { @GetMapping public ResponseEntity> getBooks( @Parameter(description = "Page number (0-indexed)") @RequestParam(required = false, defaultValue = "0") Integer page, - @Parameter(description = "Page size (max 50)") @RequestParam(required = false, defaultValue = "20") Integer size, + @Parameter(description = "Page size (max 50)") @RequestParam(required = false, defaultValue = "50") Integer size, @Parameter(description = "Sort field (title, addedOn, lastReadTime, seriesName)") @RequestParam(required = false, defaultValue = "addedOn") String sort, @Parameter(description = "Sort direction (asc, desc)") @RequestParam(required = false, defaultValue = "desc") String dir, @Parameter(description = "Filter by library ID") @RequestParam(required = false) Long libraryId, @@ -140,4 +140,19 @@ public class MobileBookController { return ResponseEntity.ok(mobileBookService.getBooksByMagicShelf(magicShelfId, page, size)); } + + @Operation(summary = "Get random books", + description = "Retrieve a paginated list of random books from accessible libraries.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Random books retrieved successfully"), + @ApiResponse(responseCode = "403", description = "Access denied") + }) + @GetMapping("/random") + public ResponseEntity> getRandomBooks( + @Parameter(description = "Page number (0-indexed)") @RequestParam(required = false, defaultValue = "0") Integer page, + @Parameter(description = "Page size (max 50)") @RequestParam(required = false, defaultValue = "20") Integer size, + @Parameter(description = "Filter by library ID") @RequestParam(required = false) Long libraryId) { + + return ResponseEntity.ok(mobileBookService.getRandomBooks(page, size, libraryId)); + } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileBookFile.java b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileBookFile.java index 103ec4bbd..7365676d3 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileBookFile.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/dto/MobileBookFile.java @@ -4,13 +4,21 @@ import com.fasterxml.jackson.annotation.JsonInclude; import lombok.Builder; import lombok.Data; +import java.time.Instant; + @Data @Builder @JsonInclude(JsonInclude.Include.NON_NULL) public class MobileBookFile { private Long id; - private String bookType; - private Long fileSizeKb; + private Long bookId; private String fileName; + private boolean isBook; + private boolean folderBased; + private String bookType; + private String archiveType; + private Long fileSizeKb; + private String extension; + private Instant addedOn; private boolean isPrimary; } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mobile/mapper/MobileBookMapper.java b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/mapper/MobileBookMapper.java index 4f25e96ed..bb94ccebd 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/mobile/mapper/MobileBookMapper.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/mapper/MobileBookMapper.java @@ -234,13 +234,32 @@ public interface MobileBookMapper { return book.getBookFiles().stream() .filter(bf -> bf.getBookType() != null && bf.isBook()) - .map(bf -> MobileBookFile.builder() - .id(bf.getId()) - .bookType(bf.getBookType().name()) - .fileSizeKb(bf.getFileSizeKb()) - .fileName(bf.getFileName()) - .isPrimary(bf.getId().equals(primaryId)) - .build()) + .map(bf -> { + String extension = null; + try { + String fileName = bf.getFileName(); + int lastDot = fileName.lastIndexOf('.'); + if (lastDot > 0) { + extension = fileName.substring(lastDot + 1); + } + } catch (Exception e) { + // Handle case where extension cannot be extracted + } + + return MobileBookFile.builder() + .id(bf.getId()) + .bookId(bf.getBook() != null ? bf.getBook().getId() : null) + .fileName(bf.getFileName()) + .isBook(bf.isBook()) + .folderBased(bf.isFolderBased()) + .bookType(bf.getBookType().name()) + .archiveType(bf.getArchiveType() != null ? bf.getArchiveType().name() : null) + .fileSizeKb(bf.getFileSizeKb()) + .extension(extension) + .addedOn(bf.getAddedOn()) + .isPrimary(bf.getId().equals(primaryId)) + .build(); + }) .collect(Collectors.toList()); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mobile/service/MobileBookService.java b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/service/MobileBookService.java index 1f03dac37..18dc22a06 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/mobile/service/MobileBookService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mobile/service/MobileBookService.java @@ -2,14 +2,20 @@ package com.adityachandel.booklore.mobile.service; import com.adityachandel.booklore.config.security.service.AuthenticationService; import com.adityachandel.booklore.exception.ApiError; -import com.adityachandel.booklore.mobile.dto.*; +import com.adityachandel.booklore.mobile.dto.MobileBookDetail; +import com.adityachandel.booklore.mobile.dto.MobileBookSummary; +import com.adityachandel.booklore.mobile.dto.MobilePageResponse; import com.adityachandel.booklore.mobile.mapper.MobileBookMapper; import com.adityachandel.booklore.mobile.specification.MobileBookSpecification; +import com.adityachandel.booklore.model.dto.Book; import com.adityachandel.booklore.model.dto.BookLoreUser; import com.adityachandel.booklore.model.dto.Library; import com.adityachandel.booklore.model.entity.*; import com.adityachandel.booklore.model.enums.ReadStatus; -import com.adityachandel.booklore.repository.*; +import com.adityachandel.booklore.repository.BookRepository; +import com.adityachandel.booklore.repository.ShelfRepository; +import com.adityachandel.booklore.repository.UserBookFileProgressRepository; +import com.adityachandel.booklore.repository.UserBookProgressRepository; import com.adityachandel.booklore.service.opds.MagicShelfBookService; import lombok.AllArgsConstructor; import org.springframework.data.domain.Page; @@ -116,8 +122,8 @@ public class MobileBookService { Long userId = user.getId(); Set accessibleLibraryIds = getAccessibleLibraryIds(user); - int pageNum = page != null && page >= 0 ? page : 0; - int pageSize = size != null && size > 0 ? Math.min(size, MAX_PAGE_SIZE) : DEFAULT_PAGE_SIZE; + int pageNum = validatePageNumber(page); + int pageSize = validatePageSize(size); Pageable pageable = PageRequest.of(pageNum, pageSize, Sort.by(Sort.Direction.DESC, "addedOn")); @@ -129,17 +135,7 @@ public class MobileBookService { ); Page bookPage = bookRepository.findAll(spec, pageable); - - Set bookIds = bookPage.getContent().stream() - .map(BookEntity::getId) - .collect(Collectors.toSet()); - Map progressMap = getProgressMap(userId, bookIds); - - List summaries = bookPage.getContent().stream() - .map(book -> mobileBookMapper.toSummary(book, progressMap.get(book.getId()))) - .collect(Collectors.toList()); - - return MobilePageResponse.of(summaries, pageNum, pageSize, bookPage.getTotalElements()); + return buildPageResponse(bookPage, userId, pageNum, pageSize); } @Transactional(readOnly = true) @@ -148,7 +144,7 @@ public class MobileBookService { Long userId = user.getId(); Set accessibleLibraryIds = getAccessibleLibraryIds(user); - int maxItems = limit != null && limit > 0 ? Math.min(limit, MAX_PAGE_SIZE) : 10; + int maxItems = validateLimit(limit, 10); Specification spec = MobileBookSpecification.combine( MobileBookSpecification.notDeleted(), @@ -158,11 +154,7 @@ public class MobileBookService { ); List books = bookRepository.findAll(spec); - - Set bookIds = books.stream() - .map(BookEntity::getId) - .collect(Collectors.toSet()); - Map progressMap = getProgressMap(userId, bookIds); + Map progressMap = getProgressMapForBooks(userId, books); return books.stream() .filter(book -> progressMap.containsKey(book.getId())) @@ -185,7 +177,7 @@ public class MobileBookService { Long userId = user.getId(); Set accessibleLibraryIds = getAccessibleLibraryIds(user); - int maxItems = limit != null && limit > 0 ? Math.min(limit, MAX_PAGE_SIZE) : 10; + int maxItems = validateLimit(limit, 10); Specification spec = MobileBookSpecification.combine( MobileBookSpecification.notDeleted(), @@ -196,17 +188,43 @@ public class MobileBookService { Pageable pageable = PageRequest.of(0, maxItems, Sort.by(Sort.Direction.DESC, "addedOn")); Page bookPage = bookRepository.findAll(spec, pageable); - - Set bookIds = bookPage.getContent().stream() - .map(BookEntity::getId) - .collect(Collectors.toSet()); - Map progressMap = getProgressMap(userId, bookIds); + Map progressMap = getProgressMapForBooks(userId, bookPage.getContent()); return bookPage.getContent().stream() .map(book -> mobileBookMapper.toSummary(book, progressMap.get(book.getId()))) .collect(Collectors.toList()); } + @Transactional(readOnly = true) + public MobilePageResponse getRandomBooks( + Integer page, + Integer size, + Long libraryId) { + + BookLoreUser user = authenticationService.getAuthenticatedUser(); + Long userId = user.getId(); + Set accessibleLibraryIds = getAccessibleLibraryIds(user); + + int pageNum = validatePageNumber(page); + int pageSize = validatePageSize(size); + + Specification spec = buildBaseSpecification(accessibleLibraryIds, libraryId); + + long totalElements = bookRepository.count(spec); + + if (totalElements == 0) { + return MobilePageResponse.of(Collections.emptyList(), pageNum, pageSize, 0L); + } + + long maxOffset = Math.max(0, totalElements - pageSize); + int randomOffset = (int) (Math.random() * (maxOffset + 1)); + + Pageable pageable = PageRequest.of(randomOffset / pageSize, pageSize); + Page bookPage = bookRepository.findAll(spec, pageable); + + return buildPageResponse(bookPage, userId, pageNum, pageSize); + } + @Transactional(readOnly = true) public MobilePageResponse getBooksByMagicShelf( Long magicShelfId, @@ -216,28 +234,25 @@ public class MobileBookService { BookLoreUser user = authenticationService.getAuthenticatedUser(); Long userId = user.getId(); - int pageNum = page != null && page >= 0 ? page : 0; - int pageSize = size != null && size > 0 ? Math.min(size, MAX_PAGE_SIZE) : DEFAULT_PAGE_SIZE; + int pageNum = validatePageNumber(page); + int pageSize = validatePageSize(size); var booksPage = magicShelfBookService.getBooksByMagicShelfId(userId, magicShelfId, pageNum, pageSize); Set bookIds = booksPage.getContent().stream() - .map(book -> book.getId()) + .map(Book::getId) .collect(Collectors.toSet()); - Map progressMap = getProgressMap(userId, bookIds); - List summaries = booksPage.getContent().stream() - .map(book -> { - BookEntity bookEntity = bookRepository.findById(book.getId()).orElse(null); - if (bookEntity == null) { - return null; - } - if (bookEntity.getIsPhysical() != null && bookEntity.getIsPhysical()) { - return null; - } - return mobileBookMapper.toSummary(bookEntity, progressMap.get(book.getId())); - }) - .filter(summary -> summary != null) + if (bookIds.isEmpty()) { + return MobilePageResponse.of(Collections.emptyList(), pageNum, pageSize, 0L); + } + + List bookEntities = bookRepository.findAllById(bookIds); + Map progressMap = getProgressMapForBooks(userId, bookEntities); + + List summaries = bookEntities.stream() + .filter(bookEntity -> bookEntity.getIsPhysical() == null || !bookEntity.getIsPhysical()) + .map(bookEntity -> mobileBookMapper.toSummary(bookEntity, progressMap.get(bookEntity.getId()))) .collect(Collectors.toList()); return MobilePageResponse.of(summaries, pageNum, pageSize, booksPage.getTotalElements()); @@ -245,20 +260,7 @@ public class MobileBookService { @Transactional public void updateReadStatus(Long bookId, ReadStatus status) { - BookLoreUser user = authenticationService.getAuthenticatedUser(); - Long userId = user.getId(); - Set accessibleLibraryIds = getAccessibleLibraryIds(user); - - BookEntity book = bookRepository.findById(bookId) - .orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); - - if (!accessibleLibraryIds.contains(book.getLibrary().getId())) { - throw ApiError.FORBIDDEN.createException("Access denied to this book"); - } - - UserBookProgressEntity progress = userBookProgressRepository - .findByUserIdAndBookId(userId, bookId) - .orElseGet(() -> createNewProgress(userId, book)); + UserBookProgressEntity progress = validateAccessAndGetProgress(bookId); progress.setReadStatus(status); progress.setReadStatusModifiedTime(Instant.now()); @@ -272,6 +274,13 @@ public class MobileBookService { @Transactional public void updatePersonalRating(Long bookId, Integer rating) { + UserBookProgressEntity progress = validateAccessAndGetProgress(bookId); + + progress.setPersonalRating(rating); + userBookProgressRepository.save(progress); + } + + private UserBookProgressEntity validateAccessAndGetProgress(Long bookId) { BookLoreUser user = authenticationService.getAuthenticatedUser(); Long userId = user.getId(); Set accessibleLibraryIds = getAccessibleLibraryIds(user); @@ -279,16 +288,17 @@ public class MobileBookService { BookEntity book = bookRepository.findById(bookId) .orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); - if (!accessibleLibraryIds.contains(book.getLibrary().getId())) { - throw ApiError.FORBIDDEN.createException("Access denied to this book"); - } + validateLibraryAccess(accessibleLibraryIds, book.getLibrary().getId()); - UserBookProgressEntity progress = userBookProgressRepository + return userBookProgressRepository .findByUserIdAndBookId(userId, bookId) .orElseGet(() -> createNewProgress(userId, book)); + } - progress.setPersonalRating(rating); - userBookProgressRepository.save(progress); + private void validateLibraryAccess(Set accessibleLibraryIds, Long libraryId) { + if (accessibleLibraryIds != null && !accessibleLibraryIds.contains(libraryId)) { + throw ApiError.FORBIDDEN.createException("Access denied to this book"); + } } private UserBookProgressEntity createNewProgress(Long userId, BookEntity book) { @@ -379,4 +389,60 @@ public class MobileBookService { return Sort.by(direction, field); } + + private int validatePageNumber(Integer page) { + return page != null && page >= 0 ? page : 0; + } + + private int validatePageSize(Integer size) { + return size != null && size > 0 ? Math.min(size, MAX_PAGE_SIZE) : DEFAULT_PAGE_SIZE; + } + + private int validateLimit(Integer limit, int defaultValue) { + return limit != null && limit > 0 ? Math.min(limit, MAX_PAGE_SIZE) : defaultValue; + } + + private Specification buildBaseSpecification(Set accessibleLibraryIds, Long libraryId) { + List> specs = new ArrayList<>(); + specs.add(MobileBookSpecification.notDeleted()); + specs.add(MobileBookSpecification.hasDigitalFile()); + + if (accessibleLibraryIds != null) { + if (libraryId != null && !accessibleLibraryIds.contains(libraryId)) { + throw ApiError.FORBIDDEN.createException("Access denied to library " + libraryId); + } + specs.add(libraryId != null + ? MobileBookSpecification.inLibrary(libraryId) + : MobileBookSpecification.inLibraries(accessibleLibraryIds)); + } else if (libraryId != null) { + specs.add(MobileBookSpecification.inLibrary(libraryId)); + } + + return MobileBookSpecification.combine(specs.toArray(new Specification[0])); + } + + private MobilePageResponse buildPageResponse( + Page bookPage, + Long userId, + int pageNum, + int pageSize) { + + Map progressMap = getProgressMapForBooks(userId, bookPage.getContent()); + + List summaries = bookPage.getContent().stream() + .map(book -> mobileBookMapper.toSummary(book, progressMap.get(book.getId()))) + .collect(Collectors.toList()); + + return MobilePageResponse.of(summaries, pageNum, pageSize, bookPage.getTotalElements()); + } + + private Map getProgressMapForBooks(Long userId, List books) { + if (books.isEmpty()) { + return Collections.emptyMap(); + } + Set bookIds = books.stream() + .map(BookEntity::getId) + .collect(Collectors.toSet()); + return getProgressMap(userId, bookIds); + } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/BookFileProgress.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/BookFileProgress.java index cafaf958f..829664691 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/BookFileProgress.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/BookFileProgress.java @@ -6,6 +6,5 @@ public record BookFileProgress( @NotNull Long bookFileId, String positionData, String positionHref, - @NotNull Float progressPercent -) { + @NotNull Float progressPercent) { } diff --git a/booklore-ui/src/app/features/book/components/book-browser/book-browser.component.html b/booklore-ui/src/app/features/book/components/book-browser/book-browser.component.html index 229303017..55ce08a00 100644 --- a/booklore-ui/src/app/features/book/components/book-browser/book-browser.component.html +++ b/booklore-ui/src/app/features/book/components/book-browser/book-browser.component.html @@ -299,7 +299,7 @@ @for (book of scroll.viewPortItems; let i = $index; track book.id) {
+ [ngStyle]="{width: currentCardSize.width + 'px', height: getCardHeight(book) + 'px'}"> this.setSleepTimer(15) }, - { label: '30 minutes', command: () => this.setSleepTimer(30) }, - { label: '45 minutes', command: () => this.setSleepTimer(45) }, - { label: '60 minutes', command: () => this.setSleepTimer(60) }, - { label: 'End of chapter', command: () => this.setSleepTimerEndOfChapter() }, - { separator: true }, - { label: 'Cancel timer', command: () => this.cancelSleepTimer(), visible: false } + {label: '15 minutes', command: () => this.setSleepTimer(15)}, + {label: '30 minutes', command: () => this.setSleepTimer(30)}, + {label: '45 minutes', command: () => this.setSleepTimer(45)}, + {label: '60 minutes', command: () => this.setSleepTimer(60)}, + {label: 'End of chapter', command: () => this.setSleepTimerEndOfChapter()}, + {separator: true}, + {label: 'Cancel timer', command: () => this.cancelSleepTimer(), visible: false} ]; bookmarks: BookMark[] = []; playbackRates = [ - { label: '0.5x', value: 0.5 }, - { label: '0.75x', value: 0.75 }, - { label: '1x', value: 1 }, - { label: '1.25x', value: 1.25 }, - { label: '1.5x', value: 1.5 }, - { label: '2x', value: 2 } + {label: '0.5x', value: 0.5}, + {label: '0.75x', value: 0.75}, + {label: '1x', value: 1}, + {label: '1.25x', value: 1.25}, + {label: '1.5x', value: 1.5}, + {label: '2x', value: 2} ]; private progressSaveInterval?: ReturnType; @@ -394,7 +392,7 @@ export class AudiobookReaderComponent implements OnInit, OnDestroy { artist: this.audiobookInfo.author || 'Unknown Author', album: this.audiobookInfo.title, artwork: this.coverUrl - ? [{ src: this.coverUrl, sizes: '512x512', type: 'image/png' }] + ? [{src: this.coverUrl, sizes: '512x512', type: 'image/png'}] : [] }); } diff --git a/booklore-ui/src/app/features/readers/ebook-reader/ebook-reader.component.ts b/booklore-ui/src/app/features/readers/ebook-reader/ebook-reader.component.ts index 105dabcf0..154f2dc1c 100644 --- a/booklore-ui/src/app/features/readers/ebook-reader/ebook-reader.component.ts +++ b/booklore-ui/src/app/features/readers/ebook-reader/ebook-reader.component.ts @@ -1,6 +1,6 @@ import {Component, CUSTOM_ELEMENTS_SCHEMA, HostListener, inject, OnDestroy, OnInit} from '@angular/core'; import {CommonModule} from '@angular/common'; -import {forkJoin, Observable, of, Subject, throwError} from 'rxjs'; +import {Observable, of, Subject, throwError} from 'rxjs'; import {catchError, map, switchMap, takeUntil, tap} from 'rxjs/operators'; import {MessageService} from 'primeng/api'; import {ReaderLoaderService} from './core/loader.service'; @@ -27,8 +27,8 @@ import {ReaderQuickSettingsComponent} from './layout/header/quick-settings.compo import {ReaderBookMetadataDialogComponent} from './dialogs/metadata-dialog.component'; import {ReaderHeaderFooterVisibilityManager} from './shared/visibility.util'; import {EpubCustomFontService} from './features/fonts/custom-font.service'; -import {TextSelectionPopupComponent, TextSelectionAction} from './shared/selection-popup.component'; -import {ReaderNoteDialogComponent, NoteDialogData, NoteDialogResult} from './dialogs/note-dialog.component'; +import {TextSelectionAction, TextSelectionPopupComponent} from './shared/selection-popup.component'; +import {NoteDialogData, NoteDialogResult, ReaderNoteDialogComponent} from './dialogs/note-dialog.component'; @Component({ selector: 'app-ebook-reader', @@ -102,7 +102,7 @@ export class EbookReaderComponent implements OnInit, OnDestroy { sectionFractions: number[] = []; showSelectionPopup = false; - popupPosition = { x: 0, y: 0 }; + popupPosition = {x: 0, y: 0}; showPopupBelow = false; overlappingAnnotationId: number | null = null; selectedText = ''; @@ -229,7 +229,7 @@ export class EbookReaderComponent implements OnInit, OnDestroy { } return this.stateService.initializeState(this.bookId, bookFileId!).pipe( - map(() => ({ book, bookType, bookFileId })) + map(() => ({book, bookType, bookFileId})) ); }), switchMap(({book, bookType, bookFileId}) => { diff --git a/booklore-ui/src/app/features/readers/ebook-reader/features/selection/selection.service.ts b/booklore-ui/src/app/features/readers/ebook-reader/features/selection/selection.service.ts index f35c49685..0b4d24979 100644 --- a/booklore-ui/src/app/features/readers/ebook-reader/features/selection/selection.service.ts +++ b/booklore-ui/src/app/features/readers/ebook-reader/features/selection/selection.service.ts @@ -208,22 +208,20 @@ export class ReaderSelectionService { return null; } - const getBasePath = (cfi: string): string => { - const inner = cfi.replace(/^epubcfi\(/, '').replace(/\)$/, ''); - const commaIndex = inner.indexOf(','); - if (commaIndex > 0) { - return inner.substring(0, commaIndex).replace(/:\d+$/, ''); - } - return inner.replace(/:\d+$/, ''); - }; - - const selectionBasePath = getBasePath(selectionCfi); + const selectionRange = this.parseCfiRange(selectionCfi); + if (!selectionRange) return null; for (const annotation of this.annotations) { - const annotationBasePath = getBasePath(annotation.cfi); - if (selectionBasePath.startsWith(annotationBasePath) || - annotationBasePath.startsWith(selectionBasePath) || - selectionBasePath === annotationBasePath) { + const annotationRange = this.parseCfiRange(annotation.cfi); + if (!annotationRange) continue; + + // Check if base paths match (same text node/element) + if (selectionRange.basePath !== annotationRange.basePath) continue; + + // Check if character ranges actually overlap + // Two ranges [a, b] and [c, d] overlap if a < d AND c < b + if (selectionRange.startOffset < annotationRange.endOffset && + annotationRange.startOffset < selectionRange.endOffset) { return annotation.id; } } @@ -231,6 +229,58 @@ export class ReaderSelectionService { return null; } + /** + * Parses a CFI range and extracts the base path and character offsets. + * CFI range format: epubcfi(parent_path,relative_start,relative_end) + * Example: epubcfi(/6/4!/4/2/1:0,/4/2/1:15) + */ + private parseCfiRange(cfi: string): { basePath: string; startOffset: number; endOffset: number } | null { + // Remove epubcfi() wrapper + const inner = cfi.replace(/^epubcfi\(/, '').replace(/\)$/, ''); + + // Find the comma that separates parent path from relative offsets + const commaIndex = inner.indexOf(','); + if (commaIndex <= 0) { + // Not a range CFI, might be a point CFI - treat as zero-width range + const offsetMatch = inner.match(/:(\d+)$/); + if (offsetMatch) { + const basePath = inner.replace(/:\d+$/, ''); + const offset = parseInt(offsetMatch[1], 10); + return { basePath, startOffset: offset, endOffset: offset }; + } + return null; + } + + // Extract parent path (before first comma) + const parentPath = inner.substring(0, commaIndex); + + // Extract relative start and end parts (after commas) + const relativePartsStr = inner.substring(commaIndex + 1); + const relativeParts = relativePartsStr.split(','); + if (relativeParts.length !== 2) return null; + + const relativeStart = relativeParts[0]; + const relativeEnd = relativeParts[1]; + + // Extract character offsets from relative parts + const startOffsetMatch = relativeStart.match(/:(\d+)$/); + const endOffsetMatch = relativeEnd.match(/:(\d+)$/); + + if (!startOffsetMatch || !endOffsetMatch) return null; + + const startOffset = parseInt(startOffsetMatch[1], 10); + const endOffset = parseInt(endOffsetMatch[1], 10); + + // Build the full base path by combining parent with the common path structure + // Strip character offsets from the path to get the node path + const startNodePath = relativeStart.replace(/:\d+$/, ''); + + // Use parent + start node path as base (start and end should be in same node for highlights) + const basePath = `${parentPath}${startNodePath}`; + + return { basePath, startOffset, endOffset }; + } + private emitState(): void { this.stateSubject.next({ visible: this._visible,