Path: blob/main/src/publish/confluence/confluence.ts
12926 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,89ATTACHMENT_UPLOAD_DELAY_MS,90DELETE_SLEEP_MILLIS,91DESCENDANT_PAGE_SIZE,92EXIT_ON_ERROR,93MAX_PAGES_TO_LOAD,94} from "./constants.ts";95import { logError, trace } from "./confluence-logger.ts";96import { md5HashBytes } from "../../core/hash.ts";97import { sleep } from "../../core/async.ts";98import { info } from "../../deno_ral/log.ts";99100export const CONFLUENCE_ID = "confluence";101102const getAccountTokens = (): Promise<AccountToken[]> => {103const getConfluenceEnvironmentAccount = () => {104const server = Deno.env.get("CONFLUENCE_DOMAIN");105const name = Deno.env.get("CONFLUENCE_USER_EMAIL");106const token = Deno.env.get("CONFLUENCE_AUTH_TOKEN");107if (server && name && token) {108return {109type: AccountTokenType.Environment,110name,111server: transformAtlassianDomain(server),112token,113};114}115};116117const readConfluenceAccessTokens = (): AccountToken[] => {118const result = readAccessTokens<AccountToken>(CONFLUENCE_ID) ?? [];119return result;120};121122let accounts: AccountToken[] = [];123124const envAccount = getConfluenceEnvironmentAccount();125if (envAccount) {126accounts = [...accounts, envAccount];127}128129const tempStoredAccessTokens = readConfluenceAccessTokens();130accounts = [...accounts, ...tempStoredAccessTokens];131return Promise.resolve(accounts);132};133134const removeToken = (token: AccountToken) => {135const existingTokens = readAccessTokens<AccountToken>(CONFLUENCE_ID) ?? [];136137const toWrite: Array<AccountToken> = existingTokens.filter((accessToken) =>138tokenFilterOut(accessToken, token)139);140141writeAccessTokens(CONFLUENCE_ID, toWrite);142};143144const promptAndAuthorizeToken = async () => {145const server: string = await Input.prompt({146indent: "",147message: "Confluence Domain:",148hint: "e.g. https://mydomain.atlassian.net/",149validate: validateServer,150transform: transformAtlassianDomain,151});152153await verifyLocation(server);154155const name = await Input.prompt({156indent: "",157message: `Confluence Account Email:`,158validate: validateEmail,159});160161const token = await Secret.prompt({162indent: "",163message: "Confluence API Token:",164hint: "Create an API token at https://id.atlassian.com/manage/api-tokens",165validate: validateToken,166});167168const accountToken: AccountToken = {169type: AccountTokenType.Authorized,170name,171server,172token,173};174await withSpinner(175{ message: "Verifying account..." },176() => verifyAccountToken(accountToken),177);178writeAccessToken<AccountToken>(179CONFLUENCE_ID,180accountToken,181writeTokenComparator,182);183184return Promise.resolve(accountToken);185};186187const promptForParentURL = async () => {188return await Input.prompt({189indent: "",190message: `Space or Parent Page URL:`,191hint: "Browse in Confluence to the space or parent, then copy the URL",192validate: validateParentURL,193});194};195196const resolveTarget = async (197accountToken: AccountToken,198target: PublishRecord,199): Promise<PublishRecord> => {200return Promise.resolve(target);201};202203const loadDocument = (baseDirectory: string, rootFile: string): ContentBody => {204const documentValue = Deno.readTextFileSync(join(baseDirectory, rootFile));205206const body: ContentBody = wrapBodyForConfluence(documentValue);207208return body;209};210211const renderDocument = async (212render: PublishRenderer,213): Promise<PublishFiles> => {214const flags: RenderFlags = {215to: "confluence-publish",216};217218return await render(flags);219};220221const renderSite = async (render: PublishRenderer): Promise<PublishFiles> => {222const flags: RenderFlags = {223to: "confluence-publish",224};225226const renderResult: PublishFiles = await render(flags);227return renderResult;228};229230async function publish(231account: AccountToken,232type: PublishType,233_input: string,234title: string,235_slug: string,236render: (flags?: RenderFlags) => Promise<PublishFiles>,237_options: PublishOptions,238publishRecord?: PublishRecord,239): Promise<[PublishRecord, URL | undefined]> {240trace("publish", {241account,242type,243_input,244title,245_slug,246_options,247publishRecord,248});249250const client = new ConfluenceClient(account);251252const user: User = await client.getUser();253254let parentUrl: string = publishRecord?.url ?? (await promptForParentURL());255256const parent: ConfluenceParent = confluenceParentFromString(parentUrl);257258const server = account?.server ?? "";259260await verifyConfluenceParent(parentUrl, parent);261262const space = await client.getSpace(parent.space);263264trace("publish", { parent, server, id: space.id, key: space.key });265266await verifyOrWarnManagePermissions(client, space, parent, user);267268const uniquifyTitle = async (title: string, idToIgnore: string = "") => {269trace("uniquifyTitle", title);270271const titleIsUnique: boolean = await client.isTitleUniqueInSpace(272title,273space,274idToIgnore,275);276277if (titleIsUnique) {278return title;279}280281const uuid = globalThis.crypto.randomUUID();282const shortUUID = uuid.split("-")[0] ?? uuid;283const uuidTitle = `${title} ${shortUUID}`;284285return uuidTitle;286};287288const fetchExistingSite = async (parentId: string): Promise<SitePage[]> => {289let descendants: ContentSummary[] = [];290let start = 0;291292for (let i = 0; i < MAX_PAGES_TO_LOAD; i++) {293const result: WrappedResult<ContentSummary> = await client294.getDescendantsPage(parentId, start);295if (result.results.length === 0) {296break;297}298299descendants = [...descendants, ...result.results];300301start = start + DESCENDANT_PAGE_SIZE;302}303304trace("descendants.length", descendants);305306const contentProperties: ContentProperty[][] = await Promise.all(307descendants.map((page: ContentSummary) =>308client.getContentProperty(page.id ?? "")309),310);311312const sitePageList: SitePage[] = mergeSitePages(313descendants,314contentProperties,315);316317return sitePageList;318};319320const uploadAttachments = (321baseDirectory: string,322attachmentsToUpload: string[],323parentId: string,324filePath: string,325existingAttachments: AttachmentSummary[] = [],326): Promise<AttachmentSummary | null>[] => {327const uploadAttachment = async (328attachmentPath: string,329): Promise<AttachmentSummary | null> => {330let fileBuffer: Uint8Array;331let fileHash: string;332const path = join(baseDirectory, attachmentPath);333334trace(335"uploadAttachment",336{337baseDirectory,338attachmentPath,339attachmentsToUpload,340parentId,341existingAttachments,342path,343},344LogPrefix.ATTACHMENT,345);346347try {348fileBuffer = await Deno.readFile(path);349fileHash = await md5HashBytes(fileBuffer);350} catch (error) {351logError(`${path} not found`, error);352return null;353}354355const fileName = pathWithForwardSlashes(attachmentPath);356357const existingDuplicateAttachment = existingAttachments.find(358(attachment: AttachmentSummary) => {359return attachment?.metadata?.comment === fileHash;360},361);362363if (existingDuplicateAttachment) {364trace(365"existing duplicate attachment found",366existingDuplicateAttachment.title,367LogPrefix.ATTACHMENT,368);369return existingDuplicateAttachment;370}371372const file = new File([fileBuffer as BlobPart], fileName);373const attachment: AttachmentSummary = await client374.createOrUpdateAttachment(parentId, file, fileHash);375376trace("attachment", attachment, LogPrefix.ATTACHMENT);377378return attachment;379};380381return attachmentsToUpload.map(uploadAttachment);382};383384const updateContent = async (385user: User,386publishFiles: PublishFiles,387id: string,388body: ContentBody,389titleToUpdate: string = title,390fileName: string = "",391uploadFileAttachments: boolean = true,392): Promise<PublishContentResult> => {393const previousPage = await client.getContent(id);394395const attachmentsToUpload: string[] = findAttachments(396body.storage.value,397publishFiles.files,398fileName,399);400401let uniqueTitle = titleToUpdate;402403if (previousPage.title !== titleToUpdate) {404uniqueTitle = await uniquifyTitle(titleToUpdate, id);405}406407trace("attachmentsToUpload", attachmentsToUpload, LogPrefix.ATTACHMENT);408409const updatedBody: ContentBody = updateImagePaths(body);410updatedBody.storage.value = footnoteTransform(updatedBody.storage.value);411412const toUpdate: ContentUpdate = {413contentChangeType: ContentChangeType.update,414id,415version: getNextVersion(previousPage),416title: uniqueTitle,417type: PAGE_TYPE,418status: ContentStatusEnum.current,419ancestors: null,420body: updatedBody,421};422423trace("updateContent", toUpdate);424trace("updateContent body", toUpdate?.body?.storage?.value);425426const updatedContent: Content = await client.updateContent(user, toUpdate);427428if (toUpdate.id && uploadFileAttachments) {429const existingAttachments: AttachmentSummary[] = await client430.getAttachments(toUpdate.id);431432trace(433"attachments",434{ existingAttachments, attachmentsToUpload },435LogPrefix.ATTACHMENT,436);437438const uploadAttachmentsResult: (AttachmentSummary | null)[] = [];439440for (let i = 0; i < attachmentsToUpload.length; i++) {441// Start exactly ONE upload by calling the helper with a single attachment442const tasks = uploadAttachments(443publishFiles.baseDir,444[attachmentsToUpload[i]], // <-- one at a time445toUpdate.id,446fileName,447existingAttachments,448);449450const res = await tasks[0];451uploadAttachmentsResult.push(res);452453if (i < attachmentsToUpload.length - 1) {454await sleep(ATTACHMENT_UPLOAD_DELAY_MS);455}456}457trace(458"uploadAttachmentsResult",459uploadAttachmentsResult,460LogPrefix.ATTACHMENT,461);462}463464return {465content: updatedContent,466hasAttachments: attachmentsToUpload.length > 0,467};468};469470const createSiteParent = async (471title: string,472body: ContentBody,473): Promise<Content> => {474let ancestors: ContentAncestor[] = [];475476if (parent?.parent) {477ancestors = [{ id: parent.parent }];478} else if (space.homepage?.id) {479ancestors = [{ id: space.homepage?.id }];480}481482const toCreate: ContentCreate = {483contentChangeType: ContentChangeType.create,484title,485type: PAGE_TYPE,486space,487status: ContentStatusEnum.current,488ancestors,489body,490};491492const createdContent = await client.createContent(user, toCreate);493return createdContent;494};495496const checkToCreateSiteParent = async (497parentId: string = "",498): Promise<string> => {499let isQuartoSiteParent = false;500501const existingSiteParent: any = await client.getContent(parentId);502503if (existingSiteParent?.id) {504const siteParentContentProperties: ContentProperty[] = await client505.getContentProperty(existingSiteParent.id ?? "");506507isQuartoSiteParent = siteParentContentProperties.find(508(property: ContentProperty) =>509property.key === ContentPropertyKey.isQuartoSiteParent,510) !== undefined;511}512513if (!isQuartoSiteParent) {514const body: ContentBody = {515storage: {516value: "",517representation: ContentBodyRepresentation.storage,518},519};520521const siteParentTitle = await uniquifyTitle(title);522const siteParent: ContentSummary = await createSiteParent(523siteParentTitle,524body,525);526527const newSiteParentId: string = siteParent.id ?? "";528529const contentProperty: Content = await client.createContentProperty(530newSiteParentId,531{ key: ContentPropertyKey.isQuartoSiteParent, value: true },532);533534parentId = newSiteParentId;535}536return parentId;537};538539const createContent = async (540publishFiles: PublishFiles,541body: ContentBody,542titleToCreate: string = title,543createParent: ConfluenceParent = parent,544fileNameParam: string = "",545): Promise<PublishContentResult> => {546const createTitle = await uniquifyTitle(titleToCreate);547548const fileName = pathWithForwardSlashes(fileNameParam);549550const attachmentsToUpload: string[] = findAttachments(551body.storage.value,552publishFiles.files,553fileName,554);555556trace("attachmentsToUpload", attachmentsToUpload, LogPrefix.ATTACHMENT);557const updatedBody: ContentBody = updateImagePaths(body);558updatedBody.storage.value = footnoteTransform(updatedBody.storage.value);559560const toCreate: ContentCreate = {561contentChangeType: ContentChangeType.create,562title: createTitle,563type: PAGE_TYPE,564space,565status: ContentStatusEnum.current,566ancestors: createParent?.parent ? [{ id: createParent.parent }] : null,567body: updatedBody,568};569570trace("createContent", { publishFiles, toCreate });571const createdContent = await client.createContent(user, toCreate);572573if (createdContent.id) {574const uploadAttachmentsResult = await Promise.all(575uploadAttachments(576publishFiles.baseDir,577attachmentsToUpload,578createdContent.id,579fileName,580),581);582trace(583"uploadAttachmentsResult",584uploadAttachmentsResult,585LogPrefix.ATTACHMENT,586);587}588589return {590content: createdContent,591hasAttachments: attachmentsToUpload.length > 0,592};593};594595const publishDocument = async (): Promise<596[[PublishRecord, URL | undefined], boolean]597> => {598const publishFiles: PublishFiles = await renderDocument(render);599600const body: ContentBody = loadDocument(601publishFiles.baseDir,602publishFiles.rootFile,603);604605trace("publishDocument", { publishFiles, body }, LogPrefix.RENDER);606607let publishResult: PublishContentResult | undefined;608let message: string = "";609let doOperation;610611if (publishRecord) {612message = `Updating content at ${publishRecord.url}...`;613doOperation = async () => {614const result = await updateContent(615user,616publishFiles,617publishRecord.id,618body,619);620publishResult = result;621};622} else {623message = `Creating content in space ${parent.space}...`;624doOperation = async () => {625const result = await createContent(publishFiles, body);626publishResult = result;627};628}629try {630await doWithSpinner(message, doOperation);631return [632buildPublishRecordForContent(server, publishResult?.content),633!!publishResult?.hasAttachments,634];635} catch (error: any) {636trace("Error Performing Operation", error);637trace("Value to Update", body?.storage?.value);638throw error;639}640};641642const publishSite = async (): Promise<643[[PublishRecord, URL | undefined], boolean]644> => {645let parentId: string = parent?.parent ?? space.homepage.id ?? "";646647parentId = await checkToCreateSiteParent(parentId);648649const siteParent: ConfluenceParent = {650space: parent.space,651parent: parentId,652};653654let existingSite: SitePage[] = await fetchExistingSite(parentId);655trace("existingSite", existingSite);656657const publishFiles: PublishFiles = await renderSite(render);658const metadataByInput: Record<string, InputMetadata> =659publishFiles.metadataByInput ?? {};660661trace("metadataByInput", metadataByInput);662663trace("publishSite", {664parentId,665publishFiles,666});667668const filteredFiles: string[] = filterFilesForUpdate(publishFiles.files);669670trace("filteredFiles", filteredFiles);671672const assembleSiteFileMetadata = async (673fileName: string,674): Promise<SiteFileMetadata> => {675const fileToContentBody = async (676fileName: string,677): Promise<ContentBody> => {678return loadDocument(publishFiles.baseDir, fileName);679};680681const originalTitle = getTitle(fileName, metadataByInput);682const title = originalTitle;683684return await {685fileName,686title,687originalTitle,688contentBody: await fileToContentBody(fileName),689};690};691692const fileMetadata: SiteFileMetadata[] = await Promise.all(693filteredFiles.map(assembleSiteFileMetadata),694);695696trace("fileMetadata", fileMetadata);697698let metadataByFilename = buildFileToMetaTable(existingSite);699700trace("metadataByFilename", metadataByFilename);701702let changeList: ConfluenceSpaceChange[] = buildSpaceChanges(703fileMetadata,704siteParent,705space,706existingSite,707);708709changeList = flattenIndexes(changeList, metadataByFilename, parentId);710711const { pass1Changes, pass2Changes } = updateLinks(712metadataByFilename,713changeList,714server,715siteParent,716);717718changeList = pass1Changes;719720trace("changelist Pass 1", changeList);721722let pathsToId: Record<string, string> = {}; // build from existing site723724const handleChangeError = (725label: string,726currentChange: ConfluenceSpaceChange,727error: any,728) => {729if (isContentUpdate(currentChange) || isContentCreate(currentChange)) {730trace("currentChange.fileName", currentChange.fileName);731trace("Value to Update", currentChange.body.storage.value);732}733if (EXIT_ON_ERROR) {734throw error;735}736};737let hasAttachments = false;738739const doChange = async (740change: ConfluenceSpaceChange,741uploadFileAttachments: boolean = true,742) => {743if (isContentCreate(change)) {744if (change.fileName === "sitemap.xml") {745trace("sitemap.xml skipped", change);746return;747}748749let ancestorId = (change?.ancestors && change?.ancestors[0]?.id) ??750null;751752if (ancestorId && pathsToId[ancestorId]) {753ancestorId = pathsToId[ancestorId];754}755756const ancestorParent: ConfluenceParent = {757space: parent.space,758parent: ancestorId ?? siteParent.parent,759};760761const universalPath = pathWithForwardSlashes(change.fileName ?? "");762763const result = await createContent(764publishFiles,765change.body,766change.title ?? "",767ancestorParent,768universalPath,769);770771if (universalPath) {772pathsToId[universalPath] = result.content.id ?? "";773}774775const contentPropertyResult: Content = await client776.createContentProperty(result.content.id ?? "", {777key: ContentPropertyKey.fileName,778value: (change as ContentCreate).fileName,779});780hasAttachments = hasAttachments || result.hasAttachments;781return result;782} else if (isContentUpdate(change)) {783const update = change as ContentUpdate;784const result = await updateContent(785user,786publishFiles,787update.id ?? "",788update.body,789update.title ?? "",790update.fileName ?? "",791uploadFileAttachments,792);793hasAttachments = hasAttachments || result.hasAttachments;794return result;795} else if (isContentDelete(change)) {796if (DELETE_DISABLED) {797console.warn("DELETE DISABELD");798return null;799}800const result = await client.deleteContent(change);801await sleep(DELETE_SLEEP_MILLIS); // TODO replace with polling802return { content: result, hasAttachments: false };803} else {804console.error("Space Change not defined");805return null;806}807};808809let pass1Count = 0;810for (let currentChange of changeList) {811try {812pass1Count = pass1Count + 1;813const doOperation = async () => await doChange(currentChange);814await doWithSpinner(815`Site Updates [${pass1Count}/${changeList.length}]`,816doOperation,817);818} catch (error: any) {819handleChangeError(820"Error Performing Change Pass 1",821currentChange,822error,823);824}825}826827if (pass2Changes.length) {828//PASS #2 to update links to newly created pages829830trace("changelist Pass 2", pass2Changes);831832existingSite = await fetchExistingSite(parentId);833metadataByFilename = buildFileToMetaTable(existingSite);834835const linkUpdateChanges: ConfluenceSpaceChange[] = convertForSecondPass(836metadataByFilename,837pass2Changes,838server,839parent,840);841842let pass2Count = 0;843for (let currentChange of linkUpdateChanges) {844try {845pass2Count = pass2Count + 1;846const doOperation = async () => await doChange(currentChange, false);847await doWithSpinner(848`Updating Links [${pass2Count}/${linkUpdateChanges.length}]`,849doOperation,850);851} catch (error: any) {852handleChangeError(853"Error Performing Change Pass 2",854currentChange,855error,856);857}858}859}860861const parentPage: Content = await client.getContent(parentId);862return [buildPublishRecordForContent(server, parentPage), hasAttachments];863};864865if (type === PublishTypeEnum.document) {866const [publishResult, hasAttachments] = await publishDocument();867if (hasAttachments) {868info(869"\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",870);871}872return publishResult;873} else {874const [publishResult, hasAttachments] = await publishSite();875if (hasAttachments) {876info(877"\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",878);879}880return publishResult;881}882}883884export const confluenceProvider: PublishProvider = {885name: CONFLUENCE_ID,886description: "Confluence",887hidden: false,888requiresServer: true,889requiresRender: true,890accountTokens: getAccountTokens,891authorizeToken: promptAndAuthorizeToken,892removeToken,893resolveTarget,894publish,895isUnauthorized,896isNotFound,897};898899900