Bugfixes and Improvements Tag Management
This commit is contained in:
@@ -24,6 +24,12 @@ export default function TagMaintenancePage() {
|
||||
const [mergeTargetTagId, setMergeTargetTagId] = useState<string>('');
|
||||
const [mergePreview, setMergePreview] = useState<any>(null);
|
||||
const [merging, setMerging] = useState(false);
|
||||
const [isMergeSuggestionsModalOpen, setIsMergeSuggestionsModalOpen] = useState(false);
|
||||
const [mergeSuggestions, setMergeSuggestions] = useState<Array<{
|
||||
group: Tag[];
|
||||
similarity: number;
|
||||
reason: string;
|
||||
}>>([]);
|
||||
|
||||
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<string>();
|
||||
|
||||
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})
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={generateMergeSuggestions}
|
||||
>
|
||||
🔍 Merge Suggestions
|
||||
</Button>
|
||||
{selectedTagIds.size > 0 && (
|
||||
<>
|
||||
<Button
|
||||
@@ -305,6 +454,13 @@ export default function TagMaintenancePage() {
|
||||
>
|
||||
Clear Selection ({selectedTagIds.size})
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={handleDeleteSelected}
|
||||
>
|
||||
🗑️ Delete Selected
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@@ -319,14 +475,24 @@ export default function TagMaintenancePage() {
|
||||
</div>
|
||||
|
||||
{filteredTags.length > 0 && (
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filteredTags.length > 0 && selectedTagIds.size === filteredTags.length}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<label className="text-sm theme-text">Select All</label>
|
||||
<div className="mb-4 flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filteredTags.length > 0 && selectedTagIds.size === filteredTags.length}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<label className="text-sm theme-text">Select All</label>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSelectUnused}
|
||||
disabled={tagStats.unused === 0}
|
||||
>
|
||||
Select Unused ({tagStats.unused})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -368,7 +534,13 @@ export default function TagMaintenancePage() {
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-4 text-xs theme-text-muted mt-1">
|
||||
<span>{tag.storyCount || 0} stories</span>
|
||||
<a
|
||||
href={`/library?tags=${encodeURIComponent(tag.name)}`}
|
||||
className="hover:theme-accent hover:underline cursor-pointer"
|
||||
title={`View ${tag.storyCount || 0} stories with tag "${tag.name}"`}
|
||||
>
|
||||
{tag.storyCount || 0} stories
|
||||
</a>
|
||||
{tag.aliasCount && tag.aliasCount > 0 && (
|
||||
<span>{tag.aliasCount} aliases</span>
|
||||
)}
|
||||
@@ -504,7 +676,7 @@ export default function TagMaintenancePage() {
|
||||
<Button
|
||||
onClick={handleConfirmMerge}
|
||||
variant="primary"
|
||||
disabled={!mergePreview || merging}
|
||||
disabled={!mergeTargetTagId || merging}
|
||||
className="flex-1"
|
||||
>
|
||||
{merging ? 'Merging...' : 'Confirm Merge'}
|
||||
@@ -514,6 +686,114 @@ export default function TagMaintenancePage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Merge Suggestions Modal */}
|
||||
{isMergeSuggestionsModalOpen && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-4xl w-full mx-4 max-h-[80vh] overflow-y-auto">
|
||||
<h2 className="text-2xl font-bold theme-header mb-4">Merge Suggestions</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="theme-text">
|
||||
Found {mergeSuggestions.length} potential merge opportunities based on similar tag names.
|
||||
</p>
|
||||
|
||||
{mergeSuggestions.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="theme-text text-lg">No similar tags found.</p>
|
||||
<p className="theme-text-muted text-sm mt-2">
|
||||
All your tags appear to have unique names.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{mergeSuggestions.map((suggestion, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border theme-border rounded-lg p-4 bg-yellow-50 dark:bg-yellow-900/20"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<h3 className="font-medium theme-header">
|
||||
Suggestion {index + 1}
|
||||
</h3>
|
||||
<p className="text-sm theme-text-muted">
|
||||
{suggestion.reason} (Similarity: {(suggestion.similarity * 100).toFixed(1)}%)
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Pre-select these tags for merging and go directly to merge modal
|
||||
const suggestedTagIds = new Set(suggestion.group.map(tag => tag.id));
|
||||
setSelectedTagIds(suggestedTagIds);
|
||||
setIsMergeSuggestionsModalOpen(false);
|
||||
|
||||
// Open merge modal directly
|
||||
setIsMergeModalOpen(true);
|
||||
setMergeTargetTagId('');
|
||||
setMergePreview(null);
|
||||
}}
|
||||
>
|
||||
Merge These
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{suggestion.group.map((tag, tagIndex) => (
|
||||
<div key={tag.id} className="flex items-center gap-2">
|
||||
<TagDisplay
|
||||
tag={tag}
|
||||
size="sm"
|
||||
showAliasesTooltip={true}
|
||||
clickable={false}
|
||||
/>
|
||||
<span className="text-xs theme-text-muted">
|
||||
({tag.storyCount || 0} stories)
|
||||
</span>
|
||||
{tagIndex < suggestion.group.length - 1 && (
|
||||
<span className="text-gray-400">→</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-4 border-t theme-border">
|
||||
<Button
|
||||
onClick={() => setIsMergeSuggestionsModalOpen(false)}
|
||||
variant="ghost"
|
||||
className="flex-1"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
{mergeSuggestions.length > 0 && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
// Select all suggested tags for batch processing
|
||||
const allSuggestedTagIds = new Set<string>();
|
||||
mergeSuggestions.forEach(suggestion => {
|
||||
suggestion.group.forEach(tag => allSuggestedTagIds.add(tag.id));
|
||||
});
|
||||
setSelectedTagIds(allSuggestedTagIds);
|
||||
setIsMergeSuggestionsModalOpen(false);
|
||||
}}
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
>
|
||||
Select All Suggested ({mergeSuggestions.reduce((acc, s) => acc + s.group.length, 0)} tags)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user