MVP Version
This commit is contained in:
@@ -2,8 +2,10 @@ package com.storycove;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
public class StoryCoveApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
package com.storycove.controller;
|
||||
|
||||
import com.storycove.dto.AuthorDto;
|
||||
import com.storycove.dto.AuthorSearchDto;
|
||||
import com.storycove.dto.SearchResultDto;
|
||||
import com.storycove.entity.Author;
|
||||
import com.storycove.service.AuthorService;
|
||||
import com.storycove.service.ImageService;
|
||||
import com.storycove.service.TypesenseService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -14,6 +20,7 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
@@ -23,12 +30,16 @@ import java.util.stream.Collectors;
|
||||
@RequestMapping("/api/authors")
|
||||
public class AuthorController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(AuthorController.class);
|
||||
|
||||
private final AuthorService authorService;
|
||||
private final ImageService imageService;
|
||||
private final TypesenseService typesenseService;
|
||||
|
||||
public AuthorController(AuthorService authorService, ImageService imageService) {
|
||||
public AuthorController(AuthorService authorService, ImageService imageService, TypesenseService typesenseService) {
|
||||
this.authorService = authorService;
|
||||
this.imageService = imageService;
|
||||
this.typesenseService = typesenseService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -63,9 +74,65 @@ public class AuthorController {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(convertToDto(savedAuthor));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<AuthorDto> updateAuthor(@PathVariable UUID id,
|
||||
@Valid @RequestBody UpdateAuthorRequest request) {
|
||||
@PutMapping(value = "/{id}", consumes = "multipart/form-data")
|
||||
public ResponseEntity<AuthorDto> updateAuthorMultipart(
|
||||
@PathVariable UUID id,
|
||||
@RequestParam(required = false) String name,
|
||||
@RequestParam(required = false) String notes,
|
||||
@RequestParam(required = false) List<String> urls,
|
||||
@RequestParam(required = false, name = "authorRating") Integer rating,
|
||||
@RequestParam(required = false, name = "avatar") MultipartFile avatarFile) {
|
||||
|
||||
System.out.println("DEBUG: MULTIPART PUT called with:");
|
||||
System.out.println(" - name: " + name);
|
||||
System.out.println(" - notes: " + notes);
|
||||
System.out.println(" - urls: " + urls);
|
||||
System.out.println(" - rating: " + rating);
|
||||
System.out.println(" - avatar: " + (avatarFile != null ? avatarFile.getOriginalFilename() : "null"));
|
||||
|
||||
try {
|
||||
Author existingAuthor = authorService.findById(id);
|
||||
|
||||
// Update basic fields
|
||||
if (name != null && !name.trim().isEmpty()) {
|
||||
existingAuthor.setName(name.trim());
|
||||
}
|
||||
if (notes != null) {
|
||||
existingAuthor.setNotes(notes);
|
||||
}
|
||||
if (urls != null) {
|
||||
existingAuthor.setUrls(urls);
|
||||
}
|
||||
|
||||
// Handle rating update
|
||||
if (rating != null) {
|
||||
System.out.println("DEBUG: Setting author rating via PUT: " + rating);
|
||||
existingAuthor.setAuthorRating(rating);
|
||||
}
|
||||
|
||||
// Handle avatar upload if provided
|
||||
if (avatarFile != null && !avatarFile.isEmpty()) {
|
||||
String imagePath = imageService.uploadImage(avatarFile, ImageService.ImageType.AVATAR);
|
||||
existingAuthor.setAvatarImagePath(imagePath);
|
||||
}
|
||||
|
||||
Author updatedAuthor = authorService.update(id, existingAuthor);
|
||||
return ResponseEntity.ok(convertToDto(updatedAuthor));
|
||||
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.badRequest().body(null);
|
||||
}
|
||||
}
|
||||
|
||||
@PutMapping(value = "/{id}", consumes = "application/json")
|
||||
public ResponseEntity<AuthorDto> updateAuthorJson(@PathVariable UUID id,
|
||||
@Valid @RequestBody UpdateAuthorRequest request) {
|
||||
System.out.println("DEBUG: JSON PUT called with:");
|
||||
System.out.println(" - name: " + request.getName());
|
||||
System.out.println(" - notes: " + request.getNotes());
|
||||
System.out.println(" - urls: " + request.getUrls());
|
||||
System.out.println(" - rating: " + request.getRating());
|
||||
|
||||
Author existingAuthor = authorService.findById(id);
|
||||
updateAuthorFromRequest(existingAuthor, request);
|
||||
|
||||
@@ -73,6 +140,15 @@ public class AuthorController {
|
||||
return ResponseEntity.ok(convertToDto(updatedAuthor));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<String> updateAuthorGeneric(@PathVariable UUID id, HttpServletRequest request) {
|
||||
System.out.println("DEBUG: GENERIC PUT called!");
|
||||
System.out.println(" - Content-Type: " + request.getContentType());
|
||||
System.out.println(" - Method: " + request.getMethod());
|
||||
|
||||
return ResponseEntity.status(415).body("Unsupported Media Type. Expected multipart/form-data or application/json");
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<?> deleteAuthor(@PathVariable UUID id) {
|
||||
authorService.delete(id);
|
||||
@@ -103,8 +179,79 @@ public class AuthorController {
|
||||
|
||||
@PostMapping("/{id}/rating")
|
||||
public ResponseEntity<AuthorDto> rateAuthor(@PathVariable UUID id, @RequestBody RatingRequest request) {
|
||||
System.out.println("DEBUG: Rating author " + id + " with rating " + request.getRating());
|
||||
Author author = authorService.setRating(id, request.getRating());
|
||||
return ResponseEntity.ok(convertToDto(author));
|
||||
System.out.println("DEBUG: After setRating, author rating is: " + author.getAuthorRating());
|
||||
AuthorDto dto = convertToDto(author);
|
||||
System.out.println("DEBUG: Final DTO rating is: " + dto.getAuthorRating());
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/debug")
|
||||
public ResponseEntity<Map<String, Object>> debugAuthor(@PathVariable UUID id) {
|
||||
Author author = authorService.findById(id);
|
||||
Integer directDbRating = null;
|
||||
String dbError = null;
|
||||
|
||||
try {
|
||||
directDbRating = authorService.getAuthorRatingFromDb(id);
|
||||
} catch (Exception e) {
|
||||
dbError = e.getMessage();
|
||||
}
|
||||
|
||||
Map<String, Object> debug = Map.of(
|
||||
"authorId", author.getId(),
|
||||
"authorName", author.getName(),
|
||||
"authorRating_entity", author.getAuthorRating(),
|
||||
"authorRating_direct_db", directDbRating,
|
||||
"db_error", dbError,
|
||||
"toString", author.toString()
|
||||
);
|
||||
return ResponseEntity.ok(debug);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/test-rating/{rating}")
|
||||
public ResponseEntity<Map<String, Object>> testSetRating(@PathVariable UUID id, @PathVariable Integer rating) {
|
||||
try {
|
||||
System.out.println("DEBUG: Test setting rating " + rating + " for author " + id);
|
||||
Author author = authorService.setRating(id, rating);
|
||||
System.out.println("DEBUG: After test setRating, got: " + author.getAuthorRating());
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"authorRating", author.getAuthorRating(),
|
||||
"message", "Rating set successfully"
|
||||
));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", false,
|
||||
"error", e.getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/test-put-rating")
|
||||
public ResponseEntity<Map<String, Object>> testPutWithRating(@PathVariable UUID id, @RequestParam Integer rating) {
|
||||
try {
|
||||
System.out.println("DEBUG: Test PUT with rating " + rating + " for author " + id);
|
||||
|
||||
Author existingAuthor = authorService.findById(id);
|
||||
existingAuthor.setAuthorRating(rating);
|
||||
Author updatedAuthor = authorService.update(id, existingAuthor);
|
||||
|
||||
System.out.println("DEBUG: After PUT update, rating is: " + updatedAuthor.getAuthorRating());
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"authorRating", updatedAuthor.getAuthorRating(),
|
||||
"message", "Rating updated via PUT successfully"
|
||||
));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", false,
|
||||
"error", e.getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/search")
|
||||
@@ -120,6 +267,91 @@ public class AuthorController {
|
||||
return ResponseEntity.ok(authorDtos);
|
||||
}
|
||||
|
||||
@GetMapping("/search-typesense")
|
||||
public ResponseEntity<SearchResultDto<AuthorDto>> searchAuthorsTypesense(
|
||||
@RequestParam(defaultValue = "*") String q,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestParam(defaultValue = "name") String sortBy,
|
||||
@RequestParam(defaultValue = "asc") String sortOrder) {
|
||||
|
||||
SearchResultDto<AuthorSearchDto> searchResults = typesenseService.searchAuthors(q, page, size, sortBy, sortOrder);
|
||||
|
||||
// Convert AuthorSearchDto results to AuthorDto
|
||||
SearchResultDto<AuthorDto> results = new SearchResultDto<>();
|
||||
results.setQuery(searchResults.getQuery());
|
||||
results.setPage(searchResults.getPage());
|
||||
results.setPerPage(searchResults.getPerPage());
|
||||
results.setTotalHits(searchResults.getTotalHits());
|
||||
results.setSearchTimeMs(searchResults.getSearchTimeMs());
|
||||
|
||||
// Handle null results gracefully
|
||||
List<AuthorDto> authorDtos = searchResults.getResults() != null
|
||||
? searchResults.getResults().stream()
|
||||
.map(this::convertSearchDtoToDto)
|
||||
.collect(Collectors.toList())
|
||||
: new ArrayList<>();
|
||||
results.setResults(authorDtos);
|
||||
|
||||
return ResponseEntity.ok(results);
|
||||
}
|
||||
|
||||
@PostMapping("/reindex-typesense")
|
||||
public ResponseEntity<Map<String, Object>> reindexAuthorsTypesense() {
|
||||
try {
|
||||
List<Author> allAuthors = authorService.findAllWithStories();
|
||||
typesenseService.reindexAllAuthors(allAuthors);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"message", "Reindexed " + allAuthors.size() + " authors",
|
||||
"count", allAuthors.size()
|
||||
));
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to reindex authors", e);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", false,
|
||||
"error", e.getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/recreate-typesense-collection")
|
||||
public ResponseEntity<Map<String, Object>> recreateAuthorsCollection() {
|
||||
try {
|
||||
// This will delete the existing collection and recreate it with correct schema
|
||||
List<Author> allAuthors = authorService.findAllWithStories();
|
||||
typesenseService.reindexAllAuthors(allAuthors);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"message", "Recreated authors collection and indexed " + allAuthors.size() + " authors",
|
||||
"count", allAuthors.size()
|
||||
));
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to recreate authors collection", e);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", false,
|
||||
"error", e.getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/typesense-schema")
|
||||
public ResponseEntity<Map<String, Object>> getAuthorsTypesenseSchema() {
|
||||
try {
|
||||
Map<String, Object> schema = typesenseService.getAuthorsCollectionSchema();
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"schema", schema
|
||||
));
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to get authors schema", e);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", false,
|
||||
"error", e.getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/top-rated")
|
||||
public ResponseEntity<List<AuthorDto>> getTopRatedAuthors(@RequestParam(defaultValue = "10") int limit) {
|
||||
Pageable pageable = PageRequest.of(0, limit);
|
||||
@@ -158,6 +390,10 @@ public class AuthorController {
|
||||
if (updateReq.getUrls() != null) {
|
||||
author.setUrls(updateReq.getUrls());
|
||||
}
|
||||
if (updateReq.getRating() != null) {
|
||||
System.out.println("DEBUG: Setting author rating via JSON: " + updateReq.getRating());
|
||||
author.setAuthorRating(updateReq.getRating());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,12 +403,38 @@ public class AuthorController {
|
||||
dto.setName(author.getName());
|
||||
dto.setNotes(author.getNotes());
|
||||
dto.setAvatarImagePath(author.getAvatarImagePath());
|
||||
|
||||
// Debug logging for author rating
|
||||
System.out.println("DEBUG: Converting author " + author.getName() +
|
||||
" with rating: " + author.getAuthorRating());
|
||||
|
||||
dto.setAuthorRating(author.getAuthorRating());
|
||||
dto.setUrls(author.getUrls());
|
||||
dto.setStoryCount(author.getStories() != null ? author.getStories().size() : 0);
|
||||
dto.setCreatedAt(author.getCreatedAt());
|
||||
dto.setUpdatedAt(author.getUpdatedAt());
|
||||
|
||||
// Calculate and set average story rating
|
||||
dto.setAverageStoryRating(authorService.calculateAverageStoryRating(author.getId()));
|
||||
|
||||
System.out.println("DEBUG: DTO authorRating set to: " + dto.getAuthorRating());
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
private AuthorDto convertSearchDtoToDto(AuthorSearchDto searchDto) {
|
||||
AuthorDto dto = new AuthorDto();
|
||||
dto.setId(searchDto.getId());
|
||||
dto.setName(searchDto.getName());
|
||||
dto.setNotes(searchDto.getNotes());
|
||||
dto.setAuthorRating(searchDto.getAuthorRating());
|
||||
dto.setAverageStoryRating(searchDto.getAverageStoryRating());
|
||||
dto.setStoryCount(searchDto.getStoryCount());
|
||||
dto.setUrls(searchDto.getUrls());
|
||||
dto.setAvatarImagePath(searchDto.getAvatarImagePath());
|
||||
dto.setCreatedAt(searchDto.getCreatedAt());
|
||||
dto.setUpdatedAt(searchDto.getUpdatedAt());
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
@@ -195,6 +457,7 @@ public class AuthorController {
|
||||
private String name;
|
||||
private String notes;
|
||||
private List<String> urls;
|
||||
private Integer rating;
|
||||
|
||||
// Getters and setters
|
||||
public String getName() { return name; }
|
||||
@@ -203,6 +466,8 @@ public class AuthorController {
|
||||
public void setNotes(String notes) { this.notes = notes; }
|
||||
public List<String> getUrls() { return urls; }
|
||||
public void setUrls(List<String> urls) { this.urls = urls; }
|
||||
public Integer getRating() { return rating; }
|
||||
public void setRating(Integer rating) { this.rating = rating; }
|
||||
}
|
||||
|
||||
public static class RatingRequest {
|
||||
|
||||
@@ -10,6 +10,8 @@ import com.storycove.dto.SearchResultDto;
|
||||
import com.storycove.dto.StorySearchDto;
|
||||
import com.storycove.service.*;
|
||||
import jakarta.validation.Valid;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
@@ -32,6 +34,8 @@ import java.util.stream.Collectors;
|
||||
@RequestMapping("/api/stories")
|
||||
public class StoryController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(StoryController.class);
|
||||
|
||||
private final StoryService storyService;
|
||||
private final AuthorService authorService;
|
||||
private final SeriesService seriesService;
|
||||
@@ -141,6 +145,60 @@ public class StoryController {
|
||||
return ResponseEntity.ok(convertToDto(story));
|
||||
}
|
||||
|
||||
@PostMapping("/reindex")
|
||||
public ResponseEntity<String> manualReindex() {
|
||||
if (typesenseService == null) {
|
||||
return ResponseEntity.ok("Typesense is not enabled, no reindexing performed");
|
||||
}
|
||||
|
||||
try {
|
||||
List<Story> allStories = storyService.findAllWithAssociations();
|
||||
typesenseService.reindexAllStories(allStories);
|
||||
return ResponseEntity.ok("Successfully reindexed " + allStories.size() + " stories");
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(500).body("Failed to reindex stories: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/reindex-typesense")
|
||||
public ResponseEntity<Map<String, Object>> reindexStoriesTypesense() {
|
||||
try {
|
||||
List<Story> allStories = storyService.findAllWithAssociations();
|
||||
typesenseService.reindexAllStories(allStories);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"message", "Reindexed " + allStories.size() + " stories",
|
||||
"count", allStories.size()
|
||||
));
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to reindex stories", e);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", false,
|
||||
"error", e.getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/recreate-typesense-collection")
|
||||
public ResponseEntity<Map<String, Object>> recreateStoriesCollection() {
|
||||
try {
|
||||
// This will delete the existing collection and recreate it with correct schema
|
||||
List<Story> allStories = storyService.findAllWithAssociations();
|
||||
typesenseService.reindexAllStories(allStories);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"message", "Recreated stories collection and indexed " + allStories.size() + " stories",
|
||||
"count", allStories.size()
|
||||
));
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to recreate stories collection", e);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", false,
|
||||
"error", e.getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/search")
|
||||
public ResponseEntity<SearchResultDto<StorySearchDto>> searchStories(
|
||||
@RequestParam String query,
|
||||
@@ -149,11 +207,13 @@ public class StoryController {
|
||||
@RequestParam(required = false) List<String> authors,
|
||||
@RequestParam(required = false) List<String> tags,
|
||||
@RequestParam(required = false) Integer minRating,
|
||||
@RequestParam(required = false) Integer maxRating) {
|
||||
@RequestParam(required = false) Integer maxRating,
|
||||
@RequestParam(required = false) String sortBy,
|
||||
@RequestParam(required = false) String sortDir) {
|
||||
|
||||
if (typesenseService != null) {
|
||||
SearchResultDto<StorySearchDto> results = typesenseService.searchStories(
|
||||
query, page, size, authors, tags, minRating, maxRating);
|
||||
query, page, size, authors, tags, minRating, maxRating, sortBy, sortDir);
|
||||
return ResponseEntity.ok(results);
|
||||
} else {
|
||||
// Fallback to basic search if Typesense is not available
|
||||
@@ -256,9 +316,13 @@ public class StoryController {
|
||||
story.setAuthor(author);
|
||||
}
|
||||
|
||||
// Handle series - either by ID or by name
|
||||
if (createReq.getSeriesId() != null) {
|
||||
Series series = seriesService.findById(createReq.getSeriesId());
|
||||
story.setSeries(series);
|
||||
} else if (createReq.getSeriesName() != null && !createReq.getSeriesName().trim().isEmpty()) {
|
||||
Series series = seriesService.findOrCreate(createReq.getSeriesName().trim());
|
||||
story.setSeries(series);
|
||||
}
|
||||
|
||||
// Handle tags
|
||||
@@ -298,9 +362,13 @@ public class StoryController {
|
||||
Author author = authorService.findById(updateReq.getAuthorId());
|
||||
story.setAuthor(author);
|
||||
}
|
||||
// Handle series - either by ID or by name
|
||||
if (updateReq.getSeriesId() != null) {
|
||||
Series series = seriesService.findById(updateReq.getSeriesId());
|
||||
story.setSeries(series);
|
||||
} else if (updateReq.getSeriesName() != null && !updateReq.getSeriesName().trim().isEmpty()) {
|
||||
Series series = seriesService.findOrCreate(updateReq.getSeriesName().trim());
|
||||
story.setSeries(series);
|
||||
}
|
||||
|
||||
// Note: Tags are now handled in StoryService.updateWithTagNames()
|
||||
@@ -360,6 +428,7 @@ public class StoryController {
|
||||
private UUID authorId;
|
||||
private String authorName;
|
||||
private UUID seriesId;
|
||||
private String seriesName;
|
||||
private List<String> tagNames;
|
||||
|
||||
// Getters and setters
|
||||
@@ -381,6 +450,8 @@ public class StoryController {
|
||||
public void setAuthorName(String authorName) { this.authorName = authorName; }
|
||||
public UUID getSeriesId() { return seriesId; }
|
||||
public void setSeriesId(UUID seriesId) { this.seriesId = seriesId; }
|
||||
public String getSeriesName() { return seriesName; }
|
||||
public void setSeriesName(String seriesName) { this.seriesName = seriesName; }
|
||||
public List<String> getTagNames() { return tagNames; }
|
||||
public void setTagNames(List<String> tagNames) { this.tagNames = tagNames; }
|
||||
}
|
||||
@@ -394,6 +465,7 @@ public class StoryController {
|
||||
private Integer volume;
|
||||
private UUID authorId;
|
||||
private UUID seriesId;
|
||||
private String seriesName;
|
||||
private List<String> tagNames;
|
||||
|
||||
// Getters and setters
|
||||
@@ -413,6 +485,8 @@ public class StoryController {
|
||||
public void setAuthorId(UUID authorId) { this.authorId = authorId; }
|
||||
public UUID getSeriesId() { return seriesId; }
|
||||
public void setSeriesId(UUID seriesId) { this.seriesId = seriesId; }
|
||||
public String getSeriesName() { return seriesName; }
|
||||
public void setSeriesName(String seriesName) { this.seriesName = seriesName; }
|
||||
public List<String> getTagNames() { return tagNames; }
|
||||
public void setTagNames(List<String> tagNames) { this.tagNames = tagNames; }
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ public class AuthorDto {
|
||||
|
||||
private String avatarImagePath;
|
||||
private Integer authorRating;
|
||||
private Double averageStoryRating;
|
||||
private List<String> urls;
|
||||
private Integer storyCount;
|
||||
private LocalDateTime createdAt;
|
||||
@@ -71,6 +72,14 @@ public class AuthorDto {
|
||||
this.authorRating = authorRating;
|
||||
}
|
||||
|
||||
public Double getAverageStoryRating() {
|
||||
return averageStoryRating;
|
||||
}
|
||||
|
||||
public void setAverageStoryRating(Double averageStoryRating) {
|
||||
this.averageStoryRating = averageStoryRating;
|
||||
}
|
||||
|
||||
public List<String> getUrls() {
|
||||
return urls;
|
||||
}
|
||||
|
||||
58
backend/src/main/java/com/storycove/dto/AuthorSearchDto.java
Normal file
58
backend/src/main/java/com/storycove/dto/AuthorSearchDto.java
Normal file
@@ -0,0 +1,58 @@
|
||||
package com.storycove.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public class AuthorSearchDto {
|
||||
|
||||
private UUID id;
|
||||
private String name;
|
||||
private String notes;
|
||||
private Integer authorRating;
|
||||
private Double averageStoryRating;
|
||||
private Integer storyCount;
|
||||
private List<String> urls;
|
||||
private String avatarImagePath;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
// Search-specific fields
|
||||
private Long searchScore;
|
||||
|
||||
public AuthorSearchDto() {}
|
||||
|
||||
// Getters and Setters
|
||||
public UUID getId() { return id; }
|
||||
public void setId(UUID id) { this.id = id; }
|
||||
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
|
||||
public String getNotes() { return notes; }
|
||||
public void setNotes(String notes) { this.notes = notes; }
|
||||
|
||||
public Integer getAuthorRating() { return authorRating; }
|
||||
public void setAuthorRating(Integer authorRating) { this.authorRating = authorRating; }
|
||||
|
||||
public Double getAverageStoryRating() { return averageStoryRating; }
|
||||
public void setAverageStoryRating(Double averageStoryRating) { this.averageStoryRating = averageStoryRating; }
|
||||
|
||||
public Integer getStoryCount() { return storyCount; }
|
||||
public void setStoryCount(Integer storyCount) { this.storyCount = storyCount; }
|
||||
|
||||
public List<String> getUrls() { return urls; }
|
||||
public void setUrls(List<String> urls) { this.urls = urls; }
|
||||
|
||||
public String getAvatarImagePath() { return avatarImagePath; }
|
||||
public void setAvatarImagePath(String avatarImagePath) { this.avatarImagePath = avatarImagePath; }
|
||||
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
|
||||
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
|
||||
public Long getSearchScore() { return searchScore; }
|
||||
public void setSearchScore(Long searchScore) { this.searchScore = searchScore; }
|
||||
}
|
||||
@@ -49,4 +49,7 @@ public interface AuthorRepository extends JpaRepository<Author, UUID> {
|
||||
|
||||
@Query("SELECT COUNT(a) FROM Author a WHERE a.createdAt >= :cutoffDate")
|
||||
long countRecentAuthors(@Param("cutoffDate") java.time.LocalDateTime cutoffDate);
|
||||
|
||||
@Query(value = "SELECT author_rating FROM authors WHERE id = :id", nativeQuery = true)
|
||||
Integer findAuthorRatingById(@Param("id") UUID id);
|
||||
}
|
||||
@@ -108,4 +108,10 @@ public interface StoryRepository extends JpaRepository<Story, UUID> {
|
||||
|
||||
@Query("SELECT s FROM Story s WHERE s.createdAt >= :since ORDER BY s.createdAt DESC")
|
||||
List<Story> findRecentlyRead(@Param("since") LocalDateTime since);
|
||||
|
||||
@Query("SELECT DISTINCT s FROM Story s " +
|
||||
"LEFT JOIN FETCH s.author " +
|
||||
"LEFT JOIN FETCH s.series " +
|
||||
"LEFT JOIN FETCH s.tags")
|
||||
List<Story> findAllWithAssociations();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.storycove.scheduled;
|
||||
|
||||
import com.storycove.entity.Story;
|
||||
import com.storycove.service.StoryService;
|
||||
import com.storycove.service.TypesenseService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Scheduled task to periodically reindex all stories in Typesense
|
||||
* to ensure search index stays synchronized with database changes.
|
||||
*/
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "storycove.typesense.enabled", havingValue = "true", matchIfMissing = true)
|
||||
public class TypesenseIndexScheduler {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(TypesenseIndexScheduler.class);
|
||||
|
||||
private final StoryService storyService;
|
||||
private final TypesenseService typesenseService;
|
||||
|
||||
@Autowired
|
||||
public TypesenseIndexScheduler(StoryService storyService,
|
||||
@Autowired(required = false) TypesenseService typesenseService) {
|
||||
this.storyService = storyService;
|
||||
this.typesenseService = typesenseService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduled task that runs periodically to reindex all stories in Typesense.
|
||||
* This ensures the search index stays synchronized with any database changes
|
||||
* that might have occurred outside of the normal story update flow.
|
||||
*
|
||||
* Interval is configurable via storycove.typesense.reindex-interval property (default: 1 hour).
|
||||
*/
|
||||
@Scheduled(fixedRateString = "${storycove.typesense.reindex-interval:3600000}")
|
||||
public void reindexAllStories() {
|
||||
if (typesenseService == null) {
|
||||
logger.debug("TypesenseService is not available, skipping scheduled reindexing");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("Starting scheduled Typesense reindexing at {}", LocalDateTime.now());
|
||||
|
||||
try {
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// Get all stories from database with eagerly loaded associations
|
||||
List<Story> allStories = storyService.findAllWithAssociations();
|
||||
|
||||
if (allStories.isEmpty()) {
|
||||
logger.info("No stories found in database, skipping reindexing");
|
||||
return;
|
||||
}
|
||||
|
||||
// Perform full reindex
|
||||
typesenseService.reindexAllStories(allStories);
|
||||
|
||||
long endTime = System.currentTimeMillis();
|
||||
long duration = endTime - startTime;
|
||||
|
||||
logger.info("Completed scheduled Typesense reindexing of {} stories in {}ms",
|
||||
allStories.size(), duration);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to complete scheduled Typesense reindexing", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual trigger for reindexing - can be called from other services or endpoints if needed
|
||||
*/
|
||||
public void triggerManualReindex() {
|
||||
logger.info("Manual Typesense reindexing triggered");
|
||||
reindexAllStories();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.storycove.service;
|
||||
|
||||
import com.storycove.entity.Author;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "storycove.typesense.enabled", havingValue = "true", matchIfMissing = true)
|
||||
public class AuthorIndexScheduler {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(AuthorIndexScheduler.class);
|
||||
|
||||
private final AuthorService authorService;
|
||||
private final TypesenseService typesenseService;
|
||||
|
||||
@Autowired
|
||||
public AuthorIndexScheduler(AuthorService authorService, TypesenseService typesenseService) {
|
||||
this.authorService = authorService;
|
||||
this.typesenseService = typesenseService;
|
||||
}
|
||||
|
||||
@Scheduled(fixedRateString = "${storycove.typesense.author-reindex-interval:7200000}") // 2 hours default
|
||||
public void reindexAllAuthors() {
|
||||
try {
|
||||
logger.info("Starting scheduled author reindexing...");
|
||||
|
||||
List<Author> allAuthors = authorService.findAllWithStories();
|
||||
logger.info("Found {} authors to reindex", allAuthors.size());
|
||||
|
||||
if (!allAuthors.isEmpty()) {
|
||||
typesenseService.reindexAllAuthors(allAuthors);
|
||||
logger.info("Successfully completed scheduled author reindexing");
|
||||
} else {
|
||||
logger.info("No authors found to reindex");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to complete scheduled author reindexing", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Manual reindex endpoint can be added to AuthorController if needed
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.storycove.service;
|
||||
|
||||
import com.storycove.entity.Author;
|
||||
import com.storycove.entity.Story;
|
||||
import com.storycove.repository.AuthorRepository;
|
||||
import com.storycove.service.exception.DuplicateResourceException;
|
||||
import com.storycove.service.exception.ResourceNotFoundException;
|
||||
import jakarta.validation.Valid;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -21,17 +24,36 @@ import java.util.UUID;
|
||||
@Transactional
|
||||
public class AuthorService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(AuthorService.class);
|
||||
|
||||
private final AuthorRepository authorRepository;
|
||||
private final TypesenseService typesenseService;
|
||||
|
||||
@Autowired
|
||||
public AuthorService(AuthorRepository authorRepository) {
|
||||
public AuthorService(AuthorRepository authorRepository, TypesenseService typesenseService) {
|
||||
this.authorRepository = authorRepository;
|
||||
this.typesenseService = typesenseService;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<Author> findAll() {
|
||||
return authorRepository.findAll();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<Author> findAllWithStories() {
|
||||
List<Author> authors = authorRepository.findAll();
|
||||
// Force load lazy collections within transaction to avoid lazy loading issues
|
||||
authors.forEach(author -> {
|
||||
if (author.getStories() != null) {
|
||||
author.getStories().size(); // Force initialization
|
||||
}
|
||||
if (author.getUrls() != null) {
|
||||
author.getUrls().size(); // Force initialization
|
||||
}
|
||||
});
|
||||
return authors;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Page<Author> findAll(Pageable pageable) {
|
||||
@@ -107,7 +129,16 @@ public class AuthorService {
|
||||
|
||||
public Author create(@Valid Author author) {
|
||||
validateAuthorForCreate(author);
|
||||
return authorRepository.save(author);
|
||||
Author savedAuthor = authorRepository.save(author);
|
||||
|
||||
// Index in Typesense
|
||||
try {
|
||||
typesenseService.indexAuthor(savedAuthor);
|
||||
} catch (Exception e) {
|
||||
logger.warn("Failed to index author in Typesense: " + savedAuthor.getName(), e);
|
||||
}
|
||||
|
||||
return savedAuthor;
|
||||
}
|
||||
|
||||
public Author update(UUID id, @Valid Author authorUpdates) {
|
||||
@@ -120,7 +151,16 @@ public class AuthorService {
|
||||
}
|
||||
|
||||
updateAuthorFields(existingAuthor, authorUpdates);
|
||||
return authorRepository.save(existingAuthor);
|
||||
Author savedAuthor = authorRepository.save(existingAuthor);
|
||||
|
||||
// Update in Typesense
|
||||
try {
|
||||
typesenseService.updateAuthor(savedAuthor);
|
||||
} catch (Exception e) {
|
||||
logger.warn("Failed to update author in Typesense: " + savedAuthor.getName(), e);
|
||||
}
|
||||
|
||||
return savedAuthor;
|
||||
}
|
||||
|
||||
public void delete(UUID id) {
|
||||
@@ -132,18 +172,43 @@ public class AuthorService {
|
||||
}
|
||||
|
||||
authorRepository.delete(author);
|
||||
|
||||
// Remove from Typesense
|
||||
try {
|
||||
typesenseService.deleteAuthor(id.toString());
|
||||
} catch (Exception e) {
|
||||
logger.warn("Failed to delete author from Typesense: " + author.getName(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public Author addUrl(UUID id, String url) {
|
||||
Author author = findById(id);
|
||||
author.addUrl(url);
|
||||
return authorRepository.save(author);
|
||||
Author savedAuthor = authorRepository.save(author);
|
||||
|
||||
// Update in Typesense
|
||||
try {
|
||||
typesenseService.updateAuthor(savedAuthor);
|
||||
} catch (Exception e) {
|
||||
logger.warn("Failed to update author in Typesense after adding URL: " + savedAuthor.getName(), e);
|
||||
}
|
||||
|
||||
return savedAuthor;
|
||||
}
|
||||
|
||||
public Author removeUrl(UUID id, String url) {
|
||||
Author author = findById(id);
|
||||
author.removeUrl(url);
|
||||
return authorRepository.save(author);
|
||||
Author savedAuthor = authorRepository.save(author);
|
||||
|
||||
// Update in Typesense
|
||||
try {
|
||||
typesenseService.updateAuthor(savedAuthor);
|
||||
} catch (Exception e) {
|
||||
logger.warn("Failed to update author in Typesense after removing URL: " + savedAuthor.getName(), e);
|
||||
}
|
||||
|
||||
return savedAuthor;
|
||||
}
|
||||
|
||||
public Author setDirectRating(UUID id, int rating) {
|
||||
@@ -162,8 +227,32 @@ public class AuthorService {
|
||||
}
|
||||
|
||||
Author author = findById(id);
|
||||
logger.debug("Setting author rating: {} for author: {} (current rating: {})",
|
||||
rating, author.getName(), author.getAuthorRating());
|
||||
|
||||
author.setAuthorRating(rating);
|
||||
return authorRepository.save(author);
|
||||
Author savedAuthor = authorRepository.save(author);
|
||||
|
||||
// Flush and refresh to ensure the entity is up-to-date
|
||||
authorRepository.flush();
|
||||
Author refreshedAuthor = findById(id);
|
||||
|
||||
logger.debug("Saved author rating: {} for author: {}",
|
||||
refreshedAuthor.getAuthorRating(), refreshedAuthor.getName());
|
||||
|
||||
// Update in Typesense
|
||||
try {
|
||||
typesenseService.updateAuthor(refreshedAuthor);
|
||||
} catch (Exception e) {
|
||||
logger.warn("Failed to update author in Typesense after rating: " + refreshedAuthor.getName(), e);
|
||||
}
|
||||
|
||||
return refreshedAuthor;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Integer getAuthorRatingFromDb(UUID id) {
|
||||
return authorRepository.findAuthorRatingById(id);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -171,17 +260,57 @@ public class AuthorService {
|
||||
return authorRepository.findTopRatedAuthors(pageable).getContent();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Double calculateAverageStoryRating(UUID authorId) {
|
||||
Author author = findById(authorId);
|
||||
|
||||
if (author.getStories() == null || author.getStories().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<Integer> ratings = author.getStories().stream()
|
||||
.map(Story::getRating)
|
||||
.filter(rating -> rating != null)
|
||||
.toList();
|
||||
|
||||
if (ratings.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ratings.stream()
|
||||
.mapToInt(Integer::intValue)
|
||||
.average()
|
||||
.orElse(0.0);
|
||||
}
|
||||
|
||||
public Author setAvatar(UUID id, String avatarPath) {
|
||||
Author author = findById(id);
|
||||
author.setAvatarImagePath(avatarPath);
|
||||
return authorRepository.save(author);
|
||||
Author savedAuthor = authorRepository.save(author);
|
||||
|
||||
// Update in Typesense
|
||||
try {
|
||||
typesenseService.updateAuthor(savedAuthor);
|
||||
} catch (Exception e) {
|
||||
logger.warn("Failed to update author in Typesense after setting avatar: " + savedAuthor.getName(), e);
|
||||
}
|
||||
|
||||
return savedAuthor;
|
||||
}
|
||||
|
||||
public Author removeAvatar(UUID id) {
|
||||
Author author = findById(id);
|
||||
author.setAvatarImagePath(null);
|
||||
return authorRepository.save(author);
|
||||
Author savedAuthor = authorRepository.save(author);
|
||||
|
||||
// Update in Typesense
|
||||
try {
|
||||
typesenseService.updateAuthor(savedAuthor);
|
||||
} catch (Exception e) {
|
||||
logger.warn("Failed to update author in Typesense after removing avatar: " + savedAuthor.getName(), e);
|
||||
}
|
||||
|
||||
return savedAuthor;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
|
||||
@@ -58,6 +58,11 @@ public class StoryService {
|
||||
public List<Story> findAll() {
|
||||
return storyRepository.findAll();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<Story> findAllWithAssociations() {
|
||||
return storyRepository.findAllWithAssociations();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Page<Story> findAll(Pageable pageable) {
|
||||
@@ -221,7 +226,14 @@ public class StoryService {
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Tag not found with id: " + tagId));
|
||||
|
||||
story.addTag(tag);
|
||||
return storyRepository.save(story);
|
||||
Story savedStory = storyRepository.save(story);
|
||||
|
||||
// Update Typesense index with new tag information
|
||||
if (typesenseService != null) {
|
||||
typesenseService.updateStory(savedStory);
|
||||
}
|
||||
|
||||
return savedStory;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -231,7 +243,14 @@ public class StoryService {
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Tag not found with id: " + tagId));
|
||||
|
||||
story.removeTag(tag);
|
||||
return storyRepository.save(story);
|
||||
Story savedStory = storyRepository.save(story);
|
||||
|
||||
// Update Typesense index with updated tag information
|
||||
if (typesenseService != null) {
|
||||
typesenseService.updateStory(savedStory);
|
||||
}
|
||||
|
||||
return savedStory;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -242,7 +261,14 @@ public class StoryService {
|
||||
|
||||
Story story = findById(id);
|
||||
story.setRating(rating);
|
||||
return storyRepository.save(story);
|
||||
Story savedStory = storyRepository.save(story);
|
||||
|
||||
// Update Typesense index with new rating
|
||||
if (typesenseService != null) {
|
||||
typesenseService.updateStory(savedStory);
|
||||
}
|
||||
|
||||
return savedStory;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.storycove.service;
|
||||
|
||||
import com.storycove.dto.AuthorSearchDto;
|
||||
import com.storycove.dto.SearchResultDto;
|
||||
import com.storycove.dto.StorySearchDto;
|
||||
import com.storycove.entity.Author;
|
||||
import com.storycove.entity.Story;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -21,6 +23,7 @@ public class TypesenseService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(TypesenseService.class);
|
||||
private static final String STORIES_COLLECTION = "stories";
|
||||
private static final String AUTHORS_COLLECTION = "authors";
|
||||
|
||||
private final Client typesenseClient;
|
||||
|
||||
@@ -33,6 +36,7 @@ public class TypesenseService {
|
||||
public void initializeCollections() {
|
||||
try {
|
||||
createStoriesCollectionIfNotExists();
|
||||
createAuthorsCollectionIfNotExists();
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to initialize Typesense collections", e);
|
||||
}
|
||||
@@ -77,6 +81,59 @@ public class TypesenseService {
|
||||
logger.info("Stories collection created successfully");
|
||||
}
|
||||
|
||||
private void createAuthorsCollectionIfNotExists() throws Exception {
|
||||
try {
|
||||
// Check if collection already exists
|
||||
typesenseClient.collections(AUTHORS_COLLECTION).retrieve();
|
||||
logger.info("Authors collection already exists");
|
||||
} catch (Exception e) {
|
||||
logger.info("Creating authors collection...");
|
||||
createAuthorsCollection();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force recreate the authors collection, deleting it first if it exists
|
||||
*/
|
||||
public void recreateAuthorsCollection() throws Exception {
|
||||
try {
|
||||
logger.info("Force deleting authors collection for recreation...");
|
||||
typesenseClient.collections(AUTHORS_COLLECTION).delete();
|
||||
logger.info("Successfully deleted authors collection");
|
||||
} catch (Exception e) {
|
||||
logger.debug("Authors collection didn't exist for deletion: {}", e.getMessage());
|
||||
}
|
||||
|
||||
// Wait a brief moment to ensure deletion is complete
|
||||
Thread.sleep(100);
|
||||
|
||||
logger.info("Creating authors collection with fresh schema...");
|
||||
createAuthorsCollection();
|
||||
logger.info("Successfully created authors collection");
|
||||
}
|
||||
|
||||
private void createAuthorsCollection() throws Exception {
|
||||
List<Field> fields = Arrays.asList(
|
||||
new Field().name("id").type("string").facet(false),
|
||||
new Field().name("name").type("string").facet(true).sort(true), // Enable both faceting and sorting
|
||||
new Field().name("notes").type("string").facet(false).optional(true),
|
||||
new Field().name("authorRating").type("int32").facet(true).sort(true).optional(true), // Enable both faceting and sorting
|
||||
new Field().name("averageStoryRating").type("float").facet(true).sort(true).optional(true), // Enable both faceting and sorting
|
||||
new Field().name("storyCount").type("int32").facet(true).sort(true), // Enable both faceting and sorting
|
||||
new Field().name("urls").type("string[]").facet(false).optional(true),
|
||||
new Field().name("avatarImagePath").type("string").facet(false).optional(true),
|
||||
new Field().name("createdAt").type("int64").facet(true).sort(true), // Enable both faceting and sorting
|
||||
new Field().name("updatedAt").type("int64").facet(true).sort(true) // Enable both faceting and sorting
|
||||
);
|
||||
|
||||
CollectionSchema collectionSchema = new CollectionSchema()
|
||||
.name(AUTHORS_COLLECTION)
|
||||
.fields(fields);
|
||||
|
||||
typesenseClient.collections().create(collectionSchema);
|
||||
logger.info("Authors collection created successfully");
|
||||
}
|
||||
|
||||
public void indexStory(Story story) {
|
||||
try {
|
||||
Map<String, Object> document = createStoryDocument(story);
|
||||
@@ -113,7 +170,9 @@ public class TypesenseService {
|
||||
List<String> authorFilters,
|
||||
List<String> tagFilters,
|
||||
Integer minRating,
|
||||
Integer maxRating) {
|
||||
Integer maxRating,
|
||||
String sortBy,
|
||||
String sortDir) {
|
||||
|
||||
try {
|
||||
long startTime = System.currentTimeMillis();
|
||||
@@ -121,15 +180,21 @@ public class TypesenseService {
|
||||
// Convert 0-based page (frontend/backend) to 1-based page (Typesense)
|
||||
int typesensePage = page + 1;
|
||||
|
||||
// Normalize query - treat empty, null, or "*" as wildcard
|
||||
String normalizedQuery = query;
|
||||
if (query == null || query.trim().isEmpty() || "*".equals(query.trim())) {
|
||||
normalizedQuery = "*";
|
||||
}
|
||||
|
||||
SearchParameters searchParameters = new SearchParameters()
|
||||
.q(query.isEmpty() ? "*" : query)
|
||||
.q(normalizedQuery)
|
||||
.queryBy("title,description,contentPlain,authorName,seriesName,tagNames")
|
||||
.page(typesensePage)
|
||||
.perPage(perPage)
|
||||
.highlightFields("title,description")
|
||||
.highlightStartTag("<mark>")
|
||||
.highlightEndTag("</mark>")
|
||||
.sortBy("_text_match:desc,createdAt:desc");
|
||||
.sortBy(buildSortParameter(normalizedQuery, sortBy, sortDir));
|
||||
|
||||
// Add filters
|
||||
List<String> filterConditions = new ArrayList<>();
|
||||
@@ -254,7 +319,7 @@ public class TypesenseService {
|
||||
document.put("description", story.getDescription() != null ? story.getDescription() : "");
|
||||
document.put("contentPlain", story.getContentPlain() != null ? story.getContentPlain() : "");
|
||||
|
||||
// Required fields - always include even if null
|
||||
// Author fields - required in schema, use empty string for missing values
|
||||
if (story.getAuthor() != null) {
|
||||
document.put("authorId", story.getAuthor().getId().toString());
|
||||
document.put("authorName", story.getAuthor().getName());
|
||||
@@ -263,6 +328,7 @@ public class TypesenseService {
|
||||
document.put("authorName", "");
|
||||
}
|
||||
|
||||
// Series fields - optional
|
||||
if (story.getSeries() != null) {
|
||||
document.put("seriesId", story.getSeries().getId().toString());
|
||||
document.put("seriesName", story.getSeries().getName());
|
||||
@@ -305,13 +371,15 @@ public class TypesenseService {
|
||||
dto.setDescription((String) doc.get("description"));
|
||||
dto.setContentPlain((String) doc.get("contentPlain"));
|
||||
|
||||
if (doc.get("authorId") != null) {
|
||||
dto.setAuthorId(UUID.fromString((String) doc.get("authorId")));
|
||||
String authorId = (String) doc.get("authorId");
|
||||
if (authorId != null && !authorId.trim().isEmpty()) {
|
||||
dto.setAuthorId(UUID.fromString(authorId));
|
||||
dto.setAuthorName((String) doc.get("authorName"));
|
||||
}
|
||||
|
||||
if (doc.get("seriesId") != null) {
|
||||
dto.setSeriesId(UUID.fromString((String) doc.get("seriesId")));
|
||||
String seriesId = (String) doc.get("seriesId");
|
||||
if (seriesId != null && !seriesId.trim().isEmpty()) {
|
||||
dto.setSeriesId(UUID.fromString(seriesId));
|
||||
dto.setSeriesName((String) doc.get("seriesName"));
|
||||
}
|
||||
|
||||
@@ -341,8 +409,9 @@ public class TypesenseService {
|
||||
timestamp, 0, java.time.ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
// Set search-specific fields
|
||||
dto.setSearchScore(hit.getTextMatch());
|
||||
// Set search-specific fields - handle null for wildcard queries
|
||||
Long textMatch = hit.getTextMatch();
|
||||
dto.setSearchScore(textMatch != null ? textMatch : 0L);
|
||||
|
||||
// Extract highlights from the Typesense response with multiple fallback approaches
|
||||
List<String> highlights = extractHighlights(hit, dto.getTitle());
|
||||
@@ -441,4 +510,384 @@ public class TypesenseService {
|
||||
|
||||
return highlights;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build sort parameter for Typesense search.
|
||||
* Maps frontend sort fields to Typesense document fields and handles direction.
|
||||
*/
|
||||
private String buildSortParameter(String query, String sortBy, String sortDir) {
|
||||
// If it's a wildcard query, use default sort without text match scoring
|
||||
boolean isWildcardQuery = query == null || query.trim().isEmpty() || "*".equals(query.trim());
|
||||
|
||||
// If no sort parameters provided, use appropriate default
|
||||
if (sortBy == null || sortBy.trim().isEmpty()) {
|
||||
return isWildcardQuery ? "createdAt:desc" : "_text_match:desc,createdAt:desc";
|
||||
}
|
||||
|
||||
// Map frontend sort fields to Typesense fields
|
||||
String typesenseField = mapSortField(sortBy);
|
||||
String direction = (sortDir != null && sortDir.equalsIgnoreCase("asc")) ? "asc" : "desc";
|
||||
|
||||
String sortParameter = typesenseField + ":" + direction;
|
||||
|
||||
// For text queries (not wildcard), include text match scoring if not already sorting by a text-based field
|
||||
if (!isWildcardQuery && !isTextBasedSort(sortBy)) {
|
||||
sortParameter = "_text_match:desc," + sortParameter;
|
||||
}
|
||||
|
||||
// Always include createdAt as a tie-breaker unless we're already sorting by it
|
||||
if (!"createdAt".equals(sortBy)) {
|
||||
sortParameter = sortParameter + ",createdAt:desc";
|
||||
}
|
||||
|
||||
return sortParameter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map frontend sort field names to Typesense document field names
|
||||
*/
|
||||
private String mapSortField(String frontendField) {
|
||||
switch (frontendField.toLowerCase()) {
|
||||
case "title":
|
||||
return "title";
|
||||
case "author":
|
||||
case "authorname":
|
||||
return "authorName";
|
||||
case "createdat":
|
||||
case "created_at":
|
||||
case "date":
|
||||
return "createdAt";
|
||||
case "rating":
|
||||
return "rating";
|
||||
case "wordcount":
|
||||
case "word_count":
|
||||
return "wordCount";
|
||||
case "volume":
|
||||
return "volume";
|
||||
case "series":
|
||||
case "seriesname":
|
||||
return "seriesName";
|
||||
default:
|
||||
// Fallback to createdAt for unknown fields
|
||||
return "createdAt";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the sort field is text-based (title, author) where text match scoring is less relevant
|
||||
*/
|
||||
private boolean isTextBasedSort(String sortBy) {
|
||||
return sortBy != null && (
|
||||
sortBy.equalsIgnoreCase("title") ||
|
||||
sortBy.equalsIgnoreCase("author") ||
|
||||
sortBy.equalsIgnoreCase("authorname") ||
|
||||
sortBy.equalsIgnoreCase("series") ||
|
||||
sortBy.equalsIgnoreCase("seriesname")
|
||||
);
|
||||
}
|
||||
|
||||
// Author indexing methods
|
||||
|
||||
public void indexAuthor(Author author) {
|
||||
try {
|
||||
Map<String, Object> document = createAuthorDocument(author);
|
||||
typesenseClient.collections(AUTHORS_COLLECTION).documents().create(document);
|
||||
logger.debug("Indexed author: {}", author.getName());
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to index author: " + author.getName(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public void updateAuthor(Author author) {
|
||||
try {
|
||||
Map<String, Object> document = createAuthorDocument(author);
|
||||
typesenseClient.collections(AUTHORS_COLLECTION).documents(author.getId().toString()).update(document);
|
||||
logger.debug("Updated author index: {}", author.getName());
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to update author index: " + author.getName(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteAuthor(String authorId) {
|
||||
try {
|
||||
typesenseClient.collections(AUTHORS_COLLECTION).documents(authorId).delete();
|
||||
logger.debug("Deleted author from index: {}", authorId);
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to delete author from index: " + authorId, e);
|
||||
}
|
||||
}
|
||||
|
||||
public void bulkIndexAuthors(List<Author> authors) {
|
||||
if (authors.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Index authors one by one for now (can optimize later)
|
||||
for (Author author : authors) {
|
||||
indexAuthor(author);
|
||||
}
|
||||
logger.info("Bulk indexed {} authors", authors.size());
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to bulk index authors", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void reindexAllAuthors(List<Author> authors) {
|
||||
try {
|
||||
// Force recreate collection with proper schema
|
||||
recreateAuthorsCollection();
|
||||
|
||||
// Bulk index all authors
|
||||
bulkIndexAuthors(authors);
|
||||
|
||||
logger.info("Reindexed all {} authors", authors.size());
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to reindex all authors", e);
|
||||
throw new RuntimeException("Failed to reindex all authors", e);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> createAuthorDocument(Author author) {
|
||||
Map<String, Object> document = new HashMap<>();
|
||||
document.put("id", author.getId().toString());
|
||||
document.put("name", author.getName());
|
||||
document.put("notes", author.getNotes() != null ? author.getNotes() : "");
|
||||
document.put("authorRating", author.getAuthorRating());
|
||||
|
||||
// Safely handle potentially lazy-loaded stories collection
|
||||
int storyCount = 0;
|
||||
Double averageStoryRating = null;
|
||||
|
||||
try {
|
||||
if (author.getStories() != null && !author.getStories().isEmpty()) {
|
||||
storyCount = author.getStories().size();
|
||||
|
||||
// Calculate average story rating
|
||||
double avgRating = author.getStories().stream()
|
||||
.filter(story -> story.getRating() != null)
|
||||
.mapToInt(story -> story.getRating())
|
||||
.average()
|
||||
.orElse(0.0);
|
||||
averageStoryRating = avgRating > 0 ? avgRating : null;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// If stories can't be loaded (lazy loading issue), set defaults
|
||||
logger.debug("Could not load stories for author {}, using defaults: {}", author.getName(), e.getMessage());
|
||||
storyCount = 0;
|
||||
averageStoryRating = null;
|
||||
}
|
||||
|
||||
document.put("storyCount", storyCount);
|
||||
document.put("averageStoryRating", averageStoryRating);
|
||||
|
||||
// Safely handle potentially lazy-loaded URLs collection
|
||||
List<String> urls = new ArrayList<>();
|
||||
try {
|
||||
if (author.getUrls() != null) {
|
||||
urls = author.getUrls();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.debug("Could not load URLs for author {}, using empty list: {}", author.getName(), e.getMessage());
|
||||
}
|
||||
document.put("urls", urls);
|
||||
document.put("avatarImagePath", author.getAvatarImagePath());
|
||||
document.put("createdAt", author.getCreatedAt() != null ?
|
||||
author.getCreatedAt().toEpochSecond(java.time.ZoneOffset.UTC) : 0);
|
||||
document.put("updatedAt", author.getUpdatedAt() != null ?
|
||||
author.getUpdatedAt().toEpochSecond(java.time.ZoneOffset.UTC) : 0);
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
public SearchResultDto<AuthorSearchDto> searchAuthors(String query, int page, int perPage, String sortBy, String sortOrder) {
|
||||
try {
|
||||
logger.info("AUTHORS SEARCH DEBUG: Searching collection '{}' with query='{}', sortBy='{}', sortOrder='{}'",
|
||||
AUTHORS_COLLECTION, query, sortBy, sortOrder);
|
||||
SearchParameters searchParameters = new SearchParameters()
|
||||
.q(query != null && !query.trim().isEmpty() ? query : "*")
|
||||
.queryBy("name,notes")
|
||||
.page(page + 1) // Typesense pages are 1-indexed
|
||||
.perPage(perPage);
|
||||
|
||||
// Add sorting if specified, with fallback if sorting fails
|
||||
if (sortBy != null && !sortBy.trim().isEmpty()) {
|
||||
String sortDirection = "desc".equalsIgnoreCase(sortOrder) ? "desc" : "asc";
|
||||
String sortField = mapAuthorSortField(sortBy);
|
||||
String sortString = sortField + ":" + sortDirection;
|
||||
logger.info("AUTHORS SEARCH DEBUG: Original sortBy='{}', mapped to='{}', full sort string='{}'",
|
||||
sortBy, sortField, sortString);
|
||||
searchParameters.sortBy(sortString);
|
||||
}
|
||||
|
||||
SearchResult searchResult;
|
||||
try {
|
||||
searchResult = typesenseClient.collections(AUTHORS_COLLECTION)
|
||||
.documents()
|
||||
.search(searchParameters);
|
||||
} catch (Exception sortException) {
|
||||
// If sorting fails (likely due to schema issues), retry without sorting
|
||||
logger.error("SORTING ERROR DEBUG: Full exception details", sortException);
|
||||
logger.warn("Sorting failed for authors search, retrying without sort: " + sortException.getMessage());
|
||||
|
||||
// Try to get collection info for debugging
|
||||
try {
|
||||
CollectionResponse collection = typesenseClient.collections(AUTHORS_COLLECTION).retrieve();
|
||||
logger.error("COLLECTION DEBUG: Collection '{}' exists with {} documents and {} fields",
|
||||
collection.getName(), collection.getNumDocuments(), collection.getFields().size());
|
||||
logger.error("COLLECTION DEBUG: Fields: {}", collection.getFields());
|
||||
} catch (Exception debugException) {
|
||||
logger.error("COLLECTION DEBUG: Failed to retrieve collection info", debugException);
|
||||
}
|
||||
|
||||
searchParameters = new SearchParameters()
|
||||
.q(query != null && !query.trim().isEmpty() ? query : "*")
|
||||
.queryBy("name,notes")
|
||||
.page(page + 1)
|
||||
.perPage(perPage);
|
||||
|
||||
searchResult = typesenseClient.collections(AUTHORS_COLLECTION)
|
||||
.documents()
|
||||
.search(searchParameters);
|
||||
}
|
||||
|
||||
return convertAuthorSearchResult(searchResult, query, page, perPage);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to search authors with query: " + query, e);
|
||||
SearchResultDto<AuthorSearchDto> emptyResult = new SearchResultDto<>();
|
||||
emptyResult.setQuery(query);
|
||||
emptyResult.setPage(page);
|
||||
emptyResult.setPerPage(perPage);
|
||||
emptyResult.setTotalHits(0);
|
||||
emptyResult.setSearchTimeMs(0);
|
||||
emptyResult.setResults(new ArrayList<>());
|
||||
return emptyResult;
|
||||
}
|
||||
}
|
||||
|
||||
private String mapAuthorSortField(String sortBy) {
|
||||
switch (sortBy.toLowerCase()) {
|
||||
case "name":
|
||||
return "name";
|
||||
case "rating":
|
||||
case "authorrating":
|
||||
return "authorRating";
|
||||
case "stories":
|
||||
case "storycount":
|
||||
return "storyCount";
|
||||
case "avgrating":
|
||||
case "averagestoryrating":
|
||||
return "averageStoryRating";
|
||||
case "created":
|
||||
case "createdat":
|
||||
return "createdAt";
|
||||
default:
|
||||
return "name";
|
||||
}
|
||||
}
|
||||
|
||||
private SearchResultDto<AuthorSearchDto> convertAuthorSearchResult(SearchResult searchResult, String query, int page, int perPage) {
|
||||
SearchResultDto<AuthorSearchDto> result = new SearchResultDto<>();
|
||||
result.setQuery(query);
|
||||
result.setPage(page);
|
||||
result.setPerPage(perPage);
|
||||
result.setTotalHits(searchResult.getFound().intValue());
|
||||
result.setSearchTimeMs(searchResult.getSearchTimeMs().intValue());
|
||||
|
||||
List<AuthorSearchDto> authors = searchResult.getHits().stream()
|
||||
.map(this::convertAuthorHit)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
result.setResults(authors);
|
||||
return result;
|
||||
}
|
||||
|
||||
private AuthorSearchDto convertAuthorHit(SearchResultHit hit) {
|
||||
Map<String, Object> doc = hit.getDocument();
|
||||
|
||||
AuthorSearchDto dto = new AuthorSearchDto();
|
||||
dto.setId(UUID.fromString((String) doc.get("id")));
|
||||
dto.setName((String) doc.get("name"));
|
||||
dto.setNotes((String) doc.get("notes"));
|
||||
|
||||
// Handle numeric fields safely
|
||||
Object authorRating = doc.get("authorRating");
|
||||
if (authorRating instanceof Number) {
|
||||
dto.setAuthorRating(((Number) authorRating).intValue());
|
||||
}
|
||||
|
||||
Object storyCount = doc.get("storyCount");
|
||||
if (storyCount instanceof Number) {
|
||||
dto.setStoryCount(((Number) storyCount).intValue());
|
||||
}
|
||||
|
||||
Object avgRating = doc.get("averageStoryRating");
|
||||
if (avgRating instanceof Number) {
|
||||
dto.setAverageStoryRating(((Number) avgRating).doubleValue());
|
||||
}
|
||||
|
||||
dto.setAvatarImagePath((String) doc.get("avatarImagePath"));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> urls = (List<String>) doc.get("urls");
|
||||
dto.setUrls(urls);
|
||||
|
||||
// Convert timestamps back to LocalDateTime
|
||||
Object createdAt = doc.get("createdAt");
|
||||
if (createdAt instanceof Number) {
|
||||
dto.setCreatedAt(java.time.LocalDateTime.ofEpochSecond(
|
||||
((Number) createdAt).longValue(), 0, java.time.ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
Object updatedAt = doc.get("updatedAt");
|
||||
if (updatedAt instanceof Number) {
|
||||
dto.setUpdatedAt(java.time.LocalDateTime.ofEpochSecond(
|
||||
((Number) updatedAt).longValue(), 0, java.time.ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
// Set search score
|
||||
Long textMatch = hit.getTextMatch();
|
||||
dto.setSearchScore(textMatch != null ? textMatch : 0L);
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current schema information for the authors collection
|
||||
*/
|
||||
public Map<String, Object> getAuthorsCollectionSchema() {
|
||||
try {
|
||||
CollectionResponse collection = typesenseClient.collections(AUTHORS_COLLECTION).retrieve();
|
||||
return Map.of(
|
||||
"name", collection.getName(),
|
||||
"num_documents", collection.getNumDocuments(),
|
||||
"fields", collection.getFields(),
|
||||
"created_at", collection.getCreatedAt()
|
||||
);
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to retrieve authors collection schema", e);
|
||||
return Map.of("error", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current schema information for the stories collection
|
||||
*/
|
||||
public Map<String, Object> getStoriesCollectionSchema() {
|
||||
try {
|
||||
CollectionResponse collection = typesenseClient.collections(STORIES_COLLECTION).retrieve();
|
||||
return Map.of(
|
||||
"name", collection.getName(),
|
||||
"num_documents", collection.getNumDocuments(),
|
||||
"fields", collection.getFields(),
|
||||
"created_at", collection.getCreatedAt()
|
||||
);
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to retrieve stories collection schema", e);
|
||||
return Map.of("error", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ storycove:
|
||||
api-key: ${TYPESENSE_API_KEY:xyz}
|
||||
host: ${TYPESENSE_HOST:localhost}
|
||||
port: ${TYPESENSE_PORT:8108}
|
||||
enabled: ${TYPESENSE_ENABLED:true}
|
||||
reindex-interval: ${TYPESENSE_REINDEX_INTERVAL:3600000} # 1 hour in milliseconds
|
||||
images:
|
||||
storage-path: ${IMAGE_STORAGE_PATH:/app/images}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user