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.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +104 -0
  3. package/assets/drawio-viewer/README.md +13 -0
  4. package/assets/drawio-viewer/viewer-static.min.js +8640 -0
  5. package/bin/dev.cmd +3 -0
  6. package/bin/dev.js +5 -0
  7. package/bin/run.cmd +3 -0
  8. package/bin/run.js +7 -0
  9. package/dist/clients/confluence/clients/attachments-client.js +74 -0
  10. package/dist/clients/confluence/clients/folders-client.js +111 -0
  11. package/dist/clients/confluence/clients/pages-client.js +128 -0
  12. package/dist/clients/confluence/confluence-client.js +44 -0
  13. package/dist/clients/confluence/utils/confluence-url.js +25 -0
  14. package/dist/clients/confluence/utils/confluence-util.js +20 -0
  15. package/dist/commands/init.js +29 -0
  16. package/dist/commands/publish.js +34 -0
  17. package/dist/commands/pull.js +54 -0
  18. package/dist/commands/sync.js +27 -0
  19. package/dist/commands/tree.js +17 -0
  20. package/dist/config/codoc-config-atlassian.js +89 -0
  21. package/dist/config/codoc-config-raw.js +21 -0
  22. package/dist/config/codoc-config.js +50 -0
  23. package/dist/config/codoc-paths.js +9 -0
  24. package/dist/hooks/command_not_found.js +7 -0
  25. package/dist/hooks/init/check-for-update.js +68 -0
  26. package/dist/hooks/init/load-env.js +15 -0
  27. package/dist/index.js +2 -0
  28. package/dist/services/codoc-id.js +7 -0
  29. package/dist/services/confluence/attachments.js +56 -0
  30. package/dist/services/confluence/folders.js +42 -0
  31. package/dist/services/confluence/labels.js +17 -0
  32. package/dist/services/confluence/pages.js +28 -0
  33. package/dist/services/conversion/confluenceToMarkdown/diagrams/drawio-diagrams.js +77 -0
  34. package/dist/services/conversion/confluenceToMarkdown/diagrams/drawio-to-image.js +131 -0
  35. package/dist/services/conversion/confluenceToMarkdown/index.js +17 -0
  36. package/dist/services/conversion/confluenceToMarkdown/preprocess/macro-converters.js +68 -0
  37. package/dist/services/conversion/confluenceToMarkdown/preprocess/preprocess.js +113 -0
  38. package/dist/services/conversion/confluenceToMarkdown/turndown.js +71 -0
  39. package/dist/services/conversion/markdownToConfluence/conversion-state.js +18 -0
  40. package/dist/services/conversion/markdownToConfluence/diagrams/mermaid-diagrams.js +56 -0
  41. package/dist/services/conversion/markdownToConfluence/diagrams/mermaid-to-drawio.js +331 -0
  42. package/dist/services/conversion/markdownToConfluence/index.js +48 -0
  43. package/dist/services/conversion/markdownToConfluence/languages.js +66 -0
  44. package/dist/services/conversion/markdownToConfluence/links.js +40 -0
  45. package/dist/services/conversion/markdownToConfluence/raw-html/raw-html.js +86 -0
  46. package/dist/services/conversion/markdownToConfluence/raw-html/task-lists.js +59 -0
  47. package/dist/services/conversion/markdownToConfluence/render/blocks.js +171 -0
  48. package/dist/services/conversion/markdownToConfluence/render/inline.js +44 -0
  49. package/dist/services/conversion/markdownToConfluence/render/page.js +31 -0
  50. package/dist/services/conversion/shared/confluence-macro-builder.js +11 -0
  51. package/dist/services/conversion/shared/gitlab-url.js +16 -0
  52. package/dist/services/conversion/shared/image-attachments.js +57 -0
  53. package/dist/services/conversion/shared/jira.js +7 -0
  54. package/dist/services/conversion/shared/macro-types.js +12 -0
  55. package/dist/services/conversion/shared/preserved-macros.js +8 -0
  56. package/dist/services/conversion/shared/read-storage-format.js +29 -0
  57. package/dist/services/conversion/shared/regex-cache.js +14 -0
  58. package/dist/services/conversion/shared/xml-escaping.js +19 -0
  59. package/dist/services/ensure-env.js +59 -0
  60. package/dist/services/files-service.js +74 -0
  61. package/dist/services/git/default-branch.js +33 -0
  62. package/dist/services/init-templates/agent-md-init.js +55 -0
  63. package/dist/services/init-templates/doc-guide-init.js +131 -0
  64. package/dist/services/init-templates/env-init.js +12 -0
  65. package/dist/services/init-templates/yaml-init.js +56 -0
  66. package/dist/services/lock/lock-file.js +61 -0
  67. package/dist/services/log/init-printer.js +50 -0
  68. package/dist/services/log/logger.js +131 -0
  69. package/dist/services/prompt.js +32 -0
  70. package/dist/services/slugify.js +6 -0
  71. package/dist/types/codoc-types.js +1 -0
  72. package/dist/use-cases/init/init.js +150 -0
  73. package/dist/use-cases/publish/publish.js +166 -0
  74. package/dist/use-cases/pull/parse-page-input.js +22 -0
  75. package/dist/use-cases/pull/pull.js +337 -0
  76. package/dist/use-cases/shared/codoc-yaml.js +45 -0
  77. package/dist/use-cases/shared/confluence-client-registry.js +27 -0
  78. package/dist/use-cases/shared/env-select.js +23 -0
  79. package/dist/use-cases/shared/fetch-page.js +11 -0
  80. package/dist/use-cases/shared/list-documents.js +172 -0
  81. package/dist/use-cases/shared/pull-folder.js +90 -0
  82. package/dist/use-cases/shared/render-remote-page.js +71 -0
  83. package/dist/use-cases/sync/attachment-upload.js +31 -0
  84. package/dist/use-cases/sync/sync-actions.js +170 -0
  85. package/dist/use-cases/sync/sync-associate.js +41 -0
  86. package/dist/use-cases/sync/sync-entries.js +36 -0
  87. package/dist/use-cases/sync/sync-handlers.js +140 -0
  88. package/dist/use-cases/sync/sync-helpers.js +8 -0
  89. package/dist/use-cases/sync/sync.js +94 -0
  90. package/dist/use-cases/tree/docs-tree.js +141 -0
  91. package/oclif.manifest.json +255 -0
  92. 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 };