Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments

Merge pull request #36 from tidefield/feat/batch-import-highlights

Add batch highlight import from CSV

authored by

Scan and committed by
GitHub
2f3f1051 5ca84076

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