Path: blob/main/src/publish/confluence/confluence-helper.ts
6446 views
/*1* confluence-helper.ts2*3* Copyright (C) 2020-2024 Posit Software, PBC4*/56import { trace } from "./confluence-logger.ts";7import { ApiError, PublishRecord } from "../types.ts";8import {9ensureTrailingSlash,10pathWithForwardSlashes,11} from "../../core/path.ts";12import { join } from "../../deno_ral/path.ts";13import { isHttpUrl } from "../../core/url.ts";14import { AccountToken, InputMetadata } from "../provider-types.ts";15import {16ConfluenceParent,17ConfluenceSpaceChange,18Content,19ContentAncestor,20ContentBody,21ContentBodyRepresentation,22ContentChange,23ContentChangeType,24ContentCreate,25ContentDelete,26ContentProperty,27ContentStatusEnum,28ContentSummary,29ContentUpdate,30ContentVersion,31EMPTY_PARENT,32PAGE_TYPE,33SiteFileMetadata,34SitePage,35Space,36} from "./api/types.ts";37import { withSpinner } from "../../core/console.ts";38import { ProjectContext } from "../../project/types.ts";39import { capitalizeWord } from "../../core/text.ts";4041export const LINK_FINDER: RegExp = /(\S*.qmd'|\S*.qmd#\S*')/g;42export const FILE_FINDER: RegExp = /(?<=href=\')(.*)(?=\.qmd)/;43const ATTACHMENT_FINDER: RegExp =44/(?<=ri:attachment ri:filename=["\'])[^"\']+?\.(?:jpe?g|png|gif|m4a|mp3|txt|svg|ai|pdf)(?=["\'])/g;4546export const capitalizeFirstLetter = (value: string = ""): string => {47if (!value || value.length === 0) {48return "";49}50return value[0].toUpperCase() + value.slice(1);51};5253export const transformAtlassianDomain = (domain: string) => {54return ensureTrailingSlash(55isHttpUrl(domain) ? domain : `https://${domain}.atlassian.net`,56);57};5859export const isUnauthorized = (error: Error): boolean => {60return (61error instanceof ApiError && (error.status === 401 || error.status === 403)62);63};6465export const isNotFound = (error: Error): boolean => {66return error instanceof ApiError && error.status === 404;67};6869const exitIfNoValue = (value: string) => {70if (value?.length === 0) {71throw new Error("");72}73};7475export const validateServer = (value: string): boolean | string => {76exitIfNoValue(value);77try {78new URL(transformAtlassianDomain(value));79return true;80} catch {81return `Not a valid URL`;82}83};8485export const validateEmail = (value: string): boolean | string => {86exitIfNoValue(value);8788// TODO use deno validation89// https://deno.land/x/[email protected]90const expression: RegExp = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i;91const isValid = expression.test(value);9293if (!isValid) {94return "Invalid email address";95}9697return true;98};99100export const validateToken = (value: string): boolean => {101exitIfNoValue(value);102return true;103};104105export const validateParentURL = (value: string): boolean => {106//TODO validate URL107exitIfNoValue(value);108return true;109};110111export const getMessageFromAPIError = (error: any): string => {112if (error instanceof ApiError) {113return `${error.status} - ${error.statusText}`;114}115116if (error?.message) {117return error.message;118}119120return "Unknown error";121};122123export const tokenFilterOut = (124accessToken: AccountToken,125token: AccountToken,126) => {127return accessToken.server !== token.server && accessToken.name !== token.name;128};129130export const confluenceParentFromString = (url: string): ConfluenceParent => {131let toMatch = url;132const urlNoParamsList = url?.split("?");133134if (urlNoParamsList.length === 2) {135toMatch = urlNoParamsList[0] ?? url;136}137138const match = toMatch.match(139/^https.*?wiki\/spaces\/(?:(~?\w+)|(~?\w+)\/overview|(~?.+)\/pages\/(\d+).*)$/,140);141if (match) {142return {143space: match[1] || match[2] || match[3],144parent: match[4],145};146}147return EMPTY_PARENT;148};149150export const wrapBodyForConfluence = (value: string): ContentBody => {151const body: ContentBody = {152storage: {153value,154representation: ContentBodyRepresentation.storage,155},156};157return body;158};159160export const buildPublishRecordForContent = (161server: string,162content: Content | undefined,163): [PublishRecord, URL] => {164if (!content?.id || !content?.space || !(server.length > 0)) {165throw new Error("Invalid Content");166}167168const url = `${169ensureTrailingSlash(server)170}wiki/spaces/${content.space.key}/pages/${content.id}`;171172const newPublishRecord: PublishRecord = {173id: content.id,174url,175};176177return [newPublishRecord, new URL(url)];178};179180export const doWithSpinner = async (181message: string,182toDo: () => Promise<any>,183) => {184return await withSpinner(185{186message,187},188toDo,189);190};191192export const getNextVersion = (previousPage: Content): ContentVersion => {193const previousNumber: number = previousPage.version?.number ?? 0;194return {195number: previousNumber + 1,196};197};198199export const writeTokenComparator = (200a: AccountToken,201b: AccountToken,202): boolean => a.server === b.server && a.name === b.name;203204export const isProjectContext = (205input: ProjectContext | string,206): input is ProjectContext => {207return (input as ProjectContext).files !== undefined;208};209210export const filterFilesForUpdate = (allFiles: string[]): string[] => {211const fileFilter = (fileName: string): boolean => {212if (!fileName.endsWith(".xml")) {213return false;214}215return true;216};217const result: string[] = allFiles.filter(fileFilter);218return result;219};220221export const isContentCreate = (222content: ConfluenceSpaceChange,223): content is ContentCreate => {224return content.contentChangeType === ContentChangeType.create;225};226227export const isContentUpdate = (228content: ConfluenceSpaceChange,229): content is ContentUpdate => {230return content.contentChangeType === ContentChangeType.update;231};232233export const isContentDelete = (234content: ConfluenceSpaceChange,235): content is ContentDelete => {236return content.contentChangeType === ContentChangeType.delete;237};238239export const buildContentCreate = (240title: string | null,241space: Space,242body: ContentBody,243fileName: string,244parent?: string,245status: ContentStatusEnum = ContentStatusEnum.current,246id: string | null = null,247type: string = PAGE_TYPE,248): ContentCreate => {249return {250contentChangeType: ContentChangeType.create,251title,252type,253space,254status,255ancestors: parent ? [{ id: parent }] : null,256body,257fileName,258};259};260261export const buildContentUpdate = (262id: string | null,263title: string | null,264body: ContentBody,265fileName: string,266parent?: string,267status: ContentStatusEnum = ContentStatusEnum.current,268type: string = PAGE_TYPE,269version: ContentVersion | null = null,270ancestors: ContentAncestor[] | null = null,271): ContentUpdate => {272return {273contentChangeType: ContentChangeType.update,274id,275version,276title,277type,278status,279ancestors: parent ? [{ id: parent }] : ancestors,280body,281fileName,282};283};284285export const findPagesToDelete = (286fileMetadataList: SiteFileMetadata[],287existingSite: SitePage[] = [],288): SitePage[] => {289const activeParents = existingSite.reduce(290(accumulator: ContentAncestor[], page: SitePage): ContentAncestor[] => {291return [...accumulator, ...(page.ancestors ?? [])];292},293[],294);295296const isActiveParent = (id: string): boolean =>297!!activeParents.find((parent) => parent.id === id);298299return existingSite.reduce((accumulator: SitePage[], page: SitePage) => {300if (301!fileMetadataList.find(302(file) =>303pathWithForwardSlashes(file.fileName) ===304(page?.metadata?.fileName ?? ""),305) &&306!isActiveParent(page.id)307) {308return [...accumulator, page];309}310311return accumulator;312}, []);313};314315export const buildSpaceChanges = (316fileMetadataList: SiteFileMetadata[],317parent: ConfluenceParent,318space: Space,319existingSite: SitePage[] = [],320): ConfluenceSpaceChange[] => {321const spaceChangesCallback = (322accumulatedChanges: ConfluenceSpaceChange[],323fileMetadata: SiteFileMetadata,324): ConfluenceSpaceChange[] => {325const findPageInExistingSite = (fileName: string) =>326existingSite.find(327(page: SitePage) => page?.metadata?.fileName === fileName,328);329330const universalFileName = pathWithForwardSlashes(fileMetadata.fileName);331const existingPage = findPageInExistingSite(universalFileName);332333let spaceChangeList: ConfluenceSpaceChange[] = [];334335const pathList = universalFileName.split("/");336337let pageParent = pathList.length > 1338? pathList.slice(0, pathList.length - 1).join("/")339: parent?.parent;340341const checkCreateParents = (): SitePage | null => {342if (pathList.length < 2) {343return null;344}345346let existingSiteParent = null;347348const parentsList = pathList.slice(0, pathList.length - 1);349350parentsList.forEach((parentFileName, index) => {351const ancestorFilePath = parentsList.slice(0, index).join("/");352353const ancestor = index > 0 ? ancestorFilePath : parent?.parent;354355let fileName = `${ancestorFilePath}/${parentFileName}`;356357if (fileName.startsWith("/")) {358fileName = parentFileName;359}360361const existingParentCreateChange = accumulatedChanges.find(362(spaceChange: any) => {363if (spaceChange.fileName) {364return spaceChange?.fileName === fileName;365}366return false;367},368);369370existingSiteParent = existingSite.find((page: SitePage) => {371if (page?.metadata?.fileName) {372return page.metadata.fileName === fileName;373}374return false;375});376377if (!existingParentCreateChange && !existingSiteParent) {378// Create a new parent page379380const existingAncestor = findPageInExistingSite(ancestor ?? "");381382spaceChangeList = [383...spaceChangeList,384buildContentCreate(385capitalizeFirstLetter(parentFileName),386space,387{388storage: {389value: "",390representation: "storage",391},392},393fileName,394existingAncestor ? existingAncestor.id : ancestor,395ContentStatusEnum.current,396),397];398}399});400401return existingSiteParent;402};403404const existingParent: SitePage | null = checkCreateParents();405406pageParent = existingParent ? existingParent.id : pageParent;407408if (existingPage) {409spaceChangeList = [410buildContentUpdate(411existingPage.id,412fileMetadata.title,413fileMetadata.contentBody,414universalFileName,415pageParent,416),417];418} else {419spaceChangeList = [420...spaceChangeList,421buildContentCreate(422fileMetadata.title,423space,424fileMetadata.contentBody,425universalFileName,426pageParent,427ContentStatusEnum.current,428),429];430}431432return [...accumulatedChanges, ...spaceChangeList];433};434435const pagesToDelete: SitePage[] = findPagesToDelete(436fileMetadataList,437existingSite,438);439440const deleteChanges: ContentDelete[] = pagesToDelete.map(441(toDelete: SitePage) => {442return { contentChangeType: ContentChangeType.delete, id: toDelete.id };443},444);445446let spaceChanges: ConfluenceSpaceChange[] = fileMetadataList.reduce(447spaceChangesCallback,448deleteChanges,449);450451const activeAncestorIds = spaceChanges.reduce(452(accumulator: any, change: any) => {453if (change?.ancestors?.length) {454const idList = change.ancestors.map(455(ancestor: ContentAncestor) => ancestor?.id ?? "",456);457458return [...accumulator, ...idList];459}460461return accumulator;462},463[],464);465466spaceChanges = spaceChanges.filter((change: ConfluenceSpaceChange) => {467if (isContentDelete(change) && activeAncestorIds.includes(change.id)) {468return false;469}470return true;471});472473return spaceChanges;474};475476export const flattenIndexes = (477changes: ConfluenceSpaceChange[],478metadataByFileName: Record<string, SitePage>,479siteParentId: string,480): ConfluenceSpaceChange[] => {481const getFileNameForChange = (change: ConfluenceSpaceChange) => {482if (isContentDelete(change)) {483return "";484}485486return pathWithForwardSlashes(change?.fileName ?? "");487};488489const isIndexFile = (change: ConfluenceSpaceChange) => {490return getFileNameForChange(change)?.endsWith("/index.xml");491};492493const toIndexPageLookup = (494accumulator: Record<string, ConfluenceSpaceChange>,495change: ConfluenceSpaceChange,496): Record<string, any> => {497if (isContentDelete(change)) {498return accumulator;499}500const fileName = getFileNameForChange(change);501const isIndex = isIndexFile(change);502503if (isIndex) {504const folderFileName = fileName.replace("/index.xml", "");505return {506...accumulator,507[folderFileName]: change,508};509}510511return accumulator;512};513514const indexLookup: Record<string, ConfluenceSpaceChange> = changes.reduce(515toIndexPageLookup,516{},517);518519const toFlattenedIndexes = (520accumulator: ConfluenceSpaceChange[],521change: ConfluenceSpaceChange,522): ConfluenceSpaceChange[] => {523if (isContentDelete(change)) {524return [...accumulator, change];525}526527const fileName = getFileNameForChange(change);528529if (fileName === "index.xml") {530const rootUpdate = buildContentUpdate(531siteParentId,532change.title,533change.body,534"index.xml",535);536return [...accumulator, rootUpdate];537}538539const parentFileName = fileName.replace("/index.xml", "");540const parentSitePage: SitePage = metadataByFileName[parentFileName];541if (isIndexFile(change)) {542if (parentSitePage) {543// The parent has already been created, this index create544// is actually an index parent update update the folder with545// index contents546const parentUpdate = buildContentUpdate(547parentSitePage.id,548change.title,549change.body,550parentSitePage.metadata.fileName ?? "",551"",552ContentStatusEnum.current,553PAGE_TYPE,554null,555parentSitePage.ancestors,556);557558return [...accumulator, parentUpdate];559} else {560return [...accumulator]; //filter out index file creates561}562}563564const indexCreateChange: ConfluenceSpaceChange = indexLookup[fileName];565566if (indexCreateChange && !isContentDelete(indexCreateChange)) {567change.title = indexCreateChange.title ?? change.title;568change.body = indexCreateChange.body ?? change.body;569}570571return [...accumulator, change];572};573574const flattenedIndexes = changes.reduce(toFlattenedIndexes, []);575576return flattenedIndexes;577};578579export const replaceExtension = (580fileName: string,581oldExtension: string,582newExtension: string,583) => {584return fileName.replace(oldExtension, newExtension);585};586587export const getTitle = (588fileName: string,589metadataByInput: Record<string, InputMetadata>,590): string => {591const qmdFileName = replaceExtension(fileName, ".xml", ".qmd");592593const metadataTitle = metadataByInput[qmdFileName]?.title;594595const titleFromFilename = capitalizeWord(fileName.split(".")[0] ?? fileName);596const title = metadataTitle ?? titleFromFilename;597return title;598};599600const flattenMetadata = (list: ContentProperty[] = []): Record<string, any> => {601const result: Record<string, any> = list.reduce(602(accumulator: any, currentValue: ContentProperty) => {603const updated: any = accumulator;604updated[currentValue.key] = currentValue.value;605return updated;606},607{},608);609610return result;611};612613export const mergeSitePages = (614shallowPages: ContentSummary[] = [],615contentProperties: ContentProperty[][] = [],616): SitePage[] => {617const result: SitePage[] = shallowPages.map(618(contentSummary: ContentSummary, index) => {619const sitePage: SitePage = {620title: contentSummary.title,621id: contentSummary.id ?? "",622metadata: flattenMetadata(contentProperties[index]),623ancestors: contentSummary.ancestors ?? [],624};625return sitePage;626},627);628return result;629};630631export const buildFileToMetaTable = (632fileMetadata: SitePage[],633): Record<string, SitePage> => {634return fileMetadata.reduce(635(accumulator: Record<string, SitePage>, page: SitePage) => {636const fileName: string = page?.metadata?.fileName ?? "";637const fileNameQMD = fileName.replace("xml", "qmd");638if (fileName.length === 0) {639return accumulator;640}641642accumulator[fileNameQMD] = page;643return accumulator;644},645{},646);647};648649export const updateLinks = (650fileMetadataTable: Record<string, SitePage>,651spaceChanges: ConfluenceSpaceChange[],652server: string,653parent: ConfluenceParent,654): {655pass1Changes: ConfluenceSpaceChange[];656pass2Changes: ConfluenceSpaceChange[];657} => {658const root = `${server}`;659const url = `${660ensureTrailingSlash(server)661}wiki/spaces/${parent.space}/pages/`;662663let collectedPass2Changes: ConfluenceSpaceChange[] = [];664665const changeMapper = (666changeToProcess: ConfluenceSpaceChange,667): ConfluenceSpaceChange => {668const replacer = (match: string): string => {669let documentFileName = "";670if (671isContentUpdate(changeToProcess) ||672isContentCreate(changeToProcess)673) {674documentFileName = changeToProcess.fileName ?? "";675}676677const docFileNamePathList = documentFileName.split("/");678679let updated: string = match;680const linkFileNameMatch = FILE_FINDER.exec(match);681682const linkFileName = linkFileNameMatch ? linkFileNameMatch[0] ?? "" : "";683684const fileNamePathList = linkFileName.split("/");685686const linkFullFileName = `${linkFileName}.qmd`;687688let siteFilePath = linkFullFileName;689const isAbsolute = siteFilePath.startsWith("/");690if (!isAbsolute && docFileNamePathList.length > 1) {691const relativePath = docFileNamePathList692.slice(0, docFileNamePathList.length - 1)693.join("/");694695if (siteFilePath.startsWith("./")) {696siteFilePath = siteFilePath.replace("./", `${relativePath}/`);697} else {698siteFilePath = `${relativePath}/${linkFullFileName}`;699}700}701702if (isAbsolute) {703siteFilePath = siteFilePath.slice(1); //remove '/'704}705706if (siteFilePath.endsWith("/index.qmd")) {707//flatten child index links to the parent708const siteFilePathParent = siteFilePath.replace("/index.qmd", "");709if (fileMetadataTable[siteFilePathParent]) {710siteFilePath = siteFilePathParent;711}712}713714if (!documentFileName.endsWith(".xml")) {715//this is a flattened index in a folder with contents716const siteFilePathParent = `${documentFileName}/${linkFullFileName}`;717if (fileMetadataTable[siteFilePathParent]) {718siteFilePath = siteFilePathParent;719}720}721722const sitePage: SitePage | null = fileMetadataTable[siteFilePath] ?? null;723724if (sitePage) {725updated = match.replace('href="', `href="${url}`);726const pagePath: string = `${url}${sitePage.id}`;727updated = updated.replace(linkFullFileName, pagePath);728} else {729if (!collectedPass2Changes.includes(changeToProcess)) {730collectedPass2Changes = [...collectedPass2Changes, changeToProcess];731}732}733734return updated;735};736737if (isContentUpdate(changeToProcess) || isContentCreate(changeToProcess)) {738const valueToProcess = changeToProcess?.body?.storage?.value;739740if (valueToProcess) {741const replacedLinks: string = valueToProcess.replaceAll(742LINK_FINDER,743replacer,744);745746changeToProcess.body.storage.value = replacedLinks;747}748}749return changeToProcess;750};751752const updatedChanges: ConfluenceSpaceChange[] = spaceChanges.map(753changeMapper,754);755756return { pass1Changes: updatedChanges, pass2Changes: collectedPass2Changes };757};758759export const convertForSecondPass = (760fileMetadataTable: Record<string, SitePage>,761spaceChanges: ConfluenceSpaceChange[],762server: string,763parent: ConfluenceParent,764): ConfluenceSpaceChange[] => {765const toUpdatesReducer = (766accumulator: ConfluenceSpaceChange[],767change: ConfluenceSpaceChange,768) => {769if (isContentUpdate(change)) {770accumulator = [...accumulator, change];771}772773if (isContentCreate(change)) {774const qmdFileName = replaceExtension(775change.fileName ?? "",776".xml",777".qmd",778);779const updateId = fileMetadataTable[qmdFileName]?.id;780781if (updateId) {782const convertedUpdate = buildContentUpdate(783updateId,784change.title,785change.body,786change.fileName ?? "",787"",788ContentStatusEnum.current,789PAGE_TYPE,790null,791change.ancestors,792);793accumulator = [...accumulator, convertedUpdate];794} else {795trace("update ID not found for", change.fileName);796}797}798799return accumulator;800};801802const changesAsUpdates = spaceChanges.reduce(toUpdatesReducer, []);803804const updateLinkResults = updateLinks(805fileMetadataTable,806changesAsUpdates,807server,808parent,809);810811return updateLinkResults.pass1Changes;812};813814export const updateImagePaths = (body: ContentBody): ContentBody => {815const replacer = (match: string): string => {816let updated: string = match.replace(/^.*[\\\/]/, "");817return updated;818};819820const bodyValue: string = body?.storage?.value;821if (!bodyValue) {822return body;823}824825const attachments = findAttachments(bodyValue);826827const withReplacedImages: string = bodyValue.replaceAll(828ATTACHMENT_FINDER,829replacer,830);831832body.storage.value = withReplacedImages;833return body;834};835836export const findAttachments = (837bodyValue: string,838publishFiles: string[] = [],839filePathParam: string = "",840): string[] => {841const filePath = pathWithForwardSlashes(filePathParam);842843const pathList = filePath.split("/");844const parentPath = pathList.slice(0, pathList.length - 1).join("/");845846const imageFinderMatches: RegExpMatchArray | null = bodyValue.match(847ATTACHMENT_FINDER,848);849let uniqueFoundImages: string[] = [...new Set(imageFinderMatches)];850851if (publishFiles.length > 0) {852uniqueFoundImages = uniqueFoundImages.map((assetFileName: string) => {853const assetInPublishFiles = publishFiles.find((assetPathParam) => {854const assetPath = pathWithForwardSlashes(assetPathParam);855856const toCheck = pathWithForwardSlashes(join(parentPath, assetFileName));857858return assetPath === toCheck;859});860861return assetInPublishFiles ?? assetFileName;862});863}864865return uniqueFoundImages ?? [];866};867868const buildConfluenceAnchor = (id: string) =>869`<ac:structured-macro ac:name="anchor" ac:schema-version="1" ac:local-id="a6aa6f25-0bee-4a7f-929b-71fcb7eba592" ac:macro-id="d2cb5be1217ae6e086bc60005e9d27b7"><ac:parameter ac:name="">${id}</ac:parameter></ac:structured-macro>`;870871export const footnoteTransform = (bodyValue: string): string => {872const BACK_ANCHOR_FINDER: RegExp = /<a href="#fn(\d)"/g;873const ANCHOR_FINDER: RegExp = /<a href="#fnref(\d)"/g;874const CONFLUENCE_ANCHOR_FINDER: RegExp =875/ac:macro-id="d2cb5be1217ae6e086bc60005e9d27b7"><ac:parameter ac:name="">fn/g;876877if (bodyValue.search(CONFLUENCE_ANCHOR_FINDER) !== -1) {878//the footnote transform has already happened879return bodyValue;880}881882const replacer = (prefix: string) => (match: string, p1: string): string =>883`${buildConfluenceAnchor(`${prefix}${p1}`)}${match}`;884885let replacedBody: string = bodyValue.replaceAll(886BACK_ANCHOR_FINDER,887replacer("fnref"),888);889890replacedBody = replacedBody.replaceAll(ANCHOR_FINDER, replacer("fn"));891892return replacedBody;893};894895896