Fix for Random Story Function
This commit is contained in:
4
backend/cookies_new.txt
Normal file
4
backend/cookies_new.txt
Normal file
@@ -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.
|
||||||
|
|
||||||
@@ -10,6 +10,8 @@ import com.storycove.repository.TagRepository;
|
|||||||
import com.storycove.service.exception.DuplicateResourceException;
|
import com.storycove.service.exception.DuplicateResourceException;
|
||||||
import com.storycove.service.exception.ResourceNotFoundException;
|
import com.storycove.service.exception.ResourceNotFoundException;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
@@ -25,11 +27,14 @@ import java.util.List;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Validated
|
@Validated
|
||||||
@Transactional
|
@Transactional
|
||||||
public class StoryService {
|
public class StoryService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(StoryService.class);
|
||||||
|
|
||||||
private final StoryRepository storyRepository;
|
private final StoryRepository storyRepository;
|
||||||
private final TagRepository tagRepository;
|
private final TagRepository tagRepository;
|
||||||
@@ -653,12 +658,35 @@ public class StoryService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Find a random story based on optional filters.
|
* Find a random story based on optional filters.
|
||||||
* Uses count + random offset approach for performance with large datasets.
|
* Uses Typesense for consistency with Library search functionality.
|
||||||
* Supports text search and multiple tags.
|
* Supports text search and multiple tags using the same logic as the Library view.
|
||||||
*/
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public Optional<Story> findRandomStory(String searchQuery, List<String> tags) {
|
public Optional<Story> findRandomStory(String searchQuery, List<String> tags) {
|
||||||
|
|
||||||
|
// Use Typesense if available for consistency with Library search
|
||||||
|
if (typesenseService != null) {
|
||||||
|
try {
|
||||||
|
Optional<UUID> 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<Story> findRandomStoryFallback(String searchQuery, List<String> tags) {
|
||||||
// Clean up inputs
|
// Clean up inputs
|
||||||
String cleanSearchQuery = (searchQuery != null && !searchQuery.trim().isEmpty()) ? searchQuery.trim() : null;
|
String cleanSearchQuery = (searchQuery != null && !searchQuery.trim().isEmpty()) ? searchQuery.trim() : null;
|
||||||
List<String> cleanTags = (tags != null) ? tags.stream()
|
List<String> cleanTags = (tags != null) ? tags.stream()
|
||||||
|
|||||||
@@ -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<UUID> getRandomStoryId(String searchQuery, List<String> 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<String, Object> createStoryDocument(Story story) {
|
private Map<String, Object> createStoryDocument(Story story) {
|
||||||
Map<String, Object> document = new HashMap<>();
|
Map<String, Object> document = new HashMap<>();
|
||||||
document.put("id", story.getId().toString());
|
document.put("id", story.getId().toString());
|
||||||
|
|||||||
@@ -169,8 +169,8 @@ export default function LibraryPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigate to the random story's reading page
|
// Navigate to the random story's detail page
|
||||||
router.push(`/stories/${randomStory.id}/read`);
|
router.push(`/stories/${randomStory.id}`);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get random story:', error);
|
console.error('Failed to get random story:', error);
|
||||||
|
|||||||
@@ -198,7 +198,17 @@ export const storyApi = {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
}): Promise<Story | null> => {
|
}): Promise<Story | null> => {
|
||||||
try {
|
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;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.response?.status === 204) {
|
if (error.response?.status === 204) {
|
||||||
|
|||||||
Reference in New Issue
Block a user