From 1d14d3d7aaba4030609caab03fd6a7531c6ca3e8 Mon Sep 17 00:00:00 2001 From: Stefan Hardegger Date: Thu, 14 Aug 2025 13:14:46 +0200 Subject: [PATCH] Fix for Random Story Function --- backend/cookies_new.txt | 4 + .../com/storycove/service/StoryService.java | 32 +++++++- .../storycove/service/TypesenseService.java | 81 +++++++++++++++++++ frontend/src/app/library/page.tsx | 4 +- frontend/src/lib/api.ts | 12 ++- 5 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 backend/cookies_new.txt diff --git a/backend/cookies_new.txt b/backend/cookies_new.txt new file mode 100644 index 0000000..c31d989 --- /dev/null +++ b/backend/cookies_new.txt @@ -0,0 +1,4 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + diff --git a/backend/src/main/java/com/storycove/service/StoryService.java b/backend/src/main/java/com/storycove/service/StoryService.java index a7a4fb0..d8dbb2f 100644 --- a/backend/src/main/java/com/storycove/service/StoryService.java +++ b/backend/src/main/java/com/storycove/service/StoryService.java @@ -10,6 +10,8 @@ import com.storycove.repository.TagRepository; import com.storycove.service.exception.DuplicateResourceException; import com.storycove.service.exception.ResourceNotFoundException; import jakarta.validation.Valid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.data.domain.Page; @@ -25,11 +27,14 @@ import java.util.List; import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; @Service @Validated @Transactional public class StoryService { + + private static final Logger logger = LoggerFactory.getLogger(StoryService.class); private final StoryRepository storyRepository; private final TagRepository tagRepository; @@ -653,12 +658,35 @@ public class StoryService { /** * Find a random story based on optional filters. - * Uses count + random offset approach for performance with large datasets. - * Supports text search and multiple tags. + * Uses Typesense for consistency with Library search functionality. + * Supports text search and multiple tags using the same logic as the Library view. */ @Transactional(readOnly = true) public Optional findRandomStory(String searchQuery, List tags) { + // Use Typesense if available for consistency with Library search + if (typesenseService != null) { + try { + Optional randomStoryId = typesenseService.getRandomStoryId(searchQuery, tags); + if (randomStoryId.isPresent()) { + return storyRepository.findById(randomStoryId.get()); + } + return Optional.empty(); + } catch (Exception e) { + // Fallback to database queries if Typesense fails + logger.warn("Typesense random story lookup failed, falling back to database queries", e); + } + } + + // Fallback to original database-based implementation if Typesense is not available + return findRandomStoryFallback(searchQuery, tags); + } + + /** + * Fallback method for random story selection using database queries. + * Used when Typesense is not available or fails. + */ + private Optional findRandomStoryFallback(String searchQuery, List tags) { // Clean up inputs String cleanSearchQuery = (searchQuery != null && !searchQuery.trim().isEmpty()) ? searchQuery.trim() : null; List cleanTags = (tags != null) ? tags.stream() diff --git a/backend/src/main/java/com/storycove/service/TypesenseService.java b/backend/src/main/java/com/storycove/service/TypesenseService.java index 4761c9d..5c17b60 100644 --- a/backend/src/main/java/com/storycove/service/TypesenseService.java +++ b/backend/src/main/java/com/storycove/service/TypesenseService.java @@ -352,6 +352,87 @@ public class TypesenseService { } } + /** + * Get a random story using the same search logic as the Library view. + * This ensures consistency between Library search results and Random Story functionality. + * Uses offset-based randomization since Typesense v0.25.0 doesn't support _rand() sorting. + */ + public Optional getRandomStoryId(String searchQuery, List tags) { + try { + String normalizedQuery = (searchQuery == null || searchQuery.trim().isEmpty()) ? "*" : searchQuery.trim(); + + // First, get the total count of matching stories + SearchParameters countParameters = new SearchParameters() + .q(normalizedQuery) + .queryBy("title,description,contentPlain,authorName,seriesName,tagNames") + .perPage(0); // No results, just count + + // Add tag filters if provided + if (tags != null && !tags.isEmpty()) { + String tagFilter = tags.stream() + .map(tag -> "tagNames:=" + escapeTypesenseValue(tag)) + .collect(Collectors.joining(" && ")); + countParameters.filterBy(tagFilter); + } + + logger.debug("Getting random story with query: '{}', tags: {}", normalizedQuery, tags); + + SearchResult countResult = typesenseClient.collections(STORIES_COLLECTION) + .documents() + .search(countParameters); + + long totalHits = countResult.getFound(); + if (totalHits == 0) { + logger.debug("No stories found matching filters"); + return Optional.empty(); + } + + // Generate random offset within the total hits + long randomOffset = (long) (Math.random() * totalHits); + logger.debug("Total hits: {}, using random offset: {}", totalHits, randomOffset); + + // Now get the actual story at that offset + SearchParameters storyParameters = new SearchParameters() + .q(normalizedQuery) + .queryBy("title,description,contentPlain,authorName,seriesName,tagNames") + .page((int) (randomOffset / 250) + 1) // Calculate page (Typesense uses 1-based pages) + .perPage(250) // Get multiple results to account for offset within page + .sortBy("_text_match:desc,createdAt:desc"); // Use standard sorting + + // Add same tag filters + if (tags != null && !tags.isEmpty()) { + String tagFilter = tags.stream() + .map(tag -> "tagNames:=" + escapeTypesenseValue(tag)) + .collect(Collectors.joining(" && ")); + storyParameters.filterBy(tagFilter); + } + + SearchResult storyResult = typesenseClient.collections(STORIES_COLLECTION) + .documents() + .search(storyParameters); + + if (storyResult.getHits().isEmpty()) { + logger.debug("No stories found in random offset query"); + return Optional.empty(); + } + + // Calculate which hit to select from the page + int indexInPage = (int) (randomOffset % 250); + indexInPage = Math.min(indexInPage, storyResult.getHits().size() - 1); + + SearchResultHit hit = storyResult.getHits().get(indexInPage); + String storyId = (String) hit.getDocument().get("id"); + logger.debug("Found random story ID: {} at offset {} (page {}, index {})", + storyId, randomOffset, storyParameters.getPage(), indexInPage); + + return Optional.of(UUID.fromString(storyId)); + + } catch (Exception e) { + logger.error("Failed to get random story with query: '{}', tags: {}", searchQuery, tags, e); + return Optional.empty(); + } + } + private Map createStoryDocument(Story story) { Map document = new HashMap<>(); document.put("id", story.getId().toString()); diff --git a/frontend/src/app/library/page.tsx b/frontend/src/app/library/page.tsx index 90c40c1..00fc117 100644 --- a/frontend/src/app/library/page.tsx +++ b/frontend/src/app/library/page.tsx @@ -169,8 +169,8 @@ export default function LibraryPage() { return; } - // Navigate to the random story's reading page - router.push(`/stories/${randomStory.id}/read`); + // Navigate to the random story's detail page + router.push(`/stories/${randomStory.id}`); } catch (error) { console.error('Failed to get random story:', error); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b410e3a..27a87d0 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -198,7 +198,17 @@ export const storyApi = { tags?: string[]; }): Promise => { try { - const response = await api.get('/stories/random', { params: filters }); + // Create URLSearchParams to properly handle array parameters like tags + const searchParams = new URLSearchParams(); + + if (filters?.searchQuery) { + searchParams.append('searchQuery', filters.searchQuery); + } + if (filters?.tags && filters.tags.length > 0) { + filters.tags.forEach(tag => searchParams.append('tags', tag)); + } + + const response = await api.get(`/stories/random?${searchParams.toString()}`); return response.data; } catch (error: any) { if (error.response?.status === 204) {