confluence-md-sync 0.5.1 → 0.6.0
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/README.md +4 -0
- package/dist/client/client.d.ts +2 -1
- package/dist/client/client.js +5 -0
- package/dist/export/to-markdown.js +5 -4
- package/dist/publish/publish.js +51 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,6 +18,10 @@ npm install -g confluence-md-sync # as a CLI: `confluence-md-sync …`
|
|
|
18
18
|
|
|
19
19
|
- **No history spam** — rendered content is SHA-256-hashed into a content
|
|
20
20
|
property; identical re-publish skips the update, page version doesn't grow.
|
|
21
|
+
- **Drift-proof** — the content property also stores the page version we last
|
|
22
|
+
wrote. If someone edits or wipes the page in Confluence, the version diverges
|
|
23
|
+
and the next publish detects the drift and restores the page from markdown
|
|
24
|
+
(git stays the source of truth) instead of trusting the stale hash.
|
|
21
25
|
- **Attachment dedup** — uploads are tagged `sha256:<hash>`; unchanged files
|
|
22
26
|
are reused. `<file>.src-sha256` sidecars pin dedup to the *source* of
|
|
23
27
|
non-deterministic artifacts (e.g. PNGs rendered by a headless browser).
|
package/dist/client/client.d.ts
CHANGED
|
@@ -73,12 +73,13 @@ export declare class ConfluenceClient {
|
|
|
73
73
|
/** Finds a page by space key and exact title. Returns null if not found. */
|
|
74
74
|
getPageByTitle(spaceKey: string, title: string): Promise<ConfluencePage | null>;
|
|
75
75
|
createPage(opts: CreatePageOptions): Promise<ConfluencePage>;
|
|
76
|
+
/** Обновляет страницу и возвращает номер версии из ответа Confluence. */
|
|
76
77
|
updatePage(pageId: string, body: {
|
|
77
78
|
title: string;
|
|
78
79
|
version: number;
|
|
79
80
|
storage: string;
|
|
80
81
|
versionMessage?: string;
|
|
81
|
-
}): Promise<
|
|
82
|
+
}): Promise<number>;
|
|
82
83
|
deletePage(pageId: string): Promise<void>;
|
|
83
84
|
/** Direct child pages of a page. */
|
|
84
85
|
getChildPages(pageId: string, limit?: number): Promise<ConfluencePage[]>;
|
package/dist/client/client.js
CHANGED
|
@@ -99,6 +99,7 @@ export class ConfluenceClient {
|
|
|
99
99
|
await this.parseError(res, `createPage(${opts.spaceKey}/${opts.title})`);
|
|
100
100
|
return (await res.json());
|
|
101
101
|
}
|
|
102
|
+
/** Обновляет страницу и возвращает номер версии из ответа Confluence. */
|
|
102
103
|
async updatePage(pageId, body) {
|
|
103
104
|
const version = { number: body.version };
|
|
104
105
|
if (body.versionMessage)
|
|
@@ -118,6 +119,10 @@ export class ConfluenceClient {
|
|
|
118
119
|
});
|
|
119
120
|
if (!res.ok)
|
|
120
121
|
await this.parseError(res, `updatePage(${pageId})`);
|
|
122
|
+
// Confluence возвращает обновлённый content с актуальным version.number.
|
|
123
|
+
// Если тела/поля нет — падаем обратно на посланную версию.
|
|
124
|
+
const data = (await res.json().catch(() => null));
|
|
125
|
+
return data?.version?.number ?? body.version;
|
|
121
126
|
}
|
|
122
127
|
async deletePage(pageId) {
|
|
123
128
|
const res = await fetch(this.url(`/rest/api/content/${pageId}`), {
|
|
@@ -630,10 +630,11 @@ class Converter {
|
|
|
630
630
|
let headerCount = 0;
|
|
631
631
|
while (headerCount < grid.length && headerFlags[headerCount])
|
|
632
632
|
headerCount++;
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
633
|
+
// Нет <th>-шапки (или вся таблица — заголовки): в записи разворачивать
|
|
634
|
+
// нечего — первая строка данных стала бы «ключами» (в т.ч. картинка →
|
|
635
|
+
// ключ `**<img …>:**`). Такую таблицу отдаём как GFM.
|
|
636
|
+
if (headerCount === 0 || headerCount >= grid.length)
|
|
637
|
+
return this.readableTable(table);
|
|
637
638
|
const header = grid[headerCount - 1];
|
|
638
639
|
const titleCells = grid
|
|
639
640
|
.slice(0, headerCount - 1)
|
package/dist/publish/publish.js
CHANGED
|
@@ -28,15 +28,19 @@ export function computeContentHash(storage) {
|
|
|
28
28
|
const canonical = storage.replace(/(\/download\/attachments\/[^"?\s]+)\?[^"\s]*/g, '$1');
|
|
29
29
|
return createHash('sha256').update(canonical, 'utf-8').digest('hex');
|
|
30
30
|
}
|
|
31
|
-
// Схема
|
|
31
|
+
// Схема content property. Схема 1 подставляла в body download-URL из
|
|
32
32
|
// _links.download как есть — с ?version=N&modificationDate=…; при ребампе
|
|
33
33
|
// аттача без изменения текста страница оставалась UNCHANGED и продолжала
|
|
34
34
|
// отдавать старую, пиненную версию картинки. Схема 2 подставляет
|
|
35
35
|
// канонический URL без query — Confluence по нему отдаёт последнюю версию
|
|
36
|
-
// аттача, и обновление диаграммы видно без переписывания body.
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
36
|
+
// аттача, и обновление диаграммы видно без переписывания body. Схема 3
|
|
37
|
+
// добавляет pageVersion — номер версии страницы после нашей последней
|
|
38
|
+
// записи; по нему детектится «дрейф» (страницу правили мимо публикатора:
|
|
39
|
+
// version.number вырос, а hash-свойство осталось прежним → без этой проверки
|
|
40
|
+
// испорченная страница никогда бы не восстановилась из markdown). Property со
|
|
41
|
+
// схемой ≠ текущей (в т.ч. без поля scheme/pageVersion) считается устаревшей —
|
|
42
|
+
// hash не сверяется, страница один раз переписывается в новом формате.
|
|
43
|
+
const HASH_SCHEME = 3;
|
|
40
44
|
/** Канонический download-URL аттача: без query (?version=N&…). */
|
|
41
45
|
function canonicalDownloadUrl(url) {
|
|
42
46
|
return url.split('?')[0];
|
|
@@ -203,42 +207,71 @@ export async function publishPage(opts, cfg) {
|
|
|
203
207
|
storage,
|
|
204
208
|
};
|
|
205
209
|
}
|
|
206
|
-
// 3.
|
|
210
|
+
// 3. Решение о публикации. Hash и версия нашей последней записи хранятся в
|
|
211
|
+
// content property (не в body — normalize storage её бы съел). Страница
|
|
212
|
+
// и её версия так и так читаются перед сравнением. Причина публикации
|
|
213
|
+
// (для диагностики инцидентов пишется в лог):
|
|
214
|
+
// no-property — свойства нет (мы эту страницу ещё не публиковали);
|
|
215
|
+
// hash-mismatch — контент изменился (или свойство устаревшей схемы);
|
|
216
|
+
// title-change — сменился заголовок при том же контенте;
|
|
217
|
+
// drift — страницу правили мимо публикатора (version.number
|
|
218
|
+
// разошёлся с записанным нами) → чиним из markdown.
|
|
207
219
|
const newHash = computeContentHash(storage);
|
|
208
220
|
const [existing, hashProp] = await Promise.all([
|
|
209
221
|
client.getPageStorage(pageId),
|
|
210
222
|
client.getContentProperty(pageId, hashKey),
|
|
211
223
|
]);
|
|
212
224
|
const title = opts.title ?? existing.title;
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
225
|
+
// Только свойство текущей схемы несёт доверенные hash + pageVersion.
|
|
226
|
+
// Устаревшая схема (или без scheme) — «версия неизвестна»: hash не сверяем
|
|
227
|
+
// (existingHash = null → одна принудительная публикация с записью нового
|
|
228
|
+
// формата; она же самовосстанавливает ранее испорченные страницы).
|
|
216
229
|
const propValue = hashProp && typeof hashProp.value === 'object' && hashProp.value !== null
|
|
217
230
|
? hashProp.value
|
|
218
231
|
: null;
|
|
219
|
-
const
|
|
232
|
+
const isCurrentScheme = propValue !== null && propValue.scheme === HASH_SCHEME;
|
|
233
|
+
const existingHash = isCurrentScheme
|
|
220
234
|
? (propValue.hash ?? null)
|
|
221
235
|
: null;
|
|
222
|
-
|
|
236
|
+
const publishedVersion = isCurrentScheme && typeof propValue.pageVersion === 'number'
|
|
237
|
+
? propValue.pageVersion
|
|
238
|
+
: null;
|
|
239
|
+
let reason;
|
|
240
|
+
if (propValue === null)
|
|
241
|
+
reason = 'no-property';
|
|
242
|
+
else if (existingHash !== newHash)
|
|
243
|
+
reason = 'hash-mismatch';
|
|
244
|
+
else if (title !== existing.title)
|
|
245
|
+
reason = 'title-change';
|
|
246
|
+
else if (publishedVersion !== existing.version)
|
|
247
|
+
reason = 'drift';
|
|
248
|
+
else
|
|
249
|
+
reason = null;
|
|
250
|
+
if (reason === null) {
|
|
223
251
|
console.log(`[publish] ${pageId} "${title}" → UNCHANGED (hash ${newHash.slice(0, 12)}, v${existing.version})`);
|
|
224
252
|
if (opts.labels?.length)
|
|
225
253
|
await client.addLabels(pageId, opts.labels);
|
|
226
254
|
return { pageId, title, version: existing.version, attachments, updated: false, created, storage };
|
|
227
255
|
}
|
|
228
256
|
// 4. Обновление страницы — только после успешного аплоада всех аттачей.
|
|
229
|
-
|
|
230
|
-
await client.updatePage(pageId, {
|
|
257
|
+
// Версию берём из ответа Confluence (update инкрементирует её сам).
|
|
258
|
+
const nextVersion = await client.updatePage(pageId, {
|
|
231
259
|
title,
|
|
232
|
-
version:
|
|
260
|
+
version: existing.version + 1,
|
|
233
261
|
storage,
|
|
234
262
|
versionMessage: opts.versionMessage,
|
|
235
263
|
});
|
|
236
|
-
// 5. Запись/обновление content property
|
|
237
|
-
// updatePage чтобы при сбое publish
|
|
238
|
-
|
|
264
|
+
// 5. Запись/обновление content property: hash + версия, которая ПОЛУЧИЛАСЬ
|
|
265
|
+
// после нашей записи. Делаем ПОСЛЕ updatePage, чтобы при сбое publish
|
|
266
|
+
// свойство не «опередило» реальное содержимое. pageVersion закрывает
|
|
267
|
+
// слепую зону дрейфа (см. HASH_SCHEME).
|
|
268
|
+
await client.setContentProperty(pageId, hashKey, { hash: newHash, scheme: HASH_SCHEME, pageVersion: nextVersion }, hashProp ? hashProp.version : null);
|
|
239
269
|
if (opts.labels?.length)
|
|
240
270
|
await client.addLabels(pageId, opts.labels);
|
|
241
|
-
|
|
271
|
+
const detail = reason === 'drift'
|
|
272
|
+
? `DRIFT (page v${existing.version} != published v${publishedVersion})`
|
|
273
|
+
: reason;
|
|
274
|
+
console.log(`[publish] ${pageId} "${title}" → ${detail} → v${nextVersion} (hash ${newHash.slice(0, 12)})`);
|
|
242
275
|
return { pageId, title, version: nextVersion, attachments, updated: true, created, storage };
|
|
243
276
|
}
|
|
244
277
|
function escapeRegex(s) {
|
package/package.json
CHANGED