@acms-subir/core 0.3.2 → 0.3.3-next.20260929113209

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/integration.mjs CHANGED
@@ -68,6 +68,7 @@ const ROUTES = [
68
68
  ['/api/email/run', 'api/email/run.ts'],
69
69
  ['/api/ops/status', 'api/ops/status.ts'],
70
70
  ['/admin/ai', 'admin/ai.astro'],
71
+ ['/admin/import', 'admin/import.astro'],
71
72
  ['/admin/tags', 'admin/tags.astro'],
72
73
  ['/admin/users', 'admin/users.astro'],
73
74
  // API
@@ -86,6 +87,10 @@ const ROUTES = [
86
87
  ['/api/mcp', 'api/mcp.ts'],
87
88
  ['/api/media/upload', 'api/media/upload.ts'],
88
89
  ['/api/restore', 'api/restore.ts'],
90
+ ['/api/export', 'api/export.ts'],
91
+ ['/api/import', 'api/import/index.ts'],
92
+ ['/api/import/asset', 'api/import/asset.ts'],
93
+ ['/api/import/media', 'api/import/media.ts'],
89
94
  ];
90
95
 
91
96
  // Packages imported by browser-side code (admin islands and the libs they
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.2",
3
+ "version": "0.3.3-next.20260929113209",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -0,0 +1,519 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { openZip, type ZipIndex } from '../../lib/import/zipread';
3
+ import { clearJournal, lite, readJournal, readSource, runImport, type ImportOptionsState, type Progress, type Source } from './runner';
4
+
5
+ type Tab = 'import' | 'export' | 'history';
6
+ type Counts = { create: number; update: number; skip: number };
7
+ interface Decision { sourceId: string; type: 'post' | 'page'; title: string; slug: string; action: 'create' | 'update' | 'skip'; reason: string }
8
+ interface Plan {
9
+ decisions: Decision[];
10
+ authors: { sourceId: string; name: string; email: string; choice: string; match: string }[];
11
+ users: { id: string; name: string; email: string }[];
12
+ counts: { posts: Counts; pages: Counts; tags: number; assets: number; redirects: number };
13
+ }
14
+ interface Run { id: string; source: string; label: string; status: 'running' | 'done' | 'undone'; report: Report; createdAt: number }
15
+ interface Report {
16
+ counts?: { posts: { created: number; updated: number }; pages: { created: number; updated: number }; users: number; tags: number; media: number; redirects: number; nav: number };
17
+ settings?: string[];
18
+ warnings?: { item: string; code: string; message: string }[];
19
+ failedAssets?: { url: string; error: string }[];
20
+ undo?: { removed: number; kept: string[]; mediaRemoved: number };
21
+ }
22
+
23
+ const SOURCE_NAMES: Record<string, string> = { ghost: 'Ghost', acms: 'acms', wordpress: 'WordPress', markdown: 'Markdown' };
24
+ const DEFAULTS: ImportOptionsState = { conflict: 'skip', identity: true, nav: true, redirects: true, headCode: false, authors: {} };
25
+ const SETTING_NAMES: Record<string, string> = {
26
+ siteTitle: 'title', tagline: 'description', siteLogo: 'logo', siteFavicon: 'icon', siteCover: 'cover image',
27
+ timezone: 'timezone', locale: 'language', headCode: 'head code',
28
+ };
29
+ const settingNames = (keys: string[]) => keys.map((k) => SETTING_NAMES[k] ?? k).join(', ');
30
+ const REASONS: Record<string, string> = { exists: 'slug already used', imported: 'imported before', done: 'already done' };
31
+
32
+ async function post<T>(body: unknown): Promise<T> {
33
+ const res = await fetch('/api/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
34
+ const data = await res.json().catch(() => null);
35
+ if (!res.ok || !data) throw new Error((data as { error?: string } | null)?.error ?? 'Request failed. Try again.');
36
+ return data as T;
37
+ }
38
+ const errText = (e: unknown) => (e instanceof Error && e.message ? e.message : 'Something went wrong. Try again.');
39
+ const date = (ms: number) => new Date(ms).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
40
+ const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
41
+
42
+ export default function ImportExport({ initialTab = 'import' }: { initialTab?: Tab }) {
43
+ const [tab, setTab] = useState<Tab>(initialTab);
44
+ const [runs, setRuns] = useState<Run[] | null>(null);
45
+
46
+ async function loadRuns() {
47
+ const res = await fetch('/api/import');
48
+ if (res.ok) setRuns(((await res.json()) as { runs: Run[] }).runs);
49
+ }
50
+ useEffect(() => { void loadRuns(); }, []);
51
+
52
+ return (
53
+ <div className="imp">
54
+ <style>{CSS}</style>
55
+ <header className="page-head">
56
+ <div>
57
+ <h1>Import &amp; export</h1>
58
+ <p className="sub">Bring content in from another CMS, or move it between acms sites.</p>
59
+ </div>
60
+ </header>
61
+ <nav className="imp-tabs" role="tablist">
62
+ {(['import', 'export', 'history'] as const).map((t) => (
63
+ <button key={t} type="button" role="tab" aria-selected={tab === t} className={tab === t ? 'on' : ''} onClick={() => setTab(t)}>
64
+ {t === 'import' ? 'Import' : t === 'export' ? 'Export' : `History${runs?.length ? ` · ${runs.length}` : ''}`}
65
+ </button>
66
+ ))}
67
+ <a href="/admin/settings#backup">Backup &amp; restore</a>
68
+ </nav>
69
+ {tab === 'import' && <ImportTab onFinished={loadRuns} openHistory={() => setTab('history')} />}
70
+ {tab === 'export' && <ExportTab />}
71
+ {tab === 'history' && <HistoryTab runs={runs} reload={loadRuns} />}
72
+ </div>
73
+ );
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+
78
+ function ImportTab({ onFinished, openHistory }: { onFinished: () => void; openHistory: () => void }) {
79
+ const [file, setFile] = useState<File | null>(null);
80
+ const [siteUrl, setSiteUrl] = useState('');
81
+ const [source, setSource] = useState<Source | null>(null);
82
+ const [contentZip, setContentZip] = useState<{ name: string; zip: ZipIndex } | null>(null);
83
+ const [options, setOptions] = useState<ImportOptionsState>(DEFAULTS);
84
+ const [plan, setPlan] = useState<Plan | null>(null);
85
+ const [planKey, setPlanKey] = useState('');
86
+ const [busy, setBusy] = useState<'read' | 'plan' | 'run' | null>(null);
87
+ const [progress, setProgress] = useState<Progress | null>(null);
88
+ const [error, setError] = useState('');
89
+ const [result, setResult] = useState<Report | null>(null);
90
+ const [showWarnings, setShowWarnings] = useState(false);
91
+ const [showSkipped, setShowSkipped] = useState(false);
92
+ const signal = useRef({ cancelled: false });
93
+
94
+ const bundle = source?.bundle;
95
+ const isGhost = source?.kind === 'ghost';
96
+ // author picks don't change the dry run, so they don't make it stale
97
+ const planKeyFor = (o: ImportOptionsState) => JSON.stringify({ hash: source?.hash, siteUrl, conflict: o.conflict, redirects: o.redirects });
98
+ const key = planKeyFor(options);
99
+ const stale = !!plan && planKey !== key;
100
+ const assetFiles = bundle ? bundle.assets.filter((a) => a.kind !== 'media').length : 0;
101
+ const needsFiles = isGhost && assetFiles > 0 && !siteUrl.trim() && !contentZip && !source?.zip;
102
+ const resumable = source ? readJournal(source.hash) : null;
103
+ const toImport = plan ? plan.counts.posts.create + plan.counts.posts.update + plan.counts.pages.create + plan.counts.pages.update : 0;
104
+
105
+ async function pick(f: File | undefined) {
106
+ if (!f) return;
107
+ setFile(f); setError(''); setPlan(null); setResult(null); setSource(null); setBusy('read');
108
+ try {
109
+ const s = await readSource(f, siteUrl.trim());
110
+ setSource(s);
111
+ setOptions({ ...DEFAULTS, identity: s.kind === 'ghost', nav: s.kind === 'ghost' });
112
+ } catch (e) { setError(errText(e)); }
113
+ finally { setBusy(null); }
114
+ }
115
+
116
+ // absolute links back to the old site become relative, so a new address
117
+ // means parsing the export again. Check and Import call this too, so a
118
+ // typed address counts even if the field never lost focus.
119
+ async function applySiteUrl(): Promise<Source | null> {
120
+ const url = siteUrl.trim().replace(/\/+$/, '');
121
+ if (url && !/^https?:\/\/[^/\s]+$/.test(url)) { setError('Enter the old site’s address, like https://yoursite.com'); return null; }
122
+ setError('');
123
+ if (file && isGhost && url !== source?.bundle.source.siteUrl) {
124
+ try { const s = await readSource(file, url); setSource(s); return s; } catch (e) { setError(errText(e)); return null; }
125
+ }
126
+ return source;
127
+ }
128
+
129
+ async function pickContent(f: File | undefined) {
130
+ if (!f) return;
131
+ try { setContentZip({ name: f.name, zip: openZip(await f.arrayBuffer()) }); setError(''); }
132
+ catch (e) { setError(errText(e)); }
133
+ }
134
+
135
+ async function check() {
136
+ const current = await applySiteUrl();
137
+ if (!current) return;
138
+ setBusy('plan'); setError('');
139
+ try {
140
+ const p = await post<Plan>({ op: 'plan', bundle: lite(current.bundle), options });
141
+ setPlan(p);
142
+ setOptions({ ...options, authors: Object.fromEntries(p.authors.map((a) => [a.sourceId, options.authors[a.sourceId] ?? a.choice])) });
143
+ setPlanKey(planKeyFor(options));
144
+ } catch (e) { setError(errText(e)); }
145
+ finally { setBusy(null); }
146
+ }
147
+
148
+ async function run() {
149
+ const current = await applySiteUrl();
150
+ if (!current) return;
151
+ setBusy('run'); setError(''); signal.current = { cancelled: false };
152
+ try {
153
+ const { report } = await runImport(current, options, contentZip?.zip ?? null, setProgress, signal.current);
154
+ setResult(report as Report);
155
+ onFinished();
156
+ } catch (e) { setError(errText(e)); }
157
+ finally { setBusy(null); setProgress(null); }
158
+ }
159
+
160
+ function reset() {
161
+ setFile(null); setSource(null); setPlan(null); setResult(null); setContentZip(null); setSiteUrl(''); setError('');
162
+ }
163
+
164
+ if (result) return <Done report={result} reset={reset} openHistory={openHistory} />;
165
+
166
+ return (
167
+ <div className="imp-grid">
168
+ <section className="imp-card">
169
+ <label className={`imp-drop${file ? ' has-file' : ''}`}>
170
+ <input type="file" accept=".json,.zip,application/json,application/zip" disabled={!!busy} onChange={(e) => pick(e.target.files?.[0])} />
171
+ {file ? (
172
+ <span><b>{file.name}</b> · {(file.size / 1024 / 1024).toFixed(1)} MB{source && <> · detected <em>{SOURCE_NAMES[source.kind]}{source.bundle.source.version ? ` ${source.bundle.source.version.split('.').slice(0, 2).join('.')}` : ''}</em></>}</span>
173
+ ) : (
174
+ <span><b>Choose an export file</b> or drop it here</span>
175
+ )}
176
+ <small>{busy === 'read' ? 'Reading…' : file ? 'Choose another file' : 'Ghost (.json) · acms export (.zip) · WordPress and Markdown are next'}</small>
177
+ </label>
178
+
179
+ {source && bundle && (
180
+ <>
181
+ <dl className="imp-found">
182
+ <div><dt>Posts</dt><dd>{bundle.items.filter((i) => i.type === 'post').length}</dd></div>
183
+ <div><dt>Pages</dt><dd>{bundle.items.filter((i) => i.type === 'page').length}</dd></div>
184
+ <div><dt>Tags</dt><dd>{bundle.tags.length}</dd></div>
185
+ <div><dt>Authors</dt><dd>{bundle.authors.length}</dd></div>
186
+ <div><dt>Files</dt><dd>{assetFiles + bundle.media.length}</dd></div>
187
+ </dl>
188
+
189
+ {isGhost && (
190
+ <div className="imp-files">
191
+ <h3>Images and files</h3>
192
+ <p className="hint">Ghost exports don’t include images; the {plural(assetFiles, 'file')} they use are copied from the old site, or from its <code>content</code> folder as a ZIP if the site is offline.</p>
193
+ <label className="field"><span>Old site address</span>
194
+ <input type="url" placeholder="https://yoursite.com" value={siteUrl} disabled={!!busy} onChange={(e) => setSiteUrl(e.target.value)} onBlur={() => void applySiteUrl()} />
195
+ </label>
196
+ <label className="imp-inline-file">
197
+ <input type="file" accept=".zip,application/zip" disabled={!!busy} onChange={(e) => pickContent(e.target.files?.[0])} />
198
+ <span className="btn sm">{contentZip ? 'Change ZIP' : 'Add content folder ZIP'}</span>
199
+ <span className="hint">{contentZip ? `${contentZip.name} · ${plural(contentZip.zip.names.length, 'file')}` : 'Optional. Used first; the address is the fallback.'}</span>
200
+ </label>
201
+ {needsFiles && <p className="imp-mark warn">Without an address or ZIP, images will point at the old site and break when it goes away.</p>}
202
+ </div>
203
+ )}
204
+
205
+ <div className="imp-opts">
206
+ <div className="imp-opt">
207
+ <span>If a slug already exists</span>
208
+ <div className="seg" role="radiogroup">
209
+ {([['skip', 'Skip'], ['update', 'Update'], ['copy', 'Import as copy']] as const).map(([v, l]) => (
210
+ <button key={v} type="button" role="radio" aria-checked={options.conflict === v} className={options.conflict === v ? 'on' : ''} onClick={() => setOptions({ ...options, conflict: v })}>{l}</button>
211
+ ))}
212
+ </div>
213
+ </div>
214
+ <div className="imp-opt">
215
+ <span>Also import</span>
216
+ <div className="imp-checks">
217
+ <label className="check-row"><input type="checkbox" checked={options.identity} onChange={(e) => setOptions({ ...options, identity: e.target.checked })} />Site title, logo and icon</label>
218
+ {bundle.nav.length > 0 && <label className="check-row"><input type="checkbox" checked={options.nav} onChange={(e) => setOptions({ ...options, nav: e.target.checked })} />Navigation ({bundle.nav.length})</label>}
219
+ <label className="check-row"><input type="checkbox" checked={options.redirects} onChange={(e) => setOptions({ ...options, redirects: e.target.checked })} />Redirects from old URLs</label>
220
+ {bundle.identity.headCode && <label className="check-row"><input type="checkbox" checked={options.headCode} onChange={(e) => setOptions({ ...options, headCode: e.target.checked })} />Head code <span className="hint">(analytics scripts: prefer Settings → Analytics)</span></label>}
221
+ </div>
222
+ </div>
223
+ </div>
224
+
225
+ {plan && plan.authors.length > 0 && (
226
+ <div className="imp-authors">
227
+ <h3>Authors</h3>
228
+ <p className="hint">New authors get a byline and an author page, but can’t sign in until you set a password in Users.</p>
229
+ {plan.authors.map((a) => (
230
+ <div key={a.sourceId} className="imp-author">
231
+ <div><b>{a.name}</b><small>{a.email}</small></div>
232
+ <select value={options.authors[a.sourceId] ?? a.choice} onChange={(e) => setOptions({ ...options, authors: { ...options.authors, [a.sourceId]: e.target.value } })}>
233
+ <option value="create">Create new author</option>
234
+ <option value="skip">Leave off bylines</option>
235
+ {plan.users.length > 0 && (
236
+ <optgroup label="Existing user">
237
+ {plan.users.map((u) => <option key={u.id} value={u.id}>{u.id === a.match ? `${u.name} (same email)` : u.name}</option>)}
238
+ </optgroup>
239
+ )}
240
+ {plan.authors.length > 1 && (
241
+ <optgroup label="Same person as">
242
+ {plan.authors.filter((o) => o.sourceId !== a.sourceId && !(options.authors[o.sourceId] ?? '').startsWith('as:')).map((o) => <option key={o.sourceId} value={`as:${o.sourceId}`}>{o.name}</option>)}
243
+ </optgroup>
244
+ )}
245
+ </select>
246
+ </div>
247
+ ))}
248
+ </div>
249
+ )}
250
+
251
+ {error && <p className="flash error">{error}</p>}
252
+ <div className="imp-actions">
253
+ <button type="button" className="btn" disabled={!!busy} onClick={check}>{busy === 'plan' ? 'Checking…' : plan ? 'Check again' : 'Check import'}</button>
254
+ {plan && (
255
+ <button type="button" className="btn primary" disabled={!!busy || stale || toImport === 0} onClick={run} title={stale ? 'Options changed: check again first' : ''}>
256
+ {busy === 'run' ? 'Importing…' : resumable?.runId ? `Resume import` : `Import ${plural(toImport, 'item')}`}
257
+ </button>
258
+ )}
259
+ {busy === 'run' && <button type="button" className="btn ghost" onClick={() => { signal.current.cancelled = true; }}>Pause</button>}
260
+ {resumable && !busy && <button type="button" className="btn ghost" onClick={() => { clearJournal(source.hash); setPlan(null); }}>Start over</button>}
261
+ </div>
262
+ {stale && <p className="hint">Options changed since the check. Check again to see the effect.</p>}
263
+ </>
264
+ )}
265
+ {!source && error && <p className="flash error">{error}</p>}
266
+ </section>
267
+
268
+ <aside className="imp-side">
269
+ {progress ? (
270
+ <div className="imp-card">
271
+ <h3>{progress.phase === 'assets' ? 'Copying files' : progress.phase === 'items' ? 'Importing posts and pages' : progress.phase === 'start' ? 'Adding authors and tags' : 'Finishing'}</h3>
272
+ <div className="imp-bar"><i style={{ width: `${progress.total ? Math.round((progress.done / progress.total) * 100) : 0}%` }} /></div>
273
+ <p className="hint">{progress.done} / {progress.total} · {progress.label}</p>
274
+ <p className="hint">You can leave this page; running the import again picks up where it stopped.</p>
275
+ </div>
276
+ ) : plan ? (
277
+ <div className="imp-card">
278
+ <h3>Dry run</h3>
279
+ <table className="imp-plan">
280
+ <thead><tr><th /><th>Create</th><th>Update</th><th>Skip</th></tr></thead>
281
+ <tbody>
282
+ <tr><td>Posts</td><Cells c={plan.counts.posts} /></tr>
283
+ <tr><td>Pages</td><Cells c={plan.counts.pages} /></tr>
284
+ <tr><td>Tags</td><td className="new">{plan.counts.tags}</td><td /><td /></tr>
285
+ <tr><td>Files</td><td className="new">{plan.counts.assets}</td><td /><td /></tr>
286
+ <tr><td>Redirects</td><td className="new">{plan.counts.redirects}</td><td /><td /></tr>
287
+ </tbody>
288
+ </table>
289
+ {plan.decisions.some((d) => d.action === 'skip') && (
290
+ <>
291
+ <button type="button" className="imp-link" onClick={() => setShowSkipped(!showSkipped)}>{showSkipped ? 'Hide' : 'Show'} skipped</button>
292
+ {showSkipped && (
293
+ <ul className="imp-list">
294
+ {plan.decisions.filter((d) => d.action === 'skip').map((d) => <li key={d.sourceId}>{d.title}<small>{REASONS[d.reason] ?? d.reason}</small></li>)}
295
+ </ul>
296
+ )}
297
+ </>
298
+ )}
299
+ {bundle && bundle.warnings.length > 0 && (
300
+ <>
301
+ <p className="imp-mark warn">{plural(bundle.warnings.length, 'warning')}</p>
302
+ <button type="button" className="imp-link" onClick={() => setShowWarnings(!showWarnings)}>{showWarnings ? 'Hide' : 'Show'} warnings</button>
303
+ {showWarnings && <ul className="imp-list">{bundle.warnings.map((w, i) => <li key={i}>{w.message}</li>)}</ul>}
304
+ </>
305
+ )}
306
+ </div>
307
+ ) : (
308
+ <div className="imp-card imp-help">
309
+ <h3>How importing works</h3>
310
+ <ol>
311
+ <li>Choose an export. Nothing is saved yet.</li>
312
+ <li><b>Check import</b> shows what will be created, updated or skipped.</li>
313
+ <li>Import. Existing content is never deleted, and History can undo the run.</li>
314
+ </ol>
315
+ <p className="hint">The export file stays in your browser; only its content is sent, never Ghost’s keys or member data.</p>
316
+ </div>
317
+ )}
318
+ </aside>
319
+ </div>
320
+ );
321
+ }
322
+
323
+ function Cells({ c }: { c: Counts }) {
324
+ return (<><td className={c.create ? 'new' : ''}>{c.create}</td><td>{c.update}</td><td className={c.skip ? 'muted' : ''}>{c.skip}</td></>);
325
+ }
326
+
327
+ function Done({ report, reset, openHistory }: { report: Report; reset: () => void; openHistory: () => void }) {
328
+ const c = report.counts;
329
+ return (
330
+ <section className="imp-card imp-done">
331
+ <p className="imp-mark ok">Import finished</p>
332
+ {c && (
333
+ <p>
334
+ {plural(c.posts.created, 'post')} and {plural(c.pages.created, 'page')} created
335
+ {c.posts.updated + c.pages.updated ? `, ${c.posts.updated + c.pages.updated} updated` : ''} · {plural(c.media, 'file')} · {plural(c.users, 'author')} · {plural(c.tags, 'tag')} · {plural(c.redirects, 'redirect')}
336
+ {report.settings?.length ? ' · site identity updated' : ''}
337
+ </p>
338
+ )}
339
+ {!!report.failedAssets?.length && (
340
+ <>
341
+ <p className="imp-mark warn">{plural(report.failedAssets.length, 'file')} couldn’t be copied and still point at the old site</p>
342
+ <ul className="imp-list">{report.failedAssets.map((f, i) => <li key={i}>{f.url}<small>{f.error}</small></li>)}</ul>
343
+ </>
344
+ )}
345
+ {!!report.warnings?.length && (
346
+ <>
347
+ <p className="imp-mark warn">{plural(report.warnings.length, 'thing')} to review</p>
348
+ <ul className="imp-list">{report.warnings.map((w, i) => <li key={i}>{w.message}</li>)}</ul>
349
+ </>
350
+ )}
351
+ <div className="imp-actions">
352
+ <a className="btn primary" href="/admin/c/posts">View posts</a>
353
+ <button type="button" className="btn" onClick={openHistory}>History</button>
354
+ <button type="button" className="btn ghost" onClick={reset}>Import another file</button>
355
+ </div>
356
+ </section>
357
+ );
358
+ }
359
+
360
+ // ---------------------------------------------------------------------------
361
+
362
+ function ExportTab() {
363
+ return (
364
+ <div className="imp-grid">
365
+ <section className="imp-card">
366
+ <h3>Export content</h3>
367
+ <p>A ZIP with every post, page, tag, author byline, navigation item and redirect, plus your uploaded media. Import it into any acms site; it merges with what’s there instead of replacing it.</p>
368
+ <p className="hint">Not included: bookings, users’ passwords, API keys, email and provider settings. For a complete copy of this site, use a backup.</p>
369
+ <div className="imp-actions"><a className="btn primary" href="/api/export" download>Download export</a></div>
370
+ </section>
371
+ <aside className="imp-side">
372
+ <div className="imp-card imp-help">
373
+ <h3>Export or backup?</h3>
374
+ <table className="imp-plan imp-compare">
375
+ <tbody>
376
+ <tr><td /><th>Export</th><th>Backup</th></tr>
377
+ <tr><td>Use for</td><td>Moving content</td><td>Recovery</td></tr>
378
+ <tr><td>Import</td><td>Merges</td><td>Replaces all</td></tr>
379
+ <tr><td>Bookings</td><td>No</td><td>Yes</td></tr>
380
+ </tbody>
381
+ </table>
382
+ </div>
383
+ </aside>
384
+ </div>
385
+ );
386
+ }
387
+
388
+ // ---------------------------------------------------------------------------
389
+
390
+ function HistoryTab({ runs, reload }: { runs: Run[] | null; reload: () => Promise<void> }) {
391
+ const [open, setOpen] = useState('');
392
+ const [confirming, setConfirming] = useState('');
393
+ const [removeMedia, setRemoveMedia] = useState(true);
394
+ const [busy, setBusy] = useState('');
395
+ const [error, setError] = useState('');
396
+
397
+ async function undo(run: Run) {
398
+ setBusy(run.id); setError('');
399
+ try { await post({ op: 'undo', runId: run.id, removeMedia }); await reload(); setConfirming(''); setOpen(run.id); }
400
+ catch (e) { setError(errText(e)); }
401
+ finally { setBusy(''); }
402
+ }
403
+
404
+ if (!runs) return <p className="hint">Loading…</p>;
405
+ if (!runs.length) return <div className="imp-card empty-state"><b>No imports yet</b><p>Imports you run appear here with their report, and can be undone.</p></div>;
406
+ return (
407
+ <section className="imp-card imp-history">
408
+ {error && <p className="flash error">{error}</p>}
409
+ {runs.map((r) => {
410
+ const c = r.report.counts;
411
+ return (
412
+ <div key={r.id} className="imp-run">
413
+ <div className="imp-run-head">
414
+ <div className="imp-run-title">
415
+ <b>{r.label || SOURCE_NAMES[r.source] || r.source}</b>
416
+ <small>{SOURCE_NAMES[r.source] ?? r.source} · {date(r.createdAt)}</small>
417
+ </div>
418
+ <span className={`imp-mark ${r.status === 'done' ? 'ok' : r.status === 'undone' ? 'muted' : 'warn'}`}>{r.status === 'done' ? 'Imported' : r.status === 'undone' ? 'Undone' : 'Unfinished'}</span>
419
+ {c && <span className="hint">{plural(c.posts.created + c.pages.created, 'item')} · {plural(c.media, 'file')}</span>}
420
+ <div className="imp-run-actions">
421
+ <button type="button" className="btn sm ghost" onClick={() => setOpen(open === r.id ? '' : r.id)}>{open === r.id ? 'Hide' : 'Report'}</button>
422
+ {r.status !== 'undone' && confirming !== r.id && <button type="button" className="btn sm danger" onClick={() => { setConfirming(r.id); setRemoveMedia(true); }}>Undo</button>}
423
+ </div>
424
+ </div>
425
+ {confirming === r.id && (
426
+ <div className="imp-confirm">
427
+ <p>Delete the posts and pages this import created, plus the authors, tags, redirects and menu items it added? Anything edited since the import is kept.</p>
428
+ {!!c?.media && <label className="check-row"><input type="checkbox" checked={removeMedia} onChange={(e) => setRemoveMedia(e.target.checked)} />Also delete the {plural(c.media, 'file')} it uploaded</label>}
429
+ <div className="imp-actions">
430
+ <button type="button" className="btn sm danger" disabled={busy === r.id} onClick={() => undo(r)}>{busy === r.id ? 'Undoing…' : 'Undo import'}</button>
431
+ <button type="button" className="btn sm ghost" disabled={busy === r.id} onClick={() => setConfirming('')}>Cancel</button>
432
+ </div>
433
+ </div>
434
+ )}
435
+ {open === r.id && (
436
+ <div className="imp-run-body">
437
+ {c && <p>{plural(c.posts.created, 'post')}, {plural(c.pages.created, 'page')} created; {c.posts.updated + c.pages.updated} updated · {plural(c.users, 'author')} · {plural(c.tags, 'tag')} · {plural(c.redirects, 'redirect')} · {plural(c.nav, 'menu item')}{r.report.settings?.length ? ` · site ${settingNames(r.report.settings)}` : ''}</p>}
438
+ {r.report.undo && <p className="hint">Undo removed {plural(r.report.undo.removed, 'item')}{r.report.undo.mediaRemoved ? ` and ${plural(r.report.undo.mediaRemoved, 'file')}` : ''}.{r.report.undo.kept.length ? ` Kept because they were edited: ${r.report.undo.kept.join(', ')}.` : ''}</p>}
439
+ {!!r.report.failedAssets?.length && <ul className="imp-list">{r.report.failedAssets.map((f, i) => <li key={i}>{f.url}<small>{f.error}</small></li>)}</ul>}
440
+ {!!r.report.warnings?.length && <ul className="imp-list">{r.report.warnings.map((w, i) => <li key={i}>{w.message}</li>)}</ul>}
441
+ {r.status === 'running' && <p className="hint">This run stopped before finishing. Choose the same file on the Import tab to resume it.</p>}
442
+ </div>
443
+ )}
444
+ </div>
445
+ );
446
+ })}
447
+ </section>
448
+ );
449
+ }
450
+
451
+ const CSS = `
452
+ .imp{max-width:1080px}
453
+ .imp-tabs{display:flex;gap:20px;border-bottom:1px solid var(--line);margin:0 0 18px;align-items:flex-end}
454
+ .imp-tabs button,.imp-tabs a{background:none;border:0;padding:8px 0;font:600 13.5px/1.2 inherit;color:var(--muted);cursor:pointer;text-decoration:none;box-shadow:inset 0 -2px 0 transparent}
455
+ .imp-tabs button.on{color:var(--text);box-shadow:inset 0 -2px 0 var(--accent)}
456
+ .imp-tabs button:hover,.imp-tabs a:hover{color:var(--text)}
457
+ .imp-tabs a{margin-left:auto;font-weight:500}
458
+ .imp-grid{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:16px;align-items:start}
459
+ .imp-card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r-lg);box-shadow:var(--shadow-card);padding:20px 22px}
460
+ .imp-card h3{font-size:14px;margin:0 0 8px;letter-spacing:-.01em}
461
+ .imp-card p{margin:0 0 10px;font-size:13.5px}
462
+ .imp-side{display:flex;flex-direction:column;gap:12px;position:sticky;top:16px}
463
+ .imp-drop{display:flex;flex-direction:column;align-items:center;gap:4px;border:1.5px dashed var(--line-strong);border-radius:12px;padding:22px;text-align:center;cursor:pointer;background:var(--bg);transition:border-color .15s}
464
+ .imp-drop:hover{border-color:var(--accent)}
465
+ .imp-drop.has-file{padding:14px}
466
+ .imp-drop input{position:absolute;opacity:0;width:1px;height:1px}
467
+ .imp-drop em{font-style:normal;color:var(--accent-deep);font-weight:600}
468
+ .imp-drop small{color:var(--faint);font-size:12px}
469
+ .imp-found{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin:14px 0 4px}
470
+ .imp-found div{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:8px 10px}
471
+ .imp-found dt{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;font-weight:600}
472
+ .imp-found dd{margin:2px 0 0;font-size:20px;font-weight:650;font-variant-numeric:tabular-nums;letter-spacing:-.02em}
473
+ .imp-files,.imp-authors{border-top:1px solid var(--line);margin-top:16px;padding-top:14px}
474
+ .imp-files .field{margin:10px 0 8px}
475
+ .imp-inline-file{display:flex;align-items:center;gap:10px;cursor:pointer}
476
+ .imp-inline-file input{position:absolute;opacity:0;width:1px;height:1px}
477
+ .imp-opts{border-top:1px solid var(--line);margin-top:16px;padding-top:14px;display:grid;gap:12px}
478
+ .imp-opt{display:grid;grid-template-columns:170px 1fr;gap:10px;align-items:start}
479
+ .imp-opt > span{font-size:12.5px;color:var(--muted);font-weight:600;padding-top:6px}
480
+ .imp-checks .check-row{margin:4px 0}
481
+ .imp-author{display:grid;grid-template-columns:1fr 240px;gap:10px;align-items:center;padding:8px 0;border-bottom:1px solid var(--line)}
482
+ .imp-author:last-child{border-bottom:0}
483
+ .imp-author small{display:block;color:var(--faint);font-size:12px}
484
+ .imp-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:16px}
485
+ .imp-bar{height:8px;background:var(--sel);border-radius:4px;overflow:hidden;margin:10px 0 8px}
486
+ .imp-bar i{display:block;height:100%;background:var(--accent);transition:width .3s}
487
+ .imp-plan{width:100%;border-collapse:collapse;font-size:13px;margin:4px 0 10px}
488
+ .imp-plan th{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);font-weight:600;text-align:right;padding:6px 4px}
489
+ .imp-plan td{padding:7px 4px;border-top:1px solid var(--line);text-align:right;font-variant-numeric:tabular-nums}
490
+ .imp-plan td:first-child{text-align:left;color:var(--text)}
491
+ .imp-plan td.new{color:var(--accent-deep);font-weight:650}
492
+ .imp-plan td.muted{color:var(--faint)}
493
+ .imp-compare th,.imp-compare td{text-align:left}
494
+ .imp-mark{display:inline-flex;align-items:center;gap:7px;font-weight:600;font-size:13px;margin:8px 0 4px}
495
+ .imp-mark::before{content:"";width:8px;height:8px;border-radius:2px;background:currentColor;flex:none}
496
+ .imp-mark.ok{color:var(--green)}
497
+ .imp-mark.warn{color:var(--amber)}
498
+ .imp-mark.muted{color:var(--faint)}
499
+ .imp-link{background:none;border:0;padding:0;color:var(--accent-deep);font:600 12.5px inherit;cursor:pointer;display:block;margin:2px 0 6px}
500
+ .imp-list{margin:4px 0 8px;padding:0;list-style:none;max-height:260px;overflow:auto;font-size:12.5px}
501
+ .imp-list li{padding:6px 0;border-bottom:1px solid var(--line);overflow-wrap:anywhere}
502
+ .imp-list li:last-child{border-bottom:0}
503
+ .imp-list small{display:block;color:var(--faint)}
504
+ .imp-help ol{margin:0 0 10px;padding-left:18px;font-size:13px}
505
+ .imp-help li{margin:4px 0}
506
+ .imp-done{max-width:720px}
507
+ .imp-run{border-bottom:1px solid var(--line);padding:12px 0}
508
+ .imp-run:first-child{padding-top:0}
509
+ .imp-run:last-child{border-bottom:0;padding-bottom:0}
510
+ .imp-run-head{display:flex;align-items:center;gap:14px;flex-wrap:wrap}
511
+ .imp-run-head .imp-mark{margin:0}
512
+ .imp-run-title{flex:1;min-width:200px}
513
+ .imp-run-title small{display:block;color:var(--faint);font-size:12px}
514
+ .imp-run-actions{display:flex;gap:6px}
515
+ .imp-run-body{margin-top:10px;font-size:13px}
516
+ .imp-confirm{margin-top:10px;background:var(--red-soft);border-radius:10px;padding:12px 14px;font-size:13px}
517
+ .imp-confirm .imp-actions{margin-top:8px}
518
+ @media(max-width:900px){.imp-grid{grid-template-columns:1fr}.imp-side{position:static}.imp-found{grid-template-columns:repeat(3,1fr)}.imp-opt,.imp-author{grid-template-columns:1fr}}
519
+ `;