From 4ab03953ae33aeb611253b6db19d3d582fefff06 Mon Sep 17 00:00:00 2001 From: Stefan Hardegger Date: Wed, 13 Aug 2025 14:48:40 +0200 Subject: [PATCH] random story selector --- .../storycove/controller/StoryController.java | 19 +++ .../storycove/repository/StoryRepository.java | 122 ++++++++++++++++++ .../com/storycove/service/StoryService.java | 74 +++++++++++ frontend/src/app/library/page.tsx | 50 ++++++- frontend/src/lib/api.ts | 16 +++ 5 files changed, 280 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/com/storycove/controller/StoryController.java b/backend/src/main/java/com/storycove/controller/StoryController.java index d823f8f..6267972 100644 --- a/backend/src/main/java/com/storycove/controller/StoryController.java +++ b/backend/src/main/java/com/storycove/controller/StoryController.java @@ -25,6 +25,7 @@ import org.springframework.web.multipart.MultipartFile; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; import java.util.stream.Collectors; @@ -84,6 +85,24 @@ public class StoryController { return ResponseEntity.ok(storyDtos); } + @GetMapping("/random") + public ResponseEntity getRandomStory( + @RequestParam(required = false) String searchQuery, + @RequestParam(required = false) List tags) { + + logger.info("Getting random story with filters - searchQuery: {}, tags: {}", + searchQuery, tags); + + Optional randomStory = storyService.findRandomStory(searchQuery, tags); + + if (randomStory.isPresent()) { + StorySummaryDto storyDto = convertToSummaryDto(randomStory.get()); + return ResponseEntity.ok(storyDto); + } else { + return ResponseEntity.noContent().build(); // 204 No Content when no stories match filters + } + } + @GetMapping("/{id}") public ResponseEntity getStoryById(@PathVariable UUID id) { Story story = storyService.findById(id); diff --git a/backend/src/main/java/com/storycove/repository/StoryRepository.java b/backend/src/main/java/com/storycove/repository/StoryRepository.java index cf7462f..382b322 100644 --- a/backend/src/main/java/com/storycove/repository/StoryRepository.java +++ b/backend/src/main/java/com/storycove/repository/StoryRepository.java @@ -119,4 +119,126 @@ public interface StoryRepository extends JpaRepository { @Query("SELECT s FROM Story s WHERE UPPER(s.title) = UPPER(:title) AND UPPER(s.author.name) = UPPER(:authorName)") List findByTitleAndAuthorNameIgnoreCase(@Param("title") String title, @Param("authorName") String authorName); + /** + * Count all stories for random selection (no filters) + */ + @Query(value = "SELECT COUNT(*) FROM stories", nativeQuery = true) + long countAllStories(); + + /** + * Count stories matching tag name filter for random selection + */ + @Query(value = "SELECT COUNT(DISTINCT s.id) FROM stories s " + + "JOIN story_tags st ON s.id = st.story_id " + + "JOIN tags t ON st.tag_id = t.id " + + "WHERE UPPER(t.name) = UPPER(?1)", + nativeQuery = true) + long countStoriesByTagName(String tagName); + + /** + * Find a random story using offset (no filters) + */ + @Query(value = "SELECT s.* FROM stories s ORDER BY s.id OFFSET ?1 LIMIT 1", nativeQuery = true) + Optional findRandomStory(long offset); + + /** + * Find a random story matching tag name filter using offset + */ + @Query(value = "SELECT s.* FROM stories s " + + "JOIN story_tags st ON s.id = st.story_id " + + "JOIN tags t ON st.tag_id = t.id " + + "WHERE UPPER(t.name) = UPPER(?1) " + + "ORDER BY s.id OFFSET ?2 LIMIT 1", + nativeQuery = true) + Optional findRandomStoryByTagName(String tagName, long offset); + + /** + * Count stories matching multiple tags (ALL tags must be present) + */ + @Query(value = "SELECT COUNT(*) FROM (" + + " SELECT DISTINCT s.id FROM stories s " + + " JOIN story_tags st ON s.id = st.story_id " + + " JOIN tags t ON st.tag_id = t.id " + + " WHERE UPPER(t.name) IN (?1) " + + " GROUP BY s.id " + + " HAVING COUNT(DISTINCT t.name) = ?2" + + ") as matched_stories", + nativeQuery = true) + long countStoriesByMultipleTags(List upperCaseTagNames, int tagCount); + + /** + * Find random story matching multiple tags (ALL tags must be present) + */ + @Query(value = "SELECT s.* FROM stories s " + + "JOIN story_tags st ON s.id = st.story_id " + + "JOIN tags t ON st.tag_id = t.id " + + "WHERE UPPER(t.name) IN (?1) " + + "GROUP BY s.id, s.title, s.summary, s.description, s.content_html, s.content_plain, s.source_url, s.cover_path, s.word_count, s.rating, s.volume, s.is_read, s.reading_position, s.last_read_at, s.author_id, s.series_id, s.created_at, s.updated_at " + + "HAVING COUNT(DISTINCT t.name) = ?2 " + + "ORDER BY s.id OFFSET ?3 LIMIT 1", + nativeQuery = true) + Optional findRandomStoryByMultipleTags(List upperCaseTagNames, int tagCount, long offset); + + /** + * Count stories matching text search (title, author, tags) + */ + @Query(value = "SELECT COUNT(DISTINCT s.id) FROM stories s " + + "LEFT JOIN authors a ON s.author_id = a.id " + + "LEFT JOIN story_tags st ON s.id = st.story_id " + + "LEFT JOIN tags t ON st.tag_id = t.id " + + "WHERE (UPPER(s.title) LIKE UPPER(?1) OR UPPER(a.name) LIKE UPPER(?1) OR UPPER(t.name) LIKE UPPER(?1))", + nativeQuery = true) + long countStoriesByTextSearch(String searchPattern); + + /** + * Find random story matching text search (title, author, tags) + */ + @Query(value = "SELECT DISTINCT s.* FROM stories s " + + "LEFT JOIN authors a ON s.author_id = a.id " + + "LEFT JOIN story_tags st ON s.id = st.story_id " + + "LEFT JOIN tags t ON st.tag_id = t.id " + + "WHERE (UPPER(s.title) LIKE UPPER(?1) OR UPPER(a.name) LIKE UPPER(?1) OR UPPER(t.name) LIKE UPPER(?1)) " + + "ORDER BY s.id OFFSET ?2 LIMIT 1", + nativeQuery = true) + Optional findRandomStoryByTextSearch(String searchPattern, long offset); + + /** + * Count stories matching both text search AND tags + */ + @Query(value = "SELECT COUNT(DISTINCT s.id) FROM stories s " + + "LEFT JOIN authors a ON s.author_id = a.id " + + "LEFT JOIN story_tags st ON s.id = st.story_id " + + "LEFT JOIN tags t ON st.tag_id = t.id " + + "WHERE (UPPER(s.title) LIKE UPPER(?1) OR UPPER(a.name) LIKE UPPER(?1) OR UPPER(t.name) LIKE UPPER(?1)) " + + "AND s.id IN (" + + " SELECT s2.id FROM stories s2 " + + " JOIN story_tags st2 ON s2.id = st2.story_id " + + " JOIN tags t2 ON st2.tag_id = t2.id " + + " WHERE UPPER(t2.name) IN (?2) " + + " GROUP BY s2.id " + + " HAVING COUNT(DISTINCT t2.name) = ?3" + + ")", + nativeQuery = true) + long countStoriesByTextSearchAndTags(String searchPattern, List upperCaseTagNames, int tagCount); + + /** + * Find random story matching both text search AND tags + */ + @Query(value = "SELECT DISTINCT s.* FROM stories s " + + "LEFT JOIN authors a ON s.author_id = a.id " + + "LEFT JOIN story_tags st ON s.id = st.story_id " + + "LEFT JOIN tags t ON st.tag_id = t.id " + + "WHERE (UPPER(s.title) LIKE UPPER(?1) OR UPPER(a.name) LIKE UPPER(?1) OR UPPER(t.name) LIKE UPPER(?1)) " + + "AND s.id IN (" + + " SELECT s2.id FROM stories s2 " + + " JOIN story_tags st2 ON s2.id = st2.story_id " + + " JOIN tags t2 ON st2.tag_id = t2.id " + + " WHERE UPPER(t2.name) IN (?2) " + + " GROUP BY s2.id " + + " HAVING COUNT(DISTINCT t2.name) = ?3" + + ") " + + "ORDER BY s.id OFFSET ?4 LIMIT 1", + nativeQuery = true) + Optional findRandomStoryByTextSearchAndTags(String searchPattern, List upperCaseTagNames, int tagCount, long offset); + } \ 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 04fe082..a7a4fb0 100644 --- a/backend/src/main/java/com/storycove/service/StoryService.java +++ b/backend/src/main/java/com/storycove/service/StoryService.java @@ -650,4 +650,78 @@ public class StoryService { } return storyRepository.findByTitleAndAuthorNameIgnoreCase(title.trim(), authorName.trim()); } + + /** + * Find a random story based on optional filters. + * Uses count + random offset approach for performance with large datasets. + * Supports text search and multiple tags. + */ + @Transactional(readOnly = true) + public Optional findRandomStory(String searchQuery, List tags) { + + // Clean up inputs + String cleanSearchQuery = (searchQuery != null && !searchQuery.trim().isEmpty()) ? searchQuery.trim() : null; + List cleanTags = (tags != null) ? tags.stream() + .filter(tag -> tag != null && !tag.trim().isEmpty()) + .map(String::trim) + .collect(Collectors.toList()) : List.of(); + + long totalCount = 0; + Optional randomStory = Optional.empty(); + + if (cleanSearchQuery != null && !cleanTags.isEmpty()) { + // Both search query and tags + String searchPattern = "%" + cleanSearchQuery + "%"; + List upperCaseTags = cleanTags.stream() + .map(String::toUpperCase) + .collect(Collectors.toList()); + + totalCount = storyRepository.countStoriesByTextSearchAndTags(searchPattern, upperCaseTags, cleanTags.size()); + if (totalCount > 0) { + long randomOffset = (long) (Math.random() * totalCount); + randomStory = storyRepository.findRandomStoryByTextSearchAndTags(searchPattern, upperCaseTags, cleanTags.size(), randomOffset); + } + + } else if (cleanSearchQuery != null) { + // Only search query + String searchPattern = "%" + cleanSearchQuery + "%"; + totalCount = storyRepository.countStoriesByTextSearch(searchPattern); + if (totalCount > 0) { + long randomOffset = (long) (Math.random() * totalCount); + randomStory = storyRepository.findRandomStoryByTextSearch(searchPattern, randomOffset); + } + + } else if (!cleanTags.isEmpty()) { + // Only tags + if (cleanTags.size() == 1) { + // Single tag - use optimized single tag query + totalCount = storyRepository.countStoriesByTagName(cleanTags.get(0)); + if (totalCount > 0) { + long randomOffset = (long) (Math.random() * totalCount); + randomStory = storyRepository.findRandomStoryByTagName(cleanTags.get(0), randomOffset); + } + } else { + // Multiple tags + List upperCaseTags = cleanTags.stream() + .map(String::toUpperCase) + .collect(Collectors.toList()); + + totalCount = storyRepository.countStoriesByMultipleTags(upperCaseTags, cleanTags.size()); + if (totalCount > 0) { + long randomOffset = (long) (Math.random() * totalCount); + randomStory = storyRepository.findRandomStoryByMultipleTags(upperCaseTags, cleanTags.size(), randomOffset); + } + } + + } else { + // No filters - get random from all stories + totalCount = storyRepository.countAllStories(); + if (totalCount > 0) { + long randomOffset = (long) (Math.random() * totalCount); + randomStory = storyRepository.findRandomStory(randomOffset); + } + } + + return randomStory; + } } \ No newline at end of file diff --git a/frontend/src/app/library/page.tsx b/frontend/src/app/library/page.tsx index 17d426f..90c40c1 100644 --- a/frontend/src/app/library/page.tsx +++ b/frontend/src/app/library/page.tsx @@ -1,7 +1,8 @@ 'use client'; import { useState, useEffect } from 'react'; -import { searchApi } from '../../lib/api'; +import { useRouter } from 'next/navigation'; +import { searchApi, storyApi } from '../../lib/api'; import { Story, Tag, FacetCount } from '../../types/api'; import AppLayout from '../../components/layout/AppLayout'; import { Input } from '../../components/ui/Input'; @@ -14,10 +15,12 @@ type ViewMode = 'grid' | 'list'; type SortOption = 'createdAt' | 'title' | 'authorName' | 'rating' | 'wordCount' | 'lastRead'; export default function LibraryPage() { + const router = useRouter(); const [stories, setStories] = useState([]); const [tags, setTags] = useState([]); const [loading, setLoading] = useState(false); const [searchLoading, setSearchLoading] = useState(false); + const [randomLoading, setRandomLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [selectedTags, setSelectedTags] = useState([]); const [viewMode, setViewMode] = useState('list'); @@ -139,6 +142,44 @@ export default function LibraryPage() { setRefreshTrigger(prev => prev + 1); }; + const handleRandomStory = async () => { + try { + setRandomLoading(true); + + // Build filter parameters based on current UI state + const filters: Record = {}; + + // Include search query if present + if (searchQuery && searchQuery.trim() !== '' && searchQuery !== '*') { + filters.searchQuery = searchQuery.trim(); + } + + // Include all selected tags + if (selectedTags.length > 0) { + filters.tags = selectedTags; + } + + console.log('Getting random story with filters:', filters); + + const randomStory = await storyApi.getRandomStory(filters); + + if (!randomStory) { + // No stories match the current filters + alert('No stories match your current filters. Try clearing some filters or adding more stories to your library.'); + return; + } + + // Navigate to the random story's reading page + router.push(`/stories/${randomStory.id}/read`); + + } catch (error) { + console.error('Failed to get random story:', error); + alert('Failed to get a random story. Please try again.'); + } finally { + setRandomLoading(false); + } + }; + if (loading) { return ( @@ -163,6 +204,13 @@ export default function LibraryPage() {
+ diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index aa0df0a..b410e3a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -192,6 +192,22 @@ export const storyApi = { }); return response.data; }, + + getRandomStory: async (filters?: { + searchQuery?: string; + tags?: string[]; + }): Promise => { + try { + const response = await api.get('/stories/random', { params: filters }); + return response.data; + } catch (error: any) { + if (error.response?.status === 204) { + // No content - no stories match filters + return null; + } + throw error; + } + }, }; // Author endpoints