|
| 1 | +import { h, Fragment } from 'preact'; |
| 2 | +import { useState, useRef, useEffect } from 'preact/hooks'; |
| 3 | +import { selectedCollection } from '../store.js'; |
| 4 | +import { closeModal } from './Modal.jsx'; |
| 5 | +import { |
| 6 | + analyzeDocs, |
| 7 | + dedupeById, |
| 8 | + runChunkedInsert, |
| 9 | + runChunkedOverwrite, |
| 10 | +} from '../importFile.js'; |
| 11 | + |
| 12 | +// Multi-stage Import from JSON File flow: |
| 13 | +// |
| 14 | +// pick → confirm → importing → done |
| 15 | +// |
| 16 | +// We don't probe the collection for conflicting _ids before uploading — |
| 17 | +// the user opted out of that. In-file deduplication still runs locally |
| 18 | +// (free, no network) so the file itself never sends the same _id twice. |
| 19 | +// Overwrite mode delegates conflict resolution to a deleteMany pass over |
| 20 | +// every _id in the file (no-op for ids that don't exist server-side). |
| 21 | + |
| 22 | +const STAGE = { |
| 23 | + PICK: 'pick', |
| 24 | + CONFIRM: 'confirm', |
| 25 | + IMPORTING: 'importing', |
| 26 | + DONE: 'done', |
| 27 | +}; |
| 28 | + |
| 29 | +function formatBytes(n) { |
| 30 | + if (n == null) return ''; |
| 31 | + if (n < 1024) return `${n} B`; |
| 32 | + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; |
| 33 | + return `${(n / (1024 * 1024)).toFixed(1)} MB`; |
| 34 | +} |
| 35 | + |
| 36 | +function formatIdSample(ids, max = 3) { |
| 37 | + const out = []; |
| 38 | + for (let i = 0; i < ids.length && i < max; i++) { |
| 39 | + const v = ids[i]; |
| 40 | + if (v && typeof v === 'object' && '$oid' in v) out.push(String(v.$oid)); |
| 41 | + else if (typeof v === 'string') out.push(v.length > 12 ? v.slice(0, 12) + '…' : v); |
| 42 | + else out.push(String(v)); |
| 43 | + } |
| 44 | + if (ids.length > max) out.push(`+${ids.length - max} more`); |
| 45 | + return out.join(', '); |
| 46 | +} |
| 47 | + |
| 48 | +export default function InsertFileWizard({ onSuccess }) { |
| 49 | + const [stage, setStage] = useState(STAGE.PICK); |
| 50 | + const [fileMeta, setFileMeta] = useState(null); |
| 51 | + const [docs, setDocs] = useState(null); |
| 52 | + const [stats, setStats] = useState(null); |
| 53 | + const [mode, setMode] = useState('insert'); // 'insert' | 'overwrite' |
| 54 | + const [importProgress, setImportProgress] = useState(null); |
| 55 | + const [importResult, setImportResult] = useState(null); |
| 56 | + const [errorMsg, setErrorMsg] = useState(null); |
| 57 | + |
| 58 | + const abortRef = useRef(null); |
| 59 | + |
| 60 | + useEffect(() => () => { abortRef.current?.abort(); }, []); |
| 61 | + |
| 62 | + function handleFile(file) { |
| 63 | + setErrorMsg(null); |
| 64 | + setFileMeta({ name: file.name, size: file.size }); |
| 65 | + file.text().then((text) => { |
| 66 | + let parsed; |
| 67 | + try { parsed = JSON.parse(text); } |
| 68 | + catch (e) { |
| 69 | + setErrorMsg(`Couldn't parse JSON: ${e.message}`); |
| 70 | + return; |
| 71 | + } |
| 72 | + if (!Array.isArray(parsed)) parsed = [parsed]; |
| 73 | + if (parsed.length === 0) { |
| 74 | + setErrorMsg('File contains no documents'); |
| 75 | + return; |
| 76 | + } |
| 77 | + setDocs(parsed); |
| 78 | + setStats(analyzeDocs(parsed)); |
| 79 | + setStage(STAGE.CONFIRM); |
| 80 | + }).catch((err) => { |
| 81 | + setErrorMsg(`Couldn't read file: ${err.message}`); |
| 82 | + }); |
| 83 | + } |
| 84 | + |
| 85 | + async function startImport() { |
| 86 | + if (!docs) return; |
| 87 | + setErrorMsg(null); |
| 88 | + |
| 89 | + const { kept, dropped: inFileDropped } = dedupeById(docs); |
| 90 | + |
| 91 | + setStage(STAGE.IMPORTING); |
| 92 | + const controller = new AbortController(); |
| 93 | + abortRef.current = controller; |
| 94 | + setImportProgress({ phase: 'insert', processed: 0, total: kept.length, inserted: 0, failedBatches: 0 }); |
| 95 | + |
| 96 | + try { |
| 97 | + let result; |
| 98 | + if (mode === 'overwrite' && stats.uniqueIdCount > 0) { |
| 99 | + result = await runChunkedOverwrite(selectedCollection.value, kept, { |
| 100 | + signal: controller.signal, |
| 101 | + onProgress: (p) => setImportProgress({ ...p, total: kept.length }), |
| 102 | + }); |
| 103 | + result.kind = 'overwrite'; |
| 104 | + } else { |
| 105 | + result = await runChunkedInsert(selectedCollection.value, kept, { |
| 106 | + signal: controller.signal, |
| 107 | + onProgress: setImportProgress, |
| 108 | + }); |
| 109 | + result.kind = 'insert'; |
| 110 | + } |
| 111 | + result.inFileDropped = inFileDropped; |
| 112 | + setImportResult(result); |
| 113 | + |
| 114 | + if (result.inserted > 0 || result.deleted > 0) onSuccess?.(); |
| 115 | + setStage(STAGE.DONE); |
| 116 | + } catch (err) { |
| 117 | + setErrorMsg(`Import failed: ${err.message}`); |
| 118 | + setStage(STAGE.CONFIRM); |
| 119 | + } finally { |
| 120 | + abortRef.current = null; |
| 121 | + } |
| 122 | + } |
| 123 | + |
| 124 | + function handleCancel() { |
| 125 | + abortRef.current?.abort(); |
| 126 | + } |
| 127 | + |
| 128 | + return ( |
| 129 | + <div class="modal-body import-wizard"> |
| 130 | + {stage === STAGE.PICK && <StagePick onFile={handleFile} errorMsg={errorMsg} onCancel={closeModal} />} |
| 131 | + |
| 132 | + {stage === STAGE.CONFIRM && stats && ( |
| 133 | + <StageConfirm |
| 134 | + fileMeta={fileMeta} |
| 135 | + stats={stats} |
| 136 | + mode={mode} |
| 137 | + setMode={setMode} |
| 138 | + errorMsg={errorMsg} |
| 139 | + onImport={startImport} |
| 140 | + onCancel={closeModal} |
| 141 | + /> |
| 142 | + )} |
| 143 | + |
| 144 | + {stage === STAGE.IMPORTING && importProgress && ( |
| 145 | + <StageImporting progress={importProgress} mode={mode} onCancel={handleCancel} /> |
| 146 | + )} |
| 147 | + |
| 148 | + {stage === STAGE.DONE && importResult && ( |
| 149 | + <StageDone result={importResult} mode={mode} fileMeta={fileMeta} onClose={closeModal} /> |
| 150 | + )} |
| 151 | + </div> |
| 152 | + ); |
| 153 | +} |
| 154 | + |
| 155 | +// ---- stage components ---- |
| 156 | + |
| 157 | +function StagePick({ onFile, errorMsg, onCancel }) { |
| 158 | + const inputRef = useRef(null); |
| 159 | + function pick(e) { |
| 160 | + const f = e.target.files?.[0]; |
| 161 | + if (f) onFile(f); |
| 162 | + } |
| 163 | + return ( |
| 164 | + <Fragment> |
| 165 | + <div class="modal-field-label">Select a JSON file with documents to insert:</div> |
| 166 | + <input ref={inputRef} type="file" accept=".json,application/json" style="display:none" onChange={pick} /> |
| 167 | + <div class="file-input-area" onClick={() => inputRef.current?.click()}> |
| 168 | + <div class="file-input-label">Click to select a JSON file</div> |
| 169 | + <div class="file-input-info" style="margin-top:4px">Array of documents, or a single document</div> |
| 170 | + </div> |
| 171 | + {errorMsg && <div class="input-hint" style="color:var(--danger)">{errorMsg}</div>} |
| 172 | + <div class="modal-actions"> |
| 173 | + <button class="btn btn-secondary" onClick={onCancel}>Cancel</button> |
| 174 | + </div> |
| 175 | + </Fragment> |
| 176 | + ); |
| 177 | +} |
| 178 | + |
| 179 | +function StageConfirm({ fileMeta, stats, mode, setMode, errorMsg, onImport, onCancel }) { |
| 180 | + const hasInFileDupes = stats.inFileDupeCount > 0; |
| 181 | + const hasIds = stats.withId > 0; |
| 182 | + const willInsert = stats.uniqueIdCount + stats.withoutId; |
| 183 | + |
| 184 | + return ( |
| 185 | + <Fragment> |
| 186 | + <FileSummary fileMeta={fileMeta} stats={stats} /> |
| 187 | + |
| 188 | + {hasInFileDupes && ( |
| 189 | + <div class="import-conflict-info"> |
| 190 | + <strong>{stats.inFileDupeCount.toLocaleString()}</strong> duplicate <code>_id</code>{stats.inFileDupeCount === 1 ? '' : 's'} within the file will be collapsed to one occurrence. |
| 191 | + {stats.inFileDupeIdSample.length > 0 && <div class="import-id-sample">e.g. {formatIdSample(stats.inFileDupeIdSample)}</div>} |
| 192 | + </div> |
| 193 | + )} |
| 194 | + |
| 195 | + {hasIds && ( |
| 196 | + <div> |
| 197 | + <div class="modal-field-label">If a document's <code>_id</code> already exists in <code>{selectedCollection.value}</code>:</div> |
| 198 | + <div class="import-mode-group"> |
| 199 | + <label class={`import-mode-option ${mode === 'insert' ? 'selected' : ''}`}> |
| 200 | + <input type="radio" name="import-mode" value="insert" checked={mode === 'insert'} onChange={() => setMode('insert')} /> |
| 201 | + <span> |
| 202 | + <span class="import-mode-title">Insert (fail on duplicate)</span> |
| 203 | + <span class="import-mode-desc">Send the file as-is. Batches with conflicting <code>_id</code>s will be reported as failures in the summary.</span> |
| 204 | + </span> |
| 205 | + </label> |
| 206 | + <label class={`import-mode-option ${mode === 'overwrite' ? 'selected' : ''}`}> |
| 207 | + <input type="radio" name="import-mode" value="overwrite" checked={mode === 'overwrite'} onChange={() => setMode('overwrite')} /> |
| 208 | + <span> |
| 209 | + <span class="import-mode-title">Overwrite</span> |
| 210 | + <span class="import-mode-desc">Delete any documents whose <code>_id</code> matches the file (no-op for <code>_id</code>s that don't exist), then insert all {willInsert.toLocaleString()} from the file. Idempotent re-import.</span> |
| 211 | + </span> |
| 212 | + </label> |
| 213 | + </div> |
| 214 | + </div> |
| 215 | + )} |
| 216 | + |
| 217 | + {errorMsg && <div class="input-hint" style="color:var(--danger)">{errorMsg}</div>} |
| 218 | + |
| 219 | + <div class="modal-actions"> |
| 220 | + <button class="btn btn-secondary" onClick={onCancel}>Cancel</button> |
| 221 | + <button |
| 222 | + class={`btn ${mode === 'overwrite' ? 'btn-danger' : 'btn-success'}`} |
| 223 | + onClick={onImport} |
| 224 | + disabled={willInsert === 0} |
| 225 | + > |
| 226 | + {mode === 'overwrite' |
| 227 | + ? `Overwrite ${willInsert.toLocaleString()} document${willInsert === 1 ? '' : 's'}` |
| 228 | + : `Insert ${willInsert.toLocaleString()} document${willInsert === 1 ? '' : 's'}`} |
| 229 | + </button> |
| 230 | + </div> |
| 231 | + </Fragment> |
| 232 | + ); |
| 233 | +} |
| 234 | + |
| 235 | +function StageImporting({ progress, mode, onCancel }) { |
| 236 | + const { processed = 0, total = 0, inserted = 0, failedBatches = 0, phase } = progress; |
| 237 | + const pct = total > 0 ? Math.min(100, Math.round((processed / total) * 100)) : 0; |
| 238 | + const label = mode === 'overwrite' && phase === 'delete' |
| 239 | + ? 'Deleting any matching documents' |
| 240 | + : 'Inserting documents'; |
| 241 | + return ( |
| 242 | + <Fragment> |
| 243 | + <div class="modal-message">{label}…</div> |
| 244 | + <div class="import-progress"> |
| 245 | + <div class="import-progress-track"> |
| 246 | + <div class="import-progress-fill" style={`width:${pct}%`}></div> |
| 247 | + </div> |
| 248 | + <div class="import-progress-counts"> |
| 249 | + <span>{processed.toLocaleString()} / {total.toLocaleString()}</span> |
| 250 | + <span>{pct}%</span> |
| 251 | + </div> |
| 252 | + </div> |
| 253 | + <div class="import-progress-meta"> |
| 254 | + {phase !== 'delete' && <span>{inserted.toLocaleString()} inserted</span>} |
| 255 | + {failedBatches > 0 && <span style="color:var(--danger)">{failedBatches} batch{failedBatches === 1 ? '' : 'es'} failed</span>} |
| 256 | + </div> |
| 257 | + <div class="modal-actions"> |
| 258 | + <button class="btn btn-secondary" onClick={onCancel}>Cancel</button> |
| 259 | + </div> |
| 260 | + </Fragment> |
| 261 | + ); |
| 262 | +} |
| 263 | + |
| 264 | +function StageDone({ result, mode, fileMeta, onClose }) { |
| 265 | + const { inserted = 0, deleted = 0, failedBatches = [], inFileDropped = 0, cancelled, kind } = result; |
| 266 | + const overall = failedBatches.length === 0 && !cancelled; |
| 267 | + |
| 268 | + return ( |
| 269 | + <Fragment> |
| 270 | + <div class={`import-result-header ${overall ? 'success' : 'partial'}`}> |
| 271 | + <span class="import-result-icon">{overall ? '✓' : cancelled ? '○' : '⚠'}</span> |
| 272 | + <span> |
| 273 | + {cancelled ? 'Cancelled' : overall ? 'Import complete' : 'Import partially complete'} |
| 274 | + {fileMeta?.name && <span class="import-result-filename"> · {fileMeta.name}</span>} |
| 275 | + </span> |
| 276 | + </div> |
| 277 | + |
| 278 | + <ul class="import-result-list"> |
| 279 | + {kind === 'overwrite' && deleted > 0 && <li>Deleted <strong>{deleted.toLocaleString()}</strong> existing record{deleted === 1 ? '' : 's'}</li>} |
| 280 | + {inserted > 0 && <li>Inserted <strong>{inserted.toLocaleString()}</strong> document{inserted === 1 ? '' : 's'}</li>} |
| 281 | + {inFileDropped > 0 && <li><strong>{inFileDropped.toLocaleString()}</strong> in-file duplicate{inFileDropped === 1 ? '' : 's'} were collapsed</li>} |
| 282 | + {failedBatches.length > 0 && ( |
| 283 | + <li style="color:var(--danger)"> |
| 284 | + <strong>{failedBatches.length}</strong> batch{failedBatches.length === 1 ? '' : 'es'} failed |
| 285 | + <ul class="import-failure-list"> |
| 286 | + {failedBatches.slice(0, 5).map((b) => ( |
| 287 | + <li> |
| 288 | + Records {b.startIdx.toLocaleString()}–{b.endIdx.toLocaleString()} ({b.count.toLocaleString()} docs): <code>{b.message}</code> |
| 289 | + </li> |
| 290 | + ))} |
| 291 | + {failedBatches.length > 5 && <li>{'… and '}{failedBatches.length - 5}{' more'}</li>} |
| 292 | + </ul> |
| 293 | + {failedBatches.some((b) => /batch op errors/i.test(b.message)) && ( |
| 294 | + <div class="import-result-hint"> |
| 295 | + <code>batch op errors occurred</code> typically means at least one document in the batch had a duplicate <code>_id</code> or violated a collection validator. Re-run with Overwrite mode to replace existing records. |
| 296 | + </div> |
| 297 | + )} |
| 298 | + </li> |
| 299 | + )} |
| 300 | + </ul> |
| 301 | + |
| 302 | + <div class="modal-actions"> |
| 303 | + <button class="btn btn-primary" onClick={onClose}>Close</button> |
| 304 | + </div> |
| 305 | + </Fragment> |
| 306 | + ); |
| 307 | +} |
| 308 | + |
| 309 | +function FileSummary({ fileMeta, stats }) { |
| 310 | + if (!fileMeta || !stats) return null; |
| 311 | + const parts = []; |
| 312 | + parts.push(`${stats.total.toLocaleString()} document${stats.total === 1 ? '' : 's'}`); |
| 313 | + if (fileMeta.size) parts.push(formatBytes(fileMeta.size)); |
| 314 | + if (stats.withId === stats.total && stats.total > 0) parts.push('all have _id'); |
| 315 | + else if (stats.withId === 0) parts.push('no explicit _id'); |
| 316 | + else parts.push(`${stats.withId.toLocaleString()} with _id, ${stats.withoutId.toLocaleString()} without`); |
| 317 | + return ( |
| 318 | + <div class="modal-count-info"> |
| 319 | + <div style="font-family:var(--font-mono);font-size:11px;color:var(--text-secondary)">{fileMeta.name}</div> |
| 320 | + <div>{parts.join(' · ')}</div> |
| 321 | + </div> |
| 322 | + ); |
| 323 | +} |
0 commit comments