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); } }