Tag Enhancement + bugfixes
This commit is contained in:
@@ -335,6 +335,44 @@ public class AuthorController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/clean-author-names")
|
||||
public ResponseEntity<Map<String, Object>> cleanAuthorNames() {
|
||||
try {
|
||||
List<Author> allAuthors = authorService.findAllWithStories();
|
||||
int cleanedCount = 0;
|
||||
|
||||
for (Author author : allAuthors) {
|
||||
String originalName = author.getName();
|
||||
String cleanedName = originalName != null ? originalName.trim() : "";
|
||||
|
||||
if (!cleanedName.equals(originalName)) {
|
||||
logger.info("Cleaning author name: '{}' -> '{}'", originalName, cleanedName);
|
||||
author.setName(cleanedName);
|
||||
authorService.update(author.getId(), author);
|
||||
cleanedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Reindex all authors after cleaning
|
||||
if (cleanedCount > 0) {
|
||||
typesenseService.reindexAllAuthors(allAuthors);
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"message", "Cleaned " + cleanedCount + " author names and reindexed",
|
||||
"cleanedCount", cleanedCount,
|
||||
"totalAuthors", allAuthors.size()
|
||||
));
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to clean author names", e);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", false,
|
||||
"error", e.getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/top-rated")
|
||||
public ResponseEntity<List<AuthorSummaryDto>> getTopRatedAuthors(@RequestParam(defaultValue = "10") int limit) {
|
||||
Pageable pageable = PageRequest.of(0, limit);
|
||||
|
||||
@@ -420,9 +420,7 @@ public class StoryController {
|
||||
if (updateReq.getSourceUrl() != null) {
|
||||
story.setSourceUrl(updateReq.getSourceUrl());
|
||||
}
|
||||
if (updateReq.getVolume() != null) {
|
||||
story.setVolume(updateReq.getVolume());
|
||||
}
|
||||
// Volume will be handled in series logic below
|
||||
// Handle author - either by ID or by name
|
||||
if (updateReq.getAuthorId() != null) {
|
||||
Author author = authorService.findById(updateReq.getAuthorId());
|
||||
@@ -431,13 +429,34 @@ public class StoryController {
|
||||
Author author = findOrCreateAuthor(updateReq.getAuthorName().trim());
|
||||
story.setAuthor(author);
|
||||
}
|
||||
// Handle series - either by ID or by name
|
||||
// Handle series - either by ID, by name, or remove from series
|
||||
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);
|
||||
} else if (updateReq.getSeriesName() != null) {
|
||||
logger.info("Processing series update: seriesName='{}', isEmpty={}", updateReq.getSeriesName(), updateReq.getSeriesName().trim().isEmpty());
|
||||
if (updateReq.getSeriesName().trim().isEmpty()) {
|
||||
// Empty series name means remove from series
|
||||
logger.info("Removing story from series");
|
||||
if (story.getSeries() != null) {
|
||||
story.getSeries().removeStory(story);
|
||||
story.setSeries(null);
|
||||
story.setVolume(null);
|
||||
logger.info("Story removed from series");
|
||||
}
|
||||
} else {
|
||||
// Non-empty series name means add to series
|
||||
logger.info("Adding story to series: '{}', volume: {}", updateReq.getSeriesName().trim(), updateReq.getVolume());
|
||||
Series series = seriesService.findOrCreate(updateReq.getSeriesName().trim());
|
||||
story.setSeries(series);
|
||||
// Set volume only if series is being set
|
||||
if (updateReq.getVolume() != null) {
|
||||
story.setVolume(updateReq.getVolume());
|
||||
logger.info("Story added to series: {} with volume: {}", series.getName(), updateReq.getVolume());
|
||||
} else {
|
||||
logger.info("Story added to series: {} with no volume", series.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Tags are now handled in StoryService.updateWithTagNames()
|
||||
@@ -559,8 +578,11 @@ public class StoryController {
|
||||
TagDto tagDto = new TagDto();
|
||||
tagDto.setId(tag.getId());
|
||||
tagDto.setName(tag.getName());
|
||||
tagDto.setColor(tag.getColor());
|
||||
tagDto.setDescription(tag.getDescription());
|
||||
tagDto.setCreatedAt(tag.getCreatedAt());
|
||||
// storyCount can be set if needed, but it might be expensive to calculate for each tag
|
||||
tagDto.setStoryCount(tag.getStories() != null ? tag.getStories().size() : 0);
|
||||
tagDto.setAliasCount(tag.getAliases() != null ? tag.getAliases().size() : 0);
|
||||
return tagDto;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package com.storycove.controller;
|
||||
|
||||
import com.storycove.dto.TagDto;
|
||||
import com.storycove.dto.TagAliasDto;
|
||||
import com.storycove.entity.Tag;
|
||||
import com.storycove.entity.TagAlias;
|
||||
import com.storycove.service.TagService;
|
||||
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;
|
||||
@@ -21,6 +25,7 @@ import java.util.stream.Collectors;
|
||||
@RequestMapping("/api/tags")
|
||||
public class TagController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(TagController.class);
|
||||
private final TagService tagService;
|
||||
|
||||
public TagController(TagService tagService) {
|
||||
@@ -54,6 +59,8 @@ public class TagController {
|
||||
public ResponseEntity<TagDto> createTag(@Valid @RequestBody CreateTagRequest request) {
|
||||
Tag tag = new Tag();
|
||||
tag.setName(request.getName());
|
||||
tag.setColor(request.getColor());
|
||||
tag.setDescription(request.getDescription());
|
||||
|
||||
Tag savedTag = tagService.create(tag);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(convertToDto(savedTag));
|
||||
@@ -66,6 +73,12 @@ public class TagController {
|
||||
if (request.getName() != null) {
|
||||
existingTag.setName(request.getName());
|
||||
}
|
||||
if (request.getColor() != null) {
|
||||
existingTag.setColor(request.getColor());
|
||||
}
|
||||
if (request.getDescription() != null) {
|
||||
existingTag.setDescription(request.getDescription());
|
||||
}
|
||||
|
||||
Tag updatedTag = tagService.update(id, existingTag);
|
||||
return ResponseEntity.ok(convertToDto(updatedTag));
|
||||
@@ -95,7 +108,7 @@ public class TagController {
|
||||
@RequestParam String query,
|
||||
@RequestParam(defaultValue = "10") int limit) {
|
||||
|
||||
List<Tag> tags = tagService.findByNameStartingWith(query, limit);
|
||||
List<Tag> tags = tagService.findByNameOrAliasStartingWith(query, limit);
|
||||
List<TagDto> tagDtos = tags.stream().map(this::convertToDto).collect(Collectors.toList());
|
||||
|
||||
return ResponseEntity.ok(tagDtos);
|
||||
@@ -142,15 +155,124 @@ public class TagController {
|
||||
return ResponseEntity.ok(tagDtos);
|
||||
}
|
||||
|
||||
// Tag alias endpoints
|
||||
@PostMapping("/{tagId}/aliases")
|
||||
public ResponseEntity<TagAliasDto> addAlias(@PathVariable UUID tagId,
|
||||
@RequestBody Map<String, String> request) {
|
||||
String aliasName = request.get("aliasName");
|
||||
if (aliasName == null || aliasName.trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
try {
|
||||
TagAlias alias = tagService.addAlias(tagId, aliasName.trim());
|
||||
TagAliasDto dto = new TagAliasDto();
|
||||
dto.setId(alias.getId());
|
||||
dto.setAliasName(alias.getAliasName());
|
||||
dto.setCanonicalTagId(alias.getCanonicalTag().getId());
|
||||
dto.setCanonicalTagName(alias.getCanonicalTag().getName());
|
||||
dto.setCreatedFromMerge(alias.getCreatedFromMerge());
|
||||
dto.setCreatedAt(alias.getCreatedAt());
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(dto);
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/{tagId}/aliases/{aliasId}")
|
||||
public ResponseEntity<?> removeAlias(@PathVariable UUID tagId, @PathVariable UUID aliasId) {
|
||||
try {
|
||||
tagService.removeAlias(tagId, aliasId);
|
||||
return ResponseEntity.ok(Map.of("message", "Alias removed successfully"));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/resolve/{name}")
|
||||
public ResponseEntity<TagDto> resolveTag(@PathVariable String name) {
|
||||
try {
|
||||
Tag resolvedTag = tagService.resolveTagByName(name);
|
||||
if (resolvedTag != null) {
|
||||
return ResponseEntity.ok(convertToDto(resolvedTag));
|
||||
} else {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/merge")
|
||||
public ResponseEntity<?> mergeTags(@Valid @RequestBody MergeTagsRequest request) {
|
||||
try {
|
||||
Tag resultTag = tagService.mergeTags(request.getSourceTagUUIDs(), request.getTargetTagUUID());
|
||||
return ResponseEntity.ok(convertToDto(resultTag));
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to merge tags", e);
|
||||
String errorMessage = e.getMessage() != null ? e.getMessage() : "Unknown error occurred";
|
||||
return ResponseEntity.badRequest().body(Map.of("error", errorMessage));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/merge/preview")
|
||||
public ResponseEntity<?> previewMerge(@Valid @RequestBody MergeTagsRequest request) {
|
||||
try {
|
||||
MergePreviewResponse preview = tagService.previewMerge(request.getSourceTagUUIDs(), request.getTargetTagUUID());
|
||||
return ResponseEntity.ok(preview);
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to preview merge", e);
|
||||
String errorMessage = e.getMessage() != null ? e.getMessage() : "Unknown error occurred";
|
||||
return ResponseEntity.badRequest().body(Map.of("error", errorMessage));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/suggest")
|
||||
public ResponseEntity<List<TagSuggestion>> suggestTags(@RequestBody TagSuggestionRequest request) {
|
||||
try {
|
||||
List<TagSuggestion> suggestions = tagService.suggestTags(
|
||||
request.getTitle(),
|
||||
request.getContent(),
|
||||
request.getSummary(),
|
||||
request.getLimit() != null ? request.getLimit() : 10
|
||||
);
|
||||
return ResponseEntity.ok(suggestions);
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to suggest tags", e);
|
||||
return ResponseEntity.ok(List.of()); // Return empty list on error
|
||||
}
|
||||
}
|
||||
|
||||
private TagDto convertToDto(Tag tag) {
|
||||
TagDto dto = new TagDto();
|
||||
dto.setId(tag.getId());
|
||||
dto.setName(tag.getName());
|
||||
dto.setColor(tag.getColor());
|
||||
dto.setDescription(tag.getDescription());
|
||||
dto.setStoryCount(tag.getStories() != null ? tag.getStories().size() : 0);
|
||||
dto.setCollectionCount(tag.getCollections() != null ? tag.getCollections().size() : 0);
|
||||
dto.setAliasCount(tag.getAliases() != null ? tag.getAliases().size() : 0);
|
||||
dto.setCreatedAt(tag.getCreatedAt());
|
||||
// updatedAt field not present in Tag entity per spec
|
||||
|
||||
// Convert aliases to DTOs for full context
|
||||
if (tag.getAliases() != null && !tag.getAliases().isEmpty()) {
|
||||
List<TagAliasDto> aliaseDtos = tag.getAliases().stream()
|
||||
.map(alias -> {
|
||||
TagAliasDto aliasDto = new TagAliasDto();
|
||||
aliasDto.setId(alias.getId());
|
||||
aliasDto.setAliasName(alias.getAliasName());
|
||||
aliasDto.setCanonicalTagId(alias.getCanonicalTag().getId());
|
||||
aliasDto.setCanonicalTagName(alias.getCanonicalTag().getName());
|
||||
aliasDto.setCreatedFromMerge(alias.getCreatedFromMerge());
|
||||
aliasDto.setCreatedAt(alias.getCreatedAt());
|
||||
return aliasDto;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
dto.setAliases(aliaseDtos);
|
||||
}
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
@@ -168,15 +290,112 @@ public class TagController {
|
||||
// Request DTOs
|
||||
public static class CreateTagRequest {
|
||||
private String name;
|
||||
private String color;
|
||||
private String description;
|
||||
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
|
||||
public String getColor() { return color; }
|
||||
public void setColor(String color) { this.color = color; }
|
||||
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
}
|
||||
|
||||
public static class UpdateTagRequest {
|
||||
private String name;
|
||||
private String color;
|
||||
private String description;
|
||||
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
|
||||
public String getColor() { return color; }
|
||||
public void setColor(String color) { this.color = color; }
|
||||
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
}
|
||||
|
||||
public static class MergeTagsRequest {
|
||||
private List<String> sourceTagIds;
|
||||
private String targetTagId;
|
||||
|
||||
public List<String> getSourceTagIds() { return sourceTagIds; }
|
||||
public void setSourceTagIds(List<String> sourceTagIds) { this.sourceTagIds = sourceTagIds; }
|
||||
|
||||
public String getTargetTagId() { return targetTagId; }
|
||||
public void setTargetTagId(String targetTagId) { this.targetTagId = targetTagId; }
|
||||
|
||||
// Helper methods to convert to UUID
|
||||
public List<UUID> getSourceTagUUIDs() {
|
||||
return sourceTagIds != null ? sourceTagIds.stream().map(UUID::fromString).toList() : null;
|
||||
}
|
||||
|
||||
public UUID getTargetTagUUID() {
|
||||
return targetTagId != null ? UUID.fromString(targetTagId) : null;
|
||||
}
|
||||
}
|
||||
|
||||
public static class MergePreviewResponse {
|
||||
private String targetTagName;
|
||||
private int targetStoryCount;
|
||||
private int totalResultStoryCount;
|
||||
private List<String> aliasesToCreate;
|
||||
|
||||
public String getTargetTagName() { return targetTagName; }
|
||||
public void setTargetTagName(String targetTagName) { this.targetTagName = targetTagName; }
|
||||
|
||||
public int getTargetStoryCount() { return targetStoryCount; }
|
||||
public void setTargetStoryCount(int targetStoryCount) { this.targetStoryCount = targetStoryCount; }
|
||||
|
||||
public int getTotalResultStoryCount() { return totalResultStoryCount; }
|
||||
public void setTotalResultStoryCount(int totalResultStoryCount) { this.totalResultStoryCount = totalResultStoryCount; }
|
||||
|
||||
public List<String> getAliasesToCreate() { return aliasesToCreate; }
|
||||
public void setAliasesToCreate(List<String> aliasesToCreate) { this.aliasesToCreate = aliasesToCreate; }
|
||||
}
|
||||
|
||||
public static class TagSuggestionRequest {
|
||||
private String title;
|
||||
private String content;
|
||||
private String summary;
|
||||
private Integer limit;
|
||||
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
|
||||
public String getContent() { return content; }
|
||||
public void setContent(String content) { this.content = content; }
|
||||
|
||||
public String getSummary() { return summary; }
|
||||
public void setSummary(String summary) { this.summary = summary; }
|
||||
|
||||
public Integer getLimit() { return limit; }
|
||||
public void setLimit(Integer limit) { this.limit = limit; }
|
||||
}
|
||||
|
||||
public static class TagSuggestion {
|
||||
private String tagName;
|
||||
private double confidence;
|
||||
private String reason;
|
||||
|
||||
public TagSuggestion() {}
|
||||
|
||||
public TagSuggestion(String tagName, double confidence, String reason) {
|
||||
this.tagName = tagName;
|
||||
this.confidence = confidence;
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public String getTagName() { return tagName; }
|
||||
public void setTagName(String tagName) { this.tagName = tagName; }
|
||||
|
||||
public double getConfidence() { return confidence; }
|
||||
public void setConfidence(double confidence) { this.confidence = confidence; }
|
||||
|
||||
public String getReason() { return reason; }
|
||||
public void setReason(String reason) { this.reason = reason; }
|
||||
}
|
||||
}
|
||||
77
backend/src/main/java/com/storycove/dto/TagAliasDto.java
Normal file
77
backend/src/main/java/com/storycove/dto/TagAliasDto.java
Normal file
@@ -0,0 +1,77 @@
|
||||
package com.storycove.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
public class TagAliasDto {
|
||||
|
||||
private UUID id;
|
||||
|
||||
@NotBlank(message = "Alias name is required")
|
||||
@Size(max = 100, message = "Alias name must not exceed 100 characters")
|
||||
private String aliasName;
|
||||
|
||||
private UUID canonicalTagId;
|
||||
private String canonicalTagName; // For convenience in frontend
|
||||
private Boolean createdFromMerge;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
public TagAliasDto() {}
|
||||
|
||||
public TagAliasDto(String aliasName, UUID canonicalTagId) {
|
||||
this.aliasName = aliasName;
|
||||
this.canonicalTagId = canonicalTagId;
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
public UUID getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(UUID id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getAliasName() {
|
||||
return aliasName;
|
||||
}
|
||||
|
||||
public void setAliasName(String aliasName) {
|
||||
this.aliasName = aliasName;
|
||||
}
|
||||
|
||||
public UUID getCanonicalTagId() {
|
||||
return canonicalTagId;
|
||||
}
|
||||
|
||||
public void setCanonicalTagId(UUID canonicalTagId) {
|
||||
this.canonicalTagId = canonicalTagId;
|
||||
}
|
||||
|
||||
public String getCanonicalTagName() {
|
||||
return canonicalTagName;
|
||||
}
|
||||
|
||||
public void setCanonicalTagName(String canonicalTagName) {
|
||||
this.canonicalTagName = canonicalTagName;
|
||||
}
|
||||
|
||||
public Boolean getCreatedFromMerge() {
|
||||
return createdFromMerge;
|
||||
}
|
||||
|
||||
public void setCreatedFromMerge(Boolean createdFromMerge) {
|
||||
this.createdFromMerge = createdFromMerge;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public class TagDto {
|
||||
@@ -14,8 +15,16 @@ public class TagDto {
|
||||
@Size(max = 100, message = "Tag name must not exceed 100 characters")
|
||||
private String name;
|
||||
|
||||
@Size(max = 7, message = "Color must be a valid hex color code")
|
||||
private String color;
|
||||
|
||||
@Size(max = 500, message = "Description must not exceed 500 characters")
|
||||
private String description;
|
||||
|
||||
private Integer storyCount;
|
||||
private Integer collectionCount;
|
||||
private Integer aliasCount;
|
||||
private List<TagAliasDto> aliases;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@@ -42,6 +51,22 @@ public class TagDto {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getColor() {
|
||||
return color;
|
||||
}
|
||||
|
||||
public void setColor(String color) {
|
||||
this.color = color;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public Integer getStoryCount() {
|
||||
return storyCount;
|
||||
}
|
||||
@@ -58,6 +83,22 @@ public class TagDto {
|
||||
this.collectionCount = collectionCount;
|
||||
}
|
||||
|
||||
public Integer getAliasCount() {
|
||||
return aliasCount;
|
||||
}
|
||||
|
||||
public void setAliasCount(Integer aliasCount) {
|
||||
this.aliasCount = aliasCount;
|
||||
}
|
||||
|
||||
public List<TagAliasDto> getAliases() {
|
||||
return aliases;
|
||||
}
|
||||
|
||||
public void setAliases(List<TagAliasDto> aliases) {
|
||||
this.aliases = aliases;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import com.fasterxml.jackson.annotation.JsonBackReference;
|
||||
import com.fasterxml.jackson.annotation.JsonManagedReference;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
@@ -24,6 +25,14 @@ public class Tag {
|
||||
@Column(nullable = false, unique = true)
|
||||
private String name;
|
||||
|
||||
@Size(max = 7, message = "Color must be a valid hex color code")
|
||||
@Column(length = 7)
|
||||
private String color; // hex color like #3B82F6
|
||||
|
||||
@Size(max = 500, message = "Description must not exceed 500 characters")
|
||||
@Column(length = 500)
|
||||
private String description;
|
||||
|
||||
|
||||
@ManyToMany(mappedBy = "tags")
|
||||
@JsonBackReference("story-tags")
|
||||
@@ -33,6 +42,10 @@ public class Tag {
|
||||
@JsonBackReference("collection-tags")
|
||||
private Set<Collection> collections = new HashSet<>();
|
||||
|
||||
@OneToMany(mappedBy = "canonicalTag", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@JsonManagedReference("tag-aliases")
|
||||
private Set<TagAlias> aliases = new HashSet<>();
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
@@ -43,6 +56,12 @@ public class Tag {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Tag(String name, String color, String description) {
|
||||
this.name = name;
|
||||
this.color = color;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Getters and Setters
|
||||
@@ -62,6 +81,22 @@ public class Tag {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getColor() {
|
||||
return color;
|
||||
}
|
||||
|
||||
public void setColor(String color) {
|
||||
this.color = color;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
|
||||
public Set<Story> getStories() {
|
||||
return stories;
|
||||
@@ -79,6 +114,14 @@ public class Tag {
|
||||
this.collections = collections;
|
||||
}
|
||||
|
||||
public Set<TagAlias> getAliases() {
|
||||
return aliases;
|
||||
}
|
||||
|
||||
public void setAliases(Set<TagAlias> aliases) {
|
||||
this.aliases = aliases;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
113
backend/src/main/java/com/storycove/entity/TagAlias.java
Normal file
113
backend/src/main/java/com/storycove/entity/TagAlias.java
Normal file
@@ -0,0 +1,113 @@
|
||||
package com.storycove.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import com.fasterxml.jackson.annotation.JsonManagedReference;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "tag_aliases")
|
||||
public class TagAlias {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@NotBlank(message = "Alias name is required")
|
||||
@Size(max = 100, message = "Alias name must not exceed 100 characters")
|
||||
@Column(name = "alias_name", nullable = false, unique = true)
|
||||
private String aliasName;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "canonical_tag_id", nullable = false)
|
||||
@JsonManagedReference("tag-aliases")
|
||||
private Tag canonicalTag;
|
||||
|
||||
@Column(name = "created_from_merge", nullable = false)
|
||||
private Boolean createdFromMerge = false;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
public TagAlias() {}
|
||||
|
||||
public TagAlias(String aliasName, Tag canonicalTag) {
|
||||
this.aliasName = aliasName;
|
||||
this.canonicalTag = canonicalTag;
|
||||
}
|
||||
|
||||
public TagAlias(String aliasName, Tag canonicalTag, Boolean createdFromMerge) {
|
||||
this.aliasName = aliasName;
|
||||
this.canonicalTag = canonicalTag;
|
||||
this.createdFromMerge = createdFromMerge;
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
public UUID getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(UUID id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getAliasName() {
|
||||
return aliasName;
|
||||
}
|
||||
|
||||
public void setAliasName(String aliasName) {
|
||||
this.aliasName = aliasName;
|
||||
}
|
||||
|
||||
public Tag getCanonicalTag() {
|
||||
return canonicalTag;
|
||||
}
|
||||
|
||||
public void setCanonicalTag(Tag canonicalTag) {
|
||||
this.canonicalTag = canonicalTag;
|
||||
}
|
||||
|
||||
public Boolean getCreatedFromMerge() {
|
||||
return createdFromMerge;
|
||||
}
|
||||
|
||||
public void setCreatedFromMerge(Boolean createdFromMerge) {
|
||||
this.createdFromMerge = createdFromMerge;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof TagAlias)) return false;
|
||||
TagAlias tagAlias = (TagAlias) o;
|
||||
return id != null && id.equals(tagAlias.id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return getClass().hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "TagAlias{" +
|
||||
"id=" + id +
|
||||
", aliasName='" + aliasName + '\'' +
|
||||
", canonicalTag=" + (canonicalTag != null ? canonicalTag.getName() : null) +
|
||||
", createdFromMerge=" + createdFromMerge +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.storycove.repository;
|
||||
|
||||
import com.storycove.entity.TagAlias;
|
||||
import com.storycove.entity.Tag;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface TagAliasRepository extends JpaRepository<TagAlias, UUID> {
|
||||
|
||||
/**
|
||||
* Find alias by exact alias name (case-insensitive)
|
||||
*/
|
||||
@Query("SELECT ta FROM TagAlias ta WHERE LOWER(ta.aliasName) = LOWER(:aliasName)")
|
||||
Optional<TagAlias> findByAliasNameIgnoreCase(@Param("aliasName") String aliasName);
|
||||
|
||||
/**
|
||||
* Find all aliases for a specific canonical tag
|
||||
*/
|
||||
List<TagAlias> findByCanonicalTag(Tag canonicalTag);
|
||||
|
||||
/**
|
||||
* Find all aliases for a specific canonical tag ID
|
||||
*/
|
||||
@Query("SELECT ta FROM TagAlias ta WHERE ta.canonicalTag.id = :tagId")
|
||||
List<TagAlias> findByCanonicalTagId(@Param("tagId") UUID tagId);
|
||||
|
||||
/**
|
||||
* Find aliases created from merge operations
|
||||
*/
|
||||
List<TagAlias> findByCreatedFromMergeTrue();
|
||||
|
||||
/**
|
||||
* Check if an alias name already exists
|
||||
*/
|
||||
boolean existsByAliasNameIgnoreCase(String aliasName);
|
||||
|
||||
/**
|
||||
* Delete all aliases for a specific tag
|
||||
*/
|
||||
void deleteByCanonicalTag(Tag canonicalTag);
|
||||
|
||||
/**
|
||||
* Count aliases for a specific tag
|
||||
*/
|
||||
@Query("SELECT COUNT(ta) FROM TagAlias ta WHERE ta.canonicalTag.id = :tagId")
|
||||
long countByCanonicalTagId(@Param("tagId") UUID tagId);
|
||||
|
||||
/**
|
||||
* Find aliases that start with the given prefix (case-insensitive)
|
||||
*/
|
||||
@Query("SELECT ta FROM TagAlias ta WHERE LOWER(ta.aliasName) LIKE LOWER(CONCAT(:prefix, '%'))")
|
||||
List<TagAlias> findByAliasNameStartingWithIgnoreCase(@Param("prefix") String prefix);
|
||||
}
|
||||
@@ -17,8 +17,12 @@ public interface TagRepository extends JpaRepository<Tag, UUID> {
|
||||
|
||||
Optional<Tag> findByName(String name);
|
||||
|
||||
Optional<Tag> findByNameIgnoreCase(String name);
|
||||
|
||||
boolean existsByName(String name);
|
||||
|
||||
boolean existsByNameIgnoreCase(String name);
|
||||
|
||||
List<Tag> findByNameContainingIgnoreCase(String name);
|
||||
|
||||
Page<Tag> findByNameContainingIgnoreCase(String name, Pageable pageable);
|
||||
|
||||
@@ -620,9 +620,24 @@ public class StoryService {
|
||||
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) {
|
||||
if (updateReq.getSeriesName().trim().isEmpty()) {
|
||||
// Empty series name means remove from series
|
||||
story.setSeries(null);
|
||||
} else {
|
||||
// Find or create series by name
|
||||
Series series = seriesService.findByNameOptional(updateReq.getSeriesName().trim())
|
||||
.orElseGet(() -> {
|
||||
Series newSeries = new Series();
|
||||
newSeries.setName(updateReq.getSeriesName().trim());
|
||||
return seriesService.create(newSeries);
|
||||
});
|
||||
story.setSeries(series);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.storycove.service;
|
||||
|
||||
import com.storycove.entity.Story;
|
||||
import com.storycove.entity.Tag;
|
||||
import com.storycove.entity.TagAlias;
|
||||
import com.storycove.repository.TagRepository;
|
||||
import com.storycove.repository.TagAliasRepository;
|
||||
import com.storycove.service.exception.DuplicateResourceException;
|
||||
import com.storycove.service.exception.ResourceNotFoundException;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -12,8 +15,11 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@@ -22,10 +28,12 @@ import java.util.UUID;
|
||||
public class TagService {
|
||||
|
||||
private final TagRepository tagRepository;
|
||||
private final TagAliasRepository tagAliasRepository;
|
||||
|
||||
@Autowired
|
||||
public TagService(TagRepository tagRepository) {
|
||||
public TagService(TagRepository tagRepository, TagAliasRepository tagAliasRepository) {
|
||||
this.tagRepository = tagRepository;
|
||||
this.tagAliasRepository = tagAliasRepository;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -207,5 +215,269 @@ public class TagService {
|
||||
if (updates.getName() != null) {
|
||||
existing.setName(updates.getName());
|
||||
}
|
||||
if (updates.getColor() != null) {
|
||||
existing.setColor(updates.getColor());
|
||||
}
|
||||
if (updates.getDescription() != null) {
|
||||
existing.setDescription(updates.getDescription());
|
||||
}
|
||||
}
|
||||
|
||||
// Tag alias management methods
|
||||
|
||||
public TagAlias addAlias(UUID tagId, String aliasName) {
|
||||
Tag canonicalTag = findById(tagId);
|
||||
|
||||
// Check if alias already exists (case-insensitive)
|
||||
if (tagAliasRepository.existsByAliasNameIgnoreCase(aliasName)) {
|
||||
throw new DuplicateResourceException("Tag alias", aliasName);
|
||||
}
|
||||
|
||||
// Check if alias name conflicts with existing tag names
|
||||
if (tagRepository.existsByNameIgnoreCase(aliasName)) {
|
||||
throw new DuplicateResourceException("Tag alias conflicts with existing tag name", aliasName);
|
||||
}
|
||||
|
||||
TagAlias alias = new TagAlias();
|
||||
alias.setAliasName(aliasName);
|
||||
alias.setCanonicalTag(canonicalTag);
|
||||
alias.setCreatedFromMerge(false);
|
||||
|
||||
return tagAliasRepository.save(alias);
|
||||
}
|
||||
|
||||
public void removeAlias(UUID tagId, UUID aliasId) {
|
||||
Tag canonicalTag = findById(tagId);
|
||||
TagAlias alias = tagAliasRepository.findById(aliasId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Tag alias", aliasId.toString()));
|
||||
|
||||
// Verify the alias belongs to the specified tag
|
||||
if (!alias.getCanonicalTag().getId().equals(tagId)) {
|
||||
throw new IllegalArgumentException("Alias does not belong to the specified tag");
|
||||
}
|
||||
|
||||
tagAliasRepository.delete(alias);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Tag resolveTagByName(String name) {
|
||||
// First try to find exact tag match
|
||||
Optional<Tag> directMatch = tagRepository.findByNameIgnoreCase(name);
|
||||
if (directMatch.isPresent()) {
|
||||
return directMatch.get();
|
||||
}
|
||||
|
||||
// Then try to find by alias
|
||||
Optional<TagAlias> aliasMatch = tagAliasRepository.findByAliasNameIgnoreCase(name);
|
||||
if (aliasMatch.isPresent()) {
|
||||
return aliasMatch.get().getCanonicalTag();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Tag mergeTags(List<UUID> sourceTagIds, UUID targetTagId) {
|
||||
// Validate target tag exists
|
||||
Tag targetTag = findById(targetTagId);
|
||||
|
||||
// Validate source tags exist and are different from target
|
||||
List<Tag> sourceTags = sourceTagIds.stream()
|
||||
.filter(id -> !id.equals(targetTagId)) // Don't merge tag with itself
|
||||
.map(this::findById)
|
||||
.toList();
|
||||
|
||||
if (sourceTags.isEmpty()) {
|
||||
throw new IllegalArgumentException("No valid source tags to merge");
|
||||
}
|
||||
|
||||
// Perform the merge atomically
|
||||
for (Tag sourceTag : sourceTags) {
|
||||
// Move all stories from source tag to target tag
|
||||
// Create a copy to avoid ConcurrentModificationException
|
||||
List<Story> storiesToMove = new ArrayList<>(sourceTag.getStories());
|
||||
storiesToMove.forEach(story -> {
|
||||
story.removeTag(sourceTag);
|
||||
story.addTag(targetTag);
|
||||
});
|
||||
|
||||
// Create alias for the source tag name
|
||||
TagAlias alias = new TagAlias();
|
||||
alias.setAliasName(sourceTag.getName());
|
||||
alias.setCanonicalTag(targetTag);
|
||||
alias.setCreatedFromMerge(true);
|
||||
tagAliasRepository.save(alias);
|
||||
|
||||
// Delete the source tag
|
||||
tagRepository.delete(sourceTag);
|
||||
}
|
||||
|
||||
return tagRepository.save(targetTag);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<Tag> findByNameOrAliasStartingWith(String query, int limit) {
|
||||
// Find tags that start with the query
|
||||
List<Tag> directMatches = tagRepository.findByNameStartingWithIgnoreCase(query.toLowerCase());
|
||||
|
||||
// Find tags via aliases that start with the query
|
||||
List<TagAlias> aliasMatches = tagAliasRepository.findByAliasNameStartingWithIgnoreCase(query.toLowerCase());
|
||||
List<Tag> aliasTagMatches = aliasMatches.stream()
|
||||
.map(TagAlias::getCanonicalTag)
|
||||
.distinct()
|
||||
.toList();
|
||||
|
||||
// Combine and deduplicate
|
||||
Set<Tag> allMatches = new HashSet<>(directMatches);
|
||||
allMatches.addAll(aliasTagMatches);
|
||||
|
||||
// Convert to list and limit results
|
||||
return allMatches.stream()
|
||||
.sorted((a, b) -> a.getName().compareToIgnoreCase(b.getName()))
|
||||
.limit(limit)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public com.storycove.controller.TagController.MergePreviewResponse previewMerge(List<UUID> sourceTagIds, UUID targetTagId) {
|
||||
// Validate target tag exists
|
||||
Tag targetTag = findById(targetTagId);
|
||||
|
||||
// Validate source tags exist and are different from target
|
||||
List<Tag> sourceTags = sourceTagIds.stream()
|
||||
.filter(id -> !id.equals(targetTagId))
|
||||
.map(this::findById)
|
||||
.toList();
|
||||
|
||||
if (sourceTags.isEmpty()) {
|
||||
throw new IllegalArgumentException("No valid source tags to merge");
|
||||
}
|
||||
|
||||
// Calculate preview data
|
||||
int targetStoryCount = targetTag.getStories().size();
|
||||
int totalStories = targetStoryCount + sourceTags.stream()
|
||||
.mapToInt(tag -> tag.getStories().size())
|
||||
.sum();
|
||||
|
||||
List<String> aliasesToCreate = sourceTags.stream()
|
||||
.map(Tag::getName)
|
||||
.toList();
|
||||
|
||||
// Create response object using the controller's inner class
|
||||
var preview = new com.storycove.controller.TagController.MergePreviewResponse();
|
||||
preview.setTargetTagName(targetTag.getName());
|
||||
preview.setTargetStoryCount(targetStoryCount);
|
||||
preview.setTotalResultStoryCount(totalStories);
|
||||
preview.setAliasesToCreate(aliasesToCreate);
|
||||
|
||||
return preview;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<com.storycove.controller.TagController.TagSuggestion> suggestTags(String title, String content, String summary, int limit) {
|
||||
List<com.storycove.controller.TagController.TagSuggestion> suggestions = new ArrayList<>();
|
||||
|
||||
// Get all existing tags for matching
|
||||
List<Tag> existingTags = findAll();
|
||||
|
||||
// Combine all text for analysis
|
||||
String combinedText = (title != null ? title : "") + " " +
|
||||
(summary != null ? summary : "") + " " +
|
||||
(content != null ? stripHtml(content) : "");
|
||||
|
||||
if (combinedText.trim().isEmpty()) {
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
String lowerText = combinedText.toLowerCase();
|
||||
|
||||
// Score each existing tag based on how well it matches the content
|
||||
for (Tag tag : existingTags) {
|
||||
double score = calculateTagRelevanceScore(tag, lowerText, title, summary);
|
||||
|
||||
if (score > 0.1) { // Only suggest tags with reasonable confidence
|
||||
String reason = generateReason(tag, lowerText, title, summary);
|
||||
suggestions.add(new com.storycove.controller.TagController.TagSuggestion(
|
||||
tag.getName(), score, reason
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by confidence score (descending) and limit results
|
||||
return suggestions.stream()
|
||||
.sorted((a, b) -> Double.compare(b.getConfidence(), a.getConfidence()))
|
||||
.limit(limit)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
private double calculateTagRelevanceScore(Tag tag, String lowerText, String title, String summary) {
|
||||
String tagName = tag.getName().toLowerCase();
|
||||
double score = 0.0;
|
||||
|
||||
// Exact matches get highest score
|
||||
if (lowerText.contains(" " + tagName + " ") || lowerText.startsWith(tagName + " ") || lowerText.endsWith(" " + tagName)) {
|
||||
score += 0.8;
|
||||
}
|
||||
|
||||
// Partial matches in title get high score
|
||||
if (title != null && title.toLowerCase().contains(tagName)) {
|
||||
score += 0.6;
|
||||
}
|
||||
|
||||
// Partial matches in summary get medium score
|
||||
if (summary != null && summary.toLowerCase().contains(tagName)) {
|
||||
score += 0.4;
|
||||
}
|
||||
|
||||
// Word-based matching (split tag name and look for individual words)
|
||||
String[] tagWords = tagName.split("[\\s-_]+");
|
||||
int matchedWords = 0;
|
||||
for (String word : tagWords) {
|
||||
if (word.length() > 2 && lowerText.contains(word)) {
|
||||
matchedWords++;
|
||||
}
|
||||
}
|
||||
if (tagWords.length > 0) {
|
||||
score += 0.3 * ((double) matchedWords / tagWords.length);
|
||||
}
|
||||
|
||||
// Boost score based on tag popularity (more used tags are more likely to be relevant)
|
||||
int storyCount = tag.getStories() != null ? tag.getStories().size() : 0;
|
||||
if (storyCount > 0) {
|
||||
score += Math.min(0.2, storyCount * 0.01); // Small boost, capped at 0.2
|
||||
}
|
||||
|
||||
return Math.min(1.0, score); // Cap at 1.0
|
||||
}
|
||||
|
||||
private String generateReason(Tag tag, String lowerText, String title, String summary) {
|
||||
String tagName = tag.getName().toLowerCase();
|
||||
|
||||
if (title != null && title.toLowerCase().contains(tagName)) {
|
||||
return "Found in title";
|
||||
}
|
||||
|
||||
if (summary != null && summary.toLowerCase().contains(tagName)) {
|
||||
return "Found in summary";
|
||||
}
|
||||
|
||||
if (lowerText.contains(" " + tagName + " ") || lowerText.startsWith(tagName + " ") || lowerText.endsWith(" " + tagName)) {
|
||||
return "Exact match in content";
|
||||
}
|
||||
|
||||
String[] tagWords = tagName.split("[\\s-_]+");
|
||||
for (String word : tagWords) {
|
||||
if (word.length() > 2 && lowerText.contains(word)) {
|
||||
return "Related keywords found";
|
||||
}
|
||||
}
|
||||
|
||||
return "Similar content";
|
||||
}
|
||||
|
||||
private String stripHtml(String html) {
|
||||
if (html == null) return "";
|
||||
// Simple HTML tag removal - replace with a proper HTML parser if needed
|
||||
return html.replaceAll("<[^>]+>", " ").replaceAll("\\s+", " ").trim();
|
||||
}
|
||||
}
|
||||
@@ -827,6 +827,11 @@ public class TypesenseService {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("AUTHOR INDEX DEBUG: Starting to index {} authors", authors.size());
|
||||
for (Author author : authors) {
|
||||
logger.info("AUTHOR INDEX DEBUG: Indexing author: '{}'", author.getName());
|
||||
}
|
||||
|
||||
try {
|
||||
// Index authors one by one for now (can optimize later)
|
||||
for (Author author : authors) {
|
||||
@@ -860,6 +865,8 @@ public class TypesenseService {
|
||||
document.put("id", author.getId().toString());
|
||||
document.put("name", author.getName());
|
||||
document.put("notes", author.getNotes() != null ? author.getNotes() : "");
|
||||
|
||||
logger.info("INDEXING AUTHOR: '{}' with ID: {}", author.getName(), author.getId());
|
||||
document.put("authorRating", author.getAuthorRating());
|
||||
|
||||
// Safely handle potentially lazy-loaded stories collection
|
||||
@@ -908,12 +915,27 @@ public class TypesenseService {
|
||||
}
|
||||
|
||||
public SearchResultDto<AuthorSearchDto> searchAuthors(String query, int page, int perPage, String sortBy, String sortOrder) {
|
||||
logger.info("AUTHOR SEARCH DEBUG: query='{}', page={}, perPage={}", query, page, perPage);
|
||||
try {
|
||||
// For substring search, try the raw query and let Typesense handle partial matching
|
||||
String searchQuery;
|
||||
if (query != null && !query.trim().isEmpty()) {
|
||||
// Use the raw query - Typesense should handle partial matching
|
||||
searchQuery = query.trim();
|
||||
} else {
|
||||
searchQuery = "*";
|
||||
}
|
||||
|
||||
SearchParameters searchParameters = new SearchParameters()
|
||||
.q(query != null && !query.trim().isEmpty() ? query : "*")
|
||||
.q(searchQuery)
|
||||
.queryBy("name,notes")
|
||||
.page(page + 1) // Typesense pages are 1-indexed
|
||||
.perPage(perPage);
|
||||
.perPage(perPage)
|
||||
.numTypos("2"); // Allow typos to increase matching flexibility
|
||||
|
||||
logger.info("AUTHOR SEARCH DEBUG: Using fuzzy search with query: '{}' for original query: '{}'", searchQuery, query);
|
||||
|
||||
logger.info("AUTHOR SEARCH DEBUG: searchParameters={}", searchParameters);
|
||||
|
||||
// Add sorting if specified, with fallback if sorting fails
|
||||
if (sortBy != null && !sortBy.trim().isEmpty()) {
|
||||
@@ -925,9 +947,11 @@ public class TypesenseService {
|
||||
|
||||
SearchResult searchResult;
|
||||
try {
|
||||
logger.info("AUTHOR SEARCH DEBUG: Executing search with final parameters");
|
||||
searchResult = typesenseClient.collections(AUTHORS_COLLECTION)
|
||||
.documents()
|
||||
.search(searchParameters);
|
||||
logger.info("AUTHOR SEARCH DEBUG: Search completed. Found {} results", searchResult.getHits().size());
|
||||
} catch (Exception sortException) {
|
||||
// If sorting fails (likely due to schema issues), retry without sorting
|
||||
logger.warn("Sorting failed for authors search, retrying without sort: " + sortException.getMessage());
|
||||
@@ -938,11 +962,16 @@ public class TypesenseService {
|
||||
} catch (Exception debugException) {
|
||||
}
|
||||
|
||||
// Use wildcard approach for fallback to handle substring search
|
||||
String fallbackSearchQuery = query != null && !query.trim().isEmpty() ? "*" + query.trim() + "*" : "*";
|
||||
|
||||
searchParameters = new SearchParameters()
|
||||
.q(query != null && !query.trim().isEmpty() ? query : "*")
|
||||
.q(fallbackSearchQuery)
|
||||
.queryBy("name,notes")
|
||||
.page(page + 1)
|
||||
.perPage(perPage);
|
||||
|
||||
logger.info("AUTHOR SEARCH DEBUG: Using fallback infix search query: '{}'", fallbackSearchQuery);
|
||||
|
||||
searchResult = typesenseClient.collections(AUTHORS_COLLECTION)
|
||||
.documents()
|
||||
|
||||
Reference in New Issue
Block a user