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,337 @@
1
+ import { onlySingleEnv } from "../../config/codoc-config-atlassian.js";
2
+ import { toPosixPath } from "../../services/files-service.js";
3
+ import { PROJECT_ROOT } from "../../config/codoc-paths.js";
4
+ import { getCodocConfig } from "../../config/codoc-config.js";
5
+ import { generateRandomCodocId } from "../../services/codoc-id.js";
6
+ import { askWithDefault, createRl, resolveConfirm, resolveValue } from "../../services/prompt.js";
7
+ import { log } from "../../services/log/logger.js";
8
+ import { loadPublishState, makePageState, savePublishState } from "../../services/lock/lock-file.js";
9
+ import { slugify } from "../../services/slugify.js";
10
+ import { pageUrl } from "../../clients/confluence/utils/confluence-url.js";
11
+ import { EnvRegistry } from "../shared/confluence-client-registry.js";
12
+ import { appendToDocsConfig, buildYamlEntry, removeDocsConfigEntries } from "../shared/codoc-yaml.js";
13
+ import { selectEnvs } from "../shared/env-select.js";
14
+ import { cleanDocPath } from "../sync/sync-entries.js";
15
+ import { closeDrawioRenderer } from "../../services/conversion/confluenceToMarkdown/diagrams/drawio-to-image.js";
16
+ import { renderRemotePageToLocal } from "../shared/render-remote-page.js";
17
+ import { fetchFolderDescendants, pullFolderToLocal, pulledPagesToChildren } from "../shared/pull-folder.js";
18
+ import { fetchPageOrUndefined } from "../shared/fetch-page.js";
19
+ import { parsePageInput } from "./parse-page-input.js";
20
+ import path from "path";
21
+ //#region src/use-cases/pull/pull.ts
22
+ /**
23
+ * Cherche un import existant pour cette page : d'abord dans le lock (par ID Confluence puis par titre),
24
+ * sinon dans le yaml (par titre) - couvre le cas d'une entrée déjà configurée mais jamais encore synchronisée.
25
+ */
26
+ function findExistingImport(config, state, pageId, title) {
27
+ const entries = Object.entries(state.pages);
28
+ const byId = entries.find(([, p]) => p.confluencePageId === pageId);
29
+ const lockMatch = byId ?? entries.find(([, p]) => p.title === title);
30
+ const codocId = lockMatch?.[0] ?? config.docs.find((d) => d.confluence.title === title)?.codocId;
31
+ if (!codocId) return void 0;
32
+ const doc = config.docs.find((d) => d.codocId === codocId);
33
+ const lockPage = lockMatch?.[1];
34
+ return {
35
+ codocId,
36
+ sourceFile: doc ? cleanDocPath(doc.path) : lockPage.sourceFile,
37
+ title: doc?.confluence.title ?? lockPage?.title ?? title,
38
+ pageId: lockPage?.confluencePageId ?? pageId,
39
+ matchedBy: byId ? "id" : lockMatch ? "titre" : "config",
40
+ parentPageId: doc?.confluence.parentPageId,
41
+ maintainedIn: doc?.maintainedIn ?? lockPage?.maintainedIn,
42
+ imagesDir: doc?.imagesDir
43
+ };
44
+ }
45
+ /** Résout les informations d'import (chemin, titre, parent, maintien, images) : flag → config
46
+ * (import existant si remplacement) → prompt. */
47
+ async function collectImportAnswers(rl, page, confluence, existing, replaceExisting, flags) {
48
+ const kept = replaceExisting ? existing : void 0;
49
+ const localPath = await resolveValue(rl, {
50
+ flag: flags.localPath,
51
+ config: kept?.sourceFile,
52
+ prompt: "Chemin local du fichier .md :",
53
+ promptDefault: `doc/${slugify(page.title)}.md`
54
+ });
55
+ if (!localPath.endsWith(".md")) throw new Error("Le chemin doit se terminer par .md");
56
+ return {
57
+ localPath,
58
+ title: await resolveValue(rl, {
59
+ flag: flags.title,
60
+ config: kept?.title,
61
+ prompt: "Titre de la page Confluence :",
62
+ promptDefault: page.title
63
+ }),
64
+ parentPageId: await resolveValue(rl, {
65
+ flag: flags.parentPageId,
66
+ config: kept?.parentPageId,
67
+ prompt: "parentPageId Confluence :",
68
+ promptDefault: page.parentId ?? confluence.defaultParentPageId ?? ""
69
+ }),
70
+ maintainedIn: (await resolveValue(rl, {
71
+ flag: flags.maintainedIn,
72
+ config: kept?.maintainedIn,
73
+ prompt: "Maintenue côté code ou confluence ? (code/confluence) :",
74
+ promptDefault: "confluence"
75
+ })).trim().toLowerCase() === "code" ? "code" : "confluence",
76
+ imagesDir: (await resolveValue(rl, {
77
+ flag: flags.imagesDir,
78
+ config: kept?.imagesDir,
79
+ prompt: "Dossier local pour les images (vide pour ignorer) :",
80
+ promptDefault: "doc/img"
81
+ })).trim() || void 0
82
+ };
83
+ }
84
+ /** Détermine l'environnement où se trouve la page (flag → URL → env unique → sonde chaque env). */
85
+ async function resolveEnvAndPage(rl, config, registry, domain, pageId, flagEnv) {
86
+ const envs = config.atlassian.environments;
87
+ const fromEnv = async (e) => {
88
+ const page = await fetchPageOrUndefined(registry.raw(e), pageId);
89
+ if (!page) throw new Error(`Page ${pageId} introuvable sur l'environnement "${e.key}" (${e.baseUrl}).`);
90
+ return {
91
+ env: e,
92
+ page
93
+ };
94
+ };
95
+ if (flagEnv) return fromEnv(selectEnvs(config, flagEnv)[0]);
96
+ if (domain) {
97
+ const match = envs.find((e) => {
98
+ try {
99
+ return new URL(e.baseUrl).hostname === domain;
100
+ } catch {
101
+ return false;
102
+ }
103
+ });
104
+ if (match) return fromEnv(match);
105
+ log.blank();
106
+ log.warning1(`Le domaine "${domain}" ne correspond à aucun environnement - détection automatique…`);
107
+ }
108
+ const onlyEnv = onlySingleEnv(envs);
109
+ if (onlyEnv) return fromEnv(onlyEnv);
110
+ log.info0(`Recherche de la page dans : ${envs.map((e) => e.key).join(", ")}…`);
111
+ const found = [];
112
+ for (const e of envs) {
113
+ const page = await fetchPageOrUndefined(registry.raw(e), pageId);
114
+ if (page) found.push({
115
+ env: e,
116
+ page
117
+ });
118
+ }
119
+ if (found.length === 0) throw new Error(`Page ${pageId} introuvable dans aucun environnement (${envs.map((e) => e.key).join(", ")}).`);
120
+ if (found.length === 1) {
121
+ log.success1(`Trouvée dans l'environnement "${found[0].env.key}".`);
122
+ return found[0];
123
+ }
124
+ log.warning1(`Page présente dans plusieurs environnements : ${found.map((f) => f.env.key).join(", ")}.`);
125
+ const key = await askWithDefault(rl, "Lequel ?", found[0].env.key);
126
+ return found.find((f) => f.env.key === key) ?? found[0];
127
+ }
128
+ /** Import d'un dossier Confluence complet : chaque descendant en .md local, une seule entrée `path: folder/*` en yaml. */
129
+ async function pullFolder(rl, client, rootPage, rootType, flags) {
130
+ const rawFolder = await resolveValue(rl, {
131
+ flag: flags.localPath,
132
+ prompt: "Dossier local de destination :",
133
+ promptDefault: `doc/${slugify(rootPage.title)}/`
134
+ });
135
+ const localFolder = toPosixPath(rawFolder).replace(/\/?$/, "/");
136
+ const parentPageId = await resolveValue(rl, {
137
+ flag: flags.parentPageId,
138
+ prompt: "parentPageId Confluence (page parente du dossier) :",
139
+ promptDefault: rootPage.parentId ?? client.configuration.defaultParentPageId ?? ""
140
+ });
141
+ const maintainedIn = (await resolveValue(rl, {
142
+ flag: flags.maintainedIn,
143
+ prompt: "Maintenue côté code ou confluence ? (code/confluence) :",
144
+ promptDefault: "confluence"
145
+ })).trim().toLowerCase() === "code" ? "code" : "confluence";
146
+ const imagesDir = (await resolveValue(rl, {
147
+ flag: flags.imagesDir,
148
+ prompt: "Dossier local pour les images (vide pour ignorer) :",
149
+ promptDefault: "doc/img"
150
+ })).trim() || void 0;
151
+ log.blank();
152
+ log.step1("Récupération des sous-pages");
153
+ const descendants = await fetchFolderDescendants(client, rootPage.id, rootType);
154
+ const pages = descendants.filter((d) => d.type !== "folder");
155
+ log.info1(`${pages.length} page(s) trouvée(s) sous "${rootPage.title}"`);
156
+ if (!pages.length) {
157
+ log.warning1("Aucune sous-page - utilise l'import simple à la place.");
158
+ return;
159
+ }
160
+ const now = (/* @__PURE__ */ new Date()).toISOString();
161
+ const written = await pullFolderToLocal({
162
+ confluenceClient: client,
163
+ rootId: rootPage.id,
164
+ rootType,
165
+ localDir: localFolder,
166
+ imagesDir,
167
+ onPage: (page, index, total) => {
168
+ log.blank();
169
+ log.progress(index, total, page.title);
170
+ log.success3(`[PULL] → ${page.localPath}`);
171
+ },
172
+ onPageError: ({ title }, err, index, total) => {
173
+ log.blank();
174
+ log.progress(index, total, title);
175
+ log.error3(`Échec "${title}" : ${err?.message ?? err}`);
176
+ }
177
+ });
178
+ const codocId = generateRandomCodocId();
179
+ const folderDir = localFolder.replace(/\/$/, "");
180
+ const state = loadPublishState();
181
+ const addToConfig = await resolveConfirm(rl, {
182
+ flag: flags.inConfig,
183
+ question: "Ajouter ce dossier à codoc.yaml (pour qu'il soit synchronisé par `codoc sync`) ?",
184
+ default: true
185
+ });
186
+ if (addToConfig) {
187
+ state.pages[codocId] = {
188
+ ...makePageState({
189
+ pageId: rootPage.id,
190
+ environment: client.configuration.key,
191
+ title: rootPage.title,
192
+ url: pageUrl(client.configuration.baseUrl, client.configuration.spaceKey, rootPage.id),
193
+ sourceFile: folderDir,
194
+ maintainedIn,
195
+ publishedAt: now,
196
+ isFolder: true
197
+ }),
198
+ children: pulledPagesToChildren(written, client.configuration.baseUrl, client.configuration.spaceKey)
199
+ };
200
+ state.lastPublished = state.lastPublished || now;
201
+ savePublishState(state);
202
+ const yamlEntry = buildYamlEntry({
203
+ localPath: `${localFolder}**`,
204
+ title: rootPage.title,
205
+ parentPageId,
206
+ imagesDir,
207
+ maintainedIn,
208
+ env: client.configuration.key,
209
+ codocId
210
+ });
211
+ await appendToDocsConfig(yamlEntry);
212
+ } else log.warning1("[SKIPPED] codoc.yaml et codoc.lock non modifiés - import ponctuel, hors suivi (pas de comparaison future par ID).");
213
+ log.finalBanner("Import dossier terminé", {
214
+ "Dossier local": localFolder,
215
+ "Pages importées": `${written.length} / ${descendants.length}`,
216
+ "Page racine": `${rootPage.id} - "${rootPage.title}"`,
217
+ "Prochaine étape": addToConfig ? "codoc sync" : "(hors suivi - codoc.yaml et codoc.lock non modifiés)"
218
+ });
219
+ }
220
+ async function pull(flags = {}) {
221
+ const config = getCodocConfig();
222
+ const state = loadPublishState();
223
+ const registry = new EnvRegistry();
224
+ const rl = createRl();
225
+ try {
226
+ log.startProcess("Import Confluence → local");
227
+ const rawInput = await resolveValue(rl, {
228
+ flag: flags.page,
229
+ prompt: "URL ou ID de la page Confluence :"
230
+ });
231
+ if (!rawInput) throw new Error("URL ou ID requis.");
232
+ const parsed = parsePageInput(rawInput);
233
+ const pageId = parsed.pageId;
234
+ log.blank();
235
+ log.step1("Récupération de la page");
236
+ const { env, page } = await resolveEnvAndPage(rl, config, registry, parsed.domain, pageId, flags.env);
237
+ const client = await registry.connect(env);
238
+ log.field("Environnement", `${env.key} (${env.baseUrl})`);
239
+ log.blank();
240
+ log.field("Titre ", page.title);
241
+ log.field("Parent ID", page.parentId ?? "(racine)");
242
+ if (page.hasImages) log.field("Images ", "oui");
243
+ if (parsed.isFolder) {
244
+ await pullFolder(rl, client, page, "folder", flags);
245
+ return;
246
+ }
247
+ const [subPages, subFolders] = await Promise.all([client.pages.fetchChildren(pageId), client.folders.childrenFolders(pageId)]);
248
+ const childCount = subPages.length + subFolders.length;
249
+ if (childCount > 0) {
250
+ log.blank();
251
+ log.info1(`Cette page contient ${childCount} sous-élément(s) (${subPages.length} page(s), ${subFolders.length} dossier(s)).`);
252
+ if (await resolveConfirm(rl, {
253
+ flag: flags.asFolder,
254
+ question: "Importer tout le dossier (toutes les sous-pages/sous-dossiers) ?",
255
+ default: true
256
+ })) {
257
+ await pullFolder(rl, client, page, "page", flags);
258
+ return;
259
+ }
260
+ }
261
+ const existing = findExistingImport(config, state, pageId, page.title);
262
+ let replaceExisting = false;
263
+ if (existing) {
264
+ const matchLabel = existing.matchedBy === "id" ? "même ID Confluence" : existing.matchedBy === "titre" ? "même titre" : "déjà dans codoc.yaml";
265
+ log.blank();
266
+ log.warning1(`Cette page semble déjà importée (${matchLabel}) :`);
267
+ log.info4(`Fichier : ${existing.sourceFile}`);
268
+ log.info4(`Titre : ${existing.title}`);
269
+ log.info4(`Page ID : ${existing.pageId}`);
270
+ replaceExisting = await resolveConfirm(rl, {
271
+ flag: flags.keepExisting,
272
+ question: "Garder la config existante (les valeurs ci-dessous serviront de défaut) ?",
273
+ default: true
274
+ });
275
+ if (!replaceExisting) log.warning1("L'existant est conservé ; un nouvel import séparé est créé.");
276
+ }
277
+ const { localPath, title, parentPageId, maintainedIn, imagesDir } = await collectImportAnswers(rl, page, env, existing, replaceExisting, flags);
278
+ log.blank();
279
+ log.step1("Conversion en Markdown");
280
+ await renderRemotePageToLocal(client, page, localPath, imagesDir);
281
+ log.success1(`[PULL] Fichier sauvegardé : ${path.resolve(PROJECT_ROOT, localPath)}`);
282
+ const codocId = replaceExisting && existing?.codocId || generateRandomCodocId();
283
+ const isReplacement = replaceExisting && Boolean(existing);
284
+ const question = isReplacement ? "Mettre à jour la config codoc.yaml existante pour cette doc ?" : "Ajouter cette doc à codoc.yaml (pour qu'elle soit synchronisée par `codoc sync`) ?";
285
+ const addToConfig = await resolveConfirm(rl, {
286
+ flag: flags.inConfig,
287
+ question,
288
+ default: true
289
+ });
290
+ if (addToConfig) {
291
+ const entry = buildYamlEntry({
292
+ localPath,
293
+ title,
294
+ parentPageId,
295
+ imagesDir,
296
+ maintainedIn,
297
+ env: env.key,
298
+ codocId
299
+ });
300
+ if (isReplacement && existing) {
301
+ await removeDocsConfigEntries([existing.sourceFile, localPath]);
302
+ await appendToDocsConfig(entry);
303
+ log.success1("[UPDATED] codoc.yaml : entrée remplacée");
304
+ } else {
305
+ await appendToDocsConfig(entry);
306
+ log.success1("[UPDATED] codoc.yaml mis à jour");
307
+ }
308
+ if (isReplacement && existing && existing.codocId !== codocId) delete state.pages[existing.codocId];
309
+ const now = (/* @__PURE__ */ new Date()).toISOString();
310
+ state.pages[codocId] = makePageState({
311
+ pageId,
312
+ environment: env.key,
313
+ title,
314
+ url: pageUrl(env.baseUrl, env.spaceKey, pageId),
315
+ sourceFile: localPath,
316
+ maintainedIn,
317
+ publishedAt: now
318
+ });
319
+ state.lastPublished = state.lastPublished || now;
320
+ savePublishState(state);
321
+ log.success1("[UPDATED] codoc.lock mis à jour");
322
+ } else log.warning1("[SKIPPED] codoc.yaml et codoc.lock non modifiés - import ponctuel, hors suivi (pas de comparaison future par ID).");
323
+ const maintainedHint = maintainedIn === "confluence" ? `${maintainedIn} (Confluence fait foi → sync régénère le .md local)` : `${maintainedIn} (le code fait foi → sync régénère la page Confluence)`;
324
+ const nextSteps = addToConfig ? `1. Révise le Markdown généré dans ${localPath}\n2. Lance codoc sync pour re-synchroniser` : `1. Révise le Markdown généré dans ${localPath}\n2. Import hors suivi - codoc.yaml et codoc.lock non modifiés`;
325
+ log.finalBanner("Import terminé", {
326
+ "Fichier local": localPath,
327
+ "Page Confluence": `${pageId} - "${title}"`,
328
+ Maintien: maintainedHint,
329
+ "Prochaines étapes": nextSteps
330
+ });
331
+ } finally {
332
+ rl.close();
333
+ await closeDrawioRenderer();
334
+ }
335
+ }
336
+ //#endregion
337
+ export { pull };
@@ -0,0 +1,45 @@
1
+ import { readFile, writeFile } from "../../services/files-service.js";
2
+ import { CONFIG_PATH } from "../../config/codoc-paths.js";
3
+ //#region src/use-cases/shared/codoc-yaml.ts
4
+ function buildYamlEntry(params) {
5
+ const lines = [];
6
+ if (params.codocId) {
7
+ lines.push(`\n - codocId: ${params.codocId}`);
8
+ lines.push(` path: ${params.localPath}`);
9
+ } else lines.push(`\n - path: ${params.localPath}`);
10
+ lines.push(` maintainedIn: ${params.maintainedIn}`);
11
+ if (params.env) lines.push(` env: ${params.env}`);
12
+ if (params.imagesDir) lines.push(` imagesDir: ${params.imagesDir}`);
13
+ if (params.title || params.parentPageId) {
14
+ lines.push(` confluence:`);
15
+ if (params.title) lines.push(` title: "${params.title.replace(/"/g, "\\\"")}"`);
16
+ if (params.parentPageId) lines.push(` parentPageId: "${params.parentPageId}"`);
17
+ }
18
+ return lines.join("\n");
19
+ }
20
+ function removeEntriesFromYaml(content, paths) {
21
+ const targets = new Set(paths);
22
+ const lines = content.split("\n");
23
+ const result = [];
24
+ let i = 0;
25
+ while (i < lines.length) {
26
+ const pathValue = lines[i].match(/^\s*-\s+path:\s*(.+?)\s*$/)?.[1]?.replace(/^["']|["']$/g, "");
27
+ if (pathValue && targets.has(pathValue)) {
28
+ i++;
29
+ while (i < lines.length && lines[i].trim() !== "" && !/^\s*-\s+path:/.test(lines[i]) && !/^\S/.test(lines[i])) i++;
30
+ continue;
31
+ }
32
+ result.push(lines[i]);
33
+ i++;
34
+ }
35
+ return result.join("\n").replace(/\n{3,}/g, "\n\n");
36
+ }
37
+ async function appendToDocsConfig(entry) {
38
+ const existing = readFile(CONFIG_PATH);
39
+ writeFile(CONFIG_PATH, existing.trimEnd() + entry + "\n");
40
+ }
41
+ async function removeDocsConfigEntries(paths) {
42
+ writeFile(CONFIG_PATH, removeEntriesFromYaml(readFile(CONFIG_PATH), paths));
43
+ }
44
+ //#endregion
45
+ export { appendToDocsConfig, buildYamlEntry, removeDocsConfigEntries, removeEntriesFromYaml };
@@ -0,0 +1,27 @@
1
+ import { log } from "../../services/log/logger.js";
2
+ import { createConfluenceClient } from "../../clients/confluence/confluence-client.js";
3
+ //#region src/use-cases/shared/confluence-client-registry.ts
4
+ /** Cache des clients Confluence par environnement. */
5
+ var EnvRegistry = class {
6
+ clients = /* @__PURE__ */ new Map();
7
+ connected = /* @__PURE__ */ new Set();
8
+ raw(env) {
9
+ let c = this.clients.get(env.key);
10
+ if (!c) {
11
+ c = createConfluenceClient(env);
12
+ this.clients.set(env.key, c);
13
+ }
14
+ return c;
15
+ }
16
+ async connect(env) {
17
+ const c = this.raw(env);
18
+ if (!this.connected.has(env.key)) {
19
+ if (!await c.pages.testConnection()) throw new Error(`Connexion impossible à l'environnement "${env.key}" (${env.baseUrl}).`);
20
+ log.success0(`Connecté à Confluence [${env.key}] - ${env.baseUrl}`);
21
+ this.connected.add(env.key);
22
+ }
23
+ return c;
24
+ }
25
+ };
26
+ //#endregion
27
+ export { EnvRegistry };
@@ -0,0 +1,23 @@
1
+ import { onlySingleEnv } from "../../config/codoc-config-atlassian.js";
2
+ import "../../config/codoc-config.js";
3
+ import { askWithDefault } from "../../services/prompt.js";
4
+ //#region src/use-cases/shared/env-select.ts
5
+ /** Filtre `config.atlassian.environments` par clé ; lève si la clé est fournie mais inconnue. */
6
+ function selectEnvs(config, envKey) {
7
+ const envs = config.atlassian.environments;
8
+ if (!envKey) return envs;
9
+ const matched = envs.filter((e) => e.key === envKey);
10
+ if (!matched.length) throw new Error(`Environnement Confluence "${envKey}" inconnu. Définis : ${envs.map((e) => e.key).join(", ")}.`);
11
+ return matched;
12
+ }
13
+ /** Choisit l'environnement Confluence cible (l'unique, sinon demande). */
14
+ async function pickEnvInteractive(rl, config) {
15
+ const envs = config.atlassian.environments;
16
+ const onlyEnv = onlySingleEnv(envs);
17
+ if (onlyEnv) return onlyEnv;
18
+ const keys = envs.map((e) => e.key);
19
+ const answer = await askWithDefault(rl, `Environnement Confluence cible (${keys.join(", ")}) :`, keys[0]);
20
+ return envs.find((e) => e.key === answer.trim()) ?? envs[0];
21
+ }
22
+ //#endregion
23
+ export { pickEnvInteractive, selectEnvs };
@@ -0,0 +1,11 @@
1
+ //#region src/use-cases/shared/fetch-page.ts
2
+ /** Récupère une page sur un environnement, ou undefined si absente/inaccessible. */
3
+ async function fetchPageOrUndefined(client, pageId) {
4
+ try {
5
+ return await client.pages.fetchPage(pageId);
6
+ } catch {
7
+ return;
8
+ }
9
+ }
10
+ //#endregion
11
+ export { fetchPageOrUndefined };
@@ -0,0 +1,172 @@
1
+ import { resolveDocEnvironmentByKey } from "../../config/codoc-config-atlassian.js";
2
+ import { collectFiles, readFile, toPosixPath } from "../../services/files-service.js";
3
+ import { PROJECT_ROOT } from "../../config/codoc-paths.js";
4
+ import { getCodocConfig } from "../../config/codoc-config.js";
5
+ import { log } from "../../services/log/logger.js";
6
+ import { findLockedRef, loadPublishState } from "../../services/lock/lock-file.js";
7
+ import { slugify } from "../../services/slugify.js";
8
+ import path from "path";
9
+ import fs from "fs/promises";
10
+ //#region src/use-cases/shared/list-documents.ts
11
+ function extractH1(markdown) {
12
+ return markdown.match(/^#\s+(.+)$/m)?.[1]?.trim();
13
+ }
14
+ function capitalizeFirst(name) {
15
+ return name.charAt(0).toUpperCase() + name.slice(1);
16
+ }
17
+ /** Vrai si le chemin yaml désigne un dossier/glob (`/**`, `/*` ou `/` final). */
18
+ function isFolderPath(docPath) {
19
+ return /\/\*\*?$/.test(docPath) || docPath.endsWith("/");
20
+ }
21
+ function normalizeDocPath(docPath) {
22
+ return {
23
+ cleanPath: docPath.replace(/\/\*\*?$/, "").replace(/\/$/, ""),
24
+ isFolder: isFolderPath(docPath)
25
+ };
26
+ }
27
+ async function collectMarkdownFiles(docPath) {
28
+ const { cleanPath, isFolder } = normalizeDocPath(docPath);
29
+ const absPath = path.resolve(PROJECT_ROOT, cleanPath);
30
+ let stat;
31
+ try {
32
+ stat = await fs.stat(absPath);
33
+ } catch {
34
+ log.warning0(`Chemin introuvable : ${absPath}`);
35
+ return [];
36
+ }
37
+ return isFolder || stat.isDirectory() ? collectFiles(absPath, [".md"]) : [absPath];
38
+ }
39
+ function matchesOverridePath(sourceFile, overridePath) {
40
+ const file = sourceFile.replace(/\.md$/, "");
41
+ const override = overridePath.replace(/^\.\//, "").replace(/\.md$/, "").replace(/\/$/, "");
42
+ return file === override || file.startsWith(`${override}/`);
43
+ }
44
+ function resolveKeywords(doc, sourceFile) {
45
+ const matches = (doc.keywordOverrides ?? []).filter((o) => matchesOverridePath(sourceFile, o.path));
46
+ if (!matches.length) return doc.keywords;
47
+ return matches.reduce((best, cur) => cur.path.length > best.path.length ? cur : best).keywords;
48
+ }
49
+ /** Dérive les métadonnées (titre, id) d'une page à partir d'un fichier + son entrée de config. */
50
+ function deriveTarget(filePath, markdown, doc, isSingleFile, env, lock) {
51
+ const filenameWithoutExt = path.basename(filePath, ".md");
52
+ const conf = doc.confluence;
53
+ const sourceFile = toPosixPath(path.relative(PROJECT_ROOT, filePath));
54
+ const lockedTitle = doc.maintainedIn === "confluence" ? findLockedRef(lock, env.key, sourceFile)?.title : void 0;
55
+ const baseTitle = (isSingleFile ? conf.title : void 0) ?? lockedTitle ?? extractH1(markdown) ?? capitalizeFirst(filenameWithoutExt);
56
+ const title = `${conf.titlePrefix ?? ""}${baseTitle}${conf.titleSuffix ?? ""}`;
57
+ const id = slugify(filenameWithoutExt);
58
+ return {
59
+ codocId: doc.codocId,
60
+ sourceFile,
61
+ markdown,
62
+ id,
63
+ title,
64
+ parentPageId: conf.parentPageId ?? env.defaultParentPageId,
65
+ imagesDir: doc.imagesDir,
66
+ maintainedIn: doc.maintainedIn,
67
+ env,
68
+ keywords: resolveKeywords(doc, sourceFile),
69
+ generateSummary: doc.generateSummary
70
+ };
71
+ }
72
+ /** Génère les DocTarget "dossier" pour chaque sous-dossier intermédiaire (parents avant enfants) et mappe filePath → lockKey du parent. */
73
+ function buildFolderHierarchy(baseDirAbs, baseDirRel, files, doc, env) {
74
+ const subdirs = /* @__PURE__ */ new Set();
75
+ for (const file of files) {
76
+ const rel = toPosixPath(path.relative(baseDirAbs, path.dirname(file)));
77
+ if (!rel || rel === ".") continue;
78
+ const parts = rel.split("/");
79
+ for (let i = 1; i <= parts.length; i++) subdirs.add(parts.slice(0, i).join("/"));
80
+ }
81
+ const sorted = [...subdirs].sort((a, b) => {
82
+ const d = a.split("/").length - b.split("/").length;
83
+ return d !== 0 ? d : a.localeCompare(b);
84
+ });
85
+ const folderTargets = [];
86
+ const relDirToKey = /* @__PURE__ */ new Map();
87
+ for (const relDir of sorted) {
88
+ const dirRelFromProject = path.posix.join(baseDirRel, relDir);
89
+ const folderName = relDir.split("/").pop();
90
+ const id = `folder:${slugify(dirRelFromProject)}`;
91
+ const fKey = `${env.key}:${id}`;
92
+ relDirToKey.set(relDir, fKey);
93
+ const parentRelDir = relDir.includes("/") ? relDir.slice(0, relDir.lastIndexOf("/")) : void 0;
94
+ const parentFolderKey = parentRelDir ? relDirToKey.get(parentRelDir) : void 0;
95
+ const baseFolderTitle = capitalizeFirst(folderName);
96
+ const folderTitle = `${doc.confluence.titlePrefix ?? ""}${baseFolderTitle}${doc.confluence.titleSuffix ?? ""}`;
97
+ folderTargets.push({
98
+ codocId: doc.codocId,
99
+ sourceFile: dirRelFromProject,
100
+ markdown: "",
101
+ id,
102
+ title: folderTitle,
103
+ parentPageId: doc.confluence.parentPageId ?? env.defaultParentPageId,
104
+ imagesDir: void 0,
105
+ maintainedIn: doc.maintainedIn,
106
+ env,
107
+ isFolder: true,
108
+ parentFolderKey,
109
+ keywords: resolveKeywords(doc, dirRelFromProject),
110
+ generateSummary: doc.generateSummary
111
+ });
112
+ }
113
+ const fileParentKey = /* @__PURE__ */ new Map();
114
+ for (const file of files) {
115
+ const relToBase = toPosixPath(path.relative(baseDirAbs, file));
116
+ const relDir = relToBase.includes("/") ? relToBase.slice(0, relToBase.lastIndexOf("/")) : ".";
117
+ fileParentKey.set(file, relDir === "." ? void 0 : relDirToKey.get(relDir));
118
+ }
119
+ return {
120
+ folderTargets,
121
+ fileParentKey
122
+ };
123
+ }
124
+ /** Énumère toutes les pages de codoc.yaml avec leurs métadonnées dérivées (sert au sync et à docsTree). */
125
+ async function listDocumentTargets() {
126
+ const config = getCodocConfig();
127
+ const lock = loadPublishState();
128
+ const targets = [];
129
+ for (const doc of config.docs) targets.push(...await expandDocEntry(doc, resolveDocEnvironmentByKey(config, doc), lock));
130
+ return targets;
131
+ }
132
+ /** Étend UNE entrée `docs:` en ses cibles Confluence (1 pour un fichier ; dossiers + fichiers pour un glob). */
133
+ async function expandDocEntry(doc, env, lock = loadPublishState()) {
134
+ const targets = [];
135
+ if (doc.maintainedIn === "confluence") {
136
+ if (isFolderPath(doc.path)) {
137
+ const files = await collectMarkdownFiles(doc.path);
138
+ targets.push(...await expandFolderGlobTargets(doc, env, files, lock));
139
+ } else {
140
+ const filePath = path.resolve(PROJECT_ROOT, normalizeDocPath(doc.path).cleanPath);
141
+ let markdown = "";
142
+ try {
143
+ markdown = readFile(filePath);
144
+ } catch {}
145
+ targets.push(deriveTarget(filePath, markdown, doc, true, env, lock));
146
+ }
147
+ return targets;
148
+ }
149
+ const files = await collectMarkdownFiles(doc.path);
150
+ if (!files.length) return targets;
151
+ const isFolderGlob = isFolderPath(doc.path);
152
+ const isSingleFile = !isFolderGlob && files.length === 1;
153
+ if (isFolderGlob) targets.push(...await expandFolderGlobTargets(doc, env, files, lock));
154
+ else for (const filePath of files) targets.push(deriveTarget(filePath, readFile(filePath), doc, isSingleFile, env, lock));
155
+ return targets;
156
+ }
157
+ /** Étend un dossier (glob) en DocTarget "dossier" puis "fichier", chacun rattaché à son dossier local via `parentFolderKey`. */
158
+ async function expandFolderGlobTargets(doc, env, files, lock) {
159
+ const { cleanPath: baseDirRel } = normalizeDocPath(doc.path);
160
+ const { folderTargets, fileParentKey } = buildFolderHierarchy(path.resolve(PROJECT_ROOT, baseDirRel), baseDirRel, files, doc, env);
161
+ const targets = [...folderTargets];
162
+ for (const filePath of files) {
163
+ const t = deriveTarget(filePath, readFile(filePath), doc, false, env, lock);
164
+ targets.push({
165
+ ...t,
166
+ parentFolderKey: fileParentKey.get(filePath)
167
+ });
168
+ }
169
+ return targets;
170
+ }
171
+ //#endregion
172
+ export { expandDocEntry, extractH1, listDocumentTargets };