Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 361 lines 12 kB view raw
1import { 2 AlertCircle, 3 CheckCircle2, 4 Download, 5 Loader2, 6 Upload, 7} from "lucide-react"; 8import type React from "react"; 9import { useRef, useState } from "react"; 10import { createHighlight } from "../../api/client"; 11import type { Selector } from "../../types"; 12 13interface Highlight { 14 url: string; 15 text: string; 16 title?: string; 17 tags?: string[]; 18 color?: string; 19 created_at?: string; 20 note?: string; 21} 22 23interface ImportProgress { 24 total: number; 25 completed: number; 26 failed: number; 27 errors: { row: number; error: string }[]; 28} 29 30export function HighlightImporter() { 31 const [progress, setProgress] = useState<ImportProgress | null>(null); 32 const [isImporting, setIsImporting] = useState(false); 33 const fileInputRef = useRef<HTMLInputElement>(null); 34 35 const parseCSV = (csv: string): Highlight[] => { 36 const lines = csv.split("\n"); 37 if (lines.length === 0) return []; 38 39 // Parse header (case-insensitive) 40 const header = lines[0].split(",").map((h) => h.trim().toLowerCase()); 41 42 // Find required columns (flexible matching) 43 const urlIdx = header.findIndex((h) => h === "url" || h === "source"); 44 const textIdx = header.findIndex( 45 (h) => h === "text" || h === "highlight" || h === "excerpt", 46 ); 47 48 // Find optional columns 49 const titleIdx = header.findIndex( 50 (h) => h === "title" || h === "article_title", 51 ); 52 const tagsIdx = header.findIndex((h) => h === "tags" || h === "tag"); 53 const colorIdx = header.findIndex( 54 (h) => h === "color" || h === "highlight_color", 55 ); 56 const createdAtIdx = header.findIndex( 57 (h) => h === "created_at" || h === "date" || h === "date_highlighted", 58 ); 59 const noteIdx = header.findIndex( 60 (h) => h === "note" || h === "notes" || h === "comment", 61 ); 62 63 // Validate required columns 64 if (urlIdx === -1) { 65 throw new Error("CSV must have a 'url' column"); 66 } 67 if (textIdx === -1) { 68 throw new Error( 69 "CSV must have a 'text' column (also matches: highlight, excerpt)", 70 ); 71 } 72 73 const highlights: Highlight[] = []; 74 75 for (let i = 1; i < lines.length; i++) { 76 const line = lines[i].trim(); 77 if (!line) continue; 78 79 const cells = parseCSVLine(line); 80 81 const url = cells[urlIdx]?.trim() || ""; 82 const text = cells[textIdx]?.trim() || ""; 83 84 if (url && text) { 85 const highlight: Highlight = { 86 url, 87 text, 88 title: titleIdx >= 0 ? cells[titleIdx]?.trim() : undefined, 89 tags: tagsIdx >= 0 ? parseTags(cells[tagsIdx]) : undefined, 90 color: 91 colorIdx >= 0 ? validateColor(cells[colorIdx]?.trim()) : "yellow", 92 created_at: 93 createdAtIdx >= 0 ? cells[createdAtIdx]?.trim() : undefined, 94 note: noteIdx >= 0 ? cells[noteIdx]?.trim() : undefined, 95 }; 96 highlights.push(highlight); 97 } 98 } 99 100 return highlights; 101 }; 102 103 const validateColor = (color?: string): string => { 104 if (!color) return "yellow"; 105 const valid = ["yellow", "blue", "green", "red", "orange", "purple"]; 106 return valid.includes(color.toLowerCase()) ? color.toLowerCase() : "yellow"; 107 }; 108 109 const parseCSVLine = (line: string): string[] => { 110 const result: string[] = []; 111 let current = ""; 112 let inQuotes = false; 113 114 for (let i = 0; i < line.length; i++) { 115 const char = line[i]; 116 const nextChar = line[i + 1]; 117 118 if (char === '"') { 119 if (inQuotes && nextChar === '"') { 120 current += '"'; 121 i++; 122 } else { 123 inQuotes = !inQuotes; 124 } 125 } else if (char === "," && !inQuotes) { 126 result.push(current); 127 current = ""; 128 } else { 129 current += char; 130 } 131 } 132 133 result.push(current); 134 return result; 135 }; 136 137 const parseTags = (tagString: string): string[] => { 138 if (!tagString) return []; 139 return tagString 140 .split(/[,;]/) 141 .map((t) => t.trim()) 142 .filter((t) => t.length > 0) 143 .slice(0, 10); // Max 10 tags per highlight 144 }; 145 146 const downloadTemplate = () => { 147 const template = `url,text,title,tags,color,created_at 148https://example.com,"Highlight text here","Page Title","tag1;tag2",yellow,2024-01-15T10:30:00Z 149https://blog.example.com,"Another highlight","Article Title","reading",blue,2024-01-16T14:20:00Z`; 150 151 const blob = new Blob([template], { type: "text/csv" }); 152 const url = URL.createObjectURL(blob); 153 const a = document.createElement("a"); 154 a.href = url; 155 a.download = "highlights-template.csv"; 156 a.click(); 157 }; 158 159 const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => { 160 const file = e.target.files?.[0]; 161 if (!file) return; 162 163 try { 164 setIsImporting(true); 165 const csv = await file.text(); 166 const highlights = parseCSV(csv); 167 168 if (highlights.length === 0) { 169 alert("No valid highlights found in CSV"); 170 setIsImporting(false); 171 return; 172 } 173 174 // Start import 175 const importState: ImportProgress = { 176 total: highlights.length, 177 completed: 0, 178 failed: 0, 179 errors: [], 180 }; 181 182 setProgress(importState); 183 184 // Import with rate limiting (1 per 500ms to avoid overload) 185 for (let i = 0; i < highlights.length; i++) { 186 const h = highlights[i]; 187 188 try { 189 const selector: Selector = { 190 type: "TextQuoteSelector", 191 exact: h.text.substring(0, 5000), // Max 5000 chars 192 }; 193 194 await createHighlight({ 195 url: h.url, 196 selector, 197 color: h.color || "yellow", 198 tags: h.tags, 199 title: h.title, 200 }); 201 202 importState.completed++; 203 } catch (error) { 204 importState.failed++; 205 importState.errors.push({ 206 row: i + 2, // +2 for header row + 0-indexing 207 error: error instanceof Error ? error.message : "Unknown error", 208 }); 209 } 210 211 setProgress({ ...importState }); 212 213 // Rate limiting 214 await new Promise((resolve) => setTimeout(resolve, 500)); 215 } 216 217 setIsImporting(false); 218 } catch (error) { 219 alert( 220 `Error parsing CSV: ${error instanceof Error ? error.message : "Unknown error"}`, 221 ); 222 setIsImporting(false); 223 } 224 225 // Reset file input 226 if (fileInputRef.current) { 227 fileInputRef.current.value = ""; 228 } 229 }; 230 231 if (!progress) { 232 return ( 233 <div className="w-full space-y-3"> 234 <label className="flex items-center justify-center w-full px-4 py-8 border-2 border-dashed border-surface-300 dark:border-surface-600 rounded-lg cursor-pointer hover:border-surface-400 dark:hover:border-surface-500 transition"> 235 <input 236 ref={fileInputRef} 237 type="file" 238 accept=".csv" 239 onChange={handleFileSelect} 240 disabled={isImporting} 241 className="hidden" 242 /> 243 <div className="flex flex-col items-center gap-2"> 244 <Upload className="w-6 h-6 text-surface-500 dark:text-surface-400" /> 245 <span className="text-sm font-medium text-surface-700 dark:text-surface-300"> 246 {isImporting ? "Processing..." : "Click to upload CSV"} 247 </span> 248 <span className="text-xs text-surface-500 dark:text-surface-400"> 249 Required columns: url, text | Optional: title, tags, color, 250 created_at 251 </span> 252 </div> 253 </label> 254 255 <button 256 type="button" 257 onClick={downloadTemplate} 258 className="w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-surface-700 dark:text-surface-300 bg-surface-100 dark:bg-surface-700 hover:bg-surface-200 dark:hover:bg-surface-600 rounded-lg transition" 259 > 260 <Download size={16} /> 261 Download Template 262 </button> 263 </div> 264 ); 265 } 266 267 const successRate = 268 progress.total > 0 269 ? ((progress.completed / progress.total) * 100).toFixed(1) 270 : "0"; 271 272 return ( 273 <div className="w-full space-y-4"> 274 <div className="p-4 bg-surface-50 dark:bg-surface-800 rounded-lg"> 275 <div className="space-y-2"> 276 <div className="flex items-center justify-between"> 277 <span className="text-sm font-medium text-surface-700 dark:text-surface-300"> 278 Import Progress 279 </span> 280 <span className="text-sm text-surface-500 dark:text-surface-400"> 281 {progress.completed} / {progress.total} 282 </span> 283 </div> 284 285 <div className="w-full bg-surface-200 dark:bg-surface-700 rounded-full h-2"> 286 <div 287 className="bg-blue-500 h-2 rounded-full transition-all duration-300" 288 style={{ 289 width: `${(progress.completed / progress.total) * 100}%`, 290 }} 291 /> 292 </div> 293 294 <div className="flex items-center justify-between text-xs text-surface-600 dark:text-surface-400"> 295 <span>{successRate}% complete</span> 296 {progress.failed > 0 && ( 297 <span className="text-red-500">{progress.failed} failed</span> 298 )} 299 </div> 300 </div> 301 </div> 302 303 {isImporting && ( 304 <div className="flex items-center justify-center gap-2 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg"> 305 <Loader2 className="w-4 h-4 animate-spin text-blue-500" /> 306 <span className="text-sm text-blue-700 dark:text-blue-300"> 307 Importing highlights... 308 </span> 309 </div> 310 )} 311 312 {!isImporting && 313 progress.failed === 0 && 314 progress.completed === progress.total && ( 315 <div className="flex items-center justify-center gap-2 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg"> 316 <CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400" /> 317 <span className="text-sm text-green-700 dark:text-green-300"> 318 Successfully imported {progress.completed} highlights! 319 </span> 320 </div> 321 )} 322 323 {progress.errors.length > 0 && ( 324 <div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg"> 325 <div className="flex items-start gap-2 mb-2"> 326 <AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" /> 327 <div> 328 <p className="text-sm font-medium text-red-700 dark:text-red-300"> 329 {progress.errors.length} errors during import 330 </p> 331 <ul className="mt-2 space-y-1"> 332 {progress.errors.slice(0, 5).map((err, idx) => ( 333 <li 334 key={idx} 335 className="text-xs text-red-600 dark:text-red-400" 336 > 337 Row {err.row}: {err.error} 338 </li> 339 ))} 340 {progress.errors.length > 5 && ( 341 <li className="text-xs text-red-600 dark:text-red-400"> 342 +{progress.errors.length - 5} more errors 343 </li> 344 )} 345 </ul> 346 </div> 347 </div> 348 </div> 349 )} 350 351 {!isImporting && ( 352 <button 353 onClick={() => setProgress(null)} 354 className="w-full px-4 py-2 text-sm font-medium bg-surface-200 dark:bg-surface-700 hover:bg-surface-300 dark:hover:bg-surface-600 rounded-lg transition" 355 > 356 Import Another File 357 </button> 358 )} 359 </div> 360 ); 361}