From a501b27169e0249b03bd7274461dc41e40156b1d Mon Sep 17 00:00:00 2001 From: Stefan Hardegger Date: Mon, 28 Jul 2025 14:09:19 +0200 Subject: [PATCH] Saving reading position --- .../storycove/controller/StoryController.java | 24 ++ .../storycove/dto/ReadingProgressRequest.java | 23 ++ .../storycove/dto/ReadingStatusRequest.java | 23 ++ .../main/java/com/storycove/dto/StoryDto.java | 29 +++ .../com/storycove/dto/StorySearchDto.java | 11 + .../com/storycove/dto/StorySummaryDto.java | 29 +++ .../main/java/com/storycove/entity/Story.java | 66 ++++++ .../com/storycove/service/StoryService.java | 39 ++++ .../storycove/service/StoryServiceTest.java | 216 ++++++++++++++++++ .../lib/scraper/strategies/textExtractor.ts | 19 +- 10 files changed, 473 insertions(+), 6 deletions(-) create mode 100644 backend/src/main/java/com/storycove/dto/ReadingProgressRequest.java create mode 100644 backend/src/main/java/com/storycove/dto/ReadingStatusRequest.java create mode 100644 backend/src/test/java/com/storycove/service/StoryServiceTest.java diff --git a/backend/src/main/java/com/storycove/controller/StoryController.java b/backend/src/main/java/com/storycove/controller/StoryController.java index 2a7fa4c..169064c 100644 --- a/backend/src/main/java/com/storycove/controller/StoryController.java +++ b/backend/src/main/java/com/storycove/controller/StoryController.java @@ -152,6 +152,20 @@ public class StoryController { return ResponseEntity.ok(convertToDto(story)); } + @PostMapping("/{id}/reading-progress") + public ResponseEntity updateReadingProgress(@PathVariable UUID id, @RequestBody ReadingProgressRequest request) { + logger.info("Updating reading progress for story {} to position {}", id, request.getPosition()); + Story story = storyService.updateReadingProgress(id, request.getPosition()); + return ResponseEntity.ok(convertToDto(story)); + } + + @PostMapping("/{id}/reading-status") + public ResponseEntity updateReadingStatus(@PathVariable UUID id, @RequestBody ReadingStatusRequest request) { + logger.info("Updating reading status for story {} to {}", id, request.getIsRead() ? "read" : "unread"); + Story story = storyService.updateReadingStatus(id, request.getIsRead()); + return ResponseEntity.ok(convertToDto(story)); + } + @PostMapping("/reindex") public ResponseEntity manualReindex() { if (typesenseService == null) { @@ -402,6 +416,11 @@ public class StoryController { dto.setCreatedAt(story.getCreatedAt()); dto.setUpdatedAt(story.getUpdatedAt()); + // Reading progress fields + dto.setIsRead(story.getIsRead()); + dto.setReadingPosition(story.getReadingPosition()); + dto.setLastReadAt(story.getLastReadAt()); + if (story.getAuthor() != null) { dto.setAuthorId(story.getAuthor().getId()); dto.setAuthorName(story.getAuthor().getName()); @@ -434,6 +453,11 @@ public class StoryController { dto.setUpdatedAt(story.getUpdatedAt()); dto.setPartOfSeries(story.isPartOfSeries()); + // Reading progress fields + dto.setIsRead(story.getIsRead()); + dto.setReadingPosition(story.getReadingPosition()); + dto.setLastReadAt(story.getLastReadAt()); + if (story.getAuthor() != null) { dto.setAuthorId(story.getAuthor().getId()); dto.setAuthorName(story.getAuthor().getName()); diff --git a/backend/src/main/java/com/storycove/dto/ReadingProgressRequest.java b/backend/src/main/java/com/storycove/dto/ReadingProgressRequest.java new file mode 100644 index 0000000..05402ed --- /dev/null +++ b/backend/src/main/java/com/storycove/dto/ReadingProgressRequest.java @@ -0,0 +1,23 @@ +package com.storycove.dto; + +import jakarta.validation.constraints.Min; + +public class ReadingProgressRequest { + + @Min(value = 0, message = "Reading position must be non-negative") + private Integer position; + + public ReadingProgressRequest() {} + + public ReadingProgressRequest(Integer position) { + this.position = position; + } + + public Integer getPosition() { + return position; + } + + public void setPosition(Integer position) { + this.position = position; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/dto/ReadingStatusRequest.java b/backend/src/main/java/com/storycove/dto/ReadingStatusRequest.java new file mode 100644 index 0000000..5fd84f4 --- /dev/null +++ b/backend/src/main/java/com/storycove/dto/ReadingStatusRequest.java @@ -0,0 +1,23 @@ +package com.storycove.dto; + +import jakarta.validation.constraints.NotNull; + +public class ReadingStatusRequest { + + @NotNull(message = "Reading status is required") + private Boolean isRead; + + public ReadingStatusRequest() {} + + public ReadingStatusRequest(Boolean isRead) { + this.isRead = isRead; + } + + public Boolean getIsRead() { + return isRead; + } + + public void setIsRead(Boolean isRead) { + this.isRead = isRead; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/dto/StoryDto.java b/backend/src/main/java/com/storycove/dto/StoryDto.java index 53c6ffe..9ac01b1 100644 --- a/backend/src/main/java/com/storycove/dto/StoryDto.java +++ b/backend/src/main/java/com/storycove/dto/StoryDto.java @@ -28,6 +28,11 @@ public class StoryDto { private Integer rating; private Integer volume; + // Reading progress fields + private Boolean isRead; + private Integer readingPosition; + private LocalDateTime lastReadAt; + // Related entities as simple references private UUID authorId; private String authorName; @@ -133,6 +138,30 @@ public class StoryDto { this.volume = volume; } + public Boolean getIsRead() { + return isRead; + } + + public void setIsRead(Boolean isRead) { + this.isRead = isRead; + } + + public Integer getReadingPosition() { + return readingPosition; + } + + public void setReadingPosition(Integer readingPosition) { + this.readingPosition = readingPosition; + } + + public LocalDateTime getLastReadAt() { + return lastReadAt; + } + + public void setLastReadAt(LocalDateTime lastReadAt) { + this.lastReadAt = lastReadAt; + } + public UUID getAuthorId() { return authorId; } diff --git a/backend/src/main/java/com/storycove/dto/StorySearchDto.java b/backend/src/main/java/com/storycove/dto/StorySearchDto.java index ba05052..1f3f3a6 100644 --- a/backend/src/main/java/com/storycove/dto/StorySearchDto.java +++ b/backend/src/main/java/com/storycove/dto/StorySearchDto.java @@ -16,6 +16,9 @@ public class StorySearchDto { private Integer rating; private Integer volume; + // Reading status + private Boolean isRead; + // Author info private UUID authorId; private String authorName; @@ -109,6 +112,14 @@ public class StorySearchDto { this.volume = volume; } + public Boolean getIsRead() { + return isRead; + } + + public void setIsRead(Boolean isRead) { + this.isRead = isRead; + } + public UUID getAuthorId() { return authorId; } diff --git a/backend/src/main/java/com/storycove/dto/StorySummaryDto.java b/backend/src/main/java/com/storycove/dto/StorySummaryDto.java index 3ab012a..5d9bdc3 100644 --- a/backend/src/main/java/com/storycove/dto/StorySummaryDto.java +++ b/backend/src/main/java/com/storycove/dto/StorySummaryDto.java @@ -20,6 +20,11 @@ public class StorySummaryDto { private Integer rating; private Integer volume; + // Reading progress fields + private Boolean isRead; + private Integer readingPosition; + private LocalDateTime lastReadAt; + // Related entities as simple references private UUID authorId; private String authorName; @@ -106,6 +111,30 @@ public class StorySummaryDto { this.volume = volume; } + public Boolean getIsRead() { + return isRead; + } + + public void setIsRead(Boolean isRead) { + this.isRead = isRead; + } + + public Integer getReadingPosition() { + return readingPosition; + } + + public void setReadingPosition(Integer readingPosition) { + this.readingPosition = readingPosition; + } + + public LocalDateTime getLastReadAt() { + return lastReadAt; + } + + public void setLastReadAt(LocalDateTime lastReadAt) { + this.lastReadAt = lastReadAt; + } + public UUID getAuthorId() { return authorId; } diff --git a/backend/src/main/java/com/storycove/entity/Story.java b/backend/src/main/java/com/storycove/entity/Story.java index ffb5d71..d099624 100644 --- a/backend/src/main/java/com/storycove/entity/Story.java +++ b/backend/src/main/java/com/storycove/entity/Story.java @@ -55,6 +55,15 @@ public class Story { @Column(name = "volume") private Integer volume; + @Column(name = "is_read") + private Boolean isRead = false; + + @Column(name = "reading_position") + private Integer readingPosition = 0; + + @Column(name = "last_read_at") + private LocalDateTime lastReadAt; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "author_id") @JsonBackReference("author-stories") @@ -212,6 +221,30 @@ public class Story { this.volume = volume; } + public Boolean getIsRead() { + return isRead; + } + + public void setIsRead(Boolean isRead) { + this.isRead = isRead; + } + + public Integer getReadingPosition() { + return readingPosition; + } + + public void setReadingPosition(Integer readingPosition) { + this.readingPosition = readingPosition; + } + + public LocalDateTime getLastReadAt() { + return lastReadAt; + } + + public void setLastReadAt(LocalDateTime lastReadAt) { + this.lastReadAt = lastReadAt; + } + public Author getAuthor() { return author; } @@ -252,6 +285,37 @@ public class Story { this.updatedAt = updatedAt; } + /** + * Updates the reading progress and timestamp + */ + public void updateReadingProgress(Integer position) { + this.readingPosition = position; + this.lastReadAt = LocalDateTime.now(); + } + + /** + * Marks the story as read and updates the reading position to the end + */ + public void markAsRead() { + this.isRead = true; + this.lastReadAt = LocalDateTime.now(); + // Set reading position to the end of content if available + if (contentPlain != null) { + this.readingPosition = contentPlain.length(); + } else if (contentHtml != null) { + this.readingPosition = contentHtml.length(); + } + } + + /** + * Marks the story as unread and resets reading position + */ + public void markAsUnread() { + this.isRead = false; + this.readingPosition = 0; + this.lastReadAt = null; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -272,6 +336,8 @@ public class Story { ", title='" + title + '\'' + ", wordCount=" + wordCount + ", rating=" + rating + + ", isRead=" + isRead + + ", readingPosition=" + readingPosition + '}'; } } \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/service/StoryService.java b/backend/src/main/java/com/storycove/service/StoryService.java index 982b3ea..408e575 100644 --- a/backend/src/main/java/com/storycove/service/StoryService.java +++ b/backend/src/main/java/com/storycove/service/StoryService.java @@ -271,6 +271,45 @@ public class StoryService { return savedStory; } + @Transactional + public Story updateReadingProgress(UUID id, Integer position) { + if (position != null && position < 0) { + throw new IllegalArgumentException("Reading position must be non-negative"); + } + + Story story = findById(id); + story.updateReadingProgress(position); + Story savedStory = storyRepository.save(story); + + // Update Typesense index with new reading progress + if (typesenseService != null) { + typesenseService.updateStory(savedStory); + } + + return savedStory; + } + + @Transactional + public Story updateReadingStatus(UUID id, Boolean isRead) { + Story story = findById(id); + + if (Boolean.TRUE.equals(isRead)) { + story.markAsRead(); + } else { + story.setIsRead(false); + story.setLastReadAt(LocalDateTime.now()); + } + + Story savedStory = storyRepository.save(story); + + // Update Typesense index with new reading status + if (typesenseService != null) { + typesenseService.updateStory(savedStory); + } + + return savedStory; + } + @Transactional(readOnly = true) public List findBySeriesOrderByVolume(UUID seriesId) { return storyRepository.findBySeriesOrderByVolume(seriesId); diff --git a/backend/src/test/java/com/storycove/service/StoryServiceTest.java b/backend/src/test/java/com/storycove/service/StoryServiceTest.java new file mode 100644 index 0000000..71d1941 --- /dev/null +++ b/backend/src/test/java/com/storycove/service/StoryServiceTest.java @@ -0,0 +1,216 @@ +package com.storycove.service; + +import com.storycove.entity.Story; +import com.storycove.repository.StoryRepository; +import com.storycove.repository.TagRepository; +import com.storycove.service.exception.ResourceNotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("Story Service Unit Tests - Reading Progress") +class StoryServiceTest { + + @Mock + private StoryRepository storyRepository; + + @Mock + private TagRepository tagRepository; + + private StoryService storyService; + private Story testStory; + private UUID testId; + + @BeforeEach + void setUp() { + testId = UUID.randomUUID(); + testStory = new Story("Test Story"); + testStory.setId(testId); + testStory.setContentHtml("

Test content for reading progress tracking

"); + + // Create StoryService with only required repositories, all services can be null for these tests + storyService = new StoryService( + storyRepository, + tagRepository, + null, // authorService - not needed for reading progress tests + null, // tagService - not needed for reading progress tests + null, // seriesService - not needed for reading progress tests + null, // sanitizationService - not needed for reading progress tests + null // typesenseService - will test both with and without + ); + } + + @Test + @DisplayName("Should update reading progress successfully") + void shouldUpdateReadingProgress() { + Integer position = 150; + when(storyRepository.findById(testId)).thenReturn(Optional.of(testStory)); + when(storyRepository.save(any(Story.class))).thenReturn(testStory); + + Story result = storyService.updateReadingProgress(testId, position); + + assertEquals(position, result.getReadingPosition()); + assertNotNull(result.getLastReadAt()); + verify(storyRepository).findById(testId); + verify(storyRepository).save(testStory); + } + + @Test + @DisplayName("Should update reading progress with zero position") + void shouldUpdateReadingProgressWithZeroPosition() { + Integer position = 0; + when(storyRepository.findById(testId)).thenReturn(Optional.of(testStory)); + when(storyRepository.save(any(Story.class))).thenReturn(testStory); + + Story result = storyService.updateReadingProgress(testId, position); + + assertEquals(0, result.getReadingPosition()); + assertNotNull(result.getLastReadAt()); + verify(storyRepository).save(testStory); + } + + @Test + @DisplayName("Should throw exception for negative reading position") + void shouldThrowExceptionForNegativeReadingPosition() { + Integer position = -1; + + assertThrows(IllegalArgumentException.class, + () -> storyService.updateReadingProgress(testId, position)); + + verify(storyRepository, never()).findById(any()); + verify(storyRepository, never()).save(any()); + } + + @Test + @DisplayName("Should handle null reading position") + void shouldHandleNullReadingPosition() { + Integer position = null; + when(storyRepository.findById(testId)).thenReturn(Optional.of(testStory)); + when(storyRepository.save(any(Story.class))).thenReturn(testStory); + + Story result = storyService.updateReadingProgress(testId, position); + + assertNull(result.getReadingPosition()); + assertNotNull(result.getLastReadAt()); + verify(storyRepository).save(testStory); + } + + @Test + @DisplayName("Should throw exception when story not found for reading progress update") + void shouldThrowExceptionWhenStoryNotFoundForReadingProgress() { + Integer position = 100; + when(storyRepository.findById(testId)).thenReturn(Optional.empty()); + + assertThrows(ResourceNotFoundException.class, + () -> storyService.updateReadingProgress(testId, position)); + + verify(storyRepository).findById(testId); + verify(storyRepository, never()).save(any()); + } + + @Test + @DisplayName("Should mark story as read") + void shouldMarkStoryAsRead() { + Boolean isRead = true; + when(storyRepository.findById(testId)).thenReturn(Optional.of(testStory)); + when(storyRepository.save(any(Story.class))).thenReturn(testStory); + + Story result = storyService.updateReadingStatus(testId, isRead); + + assertTrue(result.getIsRead()); + assertNotNull(result.getLastReadAt()); + // When marked as read, position should be set to content length + assertTrue(result.getReadingPosition() > 0); + verify(storyRepository).findById(testId); + verify(storyRepository).save(testStory); + } + + @Test + @DisplayName("Should mark story as unread") + void shouldMarkStoryAsUnread() { + Boolean isRead = false; + // First mark story as read to test transition + testStory.markAsRead(); + + when(storyRepository.findById(testId)).thenReturn(Optional.of(testStory)); + when(storyRepository.save(any(Story.class))).thenReturn(testStory); + + Story result = storyService.updateReadingStatus(testId, isRead); + + assertFalse(result.getIsRead()); + assertNotNull(result.getLastReadAt()); + verify(storyRepository).save(testStory); + } + + @Test + @DisplayName("Should handle null reading status") + void shouldHandleNullReadingStatus() { + Boolean isRead = null; + when(storyRepository.findById(testId)).thenReturn(Optional.of(testStory)); + when(storyRepository.save(any(Story.class))).thenReturn(testStory); + + Story result = storyService.updateReadingStatus(testId, isRead); + + assertFalse(result.getIsRead()); + assertNotNull(result.getLastReadAt()); + verify(storyRepository).save(testStory); + } + + @Test + @DisplayName("Should throw exception when story not found for reading status update") + void shouldThrowExceptionWhenStoryNotFoundForReadingStatus() { + Boolean isRead = true; + when(storyRepository.findById(testId)).thenReturn(Optional.empty()); + + assertThrows(ResourceNotFoundException.class, + () -> storyService.updateReadingStatus(testId, isRead)); + + verify(storyRepository).findById(testId); + verify(storyRepository, never()).save(any()); + } + + + @Test + @DisplayName("Should update lastReadAt timestamp when updating progress") + void shouldUpdateLastReadAtWhenUpdatingProgress() { + Integer position = 50; + LocalDateTime beforeUpdate = LocalDateTime.now().minusMinutes(1); + + when(storyRepository.findById(testId)).thenReturn(Optional.of(testStory)); + when(storyRepository.save(any(Story.class))).thenReturn(testStory); + + Story result = storyService.updateReadingProgress(testId, position); + + assertNotNull(result.getLastReadAt()); + assertTrue(result.getLastReadAt().isAfter(beforeUpdate)); + verify(storyRepository).save(testStory); + } + + @Test + @DisplayName("Should update lastReadAt timestamp when updating status") + void shouldUpdateLastReadAtWhenUpdatingStatus() { + Boolean isRead = true; + LocalDateTime beforeUpdate = LocalDateTime.now().minusMinutes(1); + + when(storyRepository.findById(testId)).thenReturn(Optional.of(testStory)); + when(storyRepository.save(any(Story.class))).thenReturn(testStory); + + Story result = storyService.updateReadingStatus(testId, isRead); + + assertNotNull(result.getLastReadAt()); + assertTrue(result.getLastReadAt().isAfter(beforeUpdate)); + verify(storyRepository).save(testStory); + } +} \ No newline at end of file diff --git a/frontend/src/lib/scraper/strategies/textExtractor.ts b/frontend/src/lib/scraper/strategies/textExtractor.ts index 0113197..49afac0 100644 --- a/frontend/src/lib/scraper/strategies/textExtractor.ts +++ b/frontend/src/lib/scraper/strategies/textExtractor.ts @@ -1,3 +1,4 @@ +import * as cheerio from 'cheerio'; import 'server-only'; // Dynamic cheerio import used to avoid client-side bundling issues @@ -36,7 +37,7 @@ export function extractByTextPattern( } export function extractTextBlocks( - $: any, + $: cheerio.CheerioAPI, config: TextBlockStrategy ): string { const blocks: Array<{element: any, text: string}> = []; @@ -48,7 +49,7 @@ export function extractTextBlocks( }); } - $('*').each((_: any, elem: any) => { + $('*').each((_, elem) => { const $elem = $(elem); const text = $elem.clone().children().remove().end().text().trim(); @@ -101,10 +102,16 @@ export function extractHtmlBetween( } export function extractLinkText( - $: any, + $: cheerio.CheerioAPI, config: LinkTextStrategy ): string { - let searchScope = config.searchWithin ? $(config.searchWithin) : $('body'); + let searchScope: cheerio.Cheerio; + + if (config.searchWithin) { + searchScope = $(config.searchWithin); + } else { + searchScope = $('body').length ? $('body') : $('*'); + } // Look for links near the specified text patterns let foundText = ''; @@ -112,7 +119,7 @@ export function extractLinkText( config.nearText.forEach(text => { if (foundText) return; // Already found - searchScope.find('*').each((_: any, elem: any) => { + searchScope.find('*').each((_, elem) => { const $elem = $(elem); const elemText = $elem.text().toLowerCase(); @@ -132,7 +139,7 @@ export function extractLinkText( // Look for links in the next few siblings const $siblings = $elem.nextAll().slice(0, 3); - $siblings.find('a').first().each((_: any, link: any) => { + $siblings.find('a').first().each((_, link) => { foundText = $(link).text().trim(); return false; });