Various Fixes and QoL enhancements.

This commit is contained in:
Stefan Hardegger
2025-07-26 12:05:54 +02:00
parent 5e8164c6a4
commit f95d7aa8bb
32 changed files with 758 additions and 136 deletions

1
backend/backend.log Normal file
View File

@@ -0,0 +1 @@
(eval):1: no such file or directory: ./mvnw

View File

@@ -7,6 +7,8 @@ import com.storycove.entity.Story;
import com.storycove.entity.Tag;
import com.storycove.service.CollectionService;
import com.storycove.service.ImageService;
import com.storycove.service.ReadingTimeService;
import com.storycove.service.TypesenseService;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -28,12 +30,18 @@ public class CollectionController {
private final CollectionService collectionService;
private final ImageService imageService;
private final TypesenseService typesenseService;
private final ReadingTimeService readingTimeService;
@Autowired
public CollectionController(CollectionService collectionService,
ImageService imageService) {
ImageService imageService,
@Autowired(required = false) TypesenseService typesenseService,
ReadingTimeService readingTimeService) {
this.collectionService = collectionService;
this.imageService = imageService;
this.typesenseService = typesenseService;
this.readingTimeService = readingTimeService;
}
/**
@@ -270,6 +278,35 @@ public class CollectionController {
return ResponseEntity.ok(Map.of("message", "Cover removed successfully"));
}
/**
* POST /api/collections/reindex-typesense - Reindex all collections in Typesense
*/
@PostMapping("/reindex-typesense")
public ResponseEntity<Map<String, Object>> reindexCollectionsTypesense() {
try {
List<Collection> allCollections = collectionService.findAllWithTags();
if (typesenseService != null) {
typesenseService.reindexAllCollections(allCollections);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "Successfully reindexed all collections",
"count", allCollections.size()
));
} else {
return ResponseEntity.ok(Map.of(
"success", false,
"message", "Typesense service not available"
));
}
} catch (Exception e) {
logger.error("Failed to reindex collections", e);
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"error", e.getMessage()
));
}
}
// Mapper methods
private CollectionDto mapToCollectionDto(Collection collection) {
@@ -290,6 +327,11 @@ public class CollectionController {
.toList());
}
// Map tag names for search results
if (collection.getTagNames() != null) {
dto.setTagNames(collection.getTagNames());
}
// Map collection stories (lightweight)
if (collection.getCollectionStories() != null) {
dto.setCollectionStories(collection.getCollectionStories().stream()
@@ -300,7 +342,7 @@ public class CollectionController {
// Set calculated properties
dto.setStoryCount(collection.getStoryCount());
dto.setTotalWordCount(collection.getTotalWordCount());
dto.setEstimatedReadingTime(collection.getEstimatedReadingTime());
dto.setEstimatedReadingTime(readingTimeService.calculateReadingTime(collection.getTotalWordCount()));
dto.setAverageStoryRating(collection.getAverageStoryRating());
return dto;

View File

@@ -0,0 +1,54 @@
package com.storycove.controller;
import com.storycove.dto.HtmlSanitizationConfigDto;
import com.storycove.service.HtmlSanitizationService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/config")
public class ConfigController {
private final HtmlSanitizationService htmlSanitizationService;
@Value("${app.reading.speed.default:200}")
private int defaultReadingSpeed;
@Autowired
public ConfigController(HtmlSanitizationService htmlSanitizationService) {
this.htmlSanitizationService = htmlSanitizationService;
}
/**
* Get the HTML sanitization configuration for frontend use
* This allows the frontend to use the same sanitization rules as the backend
*/
@GetMapping("/html-sanitization")
public ResponseEntity<HtmlSanitizationConfigDto> getHtmlSanitizationConfig() {
HtmlSanitizationConfigDto config = htmlSanitizationService.getConfiguration();
return ResponseEntity.ok(config);
}
/**
* Get application settings configuration
*/
@GetMapping("/settings")
public ResponseEntity<Map<String, Object>> getSettings() {
Map<String, Object> settings = Map.of(
"defaultReadingSpeed", defaultReadingSpeed
);
return ResponseEntity.ok(settings);
}
/**
* Get reading speed for calculation purposes
*/
@GetMapping("/reading-speed")
public ResponseEntity<Map<String, Integer>> getReadingSpeed() {
return ResponseEntity.ok(Map.of("wordsPerMinute", defaultReadingSpeed));
}
}

View File

@@ -1,31 +0,0 @@
package com.storycove.controller;
import com.storycove.dto.HtmlSanitizationConfigDto;
import com.storycove.service.HtmlSanitizationService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/config")
public class HtmlSanitizationController {
private final HtmlSanitizationService htmlSanitizationService;
@Autowired
public HtmlSanitizationController(HtmlSanitizationService htmlSanitizationService) {
this.htmlSanitizationService = htmlSanitizationService;
}
/**
* Get the HTML sanitization configuration for frontend use
* This allows the frontend to use the same sanitization rules as the backend
*/
@GetMapping("/html-sanitization")
public ResponseEntity<HtmlSanitizationConfigDto> getHtmlSanitizationConfig() {
HtmlSanitizationConfigDto config = htmlSanitizationService.getConfiguration();
return ResponseEntity.ok(config);
}
}

View File

@@ -41,6 +41,7 @@ public class StoryController {
private final ImageService imageService;
private final TypesenseService typesenseService;
private final CollectionService collectionService;
private final ReadingTimeService readingTimeService;
public StoryController(StoryService storyService,
AuthorService authorService,
@@ -48,7 +49,8 @@ public class StoryController {
HtmlSanitizationService sanitizationService,
ImageService imageService,
CollectionService collectionService,
@Autowired(required = false) TypesenseService typesenseService) {
@Autowired(required = false) TypesenseService typesenseService,
ReadingTimeService readingTimeService) {
this.storyService = storyService;
this.authorService = authorService;
this.seriesService = seriesService;
@@ -56,6 +58,7 @@ public class StoryController {
this.imageService = imageService;
this.collectionService = collectionService;
this.typesenseService = typesenseService;
this.readingTimeService = readingTimeService;
}
@GetMapping
@@ -467,12 +470,40 @@ public class StoryController {
// to avoid circular references and keep it lightweight
dto.setStoryCount(collection.getStoryCount());
dto.setTotalWordCount(collection.getTotalWordCount());
dto.setEstimatedReadingTime(collection.getEstimatedReadingTime());
dto.setEstimatedReadingTime(readingTimeService.calculateReadingTime(collection.getTotalWordCount()));
dto.setAverageStoryRating(collection.getAverageStoryRating());
return dto;
}
@GetMapping("/check-duplicate")
public ResponseEntity<Map<String, Object>> checkDuplicate(
@RequestParam String title,
@RequestParam String authorName) {
try {
List<Story> duplicates = storyService.findPotentialDuplicates(title, authorName);
Map<String, Object> response = Map.of(
"hasDuplicates", !duplicates.isEmpty(),
"count", duplicates.size(),
"duplicates", duplicates.stream()
.map(story -> Map.of(
"id", story.getId(),
"title", story.getTitle(),
"authorName", story.getAuthor() != null ? story.getAuthor().getName() : "",
"createdAt", story.getCreatedAt()
))
.collect(Collectors.toList())
);
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("Error checking for duplicates", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to check for duplicates"));
}
}
// Request DTOs
public static class CreateStoryRequest {
private String title;

View File

@@ -132,17 +132,39 @@ public class TagController {
return ResponseEntity.ok(stats);
}
@GetMapping("/collections")
public ResponseEntity<List<TagDto>> getTagsUsedByCollections() {
List<Tag> tags = tagService.findTagsUsedByCollections();
List<TagDto> tagDtos = tags.stream()
.map(this::convertToDtoWithCollectionCount)
.collect(Collectors.toList());
return ResponseEntity.ok(tagDtos);
}
private TagDto convertToDto(Tag tag) {
TagDto dto = new TagDto();
dto.setId(tag.getId());
dto.setName(tag.getName());
dto.setStoryCount(tag.getStories() != null ? tag.getStories().size() : 0);
dto.setCollectionCount(tag.getCollections() != null ? tag.getCollections().size() : 0);
dto.setCreatedAt(tag.getCreatedAt());
// updatedAt field not present in Tag entity per spec
return dto;
}
private TagDto convertToDtoWithCollectionCount(Tag tag) {
TagDto dto = new TagDto();
dto.setId(tag.getId());
dto.setName(tag.getName());
dto.setCollectionCount(tag.getCollections() != null ? tag.getCollections().size() : 0);
dto.setCreatedAt(tag.getCreatedAt());
// Don't set storyCount for collection-focused endpoint
return dto;
}
// Request DTOs
public static class CreateTagRequest {
private String name;

View File

@@ -16,6 +16,7 @@ public class CollectionDto {
private String coverImagePath;
private Boolean isArchived;
private List<TagDto> tags;
private List<String> tagNames; // For search results
private List<CollectionStoryDto> collectionStories;
private Integer storyCount;
private Integer totalWordCount;
@@ -83,6 +84,14 @@ public class CollectionDto {
this.tags = tags;
}
public List<String> getTagNames() {
return tagNames;
}
public void setTagNames(List<String> tagNames) {
this.tagNames = tagNames;
}
public List<CollectionStoryDto> getCollectionStories() {
return collectionStories;
}

View File

@@ -15,6 +15,7 @@ public class TagDto {
private String name;
private Integer storyCount;
private Integer collectionCount;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@@ -49,6 +50,14 @@ public class TagDto {
this.storyCount = storyCount;
}
public Integer getCollectionCount() {
return collectionCount;
}
public void setCollectionCount(Integer collectionCount) {
this.collectionCount = collectionCount;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}

View File

@@ -52,6 +52,10 @@ public class Collection {
)
private Set<Tag> tags = new HashSet<>();
// Transient field for search results - tag names only to avoid lazy loading issues
@Transient
private List<String> tagNames;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@@ -192,6 +196,14 @@ public class Collection {
this.tags = tags;
}
public List<String> getTagNames() {
return tagNames;
}
public void setTagNames(List<String> tagNames) {
this.tagNames = tagNames;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}

View File

@@ -29,6 +29,10 @@ public class Tag {
@JsonBackReference("story-tags")
private Set<Story> stories = new HashSet<>();
@ManyToMany(mappedBy = "tags")
@JsonBackReference("collection-tags")
private Set<Collection> collections = new HashSet<>();
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@@ -67,6 +71,14 @@ public class Tag {
this.stories = stories;
}
public Set<Collection> getCollections() {
return collections;
}
public void setCollections(Set<Collection> collections) {
this.collections = collections;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}

View File

@@ -45,4 +45,10 @@ public interface CollectionRepository extends JpaRepository<Collection, UUID> {
*/
@Query("SELECT c FROM Collection c WHERE c.isArchived = false ORDER BY c.updatedAt DESC")
List<Collection> findAllActiveCollections();
/**
* Find all collections with tags for reindexing operations
*/
@Query("SELECT c FROM Collection c LEFT JOIN FETCH c.tags ORDER BY c.updatedAt DESC")
List<Collection> findAllWithTags();
}

View File

@@ -114,4 +114,7 @@ public interface StoryRepository extends JpaRepository<Story, UUID> {
"LEFT JOIN FETCH s.series " +
"LEFT JOIN FETCH s.tags")
List<Story> findAllWithAssociations();
@Query("SELECT s FROM Story s WHERE UPPER(s.title) = UPPER(:title) AND UPPER(s.author.name) = UPPER(:authorName)")
List<Story> findByTitleAndAuthorNameIgnoreCase(@Param("title") String title, @Param("authorName") String authorName);
}

View File

@@ -54,4 +54,7 @@ public interface TagRepository extends JpaRepository<Tag, UUID> {
@Query("SELECT COUNT(t) FROM Tag t WHERE SIZE(t.stories) > 0")
long countUsedTags();
@Query("SELECT t FROM Tag t WHERE SIZE(t.collections) > 0 ORDER BY SIZE(t.collections) DESC, t.name ASC")
List<Tag> findTagsUsedByCollections();
}

View File

@@ -10,6 +10,7 @@ public class CollectionSearchResult extends Collection {
private Integer storedStoryCount;
private Integer storedTotalWordCount;
private int wordsPerMinute = 200; // Default, can be overridden
public CollectionSearchResult(Collection collection) {
this.setId(collection.getId());
@@ -20,6 +21,7 @@ public class CollectionSearchResult extends Collection {
this.setCreatedAt(collection.getCreatedAt());
this.setUpdatedAt(collection.getUpdatedAt());
this.setCoverImagePath(collection.getCoverImagePath());
this.setTagNames(collection.getTagNames()); // Copy tag names for search results
// Note: don't copy collectionStories or tags to avoid lazy loading issues
}
@@ -31,6 +33,10 @@ public class CollectionSearchResult extends Collection {
this.storedTotalWordCount = totalWordCount;
}
public void setWordsPerMinute(int wordsPerMinute) {
this.wordsPerMinute = wordsPerMinute;
}
@Override
public int getStoryCount() {
return storedStoryCount != null ? storedStoryCount : 0;
@@ -43,8 +49,7 @@ public class CollectionSearchResult extends Collection {
@Override
public int getEstimatedReadingTime() {
// Assuming 200 words per minute reading speed
return Math.max(1, getTotalWordCount() / 200);
return Math.max(1, getTotalWordCount() / wordsPerMinute);
}
@Override

View File

@@ -34,18 +34,21 @@ public class CollectionService {
private final StoryRepository storyRepository;
private final TagRepository tagRepository;
private final TypesenseService typesenseService;
private final ReadingTimeService readingTimeService;
@Autowired
public CollectionService(CollectionRepository collectionRepository,
CollectionStoryRepository collectionStoryRepository,
StoryRepository storyRepository,
TagRepository tagRepository,
@Autowired(required = false) TypesenseService typesenseService) {
@Autowired(required = false) TypesenseService typesenseService,
ReadingTimeService readingTimeService) {
this.collectionRepository = collectionRepository;
this.collectionStoryRepository = collectionStoryRepository;
this.storyRepository = storyRepository;
this.tagRepository = tagRepository;
this.typesenseService = typesenseService;
this.readingTimeService = readingTimeService;
}
/**
@@ -78,6 +81,13 @@ public class CollectionService {
.orElseThrow(() -> new ResourceNotFoundException("Collection not found with id: " + id));
}
/**
* Find all collections with tags for reindexing
*/
public List<Collection> findAllWithTags() {
return collectionRepository.findAllWithTags();
}
/**
* Create a new collection with optional initial stories
*/
@@ -344,7 +354,7 @@ public class CollectionService {
int totalWordCount = collectionStories.stream()
.mapToInt(cs -> cs.getStory().getWordCount() != null ? cs.getStory().getWordCount() : 0)
.sum();
int estimatedReadingTime = Math.max(1, totalWordCount / 200); // 200 words per minute
int estimatedReadingTime = readingTimeService.calculateReadingTime(totalWordCount);
double averageStoryRating = collectionStories.stream()
.filter(cs -> cs.getStory().getRating() != null)

View File

@@ -0,0 +1,28 @@
package com.storycove.service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class ReadingTimeService {
@Value("${app.reading.speed.default:200}")
private int defaultWordsPerMinute;
/**
* Calculate estimated reading time in minutes for the given word count
* @param wordCount the number of words to read
* @return estimated reading time in minutes (minimum 1 minute)
*/
public int calculateReadingTime(int wordCount) {
return Math.max(1, wordCount / defaultWordsPerMinute);
}
/**
* Get the current words per minute setting
* @return words per minute reading speed
*/
public int getWordsPerMinute() {
return defaultWordsPerMinute;
}
}

View File

@@ -593,4 +593,12 @@ public class StoryService {
}
}
}
@Transactional(readOnly = true)
public List<Story> findPotentialDuplicates(String title, String authorName) {
if (title == null || title.trim().isEmpty() || authorName == null || authorName.trim().isEmpty()) {
return List.of();
}
return storyRepository.findByTitleAndAuthorNameIgnoreCase(title.trim(), authorName.trim());
}
}

View File

@@ -191,6 +191,11 @@ public class TagService {
public long countUsedTags() {
return tagRepository.countUsedTags();
}
@Transactional(readOnly = true)
public List<Tag> findTagsUsedByCollections() {
return tagRepository.findTagsUsedByCollections();
}
private void validateTagForCreate(Tag tag) {
if (existsByName(tag.getName())) {

View File

@@ -32,12 +32,15 @@ public class TypesenseService {
private final Client typesenseClient;
private final CollectionStoryRepository collectionStoryRepository;
private final ReadingTimeService readingTimeService;
@Autowired
public TypesenseService(Client typesenseClient,
@Autowired(required = false) CollectionStoryRepository collectionStoryRepository) {
@Autowired(required = false) CollectionStoryRepository collectionStoryRepository,
ReadingTimeService readingTimeService) {
this.typesenseClient = typesenseClient;
this.collectionStoryRepository = collectionStoryRepository;
this.readingTimeService = readingTimeService;
}
@PostConstruct
@@ -65,19 +68,19 @@ public class TypesenseService {
private void createStoriesCollection() throws Exception {
List<Field> fields = Arrays.asList(
new Field().name("id").type("string").facet(false),
new Field().name("title").type("string").facet(false),
new Field().name("title").type("string").facet(false).sort(true),
new Field().name("summary").type("string").facet(false).optional(true),
new Field().name("description").type("string").facet(false),
new Field().name("contentPlain").type("string").facet(false),
new Field().name("authorId").type("string").facet(true),
new Field().name("authorName").type("string").facet(true),
new Field().name("authorName").type("string").facet(true).sort(true),
new Field().name("seriesId").type("string").facet(true).optional(true),
new Field().name("seriesName").type("string").facet(true).optional(true),
new Field().name("seriesName").type("string").facet(true).sort(true).optional(true),
new Field().name("tagNames").type("string[]").facet(true).optional(true),
new Field().name("rating").type("int32").facet(true).optional(true),
new Field().name("wordCount").type("int32").facet(true).optional(true),
new Field().name("volume").type("int32").facet(true).optional(true),
new Field().name("createdAt").type("int64").facet(false),
new Field().name("rating").type("int32").facet(true).sort(true).optional(true),
new Field().name("wordCount").type("int32").facet(true).sort(true).optional(true),
new Field().name("volume").type("int32").facet(true).sort(true).optional(true),
new Field().name("createdAt").type("int64").facet(false).sort(true),
new Field().name("sourceUrl").type("string").facet(false).optional(true),
new Field().name("coverPath").type("string").facet(false).optional(true)
);
@@ -101,6 +104,26 @@ public class TypesenseService {
}
}
/**
* Force recreate the stories collection, deleting it first if it exists
*/
public void recreateStoriesCollection() throws Exception {
try {
logger.info("Force deleting stories collection for recreation...");
typesenseClient.collections(STORIES_COLLECTION).delete();
logger.info("Successfully deleted stories collection");
} catch (Exception e) {
logger.debug("Stories collection didn't exist for deletion: {}", e.getMessage());
}
// Wait a brief moment to ensure deletion is complete
Thread.sleep(100);
logger.info("Creating stories collection with fresh schema...");
createStoriesCollection();
logger.info("Successfully created stories collection");
}
/**
* Force recreate the authors collection, deleting it first if it exists
*/
@@ -220,16 +243,14 @@ public class TypesenseService {
if (tagFilters != null && !tagFilters.isEmpty()) {
logger.info("SEARCH DEBUG: Processing {} tag filters: {}", tagFilters.size(), tagFilters);
String tagFilter = tagFilters.stream()
.map(tag -> {
String escaped = escapeTypesenseValue(tag);
String condition = "tagNames:=" + escaped;
logger.info("SEARCH DEBUG: Tag '{}' -> escaped '{}' -> condition '{}'", tag, escaped, condition);
return condition;
})
.collect(Collectors.joining(" || "));
logger.info("SEARCH DEBUG: Final tag filter condition: '{}'", tagFilter);
filterConditions.add("(" + tagFilter + ")");
// Use AND logic for multiple tags - items must have ALL selected tags
for (String tag : tagFilters) {
String escaped = escapeTypesenseValue(tag);
String condition = "tagNames:=" + escaped;
logger.info("SEARCH DEBUG: Tag '{}' -> escaped '{}' -> condition '{}'", tag, escaped, condition);
filterConditions.add(condition);
}
logger.info("SEARCH DEBUG: Added {} individual tag filter conditions", tagFilters.size());
}
if (minRating != null) {
@@ -294,15 +315,8 @@ public class TypesenseService {
public void reindexAllStories(List<Story> stories) {
try {
// Clear existing collection
try {
typesenseClient.collections(STORIES_COLLECTION).delete();
} catch (Exception e) {
logger.debug("Collection didn't exist for deletion: {}", e.getMessage());
}
// Recreate collection
createStoriesCollection();
// Force recreate collection with proper schema
recreateStoriesCollection();
// Bulk index all stories
bulkIndexStories(stories);
@@ -1007,10 +1021,11 @@ public class TypesenseService {
}
if (tags != null && !tags.isEmpty()) {
String tagFilter = tags.stream()
.map(tag -> "tags:=" + escapeTypesenseValue(tag))
.collect(Collectors.joining(" || "));
filterConditions.add("(" + tagFilter + ")");
// Use AND logic for multiple tags - collections must have ALL selected tags
for (String tag : tags) {
String condition = "tags:=" + escapeTypesenseValue(tag);
filterConditions.add(condition);
}
}
if (!filterConditions.isEmpty()) {
@@ -1197,6 +1212,15 @@ public class TypesenseService {
collection.setCoverImagePath((String) doc.get("cover_image_path"));
collection.setIsArchived((Boolean) doc.get("is_archived"));
// Set tags from Typesense document
if (doc.get("tags") != null) {
@SuppressWarnings("unchecked")
List<String> tagNames = (List<String>) doc.get("tags");
// For search results, we'll store tag names in a special field for frontend
// since we don't want to load full Tag entities for performance
collection.setTagNames(tagNames);
}
// Set timestamps
if (doc.get("created_at") != null) {
long createdAtSeconds = ((Number) doc.get("created_at")).longValue();
@@ -1210,6 +1234,7 @@ public class TypesenseService {
// For list/search views, we create a special lightweight collection that stores
// the calculated values directly to avoid lazy loading issues
CollectionSearchResult searchCollection = new CollectionSearchResult(collection);
searchCollection.setWordsPerMinute(readingTimeService.getWordsPerMinute());
// Set the calculated statistics from the Typesense document
if (doc.get("story_count") != null) {