167 lines
6.0 KiB
JavaScript
167 lines
6.0 KiB
JavaScript
import {
|
|
preparedMapDocument,
|
|
replaceAttachmentUrls,
|
|
validateBundle
|
|
} from "./interchange.js";
|
|
import { CmapModel } from "./concept-map.js";
|
|
|
|
const MAXIMUM_FILE_SIZE = 256 * 1024 * 1024;
|
|
const MAXIMUM_ATTACHMENT_SIZE = 50 * 1024 * 1024;
|
|
|
|
/** Decode one bundle attachment as a Blob accepted by the upload API. */
|
|
function attachmentBlob(attachment) {
|
|
const binary = atob(String(attachment.contentBase64 || ""));
|
|
if (binary.length > MAXIMUM_ATTACHMENT_SIZE) {
|
|
throw new Error(`Attachment exceeds 50 MiB: ${attachment.name}`);
|
|
}
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let index = 0; index < binary.length; index += 1) {
|
|
bytes[index] = binary.charCodeAt(index);
|
|
}
|
|
return new Blob([bytes], {
|
|
type: attachment.mimeType || "application/octet-stream"
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Import a validated JSON bundle through the CMap repository and page API.
|
|
* Conflict policy is supplied by the workspace; this class performs only the
|
|
* deterministic page, attachment and CMap writes and reports their counts.
|
|
*/
|
|
export class CmapJsonImporter {
|
|
constructor(cmapRepository, pageApi, translate) {
|
|
if (!cmapRepository || typeof cmapRepository.load !== "function" ||
|
|
typeof pageApi !== "function" || typeof translate !== "function") {
|
|
throw new TypeError("A CMap repository, page API and translator are required");
|
|
}
|
|
this.cmapRepository = cmapRepository;
|
|
this.pageApi = pageApi;
|
|
this.translate = translate;
|
|
}
|
|
|
|
/** Parse and validate a selected JSON file without writing wiki data. */
|
|
async read(file) {
|
|
if (file.size > MAXIMUM_FILE_SIZE) {
|
|
throw new Error(this.translate(
|
|
"cmap-json-too-large", "The CMap JSON file exceeds 256 MiB."));
|
|
}
|
|
const bundle = JSON.parse(await file.text());
|
|
return validateBundle(bundle);
|
|
}
|
|
|
|
/** Return the pages and CMaps whose slugs already occur in the wiki. */
|
|
conflicts(bundle, pages, conceptMaps) {
|
|
const existingPages = new Map(pages.map((page) => [page.slug, page]));
|
|
const existingMaps = new Map(conceptMaps.map((cmap) => [cmap.slug, cmap]));
|
|
return {
|
|
pages: bundle.pages.filter((page) => existingPages.has(page.reference)),
|
|
conceptMaps: bundle.cmaps.filter((cmap) => existingMaps.has(cmap.slug))
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Write all accepted bundle records and return created, updated and skipped counts.
|
|
* Existing records are replaced only when replaceExisting is true.
|
|
*/
|
|
async import(bundle, options = {}) {
|
|
validateBundle(bundle);
|
|
const pages = Array.isArray(options.pages) ? options.pages : [];
|
|
const conceptMaps = Array.isArray(options.conceptMaps) ? options.conceptMaps : [];
|
|
const replaceExisting = Boolean(options.replaceExisting);
|
|
const summary = String(options.summary || "Imported from CMap JSON");
|
|
const existingPages = new Map(pages.map((page) => [page.slug, page]));
|
|
const existingMaps = new Map(conceptMaps.map((cmap) => [cmap.slug, cmap]));
|
|
const result = {
|
|
mapsCreated: 0, mapsUpdated: 0, mapsSkipped: 0,
|
|
pagesCreated: 0, pagesUpdated: 0, pagesSkipped: 0,
|
|
attachmentsImported: 0
|
|
};
|
|
|
|
for (const page of bundle.pages) {
|
|
const existing = existingPages.get(page.reference);
|
|
if (existing && !replaceExisting) {
|
|
result.pagesSkipped += 1;
|
|
continue;
|
|
}
|
|
await this.importPage(page, existing, summary);
|
|
if (existing) result.pagesUpdated += 1;
|
|
else result.pagesCreated += 1;
|
|
result.attachmentsImported += Array.isArray(page.attachments) ?
|
|
page.attachments.length : 0;
|
|
}
|
|
|
|
for (const cmap of bundle.cmaps) {
|
|
const existing = existingMaps.get(cmap.slug);
|
|
if (existing && !replaceExisting) {
|
|
result.mapsSkipped += 1;
|
|
continue;
|
|
}
|
|
await this.importConceptMap(bundle, cmap, existing, summary);
|
|
if (existing) result.mapsUpdated += 1;
|
|
else result.mapsCreated += 1;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** Store one page and upload the attachments embedded in its Markdown. */
|
|
async importPage(page, existing, summary) {
|
|
const attachments = Array.isArray(page.attachments) ? page.attachments : [];
|
|
const body = {
|
|
slug: page.reference,
|
|
title: page.title,
|
|
markdown: page.markdown,
|
|
tags: page.tags,
|
|
summary
|
|
};
|
|
if (existing) {
|
|
body.markdown = await this.importAttachments(page);
|
|
body.baseVersion = existing.currentVersion;
|
|
await this.pageApi(`/api/pages/${encodeURIComponent(page.reference)}`, {
|
|
method: "PUT", body: JSON.stringify(body)
|
|
});
|
|
return;
|
|
}
|
|
|
|
const created = await this.pageApi("/api/pages", {
|
|
method: "POST", body: JSON.stringify(body)
|
|
});
|
|
if (!attachments.length) return;
|
|
body.markdown = await this.importAttachments(page);
|
|
body.baseVersion = created.currentVersion;
|
|
await this.pageApi(`/api/pages/${encodeURIComponent(page.reference)}`, {
|
|
method: "PUT", body: JSON.stringify(body)
|
|
});
|
|
}
|
|
|
|
/** Upload one page's attachments and rewrite their Markdown URLs. */
|
|
async importAttachments(page) {
|
|
const replacements = new Map();
|
|
for (const attachment of (Array.isArray(page.attachments) ? page.attachments : [])) {
|
|
const uploaded = await this.pageApi(
|
|
`/api/pages/${encodeURIComponent(page.reference)}/upload`, {
|
|
method: "POST",
|
|
headers: { "X-File-Name": attachment.name },
|
|
body: attachmentBlob(attachment)
|
|
});
|
|
replacements.set(attachment.url, uploaded.url);
|
|
}
|
|
return replaceAttachmentUrls(page.markdown, replacements);
|
|
}
|
|
|
|
/** Store one CMap after rejoining shared concepts and placements. */
|
|
async importConceptMap(bundle, cmap, existing, summary) {
|
|
const documentValue = preparedMapDocument(bundle, cmap);
|
|
const model = CmapModel.fromDocument(documentValue);
|
|
if (existing) {
|
|
const storedMap = await this.cmapRepository.load(cmap.slug);
|
|
await this.cmapRepository.save(storedMap, model, {
|
|
title: cmap.title,
|
|
saveKind: "manual",
|
|
summary
|
|
});
|
|
return;
|
|
}
|
|
await this.cmapRepository.create(cmap.title, model, cmap.slug);
|
|
}
|
|
}
|