Various Fixes and QoL enhancements.
This commit is contained in:
1
backend/backend.log
Normal file
1
backend/backend.log
Normal file
@@ -0,0 +1 @@
|
||||
(eval):1: no such file or directory: ./mvnw
|
||||
@@ -7,6 +7,8 @@ import com.storycove.entity.Story;
|
||||
import com.storycove.entity.Tag;
|
||||
import com.storycove.service.CollectionService;
|
||||
import com.storycove.service.ImageService;
|
||||
import com.storycove.service.ReadingTimeService;
|
||||
import com.storycove.service.TypesenseService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -28,12 +30,18 @@ public class CollectionController {
|
||||
|
||||
private final CollectionService collectionService;
|
||||
private final ImageService imageService;
|
||||
private final TypesenseService typesenseService;
|
||||
private final ReadingTimeService readingTimeService;
|
||||
|
||||
@Autowired
|
||||
public CollectionController(CollectionService collectionService,
|
||||
ImageService imageService) {
|
||||
ImageService imageService,
|
||||
@Autowired(required = false) TypesenseService typesenseService,
|
||||
ReadingTimeService readingTimeService) {
|
||||
this.collectionService = collectionService;
|
||||
this.imageService = imageService;
|
||||
this.typesenseService = typesenseService;
|
||||
this.readingTimeService = readingTimeService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -270,6 +278,35 @@ public class CollectionController {
|
||||
return ResponseEntity.ok(Map.of("message", "Cover removed successfully"));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/collections/reindex-typesense - Reindex all collections in Typesense
|
||||
*/
|
||||
@PostMapping("/reindex-typesense")
|
||||
public ResponseEntity<Map<String, Object>> reindexCollectionsTypesense() {
|
||||
try {
|
||||
List<Collection> allCollections = collectionService.findAllWithTags();
|
||||
if (typesenseService != null) {
|
||||
typesenseService.reindexAllCollections(allCollections);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"message", "Successfully reindexed all collections",
|
||||
"count", allCollections.size()
|
||||
));
|
||||
} else {
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", false,
|
||||
"message", "Typesense service not available"
|
||||
));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to reindex collections", e);
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"success", false,
|
||||
"error", e.getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Mapper methods
|
||||
|
||||
private CollectionDto mapToCollectionDto(Collection collection) {
|
||||
@@ -290,6 +327,11 @@ public class CollectionController {
|
||||
.toList());
|
||||
}
|
||||
|
||||
// Map tag names for search results
|
||||
if (collection.getTagNames() != null) {
|
||||
dto.setTagNames(collection.getTagNames());
|
||||
}
|
||||
|
||||
// Map collection stories (lightweight)
|
||||
if (collection.getCollectionStories() != null) {
|
||||
dto.setCollectionStories(collection.getCollectionStories().stream()
|
||||
@@ -300,7 +342,7 @@ public class CollectionController {
|
||||
// Set calculated properties
|
||||
dto.setStoryCount(collection.getStoryCount());
|
||||
dto.setTotalWordCount(collection.getTotalWordCount());
|
||||
dto.setEstimatedReadingTime(collection.getEstimatedReadingTime());
|
||||
dto.setEstimatedReadingTime(readingTimeService.calculateReadingTime(collection.getTotalWordCount()));
|
||||
dto.setAverageStoryRating(collection.getAverageStoryRating());
|
||||
|
||||
return dto;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.storycove.controller;
|
||||
|
||||
import com.storycove.dto.HtmlSanitizationConfigDto;
|
||||
import com.storycove.service.HtmlSanitizationService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/config")
|
||||
public class ConfigController {
|
||||
|
||||
private final HtmlSanitizationService htmlSanitizationService;
|
||||
|
||||
@Value("${app.reading.speed.default:200}")
|
||||
private int defaultReadingSpeed;
|
||||
|
||||
@Autowired
|
||||
public ConfigController(HtmlSanitizationService htmlSanitizationService) {
|
||||
this.htmlSanitizationService = htmlSanitizationService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the HTML sanitization configuration for frontend use
|
||||
* This allows the frontend to use the same sanitization rules as the backend
|
||||
*/
|
||||
@GetMapping("/html-sanitization")
|
||||
public ResponseEntity<HtmlSanitizationConfigDto> getHtmlSanitizationConfig() {
|
||||
HtmlSanitizationConfigDto config = htmlSanitizationService.getConfiguration();
|
||||
return ResponseEntity.ok(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get application settings configuration
|
||||
*/
|
||||
@GetMapping("/settings")
|
||||
public ResponseEntity<Map<String, Object>> getSettings() {
|
||||
Map<String, Object> settings = Map.of(
|
||||
"defaultReadingSpeed", defaultReadingSpeed
|
||||
);
|
||||
return ResponseEntity.ok(settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reading speed for calculation purposes
|
||||
*/
|
||||
@GetMapping("/reading-speed")
|
||||
public ResponseEntity<Map<String, Integer>> getReadingSpeed() {
|
||||
return ResponseEntity.ok(Map.of("wordsPerMinute", defaultReadingSpeed));
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package com.storycove.controller;
|
||||
|
||||
import com.storycove.dto.HtmlSanitizationConfigDto;
|
||||
import com.storycove.service.HtmlSanitizationService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/config")
|
||||
public class HtmlSanitizationController {
|
||||
|
||||
private final HtmlSanitizationService htmlSanitizationService;
|
||||
|
||||
@Autowired
|
||||
public HtmlSanitizationController(HtmlSanitizationService htmlSanitizationService) {
|
||||
this.htmlSanitizationService = htmlSanitizationService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the HTML sanitization configuration for frontend use
|
||||
* This allows the frontend to use the same sanitization rules as the backend
|
||||
*/
|
||||
@GetMapping("/html-sanitization")
|
||||
public ResponseEntity<HtmlSanitizationConfigDto> getHtmlSanitizationConfig() {
|
||||
HtmlSanitizationConfigDto config = htmlSanitizationService.getConfiguration();
|
||||
return ResponseEntity.ok(config);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ public class StoryController {
|
||||
private final ImageService imageService;
|
||||
private final TypesenseService typesenseService;
|
||||
private final CollectionService collectionService;
|
||||
private final ReadingTimeService readingTimeService;
|
||||
|
||||
public StoryController(StoryService storyService,
|
||||
AuthorService authorService,
|
||||
@@ -48,7 +49,8 @@ public class StoryController {
|
||||
HtmlSanitizationService sanitizationService,
|
||||
ImageService imageService,
|
||||
CollectionService collectionService,
|
||||
@Autowired(required = false) TypesenseService typesenseService) {
|
||||
@Autowired(required = false) TypesenseService typesenseService,
|
||||
ReadingTimeService readingTimeService) {
|
||||
this.storyService = storyService;
|
||||
this.authorService = authorService;
|
||||
this.seriesService = seriesService;
|
||||
@@ -56,6 +58,7 @@ public class StoryController {
|
||||
this.imageService = imageService;
|
||||
this.collectionService = collectionService;
|
||||
this.typesenseService = typesenseService;
|
||||
this.readingTimeService = readingTimeService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -467,12 +470,40 @@ public class StoryController {
|
||||
// to avoid circular references and keep it lightweight
|
||||
dto.setStoryCount(collection.getStoryCount());
|
||||
dto.setTotalWordCount(collection.getTotalWordCount());
|
||||
dto.setEstimatedReadingTime(collection.getEstimatedReadingTime());
|
||||
dto.setEstimatedReadingTime(readingTimeService.calculateReadingTime(collection.getTotalWordCount()));
|
||||
dto.setAverageStoryRating(collection.getAverageStoryRating());
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
@GetMapping("/check-duplicate")
|
||||
public ResponseEntity<Map<String, Object>> checkDuplicate(
|
||||
@RequestParam String title,
|
||||
@RequestParam String authorName) {
|
||||
try {
|
||||
List<Story> duplicates = storyService.findPotentialDuplicates(title, authorName);
|
||||
|
||||
Map<String, Object> response = Map.of(
|
||||
"hasDuplicates", !duplicates.isEmpty(),
|
||||
"count", duplicates.size(),
|
||||
"duplicates", duplicates.stream()
|
||||
.map(story -> Map.of(
|
||||
"id", story.getId(),
|
||||
"title", story.getTitle(),
|
||||
"authorName", story.getAuthor() != null ? story.getAuthor().getName() : "",
|
||||
"createdAt", story.getCreatedAt()
|
||||
))
|
||||
.collect(Collectors.toList())
|
||||
);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
logger.error("Error checking for duplicates", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to check for duplicates"));
|
||||
}
|
||||
}
|
||||
|
||||
// Request DTOs
|
||||
public static class CreateStoryRequest {
|
||||
private String title;
|
||||
|
||||
@@ -132,17 +132,39 @@ public class TagController {
|
||||
return ResponseEntity.ok(stats);
|
||||
}
|
||||
|
||||
@GetMapping("/collections")
|
||||
public ResponseEntity<List<TagDto>> getTagsUsedByCollections() {
|
||||
List<Tag> tags = tagService.findTagsUsedByCollections();
|
||||
List<TagDto> tagDtos = tags.stream()
|
||||
.map(this::convertToDtoWithCollectionCount)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return ResponseEntity.ok(tagDtos);
|
||||
}
|
||||
|
||||
private TagDto convertToDto(Tag tag) {
|
||||
TagDto dto = new TagDto();
|
||||
dto.setId(tag.getId());
|
||||
dto.setName(tag.getName());
|
||||
dto.setStoryCount(tag.getStories() != null ? tag.getStories().size() : 0);
|
||||
dto.setCollectionCount(tag.getCollections() != null ? tag.getCollections().size() : 0);
|
||||
dto.setCreatedAt(tag.getCreatedAt());
|
||||
// updatedAt field not present in Tag entity per spec
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
private TagDto convertToDtoWithCollectionCount(Tag tag) {
|
||||
TagDto dto = new TagDto();
|
||||
dto.setId(tag.getId());
|
||||
dto.setName(tag.getName());
|
||||
dto.setCollectionCount(tag.getCollections() != null ? tag.getCollections().size() : 0);
|
||||
dto.setCreatedAt(tag.getCreatedAt());
|
||||
// Don't set storyCount for collection-focused endpoint
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
// Request DTOs
|
||||
public static class CreateTagRequest {
|
||||
private String name;
|
||||
|
||||
@@ -16,6 +16,7 @@ public class CollectionDto {
|
||||
private String coverImagePath;
|
||||
private Boolean isArchived;
|
||||
private List<TagDto> tags;
|
||||
private List<String> tagNames; // For search results
|
||||
private List<CollectionStoryDto> collectionStories;
|
||||
private Integer storyCount;
|
||||
private Integer totalWordCount;
|
||||
@@ -83,6 +84,14 @@ public class CollectionDto {
|
||||
this.tags = tags;
|
||||
}
|
||||
|
||||
public List<String> getTagNames() {
|
||||
return tagNames;
|
||||
}
|
||||
|
||||
public void setTagNames(List<String> tagNames) {
|
||||
this.tagNames = tagNames;
|
||||
}
|
||||
|
||||
public List<CollectionStoryDto> getCollectionStories() {
|
||||
return collectionStories;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ public class TagDto {
|
||||
private String name;
|
||||
|
||||
private Integer storyCount;
|
||||
private Integer collectionCount;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@@ -49,6 +50,14 @@ public class TagDto {
|
||||
this.storyCount = storyCount;
|
||||
}
|
||||
|
||||
public Integer getCollectionCount() {
|
||||
return collectionCount;
|
||||
}
|
||||
|
||||
public void setCollectionCount(Integer collectionCount) {
|
||||
this.collectionCount = collectionCount;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,10 @@ public class Collection {
|
||||
)
|
||||
private Set<Tag> tags = new HashSet<>();
|
||||
|
||||
// Transient field for search results - tag names only to avoid lazy loading issues
|
||||
@Transient
|
||||
private List<String> tagNames;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
@@ -192,6 +196,14 @@ public class Collection {
|
||||
this.tags = tags;
|
||||
}
|
||||
|
||||
public List<String> getTagNames() {
|
||||
return tagNames;
|
||||
}
|
||||
|
||||
public void setTagNames(List<String> tagNames) {
|
||||
this.tagNames = tagNames;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ public class Tag {
|
||||
@JsonBackReference("story-tags")
|
||||
private Set<Story> stories = new HashSet<>();
|
||||
|
||||
@ManyToMany(mappedBy = "tags")
|
||||
@JsonBackReference("collection-tags")
|
||||
private Set<Collection> collections = new HashSet<>();
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
@@ -67,6 +71,14 @@ public class Tag {
|
||||
this.stories = stories;
|
||||
}
|
||||
|
||||
public Set<Collection> getCollections() {
|
||||
return collections;
|
||||
}
|
||||
|
||||
public void setCollections(Set<Collection> collections) {
|
||||
this.collections = collections;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
@@ -45,4 +45,10 @@ public interface CollectionRepository extends JpaRepository<Collection, UUID> {
|
||||
*/
|
||||
@Query("SELECT c FROM Collection c WHERE c.isArchived = false ORDER BY c.updatedAt DESC")
|
||||
List<Collection> findAllActiveCollections();
|
||||
|
||||
/**
|
||||
* Find all collections with tags for reindexing operations
|
||||
*/
|
||||
@Query("SELECT c FROM Collection c LEFT JOIN FETCH c.tags ORDER BY c.updatedAt DESC")
|
||||
List<Collection> findAllWithTags();
|
||||
}
|
||||
@@ -114,4 +114,7 @@ public interface StoryRepository extends JpaRepository<Story, UUID> {
|
||||
"LEFT JOIN FETCH s.series " +
|
||||
"LEFT JOIN FETCH s.tags")
|
||||
List<Story> findAllWithAssociations();
|
||||
|
||||
@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);
|
||||
}
|
||||
@@ -54,4 +54,7 @@ public interface TagRepository extends JpaRepository<Tag, UUID> {
|
||||
|
||||
@Query("SELECT COUNT(t) FROM Tag t WHERE SIZE(t.stories) > 0")
|
||||
long countUsedTags();
|
||||
|
||||
@Query("SELECT t FROM Tag t WHERE SIZE(t.collections) > 0 ORDER BY SIZE(t.collections) DESC, t.name ASC")
|
||||
List<Tag> findTagsUsedByCollections();
|
||||
}
|
||||
@@ -10,6 +10,7 @@ public class CollectionSearchResult extends Collection {
|
||||
|
||||
private Integer storedStoryCount;
|
||||
private Integer storedTotalWordCount;
|
||||
private int wordsPerMinute = 200; // Default, can be overridden
|
||||
|
||||
public CollectionSearchResult(Collection collection) {
|
||||
this.setId(collection.getId());
|
||||
@@ -20,6 +21,7 @@ public class CollectionSearchResult extends Collection {
|
||||
this.setCreatedAt(collection.getCreatedAt());
|
||||
this.setUpdatedAt(collection.getUpdatedAt());
|
||||
this.setCoverImagePath(collection.getCoverImagePath());
|
||||
this.setTagNames(collection.getTagNames()); // Copy tag names for search results
|
||||
// Note: don't copy collectionStories or tags to avoid lazy loading issues
|
||||
}
|
||||
|
||||
@@ -31,6 +33,10 @@ public class CollectionSearchResult extends Collection {
|
||||
this.storedTotalWordCount = totalWordCount;
|
||||
}
|
||||
|
||||
public void setWordsPerMinute(int wordsPerMinute) {
|
||||
this.wordsPerMinute = wordsPerMinute;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getStoryCount() {
|
||||
return storedStoryCount != null ? storedStoryCount : 0;
|
||||
@@ -43,8 +49,7 @@ public class CollectionSearchResult extends Collection {
|
||||
|
||||
@Override
|
||||
public int getEstimatedReadingTime() {
|
||||
// Assuming 200 words per minute reading speed
|
||||
return Math.max(1, getTotalWordCount() / 200);
|
||||
return Math.max(1, getTotalWordCount() / wordsPerMinute);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -34,18 +34,21 @@ public class CollectionService {
|
||||
private final StoryRepository storyRepository;
|
||||
private final TagRepository tagRepository;
|
||||
private final TypesenseService typesenseService;
|
||||
private final ReadingTimeService readingTimeService;
|
||||
|
||||
@Autowired
|
||||
public CollectionService(CollectionRepository collectionRepository,
|
||||
CollectionStoryRepository collectionStoryRepository,
|
||||
StoryRepository storyRepository,
|
||||
TagRepository tagRepository,
|
||||
@Autowired(required = false) TypesenseService typesenseService) {
|
||||
@Autowired(required = false) TypesenseService typesenseService,
|
||||
ReadingTimeService readingTimeService) {
|
||||
this.collectionRepository = collectionRepository;
|
||||
this.collectionStoryRepository = collectionStoryRepository;
|
||||
this.storyRepository = storyRepository;
|
||||
this.tagRepository = tagRepository;
|
||||
this.typesenseService = typesenseService;
|
||||
this.readingTimeService = readingTimeService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,6 +81,13 @@ public class CollectionService {
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Collection not found with id: " + id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all collections with tags for reindexing
|
||||
*/
|
||||
public List<Collection> findAllWithTags() {
|
||||
return collectionRepository.findAllWithTags();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new collection with optional initial stories
|
||||
*/
|
||||
@@ -344,7 +354,7 @@ public class CollectionService {
|
||||
int totalWordCount = collectionStories.stream()
|
||||
.mapToInt(cs -> cs.getStory().getWordCount() != null ? cs.getStory().getWordCount() : 0)
|
||||
.sum();
|
||||
int estimatedReadingTime = Math.max(1, totalWordCount / 200); // 200 words per minute
|
||||
int estimatedReadingTime = readingTimeService.calculateReadingTime(totalWordCount);
|
||||
|
||||
double averageStoryRating = collectionStories.stream()
|
||||
.filter(cs -> cs.getStory().getRating() != null)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.storycove.service;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class ReadingTimeService {
|
||||
|
||||
@Value("${app.reading.speed.default:200}")
|
||||
private int defaultWordsPerMinute;
|
||||
|
||||
/**
|
||||
* Calculate estimated reading time in minutes for the given word count
|
||||
* @param wordCount the number of words to read
|
||||
* @return estimated reading time in minutes (minimum 1 minute)
|
||||
*/
|
||||
public int calculateReadingTime(int wordCount) {
|
||||
return Math.max(1, wordCount / defaultWordsPerMinute);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current words per minute setting
|
||||
* @return words per minute reading speed
|
||||
*/
|
||||
public int getWordsPerMinute() {
|
||||
return defaultWordsPerMinute;
|
||||
}
|
||||
}
|
||||
@@ -593,4 +593,12 @@ public class StoryService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<Story> findPotentialDuplicates(String title, String authorName) {
|
||||
if (title == null || title.trim().isEmpty() || authorName == null || authorName.trim().isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return storyRepository.findByTitleAndAuthorNameIgnoreCase(title.trim(), authorName.trim());
|
||||
}
|
||||
}
|
||||
@@ -191,6 +191,11 @@ public class TagService {
|
||||
public long countUsedTags() {
|
||||
return tagRepository.countUsedTags();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<Tag> findTagsUsedByCollections() {
|
||||
return tagRepository.findTagsUsedByCollections();
|
||||
}
|
||||
|
||||
private void validateTagForCreate(Tag tag) {
|
||||
if (existsByName(tag.getName())) {
|
||||
|
||||
@@ -32,12 +32,15 @@ public class TypesenseService {
|
||||
|
||||
private final Client typesenseClient;
|
||||
private final CollectionStoryRepository collectionStoryRepository;
|
||||
private final ReadingTimeService readingTimeService;
|
||||
|
||||
@Autowired
|
||||
public TypesenseService(Client typesenseClient,
|
||||
@Autowired(required = false) CollectionStoryRepository collectionStoryRepository) {
|
||||
@Autowired(required = false) CollectionStoryRepository collectionStoryRepository,
|
||||
ReadingTimeService readingTimeService) {
|
||||
this.typesenseClient = typesenseClient;
|
||||
this.collectionStoryRepository = collectionStoryRepository;
|
||||
this.readingTimeService = readingTimeService;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
@@ -65,19 +68,19 @@ public class TypesenseService {
|
||||
private void createStoriesCollection() throws Exception {
|
||||
List<Field> fields = Arrays.asList(
|
||||
new Field().name("id").type("string").facet(false),
|
||||
new Field().name("title").type("string").facet(false),
|
||||
new Field().name("title").type("string").facet(false).sort(true),
|
||||
new Field().name("summary").type("string").facet(false).optional(true),
|
||||
new Field().name("description").type("string").facet(false),
|
||||
new Field().name("contentPlain").type("string").facet(false),
|
||||
new Field().name("authorId").type("string").facet(true),
|
||||
new Field().name("authorName").type("string").facet(true),
|
||||
new Field().name("authorName").type("string").facet(true).sort(true),
|
||||
new Field().name("seriesId").type("string").facet(true).optional(true),
|
||||
new Field().name("seriesName").type("string").facet(true).optional(true),
|
||||
new Field().name("seriesName").type("string").facet(true).sort(true).optional(true),
|
||||
new Field().name("tagNames").type("string[]").facet(true).optional(true),
|
||||
new Field().name("rating").type("int32").facet(true).optional(true),
|
||||
new Field().name("wordCount").type("int32").facet(true).optional(true),
|
||||
new Field().name("volume").type("int32").facet(true).optional(true),
|
||||
new Field().name("createdAt").type("int64").facet(false),
|
||||
new Field().name("rating").type("int32").facet(true).sort(true).optional(true),
|
||||
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("sourceUrl").type("string").facet(false).optional(true),
|
||||
new Field().name("coverPath").type("string").facet(false).optional(true)
|
||||
);
|
||||
@@ -101,6 +104,26 @@ public class TypesenseService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force recreate the stories collection, deleting it first if it exists
|
||||
*/
|
||||
public void recreateStoriesCollection() throws Exception {
|
||||
try {
|
||||
logger.info("Force deleting stories collection for recreation...");
|
||||
typesenseClient.collections(STORIES_COLLECTION).delete();
|
||||
logger.info("Successfully deleted stories collection");
|
||||
} catch (Exception e) {
|
||||
logger.debug("Stories collection didn't exist for deletion: {}", e.getMessage());
|
||||
}
|
||||
|
||||
// Wait a brief moment to ensure deletion is complete
|
||||
Thread.sleep(100);
|
||||
|
||||
logger.info("Creating stories collection with fresh schema...");
|
||||
createStoriesCollection();
|
||||
logger.info("Successfully created stories collection");
|
||||
}
|
||||
|
||||
/**
|
||||
* Force recreate the authors collection, deleting it first if it exists
|
||||
*/
|
||||
@@ -220,16 +243,14 @@ public class TypesenseService {
|
||||
|
||||
if (tagFilters != null && !tagFilters.isEmpty()) {
|
||||
logger.info("SEARCH DEBUG: Processing {} tag filters: {}", tagFilters.size(), tagFilters);
|
||||
String tagFilter = tagFilters.stream()
|
||||
.map(tag -> {
|
||||
String escaped = escapeTypesenseValue(tag);
|
||||
String condition = "tagNames:=" + escaped;
|
||||
logger.info("SEARCH DEBUG: Tag '{}' -> escaped '{}' -> condition '{}'", tag, escaped, condition);
|
||||
return condition;
|
||||
})
|
||||
.collect(Collectors.joining(" || "));
|
||||
logger.info("SEARCH DEBUG: Final tag filter condition: '{}'", tagFilter);
|
||||
filterConditions.add("(" + tagFilter + ")");
|
||||
// Use AND logic for multiple tags - items must have ALL selected tags
|
||||
for (String tag : tagFilters) {
|
||||
String escaped = escapeTypesenseValue(tag);
|
||||
String condition = "tagNames:=" + escaped;
|
||||
logger.info("SEARCH DEBUG: Tag '{}' -> escaped '{}' -> condition '{}'", tag, escaped, condition);
|
||||
filterConditions.add(condition);
|
||||
}
|
||||
logger.info("SEARCH DEBUG: Added {} individual tag filter conditions", tagFilters.size());
|
||||
}
|
||||
|
||||
if (minRating != null) {
|
||||
@@ -294,15 +315,8 @@ public class TypesenseService {
|
||||
|
||||
public void reindexAllStories(List<Story> stories) {
|
||||
try {
|
||||
// Clear existing collection
|
||||
try {
|
||||
typesenseClient.collections(STORIES_COLLECTION).delete();
|
||||
} catch (Exception e) {
|
||||
logger.debug("Collection didn't exist for deletion: {}", e.getMessage());
|
||||
}
|
||||
|
||||
// Recreate collection
|
||||
createStoriesCollection();
|
||||
// Force recreate collection with proper schema
|
||||
recreateStoriesCollection();
|
||||
|
||||
// Bulk index all stories
|
||||
bulkIndexStories(stories);
|
||||
@@ -1007,10 +1021,11 @@ public class TypesenseService {
|
||||
}
|
||||
|
||||
if (tags != null && !tags.isEmpty()) {
|
||||
String tagFilter = tags.stream()
|
||||
.map(tag -> "tags:=" + escapeTypesenseValue(tag))
|
||||
.collect(Collectors.joining(" || "));
|
||||
filterConditions.add("(" + tagFilter + ")");
|
||||
// Use AND logic for multiple tags - collections must have ALL selected tags
|
||||
for (String tag : tags) {
|
||||
String condition = "tags:=" + escapeTypesenseValue(tag);
|
||||
filterConditions.add(condition);
|
||||
}
|
||||
}
|
||||
|
||||
if (!filterConditions.isEmpty()) {
|
||||
@@ -1197,6 +1212,15 @@ public class TypesenseService {
|
||||
collection.setCoverImagePath((String) doc.get("cover_image_path"));
|
||||
collection.setIsArchived((Boolean) doc.get("is_archived"));
|
||||
|
||||
// Set tags from Typesense document
|
||||
if (doc.get("tags") != null) {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> tagNames = (List<String>) doc.get("tags");
|
||||
// For search results, we'll store tag names in a special field for frontend
|
||||
// since we don't want to load full Tag entities for performance
|
||||
collection.setTagNames(tagNames);
|
||||
}
|
||||
|
||||
// Set timestamps
|
||||
if (doc.get("created_at") != null) {
|
||||
long createdAtSeconds = ((Number) doc.get("created_at")).longValue();
|
||||
@@ -1210,6 +1234,7 @@ public class TypesenseService {
|
||||
// For list/search views, we create a special lightweight collection that stores
|
||||
// the calculated values directly to avoid lazy loading issues
|
||||
CollectionSearchResult searchCollection = new CollectionSearchResult(collection);
|
||||
searchCollection.setWordsPerMinute(readingTimeService.getWordsPerMinute());
|
||||
|
||||
// Set the calculated statistics from the Typesense document
|
||||
if (doc.get("story_count") != null) {
|
||||
|
||||
Reference in New Issue
Block a user