import { AlertCircle, CheckCircle2, Download, Loader2, Upload, } from "lucide-react"; import type React from "react"; import { useRef, useState } from "react"; import { createHighlight } from "../../api/client"; import type { Selector } from "../../types"; interface Highlight { url: string; text: string; title?: string; tags?: string[]; color?: string; created_at?: string; note?: string; } interface ImportProgress { total: number; completed: number; failed: number; errors: { row: number; error: string }[]; } export function HighlightImporter() { const [progress, setProgress] = useState(null); const [isImporting, setIsImporting] = useState(false); const fileInputRef = useRef(null); const parseCSV = (csv: string): Highlight[] => { const lines = csv.split("\n"); if (lines.length === 0) return []; // Parse header (case-insensitive) const header = lines[0].split(",").map((h) => h.trim().toLowerCase()); // Find required columns (flexible matching) const urlIdx = header.findIndex((h) => h === "url" || h === "source"); const textIdx = header.findIndex( (h) => h === "text" || h === "highlight" || h === "excerpt", ); // Find optional columns const titleIdx = header.findIndex( (h) => h === "title" || h === "article_title", ); const tagsIdx = header.findIndex((h) => h === "tags" || h === "tag"); const colorIdx = header.findIndex( (h) => h === "color" || h === "highlight_color", ); const createdAtIdx = header.findIndex( (h) => h === "created_at" || h === "date" || h === "date_highlighted", ); const noteIdx = header.findIndex( (h) => h === "note" || h === "notes" || h === "comment", ); // Validate required columns if (urlIdx === -1) { throw new Error("CSV must have a 'url' column"); } if (textIdx === -1) { throw new Error( "CSV must have a 'text' column (also matches: highlight, excerpt)", ); } const highlights: Highlight[] = []; for (let i = 1; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; const cells = parseCSVLine(line); const url = cells[urlIdx]?.trim() || ""; const text = cells[textIdx]?.trim() || ""; if (url && text) { const highlight: Highlight = { url, text, title: titleIdx >= 0 ? cells[titleIdx]?.trim() : undefined, tags: tagsIdx >= 0 ? parseTags(cells[tagsIdx]) : undefined, color: colorIdx >= 0 ? validateColor(cells[colorIdx]?.trim()) : "yellow", created_at: createdAtIdx >= 0 ? cells[createdAtIdx]?.trim() : undefined, note: noteIdx >= 0 ? cells[noteIdx]?.trim() : undefined, }; highlights.push(highlight); } } return highlights; }; const validateColor = (color?: string): string => { if (!color) return "yellow"; const valid = ["yellow", "blue", "green", "red", "orange", "purple"]; return valid.includes(color.toLowerCase()) ? color.toLowerCase() : "yellow"; }; const parseCSVLine = (line: string): string[] => { const result: string[] = []; let current = ""; let inQuotes = false; for (let i = 0; i < line.length; i++) { const char = line[i]; const nextChar = line[i + 1]; if (char === '"') { if (inQuotes && nextChar === '"') { current += '"'; i++; } else { inQuotes = !inQuotes; } } else if (char === "," && !inQuotes) { result.push(current); current = ""; } else { current += char; } } result.push(current); return result; }; const parseTags = (tagString: string): string[] => { if (!tagString) return []; return tagString .split(/[,;]/) .map((t) => t.trim()) .filter((t) => t.length > 0) .slice(0, 10); // Max 10 tags per highlight }; const downloadTemplate = () => { const template = `url,text,title,tags,color,created_at https://example.com,"Highlight text here","Page Title","tag1;tag2",yellow,2024-01-15T10:30:00Z https://blog.example.com,"Another highlight","Article Title","reading",blue,2024-01-16T14:20:00Z`; const blob = new Blob([template], { type: "text/csv" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "highlights-template.csv"; a.click(); }; const handleFileSelect = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; try { setIsImporting(true); const csv = await file.text(); const highlights = parseCSV(csv); if (highlights.length === 0) { alert("No valid highlights found in CSV"); setIsImporting(false); return; } // Start import const importState: ImportProgress = { total: highlights.length, completed: 0, failed: 0, errors: [], }; setProgress(importState); // Import with rate limiting (1 per 500ms to avoid overload) for (let i = 0; i < highlights.length; i++) { const h = highlights[i]; try { const selector: Selector = { type: "TextQuoteSelector", exact: h.text.substring(0, 5000), // Max 5000 chars }; await createHighlight({ url: h.url, selector, color: h.color || "yellow", tags: h.tags, title: h.title, }); importState.completed++; } catch (error) { importState.failed++; importState.errors.push({ row: i + 2, // +2 for header row + 0-indexing error: error instanceof Error ? error.message : "Unknown error", }); } setProgress({ ...importState }); // Rate limiting await new Promise((resolve) => setTimeout(resolve, 500)); } setIsImporting(false); } catch (error) { alert( `Error parsing CSV: ${error instanceof Error ? error.message : "Unknown error"}`, ); setIsImporting(false); } // Reset file input if (fileInputRef.current) { fileInputRef.current.value = ""; } }; if (!progress) { return (
); } const successRate = progress.total > 0 ? ((progress.completed / progress.total) * 100).toFixed(1) : "0"; return (
Import Progress {progress.completed} / {progress.total}
{successRate}% complete {progress.failed > 0 && ( {progress.failed} failed )}
{isImporting && (
Importing highlights...
)} {!isImporting && progress.failed === 0 && progress.completed === progress.total && (
Successfully imported {progress.completed} highlights!
)} {progress.errors.length > 0 && (

{progress.errors.length} errors during import

    {progress.errors.slice(0, 5).map((err, idx) => (
  • Row {err.row}: {err.error}
  • ))} {progress.errors.length > 5 && (
  • +{progress.errors.length - 5} more errors
  • )}
)} {!isImporting && ( )}
); }