MVP Version

This commit is contained in:
Stefan Hardegger
2025-07-23 12:28:48 +02:00
parent 59d29dceaf
commit d69bed00a2
22 changed files with 1781 additions and 153 deletions

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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; }
}

View File

@@ -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;
}

View 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; }
}

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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();
}
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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());
}
}
}

View File

@@ -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}

View File

@@ -97,6 +97,7 @@ export default function AddStoryPage() {
contentHtml: formData.contentHtml,
sourceUrl: formData.sourceUrl || undefined,
volume: formData.seriesName ? parseInt(formData.volume) : undefined,
seriesName: formData.seriesName || undefined,
authorName: formData.authorName || undefined,
tagNames: formData.tags.length > 0 ? formData.tags : undefined,
};

View File

@@ -122,6 +122,29 @@ export default function AuthorDetailPage() {
</span>
</div>
)}
{/* Average Story Rating */}
{author.averageStoryRating && (
<div className="flex items-center gap-1 mt-1">
<div className="flex">
{[1, 2, 3, 4, 5].map((star) => (
<span
key={star}
className={`text-sm ${
star <= Math.round(author.averageStoryRating || 0)
? 'text-blue-400'
: 'text-gray-300 dark:text-gray-600'
}`}
>
</span>
))}
</div>
<span className="text-xs theme-text ml-1">
Avg Story Rating: {author.averageStoryRating.toFixed(1)}/5
</span>
</div>
)}
</div>
</div>

View File

