@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 +5 -0
- package/package.json +1 -1
- package/src/components/import/ImportExport.tsx +519 -0
- package/src/components/import/runner.ts +284 -0
- package/src/lib/import/apply.ts +430 -0
- package/src/lib/import/bundle.ts +167 -0
- package/src/lib/import/export.ts +54 -0
- package/src/lib/import/ghost.ts +247 -0
- package/src/lib/import/lexical.ts +290 -0
- package/src/lib/import/migration.ts +27 -0
- package/src/lib/import/zipread.ts +72 -0
- package/src/lib/repo/migrations.ts +2 -0
- package/src/lib/repo/sql.ts +116 -2
- package/src/lib/repo/types.ts +17 -0
- package/src/pages/admin/import.astro +16 -0
- package/src/pages/admin/settings.astro +11 -0
- package/src/pages/api/export.ts +41 -0
- package/src/pages/api/import/asset.ts +32 -0
- package/src/pages/api/import/index.ts +47 -0
- package/src/pages/api/import/media.ts +31 -0
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
164
|
+
}
|
|
165
|
+
export function escapeAttr(s: string): string {
|
|
166
|
+
return escapeHtml(s).replace(/"/g, '"');
|
|
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
|
+
}
|