diff --git a/src/components/TagsCSVImportDialog.tsx b/src/components/TagsCSVImportDialog.tsx new file mode 100644 index 0000000..08b5a28 --- /dev/null +++ b/src/components/TagsCSVImportDialog.tsx @@ -0,0 +1,317 @@ +import { useState } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Box, + Typography, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Alert, + CircularProgress, + Chip, + Stack, +} from '@mui/material'; +import { Upload } from '@mui/icons-material'; +import { + parseCSVToPVs, + createTagMappingForTags, + createValidationSummaryForTags, + ParsedCSVRow, +} from '../utils/csvParser'; + +interface TagsCSVImportDialogProps { + open: boolean; + onClose: () => void; + onImport: (data: ParsedCSVRow[]) => Promise; + availableTagGroups: Array<{ + id: string; + name: string; + tags: Array<{ id: string; name: string }>; + }>; +} + +export function TagsCSVImportDialog({ + open, + onClose, + onImport, + availableTagGroups, +}: TagsCSVImportDialogProps) { + const [csvData, setCSVData] = useState([]); + const [parseErrors, setParseErrors] = useState([]); + const [validationSummary, setValidationSummary] = useState(''); + const [importing, setImporting] = useState(false); + const [fileSelected, setFileSelected] = useState(false); + const [importError, setImportError] = useState(null); + const [importableTagCount, setImportableTagCount] = useState(0); + + const handleClose = () => { + setCSVData([]); + setParseErrors([]); + setValidationSummary(''); + setFileSelected(false); + setImporting(false); + setImportError(null); + onClose(); + }; + + const handleFileSelect = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + const inputElement = event.target; + if (!file) return; + + try { + const content = await file.text(); + const result = parseCSVToPVs(content); + + if (result.errors.length > 0) { + setParseErrors(result.errors); + setCSVData([]); + setValidationSummary(''); + setFileSelected(false); + return; + } + + setCSVData(result.data); + setParseErrors([]); + setFileSelected(true); + + // Validate tags and calculate importable count + if (result.data.length > 0) { + // Collect all new groups, duplicate values, and track unique tags across all rows + const allNewGroups = new Set(); + const allDuplicateValues: Record> = {}; + const allUniqueTags = new Set(); + + result.data.forEach((row) => { + const mapping = createTagMappingForTags(row.groups, availableTagGroups); + + mapping.newGroups.forEach((group) => allNewGroups.add(group)); + + // Track all unique tags from CSV + Object.entries(row.groups).forEach(([group, values]) => { + values.forEach((value) => { + allUniqueTags.add(`${group}:${value}`); + }); + }); + + Object.entries(mapping.duplicateValues).forEach(([group, values]) => { + if (!allDuplicateValues[group]) { + allDuplicateValues[group] = new Set(); + } + values.forEach((value) => allDuplicateValues[group].add(value)); + }); + }); + + // Calculate total duplicate tag count + let totalDuplicateTags = 0; + Object.values(allDuplicateValues).forEach((values) => { + totalDuplicateTags += values.size; + }); + + // Calculate importable tag count (total - duplicates) + const importableCount = allUniqueTags.size - totalDuplicateTags; + setImportableTagCount(Math.max(0, importableCount)); + + // Convert sets to arrays + const newGroups = Array.from(allNewGroups); + const duplicateValues: Record = {}; + Object.entries(allDuplicateValues).forEach(([group, valueSet]) => { + duplicateValues[group] = Array.from(valueSet); + }); + + const summary = createValidationSummaryForTags(newGroups, duplicateValues); + setValidationSummary(summary); + } + } catch (error) { + setParseErrors([ + `Failed to read CSV file: ${error instanceof Error ? error.message : 'Unknown error'}`, + ]); + setCSVData([]); + setValidationSummary(''); + setFileSelected(false); + } + + // Reset file input + inputElement.value = ''; + }; + + const handleImport = async () => { + if (csvData.length === 0) return; + + setImporting(true); + setImportError(null); + try { + await onImport(csvData); + handleClose(); + } catch (error) { + setImportError(`Import failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } finally { + setImporting(false); + } + }; + + return ( + + Import Tags from CSV + + + {/* File Upload Section */} + + + + + {/* CSV Format Instructions */} + + + CSV Format Requirements: + + + • Any column names will be treated as tag groups +
+ • Tag values can be comma-separated (e.g., “tag1, tag2”) +
+ • Empty cells or values “nan”/“none” will be ignored +
• Existing tag groups and tags will be preserved +
+
+ + {/* Import Error */} + {importError && ( + + {importError} + + )} + + {/* Parse Errors */} + {parseErrors.length > 0 && ( + + {parseErrors.map((error) => ( + + {error} + + ))} + + )} + + {/* Validation Summary */} + {fileSelected && validationSummary && ( + + {validationSummary} + {validationSummary.includes('Duplicate') && ( + + Note: Duplicate values will be skipped. New groups and values will be created. + + )} + + )} + + {/* Preview Table */} + {csvData.length > 0 && ( + + + Preview ({csvData.length} row{csvData.length !== 1 ? 's' : ''}) + + + + + + Tag Group + Values + Count + + + + {Object.entries( + csvData.reduce( + (acc, row) => { + Object.entries(row.groups).forEach(([groupName, values]) => { + if (!acc[groupName]) { + acc[groupName] = new Map(); + } + values.forEach((value) => { + acc[groupName].set(value, (acc[groupName].get(value) || 0) + 1); + }); + }); + return acc; + }, + {} as Record> + ) + ).map(([groupName, valueCounts]) => ( + + + + {groupName} + + + + + {Array.from(valueCounts.entries()).map(([value, count]) => ( + 1 ? `${value} (${count})` : value} + size="small" + variant="outlined" + /> + ))} + + + + + {Array.from(valueCounts.values()).reduce( + (sum, count) => sum + count, + 0 + )}{' '} + total + + + + ))} + +
+
+
+ )} +
+
+ + + + +
+ ); +} diff --git a/src/pages/TagPage.tsx b/src/pages/TagPage.tsx index 47072ec..526037d 100644 --- a/src/pages/TagPage.tsx +++ b/src/pages/TagPage.tsx @@ -26,8 +26,10 @@ import { InputAdornment, Tooltip, } from '@mui/material'; -import { Add, Delete, Edit, NewReleasesOutlined, NoteOutlined } from '@mui/icons-material'; +import { Add, Delete, Edit, NewReleasesOutlined, NoteOutlined, Upload } from '@mui/icons-material'; import { TagGroup, Tag } from '../types'; +import { TagsCSVImportDialog } from '../components/TagsCSVImportDialog'; +import { ParsedCSVRow } from '../utils/csvParser'; interface TagPageProps { tagGroups?: TagGroup[]; @@ -43,6 +45,7 @@ interface TagPageProps { description: string ) => Promise; onDeleteTag?: (groupId: string, tagName: string) => Promise; + onImportTags: (csvData: ParsedCSVRow[]) => Promise; } export function TagPage({ @@ -54,9 +57,11 @@ export function TagPage({ onAddTag, onEditTag, onDeleteTag, + onImportTags, }: TagPageProps) { const [dialogOpen, setDialogOpen] = useState(false); const [tagDialogOpen, setTagDialogOpen] = useState(false); + const [importDialogOpen, setImportDialogOpen] = useState(false); const [selectedGroup, setSelectedGroup] = useState(null); const [selectedTag, setSelectedTag] = useState(null); const [editMode, setEditMode] = useState(false); @@ -124,6 +129,10 @@ export function TagPage({ setTagDescription(''); }; + const handleCloseImportDialog = () => { + setImportDialogOpen(false); + }; + const handleSave = async () => { if (!groupName.trim()) { // eslint-disable-next-line no-alert @@ -205,27 +214,40 @@ export function TagPage({ return 'Tag Group Details'; }; + const handleImportTagsDialog = async (csvData: ParsedCSVRow[]) => { + try { + await onImportTags(csvData); + } finally { + handleCloseImportDialog(); + } + }; + return ( - + Tag Groups + {isAdmin && onAddGroup && ( - + <> + + + )} @@ -509,6 +531,14 @@ export function TagPage({ )} + + {/* Tags CSV Import Dialog */} + ); } diff --git a/src/routes/tags.tsx b/src/routes/tags.tsx index aebb94d..14d2fd2 100644 --- a/src/routes/tags.tsx +++ b/src/routes/tags.tsx @@ -4,6 +4,7 @@ import { TagPage } from '../pages'; import { tagsService } from '../services'; import { useAdminMode } from '../contexts/AdminModeContext'; import { TagGroup } from '../types'; +import { ParsedCSVRow } from '../utils/csvParser'; function Tags() { const [tagGroups, setTagGroups] = useState([]); @@ -182,6 +183,62 @@ function Tags() { } }; + const handleImportTags = useCallback( + async (csvData: ParsedCSVRow[]) => { + try { + // Collect unique tag groups mentioned in CSV + const groupsToImport: Record = {}; + + csvData.forEach((row) => { + Object.entries(row.groups).forEach(([groupName, tagValues]) => { + if (!groupsToImport[groupName]) { + groupsToImport[groupName] = []; + } + tagValues.forEach((tagValue) => { + if (!groupsToImport[groupName].includes(tagValue)) { + groupsToImport[groupName].push(tagValue); + } + }); + }); + }); + + // Use bulk import endpoint + const result = await tagsService.bulkImportTags({ groups: groupsToImport }); + + // Refresh the tag groups list + await fetchTagGroups(); + + // Build success message + let message = ''; + if (result.tagsCreated > 0) { + message = `Successfully created ${result.tagsCreated} tag${result.tagsCreated !== 1 ? 's' : ''}`; + } + if (result.groupsCreated > 0) { + message += ` in ${result.groupsCreated} new group${result.groupsCreated !== 1 ? 's' : ''}`; + } + if (result.tagsSkipped > 0) { + message += `. ${result.tagsSkipped} tag${result.tagsSkipped !== 1 ? 's' : ''} already existed`; + } + + // Show warnings if any + if (result.warnings && result.warnings.length > 0) { + message += `\n\nWarnings:\n${result.warnings.slice(0, 10).join('\n')}`; + if (result.warnings.length > 10) { + message += `\n... and ${result.warnings.length - 10} more`; + } + } + + // eslint-disable-next-line no-alert + alert(message || 'No tags to import'); + } catch (err) { + // eslint-disable-next-line no-console + console.error('Failed to import tags:', err); + throw err; + } + }, + [fetchTagGroups] + ); + if (loading) { return
Loading tag groups...
; } @@ -200,6 +257,7 @@ function Tags() { onAddTag={handleAddTag} onEditTag={handleEditTag} onDeleteTag={handleDeleteTag} + onImportTags={handleImportTags} /> ); } diff --git a/src/services/tagsService.ts b/src/services/tagsService.ts index 1234db2..ce85070 100644 --- a/src/services/tagsService.ts +++ b/src/services/tagsService.ts @@ -76,4 +76,16 @@ export const tagsService = { async removeTagFromGroup(groupId: string, tagId: string): Promise { return apiClient.delete(`${API_CONFIG.endpoints.tags}/${groupId}/tags/${tagId}`); }, + + /** + * Bulk import tags with duplicate handling + */ + async bulkImportTags(data: { groups: Record }): Promise<{ + groupsCreated: number; + tagsCreated: number; + tagsSkipped: number; + warnings: string[]; + }> { + return apiClient.post(`${API_CONFIG.endpoints.tags}/bulk`, data); + }, }; diff --git a/src/utils/csvParser.ts b/src/utils/csvParser.ts index cdcb8e4..c8f93d4 100644 --- a/src/utils/csvParser.ts +++ b/src/utils/csvParser.ts @@ -252,3 +252,91 @@ export function createValidationSummary( return summaryParts.length > 0 ? summaryParts.join(' • ') : 'All groups and values are valid'; } + +/** + * Create tag mapping for Tags import (accepts new groups and values) + * Identifies duplicate values instead of rejecting them + * + * @param csvGroups - Tag groups from CSV (group name -> tag values) + * @param availableTagGroups - Backend tag definition (group name -> tags with IDs) + * @returns Mapping with duplicates identified + */ +export interface TagMappingForTagsResult { + validGroups: Record; // Group name -> tag values (new or existing) + newGroups: string[]; // Group names that don't exist in backend + duplicateValues: Record; // Group name -> values that already exist +} + +export function createTagMappingForTags( + csvGroups: Record, + availableTagGroups: Array<{ id: string; name: string; tags: Array<{ id: string; name: string }> }> +): TagMappingForTagsResult { + const validGroups: Record = {}; + const newGroups: string[] = []; + const duplicateValues: Record = {}; + + // Process each CSV group + Object.entries(csvGroups).forEach(([groupName, csvValues]) => { + // Find matching tag group in backend + const matchingGroup = availableTagGroups.find((g) => g.name === groupName); + + if (!matchingGroup) { + // Group doesn't exist - mark as new group + newGroups.push(groupName); + validGroups[groupName] = csvValues; // Accept all values for new group + return; + } + + // Group exists - check for duplicates + const duplicatesForGroup: string[] = []; + const validValuesForGroup: string[] = []; + + csvValues.forEach((csvValue) => { + const matchingTag = matchingGroup.tags.find((t) => t.name === csvValue); + if (matchingTag) { + duplicatesForGroup.push(csvValue); + } else { + validValuesForGroup.push(csvValue); + } + }); + + if (validValuesForGroup.length > 0) { + validGroups[groupName] = validValuesForGroup; + } + + if (duplicatesForGroup.length > 0) { + duplicateValues[groupName] = duplicatesForGroup; + } + }); + + return { + validGroups, + newGroups, + duplicateValues, + }; +} + +/** + * Create validation summary for Tags import + * Warns about new groups and duplicate values + */ +export function createValidationSummaryForTags( + newGroups: string[], + duplicateValues: Record +): string { + const summaryParts: string[] = []; + + if (newGroups.length > 0) { + summaryParts.push(`New groups to be created: ${newGroups.join(', ')}`); + } + + if (Object.keys(duplicateValues).length > 0) { + const valueParts: string[] = []; + Object.entries(duplicateValues).forEach(([groupName, values]) => { + valueParts.push(`${groupName}: ${values.sort().join(', ')}`); + }); + summaryParts.push(`Duplicate values (will be skipped): ${valueParts.join(' | ')}`); + } + + return summaryParts.length > 0 ? summaryParts.join(' • ') : 'All groups and values are new'; +}