confluence-md-sync 0.8.0 → 0.8.1

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
@@ -327,7 +327,7 @@ that doesn't fit falls back to markers/fenced blocks automatically.
327
327
  | Macro | Markdown |
328
328
  | --- | --- |
329
329
  | `info` / `note` / `warning` / `tip` | `> [!INFO] Title?` + quoted body (GitHub-style admonition) |
330
- | `details` (Page Properties) | `::: properties [id=…] [hidden=true]` + md-table + `:::` |
330
+ | `details` (Page Properties) | `::: properties [id=…] [hidden=true]` + md-table + `:::` — complex Confluence tables are normalized to GFM on export (styling dropped, cell text verified; `stats.normalized`) |
331
331
  | `expand` | `::: expand Title` + body + `:::` |
332
332
  | `panel` | `::: panel title=… borderColor=…` + body + `:::` |
333
333
  | `toc` | `{{toc}}` / `{{toc:maxLevel=3}}` |
@@ -63,6 +63,8 @@ export interface StorageToMarkdownResult {
63
63
  lossy: number;
64
64
  /** Макросы, выраженные нативным md-синтаксисом (панели, ::: …, {{…}}). */
65
65
  native: number;
66
+ /** details, чья таблица нормализована в GFM (оформление потеряно, текст сверен). */
67
+ normalized: number;
66
68
  };
67
69
  }
68
70
  /** Конвертирует storage-фрагмент страницы в Markdown. */
