codoc-cli 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/assets/drawio-viewer/README.md +13 -0
- package/assets/drawio-viewer/viewer-static.min.js +8640 -0
- package/bin/dev.cmd +3 -0
- package/bin/dev.js +5 -0
- package/bin/run.cmd +3 -0
- package/bin/run.js +7 -0
- package/dist/clients/confluence/clients/attachments-client.js +74 -0
- package/dist/clients/confluence/clients/folders-client.js +111 -0
- package/dist/clients/confluence/clients/pages-client.js +128 -0
- package/dist/clients/confluence/confluence-client.js +44 -0
- package/dist/clients/confluence/utils/confluence-url.js +25 -0
- package/dist/clients/confluence/utils/confluence-util.js +20 -0
- package/dist/commands/init.js +29 -0
- package/dist/commands/publish.js +34 -0
- package/dist/commands/pull.js +54 -0
- package/dist/commands/sync.js +27 -0
- package/dist/commands/tree.js +17 -0
- package/dist/config/codoc-config-atlassian.js +89 -0
- package/dist/config/codoc-config-raw.js +21 -0
- package/dist/config/codoc-config.js +50 -0
- package/dist/config/codoc-paths.js +9 -0
- package/dist/hooks/command_not_found.js +7 -0
- package/dist/hooks/init/check-for-update.js +68 -0
- package/dist/hooks/init/load-env.js +15 -0
- package/dist/index.js +2 -0
- package/dist/services/codoc-id.js +7 -0
- package/dist/services/confluence/attachments.js +56 -0
- package/dist/services/confluence/folders.js +42 -0
- package/dist/services/confluence/labels.js +17 -0
- package/dist/services/confluence/pages.js +28 -0
- package/dist/services/conversion/confluenceToMarkdown/diagrams/drawio-diagrams.js +77 -0
- package/dist/services/conversion/confluenceToMarkdown/diagrams/drawio-to-image.js +131 -0
- package/dist/services/conversion/confluenceToMarkdown/index.js +17 -0
- package/dist/services/conversion/confluenceToMarkdown/preprocess/macro-converters.js +68 -0
- package/dist/services/conversion/confluenceToMarkdown/preprocess/preprocess.js +113 -0
- package/dist/services/conversion/confluenceToMarkdown/turndown.js +71 -0
- package/dist/services/conversion/markdownToConfluence/conversion-state.js +18 -0
- package/dist/services/conversion/markdownToConfluence/diagrams/mermaid-diagrams.js +56 -0
- package/dist/services/conversion/markdownToConfluence/diagrams/mermaid-to-drawio.js +331 -0
- package/dist/services/conversion/markdownToConfluence/index.js +48 -0
- package/dist/services/conversion/markdownToConfluence/languages.js +66 -0
- package/dist/services/conversion/markdownToConfluence/links.js +40 -0
- package/dist/services/conversion/markdownToConfluence/raw-html/raw-html.js +86 -0
- package/dist/services/conversion/markdownToConfluence/raw-html/task-lists.js +59 -0
- package/dist/services/conversion/markdownToConfluence/render/blocks.js +171 -0
- package/dist/services/conversion/markdownToConfluence/render/inline.js +44 -0
- package/dist/services/conversion/markdownToConfluence/render/page.js +31 -0
- package/dist/services/conversion/shared/confluence-macro-builder.js +11 -0
- package/dist/services/conversion/shared/gitlab-url.js +16 -0
- package/dist/services/conversion/shared/image-attachments.js +57 -0
- package/dist/services/conversion/shared/jira.js +7 -0
- package/dist/services/conversion/shared/macro-types.js +12 -0
- package/dist/services/conversion/shared/preserved-macros.js +8 -0
- package/dist/services/conversion/shared/read-storage-format.js +29 -0
- package/dist/services/conversion/shared/regex-cache.js +14 -0
- package/dist/services/conversion/shared/xml-escaping.js +19 -0
- package/dist/services/ensure-env.js +59 -0
- package/dist/services/files-service.js +74 -0
- package/dist/services/git/default-branch.js +33 -0
- package/dist/services/init-templates/agent-md-init.js +55 -0
- package/dist/services/init-templates/doc-guide-init.js +131 -0
- package/dist/services/init-templates/env-init.js +12 -0
- package/dist/services/init-templates/yaml-init.js +56 -0
- package/dist/services/lock/lock-file.js +61 -0
- package/dist/services/log/init-printer.js +50 -0
- package/dist/services/log/logger.js +131 -0
- package/dist/services/prompt.js +32 -0
- package/dist/services/slugify.js +6 -0
- package/dist/types/codoc-types.js +1 -0
- package/dist/use-cases/init/init.js +150 -0
- package/dist/use-cases/publish/publish.js +166 -0
- package/dist/use-cases/pull/parse-page-input.js +22 -0
- package/dist/use-cases/pull/pull.js +337 -0
- package/dist/use-cases/shared/codoc-yaml.js +45 -0
- package/dist/use-cases/shared/confluence-client-registry.js +27 -0
- package/dist/use-cases/shared/env-select.js +23 -0
- package/dist/use-cases/shared/fetch-page.js +11 -0
- package/dist/use-cases/shared/list-documents.js +172 -0
- package/dist/use-cases/shared/pull-folder.js +90 -0
- package/dist/use-cases/shared/render-remote-page.js +71 -0
- package/dist/use-cases/sync/attachment-upload.js +31 -0
- package/dist/use-cases/sync/sync-actions.js +170 -0
- package/dist/use-cases/sync/sync-associate.js +41 -0
- package/dist/use-cases/sync/sync-entries.js +36 -0
- package/dist/use-cases/sync/sync-handlers.js +140 -0
- package/dist/use-cases/sync/sync-helpers.js +8 -0
- package/dist/use-cases/sync/sync.js +94 -0
- package/dist/use-cases/tree/docs-tree.js +141 -0
- package/oclif.manifest.json +255 -0
- package/package.json +98 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { toPosixPath } from "../../services/files-service.js";
|
|
2
|
+
import { slugify } from "../../services/slugify.js";
|
|
3
|
+
import { pageUrl } from "../../clients/confluence/utils/confluence-url.js";
|
|
4
|
+
import { renderRemotePageToLocal } from "./render-remote-page.js";
|
|
5
|
+
import path from "path";
|
|
6
|
+
//#region src/use-cases/shared/pull-folder.ts
|
|
7
|
+
async function fetchFolderDescendants(ConfluenceClient, parentId, parentType) {
|
|
8
|
+
const children = parentType === "folder" ? await ConfluenceClient.folders.children(parentId) : [...(await ConfluenceClient.pages.fetchChildren(parentId)).map((c) => ({
|
|
9
|
+
...c,
|
|
10
|
+
type: "page"
|
|
11
|
+
})), ...(await ConfluenceClient.folders.childrenFolders(parentId)).map((c) => ({
|
|
12
|
+
...c,
|
|
13
|
+
type: "folder"
|
|
14
|
+
}))];
|
|
15
|
+
const nested = await Promise.all(children.map((child) => fetchFolderDescendants(ConfluenceClient, child.id, child.type === "folder" ? "folder" : "page")));
|
|
16
|
+
return [...children.map((child) => ({
|
|
17
|
+
...child,
|
|
18
|
+
parentId
|
|
19
|
+
})), ...nested.flat()];
|
|
20
|
+
}
|
|
21
|
+
function folderLocalPaths(descendants, rootId, localFolder) {
|
|
22
|
+
const slugById = new Map(descendants.map((d) => [d.id, slugify(d.title)]));
|
|
23
|
+
const parentById = new Map(descendants.map((d) => [d.id, d.parentId]));
|
|
24
|
+
const hasChildren = new Set(descendants.map((d) => d.parentId).filter((id) => id !== rootId));
|
|
25
|
+
const folderIds = new Set(descendants.filter((d) => d.type === "folder").map((d) => d.id));
|
|
26
|
+
const result = /* @__PURE__ */ new Map();
|
|
27
|
+
for (const d of descendants) {
|
|
28
|
+
const parts = [];
|
|
29
|
+
let cur = d.id;
|
|
30
|
+
while (cur !== rootId) {
|
|
31
|
+
const slug = slugById.get(cur);
|
|
32
|
+
if (cur === d.id) {
|
|
33
|
+
const isDir = hasChildren.has(cur) || folderIds.has(cur);
|
|
34
|
+
parts.unshift(isDir ? `${slug}/index.md` : `${slug}.md`);
|
|
35
|
+
} else parts.unshift(slug);
|
|
36
|
+
cur = parentById.get(cur);
|
|
37
|
+
}
|
|
38
|
+
result.set(d.id, `${localFolder}${parts.join("/")}`);
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
function pulledPagesToChildren(pages, baseUrl, spaceKey) {
|
|
43
|
+
return pages.map((p) => ({
|
|
44
|
+
title: p.title,
|
|
45
|
+
confluencePageId: p.id,
|
|
46
|
+
sourceFile: p.localPath,
|
|
47
|
+
confluenceUrl: pageUrl(baseUrl, spaceKey, p.id)
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
async function pullFolderToLocal(params) {
|
|
51
|
+
const { confluenceClient, rootId, imagesDir, onPage, onPageError } = params;
|
|
52
|
+
const localFolder = toPosixPath(params.localDir).replace(/\/?$/, "/");
|
|
53
|
+
const descendants = await fetchFolderDescendants(confluenceClient, rootId, params.rootType ?? "folder");
|
|
54
|
+
const pages = descendants.filter((d) => d.type !== "folder");
|
|
55
|
+
const localPaths = folderLocalPaths(descendants, rootId, localFolder);
|
|
56
|
+
const prevPathById = new Map((params.prevChildren ?? []).map((c) => [c.confluencePageId, c.sourceFile]));
|
|
57
|
+
const folderBase = localFolder.replace(/\/$/, "");
|
|
58
|
+
const written = [];
|
|
59
|
+
for (let i = 0; i < pages.length; i++) {
|
|
60
|
+
const { id, title } = pages[i];
|
|
61
|
+
const localPath = prevPathById.get(id) ?? localPaths.get(id);
|
|
62
|
+
try {
|
|
63
|
+
const page = await confluenceClient.pages.fetchPage(id);
|
|
64
|
+
const pageImagesDir = imagesDir ? imagesDirForPage(imagesDir, folderBase, localPath) : void 0;
|
|
65
|
+
await renderRemotePageToLocal(confluenceClient, page, localPath, pageImagesDir);
|
|
66
|
+
const pulled = {
|
|
67
|
+
id,
|
|
68
|
+
title,
|
|
69
|
+
localPath
|
|
70
|
+
};
|
|
71
|
+
written.push(pulled);
|
|
72
|
+
onPage?.(pulled, i + 1, pages.length);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (!onPageError) throw err;
|
|
75
|
+
onPageError({
|
|
76
|
+
id,
|
|
77
|
+
title
|
|
78
|
+
}, err, i + 1, pages.length);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return written;
|
|
82
|
+
}
|
|
83
|
+
function imagesDirForPage(imagesDir, folderBase, localPath) {
|
|
84
|
+
const pageDir = path.posix.dirname(toPosixPath(localPath));
|
|
85
|
+
const base = toPosixPath(folderBase);
|
|
86
|
+
const sub = path.posix.relative(base, pageDir);
|
|
87
|
+
return sub && sub !== "." ? `${imagesDir.replace(/\/$/, "")}/${sub}` : imagesDir;
|
|
88
|
+
}
|
|
89
|
+
//#endregion
|
|
90
|
+
export { fetchFolderDescendants, folderLocalPaths, pullFolderToLocal, pulledPagesToChildren };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { fileExists, toPosixPath, writeBinaryFile, writeFile } from "../../services/files-service.js";
|
|
2
|
+
import { PROJECT_ROOT } from "../../config/codoc-paths.js";
|
|
3
|
+
import { log } from "../../services/log/logger.js";
|
|
4
|
+
import { isAttachmentDownload } from "../../clients/confluence/utils/confluence-util.js";
|
|
5
|
+
import { fetchAttachmentBinary, fetchAttachmentText } from "../../services/confluence/attachments.js";
|
|
6
|
+
import { embeddedAttachmentFilenames, sanitizeAttachmentFilename } from "../../services/conversion/shared/image-attachments.js";
|
|
7
|
+
import { renderMarkdown } from "../../services/conversion/confluenceToMarkdown/index.js";
|
|
8
|
+
import path from "path";
|
|
9
|
+
//#region src/use-cases/shared/render-remote-page.ts
|
|
10
|
+
function imgRelPathFor(localPath, imagesDir) {
|
|
11
|
+
return imagesDir ? path.posix.relative(path.posix.dirname(toPosixPath(localPath)), toPosixPath(imagesDir)) : void 0;
|
|
12
|
+
}
|
|
13
|
+
/** Format lisible d'une taille en octets (KiB / MiB). */
|
|
14
|
+
function humanBytes(n) {
|
|
15
|
+
if (n < 1024) return `${n} o`;
|
|
16
|
+
if (n < 1048576) return `${(n / 1024).toFixed(1)} Kio`;
|
|
17
|
+
return `${(n / 1048576).toFixed(1)} Mio`;
|
|
18
|
+
}
|
|
19
|
+
async function downloadPageImages(client, page, absImagesDir) {
|
|
20
|
+
const files = embeddedAttachmentFilenames(page.storageXml);
|
|
21
|
+
if (!files.length) return {
|
|
22
|
+
ok: 0,
|
|
23
|
+
failed: 0
|
|
24
|
+
};
|
|
25
|
+
log.info4(`${files.length} pièce(s) jointe(s) à télécharger vers ${absImagesDir}`);
|
|
26
|
+
let ok = 0;
|
|
27
|
+
let skipped = 0;
|
|
28
|
+
let failed = 0;
|
|
29
|
+
for (const filename of files) {
|
|
30
|
+
const localFilename = sanitizeAttachmentFilename(filename);
|
|
31
|
+
const absPath = path.resolve(absImagesDir, localFilename);
|
|
32
|
+
if (fileExists(absPath)) {
|
|
33
|
+
log.info4(`${localFilename} (déjà présente)`);
|
|
34
|
+
skipped++;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const result = await fetchAttachmentBinary(client.attachments, page.id, filename);
|
|
38
|
+
if (isAttachmentDownload(result)) {
|
|
39
|
+
writeBinaryFile(absPath, result.data);
|
|
40
|
+
log.success4(`[PULL] ${localFilename} (${humanBytes(result.bytes)} via ${result.via})`);
|
|
41
|
+
ok++;
|
|
42
|
+
} else if (result.notFound) {
|
|
43
|
+
log.warning4(`${localFilename} → pièce jointe introuvable sur la page`);
|
|
44
|
+
failed++;
|
|
45
|
+
} else {
|
|
46
|
+
log.error4(`${localFilename} → v1: ${result.v1Error ?? "-"} | v2: ${result.v2Error ?? "-"}`);
|
|
47
|
+
failed++;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const parts = [`${ok} téléchargée(s)`];
|
|
51
|
+
if (skipped) parts.push(`${skipped} déjà présente(s)`);
|
|
52
|
+
if (failed) parts.push(`${failed} échec(s)`);
|
|
53
|
+
log.info4(`Total : ${parts.join(", ")}`);
|
|
54
|
+
return {
|
|
55
|
+
ok,
|
|
56
|
+
failed
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
async function renderRemotePageToLocal(client, page, localPath, imagesDir) {
|
|
60
|
+
const absImagesDir = imagesDir ? path.resolve(PROJECT_ROOT, imagesDir) : void 0;
|
|
61
|
+
const markdown = await renderMarkdown(page.storageXml, {
|
|
62
|
+
fetchAttachment: (filename) => fetchAttachmentText(client.attachments, page.id, filename),
|
|
63
|
+
imagesDir: absImagesDir,
|
|
64
|
+
imgRelPath: imgRelPathFor(localPath, imagesDir),
|
|
65
|
+
baseUrl: client.configuration.baseUrl
|
|
66
|
+
});
|
|
67
|
+
writeFile(path.resolve(PROJECT_ROOT, localPath), markdown);
|
|
68
|
+
if (absImagesDir) await downloadPageImages(client, page, absImagesDir);
|
|
69
|
+
}
|
|
70
|
+
//#endregion
|
|
71
|
+
export { renderRemotePageToLocal };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { fileExists } from "../../services/files-service.js";
|
|
2
|
+
import { PROJECT_ROOT } from "../../config/codoc-paths.js";
|
|
3
|
+
import { log } from "../../services/log/logger.js";
|
|
4
|
+
import { uploadAttachmentFile } from "../../services/confluence/attachments.js";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
//#region src/use-cases/sync/attachment-upload.ts
|
|
7
|
+
/** Filenames `<ri:filename="…"/>` référencés dans un XML Confluence Storage Format. */
|
|
8
|
+
function extractAttachmentFilenames(xml) {
|
|
9
|
+
return [...new Set([...xml.matchAll(/ri:filename="([^"]+)"/g)].map((m) => m[1]))];
|
|
10
|
+
}
|
|
11
|
+
/** Upload chaque image référencée dans `xml` depuis `<imagesDir>/` ; warn si le fichier local est absent. */
|
|
12
|
+
async function uploadImagesFromXml(client, pageId, xml, imagesDir) {
|
|
13
|
+
const absDir = path.resolve(PROJECT_ROOT, imagesDir);
|
|
14
|
+
for (const filename of extractAttachmentFilenames(xml)) {
|
|
15
|
+
const filePath = path.join(absDir, filename);
|
|
16
|
+
if (fileExists(filePath)) {
|
|
17
|
+
await uploadAttachmentFile(client.attachments, pageId, filePath);
|
|
18
|
+
log.success4(`[CREATED] Pièce jointe : ${filename}`);
|
|
19
|
+
} else log.warning4(`Image introuvable : ${filePath}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Téléverse en une passe les images du XML (si imagesDir) et les diagrammes en mémoire (attachments). */
|
|
23
|
+
async function uploadPageAttachments(client, pageId, xml, imagesDir, attachments) {
|
|
24
|
+
if (imagesDir) await uploadImagesFromXml(client, pageId, xml, imagesDir);
|
|
25
|
+
for (const { filename, content } of attachments) {
|
|
26
|
+
await client.attachments.upload(pageId, filename, new Blob([content]));
|
|
27
|
+
log.success4(`[CREATED] Pièce jointe : ${filename}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
//#endregion
|
|
31
|
+
export { uploadPageAttachments };
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { log } from "../../services/log/logger.js";
|
|
2
|
+
import { makePageState } from "../../services/lock/lock-file.js";
|
|
3
|
+
import { pageUrl } from "../../clients/confluence/utils/confluence-url.js";
|
|
4
|
+
import { createOrUpdatePage } from "../../services/confluence/pages.js";
|
|
5
|
+
import { createOrEnsureFolder } from "../../services/confluence/folders.js";
|
|
6
|
+
import { renderRemotePageToLocal } from "../shared/render-remote-page.js";
|
|
7
|
+
import { renderConfluencePage } from "../../services/conversion/markdownToConfluence/index.js";
|
|
8
|
+
import { fetchPageOrUndefined } from "../shared/fetch-page.js";
|
|
9
|
+
import { uploadPageAttachments } from "./attachment-upload.js";
|
|
10
|
+
import { parentNum, urlOf } from "./sync-helpers.js";
|
|
11
|
+
import { pushKeywordLabels } from "../../services/confluence/labels.js";
|
|
12
|
+
import path from "path";
|
|
13
|
+
//#region src/use-cases/sync/sync-actions.ts
|
|
14
|
+
async function pageMeta(client, pageId) {
|
|
15
|
+
const p = await fetchPageOrUndefined(client, pageId);
|
|
16
|
+
return p ? {
|
|
17
|
+
title: p.title,
|
|
18
|
+
parentId: p.parentId
|
|
19
|
+
} : void 0;
|
|
20
|
+
}
|
|
21
|
+
function renderTarget(ctx, t) {
|
|
22
|
+
const { jira, drawio } = t.env;
|
|
23
|
+
return renderConfluencePage(t.markdown, {
|
|
24
|
+
diagramBaseName: path.basename(t.sourceFile, ".md"),
|
|
25
|
+
drawio,
|
|
26
|
+
sourceFile: t.sourceFile,
|
|
27
|
+
jira: jira.serverId || jira.server ? jira : void 0,
|
|
28
|
+
gitlab: {
|
|
29
|
+
baseUrl: ctx.config.gitlab.baseUrl,
|
|
30
|
+
branch: ctx.defaultBranch
|
|
31
|
+
},
|
|
32
|
+
generatedAt: ctx.generatedAt,
|
|
33
|
+
generateSummary: t.generateSummary
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
async function publishPage(confirm, client, t, xml, attachments, parentId, existingId) {
|
|
37
|
+
const onTitleConflict = (_existingPageId, existingUrl) => confirm(`⚠️ Une page "${t.title}" existe déjà (hors de l'arborescence suivie dans codoc.lock) : ${existingUrl}\n L'adopter et écraser son contenu par celui généré localement ?`);
|
|
38
|
+
const published = await createOrUpdatePage(client, t.title, xml, parentId, existingId, onTitleConflict);
|
|
39
|
+
await uploadPageAttachments(client, published.id, xml, t.imagesDir, attachments);
|
|
40
|
+
await pushKeywordLabels(client, published.id, t.keywords);
|
|
41
|
+
return published;
|
|
42
|
+
}
|
|
43
|
+
async function assertParentExists(client, entry) {
|
|
44
|
+
const pid = entry.parentPageId;
|
|
45
|
+
if (!pid) return;
|
|
46
|
+
if (await pageMeta(client, pid) != void 0) return;
|
|
47
|
+
if (await client.folders.exists(pid)) return;
|
|
48
|
+
throw new Error(`"${entry.path}" : parentPageId ${pid} introuvable sur l'environnement "${entry.env.key}". Revois la config.`);
|
|
49
|
+
}
|
|
50
|
+
function confluenceState(entry, env, pageId, title, now) {
|
|
51
|
+
return makePageState({
|
|
52
|
+
pageId,
|
|
53
|
+
environment: env.key,
|
|
54
|
+
title,
|
|
55
|
+
url: pageUrl(env.baseUrl, env.spaceKey, pageId),
|
|
56
|
+
sourceFile: entry.relPath,
|
|
57
|
+
maintainedIn: "confluence",
|
|
58
|
+
publishedAt: now
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
async function deleteConfluenceItem(client, id, isFolder) {
|
|
62
|
+
try {
|
|
63
|
+
if (isFolder) await client.folders.delete(id);
|
|
64
|
+
else await client.pages.delete(id);
|
|
65
|
+
return true;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async function deleteRemote(client, lock) {
|
|
71
|
+
for (const c of lock.children ?? []) await deleteConfluenceItem(client, c.confluencePageId, c.isFolder);
|
|
72
|
+
if (lock.confluencePageId) await deleteConfluenceItem(client, lock.confluencePageId, lock.isFolder);
|
|
73
|
+
}
|
|
74
|
+
async function publishMulti(ctx, env, entry, targets, prevChildren) {
|
|
75
|
+
const client = await ctx.registry.connect(env);
|
|
76
|
+
const prevBySource = new Map((prevChildren ?? []).map((c) => [c.sourceFile, c]));
|
|
77
|
+
const resolvedFolderIds = /* @__PURE__ */ new Map();
|
|
78
|
+
const children = [];
|
|
79
|
+
for (const folder of targets.filter((t) => t.isFolder)) {
|
|
80
|
+
const parentId = folder.parentFolderKey ? Number(resolvedFolderIds.get(folder.parentFolderKey) ?? folder.parentPageId) : parentNum(folder.parentPageId);
|
|
81
|
+
const existingId = prevBySource.get(folder.sourceFile)?.confluencePageId;
|
|
82
|
+
log.itemFolder({
|
|
83
|
+
env: env.key,
|
|
84
|
+
title: folder.title
|
|
85
|
+
});
|
|
86
|
+
const { id } = await createOrEnsureFolder(client, folder.title, parentId, existingId);
|
|
87
|
+
resolvedFolderIds.set(`${env.key}:${folder.id}`, id);
|
|
88
|
+
children.push({
|
|
89
|
+
title: folder.title,
|
|
90
|
+
confluencePageId: id,
|
|
91
|
+
sourceFile: folder.sourceFile,
|
|
92
|
+
isFolder: true,
|
|
93
|
+
confluenceUrl: pageUrl(env.baseUrl, env.spaceKey, id)
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
for (const file of targets.filter((t) => !t.isFolder)) {
|
|
97
|
+
const parentId = file.parentFolderKey ? Number(resolvedFolderIds.get(file.parentFolderKey) ?? file.parentPageId) : parentNum(file.parentPageId);
|
|
98
|
+
const { xml, attachments } = renderTarget(ctx, file);
|
|
99
|
+
const existingId = prevBySource.get(file.sourceFile)?.confluencePageId;
|
|
100
|
+
log.itemPublished({
|
|
101
|
+
env: env.key,
|
|
102
|
+
isUpdate: Boolean(existingId),
|
|
103
|
+
title: file.title
|
|
104
|
+
});
|
|
105
|
+
const published = await publishPage(ctx.confirm, client, file, xml, attachments, parentId, existingId);
|
|
106
|
+
children.push({
|
|
107
|
+
title: file.title,
|
|
108
|
+
confluencePageId: published.id,
|
|
109
|
+
sourceFile: file.sourceFile,
|
|
110
|
+
confluenceUrl: pageUrl(env.baseUrl, env.spaceKey, published.id)
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const currentSources = new Set(targets.map((t) => t.sourceFile));
|
|
114
|
+
for (const c of prevChildren ?? []) {
|
|
115
|
+
if (currentSources.has(c.sourceFile)) continue;
|
|
116
|
+
if (await deleteConfluenceItem(client, c.confluencePageId, c.isFolder)) log.itemDeleted({
|
|
117
|
+
env: env.key,
|
|
118
|
+
title: `retirée : ${c.title}`
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const rootFolder = targets.find((t) => t.isFolder && !t.parentFolderKey);
|
|
122
|
+
return {
|
|
123
|
+
confluencePageId: rootFolder ? resolvedFolderIds.get(`${env.key}:${rootFolder.id}`) : "",
|
|
124
|
+
environment: env.key,
|
|
125
|
+
title: entry.title ?? entry.path,
|
|
126
|
+
sourceFile: entry.relPath,
|
|
127
|
+
publishedAt: ctx.now,
|
|
128
|
+
maintainedIn: "code",
|
|
129
|
+
...targets.some((t) => t.isFolder) ? { isFolder: true } : {},
|
|
130
|
+
children
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
async function publishCode(ctx, entry, existingId, prevChildren) {
|
|
134
|
+
const env = entry.env;
|
|
135
|
+
const client = await ctx.registry.connect(env);
|
|
136
|
+
const targets = ctx.codeTargetsByCodocId.get(entry.codocId) ?? [];
|
|
137
|
+
if (!targets.length) {
|
|
138
|
+
log.warning1(`[SKIPPED] "${entry.path}" (codocId ${entry.codocId}) : dossier/fichier local vide ou introuvable - rien à publier, ignorée.`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (entry.kind !== "page") {
|
|
142
|
+
await assertParentExists(client, entry);
|
|
143
|
+
return publishMulti(ctx, env, entry, targets, prevChildren);
|
|
144
|
+
}
|
|
145
|
+
const t = targets[0];
|
|
146
|
+
const { xml, attachments } = renderTarget(ctx, t);
|
|
147
|
+
const remote = existingId ? await pageMeta(client, existingId) != void 0 : false;
|
|
148
|
+
log.itemPublished({
|
|
149
|
+
env: env.key,
|
|
150
|
+
isUpdate: remote,
|
|
151
|
+
title: t.title
|
|
152
|
+
});
|
|
153
|
+
const published = await publishPage(ctx.confirm, client, t, xml, attachments, parentNum(t.parentPageId), existingId);
|
|
154
|
+
return makePageState({
|
|
155
|
+
pageId: published.id,
|
|
156
|
+
environment: env.key,
|
|
157
|
+
title: t.title,
|
|
158
|
+
url: urlOf(env, published),
|
|
159
|
+
sourceFile: t.sourceFile,
|
|
160
|
+
maintainedIn: "code",
|
|
161
|
+
publishedAt: ctx.now
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
async function pullPageToLocal(ctx, entry, client, id) {
|
|
165
|
+
const page = await client.pages.fetchPage(id);
|
|
166
|
+
await renderRemotePageToLocal(client, page, entry.relPath, entry.imagesDir);
|
|
167
|
+
return confluenceState(entry, entry.env, id, page.title, ctx.now);
|
|
168
|
+
}
|
|
169
|
+
//#endregion
|
|
170
|
+
export { assertParentExists, confluenceState, deleteRemote, pageMeta, publishCode, pullPageToLocal };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
//#region src/use-cases/sync/sync-associate.ts
|
|
2
|
+
/** Associe entrées yaml et entrées de lock par `codocId` (lock indexé directement par codocId). */
|
|
3
|
+
function associateByCodocId(lockPages, entries) {
|
|
4
|
+
const entryIds = /* @__PURE__ */ new Set();
|
|
5
|
+
const matched = [];
|
|
6
|
+
const yamlNew = [];
|
|
7
|
+
for (const entry of entries) {
|
|
8
|
+
if (!entry.codocId) {
|
|
9
|
+
yamlNew.push(entry);
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
entryIds.add(entry.codocId);
|
|
13
|
+
const lock = lockPages[entry.codocId];
|
|
14
|
+
if (lock) matched.push({
|
|
15
|
+
entry,
|
|
16
|
+
codocId: entry.codocId,
|
|
17
|
+
lock
|
|
18
|
+
});
|
|
19
|
+
else yamlNew.push(entry);
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
matched,
|
|
23
|
+
lockOrphans: Object.entries(lockPages).filter(([codocId]) => !entryIds.has(codocId)).map(([codocId, lock]) => ({
|
|
24
|
+
codocId,
|
|
25
|
+
lock
|
|
26
|
+
})),
|
|
27
|
+
yamlNew
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** Valide AVANT toute mutation que chaque entrée yaml a un codocId (évite une mauvaise association silencieuse). */
|
|
31
|
+
function validateCodocIds(entries) {
|
|
32
|
+
const missing = entries.filter((e) => !e.codocId).map((e) => e.path);
|
|
33
|
+
if (missing.length) throw new Error("codocId manquant dans codoc.yaml pour :\n" + missing.map((p) => ` - ${p}`).join("\n") + "\n→ Ajoute un codocId unique à chaque entrée `docs:`.");
|
|
34
|
+
const seen = /* @__PURE__ */ new Set();
|
|
35
|
+
for (const e of entries) {
|
|
36
|
+
if (seen.has(e.codocId)) throw new Error(`codocId en double dans codoc.yaml : "${e.codocId}".`);
|
|
37
|
+
seen.add(e.codocId);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
//#endregion
|
|
41
|
+
export { associateByCodocId, validateCodocIds };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { resolveDocEnvironmentByKey } from "../../config/codoc-config-atlassian.js";
|
|
2
|
+
import { PROJECT_ROOT } from "../../config/codoc-paths.js";
|
|
3
|
+
import "../../config/codoc-config.js";
|
|
4
|
+
import path from "path";
|
|
5
|
+
//#region src/use-cases/sync/sync-entries.ts
|
|
6
|
+
function entryKind(docPath) {
|
|
7
|
+
if (docPath.endsWith("/**")) return "folder";
|
|
8
|
+
if (docPath.endsWith("/*")) return "glob";
|
|
9
|
+
if (docPath.endsWith("/")) return "folder";
|
|
10
|
+
return "page";
|
|
11
|
+
}
|
|
12
|
+
function cleanDocPath(docPath) {
|
|
13
|
+
return docPath.replace(/\/\*\*$/, "").replace(/\/\*$/, "").replace(/\/$/, "");
|
|
14
|
+
}
|
|
15
|
+
function buildSyncEntries(config) {
|
|
16
|
+
return config.docs.map((doc) => {
|
|
17
|
+
const env = resolveDocEnvironmentByKey(config, doc);
|
|
18
|
+
const relPath = cleanDocPath(doc.path);
|
|
19
|
+
return {
|
|
20
|
+
codocId: doc.codocId,
|
|
21
|
+
kind: entryKind(doc.path),
|
|
22
|
+
path: doc.path,
|
|
23
|
+
localPath: path.resolve(PROJECT_ROOT, relPath),
|
|
24
|
+
relPath,
|
|
25
|
+
maintainedIn: doc.maintainedIn,
|
|
26
|
+
env,
|
|
27
|
+
parentPageId: doc.confluence.parentPageId ?? env.defaultParentPageId,
|
|
28
|
+
title: doc.confluence.title,
|
|
29
|
+
titlePrefix: doc.confluence.titlePrefix,
|
|
30
|
+
titleSuffix: doc.confluence.titleSuffix,
|
|
31
|
+
imagesDir: doc.imagesDir
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
//#endregion
|
|
36
|
+
export { buildSyncEntries, cleanDocPath, entryKind };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { fileExists, removePath } from "../../services/files-service.js";
|
|
2
|
+
import { PROJECT_ROOT } from "../../config/codoc-paths.js";
|
|
3
|
+
import { log } from "../../services/log/logger.js";
|
|
4
|
+
import { getFolderRef } from "../../services/confluence/folders.js";
|
|
5
|
+
import { pullFolderToLocal, pulledPagesToChildren } from "../shared/pull-folder.js";
|
|
6
|
+
import { assertParentExists, confluenceState, deleteRemote, pageMeta, publishCode, pullPageToLocal } from "./sync-actions.js";
|
|
7
|
+
import path from "path";
|
|
8
|
+
//#region src/use-cases/sync/sync-handlers.ts
|
|
9
|
+
/** Passe 1 - entrée de lock sans config. Retourne l'entrée conservée, ou null si retirée. */
|
|
10
|
+
async function handleLockOrphan(ctx, lock) {
|
|
11
|
+
const env = lock.environment ? ctx.envByKey.get(lock.environment) : void 0;
|
|
12
|
+
if (!env) {
|
|
13
|
+
log.warning2(`"${lock.title}" : environnement "${lock.environment ?? "?"}" inconnu - conservée dans le lock.`);
|
|
14
|
+
return lock;
|
|
15
|
+
}
|
|
16
|
+
const client = await ctx.registry.connect(env);
|
|
17
|
+
const absLocal = path.resolve(PROJECT_ROOT, lock.sourceFile);
|
|
18
|
+
if (lock.maintainedIn === "confluence") {
|
|
19
|
+
if (lock.isFolder ? Boolean(await getFolderRef(client, lock.confluencePageId)) : await pageMeta(client, lock.confluencePageId)) {
|
|
20
|
+
removePath(absLocal);
|
|
21
|
+
log.itemCleaned(`"${lock.title}" retirée de la config → doc locale supprimée (${lock.sourceFile}). Page Confluence conservée.`);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
if (await ctx.confirm(`⚠️ "${lock.title}" : page Confluence introuvable ET retirée de la config. Supprimer la doc locale "${lock.sourceFile}" ? (perte définitive possible)`)) {
|
|
25
|
+
removePath(absLocal);
|
|
26
|
+
log.itemCleaned(`Doc locale supprimée : ${lock.sourceFile}`);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
log.itemKept(`Conservée : ${lock.sourceFile}`);
|
|
30
|
+
return lock;
|
|
31
|
+
}
|
|
32
|
+
const warn = fileExists(absLocal) ? "" : " (doc locale absente - perte définitive possible)";
|
|
33
|
+
if (!await ctx.confirm(`[DELETE] "${lock.title}" retirée de la config. Supprimer la page Confluence distante${warn} ?`)) {
|
|
34
|
+
log.itemKept(`Conservée à distance : ${lock.title}`);
|
|
35
|
+
return lock;
|
|
36
|
+
}
|
|
37
|
+
await deleteRemote(client, lock);
|
|
38
|
+
log.itemDeleted({
|
|
39
|
+
env: env.key,
|
|
40
|
+
title: lock.title
|
|
41
|
+
});
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
/** Passe 1 - entrée appariée (codocId présent des deux côtés). On garde le confluencePageId. */
|
|
45
|
+
async function syncMatched(ctx, entry, lock) {
|
|
46
|
+
const env = entry.env;
|
|
47
|
+
const client = await ctx.registry.connect(env);
|
|
48
|
+
const cpid = lock.confluencePageId;
|
|
49
|
+
if (entry.maintainedIn === "code") return await publishCode(ctx, entry, cpid, lock.children) ?? lock;
|
|
50
|
+
if (entry.kind === "page") {
|
|
51
|
+
const meta = await pageMeta(client, cpid);
|
|
52
|
+
if (!meta) throw new Error(`"${entry.title ?? entry.path}" : page Confluence ${cpid} introuvable. Revois la config (codocId).`);
|
|
53
|
+
if (entry.title && meta.title !== entry.title) throw new Error(`"${entry.path}" : titre Confluence ("${meta.title}") ≠ config ("${entry.title}"). Revois la config.`);
|
|
54
|
+
if (entry.parentPageId && meta.parentId && meta.parentId !== entry.parentPageId) throw new Error(`"${entry.path}" : parent Confluence (${meta.parentId}) ≠ config (${entry.parentPageId}). Revois la config.`);
|
|
55
|
+
log.itemPulled({
|
|
56
|
+
env: env.key,
|
|
57
|
+
title: entry.title ?? cpid,
|
|
58
|
+
dest: entry.relPath
|
|
59
|
+
});
|
|
60
|
+
return pullPageToLocal(ctx, entry, client, cpid);
|
|
61
|
+
}
|
|
62
|
+
await assertParentExists(client, entry);
|
|
63
|
+
const folderMeta = await getFolderRef(client, cpid);
|
|
64
|
+
if (!folderMeta) throw new Error(`"${entry.path}" : dossier Confluence ${cpid} introuvable. Revois la config.`);
|
|
65
|
+
log.itemPulled({
|
|
66
|
+
env: env.key,
|
|
67
|
+
title: `dossier "${folderMeta.title}"`,
|
|
68
|
+
dest: entry.relPath
|
|
69
|
+
});
|
|
70
|
+
const pulled = await pullFolderToLocal({
|
|
71
|
+
confluenceClient: client,
|
|
72
|
+
rootId: cpid,
|
|
73
|
+
rootType: folderMeta.type,
|
|
74
|
+
localDir: entry.relPath,
|
|
75
|
+
imagesDir: entry.imagesDir,
|
|
76
|
+
prevChildren: lock.children,
|
|
77
|
+
onPage: (p, i, total) => log.progress(i, total, p.title)
|
|
78
|
+
});
|
|
79
|
+
log.success4(`[PULL] ${pulled.length} page(s) importée(s)`);
|
|
80
|
+
return {
|
|
81
|
+
...confluenceState(entry, env, cpid, folderMeta.title, ctx.now),
|
|
82
|
+
isFolder: true,
|
|
83
|
+
children: pulledPagesToChildren(pulled, env.baseUrl, env.spaceKey)
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** Passe 2 - entrée yaml sans équivalent dans le lock (création / premier pull). Retourne `undefined` si rien à faire (voir `publishCode`). */
|
|
87
|
+
async function syncNew(ctx, entry) {
|
|
88
|
+
const env = entry.env;
|
|
89
|
+
const client = await ctx.registry.connect(env);
|
|
90
|
+
if (entry.maintainedIn === "code") return publishCode(ctx, entry);
|
|
91
|
+
if (entry.kind === "page") {
|
|
92
|
+
if (!entry.title) throw new Error(`"${entry.path}" : titre Confluence requis dans la config pour retrouver la page.`);
|
|
93
|
+
log.itemPulled({
|
|
94
|
+
env: env.key,
|
|
95
|
+
title: entry.title,
|
|
96
|
+
dest: entry.relPath
|
|
97
|
+
});
|
|
98
|
+
const found = await client.pages.findByTitle(entry.title);
|
|
99
|
+
if (!found) throw new Error(`"${entry.title}" : page Confluence introuvable (env ${env.key}). Revois la config ou fais 'codoc pull'.`);
|
|
100
|
+
const id = String(found.id);
|
|
101
|
+
const meta = await pageMeta(client, id);
|
|
102
|
+
if (entry.parentPageId && meta?.parentId && meta.parentId !== entry.parentPageId) throw new Error(`"${entry.title}" : parent Confluence (${meta.parentId}) ≠ config (${entry.parentPageId}). Revois la config.`);
|
|
103
|
+
return pullPageToLocal(ctx, entry, client, id);
|
|
104
|
+
}
|
|
105
|
+
if (!entry.title) throw new Error(`"${entry.path}" : titre Confluence requis dans la config pour retrouver le dossier.`);
|
|
106
|
+
const page = await client.pages.findByTitle(entry.title);
|
|
107
|
+
const found = page ? {
|
|
108
|
+
id: String(page.id),
|
|
109
|
+
type: "page"
|
|
110
|
+
} : entry.parentPageId ? await client.folders.findByTitleUnderParent(entry.parentPageId, entry.title).then((f) => f ? {
|
|
111
|
+
...f,
|
|
112
|
+
type: "folder"
|
|
113
|
+
} : void 0) : void 0;
|
|
114
|
+
if (!found) throw new Error(`"${entry.title}" : dossier Confluence introuvable (env ${env.key}). Revois la config ou fais 'codoc pull'.`);
|
|
115
|
+
if (entry.parentPageId) {
|
|
116
|
+
const parentId = found.type === "page" ? (await pageMeta(client, found.id))?.parentId : (await client.folders.get(found.id))?.parentId;
|
|
117
|
+
if (parentId && parentId !== entry.parentPageId) throw new Error(`"${entry.title}" : parent Confluence (${parentId}) ≠ config (${entry.parentPageId}). Revois la config.`);
|
|
118
|
+
}
|
|
119
|
+
log.itemPulled({
|
|
120
|
+
env: env.key,
|
|
121
|
+
title: `dossier "${entry.title}" (premier import)`,
|
|
122
|
+
dest: entry.relPath
|
|
123
|
+
});
|
|
124
|
+
const pulled = await pullFolderToLocal({
|
|
125
|
+
confluenceClient: client,
|
|
126
|
+
rootId: found.id,
|
|
127
|
+
rootType: found.type,
|
|
128
|
+
localDir: entry.relPath,
|
|
129
|
+
imagesDir: entry.imagesDir,
|
|
130
|
+
onPage: (p, i, total) => log.progress(i, total, p.title)
|
|
131
|
+
});
|
|
132
|
+
log.success4(`[PULL] ${pulled.length} page(s) importée(s) - dossier local créé`);
|
|
133
|
+
return {
|
|
134
|
+
...confluenceState(entry, env, found.id, entry.title, ctx.now),
|
|
135
|
+
isFolder: true,
|
|
136
|
+
children: pulledPagesToChildren(pulled, env.baseUrl, env.spaceKey)
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
//#endregion
|
|
140
|
+
export { handleLockOrphan, syncMatched, syncNew };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { wikiLink } from "../../clients/confluence/utils/confluence-url.js";
|
|
2
|
+
//#region src/use-cases/sync/sync-helpers.ts
|
|
3
|
+
const parentNum = (p) => p ? Number(p) : void 0;
|
|
4
|
+
function urlOf(env, published) {
|
|
5
|
+
return published._links?.webui ? wikiLink(env.baseUrl, published._links.webui) : void 0;
|
|
6
|
+
}
|
|
7
|
+
//#endregion
|
|
8
|
+
export { parentNum, urlOf };
|