Advanced Filters - Build optimizations

This commit is contained in:
Stefan Hardegger
2025-09-04 15:49:24 +02:00
parent 702fcb33c1
commit f92dcc5314
14 changed files with 1426 additions and 109 deletions

View File

@@ -90,12 +90,33 @@ public class StoryController {
public ResponseEntity<StorySummaryDto> getRandomStory(
@RequestParam(required = false) String searchQuery,
@RequestParam(required = false) List<String> tags,
@RequestParam(required = false) Long seed) {
@RequestParam(required = false) Long seed,
// Advanced filters
@RequestParam(required = false) Integer minWordCount,
@RequestParam(required = false) Integer maxWordCount,
@RequestParam(required = false) String createdAfter,
@RequestParam(required = false) String createdBefore,
@RequestParam(required = false) String lastReadAfter,
@RequestParam(required = false) String lastReadBefore,
@RequestParam(required = false) Integer minRating,
@RequestParam(required = false) Integer maxRating,
@RequestParam(required = false) Boolean unratedOnly,
@RequestParam(required = false) String readingStatus,
@RequestParam(required = false) Boolean hasReadingProgress,
@RequestParam(required = false) Boolean hasCoverImage,
@RequestParam(required = false) String sourceDomain,
@RequestParam(required = false) String seriesFilter,
@RequestParam(required = false) Integer minTagCount,
@RequestParam(required = false) Boolean popularOnly,
@RequestParam(required = false) Boolean hiddenGemsOnly) {
logger.info("Getting random story with filters - searchQuery: {}, tags: {}, seed: {}",
searchQuery, tags, seed);
Optional<Story> randomStory = storyService.findRandomStory(searchQuery, tags, seed);
Optional<Story> randomStory = storyService.findRandomStory(searchQuery, tags, seed,
minWordCount, maxWordCount, createdAfter, createdBefore, lastReadAfter, lastReadBefore,
minRating, maxRating, unratedOnly, readingStatus, hasReadingProgress, hasCoverImage,
sourceDomain, seriesFilter, minTagCount, popularOnly, hiddenGemsOnly);
if (randomStory.isPresent()) {
StorySummaryDto storyDto = convertToSummaryDto(randomStory.get());
@@ -273,12 +294,31 @@ public class StoryController {
@RequestParam(required = false) Integer maxRating,
@RequestParam(required = false) String sortBy,
@RequestParam(required = false) String sortDir,
@RequestParam(required = false) String facetBy) {
@RequestParam(required = false) String facetBy,
// Advanced filters
@RequestParam(required = false) Integer minWordCount,
@RequestParam(required = false) Integer maxWordCount,
@RequestParam(required = false) String createdAfter,
@RequestParam(required = false) String createdBefore,
@RequestParam(required = false) String lastReadAfter,
@RequestParam(required = false) String lastReadBefore,
@RequestParam(required = false) Boolean unratedOnly,
@RequestParam(required = false) String readingStatus,
@RequestParam(required = false) Boolean hasReadingProgress,
@RequestParam(required = false) Boolean hasCoverImage,
@RequestParam(required = false) String sourceDomain,
@RequestParam(required = false) String seriesFilter,
@RequestParam(required = false) Integer minTagCount,
@RequestParam(required = false) Boolean popularOnly,
@RequestParam(required = false) Boolean hiddenGemsOnly) {
if (typesenseService != null) {
SearchResultDto<StorySearchDto> results = typesenseService.searchStories(
query, page, size, authors, tags, minRating, maxRating, sortBy, sortDir, facetBy);
query, page, size, authors, tags, minRating, maxRating, sortBy, sortDir, facetBy,
minWordCount, maxWordCount, createdAfter, createdBefore, lastReadAfter, lastReadBefore,
unratedOnly, readingStatus, hasReadingProgress, hasCoverImage, sourceDomain, seriesFilter,
minTagCount, popularOnly, hiddenGemsOnly);
return ResponseEntity.ok(results);
} else {
// Fallback to basic search if Typesense is not available

View File

@@ -682,7 +682,13 @@ public class StoryService {
*/
@Transactional(readOnly = true)
public Optional<Story> findRandomStory(String searchQuery, List<String> tags) {
return findRandomStory(searchQuery, tags, null);
return findRandomStory(searchQuery, tags, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null);
}
public Optional<Story> findRandomStory(String searchQuery, List<String> tags, Long seed) {
return findRandomStory(searchQuery, tags, seed, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null);
}
/**
@@ -695,12 +701,23 @@ public class StoryService {
* @return Optional containing the random story if found
*/
@Transactional(readOnly = true)
public Optional<Story> findRandomStory(String searchQuery, List<String> tags, Long seed) {
public Optional<Story> findRandomStory(String searchQuery, List<String> tags, Long seed,
Integer minWordCount, Integer maxWordCount,
String createdAfter, String createdBefore,
String lastReadAfter, String lastReadBefore,
Integer minRating, Integer maxRating, Boolean unratedOnly,
String readingStatus, Boolean hasReadingProgress,
Boolean hasCoverImage, String sourceDomain,
String seriesFilter, Integer minTagCount,
Boolean popularOnly, Boolean hiddenGemsOnly) {
// Use Typesense if available for consistency with Library search
if (typesenseService != null) {
try {
Optional<UUID> randomStoryId = typesenseService.getRandomStoryId(searchQuery, tags, seed);
Optional<UUID> randomStoryId = typesenseService.getRandomStoryId(searchQuery, tags, seed,
minWordCount, maxWordCount, createdAfter, createdBefore, lastReadAfter, lastReadBefore,
minRating, maxRating, unratedOnly, readingStatus, hasReadingProgress, hasCoverImage,
sourceDomain, seriesFilter, minTagCount, popularOnly, hiddenGemsOnly);
if (randomStoryId.isPresent()) {
return storyRepository.findById(randomStoryId.get());
}

View File

@@ -124,9 +124,16 @@ public class TypesenseService {
new Field().name("wordCount").type("int32").facet(true).sort(true).optional(true),
new Field().name("volume").type("int32").facet(true).sort(true).optional(true),
new Field().name("createdAt").type("int64").facet(false).sort(true),
new Field().name("lastReadAt").type("int64").facet(false).sort(true),
new Field().name("lastReadAt").type("int64").facet(false).sort(true).optional(true),
new Field().name("sourceUrl").type("string").facet(false).optional(true),
new Field().name("coverPath").type("string").facet(false).optional(true)
new Field().name("coverPath").type("string").facet(false).optional(true),
// New advanced filter fields
new Field().name("isRead").type("bool").facet(true).optional(true),
new Field().name("readingPosition").type("int32").facet(true).sort(true).optional(true),
new Field().name("hasCover").type("bool").facet(true).optional(true),
new Field().name("sourceDomain").type("string").facet(true).optional(true),
new Field().name("isPartOfSeries").type("bool").facet(true).optional(true),
new Field().name("tagCount").type("int32").facet(true).sort(true).optional(true)
);
CollectionSchema collectionSchema = new CollectionSchema()
@@ -249,7 +256,23 @@ public class TypesenseService {
Integer maxRating,
String sortBy,
String sortDir,
String facetBy) {
String facetBy,
// Advanced filters
Integer minWordCount,
Integer maxWordCount,
String createdAfter,
String createdBefore,
String lastReadAfter,
String lastReadBefore,
Boolean unratedOnly,
String readingStatus,
Boolean hasReadingProgress,
Boolean hasCoverImage,
String sourceDomain,
String seriesFilter,
Integer minTagCount,
Boolean popularOnly,
Boolean hiddenGemsOnly) {
try {
long startTime = System.currentTimeMillis();
@@ -309,6 +332,99 @@ public class TypesenseService {
filterConditions.add("rating:<=" + maxRating);
}
// Advanced filters
if (minWordCount != null) {
filterConditions.add("wordCount:>=" + minWordCount);
}
if (maxWordCount != null) {
filterConditions.add("wordCount:<=" + maxWordCount);
}
if (createdAfter != null) {
long timestamp = convertDateStringToTimestamp(createdAfter + "T00:00:00Z");
filterConditions.add("createdAt:>" + timestamp);
}
if (createdBefore != null) {
long timestamp = convertDateStringToTimestamp(createdBefore + "T23:59:59Z");
filterConditions.add("createdAt:<" + timestamp);
}
if (lastReadAfter != null) {
long timestamp = convertDateStringToTimestamp(lastReadAfter + "T00:00:00Z");
filterConditions.add("lastReadAt:>" + timestamp);
}
if (lastReadBefore != null) {
long timestamp = convertDateStringToTimestamp(lastReadBefore + "T23:59:59Z");
filterConditions.add("lastReadAt:<" + timestamp);
}
if (unratedOnly != null && unratedOnly) {
filterConditions.add("rating:=0");
}
if (readingStatus != null) {
switch (readingStatus.toLowerCase()) {
case "unread":
filterConditions.add("isRead:=false && readingPosition:=0");
break;
case "started":
filterConditions.add("isRead:=false && readingPosition:>0");
break;
case "completed":
filterConditions.add("isRead:=true");
break;
}
}
if (hasReadingProgress != null) {
if (hasReadingProgress) {
filterConditions.add("readingPosition:>0");
} else {
filterConditions.add("readingPosition:=0");
}
}
if (hasCoverImage != null) {
if (hasCoverImage) {
filterConditions.add("hasCover:=true");
} else {
filterConditions.add("hasCover:=false");
}
}
if (sourceDomain != null && !sourceDomain.trim().isEmpty()) {
filterConditions.add("sourceDomain:=" + escapeTypesenseValue(sourceDomain.trim()));
}
if (seriesFilter != null) {
switch (seriesFilter.toLowerCase()) {
case "standalone":
filterConditions.add("isPartOfSeries:=false");
break;
case "series":
filterConditions.add("isPartOfSeries:=true");
break;
case "firstinseries":
filterConditions.add("isPartOfSeries:=true && volume:=1");
break;
case "lastinseries":
// This is complex to implement without knowing series lengths
// For now, we'll skip this specific filter
break;
}
}
if (minTagCount != null) {
filterConditions.add("tagCount:>=" + minTagCount);
}
// Note: popularOnly and hiddenGemsOnly would require calculating average ratings
// which is complex for Typesense - these could be implemented as post-processing
// or by maintaining aggregate statistics in the index
if (!filterConditions.isEmpty()) {
String finalFilter = String.join(" && ", filterConditions);
searchParameters.filterBy(finalFilter);
@@ -399,12 +515,26 @@ public class TypesenseService {
}
}
// Backward compatibility overloaded methods
public Optional<UUID> getRandomStoryId(String searchQuery, List<String> tags, Long seed) {
return getRandomStoryId(searchQuery, tags, seed, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null);
}
/**
* 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 Typesense's native _rand() function for efficient randomization with optional seed support.
*/
public Optional<UUID> getRandomStoryId(String searchQuery, List<String> tags, Long seed) {
public Optional<UUID> getRandomStoryId(String searchQuery, List<String> tags, Long seed,
Integer minWordCount, Integer maxWordCount,
String createdAfter, String createdBefore,
String lastReadAfter, String lastReadBefore,
Integer minRating, Integer maxRating, Boolean unratedOnly,
String readingStatus, Boolean hasReadingProgress,
Boolean hasCoverImage, String sourceDomain,
String seriesFilter, Integer minTagCount,
Boolean popularOnly, Boolean hiddenGemsOnly) {
try {
String normalizedQuery = (searchQuery == null || searchQuery.trim().isEmpty()) ? "*" : searchQuery.trim();
@@ -422,12 +552,111 @@ public class TypesenseService {
.sortBy(sortBy)
.perPage(1); // Only need one random result
// Add tag filters if provided
// Build all filter conditions (reuse logic from searchStories)
List<String> filterConditions = new ArrayList<>();
if (tags != null && !tags.isEmpty()) {
String tagFilter = tags.stream()
.map(tag -> "tagNames:=" + escapeTypesenseValue(tag))
.collect(Collectors.joining(" && "));
searchParameters.filterBy(tagFilter);
for (String tag : tags) {
String escaped = escapeTypesenseValue(tag);
filterConditions.add("tagNames:=" + escaped);
}
}
// Add advanced filters (same logic as searchStories method)
if (minWordCount != null) {
filterConditions.add("wordCount:>=" + minWordCount);
}
if (maxWordCount != null) {
filterConditions.add("wordCount:<=" + maxWordCount);
}
if (createdAfter != null) {
filterConditions.add("createdAt:>" + createdAfter + "T00:00:00Z");
}
if (createdBefore != null) {
filterConditions.add("createdAt:<" + createdBefore + "T23:59:59Z");
}
if (lastReadAfter != null) {
filterConditions.add("lastReadAt:>" + lastReadAfter + "T00:00:00Z");
}
if (lastReadBefore != null) {
filterConditions.add("lastReadAt:<" + lastReadBefore + "T23:59:59Z");
}
if (minRating != null) {
filterConditions.add("rating:>=" + minRating);
}
if (maxRating != null) {
filterConditions.add("rating:<=" + maxRating);
}
if (unratedOnly != null && unratedOnly) {
filterConditions.add("rating:=0");
}
if (readingStatus != null) {
switch (readingStatus.toLowerCase()) {
case "unread":
filterConditions.add("isRead:=false && readingPosition:=0");
break;
case "started":
filterConditions.add("isRead:=false && readingPosition:>0");
break;
case "completed":
filterConditions.add("isRead:=true");
break;
}
}
if (hasReadingProgress != null) {
if (hasReadingProgress) {
filterConditions.add("readingPosition:>0");
} else {
filterConditions.add("readingPosition:=0");
}
}
if (hasCoverImage != null) {
if (hasCoverImage) {
filterConditions.add("hasCover:=true");
} else {
filterConditions.add("hasCover:=false");
}
}
if (sourceDomain != null && !sourceDomain.trim().isEmpty()) {
filterConditions.add("sourceDomain:=" + escapeTypesenseValue(sourceDomain.trim()));
}
if (seriesFilter != null) {
switch (seriesFilter.toLowerCase()) {
case "standalone":
filterConditions.add("isPartOfSeries:=false");
break;
case "series":
filterConditions.add("isPartOfSeries:=true");
break;
case "firstinseries":
filterConditions.add("isPartOfSeries:=true && volume:=1");
break;
case "lastinseries":
// Skip for now - complex to implement
break;
}
}
if (minTagCount != null) {
filterConditions.add("tagCount:>=" + minTagCount);
}
if (!filterConditions.isEmpty()) {
String finalFilter = String.join(" && ", filterConditions);
searchParameters.filterBy(finalFilter);
}
SearchResult searchResult = libraryService.getCurrentTypesenseClient().collections(getStoriesCollection())
@@ -449,7 +678,10 @@ public class TypesenseService {
logger.warn("Failed to use _rand() function, falling back to offset-based randomization: {}", randException.getMessage());
// Fallback to offset-based randomization if _rand() is not supported
return getRandomStoryIdFallback(normalizedQuery, tags, seed);
return getRandomStoryIdFallback(normalizedQuery, tags, seed, minWordCount, maxWordCount,
createdAfter, createdBefore, lastReadAfter, lastReadBefore, minRating, maxRating,
unratedOnly, readingStatus, hasReadingProgress, hasCoverImage, sourceDomain,
seriesFilter, minTagCount, popularOnly, hiddenGemsOnly);
}
} catch (Exception e) {
@@ -462,7 +694,15 @@ public class TypesenseService {
* Fallback method for random story selection using offset-based randomization with seed support.
* Used when Typesense's _rand() function is not available or supported.
*/
private Optional<UUID> getRandomStoryIdFallback(String normalizedQuery, List<String> tags, Long seed) {
private Optional<UUID> getRandomStoryIdFallback(String normalizedQuery, List<String> tags, Long seed,
Integer minWordCount, Integer maxWordCount,
String createdAfter, String createdBefore,
String lastReadAfter, String lastReadBefore,
Integer minRating, Integer maxRating, Boolean unratedOnly,
String readingStatus, Boolean hasReadingProgress,
Boolean hasCoverImage, String sourceDomain,
String seriesFilter, Integer minTagCount,
Boolean popularOnly, Boolean hiddenGemsOnly) {
try {
// First, get the total count of matching stories
SearchParameters countParameters = new SearchParameters()
@@ -470,12 +710,115 @@ public class TypesenseService {
.queryBy("title,description,authorName,seriesName,tagNames")
.perPage(0); // No results, just count
// Add tag filters if provided
// Build all filter conditions (same as main method)
List<String> filterConditions = new ArrayList<>();
if (tags != null && !tags.isEmpty()) {
String tagFilter = tags.stream()
.map(tag -> "tagNames:=" + escapeTypesenseValue(tag))
.collect(Collectors.joining(" && "));
countParameters.filterBy(tagFilter);
for (String tag : tags) {
String escaped = escapeTypesenseValue(tag);
filterConditions.add("tagNames:=" + escaped);
}
}
// Add advanced filters
if (minWordCount != null) {
filterConditions.add("wordCount:>=" + minWordCount);
}
if (maxWordCount != null) {
filterConditions.add("wordCount:<=" + maxWordCount);
}
if (createdAfter != null) {
long timestamp = convertDateStringToTimestamp(createdAfter + "T00:00:00Z");
filterConditions.add("createdAt:>" + timestamp);
}
if (createdBefore != null) {
long timestamp = convertDateStringToTimestamp(createdBefore + "T23:59:59Z");
filterConditions.add("createdAt:<" + timestamp);
}
if (lastReadAfter != null) {
long timestamp = convertDateStringToTimestamp(lastReadAfter + "T00:00:00Z");
filterConditions.add("lastReadAt:>" + timestamp);
}
if (lastReadBefore != null) {
long timestamp = convertDateStringToTimestamp(lastReadBefore + "T23:59:59Z");
filterConditions.add("lastReadAt:<" + timestamp);
}
if (minRating != null) {
filterConditions.add("rating:>=" + minRating);
}
if (maxRating != null) {
filterConditions.add("rating:<=" + maxRating);
}
if (unratedOnly != null && unratedOnly) {
filterConditions.add("rating:=0");
}
if (readingStatus != null) {
switch (readingStatus.toLowerCase()) {
case "unread":
filterConditions.add("isRead:=false && readingPosition:=0");
break;
case "started":
filterConditions.add("isRead:=false && readingPosition:>0");
break;
case "completed":
filterConditions.add("isRead:=true");
break;
}
}
if (hasReadingProgress != null) {
if (hasReadingProgress) {
filterConditions.add("readingPosition:>0");
} else {
filterConditions.add("readingPosition:=0");
}
}
if (hasCoverImage != null) {
if (hasCoverImage) {
filterConditions.add("hasCover:=true");
} else {
filterConditions.add("hasCover:=false");
}
}
if (sourceDomain != null && !sourceDomain.trim().isEmpty()) {
filterConditions.add("sourceDomain:=" + escapeTypesenseValue(sourceDomain.trim()));
}
if (seriesFilter != null) {
switch (seriesFilter.toLowerCase()) {
case "standalone":
filterConditions.add("isPartOfSeries:=false");
break;
case "series":
filterConditions.add("isPartOfSeries:=true");
break;
case "firstinseries":
filterConditions.add("isPartOfSeries:=true && volume:=1");
break;
case "lastinseries":
// Skip for now - complex to implement
break;
}
}
if (minTagCount != null) {
filterConditions.add("tagCount:>=" + minTagCount);
}
if (!filterConditions.isEmpty()) {
String finalFilter = String.join(" && ", filterConditions);
countParameters.filterBy(finalFilter);
}
SearchResult countResult = libraryService.getCurrentTypesenseClient().collections(getStoriesCollection())
@@ -595,6 +938,27 @@ public class TypesenseService {
document.put("coverPath", story.getCoverPath());
}
// New advanced filter fields
document.put("isRead", story.getIsRead() != null ? story.getIsRead() : false);
document.put("readingPosition", story.getReadingPosition() != null ? story.getReadingPosition() : 0);
document.put("hasCover", story.getCoverPath() != null && !story.getCoverPath().trim().isEmpty());
// Extract domain from source URL for filtering
if (story.getSourceUrl() != null && !story.getSourceUrl().trim().isEmpty()) {
try {
java.net.URI uri = java.net.URI.create(story.getSourceUrl());
String host = uri.getHost();
document.put("sourceDomain", host != null ? host.toLowerCase() : "");
} catch (Exception e) {
document.put("sourceDomain", "");
}
} else {
document.put("sourceDomain", "");
}
document.put("isPartOfSeries", story.getSeries() != null);
document.put("tagCount", story.getTags() != null ? story.getTags().size() : 0);
return document;
}
@@ -1226,6 +1590,19 @@ public class TypesenseService {
}
}
/**
* Convert date string in ISO format to Unix timestamp for Typesense filtering
*/
private long convertDateStringToTimestamp(String dateString) {
try {
java.time.ZonedDateTime zdt = java.time.ZonedDateTime.parse(dateString);
return zdt.toEpochSecond();
} catch (Exception e) {
logger.warn("Failed to parse date string: {}", dateString, e);
return 0L;
}
}
/**
* Escape special characters in Typesense filter values.
* Typesense requires certain characters to be escaped or quoted.