Path: blob/main/src/publish/confluence/confluence.ts
6446 views
import { join } from "../../deno_ral/path.ts";1import { Confirm, Input, Secret } from "cliffy/prompt/mod.ts";2import { RenderFlags } from "../../command/render/types.ts";3import { pathWithForwardSlashes } from "../../core/path.ts";45import {6readAccessTokens,7writeAccessToken,8writeAccessTokens,9} from "../common/account.ts";1011import {12AccountToken,13AccountTokenType,14InputMetadata,15PublishFiles,16PublishProvider,17} from "../provider-types.ts";1819import { PublishOptions, PublishRecord } from "../types.ts";20import { ConfluenceClient } from "./api/index.ts";21import {22AttachmentSummary,23ConfluenceParent,24ConfluenceSpaceChange,25Content,26ContentAncestor,27ContentBody,28ContentBodyRepresentation,29ContentChange,30ContentChangeType,31ContentCreate,32ContentProperty,33ContentPropertyKey,34ContentStatusEnum,35ContentSummary,36ContentUpdate,37LogPrefix,38PAGE_TYPE,39PublishContentResult,40PublishRenderer,41PublishType,42PublishTypeEnum,43SiteFileMetadata,44SitePage,45SpaceChangeResult,46User,47WrappedResult,48} from "./api/types.ts";49import { withSpinner } from "../../core/console.ts";50import {51buildFileToMetaTable,52buildPublishRecordForContent,53buildSpaceChanges,54confluenceParentFromString,55convertForSecondPass,56doWithSpinner,57filterFilesForUpdate,58findAttachments,59flattenIndexes,60footnoteTransform,61getNextVersion,62getTitle,63isContentCreate,64isContentDelete,65isContentUpdate,66isNotFound,67isUnauthorized,68mergeSitePages,69tokenFilterOut,70transformAtlassianDomain,71updateImagePaths,72updateLinks,73validateEmail,74validateParentURL,75validateServer,76validateToken,77wrapBodyForConfluence,78writeTokenComparator,79} from "./confluence-helper.ts";8081import {82verifyAccountToken,83verifyConfluenceParent,84verifyLocation,85verifyOrWarnManagePermissions,86} from "./confluence-verify.ts";87import {88DELETE_DISABLED,89DELETE_SLEEP_MILLIS,90DESCENDANT_PAGE_SIZE,91EXIT_ON_ERROR,92MAX_PAGES_TO_LOAD,93} from "./constants.ts";94import { logError, trace } from "./confluence-logger.ts";95import { md5HashBytes } from "../../core/hash.ts";96import { sleep } from "../../core/async.ts";97import { info } from "../../deno_ral/log.ts";9899export const CONFLUENCE_ID = "confluence";100101const getAccountTokens = (): Promise<AccountToken[]> => {102const getConfluenceEnvironmentAccount = () => {103const server = Deno.env.get("CONFLUENCE_DOMAIN");104const name = Deno.env.get("CONFLUENCE_USER_EMAIL");105const token = Deno.env.get("CONFLUENCE_AUTH_TOKEN");106if (server && name && token) {107return {108type: AccountTokenType.Environment,109name,110server: transformAtlassianDomain(server),111token,112};113}114};115116const readConfluenceAccessTokens = (): AccountToken[] => {117const result = readAccessTokens<AccountToken>(CONFLUENCE_ID) ?? [];118return result;119};120121let accounts: AccountToken[] = [];122123const envAccount = getConfluenceEnvironmentAccount();124if (envAccount) {125accounts = [...accounts, envAccount];126}127128const tempStoredAccessTokens = readConfluenceAccessTokens();129accounts = [...accounts, ...tempStoredAccessTokens];130return Promise.resolve(accounts);131};132133const removeToken = (token: AccountToken) => {134const existingTokens = readAccessTokens<AccountToken>(CONFLUENCE_ID) ?? [];135136const toWrite: Array<AccountToken> = existingTokens.filter((accessToken) =>137tokenFilterOut(accessToken, token)138);139140writeAccessTokens(CONFLUENCE_ID, toWrite);141};142143const promptAndAuthorizeToken = async () => {144const server: string = await Input.prompt({145indent: "",146message: "Confluence Domain:",147hint: "e.g. https://mydomain.atlassian.net/",148validate: validateServer,149transform: transformAtlassianDomain,150});151152await verifyLocation(server);153154const name = await Input.prompt({155indent: "",156message: `Confluence Account Email:`,157validate: validateEmail,158});159160const token = await Secret.prompt({161indent: "",162message: "Confluence API Token:",163hint: "Create an API token at https://id.atlassian.com/manage/api-tokens",164validate: validateToken,165});166167const accountToken: AccountToken = {168type: AccountTokenType.Authorized,169name,170server,171token,172};173await withSpinner(174{ message: "Verifying account..." },175() => verifyAccountToken(accountToken),176);177writeAccessToken<AccountToken>(178CONFLUENCE_ID,179accountToken,180writeTokenComparator,181);182183return Promise.resolve(accountToken);184};185186const promptForParentURL = async () => {187return await Input.prompt({188indent: "",189message: `Space or Parent Page URL:`,190hint: "Browse in Confluence to the space or parent, then copy the URL",191validate: validateParentURL,192});193};194195const resolveTarget = async (196accountToken: AccountToken,197target: PublishRecord,198): Promise<PublishRecord> => {199return Promise.resolve(target);200};201202const loadDocument = (baseDirectory: string, rootFile: string): ContentBody => {203const documentValue = Deno.readTextFileSync(join(baseDirectory, rootFile));204205const body: ContentBody = wrapBodyForConfluence(documentValue);206207return body;208};209210const renderDocument = async (211render: PublishRenderer,212): Promise<PublishFiles> => {213const flags: RenderFlags = {214to: "confluence-publish",215};216217return await render(flags);218};219220const renderSite = async (render: PublishRenderer): Promise<PublishFiles> => {221const flags: RenderFlags = {222to: "confluence-publish",223};224225const renderResult: PublishFiles = await render(flags);226return renderResult;227};228229async function publish(230account: AccountToken,231type: PublishType,232_input: string,233title: string,234_slug: string,235render: (flags?: RenderFlags) => Promise<PublishFiles>,236_options: PublishOptions,237publishRecord?: PublishRecord,238): Promise<[PublishRecord, URL | undefined]> {239trace("publish", {240account,241type,242_input,243title,244_slug,245_options,246publishRecord,247});248249const client = new ConfluenceClient(account);250251const user: User = await client.getUser();252253let parentUrl: string = publishRecord?.url ?? (await promptForParentURL());254255const parent: ConfluenceParent = confluenceParentFromString(parentUrl);256257const server = account?.server ?? "";258259await verifyConfluenceParent(parentUrl, parent);260261const space = await client.getSpace(parent.space);262263trace("publish", { parent, server, id: space.id, key: space.key });264265await verifyOrWarnManagePermissions(client, space, parent, user);266267const uniquifyTitle = async (title: string, idToIgnore: string = "") => {268trace("uniquifyTitle", title);269270const titleIsUnique: boolean = await client.isTitleUniqueInSpace(271title,272space,273idToIgnore,274);275276if (titleIsUnique) {277return title;278}279280const uuid = globalThis.crypto.randomUUID();281const shortUUID = uuid.split("-")[0] ?? uuid;282const uuidTitle = `${title} ${shortUUID}`;283284return uuidTitle;285};286287const fetchExistingSite = async (parentId: string): Promise<SitePage[]> => {288let descendants: ContentSummary[] = [];289let start = 0;290291for (let i = 0; i < MAX_PAGES_TO_LOAD; i++) {292const result: WrappedResult<ContentSummary> = await client293.getDescendantsPage(parentId, start);294if (result.results.length === 0) {295break;296}297298descendants = [...descendants, ...result.results];299300start = start + DESCENDANT_PAGE_SIZE;301}302303trace("descendants.length", descendants);304305const contentProperties: ContentProperty[][] = await Promise.all(306descendants.map((page: ContentSummary) =>307client.getContentProperty(page.id ?? "")308),309);310311const sitePageList: SitePage[] = mergeSitePages(312descendants,313contentProperties,314);315316return sitePageList;317};318319const uploadAttachments = (320baseDirectory: string,321attachmentsToUpload: string[],322parentId: string,323filePath: string,324existingAttachments: AttachmentSummary[] = [],325): Promise<AttachmentSummary | null>[] => {326const uploadAttachment = async (327attachmentPath: string,328): Promise<AttachmentSummary | null> => {329let fileBuffer: Uint8Array;330let fileHash: string;331const path = join(baseDirectory, attachmentPath);332333trace(334"uploadAttachment",335{336baseDirectory,337attachmentPath,338attachmentsToUpload,339parentId,340existingAttachments,341path,342},343LogPrefix.ATTACHMENT,344);345346try {347fileBuffer = await Deno.readFile(path);348fileHash = await md5HashBytes(fileBuffer);349} catch (error) {350logError(`${path} not found`, error);351return null;352}353354const fileName = pathWithForwardSlashes(attachmentPath);355356const existingDuplicateAttachment = existingAttachments.find(357(attachment: AttachmentSummary) => {358return attachment?.metadata?.comment === fileHash;359},360);361362if (existingDuplicateAttachment) {363trace(364"existing duplicate attachment found",365existingDuplicateAttachment.title,366LogPrefix.ATTACHMENT,367);368return existingDuplicateAttachment;369}370371const file = new File([fileBuffer as BlobPart], fileName);372const attachment: AttachmentSummary = await client373.createOrUpdateAttachment(parentId, file, fileHash);374375trace("attachment", attachment, LogPrefix.ATTACHMENT);376377return attachment;378};379380return attachmentsToUpload.map(uploadAttachment);381};382383const updateContent = async (384user: User,385publishFiles: PublishFiles,386id: string,387body: ContentBody,388titleToUpdate: string = title,389fileName: string = "",390uploadFileAttachments: boolean = true,391): Promise<PublishContentResult> => {392const previousPage = await client.getContent(id);393394const attachmentsToUpload: string[] = findAttachments(395body.storage.value,396publishFiles.files,397fileName,398);399400let uniqueTitle = titleToUpdate;401402if (previousPage.title !== titleToUpdate) {403uniqueTitle = await uniquifyTitle(titleToUpdate, id);404}405406trace("attachmentsToUpload", attachmentsToUpload, LogPrefix.ATTACHMENT);407408const updatedBody: ContentBody = updateImagePaths(body);409updatedBody.storage.value = footnoteTransform(updatedBody.storage.value);410411const toUpdate: ContentUpdate = {412contentChangeType: ContentChangeType.update,413id,414version: getNextVersion(previousPage),415title: uniqueTitle,416type: PAGE_TYPE,417status: ContentStatusEnum.current,418ancestors: null,419body: updatedBody,420};421422trace("updateContent", toUpdate);423trace("updateContent body", toUpdate?.body?.storage?.value);424425const updatedContent: Content = await client.updateContent(user, toUpdate);426427if (toUpdate.id && uploadFileAttachments) {428const existingAttachments: AttachmentSummary[] = await client429.getAttachments(toUpdate.id);430431trace(432"attachments",433{ existingAttachments, attachmentsToUpload },434LogPrefix.ATTACHMENT,435);436437const uploadAttachmentsResult = await Promise.all(438uploadAttachments(439publishFiles.baseDir,440attachmentsToUpload,441toUpdate.id,442fileName,443existingAttachments,444),445);446trace(447"uploadAttachmentsResult",448uploadAttachmentsResult,449LogPrefix.ATTACHMENT,450);451}452453return {454content: updatedContent,455hasAttachments: attachmentsToUpload.length > 0,456};457};458459const createSiteParent = async (460title: string,461body: ContentBody,462): Promise<Content> => {463let ancestors: ContentAncestor[] = [];464465if (parent?.parent) {466ancestors = [{ id: parent.parent }];467} else if (space.homepage?.id) {468ancestors = [{ id: space.homepage?.id }];469}470471const toCreate: ContentCreate = {472contentChangeType: ContentChangeType.create,473title,474type: PAGE_TYPE,475space,476status: ContentStatusEnum.current,477ancestors,478body,479};480481const createdContent = await client.createContent(user, toCreate);482return createdContent;483};484485const checkToCreateSiteParent = async (486parentId: string = "",487): Promise<string> => {488let isQuartoSiteParent = false;489490const existingSiteParent: any = await client.getContent(parentId);491492if (existingSiteParent?.id) {493const siteParentContentProperties: ContentProperty[] = await client494.getContentProperty(existingSiteParent.id ?? "");495496isQuartoSiteParent = siteParentContentProperties.find(497(property: ContentProperty) =>498property.key === ContentPropertyKey.isQuartoSiteParent,499) !== undefined;500}501502if (!isQuartoSiteParent) {503const body: ContentBody = {504storage: {505value: "",506representation: ContentBodyRepresentation.storage,507},508};509510const siteParentTitle = await uniquifyTitle(title);511const siteParent: ContentSummary = await createSiteParent(512siteParentTitle,513body,514);515516const newSiteParentId: string = siteParent.id ?? "";517518const contentProperty: Content = await client.createContentProperty(519newSiteParentId,520{ key: ContentPropertyKey.isQuartoSiteParent, value: true },521);522523parentId = newSiteParentId;524}525return parentId;526};527528const createContent = async (529publishFiles: PublishFiles,530body: ContentBody,531titleToCreate: string = title,532createParent: ConfluenceParent = parent,533fileNameParam: string = "",534): Promise<PublishContentResult> => {535const createTitle = await uniquifyTitle(titleToCreate);536537const fileName = pathWithForwardSlashes(fileNameParam);538539const attachmentsToUpload: string[] = findAttachments(540body.storage.value,541publishFiles.files,542fileName,543);544545trace("attachmentsToUpload", attachmentsToUpload, LogPrefix.ATTACHMENT);546const updatedBody: ContentBody = updateImagePaths(body);547updatedBody.storage.value = footnoteTransform(updatedBody.storage.value);548549const toCreate: ContentCreate = {550contentChangeType: ContentChangeType.create,551title: createTitle,552type: PAGE_TYPE,553space,554status: ContentStatusEnum.current,555ancestors: createParent?.parent ? [{ id: createParent.parent }] : null,556body: updatedBody,557};558559trace("createContent", { publishFiles, toCreate });560const createdContent = await client.createContent(user, toCreate);561562if (createdContent.id) {563const uploadAttachmentsResult = await Promise.all(564uploadAttachments(565publishFiles.baseDir,566attachmentsToUpload,567createdContent.id,568fileName,569),570);571trace(572"uploadAttachmentsResult",573uploadAttachmentsResult,574LogPrefix.ATTACHMENT,575);576}577578return {579content: createdContent,580hasAttachments: attachmentsToUpload.length > 0,581};582};583584const publishDocument = async (): Promise<585[[PublishRecord, URL | undefined], boolean]586> => {587const publishFiles: PublishFiles = await renderDocument(render);588589const body: ContentBody = loadDocument(590publishFiles.baseDir,591publishFiles.rootFile,592);593594trace("publishDocument", { publishFiles, body }, LogPrefix.RENDER);595596let publishResult: PublishContentResult | undefined;597let message: string = "";598let doOperation;599600if (publishRecord) {601message = `Updating content at ${publishRecord.url}...`;602doOperation = async () => {603const result = await updateContent(604user,605publishFiles,606publishRecord.id,607body,608);609publishResult = result;610};611} else {612message = `Creating content in space ${parent.space}...`;613doOperation = async () => {614const result = await createContent(publishFiles, body);615publishResult = result;616};617}618try {619await doWithSpinner(message, doOperation);620return [621buildPublishRecordForContent(server, publishResult?.content),622!!publishResult?.hasAttachments,623];624} catch (error: any) {625trace("Error Performing Operation", error);626trace("Value to Update", body?.storage?.value);627throw error;628}629};630631const publishSite = async (): Promise<632[[PublishRecord, URL | undefined], boolean]633> => {634let parentId: string = parent?.parent ?? space.homepage.id ?? "";635636parentId = await checkToCreateSiteParent(parentId);637638const siteParent: ConfluenceParent = {639space: parent.space,640parent: parentId,641};642643let existingSite: SitePage[] = await fetchExistingSite(parentId);644trace("existingSite", existingSite);645646const publishFiles: PublishFiles = await renderSite(render);647const metadataByInput: Record<string, InputMetadata> =648publishFiles.metadataByInput ?? {};649650trace("metadataByInput", metadataByInput);651652trace("publishSite", {653parentId,654publishFiles,655});656657const filteredFiles: string[] = filterFilesForUpdate(publishFiles.files);658659trace("filteredFiles", filteredFiles);660661const assembleSiteFileMetadata = async (662fileName: string,663): Promise<SiteFileMetadata> => {664const fileToContentBody = async (665fileName: string,666): Promise<ContentBody> => {667return loadDocument(publishFiles.baseDir, fileName);668};669670const originalTitle = getTitle(fileName, metadataByInput);671const title = originalTitle;672673return await {674fileName,675title,676originalTitle,677contentBody: await fileToContentBody(fileName),678};679};680681const fileMetadata: SiteFileMetadata[] = await Promise.all(682filteredFiles.map(assembleSiteFileMetadata),683);684685trace("fileMetadata", fileMetadata);686687let metadataByFilename = buildFileToMetaTable(existingSite);688689trace("metadataByFilename", metadataByFilename);690691let changeList: ConfluenceSpaceChange[] = buildSpaceChanges(692fileMetadata,693siteParent,694space,695existingSite,696);697698changeList = flattenIndexes(changeList, metadataByFilename, parentId);699700const { pass1Changes, pass2Changes } = updateLinks(701metadataByFilename,702changeList,703server,704siteParent,705);706707changeList = pass1Changes;708709trace("changelist Pass 1", changeList);710711let pathsToId: Record<string, string> = {}; // build from existing site712713const handleChangeError = (714label: string,715currentChange: ConfluenceSpaceChange,716error: any,717) => {718if (isContentUpdate(currentChange) || isContentCreate(currentChange)) {719trace("currentChange.fileName", currentChange.fileName);720trace("Value to Update", currentChange.body.storage.value);721}722if (EXIT_ON_ERROR) {723throw error;724}725};726let hasAttachments = false;727728const doChange = async (729change: ConfluenceSpaceChange,730uploadFileAttachments: boolean = true,731) => {732if (isContentCreate(change)) {733if (change.fileName === "sitemap.xml") {734trace("sitemap.xml skipped", change);735return;736}737738let ancestorId = (change?.ancestors && change?.ancestors[0]?.id) ??739null;740741if (ancestorId && pathsToId[ancestorId]) {742ancestorId = pathsToId[ancestorId];743}744745const ancestorParent: ConfluenceParent = {746space: parent.space,747parent: ancestorId ?? siteParent.parent,748};749750const universalPath = pathWithForwardSlashes(change.fileName ?? "");751752const result = await createContent(753publishFiles,754change.body,755change.title ?? "",756ancestorParent,757universalPath,758);759760if (universalPath) {761pathsToId[universalPath] = result.content.id ?? "";762}763764const contentPropertyResult: Content = await client765.createContentProperty(result.content.id ?? "", {766key: ContentPropertyKey.fileName,767value: (change as ContentCreate).fileName,768});769hasAttachments = hasAttachments || result.hasAttachments;770return result;771} else if (isContentUpdate(change)) {772const update = change as ContentUpdate;773const result = await updateContent(774user,775publishFiles,776update.id ?? "",777update.body,778update.title ?? "",779update.fileName ?? "",780uploadFileAttachments,781);782hasAttachments = hasAttachments || result.hasAttachments;783return result;784} else if (isContentDelete(change)) {785if (DELETE_DISABLED) {786console.warn("DELETE DISABELD");787return null;788}789const result = await client.deleteContent(change);790await sleep(DELETE_SLEEP_MILLIS); // TODO replace with polling791return { content: result, hasAttachments: false };792} else {793console.error("Space Change not defined");794return null;795}796};797798let pass1Count = 0;799for (let currentChange of changeList) {800try {801pass1Count = pass1Count + 1;802const doOperation = async () => await doChange(currentChange);803await doWithSpinner(804`Site Updates [${pass1Count}/${changeList.length}]`,805doOperation,806);807} catch (error: any) {808handleChangeError(809"Error Performing Change Pass 1",810currentChange,811error,812);813}814}815816if (pass2Changes.length) {817//PASS #2 to update links to newly created pages818819trace("changelist Pass 2", pass2Changes);820821existingSite = await fetchExistingSite(parentId);822metadataByFilename = buildFileToMetaTable(existingSite);823824const linkUpdateChanges: ConfluenceSpaceChange[] = convertForSecondPass(825metadataByFilename,826pass2Changes,827server,828parent,829);830831let pass2Count = 0;832for (let currentChange of linkUpdateChanges) {833try {834pass2Count = pass2Count + 1;835const doOperation = async () => await doChange(currentChange, false);836await doWithSpinner(837`Updating Links [${pass2Count}/${linkUpdateChanges.length}]`,838doOperation,839);840} catch (error: any) {841handleChangeError(842"Error Performing Change Pass 2",843currentChange,844error,845);846}847}848}849850const parentPage: Content = await client.getContent(parentId);851return [buildPublishRecordForContent(server, parentPage), hasAttachments];852};853854if (type === PublishTypeEnum.document) {855const [publishResult, hasAttachments] = await publishDocument();856if (hasAttachments) {857info(858"\nNote: The published content includes attachments or images. You may see a placeholder for a few moments while Confluence processes the image or attachment.\n",859);860}861return publishResult;862} else {863const [publishResult, hasAttachments] = await publishSite();864if (hasAttachments) {865info(866"\nNote: The published content includes attachments or images. You may see a placeholder for a few moments while Confluence processes the image or attachment.\n",867);868}869return publishResult;870}871}872873export const confluenceProvider: PublishProvider = {874name: CONFLUENCE_ID,875description: "Confluence",876hidden: false,877requiresServer: true,878requiresRender: true,879accountTokens: getAccountTokens,880authorizeToken: promptAndAuthorizeToken,881removeToken,882resolveTarget,883publish,884isUnauthorized,885isNotFound,886};887888889