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,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 };
|