@jant/core 0.7.0 → 0.7.1
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/bin/commands/export.js +3 -1
- package/bin/commands/import-site.js +689 -216
- package/bin/commands/setup.js +136 -0
- package/bin/commands/site/export.js +71 -34
- package/bin/commands/site/pull-media.js +2 -6
- package/bin/commands/site/snapshot/export.js +18 -60
- package/bin/commands/site/snapshot/import.js +22 -23
- package/bin/lib/d1-query.js +87 -2
- package/bin/lib/hugo-markdown.js +4 -0
- package/bin/lib/site-pull-media.js +21 -28
- package/bin/lib/site-selection.js +10 -1
- package/bin/lib/site-snapshot.js +338 -3
- package/bin/lib/sql-export.js +68 -5
- package/bin/lib/wrangler-cli.js +9 -0
- package/bin/lib/zip-archive.js +187 -0
- package/dist/{app-K_Aa1MMn.js → app-ZcaI1kPN.js} +732 -217
- package/dist/client/.vite/manifest.json +20 -20
- package/dist/client/_assets/chunks/{create-editor-CD3FhrOB.js → create-editor-B-m7X7S5.js} +50 -46
- package/dist/client/_assets/chunks/{sortable-list-CgaL2jCs.js → sortable-list-BJyd-LXE.js} +1 -1
- package/dist/client/_assets/chunks/{unsafe-svg-0QCkP0vZ.js → unsafe-svg-BkscJx69.js} +2 -2
- package/dist/client/_assets/{client-GiYENVw8.css → client-DAhoqPdr.css} +1 -1
- package/dist/client/_assets/{client-auth-k8gJ6QJj.js → client-auth-CAMfTrKW.js} +1 -1
- package/dist/client/_assets/{client-compose-B_kDtWkW.js → client-compose-D6rExKVK.js} +1 -1
- package/dist/client/_assets/{client-VnFxJN7G.js → client-knSEyUJO.js} +1 -1
- package/dist/client/_assets/{client-manage-M90aSTOk.js → client-manage-sVOkqVbH.js} +1 -1
- package/dist/client/_assets/{client-settings-DfYs9n1F.js → client-settings-CGR_nZ78.js} +7 -7
- package/dist/{github-sync-BPAvT999.js → github-sync-Gw4orAHk.js} +799 -110
- package/dist/index.js +2 -2
- package/dist/node.js +9 -5
- package/package.json +13 -3
- package/src/__tests__/dev-scripts.test.ts +203 -0
- package/src/__tests__/export-collection-order.test.ts +276 -0
- package/src/__tests__/export-feed-ids.test.ts +184 -0
- package/src/__tests__/export-feed-order.test.ts +294 -0
- package/src/__tests__/export-hugo-build.test.ts +130 -0
- package/src/__tests__/export-import-roundtrip.test.ts +94 -0
- package/src/__tests__/export-service.test.ts +611 -28
- package/src/__tests__/export-smart-collection.test.ts +329 -0
- package/src/__tests__/helpers/hugo-site.ts +146 -0
- package/src/__tests__/import-site-command.test.ts +487 -1
- package/src/__tests__/mise-config.test.ts +75 -4
- package/src/__tests__/node-dev-tasks.test.ts +264 -0
- package/src/__tests__/site-export-canonical-import.test.ts +149 -0
- package/src/__tests__/snapshot-canonical-replay.test.ts +179 -0
- package/src/__tests__/snapshot-settings.test.ts +97 -0
- package/src/__tests__/snapshot-tables.test.ts +219 -0
- package/src/__tests__/sql-export.test.ts +173 -0
- package/src/__tests__/zip-archive.test.ts +106 -0
- package/src/app.tsx +15 -6
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +1 -1
- package/src/client/components/__tests__/jant-settings-general.test.ts +21 -1
- package/src/client/components/jant-repo-picker-types.ts +6 -1
- package/src/client/components/jant-repo-picker.ts +4 -7
- package/src/client/components/jant-settings-general.ts +17 -12
- package/src/client/tiptap/__tests__/list-editing.test.ts +224 -86
- package/src/client/tiptap/__tests__/mark-exit.test.ts +1 -2
- package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +26 -0
- package/src/client/tiptap/extensions.ts +0 -3
- package/src/client/tiptap/structural-keymap.ts +158 -47
- package/src/db/__tests__/d1-query.test.ts +56 -1
- package/src/i18n/locales/settings/en.po +8 -8
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +8 -8
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +8 -8
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/github-sync-repo-name.test.ts +40 -0
- package/src/lib/__tests__/image.test.ts +27 -1
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +105 -0
- package/src/lib/__tests__/markdown.test.ts +10 -0
- package/src/lib/__tests__/resolve-config.test.ts +67 -0
- package/src/lib/__tests__/schemas.test.ts +27 -1
- package/src/lib/__tests__/timeline.test.ts +87 -0
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
- package/src/lib/discover.ts +3 -1
- package/src/lib/github-sync-repo-name.ts +45 -0
- package/src/lib/hugo-markdown.ts +46 -0
- package/src/lib/image.ts +17 -4
- package/src/lib/markdown-manager.ts +392 -2
- package/src/lib/post-body-html.ts +11 -4
- package/src/lib/resolve-config.ts +58 -1
- package/src/lib/schemas.ts +50 -5
- package/src/lib/thread-fold.ts +3 -3
- package/src/lib/timeline.ts +1 -1
- package/src/lib/tiptap-to-markdown.ts +13 -7
- package/src/lib/url.ts +20 -0
- package/src/lib/view.ts +2 -2
- package/src/node/__tests__/cli-setup.test.ts +163 -0
- package/src/node/__tests__/cli-site-snapshot.test.ts +67 -0
- package/src/node/__tests__/cli-snapshot-meta.test.ts +20 -0
- package/src/node/__tests__/runtime.test.ts +38 -0
- package/src/node/index.ts +2 -0
- package/src/node/request-handler.ts +3 -1
- package/src/routes/api/__tests__/posts.test.ts +24 -0
- package/src/routes/api/__tests__/upload.test.ts +34 -0
- package/src/routes/api/export.ts +3 -3
- package/src/routes/api/internal/sites.ts +0 -1
- package/src/routes/api/posts.ts +2 -0
- package/src/routes/api/public/posts.ts +21 -1
- package/src/routes/api/upload.ts +10 -3
- package/src/routes/compose.tsx +4 -0
- package/src/routes/dash/__tests__/github-sync-app.test.ts +365 -0
- package/src/routes/dash/settings.tsx +212 -70
- package/src/routes/pages/__tests__/post-page-round-trips.test.ts +211 -0
- package/src/routes/pages/__tests__/thread-order.test.ts +179 -0
- package/src/routes/pages/archive.tsx +17 -11
- package/src/routes/pages/featured.tsx +11 -5
- package/src/routes/pages/page.tsx +53 -31
- package/src/routes/pages/search.tsx +4 -2
- package/src/runtime/__tests__/readiness.test.ts +23 -0
- package/src/runtime/node.ts +49 -0
- package/src/runtime/readiness.ts +15 -0
- package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
- package/src/services/__tests__/custom-url.test.ts +34 -0
- package/src/services/__tests__/github-app-installations.test.ts +153 -0
- package/src/services/__tests__/github-sync-push.test.ts +46 -0
- package/src/services/__tests__/media.test.ts +31 -0
- package/src/services/__tests__/path.test.ts +56 -0
- package/src/services/__tests__/post-timeline.test.ts +127 -0
- package/src/services/__tests__/post.test.ts +74 -0
- package/src/services/bootstrap.ts +263 -44
- package/src/services/custom-url.ts +2 -2
- package/src/services/export-theme/layouts/_default/alias.html +27 -1
- package/src/services/export-theme/layouts/_default/list.html +2 -63
- package/src/services/export-theme/layouts/_default/rss.xml +27 -38
- package/src/services/export-theme/layouts/collections/list.html +3 -3
- package/src/services/export-theme/layouts/featured/list.html +1 -4
- package/src/services/export-theme/layouts/index.html +40 -26
- package/src/services/export-theme/layouts/partials/collection-members.html +100 -0
- package/src/services/export-theme/layouts/partials/collection-threads.html +33 -0
- package/src/services/export-theme/layouts/partials/featured-members.html +35 -0
- package/src/services/export-theme/layouts/partials/featured-thread.html +1 -1
- package/src/services/export-theme/layouts/partials/footer.html +1 -1
- package/src/services/export-theme/layouts/partials/head.html +1 -1
- package/src/services/export-theme/layouts/partials/header.html +5 -3
- package/src/services/export-theme/layouts/partials/jant-data.html +23 -0
- package/src/services/export-theme/layouts/partials/latest-members.html +48 -0
- package/src/services/export-theme/layouts/partials/smart-collection-members.html +129 -0
- package/src/services/export-theme/layouts/partials/thread-preview.html +1 -1
- package/src/services/export-theme/layouts/post/list.html +1 -1
- package/src/services/export-theme/layouts/smart_collection/list.html +24 -0
- package/src/services/export-theme/styles/main.css +0 -1
- package/src/services/export-theme/theme.toml +1 -1
- package/src/services/export.ts +620 -71
- package/src/services/github-app-installations.ts +171 -4
- package/src/services/github-sync.ts +69 -15
- package/src/services/mcp.ts +19 -1
- package/src/services/media.ts +8 -1
- package/src/services/path.ts +34 -1
- package/src/services/post.ts +214 -39
- package/src/services/site-admin.ts +3 -7
- package/src/services/site.ts +83 -31
- package/src/styles/ui.css +7 -1
- package/src/types/app-context.ts +16 -0
- package/src/types/bindings.ts +5 -0
- package/src/types/operations.ts +10 -0
- package/src/ui/dash/settings/GeneralContent.tsx +10 -8
- package/src/client/tiptap/exitable-marks.ts +0 -73
|
@@ -1,16 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
mkdtemp,
|
|
4
|
-
readFile,
|
|
5
|
-
readdir,
|
|
6
|
-
rm,
|
|
7
|
-
stat,
|
|
8
|
-
writeFile,
|
|
9
|
-
} from "node:fs/promises";
|
|
1
|
+
import { openAsBlob } from "node:fs";
|
|
2
|
+
import { mkdtemp, readFile, readdir, rm, stat } from "node:fs/promises";
|
|
10
3
|
import { resolve, join, extname, dirname, basename } from "node:path";
|
|
11
4
|
import { tmpdir } from "node:os";
|
|
12
5
|
import { parseArgs } from "node:util";
|
|
13
|
-
import { typeidUnboxed } from "typeid-js";
|
|
14
6
|
import { CLI_API_TOKEN_ENV_VAR, getCliApiToken } from "../lib/cli-api-token.js";
|
|
15
7
|
import {
|
|
16
8
|
extractAttachmentBlocks,
|
|
@@ -19,6 +11,7 @@ import {
|
|
|
19
11
|
rewriteMediaReferences,
|
|
20
12
|
} from "../lib/site-media-parser.js";
|
|
21
13
|
import { parseFrontMatter as parseFrontMatterShared } from "../lib/hugo-markdown.js";
|
|
14
|
+
import { extractZipFile } from "../lib/zip-archive.js";
|
|
22
15
|
|
|
23
16
|
/**
|
|
24
17
|
* Parse front matter from a Markdown file.
|
|
@@ -32,22 +25,27 @@ async function parseToml(content) {
|
|
|
32
25
|
}
|
|
33
26
|
|
|
34
27
|
/**
|
|
35
|
-
*
|
|
36
|
-
* `static
|
|
37
|
-
* `
|
|
28
|
+
* Static directories of a Jant Hugo export, in Hugo's lookup order: the root
|
|
29
|
+
* `static/` (user overrides) before `themes/jant/static/`, where Jant writes
|
|
30
|
+
* `custom.css`, `favicon.ico`, and `apple-touch-icon.png`.
|
|
31
|
+
*/
|
|
32
|
+
const IMPORT_STATIC_DIRS = [["static"], ["themes", "jant", "static"]];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read custom.css from the first static directory of a Jant Hugo export that
|
|
36
|
+
* has one.
|
|
38
37
|
*/
|
|
39
38
|
async function readImportCustomCss(rootDir) {
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
for (const staticDir of IMPORT_STATIC_DIRS) {
|
|
40
|
+
const css = await readFile(
|
|
41
|
+
join(rootDir, ...staticDir, "custom.css"),
|
|
42
|
+
"utf-8",
|
|
43
|
+
).catch(() => null);
|
|
44
|
+
if (css !== null) {
|
|
45
|
+
return css;
|
|
46
|
+
}
|
|
46
47
|
}
|
|
47
|
-
return
|
|
48
|
-
join(rootDir, "themes", "jant", "static", "custom.css"),
|
|
49
|
-
"utf-8",
|
|
50
|
-
).catch(() => "");
|
|
48
|
+
return "";
|
|
51
49
|
}
|
|
52
50
|
|
|
53
51
|
function resolveImportUrl(url, siteConfig) {
|
|
@@ -148,26 +146,40 @@ async function resolveImportLocalAssetPath(rawUrl, siteConfig, sourceRootDir) {
|
|
|
148
146
|
return null;
|
|
149
147
|
}
|
|
150
148
|
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
149
|
+
for (const staticDir of IMPORT_STATIC_DIRS) {
|
|
150
|
+
const fullPath = join(sourceRootDir, ...staticDir, pathname);
|
|
151
|
+
const fileStat = await stat(fullPath).catch(() => null);
|
|
152
|
+
if (fileStat?.isFile()) {
|
|
153
|
+
return fullPath;
|
|
154
|
+
}
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
return
|
|
157
|
+
return null;
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Read one asset as a Blob, from the export's files or its URL.
|
|
162
|
+
*
|
|
163
|
+
* A local file comes back file-backed (`openAsBlob`): an upload streams it
|
|
164
|
+
* from disk. Reading every file into memory first held gigabytes during the
|
|
165
|
+
* import of a real site, since Node does not count Blob memory toward the
|
|
166
|
+
* garbage collector's pressure.
|
|
167
|
+
*
|
|
168
|
+
* @param {{ sourceUrl?: string, sourceFilePath?: string | null, mimeType?: string, originalName?: string }} options
|
|
169
|
+
* @returns {Promise<{ blob: Blob, filename: string, contentType: string } | null>}
|
|
170
|
+
* The asset, or null when a remote source is missing or unreachable
|
|
171
|
+
*/
|
|
160
172
|
async function readImportAsset(options) {
|
|
161
173
|
const { sourceUrl, sourceFilePath, mimeType, originalName } = options;
|
|
162
174
|
|
|
163
175
|
if (sourceFilePath) {
|
|
164
|
-
const bytes = new Uint8Array(await readFile(sourceFilePath));
|
|
165
176
|
const filename =
|
|
166
177
|
originalName || basename(sourceFilePath) || getFilenameFromUrl(sourceUrl);
|
|
178
|
+
const contentType = mimeType || guessMimeType(filename);
|
|
167
179
|
return {
|
|
168
|
-
|
|
180
|
+
blob: await openAsBlob(sourceFilePath, { type: contentType }),
|
|
169
181
|
filename,
|
|
170
|
-
contentType
|
|
182
|
+
contentType,
|
|
171
183
|
};
|
|
172
184
|
}
|
|
173
185
|
|
|
@@ -182,23 +194,17 @@ async function readImportAsset(options) {
|
|
|
182
194
|
|
|
183
195
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
184
196
|
const filename = originalName || getFilenameFromUrl(sourceUrl) || "file";
|
|
197
|
+
const contentType =
|
|
198
|
+
mimeType ||
|
|
199
|
+
response.headers.get("content-type")?.split(";")[0] ||
|
|
200
|
+
guessMimeType(filename);
|
|
185
201
|
return {
|
|
186
|
-
bytes,
|
|
202
|
+
blob: new Blob([bytes], { type: contentType }),
|
|
187
203
|
filename,
|
|
188
|
-
contentType
|
|
189
|
-
mimeType ||
|
|
190
|
-
response.headers.get("content-type")?.split(";")[0] ||
|
|
191
|
-
guessMimeType(filename),
|
|
204
|
+
contentType,
|
|
192
205
|
};
|
|
193
206
|
}
|
|
194
207
|
|
|
195
|
-
function toArrayBuffer(bytes) {
|
|
196
|
-
return bytes.buffer.slice(
|
|
197
|
-
bytes.byteOffset,
|
|
198
|
-
bytes.byteOffset + bytes.byteLength,
|
|
199
|
-
);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
208
|
async function readMediaSpecAsset(media, field = "src") {
|
|
203
209
|
if (field === "poster") {
|
|
204
210
|
return readImportAsset({
|
|
@@ -305,45 +311,60 @@ async function assertImportSlugAvailable(target, slug, label, kind) {
|
|
|
305
311
|
* Download a media file and upload it to the Jant API.
|
|
306
312
|
* Returns the new URL, or null on failure.
|
|
307
313
|
*/
|
|
314
|
+
/**
|
|
315
|
+
* Upload one media file through the site's API.
|
|
316
|
+
*
|
|
317
|
+
* Throws when the file can't be read or the site refuses it, so a caller
|
|
318
|
+
* decides whether that is fatal (the export's own media) or leaves a link in
|
|
319
|
+
* place (an image in the body). It used to return null for either, and a
|
|
320
|
+
* post was created without the file and without a word.
|
|
321
|
+
*
|
|
322
|
+
* @param {Record<string, unknown>} media - A normalized media spec
|
|
323
|
+
* @param {string} apiUrl - Target site URL
|
|
324
|
+
* @param {string} token - API token
|
|
325
|
+
* @returns {Promise<{ url: string, id: string }>} The uploaded media
|
|
326
|
+
*/
|
|
308
327
|
async function uploadRemoteMedia(media, apiUrl, token) {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
}
|
|
328
|
+
const asset = await readMediaSpecAsset(media);
|
|
329
|
+
if (!asset) {
|
|
330
|
+
throw new Error(`Couldn't read ${media.src}`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const formData = new FormData();
|
|
334
|
+
formData.append("file", asset.blob, asset.filename);
|
|
335
|
+
if (media.alt) formData.append("alt", media.alt);
|
|
336
|
+
if (media.summary) formData.append("summary", media.summary);
|
|
337
|
+
if (media.width) formData.append("width", String(media.width));
|
|
338
|
+
if (media.height) formData.append("height", String(media.height));
|
|
339
|
+
if (media.blurhash) formData.append("blurhash", media.blurhash);
|
|
340
|
+
if (media.waveform) formData.append("waveform", media.waveform);
|
|
341
|
+
if (media.durationSeconds) {
|
|
342
|
+
formData.append("durationSeconds", String(media.durationSeconds));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (media.poster) {
|
|
346
|
+
const posterAsset = await readMediaSpecAsset(media, "poster");
|
|
347
|
+
if (posterAsset) {
|
|
348
|
+
formData.append("poster", posterAsset.blob, posterAsset.filename);
|
|
349
|
+
} else {
|
|
350
|
+
console.warn(`Warning: couldn't read the poster ${media.poster}`);
|
|
333
351
|
}
|
|
352
|
+
}
|
|
334
353
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
354
|
+
const uploadResponse = await fetch(`${apiUrl}/api/upload`, {
|
|
355
|
+
method: "POST",
|
|
356
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
357
|
+
body: formData,
|
|
358
|
+
});
|
|
340
359
|
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
360
|
+
if (!uploadResponse.ok) {
|
|
361
|
+
const detail = (await uploadResponse.text().catch(() => "")).slice(0, 300);
|
|
362
|
+
throw new Error(
|
|
363
|
+
`The site refused ${media.src}: HTTP ${uploadResponse.status}${detail ? ` ${detail}` : ""}`,
|
|
364
|
+
);
|
|
346
365
|
}
|
|
366
|
+
const data = await uploadResponse.json();
|
|
367
|
+
return { url: data.url, id: data.id };
|
|
347
368
|
}
|
|
348
369
|
|
|
349
370
|
function getFilenameFromUrl(fileUrl) {
|
|
@@ -394,33 +415,6 @@ function guessMimeType(filename) {
|
|
|
394
415
|
}
|
|
395
416
|
}
|
|
396
417
|
|
|
397
|
-
function generateImportedStorageKey(originalName) {
|
|
398
|
-
const id = typeidUnboxed("med");
|
|
399
|
-
const extension = extname(originalName) || "";
|
|
400
|
-
const filename = `${id}${extension}`;
|
|
401
|
-
return {
|
|
402
|
-
id,
|
|
403
|
-
filename,
|
|
404
|
-
storageKey: `media/${filename}`,
|
|
405
|
-
};
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
function getMediaPublicUrl(storageKey, provider, appConfig) {
|
|
409
|
-
const base =
|
|
410
|
-
provider === "s3"
|
|
411
|
-
? appConfig.s3PublicUrl
|
|
412
|
-
: provider === "local"
|
|
413
|
-
? appConfig.localPublicUrl
|
|
414
|
-
: appConfig.r2PublicUrl;
|
|
415
|
-
|
|
416
|
-
if (base) {
|
|
417
|
-
return `${base.replace(/\/+$/, "")}/${storageKey}`;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
const prefix = appConfig.sitePathPrefix || "";
|
|
421
|
-
return `${prefix}/${storageKey}`.replace(/\/{2,}/g, "/");
|
|
422
|
-
}
|
|
423
|
-
|
|
424
418
|
function normalizeImportedBodySegment(markdown) {
|
|
425
419
|
const extracted = extractAttachmentBlocks(markdown);
|
|
426
420
|
const normalized = normalizeImportedBody(extracted.markdown);
|
|
@@ -515,6 +509,10 @@ async function mediaSpecFromJantMedia(entry, sourceRootDir) {
|
|
|
515
509
|
waveform: typeof entry.waveform === "string" ? entry.waveform : undefined,
|
|
516
510
|
summary: typeof entry.summary === "string" ? entry.summary : undefined,
|
|
517
511
|
chars: typeof entry.chars === "number" ? entry.chars : undefined,
|
|
512
|
+
durationSeconds:
|
|
513
|
+
typeof entry.duration_seconds === "number"
|
|
514
|
+
? entry.duration_seconds
|
|
515
|
+
: undefined,
|
|
518
516
|
};
|
|
519
517
|
}
|
|
520
518
|
|
|
@@ -624,7 +622,7 @@ async function normalizeTextAttachmentSpec(spec, siteConfig, sourceRootDir) {
|
|
|
624
622
|
|
|
625
623
|
let markdown;
|
|
626
624
|
try {
|
|
627
|
-
markdown =
|
|
625
|
+
markdown = await asset.blob.text();
|
|
628
626
|
} catch {
|
|
629
627
|
return null;
|
|
630
628
|
}
|
|
@@ -667,7 +665,16 @@ async function buildImportedAttachments(
|
|
|
667
665
|
sourceRootDir,
|
|
668
666
|
);
|
|
669
667
|
if (!normalized || normalized.src.startsWith("data:")) continue;
|
|
670
|
-
|
|
668
|
+
let result;
|
|
669
|
+
try {
|
|
670
|
+
result = await target.uploadMedia(normalized);
|
|
671
|
+
} catch (err) {
|
|
672
|
+
// The post's own attachment: creating the post without it would lose
|
|
673
|
+
// the file quietly.
|
|
674
|
+
throw new Error(`Couldn't upload ${spec.src}: ${err.message}`, {
|
|
675
|
+
cause: err,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
671
678
|
if (!result) continue;
|
|
672
679
|
attachments.push({
|
|
673
680
|
type: "media",
|
|
@@ -692,7 +699,14 @@ async function uploadMediaList(mediaSpecs, target, siteConfig, sourceRootDir) {
|
|
|
692
699
|
sourceRootDir,
|
|
693
700
|
);
|
|
694
701
|
if (!normalized || normalized.src.startsWith("data:")) continue;
|
|
695
|
-
|
|
702
|
+
let result;
|
|
703
|
+
try {
|
|
704
|
+
result = await target.uploadMedia(normalized);
|
|
705
|
+
} catch (err) {
|
|
706
|
+
// A body image may be a third party's; the Markdown keeps its URL.
|
|
707
|
+
console.warn(`Warning: kept ${spec.src} as a link. ${err.message}`);
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
696
710
|
if (!result) continue;
|
|
697
711
|
// Key the rewrite map by the *original* URL as it appears in the body
|
|
698
712
|
// (e.g. `/media/...`). `normalized.src` has been resolved against
|
|
@@ -726,7 +740,16 @@ async function uploadBundleResources(resourceSpecs, target) {
|
|
|
726
740
|
for (const spec of resourceSpecs) {
|
|
727
741
|
if (!spec) continue;
|
|
728
742
|
if (!spec.srcFilePath && !isAbsoluteUrl(spec.src)) continue;
|
|
729
|
-
|
|
743
|
+
let result;
|
|
744
|
+
try {
|
|
745
|
+
result = await target.uploadMedia(spec);
|
|
746
|
+
} catch (err) {
|
|
747
|
+
// The post's own attachment: creating the post without it would lose
|
|
748
|
+
// the file quietly.
|
|
749
|
+
throw new Error(`Couldn't upload ${spec.src}: ${err.message}`, {
|
|
750
|
+
cause: err,
|
|
751
|
+
});
|
|
752
|
+
}
|
|
730
753
|
if (!result) continue;
|
|
731
754
|
urlMap.set(spec.src, result.url);
|
|
732
755
|
mediaIds.push(result.id);
|
|
@@ -888,6 +911,9 @@ async function loadSiteConfig(rootDir) {
|
|
|
888
911
|
nav_exported: Array.isArray(jantData.nav),
|
|
889
912
|
collections_directory: directoryItems,
|
|
890
913
|
collections_directory_exported: directoryExported,
|
|
914
|
+
custom_urls: Array.isArray(jantData.custom_url)
|
|
915
|
+
? jantData.custom_url
|
|
916
|
+
: [],
|
|
891
917
|
},
|
|
892
918
|
jant_export: {
|
|
893
919
|
format:
|
|
@@ -908,8 +934,10 @@ function buildSettingsUpdatesFromConfig(siteConfig, customCss = "") {
|
|
|
908
934
|
SITE_DESCRIPTION: String(siteConfig?.description || ""),
|
|
909
935
|
SITE_LANGUAGE: String(siteConfig?.default_language || "en"),
|
|
910
936
|
SITE_FOOTER: String(jant.site_footer_markdown || ""),
|
|
911
|
-
SHOW_JANT_BRANDING_ON_HOME: jant.show_jant_branding_on_home
|
|
912
|
-
|
|
937
|
+
SHOW_JANT_BRANDING_ON_HOME: jant.show_jant_branding_on_home
|
|
938
|
+
? "true"
|
|
939
|
+
: "false",
|
|
940
|
+
NOINDEX: jant.noindex ? "true" : "false",
|
|
913
941
|
PUBLIC_API_ENABLED: jant.public_api_enabled === false ? "false" : "true",
|
|
914
942
|
RSS_FEEDS_ENABLED: jant.rss_feeds_enabled === false ? "false" : "true",
|
|
915
943
|
SHOW_HEADER_AVATAR: jant.show_header_avatar ? "true" : "",
|
|
@@ -946,6 +974,28 @@ function splitSettingsUpdatesForImport(updates) {
|
|
|
946
974
|
return { editable, internal };
|
|
947
975
|
}
|
|
948
976
|
|
|
977
|
+
const NAV_PLACEMENTS = new Set(["header", "more"]);
|
|
978
|
+
const NAV_ITEM_TYPES = new Set([
|
|
979
|
+
"system",
|
|
980
|
+
"link",
|
|
981
|
+
"collection",
|
|
982
|
+
"smart_collection",
|
|
983
|
+
"page",
|
|
984
|
+
]);
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* The slug a nav URL names, for exports older than `collection_slug` and
|
|
988
|
+
* `post_slug`: `/now` or `/now/` → `now`. Anything else names no slug.
|
|
989
|
+
*
|
|
990
|
+
* @param {unknown} url - The exported nav URL
|
|
991
|
+
* @returns {string | null} The slug, or null
|
|
992
|
+
*/
|
|
993
|
+
function getSlugFromNavUrl(url) {
|
|
994
|
+
if (typeof url !== "string") return null;
|
|
995
|
+
const match = url.match(/^\/([^/?#]+)\/?$/);
|
|
996
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
997
|
+
}
|
|
998
|
+
|
|
949
999
|
function normalizeImportedNavItems(siteConfig) {
|
|
950
1000
|
const jant = siteConfig?.extra?.jant || {};
|
|
951
1001
|
const navItems = jant.nav;
|
|
@@ -961,23 +1011,217 @@ function normalizeImportedNavItems(siteConfig) {
|
|
|
961
1011
|
items: navItems
|
|
962
1012
|
.map((item) => {
|
|
963
1013
|
if (!item || typeof item !== "object") return null;
|
|
964
|
-
const type = item.type
|
|
965
|
-
|
|
966
|
-
|
|
1014
|
+
const type = NAV_ITEM_TYPES.has(item.type) ? item.type : "link";
|
|
1015
|
+
const placement = NAV_PLACEMENTS.has(item.placement)
|
|
1016
|
+
? item.placement
|
|
1017
|
+
: "header";
|
|
1018
|
+
const label = typeof item.label === "string" ? item.label : "";
|
|
1019
|
+
const url = typeof item.url === "string" ? item.url : "";
|
|
1020
|
+
const customLabel =
|
|
1021
|
+
typeof item.custom_label === "string" && item.custom_label.trim()
|
|
1022
|
+
? item.custom_label
|
|
1023
|
+
: null;
|
|
1024
|
+
|
|
1025
|
+
if (type === "system") {
|
|
1026
|
+
if (typeof item.system_key !== "string" || !item.system_key) {
|
|
1027
|
+
return null;
|
|
1028
|
+
}
|
|
1029
|
+
return { type, systemKey: item.system_key, customLabel, placement };
|
|
967
1030
|
}
|
|
968
|
-
if (
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
return { type,
|
|
1031
|
+
if (type === "collection") {
|
|
1032
|
+
const slug =
|
|
1033
|
+
typeof item.collection_slug === "string"
|
|
1034
|
+
? item.collection_slug
|
|
1035
|
+
: getSlugFromNavUrl(url);
|
|
1036
|
+
return { type, slug, customLabel, label, url, placement };
|
|
974
1037
|
}
|
|
975
|
-
|
|
1038
|
+
if (type === "page") {
|
|
1039
|
+
const slug =
|
|
1040
|
+
typeof item.post_slug === "string"
|
|
1041
|
+
? item.post_slug
|
|
1042
|
+
: getSlugFromNavUrl(url);
|
|
1043
|
+
return { type, slug, customLabel, label, url, placement };
|
|
1044
|
+
}
|
|
1045
|
+
if (type === "smart_collection") {
|
|
1046
|
+
const slug =
|
|
1047
|
+
typeof item.smart_collection_slug === "string"
|
|
1048
|
+
? item.smart_collection_slug
|
|
1049
|
+
: getSlugFromNavUrl(url);
|
|
1050
|
+
return { type, slug, customLabel, label, url, placement };
|
|
1051
|
+
}
|
|
1052
|
+
if (!label || !url) return null;
|
|
1053
|
+
return { type: "link", label: customLabel ?? label, url, placement };
|
|
976
1054
|
})
|
|
977
1055
|
.filter(Boolean),
|
|
978
1056
|
};
|
|
979
1057
|
}
|
|
980
1058
|
|
|
1059
|
+
/**
|
|
1060
|
+
* The custom URLs an export lists that name no post or collection: redirects,
|
|
1061
|
+
* and legacy archive URLs the importer reports but can't recreate.
|
|
1062
|
+
*
|
|
1063
|
+
* @param {Record<string, unknown>} siteConfig - From `loadSiteConfig`
|
|
1064
|
+
* @returns {Array<{ path: string, kind: "redirect" | "archive", to?: string, status?: 301 | 302, archiveQuery?: string }>}
|
|
1065
|
+
*/
|
|
1066
|
+
function normalizeImportedCustomUrls(siteConfig) {
|
|
1067
|
+
const entries = siteConfig?.extra?.jant?.custom_urls;
|
|
1068
|
+
if (!Array.isArray(entries)) return [];
|
|
1069
|
+
|
|
1070
|
+
return entries
|
|
1071
|
+
.map((entry) => {
|
|
1072
|
+
if (!entry || typeof entry !== "object") return null;
|
|
1073
|
+
const path =
|
|
1074
|
+
typeof entry.path === "string" ? entry.path.replace(/^\/+/, "") : "";
|
|
1075
|
+
if (!path) return null;
|
|
1076
|
+
if (entry.kind === "redirect" && typeof entry.to === "string") {
|
|
1077
|
+
return {
|
|
1078
|
+
path,
|
|
1079
|
+
kind: "redirect",
|
|
1080
|
+
to: entry.to,
|
|
1081
|
+
status: entry.status === 302 ? 302 : 301,
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
if (entry.kind === "archive") {
|
|
1085
|
+
return {
|
|
1086
|
+
path,
|
|
1087
|
+
kind: "archive",
|
|
1088
|
+
archiveQuery:
|
|
1089
|
+
typeof entry.archive_query === "string"
|
|
1090
|
+
? entry.archive_query
|
|
1091
|
+
: undefined,
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
return null;
|
|
1095
|
+
})
|
|
1096
|
+
.filter(Boolean);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* The create request for one imported nav item, once the posts and
|
|
1101
|
+
* collections it may point at exist. A target that didn't come across stays
|
|
1102
|
+
* in the navigation as a plain link to the same address.
|
|
1103
|
+
*
|
|
1104
|
+
* @param {Record<string, unknown>} item - From `normalizeImportedNavItems`
|
|
1105
|
+
* @param {{ collectionSlugToId: Map<string, string>, smartCollectionSlugToId: Map<string, string>, postSlugToId: Map<string, string> }} targets
|
|
1106
|
+
* @returns {{ payload: Record<string, unknown>, customLabel: string | null, warning: string | null }}
|
|
1107
|
+
*/
|
|
1108
|
+
function buildNavItemCreateRequest(item, targets) {
|
|
1109
|
+
const { placement, customLabel } = item;
|
|
1110
|
+
|
|
1111
|
+
if (item.type === "system") {
|
|
1112
|
+
return {
|
|
1113
|
+
payload: { type: "system", systemKey: item.systemKey, placement },
|
|
1114
|
+
customLabel,
|
|
1115
|
+
warning: null,
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
if (item.type === "link") {
|
|
1119
|
+
return {
|
|
1120
|
+
payload: { type: "link", label: item.label, url: item.url, placement },
|
|
1121
|
+
customLabel: null,
|
|
1122
|
+
warning: null,
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
const targetId =
|
|
1127
|
+
item.type === "collection"
|
|
1128
|
+
? targets.collectionSlugToId.get(item.slug)
|
|
1129
|
+
: item.type === "smart_collection"
|
|
1130
|
+
? targets.smartCollectionSlugToId.get(item.slug)
|
|
1131
|
+
: item.type === "page"
|
|
1132
|
+
? targets.postSlugToId.get(item.slug)
|
|
1133
|
+
: undefined;
|
|
1134
|
+
if (targetId) {
|
|
1135
|
+
const targetField =
|
|
1136
|
+
item.type === "collection"
|
|
1137
|
+
? "collectionId"
|
|
1138
|
+
: item.type === "smart_collection"
|
|
1139
|
+
? "smartCollectionId"
|
|
1140
|
+
: "postId";
|
|
1141
|
+
return {
|
|
1142
|
+
payload: {
|
|
1143
|
+
type: item.type,
|
|
1144
|
+
[targetField]: targetId,
|
|
1145
|
+
...(customLabel ? { label: customLabel } : {}),
|
|
1146
|
+
placement,
|
|
1147
|
+
},
|
|
1148
|
+
customLabel: null,
|
|
1149
|
+
warning: null,
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
return {
|
|
1154
|
+
payload: {
|
|
1155
|
+
type: "link",
|
|
1156
|
+
label: customLabel ?? item.label,
|
|
1157
|
+
url: item.url,
|
|
1158
|
+
placement,
|
|
1159
|
+
},
|
|
1160
|
+
customLabel: null,
|
|
1161
|
+
warning: `The ${item.type.replace("_", " ")} behind "${item.label}" wasn't imported; it stays in the navigation as a link to ${item.url}.`,
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* The create request for one exported smart collection.
|
|
1167
|
+
*
|
|
1168
|
+
* The conditions arrive as the export spells them, with the collection one
|
|
1169
|
+
* naming a slug; it becomes this site's ID. When that collection didn't come
|
|
1170
|
+
* across, the smart collection is skipped rather than created without the
|
|
1171
|
+
* condition, which would widen it to posts it never held.
|
|
1172
|
+
*
|
|
1173
|
+
* @param {{ slug: string, frontMatter: Record<string, unknown> }} bundle - A `type: smart_collection` section
|
|
1174
|
+
* @param {Map<string, string>} collectionSlugToId - Imported collections
|
|
1175
|
+
* @returns {{ payload: Record<string, unknown> | null, warning: string | null }}
|
|
1176
|
+
* @example
|
|
1177
|
+
* buildSmartCollectionCreateRequest(bundle, new Map([["ideas", "col_01…"]]));
|
|
1178
|
+
*/
|
|
1179
|
+
function buildSmartCollectionCreateRequest(bundle, collectionSlugToId) {
|
|
1180
|
+
const frontMatter = bundle.frontMatter;
|
|
1181
|
+
const exported = frontMatter.selection;
|
|
1182
|
+
const selection =
|
|
1183
|
+
exported && typeof exported === "object" && !Array.isArray(exported)
|
|
1184
|
+
? { ...exported }
|
|
1185
|
+
: {};
|
|
1186
|
+
|
|
1187
|
+
if (selection.collection !== undefined) {
|
|
1188
|
+
const collectionId =
|
|
1189
|
+
typeof selection.collection === "string"
|
|
1190
|
+
? collectionSlugToId.get(selection.collection)
|
|
1191
|
+
: undefined;
|
|
1192
|
+
if (!collectionId) {
|
|
1193
|
+
return {
|
|
1194
|
+
payload: null,
|
|
1195
|
+
warning: `skipped the smart collection /${bundle.slug}: the collection it filters by, "${selection.collection}", wasn't imported.`,
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
selection.collection = [collectionId];
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
const title =
|
|
1202
|
+
typeof frontMatter.title === "string" && frontMatter.title.trim()
|
|
1203
|
+
? frontMatter.title
|
|
1204
|
+
: bundle.slug;
|
|
1205
|
+
return {
|
|
1206
|
+
payload: {
|
|
1207
|
+
slug: bundle.slug,
|
|
1208
|
+
title,
|
|
1209
|
+
...(typeof frontMatter.summary_text === "string" &&
|
|
1210
|
+
frontMatter.summary_text.trim()
|
|
1211
|
+
? { description: frontMatter.summary_text }
|
|
1212
|
+
: {}),
|
|
1213
|
+
selection,
|
|
1214
|
+
...(typeof frontMatter.sort_order === "string"
|
|
1215
|
+
? { sort: frontMatter.sort_order }
|
|
1216
|
+
: {}),
|
|
1217
|
+
...(typeof frontMatter.display_layout === "string"
|
|
1218
|
+
? { layout: frontMatter.display_layout }
|
|
1219
|
+
: {}),
|
|
1220
|
+
},
|
|
1221
|
+
warning: null,
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
|
|
981
1225
|
function normalizeImportedCollectionDirectory(siteConfig) {
|
|
982
1226
|
const jant = siteConfig?.extra?.jant || {};
|
|
983
1227
|
const directoryItems = jant.collections_directory;
|
|
@@ -995,12 +1239,12 @@ function normalizeImportedCollectionDirectory(siteConfig) {
|
|
|
995
1239
|
if (!item || typeof item !== "object") return null;
|
|
996
1240
|
|
|
997
1241
|
if (
|
|
998
|
-
item.type === "collection" &&
|
|
1242
|
+
(item.type === "collection" || item.type === "smart_collection") &&
|
|
999
1243
|
typeof item.slug === "string" &&
|
|
1000
1244
|
item.slug.trim()
|
|
1001
1245
|
) {
|
|
1002
1246
|
return {
|
|
1003
|
-
type:
|
|
1247
|
+
type: item.type,
|
|
1004
1248
|
slug: item.slug.trim(),
|
|
1005
1249
|
};
|
|
1006
1250
|
}
|
|
@@ -1021,6 +1265,10 @@ function normalizeImportedCollectionDirectory(siteConfig) {
|
|
|
1021
1265
|
type: "link",
|
|
1022
1266
|
label: item.label,
|
|
1023
1267
|
url: item.url,
|
|
1268
|
+
description:
|
|
1269
|
+
typeof item.description === "string" && item.description.trim()
|
|
1270
|
+
? item.description
|
|
1271
|
+
: null,
|
|
1024
1272
|
};
|
|
1025
1273
|
}
|
|
1026
1274
|
|
|
@@ -1149,6 +1397,7 @@ async function syncImportedCollectionDirectory(
|
|
|
1149
1397
|
target,
|
|
1150
1398
|
importedDirectory,
|
|
1151
1399
|
collectionSlugToId,
|
|
1400
|
+
smartCollectionSlugToId,
|
|
1152
1401
|
) {
|
|
1153
1402
|
if (!importedDirectory.exported) {
|
|
1154
1403
|
return { created: 0, deleted: 0, moved: 0 };
|
|
@@ -1156,8 +1405,12 @@ async function syncImportedCollectionDirectory(
|
|
|
1156
1405
|
|
|
1157
1406
|
let deleted = 0;
|
|
1158
1407
|
const existingItems = await target.listCollectionDirectoryItems();
|
|
1408
|
+
// Collections and smart collections get their row when they are created;
|
|
1409
|
+
// dividers and links exist only in the directory and are rebuilt from it.
|
|
1159
1410
|
for (const item of existingItems) {
|
|
1160
|
-
if (item.type === "collection")
|
|
1411
|
+
if (item.type === "collection" || item.type === "smart_collection") {
|
|
1412
|
+
continue;
|
|
1413
|
+
}
|
|
1161
1414
|
const removed = await target.deleteCollectionDirectoryItem(item.id);
|
|
1162
1415
|
if (removed !== false) {
|
|
1163
1416
|
deleted += 1;
|
|
@@ -1165,24 +1418,33 @@ async function syncImportedCollectionDirectory(
|
|
|
1165
1418
|
}
|
|
1166
1419
|
|
|
1167
1420
|
let currentItems = await target.listCollectionDirectoryItems();
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1421
|
+
// Directory rows by what they list: `collection:<id>` or
|
|
1422
|
+
// `smart_collection:<id>`.
|
|
1423
|
+
const rowIdsByTarget = new Map();
|
|
1424
|
+
for (const item of currentItems) {
|
|
1425
|
+
if (item.type === "collection" && item.collectionId) {
|
|
1426
|
+
rowIdsByTarget.set(`collection:${item.collectionId}`, item.id);
|
|
1427
|
+
} else if (item.type === "smart_collection" && item.smartCollectionId) {
|
|
1428
|
+
rowIdsByTarget.set(`smart_collection:${item.smartCollectionId}`, item.id);
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1173
1431
|
|
|
1174
1432
|
const desiredIds = [];
|
|
1175
|
-
const
|
|
1433
|
+
const seenTargets = new Set();
|
|
1176
1434
|
let created = 0;
|
|
1177
1435
|
|
|
1178
1436
|
for (const item of importedDirectory.items) {
|
|
1179
|
-
if (item.type === "collection") {
|
|
1180
|
-
const
|
|
1181
|
-
|
|
1437
|
+
if (item.type === "collection" || item.type === "smart_collection") {
|
|
1438
|
+
const targetId =
|
|
1439
|
+
item.type === "collection"
|
|
1440
|
+
? collectionSlugToId.get(item.slug)
|
|
1441
|
+
: smartCollectionSlugToId.get(item.slug);
|
|
1442
|
+
const key = `${item.type}:${targetId}`;
|
|
1443
|
+
if (!targetId || seenTargets.has(key)) {
|
|
1182
1444
|
continue;
|
|
1183
1445
|
}
|
|
1184
|
-
|
|
1185
|
-
const directoryItemId =
|
|
1446
|
+
seenTargets.add(key);
|
|
1447
|
+
const directoryItemId = rowIdsByTarget.get(key);
|
|
1186
1448
|
if (directoryItemId) {
|
|
1187
1449
|
desiredIds.push(directoryItemId);
|
|
1188
1450
|
}
|
|
@@ -1199,6 +1461,7 @@ async function syncImportedCollectionDirectory(
|
|
|
1199
1461
|
type: "link",
|
|
1200
1462
|
label: item.label,
|
|
1201
1463
|
url: item.url,
|
|
1464
|
+
...(item.description ? { description: item.description } : {}),
|
|
1202
1465
|
},
|
|
1203
1466
|
);
|
|
1204
1467
|
desiredIds.push(createdItem.id);
|
|
@@ -1253,17 +1516,6 @@ async function getIncompleteSetupError(target, targetLabel) {
|
|
|
1253
1516
|
return buildIncompleteSetupError(targetLabel);
|
|
1254
1517
|
}
|
|
1255
1518
|
|
|
1256
|
-
function createUploadFile(name, type, bytes) {
|
|
1257
|
-
return {
|
|
1258
|
-
name,
|
|
1259
|
-
type,
|
|
1260
|
-
size: bytes.byteLength,
|
|
1261
|
-
stream() {
|
|
1262
|
-
return new Blob([bytes], { type }).stream();
|
|
1263
|
-
},
|
|
1264
|
-
};
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
1519
|
class ApiError extends Error {
|
|
1268
1520
|
constructor(status, text) {
|
|
1269
1521
|
super(`HTTP ${status}: ${text}`);
|
|
@@ -1346,6 +1598,12 @@ function createRemoteTarget(apiUrl, token) {
|
|
|
1346
1598
|
async deleteNavItem(id) {
|
|
1347
1599
|
return apiCall("DELETE", `/api/nav-items/${id}`, apiUrl, token);
|
|
1348
1600
|
},
|
|
1601
|
+
async updateNavItem(id, data) {
|
|
1602
|
+
return apiCall("PUT", `/api/nav-items/${id}`, apiUrl, token, data);
|
|
1603
|
+
},
|
|
1604
|
+
async createCustomUrl(data) {
|
|
1605
|
+
return apiCall("POST", "/api/custom-urls", apiUrl, token, data);
|
|
1606
|
+
},
|
|
1349
1607
|
async removeSiteAvatar() {
|
|
1350
1608
|
return apiCall("DELETE", "/api/settings/avatar", apiUrl, token);
|
|
1351
1609
|
},
|
|
@@ -1359,11 +1617,7 @@ function createRemoteTarget(apiUrl, token) {
|
|
|
1359
1617
|
}
|
|
1360
1618
|
|
|
1361
1619
|
const formData = new FormData();
|
|
1362
|
-
formData.append(
|
|
1363
|
-
"file",
|
|
1364
|
-
new Blob([avatarAsset.bytes], { type: avatarAsset.contentType }),
|
|
1365
|
-
avatarAsset.filename,
|
|
1366
|
-
);
|
|
1620
|
+
formData.append("file", avatarAsset.blob, avatarAsset.filename);
|
|
1367
1621
|
|
|
1368
1622
|
if (data.faviconUrl || data.faviconFilePath) {
|
|
1369
1623
|
const faviconAsset = await readImportAsset({
|
|
@@ -1373,13 +1627,7 @@ function createRemoteTarget(apiUrl, token) {
|
|
|
1373
1627
|
originalName: "favicon.ico",
|
|
1374
1628
|
});
|
|
1375
1629
|
if (faviconAsset) {
|
|
1376
|
-
formData.append(
|
|
1377
|
-
"favicon",
|
|
1378
|
-
new Blob([faviconAsset.bytes], {
|
|
1379
|
-
type: faviconAsset.contentType,
|
|
1380
|
-
}),
|
|
1381
|
-
faviconAsset.filename,
|
|
1382
|
-
);
|
|
1630
|
+
formData.append("favicon", faviconAsset.blob, faviconAsset.filename);
|
|
1383
1631
|
}
|
|
1384
1632
|
}
|
|
1385
1633
|
|
|
@@ -1391,9 +1639,7 @@ function createRemoteTarget(apiUrl, token) {
|
|
|
1391
1639
|
if (appleTouchAsset) {
|
|
1392
1640
|
formData.append(
|
|
1393
1641
|
"appleTouch",
|
|
1394
|
-
|
|
1395
|
-
type: appleTouchAsset.contentType,
|
|
1396
|
-
}),
|
|
1642
|
+
appleTouchAsset.blob,
|
|
1397
1643
|
appleTouchAsset.filename,
|
|
1398
1644
|
);
|
|
1399
1645
|
}
|
|
@@ -1429,6 +1675,25 @@ function createRemoteTarget(apiUrl, token) {
|
|
|
1429
1675
|
async createCollection(data) {
|
|
1430
1676
|
return apiCall("POST", "/api/collections", apiUrl, token, data);
|
|
1431
1677
|
},
|
|
1678
|
+
async listSmartCollections() {
|
|
1679
|
+
const result = await apiCall(
|
|
1680
|
+
"GET",
|
|
1681
|
+
"/api/smart-collections",
|
|
1682
|
+
apiUrl,
|
|
1683
|
+
token,
|
|
1684
|
+
);
|
|
1685
|
+
return result.smartCollections || [];
|
|
1686
|
+
},
|
|
1687
|
+
async createSmartCollection(data) {
|
|
1688
|
+
const result = await apiCall(
|
|
1689
|
+
"POST",
|
|
1690
|
+
"/api/smart-collections",
|
|
1691
|
+
apiUrl,
|
|
1692
|
+
token,
|
|
1693
|
+
data,
|
|
1694
|
+
);
|
|
1695
|
+
return result.smartCollection;
|
|
1696
|
+
},
|
|
1432
1697
|
async createCollectionDirectoryItem(data) {
|
|
1433
1698
|
return apiCall(
|
|
1434
1699
|
"POST",
|
|
@@ -1495,23 +1760,76 @@ function createRemoteTarget(apiUrl, token) {
|
|
|
1495
1760
|
};
|
|
1496
1761
|
}
|
|
1497
1762
|
|
|
1763
|
+
function getBundleTimestamp(bundle) {
|
|
1764
|
+
return parseImportTimestamp(bundle.frontMatter.date);
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
function getBundleSourceId(bundle) {
|
|
1768
|
+
return typeof bundle.frontMatter.id === "string" ? bundle.frontMatter.id : "";
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
/**
|
|
1772
|
+
* Order bundles by `date`, then by the original TypeID (time-sortable, so it
|
|
1773
|
+
* breaks a same-second tie the way the source site did), then by directory.
|
|
1774
|
+
*
|
|
1775
|
+
* @param {{ frontMatter: Record<string, unknown>, dir: string }} a
|
|
1776
|
+
* @param {{ frontMatter: Record<string, unknown>, dir: string }} b
|
|
1777
|
+
* @returns {number} Negative when `a` comes first
|
|
1778
|
+
*/
|
|
1779
|
+
function compareBundlesByDateThenId(a, b) {
|
|
1780
|
+
const aDate = getBundleTimestamp(a);
|
|
1781
|
+
const bDate = getBundleTimestamp(b);
|
|
1782
|
+
if (aDate !== null && bDate !== null && aDate !== bDate) return aDate - bDate;
|
|
1783
|
+
if (aDate !== null && bDate === null) return -1;
|
|
1784
|
+
if (aDate === null && bDate !== null) return 1;
|
|
1785
|
+
|
|
1786
|
+
const aId = getBundleSourceId(a);
|
|
1787
|
+
const bId = getBundleSourceId(b);
|
|
1788
|
+
if (aId && bId && aId !== bId) return aId < bId ? -1 : 1;
|
|
1789
|
+
|
|
1790
|
+
return basename(a.dir).localeCompare(basename(b.dir));
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
/**
|
|
1794
|
+
* Order reply bundles by their exported `weight` (position in the Thread),
|
|
1795
|
+
* falling back to {@link compareBundlesByDateThenId} for older exports.
|
|
1796
|
+
*
|
|
1797
|
+
* @param {{ frontMatter: Record<string, unknown>, dir: string }} a
|
|
1798
|
+
* @param {{ frontMatter: Record<string, unknown>, dir: string }} b
|
|
1799
|
+
* @returns {number} Negative when `a` comes first
|
|
1800
|
+
*/
|
|
1801
|
+
function compareReplyBundles(a, b) {
|
|
1802
|
+
const aWeight = a.frontMatter.weight;
|
|
1803
|
+
const bWeight = b.frontMatter.weight;
|
|
1804
|
+
if (
|
|
1805
|
+
typeof aWeight === "number" &&
|
|
1806
|
+
typeof bWeight === "number" &&
|
|
1807
|
+
aWeight !== bWeight
|
|
1808
|
+
) {
|
|
1809
|
+
return aWeight - bWeight;
|
|
1810
|
+
}
|
|
1811
|
+
return compareBundlesByDateThenId(a, b);
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1498
1814
|
/**
|
|
1499
1815
|
* Walk `content/` and classify each `_index.md` / `index.md` bundle by its
|
|
1500
1816
|
* front-matter `type`. Returns ordered root-post bundles (with child reply
|
|
1501
|
-
* bundles attached) and
|
|
1817
|
+
* bundles attached), collection landing pages, and smart collection pages.
|
|
1502
1818
|
*
|
|
1503
1819
|
* Algorithm:
|
|
1504
1820
|
* 1. Recurse into `content/` collecting every directory that has either
|
|
1505
1821
|
* `_index.md` (branch bundle / section) or `index.md` (leaf bundle).
|
|
1506
1822
|
* 2. For each `_index.md`, read front matter. `type: "post"` (or a
|
|
1507
1823
|
* missing `type` with post-shaped keys) → root bundle. `type:
|
|
1508
|
-
* "collection"` → collection landing page
|
|
1824
|
+
* "collection"` → collection landing page, `type: "smart_collection"` →
|
|
1825
|
+
* smart collection conditions. Other known section types
|
|
1509
1826
|
* (`home`, `featured`, `archive`, `collections`) are recorded and
|
|
1510
1827
|
* skipped for post import.
|
|
1511
1828
|
* 3. For each root bundle, enumerate immediate child directories; any
|
|
1512
1829
|
* child dir containing `index.md` becomes a reply leaf bundle.
|
|
1513
|
-
* 4. Reply bundles are sorted by `
|
|
1514
|
-
*
|
|
1830
|
+
* 4. Reply bundles are sorted by `weight` (Thread position), then `date`,
|
|
1831
|
+
* then the original ID; root bundles by `date`, then the original ID.
|
|
1832
|
+
* Creating posts in that order keeps same-second ties as they were.
|
|
1515
1833
|
*/
|
|
1516
1834
|
async function walkHugoContent(rootDir) {
|
|
1517
1835
|
const contentDir = join(rootDir, "content");
|
|
@@ -1597,6 +1915,7 @@ async function walkHugoContent(rootDir) {
|
|
|
1597
1915
|
// Attach leaf children to their parent root bundles.
|
|
1598
1916
|
const rootBundles = [];
|
|
1599
1917
|
const collectionBundles = [];
|
|
1918
|
+
const smartCollectionBundles = [];
|
|
1600
1919
|
|
|
1601
1920
|
for (const record of dirs.values()) {
|
|
1602
1921
|
if (record.kind === "leaf") {
|
|
@@ -1611,6 +1930,10 @@ async function walkHugoContent(rootDir) {
|
|
|
1611
1930
|
collectionBundles.push(record);
|
|
1612
1931
|
continue;
|
|
1613
1932
|
}
|
|
1933
|
+
if (record.kind === "smart_collection") {
|
|
1934
|
+
smartCollectionBundles.push(record);
|
|
1935
|
+
continue;
|
|
1936
|
+
}
|
|
1614
1937
|
if (record.kind === "post") {
|
|
1615
1938
|
rootBundles.push(record);
|
|
1616
1939
|
continue;
|
|
@@ -1618,27 +1941,19 @@ async function walkHugoContent(rootDir) {
|
|
|
1618
1941
|
// home / featured / archive / collections / anything else — skip.
|
|
1619
1942
|
}
|
|
1620
1943
|
|
|
1621
|
-
//
|
|
1944
|
+
// Replies in Thread order: the exported `weight`, which follows Jant's own
|
|
1945
|
+
// order (creation time, then ID). Older exports have no weight; `date` then
|
|
1946
|
+
// the original ID come next, since replies written in one second tie on
|
|
1947
|
+
// `date`, and the directory name last.
|
|
1622
1948
|
for (const root of rootBundles) {
|
|
1623
|
-
root.children.sort(
|
|
1624
|
-
const aDate =
|
|
1625
|
-
typeof a.frontMatter.date === "string"
|
|
1626
|
-
? Date.parse(a.frontMatter.date)
|
|
1627
|
-
: NaN;
|
|
1628
|
-
const bDate =
|
|
1629
|
-
typeof b.frontMatter.date === "string"
|
|
1630
|
-
? Date.parse(b.frontMatter.date)
|
|
1631
|
-
: NaN;
|
|
1632
|
-
if (Number.isFinite(aDate) && Number.isFinite(bDate) && aDate !== bDate) {
|
|
1633
|
-
return aDate - bDate;
|
|
1634
|
-
}
|
|
1635
|
-
if (Number.isFinite(aDate) && !Number.isFinite(bDate)) return -1;
|
|
1636
|
-
if (!Number.isFinite(aDate) && Number.isFinite(bDate)) return 1;
|
|
1637
|
-
return basename(a.dir).localeCompare(basename(b.dir));
|
|
1638
|
-
});
|
|
1949
|
+
root.children.sort(compareReplyBundles);
|
|
1639
1950
|
}
|
|
1640
1951
|
|
|
1641
|
-
|
|
1952
|
+
// Roots in creation order, oldest first, so the new IDs keep the original
|
|
1953
|
+
// order among posts that share a second: lists break those ties by ID.
|
|
1954
|
+
rootBundles.sort(compareBundlesByDateThenId);
|
|
1955
|
+
|
|
1956
|
+
return { rootBundles, collectionBundles, smartCollectionBundles };
|
|
1642
1957
|
}
|
|
1643
1958
|
|
|
1644
1959
|
/**
|
|
@@ -1770,6 +2085,45 @@ function shouldImportReplyQuietly(rootFrontMatter, replyFrontMatter) {
|
|
|
1770
2085
|
);
|
|
1771
2086
|
}
|
|
1772
2087
|
|
|
2088
|
+
/**
|
|
2089
|
+
* The post a Thread's next reply has to answer: the site's rule, the last post
|
|
2090
|
+
* in Thread order. The root comes first whatever its creation time, so the
|
|
2091
|
+
* first reply always ends the Thread; after that the newest reply by creation
|
|
2092
|
+
* time, then by ID, does.
|
|
2093
|
+
*
|
|
2094
|
+
* @param {{ id: string, createdAt: number, replyToId?: string | null }} tail - The current end
|
|
2095
|
+
* @param {{ id: string, createdAt: number, replyToId?: string | null } | null | undefined} created - The post just created
|
|
2096
|
+
* @returns {{ id: string, createdAt: number, replyToId?: string | null }} The new end
|
|
2097
|
+
* @example
|
|
2098
|
+
* getNextThreadTail(
|
|
2099
|
+
* { id: "root", createdAt: 20, replyToId: null },
|
|
2100
|
+
* { id: "r1", createdAt: 10, replyToId: "root" },
|
|
2101
|
+
* );
|
|
2102
|
+
* // r1: a reply older than the root still ends the Thread
|
|
2103
|
+
*/
|
|
2104
|
+
function getNextThreadTail(tail, created) {
|
|
2105
|
+
if (!created?.id) return tail;
|
|
2106
|
+
return compareThreadOrder(created, tail) > 0 ? created : tail;
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
/**
|
|
2110
|
+
* Compare two posts of one Thread the way the site orders them (`threadOrder`
|
|
2111
|
+
* in the post service): the root first, then creation time, then ID.
|
|
2112
|
+
*
|
|
2113
|
+
* @param {{ id: string, createdAt: number, replyToId?: string | null }} a
|
|
2114
|
+
* @param {{ id: string, createdAt: number, replyToId?: string | null }} b
|
|
2115
|
+
* @returns {number} Negative when `a` comes first, positive when `b` does
|
|
2116
|
+
* @example
|
|
2117
|
+
* compareThreadOrder({ id: "r1", createdAt: 10, replyToId: "root" },
|
|
2118
|
+
* { id: "root", createdAt: 20, replyToId: null }); // 1
|
|
2119
|
+
*/
|
|
2120
|
+
function compareThreadOrder(a, b) {
|
|
2121
|
+
const rank = (post) => (post.replyToId ? 1 : 0);
|
|
2122
|
+
if (rank(a) !== rank(b)) return rank(a) - rank(b);
|
|
2123
|
+
if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt;
|
|
2124
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
2125
|
+
}
|
|
2126
|
+
|
|
1773
2127
|
/**
|
|
1774
2128
|
* Build the payload for `target.createPost()` from a parsed bundle. Works
|
|
1775
2129
|
* for both root bundles (`forReply: false`) and reply leaf bundles — the
|
|
@@ -1825,6 +2179,16 @@ function buildPostPayloadFromBundle(bundle, options) {
|
|
|
1825
2179
|
status === "published" && typeof frontMatter.date === "string"
|
|
1826
2180
|
? Math.floor(new Date(frontMatter.date).getTime() / 1000)
|
|
1827
2181
|
: undefined,
|
|
2182
|
+
// `date` holds the publish time of a published post and the creation
|
|
2183
|
+
// time of a draft; `created` and `updated` are written when they differ.
|
|
2184
|
+
createdAt:
|
|
2185
|
+
parseImportTimestamp(frontMatter.created) ??
|
|
2186
|
+
parseImportTimestamp(frontMatter.date) ??
|
|
2187
|
+
undefined,
|
|
2188
|
+
updatedAt:
|
|
2189
|
+
parseImportTimestamp(frontMatter.updated) ??
|
|
2190
|
+
parseImportTimestamp(frontMatter.date) ??
|
|
2191
|
+
undefined,
|
|
1828
2192
|
featuredAt:
|
|
1829
2193
|
typeof frontMatter.featured_at === "string" && frontMatter.featured_at
|
|
1830
2194
|
? Math.floor(new Date(frontMatter.featured_at).getTime() / 1000)
|
|
@@ -1875,8 +2239,13 @@ export const __test__ = {
|
|
|
1875
2239
|
extractAttachmentBlocks,
|
|
1876
2240
|
buildImportedAttachments,
|
|
1877
2241
|
uploadMediaList,
|
|
2242
|
+
uploadBundleResources,
|
|
1878
2243
|
buildSettingsUpdatesFromConfig,
|
|
2244
|
+
splitSettingsUpdatesForImport,
|
|
1879
2245
|
normalizeImportedNavItems,
|
|
2246
|
+
buildNavItemCreateRequest,
|
|
2247
|
+
buildSmartCollectionCreateRequest,
|
|
2248
|
+
normalizeImportedCustomUrls,
|
|
1880
2249
|
normalizeImportedCollectionDirectory,
|
|
1881
2250
|
buildSiteAvatarImport,
|
|
1882
2251
|
reorderCollectionDirectoryItems,
|
|
@@ -1893,6 +2262,7 @@ export const __test__ = {
|
|
|
1893
2262
|
resolveThreadCollectionMemberships,
|
|
1894
2263
|
buildPostPayloadFromBundle,
|
|
1895
2264
|
shouldImportReplyQuietly,
|
|
2265
|
+
getNextThreadTail,
|
|
1896
2266
|
};
|
|
1897
2267
|
|
|
1898
2268
|
function printImportUsage() {
|
|
@@ -1995,21 +2365,14 @@ export async function run(argv) {
|
|
|
1995
2365
|
|
|
1996
2366
|
if (inputStat.isFile()) {
|
|
1997
2367
|
console.log(`Reading ZIP ${inputPath}...`);
|
|
1998
|
-
const zipData = await readFile(inputPath);
|
|
1999
|
-
const { unzipSync } = await import("fflate");
|
|
2000
|
-
const files = unzipSync(new Uint8Array(zipData));
|
|
2001
2368
|
tempSourceRootDir = await mkdtemp(join(tmpdir(), "jant-site-import-"));
|
|
2002
2369
|
sourceRootDir = tempSourceRootDir;
|
|
2003
|
-
|
|
2004
|
-
const fullPath = join(sourceRootDir, path);
|
|
2005
|
-
await mkdir(dirname(fullPath), { recursive: true });
|
|
2006
|
-
await writeFile(fullPath, data);
|
|
2007
|
-
}
|
|
2370
|
+
await extractZipFile(inputPath, sourceRootDir);
|
|
2008
2371
|
} else {
|
|
2009
2372
|
console.log(`Reading directory ${inputPath}...`);
|
|
2010
2373
|
}
|
|
2011
2374
|
|
|
2012
|
-
const { rootBundles, collectionBundles } =
|
|
2375
|
+
const { rootBundles, collectionBundles, smartCollectionBundles } =
|
|
2013
2376
|
await walkHugoContent(sourceRootDir);
|
|
2014
2377
|
const siteConfig = await loadSiteConfig(sourceRootDir);
|
|
2015
2378
|
const customCss = await readImportCustomCss(sourceRootDir);
|
|
@@ -2020,11 +2383,17 @@ export async function run(argv) {
|
|
|
2020
2383
|
0,
|
|
2021
2384
|
);
|
|
2022
2385
|
console.log(
|
|
2023
|
-
`Found ${rootBundles.length} posts (+${replyCount} replies) and ${
|
|
2386
|
+
`Found ${rootBundles.length} posts (+${replyCount} replies), ${collectionBundles.length} collections, and ${smartCollectionBundles.length} smart collections`,
|
|
2024
2387
|
);
|
|
2025
2388
|
const importedCollectionDirectory = siteConfig
|
|
2026
2389
|
? normalizeImportedCollectionDirectory(siteConfig)
|
|
2027
2390
|
: { exported: false, items: [] };
|
|
2391
|
+
const importedNav = siteConfig
|
|
2392
|
+
? normalizeImportedNavItems(siteConfig)
|
|
2393
|
+
: { exported: false, items: [] };
|
|
2394
|
+
const importedCustomUrls = siteConfig
|
|
2395
|
+
? normalizeImportedCustomUrls(siteConfig)
|
|
2396
|
+
: [];
|
|
2028
2397
|
|
|
2029
2398
|
if (target) {
|
|
2030
2399
|
const setupError = await getIncompleteSetupError(
|
|
@@ -2044,7 +2413,6 @@ export async function run(argv) {
|
|
|
2044
2413
|
siteConfig,
|
|
2045
2414
|
customCss,
|
|
2046
2415
|
);
|
|
2047
|
-
const importedNav = normalizeImportedNavItems(siteConfig);
|
|
2048
2416
|
const avatarImport = await buildSiteAvatarImport(
|
|
2049
2417
|
siteConfig,
|
|
2050
2418
|
sourceRootDir,
|
|
@@ -2097,21 +2465,6 @@ export async function run(argv) {
|
|
|
2097
2465
|
}
|
|
2098
2466
|
}
|
|
2099
2467
|
|
|
2100
|
-
if (importedNav.exported) {
|
|
2101
|
-
try {
|
|
2102
|
-
const existingNavItems = await target.listNavItems();
|
|
2103
|
-
for (const item of existingNavItems) {
|
|
2104
|
-
await target.deleteNavItem(item.id);
|
|
2105
|
-
}
|
|
2106
|
-
for (const item of importedNav.items) {
|
|
2107
|
-
await target.createNavItem(item);
|
|
2108
|
-
}
|
|
2109
|
-
} catch (err) {
|
|
2110
|
-
console.error(`Error importing navigation: ${err.message}`);
|
|
2111
|
-
process.exit(1);
|
|
2112
|
-
}
|
|
2113
|
-
}
|
|
2114
|
-
|
|
2115
2468
|
if (avatarImport) {
|
|
2116
2469
|
try {
|
|
2117
2470
|
await target.syncSiteAvatar(
|
|
@@ -2132,6 +2485,8 @@ export async function run(argv) {
|
|
|
2132
2485
|
|
|
2133
2486
|
// 3. Fetch existing collections and create missing ones
|
|
2134
2487
|
const collectionSlugToId = new Map();
|
|
2488
|
+
// Root posts by slug, for navigation entries that point at a page.
|
|
2489
|
+
const postSlugToId = new Map();
|
|
2135
2490
|
|
|
2136
2491
|
if (!dryRun) {
|
|
2137
2492
|
try {
|
|
@@ -2184,6 +2539,60 @@ export async function run(argv) {
|
|
|
2184
2539
|
}
|
|
2185
2540
|
}
|
|
2186
2541
|
|
|
2542
|
+
// Smart collections after collections, which their conditions may name.
|
|
2543
|
+
// They hold no posts, so nothing about them waits for the posts below.
|
|
2544
|
+
const smartCollectionSlugToId = new Map();
|
|
2545
|
+
if (!dryRun) {
|
|
2546
|
+
try {
|
|
2547
|
+
for (const existing of await target.listSmartCollections()) {
|
|
2548
|
+
smartCollectionSlugToId.set(existing.slug, existing.id);
|
|
2549
|
+
}
|
|
2550
|
+
} catch (err) {
|
|
2551
|
+
console.error(
|
|
2552
|
+
`Error fetching existing smart collections: ${err.message}`,
|
|
2553
|
+
);
|
|
2554
|
+
process.exit(1);
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
for (const bundle of smartCollectionBundles) {
|
|
2559
|
+
const slug = bundle.slug;
|
|
2560
|
+
if (smartCollectionSlugToId.has(slug)) {
|
|
2561
|
+
console.error(
|
|
2562
|
+
`Import conflict: smart collection slug "${slug}" is already in use. Import into an empty site or remove the existing smart collection first.`,
|
|
2563
|
+
);
|
|
2564
|
+
process.exit(1);
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
const request = buildSmartCollectionCreateRequest(
|
|
2568
|
+
bundle,
|
|
2569
|
+
collectionSlugToId,
|
|
2570
|
+
);
|
|
2571
|
+
if (!request.payload) {
|
|
2572
|
+
console.warn(`Warning: ${request.warning}`);
|
|
2573
|
+
continue;
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
if (dryRun) {
|
|
2577
|
+
console.log(
|
|
2578
|
+
`[dry-run] Would create smart collection: ${request.payload.title}`,
|
|
2579
|
+
);
|
|
2580
|
+
smartCollectionSlugToId.set(slug, `dry-run-${slug}`);
|
|
2581
|
+
continue;
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
try {
|
|
2585
|
+
const created = await target.createSmartCollection(request.payload);
|
|
2586
|
+
smartCollectionSlugToId.set(slug, created.id);
|
|
2587
|
+
console.log(`Created smart collection: ${request.payload.title}`);
|
|
2588
|
+
} catch (err) {
|
|
2589
|
+
console.error(
|
|
2590
|
+
`Error creating smart collection "${slug}": ${err.message}`,
|
|
2591
|
+
);
|
|
2592
|
+
process.exit(1);
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2187
2596
|
if (importedCollectionDirectory.exported) {
|
|
2188
2597
|
if (dryRun) {
|
|
2189
2598
|
console.log(
|
|
@@ -2195,6 +2604,7 @@ export async function run(argv) {
|
|
|
2195
2604
|
target,
|
|
2196
2605
|
importedCollectionDirectory,
|
|
2197
2606
|
collectionSlugToId,
|
|
2607
|
+
smartCollectionSlugToId,
|
|
2198
2608
|
);
|
|
2199
2609
|
} catch (err) {
|
|
2200
2610
|
console.error(
|
|
@@ -2344,6 +2754,7 @@ export async function run(argv) {
|
|
|
2344
2754
|
try {
|
|
2345
2755
|
post = await target.createPost(postData);
|
|
2346
2756
|
postsCreated++;
|
|
2757
|
+
if (post?.id && post.slug) postSlugToId.set(post.slug, post.id);
|
|
2347
2758
|
// Translation groups are rebuilt after every post exists: the group ID
|
|
2348
2759
|
// in the export is opaque and its members can appear in any order, so
|
|
2349
2760
|
// there is nothing to link to until the whole run is done.
|
|
@@ -2369,10 +2780,14 @@ export async function run(argv) {
|
|
|
2369
2780
|
// Create replies before aliases so reply slugs can claim their paths.
|
|
2370
2781
|
if (!post) continue;
|
|
2371
2782
|
const replySlugPaths = new Set();
|
|
2372
|
-
// Jant threads are linear:
|
|
2373
|
-
//
|
|
2374
|
-
//
|
|
2375
|
-
|
|
2783
|
+
// Jant threads are linear: a reply must point at the current end of the
|
|
2784
|
+
// thread, which the site reads as its last post in Thread order — the
|
|
2785
|
+
// root first, then replies by creation time, then ID. Creation times
|
|
2786
|
+
// are restored from the export (or taken from `date` in an older one),
|
|
2787
|
+
// so a reply sent after another can carry the earlier time; the end is
|
|
2788
|
+
// then the one with the later time, not the one sent last. Track it by
|
|
2789
|
+
// the site's rule.
|
|
2790
|
+
let threadTail = post;
|
|
2376
2791
|
for (const replyBundle of rootBundle.children) {
|
|
2377
2792
|
const replyFm = replyBundle.frontMatter;
|
|
2378
2793
|
const replySlug = replyBundle.slug;
|
|
@@ -2471,16 +2886,14 @@ export async function run(argv) {
|
|
|
2471
2886
|
bodyMarkdown: replyBody,
|
|
2472
2887
|
attachments: replyAttachments,
|
|
2473
2888
|
memberships: { entries: [], ids: [] },
|
|
2474
|
-
replyToId:
|
|
2889
|
+
replyToId: threadTail.id,
|
|
2475
2890
|
quietReply: shouldImportReplyQuietly(rootFm, replyFm),
|
|
2476
2891
|
});
|
|
2477
2892
|
|
|
2478
2893
|
try {
|
|
2479
2894
|
const createdReply = await target.createPost(replyData);
|
|
2480
2895
|
repliesCreated++;
|
|
2481
|
-
|
|
2482
|
-
threadTailId = createdReply.id;
|
|
2483
|
-
}
|
|
2896
|
+
threadTail = getNextThreadTail(threadTail, createdReply);
|
|
2484
2897
|
} catch (err) {
|
|
2485
2898
|
console.error(` Error creating reply: ${err.message}`);
|
|
2486
2899
|
process.exit(1);
|
|
@@ -2536,6 +2949,63 @@ export async function run(argv) {
|
|
|
2536
2949
|
}
|
|
2537
2950
|
}
|
|
2538
2951
|
|
|
2952
|
+
// Navigation and custom URLs go last: they point at posts and
|
|
2953
|
+
// collections, which exist by now.
|
|
2954
|
+
let customUrlsCreated = 0;
|
|
2955
|
+
if (!dryRun && target) {
|
|
2956
|
+
if (importedNav.exported) {
|
|
2957
|
+
try {
|
|
2958
|
+
const existingNavItems = await target.listNavItems();
|
|
2959
|
+
for (const item of existingNavItems) {
|
|
2960
|
+
await target.deleteNavItem(item.id);
|
|
2961
|
+
}
|
|
2962
|
+
for (const item of importedNav.items) {
|
|
2963
|
+
const request = buildNavItemCreateRequest(item, {
|
|
2964
|
+
collectionSlugToId,
|
|
2965
|
+
smartCollectionSlugToId,
|
|
2966
|
+
postSlugToId,
|
|
2967
|
+
});
|
|
2968
|
+
if (request.warning) console.warn(`Warning: ${request.warning}`);
|
|
2969
|
+
const created = await target.createNavItem(request.payload);
|
|
2970
|
+
if (request.customLabel && created?.id) {
|
|
2971
|
+
await target.updateNavItem(created.id, {
|
|
2972
|
+
label: request.customLabel,
|
|
2973
|
+
});
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
} catch (err) {
|
|
2977
|
+
console.error(`Error importing navigation: ${err.message}`);
|
|
2978
|
+
process.exit(1);
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2982
|
+
for (const customUrl of importedCustomUrls) {
|
|
2983
|
+
if (customUrl.kind === "archive") {
|
|
2984
|
+
console.warn(
|
|
2985
|
+
`Warning: skipped /${customUrl.path}, an archive URL (${customUrl.archiveQuery ?? "no filter"}). Archive URLs can no longer be created. Create a smart collection at /${customUrl.path} with the same conditions, here or on the source site before exporting.`,
|
|
2986
|
+
);
|
|
2987
|
+
continue;
|
|
2988
|
+
}
|
|
2989
|
+
try {
|
|
2990
|
+
await target.createCustomUrl({
|
|
2991
|
+
path: customUrl.path,
|
|
2992
|
+
targetType: "redirect",
|
|
2993
|
+
toPath: customUrl.to,
|
|
2994
|
+
redirectType: String(customUrl.status),
|
|
2995
|
+
});
|
|
2996
|
+
customUrlsCreated++;
|
|
2997
|
+
} catch (err) {
|
|
2998
|
+
console.warn(
|
|
2999
|
+
`Warning: couldn't recreate the redirect /${customUrl.path} → ${customUrl.to}: ${err.message}`,
|
|
3000
|
+
);
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
} else if (dryRun && importedCustomUrls.length > 0) {
|
|
3004
|
+
console.log(
|
|
3005
|
+
`[dry-run] Would recreate ${importedCustomUrls.length} custom URLs`,
|
|
3006
|
+
);
|
|
3007
|
+
}
|
|
3008
|
+
|
|
2539
3009
|
await target?.close();
|
|
2540
3010
|
|
|
2541
3011
|
// 5. Summary
|
|
@@ -2550,6 +3020,9 @@ export async function run(argv) {
|
|
|
2550
3020
|
if (aliasesCreated > 0) {
|
|
2551
3021
|
console.log(` Aliases created: ${aliasesCreated}`);
|
|
2552
3022
|
}
|
|
3023
|
+
if (customUrlsCreated > 0) {
|
|
3024
|
+
console.log(` Redirects created: ${customUrlsCreated}`);
|
|
3025
|
+
}
|
|
2553
3026
|
if (dryRun) {
|
|
2554
3027
|
console.log(" (dry-run mode — no changes were made)");
|
|
2555
3028
|
}
|