@telefonica/confluence-sync
Version:
Creates/updates/deletes Confluence pages based on a list of objects containing the page contents. Supports nested pages and attachments upload
318 lines (317 loc) • 15.4 kB
JavaScript
;
// SPDX-FileCopyrightText: 2024 Telefónica Innovación Digital
// SPDX-License-Identifier: Apache-2.0
Object.defineProperty(exports, "__esModule", { value: true });
exports.ConfluenceSyncPages = void 0;
const promises_1 = require("node:fs/promises");
const logger_1 = require("@mocks-server/logger");
const fastq_1 = require("fastq");
const CustomConfluenceClient_1 = require("./confluence/CustomConfluenceClient");
const ConfluenceSyncPages_types_1 = require("./ConfluenceSyncPages.types");
const CompoundError_1 = require("./errors/CompoundError");
const NotAncestorsExpectedValidationError_1 = require("./errors/NotAncestorsExpectedValidationError");
const PendingPagesToSyncError_1 = require("./errors/PendingPagesToSyncError");
const RootPageRequiredException_1 = require("./errors/RootPageRequiredException");
const ShouldUseIdModeException_1 = require("./errors/ShouldUseIdModeException");
const RootPageForbiddenException_1 = require("./errors/RootPageForbiddenException");
const PagesWithoutIdException_1 = require("./errors/PagesWithoutIdException");
const InvalidSyncModeException_1 = require("./errors/InvalidSyncModeException");
const Pages_1 = require("./support/Pages");
const LOGGER_NAMESPACE = "confluence-sync-pages";
const DEFAULT_LOG_LEVEL = "silent";
const ConfluenceSyncPages = class ConfluenceSyncPages {
_logger;
_confluenceClient;
_rootPageId;
_syncMode;
constructor({ logLevel, url, spaceId, personalAccessToken, dryRun, syncMode, rootPageId, }) {
this._logger = new logger_1.Logger(LOGGER_NAMESPACE, {
level: logLevel ?? DEFAULT_LOG_LEVEL,
});
this._confluenceClient = new CustomConfluenceClient_1.CustomConfluenceClient({
url,
spaceId,
personalAccessToken,
logger: this._logger.namespace("confluence"),
dryRun,
});
this._syncMode = syncMode ?? ConfluenceSyncPages_types_1.SyncModes.TREE;
if (![ConfluenceSyncPages_types_1.SyncModes.FLAT, ConfluenceSyncPages_types_1.SyncModes.ID, ConfluenceSyncPages_types_1.SyncModes.TREE].includes(this._syncMode)) {
throw new InvalidSyncModeException_1.InvalidSyncModeException(this._syncMode);
}
if (this._syncMode === ConfluenceSyncPages_types_1.SyncModes.TREE && rootPageId === undefined) {
throw new RootPageRequiredException_1.RootPageRequiredException(ConfluenceSyncPages_types_1.SyncModes.TREE);
}
this._rootPageId = rootPageId;
}
get logger() {
return this._logger;
}
async sync(pages) {
const syncMode = this._syncMode;
const confluencePages = new Map();
const pagesMap = new Map(pages.map((page) => [page.title, page]));
const tasksDone = new Array();
const errors = new Array();
const queue = (0, fastq_1.promise)(this._handleTask.bind(this), 1);
function enqueueTask(task) {
queue.push({
task,
enqueueTask,
getConfluencePageByTitle,
getPageByTitle,
getPagesByAncestor,
storeConfluencePage,
});
}
function getConfluencePageByTitle(pageTitle) {
return confluencePages.get(pageTitle);
}
function storeConfluencePage(page) {
confluencePages.set(page.title, page);
}
function getPageByTitle(pageTitle) {
return pagesMap.get(pageTitle);
}
function getPagesByAncestor(ancestor, isRoot = false) {
if (syncMode === ConfluenceSyncPages_types_1.SyncModes.FLAT) {
// NOTE: in flat mode all pages without id are considered
// children of root page. Otherwise, they are pages with mirror
// page in Confluence and have no ancestors.
if (isRoot) {
return pages.filter((page) => page.id === undefined);
}
return [];
}
if (isRoot) {
return pages
.filter((page) => page.ancestors === undefined || page.ancestors.length === 0)
.concat(pages.filter((page) => page.ancestors?.at(-1) === ancestor));
}
return pages.filter((page) => page.ancestors?.at(-1) === ancestor);
}
queue.error((e, job) => {
if (e) {
errors.push(e);
return;
}
const task = job.task;
tasksDone.push(task);
});
if (this._syncMode === ConfluenceSyncPages_types_1.SyncModes.FLAT) {
this._validateFlatMode(pages);
pages
.filter((page) => page.id !== undefined)
.forEach((page) => {
enqueueTask({ type: "update", pageId: page.id, page });
});
}
if (this._syncMode === ConfluenceSyncPages_types_1.SyncModes.ID) {
this._validateIdMode(pages);
pages.forEach((page) => {
enqueueTask({ type: "update", pageId: page.id, page });
});
}
if (this._rootPageId !== undefined)
enqueueTask({ type: "init", pageId: this._rootPageId });
await queue.drained();
if (errors.length > 0) {
const e = new CompoundError_1.CompoundError(...errors);
this._logger.error(`Error occurs during sync: ${e}`);
throw e;
}
this._assertPendingPagesToCreate(tasksDone, pages);
this._reportTasks(tasksDone);
}
_validateIdMode(pages) {
const pagesWithoutId = pages.filter((page) => page.id === undefined);
if (pagesWithoutId.length > 0) {
throw new PagesWithoutIdException_1.PagesWithoutIdException(pagesWithoutId);
}
if (this._rootPageId !== undefined) {
throw new RootPageForbiddenException_1.RootPageForbiddenException();
}
const pagesWithAncestors = pages.filter((page) => page.ancestors !== undefined && page.ancestors.length > 0);
if (pagesWithAncestors.length > 0) {
throw new NotAncestorsExpectedValidationError_1.NotAncestorsExpectedValidationError(pagesWithAncestors);
}
}
_validateFlatMode(pages) {
const pagesWithoutId = pages.filter((page) => page.id === undefined);
if (pagesWithoutId.length === 0) {
throw new ShouldUseIdModeException_1.ShouldUseIdModeException(`There are no pages without id. You should use ID mode instead of FLAT mode`);
}
if (this._rootPageId === undefined) {
throw new RootPageRequiredException_1.RootPageRequiredException(ConfluenceSyncPages_types_1.SyncModes.FLAT);
}
const pagesWithAncestors = pages.filter((page) => page.ancestors !== undefined && page.ancestors.length > 0);
if (pagesWithAncestors.length > 0) {
throw new NotAncestorsExpectedValidationError_1.NotAncestorsExpectedValidationError(pagesWithAncestors);
}
}
_assertPendingPagesToCreate(tasksDone, _pages) {
const createdOrUpdatedPages = tasksDone
.filter((task) => task.type === "create" || task.type === "update")
.map((task) => task.page.title);
const pendingPagesToCreate = _pages.filter((page) => !createdOrUpdatedPages.includes(page.title));
if (pendingPagesToCreate.length > 0) {
const e = new PendingPagesToSyncError_1.PendingPagesToSyncError(pendingPagesToCreate);
this._logger.error(e.message);
throw e;
}
}
_reportTasks(tasks) {
const createdPages = tasks.filter((task) => task.type === "create");
this._logger.debug(`Created pages: ${createdPages.length}
${createdPages.map((task) => `+ ${task.page.title}`).join("\n")}`);
const updatedPages = tasks.filter((task) => task.type === "update");
this._logger.debug(`Updated pages: ${updatedPages.length}
${updatedPages.map((task) => `⟳ #${task.pageId} ${task.page.title}`).join("\n")}`);
const deletedPages = tasks.filter((task) => task.type === "delete");
this._logger.debug(`Deleted pages: ${deletedPages.length}
${deletedPages.map((task) => `- #${task.pageId}`).join("\n")}`);
this._logger.info("Sync finished");
}
_handleTask(job) {
const task = job.task;
switch (task.type) {
case "init":
return this._initSync({ ...job, task });
case "create":
return this._createPage({ ...job, task });
case "update":
return this._updatePage({ ...job, task });
case "delete":
return this._deletePage({ ...job, task });
case "createAttachments":
return this._createAttachments({ ...job, task });
case "deleteAttachments":
return this._deleteAttachments({ ...job, task });
}
}
async _initSync(job) {
this._logger.debug(`Reading page ${job.task.pageId}`);
const confluencePage = await this._confluenceClient.getPage(job.task.pageId);
job.storeConfluencePage(confluencePage);
this._enqueueChildrenPages(job, confluencePage, true);
}
async _createPage(job) {
this._logger.debug(`Creating page ${JSON.stringify(job.task.page)}`);
const ancestors = job.task.page.ancestors?.map((ancestorTitle) => {
const ancestor = job.getConfluencePageByTitle(ancestorTitle);
// NOTE: This should never happen. Defensively check anyway.
// istanbul ignore next
if (ancestor === undefined) {
throw new Error(`Could not find ancestor ${ancestorTitle}`);
}
return { id: ancestor.id, title: ancestor.title };
});
const confluencePage = await this._confluenceClient.createPage({
...job.task.page,
ancestors,
});
job.storeConfluencePage(confluencePage);
if (job.task.page.attachments !== undefined &&
Object.entries(job.task.page.attachments).length > 0)
job.enqueueTask({
type: "createAttachments",
pageId: confluencePage.id,
pageTitle: confluencePage.title,
attachments: job.task.page.attachments,
});
const descendants = job.getPagesByAncestor(job.task.page.title);
for (const descendant of descendants) {
job.enqueueTask({ type: "create", page: descendant });
}
}
async _updatePage(job) {
this._logger.debug(`Updating page ${job.task.page.title}`);
const confluencePage = await this._confluenceClient.getPage(job.task.pageId);
const updatedConfluencePage = await this._confluenceClient.updatePage({
id: confluencePage.id,
title: job.task.page.title,
content: job.task.page.content,
ancestors: confluencePage.ancestors,
version: confluencePage.version + 1,
});
job.storeConfluencePage(updatedConfluencePage);
const attachments = await this._confluenceClient.getAttachments(confluencePage.id);
for (const attachment of attachments) {
this._logger.debug(`Enqueueing delete attachment ${attachment.title} for page ${confluencePage.title}`);
job.enqueueTask({ type: "deleteAttachments", pageId: attachment.id });
}
if (job.task.page.attachments !== undefined &&
Object.entries(job.task.page.attachments).length > 0) {
job.enqueueTask({
type: "createAttachments",
pageId: confluencePage.id,
pageTitle: confluencePage.title,
attachments: job.task.page.attachments,
});
}
if (this._syncMode === ConfluenceSyncPages_types_1.SyncModes.TREE) {
this._enqueueChildrenPages(job, confluencePage);
}
}
async _deletePage(job) {
this._logger.debug(`Deleting page ${job.task.pageId}`);
const confluencePage = await this._confluenceClient.getPage(job.task.pageId);
for (const descendant of confluencePage.children ?? []) {
job.enqueueTask({ type: "delete", pageId: descendant.id });
}
await this._confluenceClient.deleteContent(job.task.pageId);
}
async _deleteAttachments(job) {
this._logger.debug(`Deleting attachment ${job.task.pageId}`);
await this._confluenceClient.deleteContent(job.task.pageId);
}
async _createAttachments(job) {
this._logger.debug(`Creating attachments for page ${job.task.pageTitle}, attachments: ${JSON.stringify(job.task.attachments)}`);
const attachments = await Promise.all(Object.entries(job.task.attachments).map(async ([name, path]) => ({
filename: name,
file: await (0, promises_1.readFile)(path),
})));
await this._confluenceClient.createAttachments(job.task.pageId, attachments);
}
_enqueueChildrenPages(job, confluencePage, isRoot = false) {
const descendants = job.getPagesByAncestor(confluencePage.title, isRoot);
const confluenceDescendants = confluencePage.children ?? [];
let descendantsWithPageId = [];
if (isRoot) {
descendants.forEach((descendant) => {
descendant.ancestors = [confluencePage.title];
});
if (this._syncMode === ConfluenceSyncPages_types_1.SyncModes.FLAT) {
const confluenceDescendantsInputPages = confluenceDescendants
.map(({ title }) => job.getPageByTitle(title))
.filter(Boolean);
descendantsWithPageId = (0, Pages_1.getPagesTitles)(confluenceDescendantsInputPages.filter((page) => page.id !== undefined));
if (descendantsWithPageId.length)
this._logger.warn(`Some children of root page contains id: ${descendantsWithPageId.join(", ")}`);
}
}
const descendantsToCreate = descendants.filter((descendant) => !confluenceDescendants.some((other) => other.title === descendant.title));
const descendantsToUpdate = confluenceDescendants
.filter((descendant) => descendants.some((other) => other.title === descendant.title))
.map((descendant) => {
const page = job.getPageByTitle(descendant.title);
return { pageId: descendant.id, page };
});
const descendantsToDelete = confluenceDescendants.filter((descendant) => !descendants.some((other) => other.title === descendant.title) &&
!descendantsWithPageId.includes(descendant.title));
for (const descendant of descendantsToDelete) {
job.enqueueTask({ type: "delete", pageId: descendant.id });
}
for (const descendant of descendantsToCreate) {
job.enqueueTask({ type: "create", page: descendant });
}
for (const descendant of descendantsToUpdate) {
job.enqueueTask({
type: "update",
pageId: descendant.pageId,
page: descendant.page,
});
}
}
};
exports.ConfluenceSyncPages = ConfluenceSyncPages;