Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/publish/confluence/confluence-helper.ts
6446 views
1
/*
2
* confluence-helper.ts
3
*
4
* Copyright (C) 2020-2024 Posit Software, PBC
5
*/
6
7
import { trace } from "./confluence-logger.ts";
8
import { ApiError, PublishRecord } from "../types.ts";
9
import {
10
ensureTrailingSlash,
11
pathWithForwardSlashes,
12
} from "../../core/path.ts";
13
import { join } from "../../deno_ral/path.ts";
14
import { isHttpUrl } from "../../core/url.ts";
15
import { AccountToken, InputMetadata } from "../provider-types.ts";
16
import {
17
ConfluenceParent,
18
ConfluenceSpaceChange,
19
Content,
20
ContentAncestor,
21
ContentBody,
22
ContentBodyRepresentation,
23
ContentChange,
24
ContentChangeType,
25
ContentCreate,
26
ContentDelete,
27
ContentProperty,
28
ContentStatusEnum,
29
ContentSummary,
30
ContentUpdate,
31
ContentVersion,
32
EMPTY_PARENT,
33
PAGE_TYPE,
34
SiteFileMetadata,
35
SitePage,
36
Space,
37
} from "./api/types.ts";
38
import { withSpinner } from "../../core/console.ts";
39
import { ProjectContext } from "../../project/types.ts";
40
import { capitalizeWord } from "../../core/text.ts";
41
42
export const LINK_FINDER: RegExp = /(\S*.qmd'|\S*.qmd#\S*')/g;
43
export const FILE_FINDER: RegExp = /(?<=href=\')(.*)(?=\.qmd)/;
44
const ATTACHMENT_FINDER: RegExp =
45
/(?<=ri:attachment ri:filename=["\'])[^"\']+?\.(?:jpe?g|png|gif|m4a|mp3|txt|svg|ai|pdf)(?=["\'])/g;
46
47
export const capitalizeFirstLetter = (value: string = ""): string => {
48
if (!value || value.length === 0) {
49
return "";
50
}
51
return value[0].toUpperCase() + value.slice(1);
52
};
53
54
export const transformAtlassianDomain = (domain: string) => {
55
return ensureTrailingSlash(
56
isHttpUrl(domain) ? domain : `https://${domain}.atlassian.net`,
57
);
58
};
59
60
export const isUnauthorized = (error: Error): boolean => {
61
return (
62
error instanceof ApiError && (error.status === 401 || error.status === 403)
63
);
64
};
65
66
export const isNotFound = (error: Error): boolean => {
67
return error instanceof ApiError && error.status === 404;
68
};
69
70
const exitIfNoValue = (value: string) => {
71
if (value?.length === 0) {
72
throw new Error("");
73
}
74
};
75
76
export const validateServer = (value: string): boolean | string => {
77
exitIfNoValue(value);
78
try {
79
new URL(transformAtlassianDomain(value));
80
return true;
81
} catch {
82
return `Not a valid URL`;
83
}
84
};
85
86
export const validateEmail = (value: string): boolean | string => {
87
exitIfNoValue(value);
88
89
// TODO use deno validation
90
// https://deno.land/x/[email protected]
91
const expression: RegExp = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i;
92
const isValid = expression.test(value);
93
94
if (!isValid) {
95
return "Invalid email address";
96
}
97
98
return true;
99
};
100
101
export const validateToken = (value: string): boolean => {
102
exitIfNoValue(value);
103
return true;
104
};
105
106
export const validateParentURL = (value: string): boolean => {
107
//TODO validate URL
108
exitIfNoValue(value);
109
return true;
110
};
111
112
export const getMessageFromAPIError = (error: any): string => {
113
if (error instanceof ApiError) {
114
return `${error.status} - ${error.statusText}`;
115
}
116
117
if (error?.message) {
118
return error.message;
119
}
120
121
return "Unknown error";
122
};
123
124
export const tokenFilterOut = (
125
accessToken: AccountToken,
126
token: AccountToken,
127
) => {
128
return accessToken.server !== token.server && accessToken.name !== token.name;
129
};
130
131
export const confluenceParentFromString = (url: string): ConfluenceParent => {
132
let toMatch = url;
133
const urlNoParamsList = url?.split("?");
134
135
if (urlNoParamsList.length === 2) {
136
toMatch = urlNoParamsList[0] ?? url;
137
}
138
139
const match = toMatch.match(
140
/^https.*?wiki\/spaces\/(?:(~?\w+)|(~?\w+)\/overview|(~?.+)\/pages\/(\d+).*)$/,
141
);
142
if (match) {
143
return {
144
space: match[1] || match[2] || match[3],
145
parent: match[4],
146
};
147
}
148
return EMPTY_PARENT;
149
};
150
151
export const wrapBodyForConfluence = (value: string): ContentBody => {
152
const body: ContentBody = {
153
storage: {
154
value,
155
representation: ContentBodyRepresentation.storage,
156
},
157
};
158
return body;
159
};
160
161
export const buildPublishRecordForContent = (
162
server: string,
163
content: Content | undefined,
164
): [PublishRecord, URL] => {
165
if (!content?.id || !content?.space || !(server.length > 0)) {
166
throw new Error("Invalid Content");
167
}
168
169
const url = `${
170
ensureTrailingSlash(server)
171
}wiki/spaces/${content.space.key}/pages/${content.id}`;
172
173
const newPublishRecord: PublishRecord = {
174
id: content.id,
175
url,
176
};
177
178
return [newPublishRecord, new URL(url)];
179
};
180
181
export const doWithSpinner = async (
182
message: string,
183
toDo: () => Promise<any>,
184
) => {
185
return await withSpinner(
186
{
187
message,
188
},
189
toDo,
190
);
191
};
192
193
export const getNextVersion = (previousPage: Content): ContentVersion => {
194
const previousNumber: number = previousPage.version?.number ?? 0;
195
return {
196
number: previousNumber + 1,
197
};
198
};
199
200
export const writeTokenComparator = (
201
a: AccountToken,
202
b: AccountToken,
203
): boolean => a.server === b.server && a.name === b.name;
204
205
export const isProjectContext = (
206
input: ProjectContext | string,
207
): input is ProjectContext => {
208
return (input as ProjectContext).files !== undefined;
209
};
210
211
export const filterFilesForUpdate = (allFiles: string[]): string[] => {
212
const fileFilter = (fileName: string): boolean => {
213
if (!fileName.endsWith(".xml")) {
214
return false;
215
}
216
return true;
217
};
218
const result: string[] = allFiles.filter(fileFilter);
219
return result;
220
};
221
222
export const isContentCreate = (
223
content: ConfluenceSpaceChange,
224
): content is ContentCreate => {
225
return content.contentChangeType === ContentChangeType.create;
226
};
227
228
export const isContentUpdate = (
229
content: ConfluenceSpaceChange,
230
): content is ContentUpdate => {
231
return content.contentChangeType === ContentChangeType.update;
232
};
233
234
export const isContentDelete = (
235
content: ConfluenceSpaceChange,
236
): content is ContentDelete => {
237
return content.contentChangeType === ContentChangeType.delete;
238
};
239
240
export const buildContentCreate = (
241
title: string | null,
242
space: Space,
243
body: ContentBody,
244
fileName: string,
245
parent?: string,
246
status: ContentStatusEnum = ContentStatusEnum.current,
247
id: string | null = null,
248
type: string = PAGE_TYPE,
249
): ContentCreate => {
250
return {
251
contentChangeType: ContentChangeType.create,
252
title,
253
type,
254
space,
255
status,
256
ancestors: parent ? [{ id: parent }] : null,
257
body,
258
fileName,
259
};
260
};
261
262
export const buildContentUpdate = (
263
id: string | null,
264
title: string | null,
265
body: ContentBody,
266
fileName: string,
267
parent?: string,
268
status: ContentStatusEnum = ContentStatusEnum.current,
269
type: string = PAGE_TYPE,
270
version: ContentVersion | null = null,
271
ancestors: ContentAncestor[] | null = null,
272
): ContentUpdate => {
273
return {
274
contentChangeType: ContentChangeType.update,
275
id,
276
version,
277
title,
278
type,
279
status,
280
ancestors: parent ? [{ id: parent }] : ancestors,
281
body,
282
fileName,
283
};
284
};
285
286
export const findPagesToDelete = (
287
fileMetadataList: SiteFileMetadata[],
288
existingSite: SitePage[] = [],
289
): SitePage[] => {
290
const activeParents = existingSite.reduce(
291
(accumulator: ContentAncestor[], page: SitePage): ContentAncestor[] => {
292
return [...accumulator, ...(page.ancestors ?? [])];
293
},
294
[],
295
);
296
297
const isActiveParent = (id: string): boolean =>
298
!!activeParents.find((parent) => parent.id === id);
299
300
return existingSite.reduce((accumulator: SitePage[], page: SitePage) => {
301
if (
302
!fileMetadataList.find(
303
(file) =>
304
pathWithForwardSlashes(file.fileName) ===
305
(page?.metadata?.fileName ?? ""),
306
) &&
307
!isActiveParent(page.id)
308
) {
309
return [...accumulator, page];
310
}
311
312
return accumulator;
313
}, []);
314
};
315
316
export const buildSpaceChanges = (
317
fileMetadataList: SiteFileMetadata[],
318
parent: ConfluenceParent,
319
space: Space,
320
existingSite: SitePage[] = [],
321
): ConfluenceSpaceChange[] => {
322
const spaceChangesCallback = (
323
accumulatedChanges: ConfluenceSpaceChange[],
324
fileMetadata: SiteFileMetadata,
325
): ConfluenceSpaceChange[] => {
326
const findPageInExistingSite = (fileName: string) =>
327
existingSite.find(
328
(page: SitePage) => page?.metadata?.fileName === fileName,
329
);
330
331
const universalFileName = pathWithForwardSlashes(fileMetadata.fileName);
332
const existingPage = findPageInExistingSite(universalFileName);
333
334
let spaceChangeList: ConfluenceSpaceChange[] = [];
335
336
const pathList = universalFileName.split("/");
337
338
let pageParent = pathList.length > 1
339
? pathList.slice(0, pathList.length - 1).join("/")
340
: parent?.parent;
341
342
const checkCreateParents = (): SitePage | null => {
343
if (pathList.length < 2) {
344
return null;
345
}
346
347
let existingSiteParent = null;
348
349
const parentsList = pathList.slice(0, pathList.length - 1);
350
351
parentsList.forEach((parentFileName, index) => {
352
const ancestorFilePath = parentsList.slice(0, index).join("/");
353
354
const ancestor = index > 0 ? ancestorFilePath : parent?.parent;
355
356
let fileName = `${ancestorFilePath}/${parentFileName}`;
357
358
if (fileName.startsWith("/")) {
359
fileName = parentFileName;
360
}
361
362
const existingParentCreateChange = accumulatedChanges.find(
363
(spaceChange: any) => {
364
if (spaceChange.fileName) {
365
return spaceChange?.fileName === fileName;
366
}
367
return false;
368
},
369
);
370
371
existingSiteParent = existingSite.find((page: SitePage) => {
372
if (page?.metadata?.fileName) {
373
return page.metadata.fileName === fileName;
374
}
375
return false;
376
});
377
378
if (!existingParentCreateChange && !existingSiteParent) {
379
// Create a new parent page
380
381
const existingAncestor = findPageInExistingSite(ancestor ?? "");
382
383
spaceChangeList = [
384
...spaceChangeList,
385
buildContentCreate(
386
capitalizeFirstLetter(parentFileName),
387
space,
388
{
389
storage: {
390
value: "",
391
representation: "storage",
392
},
393
},
394
fileName,
395
existingAncestor ? existingAncestor.id : ancestor,
396
ContentStatusEnum.current,
397
),
398
];
399
}
400
});
401
402
return existingSiteParent;
403
};
404
405
const existingParent: SitePage | null = checkCreateParents();
406
407
pageParent = existingParent ? existingParent.id : pageParent;
408
409
if (existingPage) {
410
spaceChangeList = [
411
buildContentUpdate(
412
existingPage.id,
413
fileMetadata.title,
414
fileMetadata.contentBody,
415
universalFileName,
416
pageParent,
417
),
418
];
419
} else {
420
spaceChangeList = [
421
...spaceChangeList,
422
buildContentCreate(
423
fileMetadata.title,
424
space,
425
fileMetadata.contentBody,
426
universalFileName,
427
pageParent,
428
ContentStatusEnum.current,
429
),
430
];
431
}
432
433
return [...accumulatedChanges, ...spaceChangeList];
434
};
435
436
const pagesToDelete: SitePage[] = findPagesToDelete(
437
fileMetadataList,
438
existingSite,
439
);
440
441
const deleteChanges: ContentDelete[] = pagesToDelete.map(
442
(toDelete: SitePage) => {
443
return { contentChangeType: ContentChangeType.delete, id: toDelete.id };
444
},
445
);
446
447
let spaceChanges: ConfluenceSpaceChange[] = fileMetadataList.reduce(
448
spaceChangesCallback,
449
deleteChanges,
450
);
451
452
const activeAncestorIds = spaceChanges.reduce(
453
(accumulator: any, change: any) => {
454
if (change?.ancestors?.length) {
455
const idList = change.ancestors.map(
456
(ancestor: ContentAncestor) => ancestor?.id ?? "",
457
);
458
459
return [...accumulator, ...idList];
460
}
461
462
return accumulator;
463
},
464
[],
465
);
466
467
spaceChanges = spaceChanges.filter((change: ConfluenceSpaceChange) => {
468
if (isContentDelete(change) && activeAncestorIds.includes(change.id)) {
469
return false;
470
}
471
return true;
472
});
473
474
return spaceChanges;
475
};
476
477
export const flattenIndexes = (
478
changes: ConfluenceSpaceChange[],
479
metadataByFileName: Record<string, SitePage>,
480
siteParentId: string,
481
): ConfluenceSpaceChange[] => {
482
const getFileNameForChange = (change: ConfluenceSpaceChange) => {
483
if (isContentDelete(change)) {
484
return "";
485
}
486
487
return pathWithForwardSlashes(change?.fileName ?? "");
488
};
489
490
const isIndexFile = (change: ConfluenceSpaceChange) => {
491
return getFileNameForChange(change)?.endsWith("/index.xml");
492
};
493
494
const toIndexPageLookup = (
495
accumulator: Record<string, ConfluenceSpaceChange>,
496
change: ConfluenceSpaceChange,
497
): Record<string, any> => {
498
if (isContentDelete(change)) {
499
return accumulator;
500
}
501
const fileName = getFileNameForChange(change);
502
const isIndex = isIndexFile(change);
503
504
if (isIndex) {
505
const folderFileName = fileName.replace("/index.xml", "");
506
return {
507
...accumulator,
508
[folderFileName]: change,
509
};
510
}
511
512
return accumulator;
513
};
514
515
const indexLookup: Record<string, ConfluenceSpaceChange> = changes.reduce(
516
toIndexPageLookup,
517
{},
518
);
519
520
const toFlattenedIndexes = (
521
accumulator: ConfluenceSpaceChange[],
522
change: ConfluenceSpaceChange,
523
): ConfluenceSpaceChange[] => {
524
if (isContentDelete(change)) {
525
return [...accumulator, change];
526
}
527
528
const fileName = getFileNameForChange(change);
529
530
if (fileName === "index.xml") {
531
const rootUpdate = buildContentUpdate(
532
siteParentId,
533
change.title,
534
change.body,
535
"index.xml",
536
);
537
return [...accumulator, rootUpdate];
538
}
539
540
const parentFileName = fileName.replace("/index.xml", "");
541
const parentSitePage: SitePage = metadataByFileName[parentFileName];
542
if (isIndexFile(change)) {
543
if (parentSitePage) {
544
// The parent has already been created, this index create
545
// is actually an index parent update update the folder with
546
// index contents
547
const parentUpdate = buildContentUpdate(
548
parentSitePage.id,
549
change.title,
550
change.body,
551
parentSitePage.metadata.fileName ?? "",
552
"",
553
ContentStatusEnum.current,
554
PAGE_TYPE,
555
null,
556
parentSitePage.ancestors,
557
);
558
559
return [...accumulator, parentUpdate];
560
} else {
561
return [...accumulator]; //filter out index file creates
562
}
563
}
564
565
const indexCreateChange: ConfluenceSpaceChange = indexLookup[fileName];
566
567
if (indexCreateChange && !isContentDelete(indexCreateChange)) {
568
change.title = indexCreateChange.title ?? change.title;
569
change.body = indexCreateChange.body ?? change.body;
570
}
571
572
return [...accumulator, change];
573
};
574
575
const flattenedIndexes = changes.reduce(toFlattenedIndexes, []);
576
577
return flattenedIndexes;
578
};
579
580
export const replaceExtension = (
581
fileName: string,
582
oldExtension: string,
583
newExtension: string,
584
) => {
585
return fileName.replace(oldExtension, newExtension);
586
};
587
588
export const getTitle = (
589
fileName: string,
590
metadataByInput: Record<string, InputMetadata>,
591
): string => {
592
const qmdFileName = replaceExtension(fileName, ".xml", ".qmd");
593
594
const metadataTitle = metadataByInput[qmdFileName]?.title;
595
596
const titleFromFilename = capitalizeWord(fileName.split(".")[0] ?? fileName);
597
const title = metadataTitle ?? titleFromFilename;
598
return title;
599
};
600
601
const flattenMetadata = (list: ContentProperty[] = []): Record<string, any> => {
602
const result: Record<string, any> = list.reduce(
603
(accumulator: any, currentValue: ContentProperty) => {
604
const updated: any = accumulator;
605
updated[currentValue.key] = currentValue.value;
606
return updated;
607
},
608
{},
609
);
610
611
return result;
612
};
613
614
export const mergeSitePages = (
615
shallowPages: ContentSummary[] = [],
616
contentProperties: ContentProperty[][] = [],
617
): SitePage[] => {
618
const result: SitePage[] = shallowPages.map(
619
(contentSummary: ContentSummary, index) => {
620
const sitePage: SitePage = {
621
title: contentSummary.title,
622
id: contentSummary.id ?? "",
623
metadata: flattenMetadata(contentProperties[index]),
624
ancestors: contentSummary.ancestors ?? [],
625
};
626
return sitePage;
627
},
628
);
629
return result;
630
};
631
632
export const buildFileToMetaTable = (
633
fileMetadata: SitePage[],
634
): Record<string, SitePage> => {
635
return fileMetadata.reduce(
636
(accumulator: Record<string, SitePage>, page: SitePage) => {
637
const fileName: string = page?.metadata?.fileName ?? "";
638
const fileNameQMD = fileName.replace("xml", "qmd");
639
if (fileName.length === 0) {
640
return accumulator;
641
}
642
643
accumulator[fileNameQMD] = page;
644
return accumulator;
645
},
646
{},
647
);
648
};
649
650
export const updateLinks = (
651
fileMetadataTable: Record<string, SitePage>,
652
spaceChanges: ConfluenceSpaceChange[],
653
server: string,
654
parent: ConfluenceParent,
655
): {
656
pass1Changes: ConfluenceSpaceChange[];
657
pass2Changes: ConfluenceSpaceChange[];
658
} => {
659
const root = `${server}`;
660
const url = `${
661
ensureTrailingSlash(server)
662
}wiki/spaces/${parent.space}/pages/`;
663
664
let collectedPass2Changes: ConfluenceSpaceChange[] = [];
665
666
const changeMapper = (
667
changeToProcess: ConfluenceSpaceChange,
668
): ConfluenceSpaceChange => {
669
const replacer = (match: string): string => {
670
let documentFileName = "";
671
if (
672
isContentUpdate(changeToProcess) ||
673
isContentCreate(changeToProcess)
674
) {
675
documentFileName = changeToProcess.fileName ?? "";
676
}
677
678
const docFileNamePathList = documentFileName.split("/");
679
680
let updated: string = match;
681
const linkFileNameMatch = FILE_FINDER.exec(match);
682
683
const linkFileName = linkFileNameMatch ? linkFileNameMatch[0] ?? "" : "";
684
685
const fileNamePathList = linkFileName.split("/");
686
687
const linkFullFileName = `${linkFileName}.qmd`;
688
689
let siteFilePath = linkFullFileName;
690
const isAbsolute = siteFilePath.startsWith("/");
691
if (!isAbsolute && docFileNamePathList.length > 1) {
692
const relativePath = docFileNamePathList
693
.slice(0, docFileNamePathList.length - 1)
694
.join("/");
695
696
if (siteFilePath.startsWith("./")) {
697
siteFilePath = siteFilePath.replace("./", `${relativePath}/`);
698
} else {
699
siteFilePath = `${relativePath}/${linkFullFileName}`;
700
}
701
}
702
703
if (isAbsolute) {
704
siteFilePath = siteFilePath.slice(1); //remove '/'
705
}
706
707
if (siteFilePath.endsWith("/index.qmd")) {
708
//flatten child index links to the parent
709
const siteFilePathParent = siteFilePath.replace("/index.qmd", "");
710
if (fileMetadataTable[siteFilePathParent]) {
711
siteFilePath = siteFilePathParent;
712
}
713
}
714
715
if (!documentFileName.endsWith(".xml")) {
716
//this is a flattened index in a folder with contents
717
const siteFilePathParent = `${documentFileName}/${linkFullFileName}`;
718
if (fileMetadataTable[siteFilePathParent]) {
719
siteFilePath = siteFilePathParent;
720
}
721
}
722
723
const sitePage: SitePage | null = fileMetadataTable[siteFilePath] ?? null;
724
725
if (sitePage) {
726
updated = match.replace('href="', `href="${url}`);
727
const pagePath: string = `${url}${sitePage.id}`;
728
updated = updated.replace(linkFullFileName, pagePath);
729
} else {
730
if (!collectedPass2Changes.includes(changeToProcess)) {
731
collectedPass2Changes = [...collectedPass2Changes, changeToProcess];
732
}
733
}
734
735
return updated;
736
};
737
738
if (isContentUpdate(changeToProcess) || isContentCreate(changeToProcess)) {
739
const valueToProcess = changeToProcess?.body?.storage?.value;
740
741
if (valueToProcess) {
742
const replacedLinks: string = valueToProcess.replaceAll(
743
LINK_FINDER,
744
replacer,
745
);
746
747
changeToProcess.body.storage.value = replacedLinks;
748
}
749
}
750
return changeToProcess;
751
};
752
753
const updatedChanges: ConfluenceSpaceChange[] = spaceChanges.map(
754
changeMapper,
755
);
756
757
return { pass1Changes: updatedChanges, pass2Changes: collectedPass2Changes };
758
};
759
760
export const convertForSecondPass = (
761
fileMetadataTable: Record<string, SitePage>,
762
spaceChanges: ConfluenceSpaceChange[],
763
server: string,
764
parent: ConfluenceParent,
765
): ConfluenceSpaceChange[] => {
766
const toUpdatesReducer = (
767
accumulator: ConfluenceSpaceChange[],
768
change: ConfluenceSpaceChange,
769
) => {
770
if (isContentUpdate(change)) {
771
accumulator = [...accumulator, change];
772
}
773
774
if (isContentCreate(change)) {
775
const qmdFileName = replaceExtension(
776
change.fileName ?? "",
777
".xml",
778
".qmd",
779
);
780
const updateId = fileMetadataTable[qmdFileName]?.id;
781
782
if (updateId) {
783
const convertedUpdate = buildContentUpdate(
784
updateId,
785
change.title,
786
change.body,
787
change.fileName ?? "",
788
"",
789
ContentStatusEnum.current,
790
PAGE_TYPE,
791
null,
792
change.ancestors,
793
);
794
accumulator = [...accumulator, convertedUpdate];
795
} else {
796
trace("update ID not found for", change.fileName);
797
}
798
}
799
800
return accumulator;
801
};
802
803
const changesAsUpdates = spaceChanges.reduce(toUpdatesReducer, []);
804
805
const updateLinkResults = updateLinks(
806
fileMetadataTable,
807
changesAsUpdates,
808
server,
809
parent,
810
);
811
812
return updateLinkResults.pass1Changes;
813
};
814
815
export const updateImagePaths = (body: ContentBody): ContentBody => {
816
const replacer = (match: string): string => {
817
let updated: string = match.replace(/^.*[\\\/]/, "");
818
return updated;
819
};
820
821
const bodyValue: string = body?.storage?.value;
822
if (!bodyValue) {
823
return body;
824
}
825
826
const attachments = findAttachments(bodyValue);
827
828
const withReplacedImages: string = bodyValue.replaceAll(
829
ATTACHMENT_FINDER,
830
replacer,
831
);
832
833
body.storage.value = withReplacedImages;
834
return body;
835
};
836
837
export const findAttachments = (
838
bodyValue: string,
839
publishFiles: string[] = [],
840
filePathParam: string = "",
841
): string[] => {
842
const filePath = pathWithForwardSlashes(filePathParam);
843
844
const pathList = filePath.split("/");
845
const parentPath = pathList.slice(0, pathList.length - 1).join("/");
846
847
const imageFinderMatches: RegExpMatchArray | null = bodyValue.match(
848
ATTACHMENT_FINDER,
849
);
850
let uniqueFoundImages: string[] = [...new Set(imageFinderMatches)];
851
852
if (publishFiles.length > 0) {
853
uniqueFoundImages = uniqueFoundImages.map((assetFileName: string) => {
854
const assetInPublishFiles = publishFiles.find((assetPathParam) => {
855
const assetPath = pathWithForwardSlashes(assetPathParam);
856
857
const toCheck = pathWithForwardSlashes(join(parentPath, assetFileName));
858
859
return assetPath === toCheck;
860
});
861
862
return assetInPublishFiles ?? assetFileName;
863
});
864
}
865
866
return uniqueFoundImages ?? [];
867
};
868
869
const buildConfluenceAnchor = (id: string) =>
870
`<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>`;
871
872
export const footnoteTransform = (bodyValue: string): string => {
873
const BACK_ANCHOR_FINDER: RegExp = /<a href="#fn(\d)"/g;
874
const ANCHOR_FINDER: RegExp = /<a href="#fnref(\d)"/g;
875
const CONFLUENCE_ANCHOR_FINDER: RegExp =
876
/ac:macro-id="d2cb5be1217ae6e086bc60005e9d27b7"><ac:parameter ac:name="">fn/g;
877
878
if (bodyValue.search(CONFLUENCE_ANCHOR_FINDER) !== -1) {
879
//the footnote transform has already happened
880
return bodyValue;
881
}
882
883
const replacer = (prefix: string) => (match: string, p1: string): string =>
884
`${buildConfluenceAnchor(`${prefix}${p1}`)}${match}`;
885
886
let replacedBody: string = bodyValue.replaceAll(
887
BACK_ANCHOR_FINDER,
888
replacer("fnref"),
889
);
890
891
replacedBody = replacedBody.replaceAll(ANCHOR_FINDER, replacer("fn"));
892
893
return replacedBody;
894
};
895
896