random story selector
This commit is contained in:
@@ -25,6 +25,7 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -84,6 +85,24 @@ public class StoryController {
|
|||||||
return ResponseEntity.ok(storyDtos);
|
return ResponseEntity.ok(storyDtos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/random")
|
||||||
|
public ResponseEntity<StorySummaryDto> getRandomStory(
|
||||||
|
@RequestParam(required = false) String searchQuery,
|
||||||
|
@RequestParam(required = false) List<String> tags) {
|
||||||
|
|
||||||
|
logger.info("Getting random story with filters - searchQuery: {}, tags: {}",
|
||||||
|
searchQuery, tags);
|
||||||
|
|
||||||
|
Optional<Story> 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}")
|
@GetMapping("/{id}")
|
||||||
public ResponseEntity<StoryDto> getStoryById(@PathVariable UUID id) {
|
public ResponseEntity<StoryDto> getStoryById(@PathVariable UUID id) {
|
||||||
Story story = storyService.findById(id);
|
Story story = storyService.findById(id);
|
||||||
|
|||||||
@@ -119,4 +119,126 @@ public interface StoryRepository extends JpaRepository<Story, UUID> {
|
|||||||
@Query("SELECT s FROM Story s WHERE UPPER(s.title) = UPPER(:title) AND UPPER(s.author.name) = UPPER(:authorName)")
|
@Query("SELECT s FROM Story s WHERE UPPER(s.title) = UPPER(:title) AND UPPER(s.author.name) = UPPER(:authorName)")
|
||||||
List<Story> findByTitleAndAuthorNameIgnoreCase(@Param("title") String title, @Param("authorName") String authorName);
|
List<Story> 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<Story> 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<Story> 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<String> 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<Story> findRandomStoryByMultipleTags(List<String> 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<Story> 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<String> 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<Story> findRandomStoryByTextSearchAndTags(String searchPattern, List<String> upperCaseTagNames, int tagCount, long offset);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -650,4 +650,78 @@ public class StoryService {
|
|||||||
}
|
}
|
||||||
return storyRepository.findByTitleAndAuthorNameIgnoreCase(title.trim(), authorName.trim());
|
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<Story> findRandomStory(String searchQuery, List<String> tags) {
|
||||||
|
|
||||||
|
// Clean up inputs
|
||||||
|
String cleanSearchQuery = (searchQuery != null && !searchQuery.trim().isEmpty()) ? searchQuery.trim() : null;
|
||||||
|
List<String> cleanTags = (tags != null) ? tags.stream()
|
||||||
|
.filter(tag -> tag != null && !tag.trim().isEmpty())
|
||||||
|
.map(String::trim)
|
||||||
|
.collect(Collectors.toList()) : List.of();
|
||||||
|
|
||||||
|
long totalCount = 0;
|
||||||
|
Optional<Story> randomStory = Optional.empty();
|
||||||
|
|
||||||
|
if (cleanSearchQuery != null && !cleanTags.isEmpty()) {
|
||||||
|
// Both search query and tags
|
||||||
|
String searchPattern = "%" + cleanSearchQuery + "%";
|
||||||
|
List<String> 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<String> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
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 { Story, Tag, FacetCount } from '../../types/api';
|
||||||
import AppLayout from '../../components/layout/AppLayout';
|
import AppLayout from '../../components/layout/AppLayout';
|
||||||
import { Input } from '../../components/ui/Input';
|
import { Input } from '../../components/ui/Input';
|
||||||
@@ -14,10 +15,12 @@ type ViewMode = 'grid' | 'list';
|
|||||||
type SortOption = 'createdAt' | 'title' | 'authorName' | 'rating' | 'wordCount' | 'lastRead';
|
type SortOption = 'createdAt' | 'title' | 'authorName' | 'rating' | 'wordCount' | 'lastRead';
|
||||||
|
|
||||||
export default function LibraryPage() {
|
export default function LibraryPage() {
|
||||||
|
const router = useRouter();
|
||||||
const [stories, setStories] = useState<Story[]>([]);
|
const [stories, setStories] = useState<Story[]>([]);
|
||||||
const [tags, setTags] = useState<Tag[]>([]);
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [searchLoading, setSearchLoading] = useState(false);
|
const [searchLoading, setSearchLoading] = useState(false);
|
||||||
|
const [randomLoading, setRandomLoading] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||||
@@ -139,6 +142,44 @@ export default function LibraryPage() {
|
|||||||
setRefreshTrigger(prev => prev + 1);
|
setRefreshTrigger(prev => prev + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRandomStory = async () => {
|
||||||
|
try {
|
||||||
|
setRandomLoading(true);
|
||||||
|
|
||||||
|
// Build filter parameters based on current UI state
|
||||||
|
const filters: Record<string, any> = {};
|
||||||
|
|
||||||
|
// 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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
@@ -163,6 +204,13 @@ export default function LibraryPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleRandomStory}
|
||||||
|
disabled={randomLoading || totalElements === 0}
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{randomLoading ? '🎲 ...' : '🎲 Random Story'}
|
||||||
|
</Button>
|
||||||
<Button href="/import">
|
<Button href="/import">
|
||||||
Add New Story
|
Add New Story
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -192,6 +192,22 @@ export const storyApi = {
|
|||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getRandomStory: async (filters?: {
|
||||||
|
searchQuery?: string;
|
||||||
|
tags?: string[];
|
||||||
|
}): Promise<Story | null> => {
|
||||||
|
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
|
// Author endpoints
|
||||||
|
|||||||
Reference in New Issue
Block a user