From 95ce5fb5326a19198136d1fba3637c9f3a62f843 Mon Sep 17 00:00:00 2001 From: Stefan Hardegger Date: Mon, 18 Aug 2025 08:54:18 +0200 Subject: [PATCH] Bugfixes and Improvements Tag Management --- .../com/storycove/service/TagService.java | 10 +- .../src/app/settings/tag-maintenance/page.tsx | 300 +++++++++++++++++- frontend/src/components/stories/StoryCard.tsx | 30 +- frontend/src/components/stories/TagInput.tsx | 193 ++++++++++- 4 files changed, 491 insertions(+), 42 deletions(-) diff --git a/backend/src/main/java/com/storycove/service/TagService.java b/backend/src/main/java/com/storycove/service/TagService.java index 6fa1707..7bc0ab7 100644 --- a/backend/src/main/java/com/storycove/service/TagService.java +++ b/backend/src/main/java/com/storycove/service/TagService.java @@ -355,9 +355,13 @@ public class TagService { // Calculate preview data int targetStoryCount = targetTag.getStories().size(); - int totalStories = targetStoryCount + sourceTags.stream() - .mapToInt(tag -> tag.getStories().size()) - .sum(); + + // Collect all unique stories from all tags (including target) to handle overlaps correctly + Set allUniqueStories = new HashSet<>(targetTag.getStories()); + for (Tag sourceTag : sourceTags) { + allUniqueStories.addAll(sourceTag.getStories()); + } + int totalStories = allUniqueStories.size(); List aliasesToCreate = sourceTags.stream() .map(Tag::getName) diff --git a/frontend/src/app/settings/tag-maintenance/page.tsx b/frontend/src/app/settings/tag-maintenance/page.tsx index fe9369d..1b3ae38 100644 --- a/frontend/src/app/settings/tag-maintenance/page.tsx +++ b/frontend/src/app/settings/tag-maintenance/page.tsx @@ -24,6 +24,12 @@ export default function TagMaintenancePage() { const [mergeTargetTagId, setMergeTargetTagId] = useState(''); const [mergePreview, setMergePreview] = useState(null); const [merging, setMerging] = useState(false); + const [isMergeSuggestionsModalOpen, setIsMergeSuggestionsModalOpen] = useState(false); + const [mergeSuggestions, setMergeSuggestions] = useState>([]); useEffect(() => { loadTags(); @@ -107,6 +113,142 @@ export default function TagMaintenancePage() { } }; + const handleSelectUnused = () => { + const unusedTags = filteredTags.filter(tag => !tag.storyCount || tag.storyCount === 0); + setSelectedTagIds(new Set(unusedTags.map(tag => tag.id))); + }; + + const handleDeleteSelected = async () => { + if (selectedTagIds.size === 0) return; + + const confirmation = confirm( + `Are you sure you want to delete ${selectedTagIds.size} selected tag(s)? This action cannot be undone.` + ); + + if (!confirmation) return; + + try { + const deletePromises = Array.from(selectedTagIds).map(tagId => + tagApi.deleteTag(tagId) + ); + + await Promise.all(deletePromises); + + // Reload tags and reset selection + await loadTags(); + setSelectedTagIds(new Set()); + } catch (error) { + console.error('Failed to delete tags:', error); + alert('Failed to delete some tags. Please try again.'); + } + }; + + const generateMergeSuggestions = () => { + const suggestions: Array<{ + group: Tag[]; + similarity: number; + reason: string; + }> = []; + + // Helper function to calculate similarity between two strings + const calculateSimilarity = (str1: string, str2: string): number => { + const s1 = str1.toLowerCase(); + const s2 = str2.toLowerCase(); + + // Exact match + if (s1 === s2) return 1.0; + + // Check for common patterns + const patterns = [ + // Plural vs singular + { regex: /(.+)s$/, match: (a: string, b: string) => a === b + 's' || b === a + 's' }, + // Hyphen vs underscore vs space + { regex: /[-_\s]/, match: (a: string, b: string) => + a.replace(/[-_\s]/g, '') === b.replace(/[-_\s]/g, '') }, + // Common abbreviations + { regex: /\b(and|&)\b/, match: (a: string, b: string) => + a.replace(/\band\b/g, '&') === b || a === b.replace(/\band\b/g, '&') }, + ]; + + for (const pattern of patterns) { + if (pattern.match(s1, s2)) return 0.9; + } + + // Levenshtein distance for similar words + const distance = levenshteinDistance(s1, s2); + const maxLength = Math.max(s1.length, s2.length); + const similarity = 1 - (distance / maxLength); + + return similarity > 0.8 ? similarity : 0; + }; + + // Simple Levenshtein distance implementation + const levenshteinDistance = (str1: string, str2: string): number => { + const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null)); + + for (let i = 0; i <= str1.length; i++) matrix[0][i] = i; + for (let j = 0; j <= str2.length; j++) matrix[j][0] = j; + + for (let j = 1; j <= str2.length; j++) { + for (let i = 1; i <= str1.length; i++) { + const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1; + matrix[j][i] = Math.min( + matrix[j][i - 1] + 1, + matrix[j - 1][i] + 1, + matrix[j - 1][i - 1] + indicator + ); + } + } + + return matrix[str2.length][str1.length]; + }; + + // Find similar tags + const processedTags = new Set(); + + for (let i = 0; i < tags.length; i++) { + if (processedTags.has(tags[i].id)) continue; + + const similarTags = [tags[i]]; + processedTags.add(tags[i].id); + + for (let j = i + 1; j < tags.length; j++) { + if (processedTags.has(tags[j].id)) continue; + + const similarity = calculateSimilarity(tags[i].name, tags[j].name); + if (similarity > 0.8) { + similarTags.push(tags[j]); + processedTags.add(tags[j].id); + } + } + + if (similarTags.length > 1) { + const maxSimilarity = Math.max(...similarTags.slice(1).map(tag => + calculateSimilarity(similarTags[0].name, tag.name) + )); + + let reason = 'Similar names detected'; + if (maxSimilarity === 0.9) { + reason = 'Likely plural/singular or formatting variations'; + } else if (maxSimilarity > 0.95) { + reason = 'Very similar names, possible duplicates'; + } + + suggestions.push({ + group: similarTags, + similarity: maxSimilarity, + reason + }); + } + } + + // Sort by similarity descending + suggestions.sort((a, b) => b.similarity - a.similarity); + + setMergeSuggestions(suggestions); + setIsMergeSuggestionsModalOpen(true); + }; + const handleMergeSelected = () => { if (selectedTagIds.size < 2) { alert('Please select at least 2 tags to merge'); @@ -296,6 +438,13 @@ export default function TagMaintenancePage() { Tags ({filteredTags.length})
+ {selectedTagIds.size > 0 && ( <> +
)} @@ -368,7 +534,13 @@ export default function TagMaintenancePage() {

)}
- {tag.storyCount || 0} stories + + {tag.storyCount || 0} stories + {tag.aliasCount && tag.aliasCount > 0 && ( {tag.aliasCount} aliases )} @@ -504,7 +676,7 @@ export default function TagMaintenancePage() {
)} + + {/* Merge Suggestions Modal */} + {isMergeSuggestionsModalOpen && ( +
+
+

Merge Suggestions

+ +
+

+ Found {mergeSuggestions.length} potential merge opportunities based on similar tag names. +

+ + {mergeSuggestions.length === 0 ? ( +
+

No similar tags found.

+

+ All your tags appear to have unique names. +

+
+ ) : ( +
+ {mergeSuggestions.map((suggestion, index) => ( +
+
+
+

+ Suggestion {index + 1} +

+

+ {suggestion.reason} (Similarity: {(suggestion.similarity * 100).toFixed(1)}%) +

+
+ +
+ +
+ {suggestion.group.map((tag, tagIndex) => ( +
+ + + ({tag.storyCount || 0} stories) + + {tagIndex < suggestion.group.length - 1 && ( + + )} +
+ ))} +
+
+ ))} +
+ )} + + {/* Actions */} +
+ + {mergeSuggestions.length > 0 && ( + + )} +
+
+
+
+ )} ); } \ No newline at end of file diff --git a/frontend/src/components/stories/StoryCard.tsx b/frontend/src/components/stories/StoryCard.tsx index 73bc981..c6c993a 100644 --- a/frontend/src/components/stories/StoryCard.tsx +++ b/frontend/src/components/stories/StoryCard.tsx @@ -6,6 +6,7 @@ import Image from 'next/image'; import { Story } from '../../types/api'; import { storyApi, getImageUrl } from '../../lib/api'; import Button from '../ui/Button'; +import TagDisplay from '../tags/TagDisplay'; interface StoryCardProps { story: Story; @@ -27,7 +28,10 @@ export default function StoryCard({ const [rating, setRating] = useState(story.rating || 0); const [updating, setUpdating] = useState(false); - const handleRatingClick = async (newRating: number) => { + const handleRatingClick = async (e: React.MouseEvent, newRating: number) => { + e.preventDefault(); + e.stopPropagation(); + if (updating) return; try { @@ -106,12 +110,12 @@ export default function StoryCard({ {Array.isArray(story.tags) && story.tags.length > 0 && (
{story.tags.slice(0, 3).map((tag) => ( - - {tag.name} - + tag={tag} + size="sm" + clickable={false} + /> ))} {story.tags.length > 3 && ( @@ -129,7 +133,7 @@ export default function StoryCard({ {[1, 2, 3, 4, 5].map((star) => ( ))}
)}

- Type and press Enter or comma to add tags + Type and press Enter or comma to add tags. Supports fuzzy matching for typos.

);