Tag Enhancement + bugfixes

This commit is contained in:
Stefan Hardegger
2025-08-17 17:16:40 +02:00
parent 6b83783381
commit 1a99d9830d
34 changed files with 2996 additions and 97 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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