@@ -79,7 +79,7 @@ class Converter {
79
79
  registry;
80
80
  images = new Set();
81
81
  files = new Set();
82
- stats = { markers: 0, fenced: 0, rawHtml: 0, lossy: 0, native: 0 };
82
+ stats = { markers: 0, fenced: 0, rawHtml: 0, lossy: 0, native: 0, normalized: 0 };
83
83
  readable;
84
84
  localFiles;
85
85
  tablesAsRecords;
@@ -334,19 +334,32 @@ class Converter {
334
334
  const nonWs = richBody.children.filter((n) => !(n.kind === 'text' && /^[ \t\r\n]*$/.test(n.raw)));
335
335
  if (bodyEls.length !== 1 || nonWs.length !== 1 || bodyEls[0].name !== 'table')
336
336
  return null;
337
- let tableMd;
337
+ const dp = [...pmap.entries()].map(([k, v]) => (oneline(v) && !/["\s]/.test(v) ? ` ${k}=${v}` : null));
338
+ if (dp.some((x) => x === null))
339
+ return null;
340
+ let tableMd = null;
338
341
  try {
339
342
  tableMd = this.tableToMd(bodyEls[0]);
340
343
  }
341
344
  catch (e) {
342
- if (e instanceof Unrepresentable)
345
+ if (!(e instanceof Unrepresentable))
346
+ throw e;
347
+ }
348
+ if (tableMd !== null) {
349
+ candidate = `::: properties${dp.join('')}\n${tableMd}\n:::`;
350
+ }
351
+ else {
352
+ // «Свойства страницы» обязаны жить md-таблицей: сложную таблицу
353
+ // нормализуем в GFM (оформление теряется, текст сверяется рендером).
354
+ const norm = this.propertiesGridMd(bodyEls[0]);
355
+ if (norm === null)
343
356
  return null;
344
- throw e;
357
+ const cand = `::: properties${dp.join('')}\n${norm.md}\n:::`;
358
+ if (!this.verifyNormalizedDetails(cand, norm.rows))
359
+ return null;
360
+ this.stats.normalized++;
361
+ return cand; // канонической эквивалентности нет по построению — верифицирован текст
345
362
  }
346
- const dp = [...pmap.entries()].map(([k, v]) => (oneline(v) && !/["\s]/.test(v) ? ` ${k}=${v}` : null));
347
- if (dp.some((x) => x === null))
348
- return null;
349
- candidate = `::: properties${dp.join('')}\n${tableMd}\n:::`;
350
363
  }
351
364
  else if (name === 'expand' && richBody !== null) {
352
365
  if (![...pmap.keys()].every((k) => k === 'title'))
@@ -518,6 +531,110 @@ class Converter {
518
531
  return this.verifyMacroMarker(el, markerMd) ? markerMd : null;
519
532
  }
520
533
  /** Маркер → render-конвейер → канонически равен исходному макросу? */
534
+ /** Таблица details → нормализованный GFM: пустые колонки (нумерация) отброшены,
535
+ * заголовок синтезируется («Поле|Значение» для двух колонок). null — не таблица. */
536
+ /** Убирает ac:/ri:-вставки без текста (упоминания пользователей, эмодзи):
537
+ * в md им нечем быть, а их присутствие роняло flatten всей ячейки. */
538
+ stripEmptyNamespaced(nodes) {
539
+ const out = [];
540
+ for (const n of nodes) {
541
+ if (n.kind === 'el') {
542
+ if (n.name.includes(':') && textContent(n.children).trim() === '')
543
+ continue;
544
+ out.push({ ...n, children: this.stripEmptyNamespaced(n.children) });
545
+ }
546
+ else
547
+ out.push(n);
548
+ }
549
+ return out;
550
+ }
551
+ /** Таблица details → нормализованный GFM: пустые колонки (нумерация) отброшены,
552
+ * заголовок синтезируется («Поле|Значение» для двух колонок). Ячейки — readable-
553
+ * flatten; несовместимые вставки (напр. упоминание пользователя) — в голый текст.
554
+ * null — не таблица/пусто. */
555
+ propertiesGridMd(table) {
556
+ const saved = this.readable;
557
+ this.readable = true;
558
+ try {
559
+ const trs = [];
560
+ for (const child of elements(table.children)) {
561
+ if (['thead', 'tbody', 'tfoot'].includes(child.name)) {
562
+ for (const tr of elements(child.children))
563
+ if (tr.name === 'tr')
564
+ trs.push(tr);
565
+ }
566
+ else if (child.name === 'tr')
567
+ trs.push(child);
568
+ }
569
+ if (trs.length === 0)
570
+ return null;
571
+ const grid = trs.map((tr) => elements(tr.children)
572
+ .filter((c) => c.name === 'td' || c.name === 'th')
573
+ .map((c) => {
574
+ let flat;
575
+ try {
576
+ flat = this.cellFlatten(this.stripEmptyNamespaced(c.children));
577
+ }
578
+ catch (e) {
579
+ if (!(e instanceof Unrepresentable))
580
+ throw e;
581
+ flat = textContent(c.children);
582
+ }
583
+ return flat.replace(/\s+/g, ' ').trim();
584
+ }));
585
+ const cols = Math.max(...grid.map((r) => r.length));
586
+ const norm = grid.map((r) => Array.from({ length: cols }, (_, i) => r[i] ?? ''));
587
+ const keep = Array.from({ length: cols }, (_, i) => norm.some((r) => r[i] !== ''));
588
+ const rows = norm.map((r) => r.filter((_, i) => keep[i])).filter((r) => r.some((c) => c !== ''));
589
+ const width = rows[0]?.length ?? 0;
590
+ if (width === 0 || rows.some((r) => r.length !== width))
591
+ return null;
592
+ const esc = (s) => s.replace(/\|/g, '\\|');
593
+ const header = width === 2 ? ['Поле', 'Значение'] : rows[0].map(() => ' ');
594
+ const line = (cells) => `| ${cells.map(esc).join(' | ')} |`;
595
+ const md = [line(header), `| ${header.map(() => '---').join(' | ')} |`, ...rows.map(line)].join('\n');
596
+ return { md, rows };
597
+ }
598
+ finally {
599
+ this.readable = saved;
600
+ }
601
+ }
602
+ /** Нормализованные details: рендерим кандидата и сверяем ТЕКСТ ячеек с исходным. */
603
+ verifyNormalizedDetails(candidate, rows) {
604
+ try {
605
+ let storage = renderToStorage(candidate, { images: new Map(), files: new Map() }, { imageStyle: 'attachment', fileStyle: 'attachment', linkify: false });
606
+ storage = processMacros(storage, this.registry).toString();
607
+ let renderedTable = null;
608
+ const walk = (ns) => {
609
+ for (const n of elements(ns)) {
610
+ if (renderedTable)
611
+ return;
612
+ if (n.name === 'table') {
613
+ renderedTable = n;
614
+ return;
615
+ }
616
+ walk(n.children);
617
+ }
618
+ };
619
+ walk(parseStorage(storage));
620
+ if (!renderedTable)
621
+ return false;
622
+ const backNorm = this.propertiesGridMd(renderedTable);
623
+ if (backNorm === null)
624
+ return false;
625
+ const back = backNorm.rows;
626
+ const width = rows[0]?.length ?? 0;
627
+ const headerIsSynth = back.length > 0 &&
628
+ (width === 2 ? back[0][0] === 'Поле' && back[0][1] === 'Значение' : back[0].every((c) => c === ''));
629
+ const body = headerIsSynth ? back.slice(1) : back;
630
+ if (body.length !== rows.length)
631
+ return false;
632
+ return body.every((r, i) => r.length === rows[i].length && r.every((c, j) => c === rows[i][j]));
633
+ }
634
+ catch {
635
+ return false;
636
+ }
637
+ }
521
638
  verifyMacroMarker(el, markerMd) {
522
639
  try {
523
640
  let storage = renderToStorage(markerMd, { images: new Map(), files: new Map() }, { imageStyle: 'attachment', fileStyle: 'attachment', linkify: false });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "confluence-md-sync",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
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",