@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.
@@ -0,0 +1,430 @@
1
+ import { z } from 'zod';
2
+ import { Page, Post, normalizeRedirectPath, slugify, type User } from '../schema';
3
+ import { CONTENT, collectionUrl } from '../content';
4
+ import type { ContentRepository, ImportRecord } from '../repo/types';
5
+ import { ASSET_SCHEME, ImportAuthor, ImportIdentity, ImportItem, ImportNav, ImportRedirect, ImportTag, ImportWarning, type ImportBundle } from './bundle';
6
+
7
+ // Server half of the import pipeline. The browser parses the source file,
8
+ // re-hosts assets and sends the bundle in steps:
9
+ //
10
+ // plan → per-item decisions (create / update / skip), nothing written
11
+ // start → import run + authors + tags (+ records of re-hosted media)
12
+ // items → batches of posts/pages, re-decided server-side, written
13
+ // finish → redirects from old URLs, navigation, site identity, report
14
+ // undo → removes what a run created and nobody has edited since
15
+ //
16
+ // Every write is recorded (source id ↔ acms id) so a re-run updates the same
17
+ // items instead of duplicating them, and a closed tab can resume.
18
+
19
+ export const ImportOptions = z.object({
20
+ // when a slug is already taken by content that wasn't imported from this
21
+ // source: keep it, overwrite it, or import alongside with a new slug
22
+ conflict: z.enum(['skip', 'update', 'copy']).default('skip'),
23
+ identity: z.boolean().default(false),
24
+ nav: z.boolean().default(false),
25
+ redirects: z.boolean().default(true),
26
+ headCode: z.boolean().default(false),
27
+ // author sourceId → 'create' | 'skip' | an existing user id | 'as:<sourceId>'
28
+ // (the same person as another author in this import)
29
+ authors: z.record(z.string().max(200)).default({}),
30
+ });
31
+ export type ImportOptions = z.infer<typeof ImportOptions>;
32
+
33
+ export type ItemAction = 'create' | 'update' | 'skip';
34
+ export interface ItemDecision {
35
+ sourceId: string;
36
+ type: 'post' | 'page';
37
+ title: string;
38
+ slug: string;
39
+ action: ItemAction;
40
+ reason: '' | 'exists' | 'imported' | 'done';
41
+ targetId: string;
42
+ }
43
+
44
+ export interface ImportState {
45
+ posts: Post[];
46
+ pages: Page[];
47
+ records: Map<string, ImportRecord>; // `${kind}:${sourceId}`
48
+ }
49
+
50
+ export function cleanSlug(slug: string): string {
51
+ const s = slug.toLowerCase().trim();
52
+ return /^[a-z0-9][a-z0-9-]*$/.test(s) ? s.slice(0, 200) : slugify(s).slice(0, 200);
53
+ }
54
+
55
+ export function collectionFor(item: Pick<ImportItem, 'collection'>): string {
56
+ return CONTENT.collections[item.collection] ? item.collection : 'posts';
57
+ }
58
+
59
+ export async function loadState(repo: ContentRepository, items: Pick<ImportItem, 'sourceId' | 'type'>[]): Promise<ImportState> {
60
+ const [posts, pages, postRecs, pageRecs] = await Promise.all([
61
+ repo.listPosts(),
62
+ repo.listPages(),
63
+ repo.findImportRecords('post', items.filter((i) => i.type === 'post').map((i) => i.sourceId)),
64
+ repo.findImportRecords('page', items.filter((i) => i.type === 'page').map((i) => i.sourceId)),
65
+ ]);
66
+ const records = new Map<string, ImportRecord>();
67
+ for (const r of [...postRecs, ...pageRecs]) records.set(`${r.kind}:${r.sourceId}`, r);
68
+ return { posts, pages, records };
69
+ }
70
+
71
+ // Decide every item against the current site. `claimed` tracks slugs taken
72
+ // earlier in the same bundle so two items can't land on one slug.
73
+ export function decideItems(items: Pick<ImportItem, 'sourceId' | 'type' | 'title' | 'slug' | 'collection'>[], state: ImportState, conflict: ImportOptions['conflict'], runId = ''): ItemDecision[] {
74
+ const claimed = new Set<string>();
75
+ const key = (type: string, collection: string, slug: string) => (type === 'page' ? `page:${slug}` : `post:${collection}:${slug}`);
76
+ for (const p of state.posts) claimed.add(key('post', p.collection, p.slug));
77
+ for (const p of state.pages) claimed.add(key('page', '', p.slug));
78
+ const out: ItemDecision[] = [];
79
+ for (const item of items) {
80
+ const collection = item.type === 'post' ? collectionFor(item) : '';
81
+ let slug = cleanSlug(item.slug);
82
+ const base = { sourceId: item.sourceId, type: item.type, title: item.title };
83
+ const rec = state.records.get(`${item.type}:${item.sourceId}`);
84
+ const recTarget = rec && (item.type === 'post' ? state.posts.find((p) => p.id === rec.acmsId) : state.pages.find((p) => p.id === rec.acmsId));
85
+ if (recTarget) {
86
+ // imported before: this run already wrote it (resume), or an earlier
87
+ // run did — refresh it only when the user asked for updates
88
+ if (rec!.runId === runId) out.push({ ...base, slug: recTarget.slug, action: 'skip', reason: 'done', targetId: recTarget.id });
89
+ else if (conflict === 'update') out.push({ ...base, slug: recTarget.slug, action: 'update', reason: 'imported', targetId: recTarget.id });
90
+ else out.push({ ...base, slug: recTarget.slug, action: 'skip', reason: 'imported', targetId: recTarget.id });
91
+ continue;
92
+ }
93
+ if (claimed.has(key(item.type, collection, slug))) {
94
+ const existing = item.type === 'post'
95
+ ? state.posts.find((p) => p.collection === collection && p.slug === slug)
96
+ : state.pages.find((p) => p.slug === slug);
97
+ if (conflict === 'update' && existing) {
98
+ out.push({ ...base, slug, action: 'update', reason: 'exists', targetId: existing.id });
99
+ continue;
100
+ }
101
+ if (conflict !== 'copy') {
102
+ out.push({ ...base, slug, action: 'skip', reason: 'exists', targetId: existing?.id ?? '' });
103
+ continue;
104
+ }
105
+ let n = 2;
106
+ while (claimed.has(key(item.type, collection, `${slug}-${n}`))) n++;
107
+ slug = `${slug}-${n}`;
108
+ }
109
+ claimed.add(key(item.type, collection, slug));
110
+ out.push({ ...base, slug, action: 'create', reason: '', targetId: '' });
111
+ }
112
+ return out;
113
+ }
114
+
115
+ // ---- plan ----
116
+
117
+ export interface AuthorPlan { sourceId: string; name: string; email: string; choice: string; match: string }
118
+
119
+ export function defaultAuthorChoice(author: ImportAuthor, users: User[], previous?: string): string {
120
+ if (previous && users.some((u) => u.id === previous)) return previous;
121
+ const byEmail = author.email && users.find((u) => u.email.toLowerCase() === author.email.toLowerCase());
122
+ if (byEmail) return byEmail.id;
123
+ return author.inactive ? 'skip' : 'create';
124
+ }
125
+
126
+ export async function planImport(repo: ContentRepository, bundle: ImportBundle, options: ImportOptions) {
127
+ const [state, users, userRecs] = await Promise.all([
128
+ loadState(repo, bundle.items),
129
+ repo.listUsers(),
130
+ repo.findImportRecords('user', bundle.authors.map((a) => a.sourceId)),
131
+ ]);
132
+ const decisions = decideItems(bundle.items, state, options.conflict);
133
+ const prev = new Map(userRecs.map((r) => [r.sourceId, r.acmsId]));
134
+ const authors: AuthorPlan[] = bundle.authors.map((a) => {
135
+ const match = defaultAuthorChoice(a, users, prev.get(a.sourceId));
136
+ return { sourceId: a.sourceId, name: a.name, email: a.email, choice: options.authors[a.sourceId] ?? match, match: match === 'create' || match === 'skip' ? '' : match };
137
+ });
138
+ // bylines only matter for authors that wrote something in this bundle
139
+ const writing = new Set(bundle.items.flatMap((i) => i.authors));
140
+ const count = (type: 'post' | 'page', action: ItemAction) => decisions.filter((d) => d.type === type && d.action === action).length;
141
+ return {
142
+ decisions,
143
+ authors: authors.filter((a) => writing.has(a.sourceId)),
144
+ users: users.map((u) => ({ id: u.id, name: u.name, email: u.email })),
145
+ counts: {
146
+ posts: { create: count('post', 'create'), update: count('post', 'update'), skip: count('post', 'skip') },
147
+ pages: { create: count('page', 'create'), update: count('page', 'update'), skip: count('page', 'skip') },
148
+ tags: bundle.tags.length,
149
+ assets: bundle.assets.filter((a) => a.kind !== 'media').length + bundle.media.length,
150
+ // only URLs that actually change need a redirect (Ghost pages keep theirs)
151
+ redirects: options.redirects ? bundle.items.filter((item, i) => {
152
+ const d = decisions[i];
153
+ if (!item.legacyPath || d.action === 'skip') return false;
154
+ const to = item.type === 'post' ? collectionUrl(collectionFor(item), d.slug) : `/${d.slug}`;
155
+ return normalizeRedirectPath(item.legacyPath) !== normalizeRedirectPath(to);
156
+ }).length + bundle.redirects.length : 0,
157
+ },
158
+ };
159
+ }
160
+
161
+ // ---- start: run, authors, tags ----
162
+
163
+ export const StartInput = z.object({
164
+ source: z.string().min(1).max(100),
165
+ label: z.string().max(300).default(''),
166
+ authors: z.array(ImportAuthor).max(2000).default([]),
167
+ tags: z.array(ImportTag).max(10_000).default([]),
168
+ // media rows re-hosted from this source before the run started (asset url → media id)
169
+ media: z.array(z.object({ sourceId: z.string().min(1).max(2000), id: z.string().min(1).max(100) })).max(50_000).default([]),
170
+ options: ImportOptions,
171
+ });
172
+
173
+ export async function startImport(repo: ContentRepository, input: z.infer<typeof StartInput>) {
174
+ // callers send only authors with bylines (Ghost exports list every staff
175
+ // account, including ones that never wrote anything)
176
+ const runId = crypto.randomUUID();
177
+ await repo.createImportRun({ id: runId, source: input.source, label: input.label });
178
+ const records: ImportRecord[] = [];
179
+ const record = (kind: ImportRecord['kind'], sourceId: string, acmsId: string, stamp = 0) =>
180
+ records.push({ runId, kind, sourceId, acmsId, action: 'created', stamp });
181
+
182
+ for (const m of input.media) record('media', m.sourceId, m.id);
183
+
184
+ // authors: reuse earlier imports and email matches, create the rest
185
+ let users = await repo.listUsers();
186
+ const prev = new Map((await repo.findImportRecords('user', input.authors.map((a) => a.sourceId))).map((r) => [r.sourceId, r.acmsId]));
187
+ const authorIds: Record<string, string | null> = {};
188
+ const merged: [string, string][] = [];
189
+ for (const a of input.authors) {
190
+ const choice = input.options.authors[a.sourceId] ?? defaultAuthorChoice(a, users, prev.get(a.sourceId));
191
+ if (choice === 'skip') { authorIds[a.sourceId] = null; continue; }
192
+ if (choice.startsWith('as:')) { merged.push([a.sourceId, choice.slice(3)]); continue; }
193
+ if (choice !== 'create') { authorIds[a.sourceId] = users.some((u) => u.id === choice) ? choice : null; continue; }
194
+ const email = (a.email || `${cleanSlug(a.slug || a.name)}@users.invalid`).toLowerCase();
195
+ const existing = users.find((u) => u.email.toLowerCase() === email);
196
+ if (existing) { authorIds[a.sourceId] = existing.id; continue; }
197
+ // imported authors get bylines, not logins: no password until an owner
198
+ // sets one from Users
199
+ const user = await repo.createUser({ name: a.name, email, role: 'editor', passHash: '' });
200
+ await repo.updateUser(user.id, {
201
+ bio: a.bio,
202
+ avatar: a.avatar.startsWith(ASSET_SCHEME) ? '' : a.avatar,
203
+ slug: a.slug ? cleanSlug(a.slug) : '',
204
+ });
205
+ users = [...users, user];
206
+ authorIds[a.sourceId] = user.id;
207
+ record('user', a.sourceId, user.id);
208
+ }
209
+ // merges resolve once their target exists (one level; chains end at null)
210
+ for (const [from, to] of merged) authorIds[from] = authorIds[to] ?? null;
211
+
212
+ // tags: register the source's descriptions/slugs where the name is new
213
+ const tags = await repo.listTags();
214
+ for (const t of input.tags) {
215
+ const slug = cleanSlug(t.slug || t.name);
216
+ if (tags.some((x) => x.slug === slug || x.name.toLowerCase() === t.name.toLowerCase())) continue;
217
+ const tag = await repo.createTag({ name: t.name, slug, description: t.description });
218
+ tags.push(tag);
219
+ record('tag', t.name, tag.id);
220
+ }
221
+
222
+ if (records.length) await repo.addImportRecords(records);
223
+ return { runId, authorIds };
224
+ }
225
+
226
+ // ---- items ----
227
+
228
+ export const ItemsInput = z.object({
229
+ runId: z.string().uuid(),
230
+ items: z.array(ImportItem).min(1).max(50),
231
+ authorIds: z.record(z.string().nullable()).default({}),
232
+ options: ImportOptions,
233
+ });
234
+
235
+ export async function importItems(repo: ContentRepository, input: z.infer<typeof ItemsInput>) {
236
+ const run = await repo.getImportRun(input.runId);
237
+ if (!run || run.status !== 'running') throw new Error('This import run has finished or was undone. Start the import again.');
238
+ const users = new Set((await repo.listUsers()).map((u) => u.id));
239
+ const state = await loadState(repo, input.items);
240
+ const decisions = decideItems(input.items, state, input.options.conflict, input.runId);
241
+ const posts: Post[] = [], pages: Page[] = [];
242
+ const records: ImportRecord[] = [];
243
+ const results: { sourceId: string; action: ItemAction; reason: ItemDecision['reason']; id: string; type: 'post' | 'page' }[] = [];
244
+
245
+ input.items.forEach((item, i) => {
246
+ const d = decisions[i];
247
+ if (d.action === 'skip') { results.push({ sourceId: item.sourceId, action: 'skip', reason: d.reason, id: d.targetId, type: item.type }); return; }
248
+ const id = d.action === 'update' ? d.targetId : crypto.randomUUID();
249
+ const html = item.html.includes(ASSET_SCHEME) ? item.html.replace(/acms-asset:[a-z0-9-]+/g, '') : item.html;
250
+ // new items keep the source's timestamps; refreshed ones are "now". The
251
+ // stamp is what undo compares against to spot later edits.
252
+ const stamp = d.action === 'create' ? item.updatedAt : Date.now();
253
+ if (item.type === 'post') {
254
+ const existing = state.posts.find((p) => p.id === id);
255
+ const authorIds = [...new Set(item.authors.map((a) => input.authorIds[a]).filter((a): a is string => !!a && users.has(a)))];
256
+ posts.push(Post.parse({
257
+ id, collection: collectionFor(item), slug: d.slug, title: item.title,
258
+ body: '', bodyJson: '', bodyHtml: html, excerpt: item.excerpt, tags: [...new Set(item.tags)],
259
+ cover: stripPlaceholder(item.cover), coverAlt: item.coverAlt, seoTitle: item.seoTitle, seoDesc: item.seoDesc,
260
+ status: item.status, featured: item.featured, authorId: authorIds[0] ?? null, authorIds, template: item.template,
261
+ draft: null, publishedAt: item.status === 'draft' ? item.publishedAt : item.publishedAt ?? item.createdAt,
262
+ scheduledAt: item.status === 'scheduled' ? item.scheduledAt : null,
263
+ createdAt: existing?.createdAt ?? item.createdAt, updatedAt: stamp,
264
+ }));
265
+ } else {
266
+ const existing = state.pages.find((p) => p.id === id);
267
+ pages.push(Page.parse({
268
+ id, slug: d.slug, title: item.title, status: item.status === 'scheduled' ? 'draft' : item.status,
269
+ isHome: existing?.isHome ?? false, locale: existing?.locale ?? item.locale, blocks: [], bodyJson: '', bodyHtml: html,
270
+ seoTitle: item.seoTitle, seoDesc: item.seoDesc || item.excerpt, template: item.template, draft: null,
271
+ createdAt: existing?.createdAt ?? item.createdAt, updatedAt: stamp,
272
+ }));
273
+ }
274
+ records.push({ runId: input.runId, kind: item.type, sourceId: item.sourceId, acmsId: id, action: d.action === 'update' ? 'updated' : 'created', stamp });
275
+ results.push({ sourceId: item.sourceId, action: d.action, reason: d.reason, id, type: item.type });
276
+ });
277
+
278
+ if (posts.length || pages.length) {
279
+ await repo.writeImported({ posts, pages });
280
+ await repo.ensureTags([...new Set(posts.flatMap((p) => p.tags))]);
281
+ await repo.addImportRecords(records);
282
+ }
283
+ return { results };
284
+ }
285
+
286
+ function stripPlaceholder(v: string): string {
287
+ return v.startsWith(ASSET_SCHEME) ? '' : v;
288
+ }
289
+
290
+ // ---- finish ----
291
+
292
+ export const FinishInput = z.object({
293
+ runId: z.string().uuid(),
294
+ identity: ImportIdentity.default({}),
295
+ nav: z.array(ImportNav).max(500).default([]),
296
+ // sourceId → old path, for the items in this bundle
297
+ legacy: z.array(z.object({ sourceId: z.string().max(200), type: z.enum(['post', 'page']), path: z.string().max(1000) })).max(50_000).default([]),
298
+ redirects: z.array(ImportRedirect).max(10_000).default([]),
299
+ warnings: z.array(ImportWarning).max(10_000).default([]),
300
+ failedAssets: z.array(z.object({ url: z.string().max(2000), error: z.string().max(500) })).max(50_000).default([]),
301
+ options: ImportOptions,
302
+ });
303
+
304
+ export async function finishImport(repo: ContentRepository, input: z.infer<typeof FinishInput>) {
305
+ const run = await repo.getImportRun(input.runId);
306
+ if (!run || run.status !== 'running') throw new Error('This import run has already finished.');
307
+ const records: ImportRecord[] = [];
308
+ const all = await repo.listImportRecords(input.runId);
309
+ const created = { redirects: 0, nav: 0 };
310
+
311
+ if (input.options.redirects && input.legacy.length) {
312
+ const byItem = new Map(all.filter((r) => r.kind === 'post' || r.kind === 'page').map((r) => [`${r.kind}:${r.sourceId}`, r.acmsId]));
313
+ const [posts, pages] = await Promise.all([repo.listPosts(), repo.listPages()]);
314
+ for (const l of input.legacy) {
315
+ const id = byItem.get(`${l.type}:${l.sourceId}`);
316
+ const target = l.type === 'post' ? posts.find((p) => p.id === id) : pages.find((p) => p.id === id);
317
+ if (!target) continue;
318
+ const to = l.type === 'post' ? collectionUrl((target as Post).collection, target.slug) : `/${target.slug}`;
319
+ const from = normalizeRedirectPath(l.path);
320
+ if (!from || from === '/' || from === normalizeRedirectPath(to)) continue;
321
+ const r = await repo.createRedirect({ fromPath: from, toPath: to, code: 301 });
322
+ records.push({ runId: input.runId, kind: 'redirect', sourceId: from, acmsId: r.id, action: 'created', stamp: 0 });
323
+ created.redirects++;
324
+ }
325
+ }
326
+
327
+ if (input.options.redirects) {
328
+ for (const r of input.redirects) {
329
+ const from = normalizeRedirectPath(r.from), to = normalizeRedirectPath(r.to);
330
+ if (!from || !to || from === to || from === '/') continue;
331
+ const rule = await repo.createRedirect({ fromPath: from, toPath: to, code: 301 });
332
+ records.push({ runId: input.runId, kind: 'redirect', sourceId: from, acmsId: rule.id, action: 'created', stamp: 0 });
333
+ created.redirects++;
334
+ }
335
+ }
336
+
337
+ if (input.options.nav && input.nav.length) {
338
+ const existing = await repo.listNavItems();
339
+ const seen = new Set(existing.map((n) => `${n.area}:${n.url.replace(/\/+$/, '') || '/'}`));
340
+ for (const n of input.nav) {
341
+ const k = `${n.area}:${n.url.replace(/\/+$/, '') || '/'}`;
342
+ if (seen.has(k) || n.url.includes(ASSET_SCHEME)) continue;
343
+ seen.add(k);
344
+ const item = await repo.createNavItem({ label: n.label, url: n.url, area: n.area });
345
+ records.push({ runId: input.runId, kind: 'nav', sourceId: k, acmsId: item.id, action: 'created', stamp: 0 });
346
+ created.nav++;
347
+ }
348
+ }
349
+
350
+ const settings: Record<string, string> = {};
351
+ if (input.options.identity) {
352
+ for (const [k, v] of Object.entries(input.identity)) {
353
+ if (k === 'headCode' || typeof v !== 'string' || !v.trim() || v.includes(ASSET_SCHEME)) continue;
354
+ settings[k] = v;
355
+ }
356
+ }
357
+ if (input.options.headCode && input.identity.headCode?.trim()) {
358
+ const current = (await repo.getSettings()).headCode;
359
+ if (!current.includes(input.identity.headCode.trim())) settings.headCode = [current.trim(), input.identity.headCode.trim()].filter(Boolean).join('\n');
360
+ }
361
+ if (Object.keys(settings).length) await repo.updateSettings(settings);
362
+ if (records.length) await repo.addImportRecords(records);
363
+
364
+ const final = [...all, ...records];
365
+ const count = (kind: ImportRecord['kind'], action?: ImportRecord['action']) => final.filter((r) => r.kind === kind && (!action || r.action === action)).length;
366
+ const report = {
367
+ counts: {
368
+ posts: { created: count('post', 'created'), updated: count('post', 'updated') },
369
+ pages: { created: count('page', 'created'), updated: count('page', 'updated') },
370
+ users: count('user'), tags: count('tag'), media: count('media'), redirects: created.redirects, nav: created.nav,
371
+ },
372
+ settings: Object.keys(settings),
373
+ warnings: input.warnings,
374
+ failedAssets: input.failedAssets,
375
+ };
376
+ await repo.updateImportRun(input.runId, { status: 'done', report });
377
+ return { report };
378
+ }
379
+
380
+ // ---- undo ----
381
+
382
+ export async function undoImport(
383
+ repo: ContentRepository,
384
+ runId: string,
385
+ opts: { removeMedia: boolean; deleteMediaObject?: (key: string) => Promise<void> },
386
+ ) {
387
+ const run = await repo.getImportRun(runId);
388
+ if (!run) throw new Error('Import not found.');
389
+ if (run.status === 'undone') throw new Error('This import was already undone.');
390
+ const records = await repo.listImportRecords(runId);
391
+ const created = (kind: ImportRecord['kind']) => records.filter((r) => r.kind === kind && r.action === 'created');
392
+ const kept: string[] = [];
393
+
394
+ const [posts, pages] = await Promise.all([repo.listPosts(), repo.listPages()]);
395
+ // anything edited after the import (updated_at moved past the stamp) stays
396
+ const deletePosts = created('post').filter((r) => {
397
+ const p = posts.find((x) => x.id === r.acmsId);
398
+ if (p && p.updatedAt !== r.stamp) kept.push(p.title);
399
+ return p && p.updatedAt === r.stamp;
400
+ }).map((r) => r.acmsId);
401
+ const deletePages = created('page').filter((r) => {
402
+ const p = pages.find((x) => x.id === r.acmsId);
403
+ if (p && p.updatedAt !== r.stamp) kept.push(p.title);
404
+ return p && p.updatedAt === r.stamp;
405
+ }).map((r) => r.acmsId);
406
+ if (deletePosts.length || deletePages.length) await repo.writeImported({ deletePosts, deletePages });
407
+
408
+ const remaining = posts.filter((p) => !deletePosts.includes(p.id));
409
+ for (const r of created('redirect')) await repo.deleteRedirect(r.acmsId);
410
+ for (const r of created('nav')) await repo.deleteNavItem(r.acmsId);
411
+ for (const r of created('tag')) {
412
+ const tag = (await repo.listTags()).find((t) => t.id === r.acmsId);
413
+ if (tag && !remaining.some((p) => p.tags.includes(tag.name))) await repo.deleteTag(tag.id);
414
+ }
415
+ for (const r of created('user')) {
416
+ if (!remaining.some((p) => p.authorIds.includes(r.acmsId))) await repo.deleteUser(r.acmsId);
417
+ }
418
+ let mediaRemoved = 0;
419
+ if (opts.removeMedia) {
420
+ for (const r of created('media')) {
421
+ const m = await repo.getMedia(r.acmsId);
422
+ if (!m) continue;
423
+ await repo.deleteMedia(m.id);
424
+ for (const key of [m.key, ...m.variants.map((v) => v.key)]) await opts.deleteMediaObject?.(key).catch(() => {});
425
+ mediaRemoved++;
426
+ }
427
+ }
428
+ await repo.updateImportRun(runId, { status: 'undone', report: { ...run.report, undo: { removed: deletePosts.length + deletePages.length, kept, mediaRemoved } } });
429
+ return { removed: deletePosts.length + deletePages.length, kept, mediaRemoved };
430
+ }
@@ -0,0 +1,167 @@
1
+ import { z } from 'zod';
2
+ import { Media } from '../schema';
3
+
4
+ // The Import Bundle is the one shape every importer produces (Ghost,
5
+ // WordPress, Markdown…) and the only shape the import API accepts. Serialized
6
+ // with its media it is also acms's own portable export (`acms-export`), so
7
+ // acms → acms moves use the same path as migrations from other CMSes.
8
+ //
9
+ // Bodies are editor-compatible HTML (the `.pc-*` card markup the editor's
10
+ // parseHTML rules load), exactly like the drafts and agent APIs: bodyJson is
11
+ // left empty and fills in on the first editor save.
12
+ //
13
+ // Files that still live on the old site are listed in `assets` and referenced
14
+ // from any string field as `acms-asset:<ref>`. The browser re-hosts each one
15
+ // (resize + variants for images) and swaps the placeholders for /media URLs
16
+ // before items reach the server. acms exports instead carry their media rows
17
+ // verbatim (`media`), with binaries under media/<key> in the ZIP, so keys and
18
+ // every URL in the content stay valid.
19
+
20
+ export const BUNDLE_FORMAT = 'acms-export';
21
+ export const BUNDLE_VERSION = 1;
22
+ export const ASSET_SCHEME = 'acms-asset:';
23
+
24
+ export const ImportSourceKind = z.enum(['acms', 'ghost', 'wordpress', 'markdown']);
25
+ export type ImportSourceKind = z.infer<typeof ImportSourceKind>;
26
+
27
+ const text = (max: number) => z.string().max(max).default('');
28
+
29
+ export const ImportAsset = z.object({
30
+ ref: z.string().regex(/^[a-z0-9-]{1,40}$/),
31
+ // absolute URL on the old site, or a path inside an uploaded content ZIP
32
+ url: z.string().min(1).max(2000),
33
+ kind: z.enum(['image', 'file', 'media']),
34
+ filename: text(300),
35
+ alt: text(1000),
36
+ });
37
+ export type ImportAsset = z.infer<typeof ImportAsset>;
38
+
39
+ export const ImportAuthor = z.object({
40
+ sourceId: z.string().min(1).max(200),
41
+ name: z.string().min(1).max(200),
42
+ slug: text(200),
43
+ email: text(320),
44
+ bio: text(5000),
45
+ avatar: text(2000),
46
+ // Ghost's locked/suspended users: the importer defaults them to "skip"
47
+ inactive: z.boolean().default(false),
48
+ });
49
+ export type ImportAuthor = z.infer<typeof ImportAuthor>;
50
+
51
+ export const ImportTag = z.object({
52
+ name: z.string().min(1).max(200),
53
+ slug: z.string().min(1).max(200),
54
+ description: text(5000),
55
+ });
56
+ export type ImportTag = z.infer<typeof ImportTag>;
57
+
58
+ export const ImportItem = z.object({
59
+ sourceId: z.string().min(1).max(200),
60
+ type: z.enum(['post', 'page']),
61
+ // acms({ content.collections }) key; unknown keys fall back to 'posts'
62
+ collection: z.string().max(100).default('posts'),
63
+ slug: z.string().min(1).max(300),
64
+ title: z.string().max(1000),
65
+ status: z.enum(['draft', 'published', 'scheduled']),
66
+ html: z.string().max(5_000_000).default(''),
67
+ excerpt: text(5000),
68
+ cover: text(2000),
69
+ coverAlt: text(1000),
70
+ seoTitle: text(1000),
71
+ seoDesc: text(5000),
72
+ featured: z.boolean().default(false),
73
+ template: text(100),
74
+ locale: text(35),
75
+ tags: z.array(z.string().max(200)).max(200).default([]),
76
+ // ImportAuthor.sourceId values, in byline order
77
+ authors: z.array(z.string().max(200)).max(50).default([]),
78
+ publishedAt: z.number().nullable().default(null),
79
+ scheduledAt: z.number().nullable().default(null),
80
+ createdAt: z.number(),
81
+ updatedAt: z.number(),
82
+ // the item's path on the old site; the importer adds a 301 when it differs
83
+ legacyPath: text(1000),
84
+ });
85
+ export type ImportItem = z.infer<typeof ImportItem>;
86
+
87
+ export const ImportNav = z.object({
88
+ label: z.string().min(1).max(200),
89
+ url: z.string().min(1).max(2000),
90
+ area: z.enum(['header', 'footer', 'social']),
91
+ });
92
+ export type ImportNav = z.infer<typeof ImportNav>;
93
+
94
+ export const ImportRedirect = z.object({ from: z.string().min(1).max(1000), to: z.string().min(1).max(1000) });
95
+ export type ImportRedirect = z.infer<typeof ImportRedirect>;
96
+
97
+ // allow-listed site identity; nothing else from a source's settings is kept
98
+ export const ImportIdentity = z.object({
99
+ siteTitle: z.string().max(200).optional(),
100
+ tagline: z.string().max(1000).optional(),
101
+ siteLogo: z.string().max(2000).optional(),
102
+ siteFavicon: z.string().max(2000).optional(),
103
+ siteCover: z.string().max(2000).optional(),
104
+ timezone: z.string().max(100).optional(),
105
+ locale: z.string().max(35).optional(),
106
+ headCode: z.string().max(100_000).optional(),
107
+ });
108
+ export type ImportIdentity = z.infer<typeof ImportIdentity>;
109
+
110
+ export const ImportWarning = z.object({
111
+ item: z.string().max(200).default(''),
112
+ code: z.string().max(60),
113
+ message: z.string().max(2000),
114
+ });
115
+ export type ImportWarning = z.infer<typeof ImportWarning>;
116
+
117
+ export const ImportBundle = z.object({
118
+ format: z.literal(BUNDLE_FORMAT),
119
+ version: z.literal(BUNDLE_VERSION),
120
+ source: z.object({
121
+ kind: ImportSourceKind,
122
+ version: text(50),
123
+ title: text(200),
124
+ siteUrl: text(2000),
125
+ exportedAt: text(40),
126
+ }),
127
+ identity: ImportIdentity.default({}),
128
+ authors: z.array(ImportAuthor).max(2000).default([]),
129
+ tags: z.array(ImportTag).max(10_000).default([]),
130
+ items: z.array(ImportItem).max(50_000).default([]),
131
+ assets: z.array(ImportAsset).max(50_000).default([]),
132
+ media: z.array(Media).max(50_000).default([]),
133
+ nav: z.array(ImportNav).max(500).default([]),
134
+ // the source site's own redirect rules (acms exports)
135
+ redirects: z.array(ImportRedirect).max(10_000).default([]),
136
+ warnings: z.array(ImportWarning).max(10_000).default([]),
137
+ });
138
+ export type ImportBundle = z.infer<typeof ImportBundle>;
139
+
140
+ export const assetPlaceholder = (ref: string) => `${ASSET_SCHEME}${ref}`;
141
+
142
+ // Swap every acms-asset:<ref> for its re-hosted URL. <img> tags built by the
143
+ // importers carry only src/alt/width/height, so images that gained responsive
144
+ // variants get srcset/sizes added here. Unresolved refs (a failed download)
145
+ // fall back to the original URL, which is reported separately.
146
+ export interface ResolvedAsset { url: string; srcset?: string; sizes?: string }
147
+ const PLACEHOLDER_RE = /acms-asset:([a-z0-9-]{1,40})/g;
148
+ export function resolveAssets(value: string, resolved: Map<string, ResolvedAsset>, fallback: Map<string, string>): string {
149
+ if (!value.includes(ASSET_SCHEME)) return value;
150
+ const withSrcset = value.replace(/<img\b([^>]*?)\ssrc="acms-asset:([a-z0-9-]{1,40})"([^>]*)>/g, (tag, before: string, ref: string, after: string) => {
151
+ const r = resolved.get(ref);
152
+ if (!r?.srcset || /\ssrcset=/.test(tag)) return tag;
153
+ return `<img${before} src="acms-asset:${ref}" srcset="${escapeAttr(r.srcset)}" sizes="${escapeAttr(r.sizes ?? '')}"${after}>`;
154
+ });
155
+ return withSrcset.replace(PLACEHOLDER_RE, (m, ref: string) => resolved.get(ref)?.url ?? fallback.get(ref) ?? m);
156
+ }
157
+
158
+ export function assetRefsIn(value: string): string[] {
159
+ return [...value.matchAll(PLACEHOLDER_RE)].map((m) => m[1]);
160
+ }
161
+
162
+ export function escapeHtml(s: string): string {
163
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
164
+ }
165
+ export function escapeAttr(s: string): string {
166
+ return escapeHtml(s).replace(/"/g, '&quot;');
167
+ }
@@ -0,0 +1,54 @@
1
+ import type { ContentRepository } from '../repo/types';
2
+ import { blocksToHtml } from '../blocksToHtml';
3
+ import { collectionUrl } from '../content';
4
+ import { BUNDLE_FORMAT, BUNDLE_VERSION, type ImportBundle } from './bundle';
5
+
6
+ // acms's own portable export: the site's content as an Import Bundle, so
7
+ // importing it elsewhere (another acms site, or this one after a reset) runs
8
+ // the same merge pipeline as a Ghost import. Unlike a backup it carries no
9
+ // bookings, credentials, API keys or settings beyond public site identity,
10
+ // and media keeps its storage keys so every /media URL stays valid.
11
+ export async function exportBundle(repo: ContentRepository, now = Date.now()): Promise<ImportBundle> {
12
+ const [settings, posts, pages, tags, users, nav, media, redirects] = await Promise.all([
13
+ repo.getSettings(), repo.listPosts(), repo.listPages(), repo.listTags(), repo.listUsers(),
14
+ repo.listNavItems(), repo.listMedia(), repo.listRedirects(),
15
+ ]);
16
+ const authorIds = new Set(posts.flatMap((p) => p.authorIds));
17
+ return {
18
+ format: BUNDLE_FORMAT,
19
+ version: BUNDLE_VERSION,
20
+ source: { kind: 'acms', version: '', title: settings.siteTitle, siteUrl: settings.siteUrl, exportedAt: new Date(now).toISOString() },
21
+ identity: {
22
+ siteTitle: settings.siteTitle, tagline: settings.tagline, siteLogo: settings.siteLogo, siteFavicon: settings.siteFavicon,
23
+ siteCover: settings.siteCover, timezone: settings.timezone, locale: settings.locale, headCode: settings.headCode,
24
+ },
25
+ // only people with bylines travel; staff accounts stay on this site
26
+ authors: users.filter((u) => authorIds.has(u.id)).map((u) => ({
27
+ sourceId: u.id, name: u.name, slug: u.slug, email: u.email, bio: u.bio, avatar: u.avatar, inactive: false,
28
+ })),
29
+ tags: tags.map((t) => ({ name: t.name, slug: t.slug, description: t.description })),
30
+ items: [
31
+ ...posts.map((p) => ({
32
+ sourceId: p.id, type: 'post' as const, collection: p.collection, slug: p.slug, title: p.title,
33
+ status: p.status === 'archived' ? 'draft' as const : p.status,
34
+ html: p.bodyHtml, excerpt: p.excerpt, cover: p.cover, coverAlt: p.coverAlt, seoTitle: p.seoTitle, seoDesc: p.seoDesc,
35
+ featured: p.featured, template: p.template, locale: '', tags: p.tags, authors: p.authorIds,
36
+ publishedAt: p.publishedAt, scheduledAt: p.scheduledAt, createdAt: p.createdAt, updatedAt: p.updatedAt,
37
+ legacyPath: collectionUrl(p.collection, p.slug),
38
+ })),
39
+ ...pages.map((p) => ({
40
+ sourceId: p.id, type: 'page' as const, collection: 'posts', slug: p.slug, title: p.title,
41
+ status: p.status === 'archived' || p.status === 'scheduled' ? 'draft' as const : p.status,
42
+ html: p.bodyHtml || blocksToHtml(p.blocks), excerpt: '', cover: '', coverAlt: '', seoTitle: p.seoTitle, seoDesc: p.seoDesc,
43
+ featured: false, template: p.template, locale: p.locale, tags: [], authors: [],
44
+ publishedAt: null, scheduledAt: null, createdAt: p.createdAt, updatedAt: p.updatedAt,
45
+ legacyPath: `/${p.slug}`,
46
+ })),
47
+ ],
48
+ assets: [],
49
+ media,
50
+ nav: nav.map((n) => ({ label: n.label, url: n.url, area: n.area })),
51
+ redirects: redirects.map((r) => ({ from: r.fromPath, to: r.toPath })),
52
+ warnings: [],
53
+ };
54
+ }