@@ -7,6 +7,7 @@ import { authorApi, getImageUrl } from '../../lib/api';
import { Author } from '../../types/api';
import AppLayout from '../../components/layout/AppLayout';
import { Input } from '../../components/ui/Input';
import Button from '../../components/ui/Button';
import LoadingSpinner from '../../components/ui/LoadingSpinner';
export default function AuthorsPage() {
@@ -14,41 +15,75 @@ export default function AuthorsPage() {
const [filteredAuthors, setFilteredAuthors] = useState<Author[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [sortBy, setSortBy] = useState('name');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const [currentPage, setCurrentPage] = useState(0);
const [totalHits, setTotalHits] = useState(0);
const [hasMore, setHasMore] = useState(false);
const ITEMS_PER_PAGE = 50; // Safe limit under Typesense's 250 limit
useEffect(() => {
const loadAuthors = async () => {
try {
setLoading(true);
const authorsResult = await authorApi.getAuthors({ page: 0, size: 1000 }); // Get all authors
setAuthors(authorsResult.content || []);
setFilteredAuthors(authorsResult.content || []);
const searchResults = await authorApi.searchAuthorsTypesense({
q: searchQuery || '*',
page: currentPage,
size: ITEMS_PER_PAGE,
sortBy: sortBy,
sortOrder: sortOrder
});
if (currentPage === 0) {
// First page - replace all results
setAuthors(searchResults.results || []);
setFilteredAuthors(searchResults.results || []);
} else {
// Subsequent pages - append results
setAuthors(prev => [...prev, ...(searchResults.results || [])]);
setFilteredAuthors(prev => [...prev, ...(searchResults.results || [])]);
}
setTotalHits(searchResults.totalHits);
setHasMore(searchResults.results.length === ITEMS_PER_PAGE && (currentPage + 1) * ITEMS_PER_PAGE < searchResults.totalHits);
} catch (error) {
console.error('Failed to load authors:', error);
// Fallback to regular API if Typesense fails (only for first page)
if (currentPage === 0) {
try {
const authorsResult = await authorApi.getAuthors({ page: 0, size: ITEMS_PER_PAGE });
setAuthors(authorsResult.content || []);
setFilteredAuthors(authorsResult.content || []);
setTotalHits(authorsResult.totalElements || 0);
setHasMore(authorsResult.content.length === ITEMS_PER_PAGE);
} catch (fallbackError) {
console.error('Fallback also failed:', fallbackError);
}
}
} finally {
setLoading(false);
}
};
loadAuthors();
}, []);
}, [searchQuery, sortBy, sortOrder, currentPage]);
// Reset pagination when search or sort changes
useEffect(() => {
if (!Array.isArray(authors)) {
setFilteredAuthors([]);
return;
if (currentPage !== 0) {
setCurrentPage(0);
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
const filtered = authors.filter(author =>
author.name.toLowerCase().includes(query) ||
(author.notes && author.notes.toLowerCase().includes(query))
);
setFilteredAuthors(filtered);
} else {
setFilteredAuthors(authors);
}, [searchQuery, sortBy, sortOrder]);
const loadMore = () => {
if (hasMore && !loading) {
setCurrentPage(prev => prev + 1);
}
}, [searchQuery, authors]);
};
// Client-side filtering no longer needed since we use Typesense
// Note: We no longer have individual story ratings in the author list
// Average rating would need to be calculated on backend if needed
@@ -71,23 +106,65 @@ export default function AuthorsPage() {
<div>
<h1 className="text-3xl font-bold theme-header">Authors</h1>
<p className="theme-text mt-1">
{filteredAuthors.length} {filteredAuthors.length === 1 ? 'author' : 'authors'}
{filteredAuthors.length} of {totalHits} {totalHits === 1 ? 'author' : 'authors'}
{searchQuery ? ` found` : ` in your library`}
{hasMore && ` (showing first ${filteredAuthors.length})`}
</p>
</div>
{/* View Mode Toggle */}
<div className="flex items-center gap-2">
<Button
variant={viewMode === 'grid' ? 'primary' : 'ghost'}
onClick={() => setViewMode('grid')}
className="px-3 py-2"
>
Grid
</Button>
<Button
variant={viewMode === 'list' ? 'primary' : 'ghost'}
onClick={() => setViewMode('list')}
className="px-3 py-2"
>
List
</Button>
</div>
</div>
{/* Search */}
<div className="max-w-md">
<Input
type="search"
placeholder="Search authors..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{/* Search and Sort Controls */}
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1 max-w-md">
<Input
type="search"
placeholder="Search authors..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="flex gap-2">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="px-3 py-2 border rounded-lg theme-card border-gray-300 dark:border-gray-600"
>
<option value="name">Name</option>
<option value="rating">Author Rating</option>
<option value="storycount">Story Count</option>
<option value="avgrating">Avg Story Rating</option>
</select>
<Button
variant="ghost"
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
className="px-3 py-2"
>
{sortOrder === 'asc' ? '↑' : '↓'}
</Button>
</div>
</div>
{/* Authors Grid */}
{/* Authors Display */}
{filteredAuthors.length === 0 ? (
<div className="text-center py-20">
<div className="theme-text text-lg mb-4">
@@ -109,100 +186,246 @@ export default function AuthorsPage() {
</p>
)}
</div>
) : (
) : viewMode === 'grid' ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredAuthors.map((author) => {
return (
<Link
key={author.id}
href={`/authors/${author.id}`}
className="theme-card theme-shadow rounded-lg p-6 hover:shadow-lg transition-shadow group"
>
{/* Avatar */}
<div className="flex items-center gap-4 mb-4">
<div className="w-16 h-16 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0">
{author.avatarImagePath ? (
<Image
src={getImageUrl(author.avatarImagePath)}
alt={author.name}
width={64}
height={64}
className="w-full h-full object-cover"
unoptimized
/>
) : (
<div className="w-full h-full flex items-center justify-center text-2xl theme-text">
👤
</div>
)}
</div>
<div className="min-w-0 flex-1">
<h3 className="text-lg font-semibold theme-header group-hover:theme-accent transition-colors truncate">
{author.name}
</h3>
<div className="flex items-center gap-2 mt-1">
{/* Author Rating */}
{author.authorRating && (
<div className="flex items-center gap-1">
<div className="flex">
{[1, 2, 3, 4, 5].map((star) => (
<span
key={star}
className={`text-sm ${
star <= (author.authorRating || 0)
? 'text-yellow-400'
: 'text-gray-300 dark:text-gray-600'
}`}
>
</span>
))}
</div>
<span className="text-sm theme-text">
({author.authorRating}/5)
</span>
</div>
)}
</div>
</div>
</div>
{/* Stats */}
<div className="space-y-2 mb-4">
<div className="flex justify-between items-center text-sm">
<span className="theme-text">Stories:</span>
<span className="font-medium theme-header">
{author.storyCount || 0}
</span>
</div>
{author.urls.length > 0 && (
<div className="flex justify-between items-center text-sm">
<span className="theme-text">Links:</span>
<span className="font-medium theme-header">
{author.urls.length}
</span>
</div>
)}
</div>
{/* Notes Preview */}
{author.notes && (
<div className="text-sm theme-text">
<p className="line-clamp-3">
{author.notes}
</p>
</div>
)}
</Link>
);
})}
{filteredAuthors.map((author) => (
<AuthorGridCard key={author.id} author={author} />
))}
</div>
) : (
<div className="space-y-3">
{filteredAuthors.map((author) => (
<AuthorListItem key={author.id} author={author} />
))}
</div>
)}
{/* Load More Button */}
{hasMore && (
<div className="flex justify-center pt-8">
<Button
onClick={loadMore}
disabled={loading}
variant="ghost"
className="px-8 py-3"
loading={loading}
>
{loading ? 'Loading...' : `Load More Authors (${totalHits - filteredAuthors.length} remaining)`}
</Button>
</div>
)}
</div>
</AppLayout>
);
}
// Author Grid Card Component
function AuthorGridCard({ author }: { author: Author }) {
return (
<Link
href={`/authors/${author.id}`}
className="theme-card theme-shadow rounded-lg p-6 hover:shadow-lg transition-shadow group"
>
{/* Avatar */}
<div className="flex items-center gap-4 mb-4">
<div className="w-16 h-16 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0">
{author.avatarImagePath ? (
<Image
src={getImageUrl(author.avatarImagePath)}
alt={author.name}
width={64}
height={64}
className="w-full h-full object-cover"
unoptimized
/>
) : (
<div className="w-full h-full flex items-center justify-center text-2xl theme-text">
👤
</div>
)}
</div>
<div className="min-w-0 flex-1">
<h3 className="text-lg font-semibold theme-header group-hover:theme-accent transition-colors truncate">
{author.name}
</h3>
<div className="flex items-center gap-2 mt-1">
{/* Author Rating */}
{author.authorRating && (
<div className="flex items-center gap-1">
<div className="flex">
{[1, 2, 3, 4, 5].map((star) => (
<span
key={star}
className={`text-sm ${
star <= (author.authorRating || 0)
? 'text-yellow-400'
: 'text-gray-300 dark:text-gray-600'
}`}
>
</span>
))}
</div>
<span className="text-sm theme-text">
({author.authorRating}/5)
</span>
</div>
)}
{/* Average Story Rating */}
{author.averageStoryRating && (
<div className="flex items-center gap-1">
<div className="flex">
{[1, 2, 3, 4, 5].map((star) => (
<span
key={star}
className={`text-xs ${
star <= Math.round(author.averageStoryRating || 0)
? 'text-blue-400'
: 'text-gray-300 dark:text-gray-600'
}`}
>
</span>
))}
</div>
<span className="text-xs theme-text">
({author.averageStoryRating.toFixed(1)})
</span>
</div>
)}
</div>
</div>
</div>
{/* Stats */}
<div className="space-y-2 mb-4">
<div className="flex justify-between items-center text-sm">
<span className="theme-text">Stories:</span>
<span className="font-medium theme-header">
{author.storyCount || 0}
</span>
</div>
{author.urls && author.urls.length > 0 && (
<div className="flex justify-between items-center text-sm">
<span className="theme-text">Links:</span>
<span className="font-medium theme-header">
{author.urls.length}
</span>
</div>
)}
</div>
{/* Notes Preview */}
{author.notes && (
<div className="text-sm theme-text">
<p className="line-clamp-3">
{author.notes}
</p>
</div>
)}
</Link>
);
}
// Author List Item Component
function AuthorListItem({ author }: { author: Author }) {
return (
<Link
href={`/authors/${author.id}`}
className="theme-card theme-shadow rounded-lg p-4 hover:shadow-lg transition-shadow group flex items-center gap-4"
>
{/* Avatar */}
<div className="w-12 h-12 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0">
{author.avatarImagePath ? (
<Image
src={getImageUrl(author.avatarImagePath)}
alt={author.name}
width={48}
height={48}
className="w-full h-full object-cover"
unoptimized
/>
) : (
<div className="w-full h-full flex items-center justify-center text-xl theme-text">
👤
</div>
)}
</div>
{/* Author Info */}
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold theme-header group-hover:theme-accent transition-colors truncate">
{author.name}
</h3>
<div className="flex items-center gap-4 mt-1">
{/* Ratings */}
<div className="flex items-center gap-3">
{author.authorRating && (
<div className="flex items-center gap-1">
<div className="flex">
{[1, 2, 3, 4, 5].map((star) => (
<span
key={star}
className={`text-sm ${
star <= (author.authorRating || 0)
? 'text-yellow-400'
: 'text-gray-300 dark:text-gray-600'
}`}
>
</span>
))}
</div>
<span className="text-sm theme-text">
({author.authorRating})
</span>
</div>
)}
{author.averageStoryRating && (
<div className="flex items-center gap-1">
<div className="flex">
{[1, 2, 3, 4, 5].map((star) => (
<span
key={star}
className={`text-xs ${
star <= Math.round(author.averageStoryRating || 0)
? 'text-blue-400'
: 'text-gray-300 dark:text-gray-600'
}`}
>
</span>
))}
</div>
<span className="text-xs theme-text">
Avg: {author.averageStoryRating.toFixed(1)}
</span>
</div>
)}
</div>
{/* Stats */}
<div className="flex items-center gap-4 text-sm theme-text">
<span>{author.storyCount || 0} stories</span>
{author.urls && author.urls.length > 0 && (
<span>{author.urls.length} links</span>
)}
</div>
</div>
{/* Notes Preview */}
{author.notes && (
<p className="text-sm theme-text mt-2 line-clamp-2">
{author.notes}
</p>
)}
</div>
</Link>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { storyApi, searchApi, tagApi } from '../../lib/api';
import { searchApi, tagApi } from '../../lib/api';
import { Story, Tag } from '../../types/api';
import AppLayout from '../../components/layout/AppLayout';
import { Input } from '../../components/ui/Input';

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import AppLayout from '../../components/layout/AppLayout';
import { useTheme } from '../../lib/theme';
import Button from '../../components/ui/Button';
import { storyApi, authorApi } from '../../lib/api';
type FontFamily = 'serif' | 'sans' | 'mono';
type FontSize = 'small' | 'medium' | 'large' | 'extra-large';
@@ -27,6 +28,15 @@ export default function SettingsPage() {
const { theme, setTheme } = useTheme();
const [settings, setSettings] = useState<Settings>(defaultSettings);
const [saved, setSaved] = useState(false);
const [typesenseStatus, setTypesenseStatus] = useState<{
stories: { loading: boolean; message: string; success?: boolean };
authors: { loading: boolean; message: string; success?: boolean };
}>({
stories: { loading: false, message: '' },
authors: { loading: false, message: '' }
});
const [authorsSchema, setAuthorsSchema] = useState<any>(null);
const [showSchema, setShowSchema] = useState(false);
// Load settings from localStorage on mount
useEffect(() => {
@@ -85,6 +95,66 @@ export default function SettingsPage() {
setSettings(prev => ({ ...prev, [key]: value }));
};
const handleTypesenseOperation = async (
type: 'stories' | 'authors',
operation: 'reindex' | 'recreate',
apiCall: () => Promise<{ success: boolean; message: string; count?: number; error?: string }>
) => {
setTypesenseStatus(prev => ({
...prev,
[type]: { loading: true, message: 'Processing...', success: undefined }
}));
try {
const result = await apiCall();
setTypesenseStatus(prev => ({
...prev,
[type]: {
loading: false,
message: result.success ? result.message : result.error || 'Operation failed',
success: result.success
}
}));
// Clear message after 5 seconds
setTimeout(() => {
setTypesenseStatus(prev => ({
...prev,
[type]: { loading: false, message: '', success: undefined }
}));
}, 5000);
} catch (error) {
setTypesenseStatus(prev => ({
...prev,
[type]: {
loading: false,
message: 'Network error occurred',
success: false
}
}));
setTimeout(() => {
setTypesenseStatus(prev => ({
...prev,
[type]: { loading: false, message: '', success: undefined }
}));
}, 5000);
}
};
const fetchAuthorsSchema = async () => {
try {
const result = await authorApi.getTypesenseSchema();
if (result.success) {
setAuthorsSchema(result.schema);
} else {
setAuthorsSchema({ error: result.error });
}
} catch (error) {
setAuthorsSchema({ error: 'Failed to fetch schema' });
}
};
return (
<AppLayout>
<div className="max-w-2xl mx-auto space-y-8">
@@ -251,6 +321,119 @@ export default function SettingsPage() {
</div>
</div>
{/* Typesense Search Management */}
<div className="theme-card theme-shadow rounded-lg p-6">
<h2 className="text-xl font-semibold theme-header mb-4">Search Index Management</h2>
<p className="theme-text mb-6">
Manage the Typesense search indexes for stories and authors. Use these tools if search functionality isn't working properly.
</p>
<div className="space-y-6">
{/* Stories Section */}
<div className="border theme-border rounded-lg p-4">
<h3 className="text-lg font-semibold theme-header mb-3">Stories Index</h3>
<div className="flex flex-col sm:flex-row gap-3 mb-3">
<Button
onClick={() => handleTypesenseOperation('stories', 'reindex', storyApi.reindexTypesense)}
disabled={typesenseStatus.stories.loading}
loading={typesenseStatus.stories.loading}
variant="ghost"
className="flex-1"
>
{typesenseStatus.stories.loading ? 'Reindexing...' : 'Reindex Stories'}
</Button>
<Button
onClick={() => handleTypesenseOperation('stories', 'recreate', storyApi.recreateTypesenseCollection)}
disabled={typesenseStatus.stories.loading}
loading={typesenseStatus.stories.loading}
variant="secondary"
className="flex-1"
>
{typesenseStatus.stories.loading ? 'Recreating...' : 'Recreate Collection'}
</Button>
</div>
{typesenseStatus.stories.message && (
<div className={`text-sm p-2 rounded ${
typesenseStatus.stories.success
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
}`}>
{typesenseStatus.stories.message}
</div>
)}
</div>
{/* Authors Section */}
<div className="border theme-border rounded-lg p-4">
<h3 className="text-lg font-semibold theme-header mb-3">Authors Index</h3>
<div className="flex flex-col sm:flex-row gap-3 mb-3">
<Button
onClick={() => handleTypesenseOperation('authors', 'reindex', authorApi.reindexTypesense)}
disabled={typesenseStatus.authors.loading}
loading={typesenseStatus.authors.loading}
variant="ghost"
className="flex-1"
>
{typesenseStatus.authors.loading ? 'Reindexing...' : 'Reindex Authors'}
</Button>
<Button
onClick={() => handleTypesenseOperation('authors', 'recreate', authorApi.recreateTypesenseCollection)}
disabled={typesenseStatus.authors.loading}
loading={typesenseStatus.authors.loading}
variant="secondary"
className="flex-1"
>
{typesenseStatus.authors.loading ? 'Recreating...' : 'Recreate Collection'}
</Button>
</div>
{typesenseStatus.authors.message && (
<div className={`text-sm p-2 rounded ${
typesenseStatus.authors.success
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
}`}>
{typesenseStatus.authors.message}
</div>
)}
{/* Debug Schema Section */}
<div className="border-t theme-border pt-3">
<div className="flex items-center gap-2 mb-2">
<Button
onClick={fetchAuthorsSchema}
variant="ghost"
className="text-xs"
>
Inspect Schema
</Button>
<Button
onClick={() => setShowSchema(!showSchema)}
variant="ghost"
className="text-xs"
disabled={!authorsSchema}
>
{showSchema ? 'Hide' : 'Show'} Schema
</Button>
</div>
{showSchema && authorsSchema && (
<div className="text-xs theme-text bg-gray-50 dark:bg-gray-800 p-3 rounded border overflow-auto max-h-48">
<pre>{JSON.stringify(authorsSchema, null, 2)}</pre>
</div>
)}
</div>
</div>
<div className="text-sm theme-text bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg">
<p className="font-medium mb-1">When to use these tools:</p>
<ul className="text-xs space-y-1 ml-4">
<li>• <strong>Reindex:</strong> Refresh search data while keeping the existing schema</li>
<li>• <strong>Recreate Collection:</strong> Delete and rebuild the entire search index (fixes schema issues)</li>
</ul>
</div>
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-4">
<Button

View File

@@ -135,8 +135,8 @@ export default function EditStoryPage() {
contentHtml: formData.contentHtml,
sourceUrl: formData.sourceUrl || undefined,
volume: formData.seriesName ? parseInt(formData.volume) : undefined,
seriesName: formData.seriesName || undefined,
authorId: story.authorId, // Keep existing author ID
seriesId: story.seriesId, // Keep existing series ID for now
tagNames: formData.tags,
};
@@ -298,11 +298,7 @@ export default function EditStoryPage() {
onChange={handleInputChange('seriesName')}
placeholder="Enter series name if part of a series"
error={errors.seriesName}
disabled
/>
<p className="text-sm theme-text mt-1">
Series changes not yet supported in edit mode
</p>
</div>
<Input

View File

@@ -82,6 +82,7 @@ export const storyApi = {
authorId?: string;
authorName?: string;
seriesId?: string;
seriesName?: string;
tagNames?: string[];
}): Promise<Story> => {
const response = await api.post('/stories', storyData);
@@ -97,6 +98,7 @@ export const storyApi = {
volume?: number;
authorId?: string;
seriesId?: string;
seriesName?: string;
tagNames?: string[];
}): Promise<Story> => {
const response = await api.put(`/stories/${id}`, storyData);
@@ -133,6 +135,16 @@ export const storyApi = {
const response = await api.delete(`/stories/${storyId}/tags/${tagId}`);
return response.data;
},
reindexTypesense: async (): Promise<{ success: boolean; message: string; count?: number; error?: string }> => {
const response = await api.post('/stories/reindex-typesense');
return response.data;
},
recreateTypesenseCollection: async (): Promise<{ success: boolean; message: string; count?: number; error?: string }> => {
const response = await api.post('/stories/recreate-typesense-collection');
return response.data;
},
};
// Author endpoints
@@ -171,6 +183,39 @@ export const authorApi = {
removeAvatar: async (id: string): Promise<void> => {
await api.delete(`/authors/${id}/avatar`);
},
searchAuthorsTypesense: async (params?: {
q?: string;
page?: number;
size?: number;
sortBy?: string;
sortOrder?: string;
}): Promise<{
results: Author[];
totalHits: number;
page: number;
perPage: number;
query: string;
searchTimeMs: number;
}> => {
const response = await api.get('/authors/search-typesense', { params });
return response.data;
},
reindexTypesense: async (): Promise<{ success: boolean; message: string; count?: number; error?: string }> => {
const response = await api.post('/authors/reindex-typesense');
return response.data;
},
recreateTypesenseCollection: async (): Promise<{ success: boolean; message: string; count?: number; error?: string }> => {
const response = await api.post('/authors/recreate-typesense-collection');
return response.data;
},
getTypesenseSchema: async (): Promise<{ success: boolean; schema?: any; error?: string }> => {
const response = await api.get('/authors/typesense-schema');
return response.data;
},
};
// Tag endpoints

View File

@@ -23,6 +23,7 @@ export interface Author {
name: string;
notes?: string;
authorRating?: number;
averageStoryRating?: number;
avatarImagePath?: string;
urls: string[];
storyCount: number;

File diff suppressed because one or more lines are too long