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 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).
@@ -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<void>;
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[]>;
@@ -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
- if (headerCount === 0)
634
- headerCount = 1; // нет <th>-шапки первая строка как ключи
635
- if (headerCount >= grid.length)
636
- headerCount = 1; // не съедать всю таблицу
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)
@@ -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
- // Схема записи download-URL в body. Схема 1 подставляла URL из
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. Property со
37
- // схемой текущей т.ч. без поля scheme) считается устаревшей — страница
38
- // один раз переписывается каноническими URL.
39
- const HASH_SCHEME = 2;
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. Content-hash check. Hash хранится в content property, не в body.
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
- // Property, писанная другой схемой (или до появления scheme), не считается
214
- // совпадением: body мог быть записан с пином версий аттачей его нужно
215
- // один раз переписать каноническими URL.
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 existingHash = propValue && propValue.scheme === HASH_SCHEME
232
+ const isCurrentScheme = propValue !== null && propValue.scheme === HASH_SCHEME;
233
+ const existingHash = isCurrentScheme
220
234
  ? (propValue.hash ?? null)
221
235
  : null;
222
- if (existingHash === newHash && title === existing.title) {
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
- const nextVersion = existing.version + 1;
230
- await client.updatePage(pageId, {
257
+ // Версию берём из ответа Confluence (update инкрементирует её сам).
258
+ const nextVersion = await client.updatePage(pageId, {
231
259
  title,
232
- version: nextVersion,
260
+ version: existing.version + 1,
233
261
  storage,
234
262
  versionMessage: opts.versionMessage,
235
263
  });
236
- // 5. Запись/обновление content property с новым hash. Делаем ПОСЛЕ
237
- // updatePage чтобы при сбое publish hash не «опередил» реальное содержимое.
238
- await client.setContentProperty(pageId, hashKey, { hash: newHash, scheme: HASH_SCHEME }, hashProp ? hashProp.version : null);
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
- console.log(`[publish] ${pageId} "${title}" v${nextVersion} (hash ${newHash.slice(0, 12)})`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "confluence-md-sync",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Publish Markdown to Confluence (Data Center & Cloud): idempotent page sync, attachment dedup, tables and a pluggable macro system",
5
5
  "keywords": [
6
6
  "confluence",