@acms-subir/core 0.3.1 → 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.
@@ -0,0 +1,284 @@
1
+ // Browser half of the import pipeline: read the source file, build the
2
+ // Import Bundle, re-host its files, then drive /api/import step by step.
3
+ // Progress is journaled in localStorage (keyed by a hash of the file) so a
4
+ // closed tab resumes where it stopped instead of re-uploading everything.
5
+
6
+ import type { Media } from '../../lib/schema';
7
+ import { ImportBundle, resolveAssets, type ImportAsset, type ResolvedAsset } from '../../lib/import/bundle';
8
+ import { ghostToBundle, isGhostExport } from '../../lib/import/ghost';
9
+ import { openZip, type ZipIndex } from '../../lib/import/zipread';
10
+ import { renderMarkdown } from '../../lib/markdown';
11
+ import { DEFAULT_SIZES, mediaSrc, mediaSrcset, mediaUrl, uploadFile, uploadImage } from '../editor/imaging';
12
+
13
+ export interface Source {
14
+ kind: ImportBundle['source']['kind'];
15
+ label: string;
16
+ bundle: ImportBundle;
17
+ // acms exports: the ZIP holding media/<key> binaries
18
+ zip: ZipIndex | null;
19
+ hash: string;
20
+ }
21
+
22
+ export interface ImportOptionsState {
23
+ conflict: 'skip' | 'update' | 'copy';
24
+ identity: boolean;
25
+ nav: boolean;
26
+ redirects: boolean;
27
+ headCode: boolean;
28
+ authors: Record<string, string>;
29
+ }
30
+
31
+ export interface Progress {
32
+ phase: 'assets' | 'start' | 'items' | 'finish' | 'done';
33
+ done: number;
34
+ total: number;
35
+ label: string;
36
+ }
37
+
38
+ async function sha(buf: ArrayBuffer): Promise<string> {
39
+ const d = await crypto.subtle.digest('SHA-256', buf);
40
+ return [...new Uint8Array(d)].slice(0, 12).map((b) => b.toString(16).padStart(2, '0')).join('');
41
+ }
42
+
43
+ export async function readSource(file: File, siteUrl = ''): Promise<Source> {
44
+ const buf = await file.arrayBuffer();
45
+ const hash = await sha(buf);
46
+ const isZip = new Uint8Array(buf.slice(0, 4)).join(',') === '80,75,3,4';
47
+ if (isZip) {
48
+ const zip = openZip(buf);
49
+ if (zip.has('content.json') && !zip.has('bundle.json')) {
50
+ throw new Error('This is an acms backup. Restore it from Settings → Backup instead; it replaces the whole site.');
51
+ }
52
+ if (zip.has('bundle.json')) {
53
+ const bundle = ImportBundle.parse(JSON.parse(new TextDecoder().decode(await zip.read('bundle.json'))));
54
+ return { kind: bundle.source.kind, label: bundle.source.title || file.name, bundle, zip, hash };
55
+ }
56
+ // a Ghost export zipped together with its content folder
57
+ const json = zip.names.find((n) => /\.json$/i.test(n) && !n.includes('/'));
58
+ if (json) {
59
+ const parsed = JSON.parse(new TextDecoder().decode(await zip.read(json)));
60
+ if (isGhostExport(parsed)) {
61
+ const bundle = ghostToBundle(parsed, { siteUrl, markdown: renderMarkdown });
62
+ return { kind: 'ghost', label: bundle.source.title || file.name, bundle, zip, hash };
63
+ }
64
+ }
65
+ throw new Error('This ZIP isn’t an acms export or a Ghost export. For a Ghost images folder, add it under “Images” after choosing the Ghost .json file.');
66
+ }
67
+ let parsed: unknown;
68
+ try { parsed = JSON.parse(new TextDecoder().decode(buf)); }
69
+ catch { throw new Error('Choose a Ghost export (.json) or an acms export (.zip).'); }
70
+ if (isGhostExport(parsed)) {
71
+ const bundle = ghostToBundle(parsed, { siteUrl, markdown: renderMarkdown });
72
+ return { kind: 'ghost', label: bundle.source.title || file.name, bundle, zip: null, hash };
73
+ }
74
+ const format = (parsed as { format?: string })?.format;
75
+ if (format === 'acms-backup') throw new Error('This is an acms backup. Restore it from Settings → Backup instead.');
76
+ if (format === 'acms-export') {
77
+ const bundle = ImportBundle.parse(parsed);
78
+ return { kind: bundle.source.kind, label: bundle.source.title || file.name, bundle, zip: null, hash };
79
+ }
80
+ throw new Error('This file isn’t a format acms can import yet. Supported: Ghost (.json) and acms exports (.zip).');
81
+ }
82
+
83
+ // ---- asset lookup ----
84
+
85
+ const MIME: Record<string, string> = {
86
+ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif', svg: 'image/svg+xml',
87
+ pdf: 'application/pdf', zip: 'application/zip', txt: 'text/plain', csv: 'text/csv', md: 'text/markdown', json: 'application/json',
88
+ doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
89
+ xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
90
+ ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
91
+ epub: 'application/epub+zip',
92
+ };
93
+ const mimeFor = (name: string, fallback = 'application/octet-stream') => MIME[name.split('.').pop()?.toLowerCase() ?? ''] ?? fallback;
94
+
95
+ // A Ghost content ZIP can be rooted anywhere: content/images/…, images/…, or
96
+ // inside a site folder (mysite/content/images/…). Match on the tail.
97
+ export function findInZip(zip: ZipIndex, path: string): string | null {
98
+ if (zip.has(path)) return path;
99
+ const tail = path.replace(/^content\//, '');
100
+ return zip.names.find((n) => n === tail || n.endsWith(`/${path}`) || n.endsWith(`/${tail}`)) ?? null;
101
+ }
102
+
103
+ export function sourceAssetUrl(bundle: ImportBundle, asset: ImportAsset): string {
104
+ if (/^https?:\/\//.test(asset.url)) return asset.url;
105
+ return bundle.source.siteUrl ? `${bundle.source.siteUrl.replace(/\/+$/, '')}/${asset.url}` : `/${asset.url}`;
106
+ }
107
+
108
+ async function fetchAsset(bundle: ImportBundle, asset: ImportAsset, contentZip: ZipIndex | null): Promise<File> {
109
+ const name = asset.filename || asset.url.split('/').pop() || 'file';
110
+ const inZip = contentZip && findInZip(contentZip, asset.url);
111
+ if (inZip) {
112
+ const bytes = await contentZip!.read(inZip);
113
+ return new File([bytes], name, { type: mimeFor(inZip) });
114
+ }
115
+ const url = sourceAssetUrl(bundle, asset);
116
+ if (!/^https?:\/\//.test(url)) throw new Error('Not in the uploaded images ZIP, and no old site URL to download from.');
117
+ const res = await fetch('/api/import/asset', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }) });
118
+ if (!res.ok) throw new Error(((await res.json().catch(() => null)) as { error?: string } | null)?.error ?? `Download failed (${res.status})`);
119
+ const blob = await res.blob();
120
+ return new File([blob], name, { type: mimeFor(name, blob.type || 'application/octet-stream') });
121
+ }
122
+
123
+ // ---- journal ----
124
+
125
+ interface Journal {
126
+ runId: string;
127
+ assets: Record<string, ResolvedAsset & { mediaId: string; sourceId: string }>;
128
+ failed: Record<string, string>;
129
+ done: string[];
130
+ authorIds: Record<string, string | null>;
131
+ }
132
+ const journalKey = (hash: string) => `acms-import:${hash}`;
133
+ export function readJournal(hash: string): Journal | null {
134
+ try { return JSON.parse(localStorage.getItem(journalKey(hash)) ?? 'null'); } catch { return null; }
135
+ }
136
+ function writeJournal(hash: string, j: Journal) {
137
+ try { localStorage.setItem(journalKey(hash), JSON.stringify(j)); } catch { /* quota/private mode: no resume */ }
138
+ }
139
+ export function clearJournal(hash: string) {
140
+ try { localStorage.removeItem(journalKey(hash)); } catch { /* ignore */ }
141
+ }
142
+
143
+ async function api<T>(body: unknown): Promise<T> {
144
+ const res = await fetch('/api/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
145
+ const data = await res.json().catch(() => null);
146
+ if (!res.ok || !data) throw new Error((data as { error?: string } | null)?.error ?? `Import request failed (${res.status})`);
147
+ return data as T;
148
+ }
149
+
150
+ // The plan needs only item headers: bodies can be megabytes.
151
+ export function lite(bundle: ImportBundle): ImportBundle {
152
+ return { ...bundle, items: bundle.items.map((i) => ({ ...i, html: '' })) };
153
+ }
154
+
155
+ export interface RunResult {
156
+ runId: string;
157
+ report: Record<string, unknown>;
158
+ }
159
+
160
+ export async function runImport(
161
+ source: Source,
162
+ options: ImportOptionsState,
163
+ contentZip: ZipIndex | null,
164
+ onProgress: (p: Progress) => void,
165
+ signal: { cancelled: boolean },
166
+ ): Promise<RunResult> {
167
+ const { bundle, hash } = source;
168
+ // a Ghost export zipped together with its content folder serves both roles
169
+ const files = contentZip ?? (source.kind === 'ghost' ? source.zip : null);
170
+ const journal: Journal = readJournal(hash) ?? { runId: '', assets: {}, failed: {}, done: [], authorIds: {} };
171
+ const save = () => writeJournal(hash, journal);
172
+
173
+ // 1. files → media library
174
+ const assets = bundle.assets.filter((a) => a.kind !== 'media');
175
+ const total = assets.length + bundle.media.length;
176
+ let n = 0;
177
+ for (const a of assets) {
178
+ if (signal.cancelled) throw new Error('Import paused. Run it again to continue where it stopped.');
179
+ onProgress({ phase: 'assets', done: n++, total, label: a.filename || a.url });
180
+ if (journal.assets[a.ref]) continue;
181
+ try {
182
+ const file = await fetchAsset(bundle, a, files);
183
+ const media: Media = a.kind === 'image' && file.type.startsWith('image/') ? await uploadImage(file, a.alt) : await uploadFile(file);
184
+ journal.assets[a.ref] = media.mime.startsWith('image/')
185
+ ? { url: mediaSrc(media), srcset: mediaSrcset(media), sizes: media.variants.length ? DEFAULT_SIZES : '', mediaId: media.id, sourceId: a.url }
186
+ : { url: mediaUrl(media.key), mediaId: media.id, sourceId: a.url };
187
+ delete journal.failed[a.ref];
188
+ } catch (e) {
189
+ journal.failed[a.ref] = e instanceof Error ? e.message : 'Download failed';
190
+ }
191
+ save();
192
+ }
193
+ // acms exports: media rows move verbatim with their keys
194
+ for (const m of bundle.media) {
195
+ if (signal.cancelled) throw new Error('Import paused. Run it again to continue where it stopped.');
196
+ onProgress({ phase: 'assets', done: n++, total, label: m.filename });
197
+ if (journal.assets[`m-${m.id}`] || !source.zip) continue;
198
+ try {
199
+ const form = new FormData();
200
+ form.set('media', JSON.stringify(m));
201
+ for (const key of [m.key, ...m.variants.map((v) => v.key)]) {
202
+ form.set(key, new File([await source.zip.read(`media/${key}`)], key.split('/').pop() ?? 'file', { type: key === m.key ? m.mime : 'image/webp' }));
203
+ }
204
+ const res = await fetch('/api/import/media', { method: 'POST', body: form });
205
+ const data = (await res.json().catch(() => null)) as { error?: string; existed?: boolean } | null;
206
+ if (!res.ok) throw new Error(data?.error ?? `Upload failed (${res.status})`);
207
+ journal.assets[`m-${m.id}`] = { url: mediaUrl(m.key), mediaId: data?.existed ? '' : m.id, sourceId: m.id };
208
+ delete journal.failed[`m-${m.id}`];
209
+ } catch (e) {
210
+ journal.failed[`m-${m.id}`] = e instanceof Error ? e.message : 'Upload failed';
211
+ }
212
+ save();
213
+ }
214
+
215
+ const resolved = new Map(Object.entries(journal.assets).map(([ref, r]) => [ref, r]));
216
+ const fallback = new Map(bundle.assets.map((a) => [a.ref, sourceAssetUrl(bundle, a)]));
217
+ const fix = (v: string) => resolveAssets(v, resolved, fallback);
218
+
219
+ // 2. run + authors + tags; only people with a byline become authors
220
+ const writers = new Set(bundle.items.flatMap((i) => i.authors));
221
+ onProgress({ phase: 'start', done: 0, total: 1, label: 'Authors and tags' });
222
+ if (!journal.runId || !(await runIsOpen(journal.runId))) {
223
+ const started = await api<{ runId: string; authorIds: Record<string, string | null> }>({
224
+ op: 'start', source: bundle.source.kind, label: source.label,
225
+ authors: bundle.authors.filter((a) => writers.has(a.sourceId)).map((a) => ({ ...a, avatar: fix(a.avatar) })),
226
+ tags: bundle.tags,
227
+ media: Object.values(journal.assets).filter((r) => r.mediaId).map((r) => ({ sourceId: r.sourceId, id: r.mediaId })),
228
+ options,
229
+ });
230
+ journal.runId = started.runId;
231
+ journal.authorIds = started.authorIds;
232
+ journal.done = [];
233
+ save();
234
+ }
235
+
236
+ // 3. posts & pages, in batches that stay well under the request cap
237
+ const items = bundle.items.filter((i) => !journal.done.includes(i.sourceId));
238
+ let done = bundle.items.length - items.length;
239
+ const batches: typeof items[] = [];
240
+ let cur: typeof items = [], size = 0;
241
+ for (const it of items) {
242
+ const s = it.html.length + 2000;
243
+ if (cur.length && (cur.length >= 10 || size + s > 4_000_000)) { batches.push(cur); cur = []; size = 0; }
244
+ cur.push(it); size += s;
245
+ }
246
+ if (cur.length) batches.push(cur);
247
+ for (const batch of batches) {
248
+ if (signal.cancelled) throw new Error('Import paused. Run it again to continue where it stopped.');
249
+ onProgress({ phase: 'items', done, total: bundle.items.length, label: batch[0].title });
250
+ await api({
251
+ op: 'items', runId: journal.runId, authorIds: journal.authorIds, options,
252
+ items: batch.map((i) => ({ ...i, html: fix(i.html), cover: fix(i.cover) })),
253
+ });
254
+ journal.done.push(...batch.map((i) => i.sourceId));
255
+ done += batch.length;
256
+ save();
257
+ }
258
+
259
+ // 4. redirects, navigation, identity, report
260
+ onProgress({ phase: 'finish', done: 0, total: 1, label: 'Redirects and settings' });
261
+ const identity = Object.fromEntries(Object.entries(bundle.identity).map(([k, v]) => [k, typeof v === 'string' ? fix(v) : v]));
262
+ const failedAssets = Object.entries(journal.failed).map(([ref, error]) => {
263
+ const a = bundle.assets.find((x) => x.ref === ref);
264
+ return { url: a ? sourceAssetUrl(bundle, a) : ref.replace(/^m-/, 'media '), error };
265
+ });
266
+ const { report } = await api<{ report: Record<string, unknown> }>({
267
+ op: 'finish', runId: journal.runId, options, identity,
268
+ nav: bundle.nav.map((n) => ({ ...n, url: fix(n.url) })),
269
+ legacy: bundle.items.filter((i) => i.legacyPath).map((i) => ({ sourceId: i.sourceId, type: i.type, path: i.legacyPath })),
270
+ redirects: bundle.redirects,
271
+ warnings: bundle.warnings,
272
+ failedAssets,
273
+ });
274
+ clearJournal(hash);
275
+ onProgress({ phase: 'done', done: 1, total: 1, label: '' });
276
+ return { runId: journal.runId, report };
277
+ }
278
+
279
+ async function runIsOpen(runId: string): Promise<boolean> {
280
+ const res = await fetch('/api/import');
281
+ if (!res.ok) return false;
282
+ const { runs } = (await res.json()) as { runs: { id: string; status: string }[] };
283
+ return runs.some((r) => r.id === runId && r.status === 'running');
284
+ }
@@ -0,0 +1,50 @@
1
+ // Embeddable-URL detection shared by the editor's embed card (paste →
2
+ // card) and the importers (a Ghost/WordPress embed → embedCard markup).
3
+ export interface EmbedInfo {
4
+ provider: string;
5
+ embedSrc: string;
6
+ aspect: boolean; // true → 16:9, false → fixed height
7
+ }
8
+
9
+ export function detectEmbed(raw: string): EmbedInfo | null {
10
+ let url: URL;
11
+ try {
12
+ url = new URL(raw.trim());
13
+ } catch {
14
+ return null;
15
+ }
16
+ const host = url.hostname.replace(/^www\./, '');
17
+ if (host === 'youtube.com' || host === 'm.youtube.com' || host === 'youtu.be' || host === 'youtube-nocookie.com') {
18
+ const id =
19
+ host === 'youtu.be'
20
+ ? url.pathname.slice(1).split('/')[0]
21
+ : url.pathname.startsWith('/shorts/') || url.pathname.startsWith('/embed/') || url.pathname.startsWith('/live/')
22
+ ? url.pathname.split('/')[2]
23
+ : url.searchParams.get('v');
24
+ // keep a start offset (?start=90, ?t=1h2m3s) — sermon and talk links
25
+ // usually point into the middle of a stream
26
+ const start = youtubeStart(url.searchParams.get('start') ?? url.searchParams.get('t'));
27
+ if (id) return { provider: 'youtube', embedSrc: `https://www.youtube-nocookie.com/embed/${id}${start ? `?start=${start}` : ''}`, aspect: true };
28
+ }
29
+ if (host === 'vimeo.com' || host === 'player.vimeo.com') {
30
+ const id = url.pathname.split('/').filter(Boolean).pop();
31
+ if (id && /^\d+$/.test(id)) return { provider: 'vimeo', embedSrc: `https://player.vimeo.com/video/${id}`, aspect: true };
32
+ }
33
+ if (host === 'loom.com' && url.pathname.startsWith('/share/')) {
34
+ return { provider: 'loom', embedSrc: `https://www.loom.com/embed/${url.pathname.split('/')[2]}`, aspect: true };
35
+ }
36
+ if (host === 'codepen.io' && url.pathname.includes('/pen/')) {
37
+ return { provider: 'codepen', embedSrc: raw.replace('/pen/', '/embed/'), aspect: false };
38
+ }
39
+ if (url.protocol === 'https:') {
40
+ return { provider: 'iframe', embedSrc: raw, aspect: false };
41
+ }
42
+ return null;
43
+ }
44
+
45
+ function youtubeStart(raw: string | null): number {
46
+ if (!raw) return 0;
47
+ if (/^\d+s?$/.test(raw)) return parseInt(raw, 10);
48
+ const m = raw.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
49
+ return m ? Number(m[1] ?? 0) * 3600 + Number(m[2] ?? 0) * 60 + Number(m[3] ?? 0) : 0;
50
+ }