Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
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}