backlog-exporter 1.1.0 → 1.2.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.
@@ -0,0 +1 @@
1
+ export declare function convertDocumentJsonToMarkdown(json: unknown): string;
@@ -0,0 +1,389 @@
1
+ const BLOCK_TYPES = new Set([
2
+ 'blockquote',
3
+ 'bulletList',
4
+ 'codeBlock',
5
+ 'heading',
6
+ 'horizontalRule',
7
+ 'orderedList',
8
+ 'paragraph',
9
+ 'table',
10
+ ]);
11
+ // マーク適用順。codeは最内側に置き、他の記号がコードスパンの外に出るようにする
12
+ const MARK_ORDER = ['code', 'bold', 'italic', 'strike', 'link'];
13
+ function isNode(value) {
14
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
15
+ }
16
+ function isBlockLike(node) {
17
+ return typeof node.type === 'string' && BLOCK_TYPES.has(node.type);
18
+ }
19
+ function childNodes(node) {
20
+ return Array.isArray(node.content) ? node.content.filter((child) => isNode(child)) : [];
21
+ }
22
+ function nodeMarks(node) {
23
+ return (Array.isArray(node.marks) ? node.marks : []).filter((mark) => isNode(mark));
24
+ }
25
+ function attrString(node, key) {
26
+ const value = node.attrs?.[key];
27
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
28
+ }
29
+ function markOrder(mark) {
30
+ const index = MARK_ORDER.indexOf(mark.type ?? '');
31
+ // 未知のマークは最外側に置く(-1で最内側に来るのを防ぐ)
32
+ return index === -1 ? MARK_ORDER.length : index;
33
+ }
34
+ function sortMarks(marks) {
35
+ return [...marks].sort((a, b) => markOrder(a) - markOrder(b));
36
+ }
37
+ // 隣接テキストのマークが同一かの判定用。属性まで含めて比較する
38
+ function markKey(mark) {
39
+ return `${mark.type ?? ''}:${JSON.stringify(mark.attrs ?? {})}`;
40
+ }
41
+ function marksKey(marks) {
42
+ return sortMarks(marks)
43
+ .map((mark) => markKey(mark))
44
+ .join('|');
45
+ }
46
+ // 連続するバッククォートの最長連を求め、それより長いフェンス/デリミタを選ぶ
47
+ function longestBacktickRun(text) {
48
+ let longest = 0;
49
+ for (const run of text.matchAll(/`+/g)) {
50
+ longest = Math.max(longest, run[0].length);
51
+ }
52
+ return longest;
53
+ }
54
+ function inlineCode(text) {
55
+ const delimiter = '`'.repeat(longestBacktickRun(text) + 1);
56
+ // 内容の端がバッククォートの場合、区切りと繋がらないよう空白で隔てる
57
+ const pad = text.startsWith('`') || text.endsWith('`') ? ' ' : '';
58
+ return `${delimiter}${pad}${text}${pad}${delimiter}`;
59
+ }
60
+ // CommonMarkのリンク先は空白・括弧を含む場合<>で囲う必要がある
61
+ function linkDestination(destination) {
62
+ return /[\s()]/.test(destination) ? `<${destination}>` : destination;
63
+ }
64
+ function escapeBracketText(text) {
65
+ return text.replaceAll('[', String.raw `\[`).replaceAll(']', String.raw `\]`);
66
+ }
67
+ // 行頭に来るとブロック記法として解釈される文字をエスケープする。
68
+ // リスト内の字下げはリスト側で付けるため、ここではテキスト自身の先頭空白だけを見る
69
+ function escapeBlockStart(text) {
70
+ return (text
71
+ // 4スペース以上の字下げはコードブロックになるため3つまでに詰める
72
+ .replace(/^ {4,}/, ' ')
73
+ .replace(/^(\s*)([#>])/, String.raw `$1\$2`)
74
+ .replace(/^(\s*)(~~~)/, String.raw `$1\$2`)
75
+ .replace(/^(\s*)([+-])(\s)/, String.raw `$1\$2$3`)
76
+ .replace(/^(\s*)(\d{1,9})([).])(\s)/, String.raw `$1$2\$3$4`)
77
+ // setext見出しの下線(=== や ---)は行全体がその文字のときだけ成立する
78
+ .replace(/^(\s*)([=-])(?=[=-]*\s*$)/, String.raw `$1\$2`));
79
+ }
80
+ // 自動リンクされるURLはエスケープするとリンク先が壊れるため素通しする
81
+ const URL_PATTERN = /https?:\/\/[^\s<>]+/g;
82
+ // インラインの強調記法として解釈されうる文字のみを対象にする(CJK約物は対象外)
83
+ function escapeInline(text) {
84
+ let result = '';
85
+ let last = 0;
86
+ for (const match of text.matchAll(URL_PATTERN)) {
87
+ result += escapeInlineSpan(text.slice(last, match.index)) + match[0];
88
+ last = match.index + match[0].length;
89
+ }
90
+ return result + escapeInlineSpan(text.slice(last));
91
+ }
92
+ function escapeInlineSpan(text) {
93
+ return text.replaceAll(/[[\\\]_`*<]/g, String.raw `\$&`);
94
+ }
95
+ function applyMark(text, mark) {
96
+ switch (mark.type) {
97
+ case 'bold': {
98
+ return wrapKeepingWhitespace(text, '**');
99
+ }
100
+ case 'code': {
101
+ return inlineCode(text);
102
+ }
103
+ case 'italic': {
104
+ return wrapKeepingWhitespace(text, '*');
105
+ }
106
+ case 'link': {
107
+ const href = typeof mark.attrs?.href === 'string' ? mark.attrs.href : '';
108
+ return `[${text}](${linkDestination(href)})`;
109
+ }
110
+ case 'strike': {
111
+ return wrapKeepingWhitespace(text, '~~');
112
+ }
113
+ default: {
114
+ return text;
115
+ }
116
+ }
117
+ }
118
+ // 区切り記号の内側に空白があると強調が成立しないため、空白を外側へ追い出す
119
+ function wrapKeepingWhitespace(text, delimiter) {
120
+ const match = /^(\s*)([\s\S]*?)(\s*)$/.exec(text);
121
+ if (!match)
122
+ return `${delimiter}${text}${delimiter}`;
123
+ const [, leading, core, trailing] = match;
124
+ if (core === '')
125
+ return text;
126
+ return `${leading}${delimiter}${core}${delimiter}${trailing}`;
127
+ }
128
+ function renderMarkedText(text, marks, atLineStart) {
129
+ if (text === '')
130
+ return '';
131
+ const hasCode = marks.some((mark) => mark.type === 'code');
132
+ // コードマーク内はエスケープせず原文のまま出す
133
+ let result = hasCode ? text : escapeInline(text);
134
+ if (!hasCode && atLineStart)
135
+ result = escapeBlockStart(result);
136
+ for (const mark of sortMarks(marks)) {
137
+ result = applyMark(result, mark);
138
+ }
139
+ return result;
140
+ }
141
+ // 同じマークの連続テキストは1つに束ねる(**a****b** のような無効な出力を防ぐ)
142
+ function coalesceTextNodes(nodes) {
143
+ const result = [];
144
+ for (const node of nodes) {
145
+ const previous = result.at(-1);
146
+ if (node.type === 'text' &&
147
+ previous?.type === 'text' &&
148
+ marksKey(nodeMarks(previous)) === marksKey(nodeMarks(node))) {
149
+ result[result.length - 1] = { ...previous, text: (previous.text ?? '') + (node.text ?? '') };
150
+ continue;
151
+ }
152
+ result.push(node);
153
+ }
154
+ return result;
155
+ }
156
+ // 隣接テキストが共通の外側マーク(**等)を持つ場合、閉じて開き直すと強調が壊れる。
157
+ // 共通マークで全体を1度だけ包み、内側の差分マークだけを各テキストに適用する
158
+ function renderTextRun(nodes, atLineStart) {
159
+ const shared = [];
160
+ if (nodes.length > 1) {
161
+ const first = sortMarks(nodeMarks(nodes[0]));
162
+ for (const mark of first) {
163
+ // codeは内容ごとに区切りが変わるため共通化しない
164
+ if (mark.type === 'code')
165
+ continue;
166
+ if (nodes.every((node) => nodeMarks(node).some((other) => markKey(other) === markKey(mark)))) {
167
+ shared.push(mark);
168
+ }
169
+ }
170
+ }
171
+ const sharedKeys = new Set(shared.map((mark) => markKey(mark)));
172
+ let inner = '';
173
+ for (const [index, node] of nodes.entries()) {
174
+ const rest = nodeMarks(node).filter((mark) => !sharedKeys.has(markKey(mark)));
175
+ inner += renderMarkedText(node.text ?? '', rest, atLineStart && index === 0);
176
+ }
177
+ let result = inner;
178
+ for (const mark of sortMarks(shared)) {
179
+ result = applyMark(result, mark);
180
+ }
181
+ return result;
182
+ }
183
+ // 末尾/先頭のhardBreakは余分な空行になるため落とす
184
+ function trimHardBreaks(nodes) {
185
+ let start = 0;
186
+ let end = nodes.length;
187
+ while (start < end && nodes[start].type === 'hardBreak')
188
+ start++;
189
+ while (end > start && nodes[end - 1].type === 'hardBreak')
190
+ end--;
191
+ return nodes.slice(start, end);
192
+ }
193
+ function renderInline(nodes, atBlockStart = false) {
194
+ // 空テキストはマークの結合を妨げるだけなので取り除く
195
+ const meaningful = nodes.filter((node) => node.type !== 'text' || (node.text ?? '') !== '');
196
+ const prepared = coalesceTextNodes(trimHardBreaks(meaningful));
197
+ let result = '';
198
+ let atLineStart = atBlockStart;
199
+ for (let index = 0; index < prepared.length; index++) {
200
+ const node = prepared[index];
201
+ const isStart = atLineStart;
202
+ // 行頭とみなせるのはブロック先頭とhardBreak直後だけ
203
+ atLineStart = false;
204
+ switch (node.type) {
205
+ case 'attachmentBadge': {
206
+ result += renderAttachmentBadge(node);
207
+ break;
208
+ }
209
+ case 'hardBreak': {
210
+ result += ' \n';
211
+ atLineStart = true;
212
+ break;
213
+ }
214
+ case 'image': {
215
+ result += renderImage(node);
216
+ break;
217
+ }
218
+ case 'issueMention': {
219
+ result += renderIssueMention(node);
220
+ break;
221
+ }
222
+ case 'text': {
223
+ // 連続するテキストはまとめて処理し、共通マークを1度だけ適用する
224
+ let end = index;
225
+ while (end + 1 < prepared.length && prepared[end + 1].type === 'text')
226
+ end++;
227
+ result += renderTextRun(prepared.slice(index, end + 1), isStart);
228
+ index = end;
229
+ break;
230
+ }
231
+ default: {
232
+ result += renderInline(childNodes(node), isStart);
233
+ }
234
+ }
235
+ }
236
+ return result;
237
+ }
238
+ function renderImage(node) {
239
+ const src = attrString(node, 'src') ?? '';
240
+ const alt = escapeBracketText(attrString(node, 'alt') ?? '');
241
+ const title = attrString(node, 'title');
242
+ const destination = linkDestination(src);
243
+ return title ? `![${alt}](${destination} "${title}")` : `![${alt}](${destination})`;
244
+ }
245
+ // Backlog独自ノード。attrsの形が公開されていないため、ラベルになりうるキーを順に探す
246
+ function renderAttachmentBadge(node) {
247
+ const label = attrString(node, 'name') ??
248
+ attrString(node, 'title') ??
249
+ attrString(node, 'fileName') ??
250
+ attrString(node, 'text') ??
251
+ renderInline(childNodes(node));
252
+ const src = attrString(node, 'src') ?? attrString(node, 'href') ?? attrString(node, 'url');
253
+ const resolved = escapeBracketText(label.length > 0 ? label : '添付ファイル');
254
+ return src ? `[${resolved}](${linkDestination(src)})` : resolved;
255
+ }
256
+ // Backlog独自ノード。課題キーを持つキーを順に探す
257
+ function renderIssueMention(node) {
258
+ const key = attrString(node, 'issueKey') ??
259
+ attrString(node, 'key') ??
260
+ attrString(node, 'label') ??
261
+ attrString(node, 'text') ??
262
+ renderInline(childNodes(node));
263
+ return key;
264
+ }
265
+ function renderCodeBlock(node) {
266
+ const language = attrString(node, 'language');
267
+ // Backlogは言語未指定を"auto"として返す
268
+ const info = language && language !== 'auto' ? language : '';
269
+ const code = childNodes(node)
270
+ .map((child) => (typeof child.text === 'string' ? child.text : ''))
271
+ .join('');
272
+ // 内容に含まれるバッククォート連より長いフェンスにしてブロックが途中で閉じるのを防ぐ
273
+ const fence = '`'.repeat(Math.max(3, longestBacktickRun(code) + 1));
274
+ return `${fence}${info}\n${code}\n${fence}`;
275
+ }
276
+ function renderBlockquote(node) {
277
+ return renderBlocks(childNodes(node))
278
+ .split('\n')
279
+ .map((line) => (line === '' ? '>' : `> ${line}`))
280
+ .join('\n');
281
+ }
282
+ function renderListItem(node, marker) {
283
+ const children = childNodes(node);
284
+ const rendered = children.map((child) => child.type === 'bulletList' || child.type === 'orderedList' ? renderList(child) : renderBlock(child));
285
+ let body = '';
286
+ for (const [index, block] of rendered.entries()) {
287
+ if (block === '')
288
+ continue;
289
+ if (body === '') {
290
+ body = block;
291
+ continue;
292
+ }
293
+ // 段落直後のネストリストはtight listとして1改行で繋ぐ
294
+ const isNestedList = children[index].type === 'bulletList' || children[index].type === 'orderedList';
295
+ body += isNestedList ? `\n${block}` : `\n\n${block}`;
296
+ }
297
+ if (body === '')
298
+ return marker.trimEnd();
299
+ const indent = ' '.repeat(marker.length);
300
+ return body
301
+ .split('\n')
302
+ .map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`))
303
+ .join('\n');
304
+ }
305
+ function renderList(node) {
306
+ const ordered = node.type === 'orderedList';
307
+ const startAttr = node.attrs?.start;
308
+ const start = ordered && typeof startAttr === 'number' && Number.isInteger(startAttr) ? startAttr : 1;
309
+ return childNodes(node)
310
+ .map((item, index) => renderListItem(item, ordered ? `${start + index}. ` : '- '))
311
+ .join('\n');
312
+ }
313
+ function renderCell(node) {
314
+ return childNodes(node)
315
+ .map((child) => renderBlock(child))
316
+ .filter((block) => block !== '')
317
+ .join('<br>')
318
+ .replaceAll(/ {0,2}\n/g, '<br>')
319
+ .replaceAll('|', String.raw `\|`);
320
+ }
321
+ function renderTable(node) {
322
+ const rows = childNodes(node).filter((row) => row.type === 'tableRow' || childNodes(row).length > 0);
323
+ if (rows.length === 0)
324
+ return '';
325
+ const cellRows = rows.map((row) => childNodes(row));
326
+ const columnCount = Math.max(...cellRows.map((cells) => cells.length));
327
+ if (columnCount === 0)
328
+ return '';
329
+ const lines = cellRows.map((cells) => {
330
+ const rendered = cells.map((cell) => renderCell(cell));
331
+ while (rendered.length < columnCount)
332
+ rendered.push('');
333
+ return `| ${rendered.join(' | ')} |`;
334
+ });
335
+ // 1行目がtableHeaderでなくてもGitHub Markdownとして描画されるよう区切り行を必ず入れる
336
+ const separator = `| ${Array.from({ length: columnCount }, () => '---').join(' | ')} |`;
337
+ return [lines[0], separator, ...lines.slice(1)].join('\n');
338
+ }
339
+ function renderBlock(node) {
340
+ switch (node.type) {
341
+ case 'blockquote': {
342
+ return renderBlockquote(node);
343
+ }
344
+ case 'bulletList':
345
+ case 'orderedList': {
346
+ return renderList(node);
347
+ }
348
+ case 'codeBlock': {
349
+ return renderCodeBlock(node);
350
+ }
351
+ case 'heading': {
352
+ const levelAttr = node.attrs?.level;
353
+ const level = typeof levelAttr === 'number' && levelAttr >= 1 && levelAttr <= 6 ? Math.trunc(levelAttr) : 1;
354
+ const inline = renderInline(childNodes(node));
355
+ return inline === '' ? '' : `${'#'.repeat(level)} ${inline}`;
356
+ }
357
+ case 'horizontalRule': {
358
+ return '---';
359
+ }
360
+ case 'image': {
361
+ return renderImage(node);
362
+ }
363
+ case 'paragraph': {
364
+ return renderInline(childNodes(node), true);
365
+ }
366
+ case 'table': {
367
+ return renderTable(node);
368
+ }
369
+ default: {
370
+ const children = childNodes(node);
371
+ if (children.length === 0)
372
+ return renderInline([node], true);
373
+ // 未知のブロックは内容を落とさずそのまま連結する
374
+ return children.some((child) => isBlockLike(child)) ? renderBlocks(children) : renderInline(children, true);
375
+ }
376
+ }
377
+ }
378
+ function renderBlocks(nodes) {
379
+ return nodes
380
+ .map((node) => renderBlock(node))
381
+ .filter((block) => block !== '')
382
+ .join('\n\n');
383
+ }
384
+ export function convertDocumentJsonToMarkdown(json) {
385
+ if (!isNode(json))
386
+ return '';
387
+ const blocks = json.type === 'doc' ? childNodes(json) : [json];
388
+ return renderBlocks(blocks);
389
+ }
@@ -1,2 +1,4 @@
1
1
  import { DocumentDetail } from './document.js';
2
+ export declare const EMPTY_BODY_PLACEHOLDER = "\uFF08\u5185\u5BB9\u306A\u3057\uFF09";
3
+ export declare function buildDocumentBody(documentDetail: Pick<DocumentDetail, 'json' | 'plain'>): string;
2
4
  export declare function buildDocumentMarkdown(documentDetail: DocumentDetail, backlogDocumentUrl: string, attachmentLinks?: Map<number, string>): string;
@@ -1,5 +1,15 @@
1
1
  import { escapeLinkText } from '../../../shared/attachment.js';
2
2
  import { wrapBody } from '../../../shared/markdown/body-marker.js';
3
+ import { convertDocumentJsonToMarkdown } from './document-json-markdown.js';
4
+ export const EMPTY_BODY_PLACEHOLDER = '(内容なし)';
5
+ // plainはBacklog側で改行が失われることがあるため、構造を持つjsonを優先する。
6
+ // 本文の有無の判定(親indexの保存要否)もこの結果を唯一の基準にする
7
+ export function buildDocumentBody(documentDetail) {
8
+ const fromJson = convertDocumentJsonToMarkdown(documentDetail.json);
9
+ if (fromJson.trim() !== '')
10
+ return fromJson;
11
+ return documentDetail.plain?.trim() ? documentDetail.plain : '';
12
+ }
3
13
  export function buildDocumentMarkdown(documentDetail, backlogDocumentUrl, attachmentLinks) {
4
14
  // 添付ファイルリストの作成(ダウンロード済みはローカルへの相対リンク付き)
5
15
  let attachmentsSection = '';
@@ -36,5 +46,5 @@ export function buildDocumentMarkdown(documentDetail, backlogDocumentUrl, attach
36
46
 
37
47
  ## 内容
38
48
 
39
- ${wrapBody(documentDetail.plain || '(内容なし)')}${attachmentsSection}${tagsSection}`;
49
+ ${wrapBody(buildDocumentBody(documentDetail) || EMPTY_BODY_PLACEHOLDER)}${attachmentsSection}${tagsSection}`;
40
50
  }
@@ -1,6 +1,7 @@
1
1
  import { ExpectedPaths } from '../../prune/domain/expected-paths.js';
2
- import { DocumentNode } from './document.js';
2
+ import { DocumentNode, DocumentSummary } from './document.js';
3
3
  export declare const PARENT_DOCUMENT_INDEX_FILENAME = "00_index.md";
4
+ export declare const DOCUMENT_FALLBACK_PARENT_PATH = "";
4
5
  export declare function documentFolderPath(currentPath: string, folderName: string): string;
5
6
  export declare function documentFileName(title: string, asParentIndex: boolean): string;
6
7
  export declare function documentUrl(domain: string, projectIdOrKey: string, documentId: string): string;
@@ -21,3 +22,4 @@ export interface DocumentTreePaths extends ExpectedPaths {
21
22
  }
22
23
  export declare function collectDocumentTreePaths(rootNodes: DocumentNode[]): DocumentTreePaths;
23
24
  export declare function resolveDocumentLeafPaths(paths: DocumentTreePaths, titlesById: Map<string, string>): void;
25
+ export declare function addFallbackDocumentPaths(paths: ExpectedPaths, documents: DocumentSummary[]): void;
@@ -4,6 +4,8 @@ import { backlogOrigin } from '../../../shared/backlog-url.js';
4
4
  import { sanitizeFileName } from '../../../shared/file-name.js';
5
5
  // 子を持つ親ドキュメント自身の本文の保存先。ダウンロード側とprune側でレイアウト定義を共有する
6
6
  export const PARENT_DOCUMENT_INDEX_FILENAME = '00_index.md';
7
+ // ツリーに現れないドキュメントの保存先。ツリー上の位置が分からないため出力ルート直下に置く
8
+ export const DOCUMENT_FALLBACK_PARENT_PATH = '';
7
9
  export function documentFolderPath(currentPath, folderName) {
8
10
  return path.join(currentPath, sanitizeFileName(folderName));
9
11
  }
@@ -55,3 +57,9 @@ export function resolveDocumentLeafPaths(paths, titlesById) {
55
57
  paths.expectedFiles.add(path.join(leaf.currentPath, `${sanitizeFileName(title)}.md`).normalize('NFC'));
56
58
  }
57
59
  }
60
+ // ツリーに現れないドキュメントは出力ルート直下に保存されるため、同じ配置で期待パスに加える
61
+ export function addFallbackDocumentPaths(paths, documents) {
62
+ for (const document of documents) {
63
+ paths.expectedFiles.add(path.join(DOCUMENT_FALLBACK_PARENT_PATH, documentFileName(document.title, false)).normalize('NFC'));
64
+ }
65
+ }
@@ -1,9 +1,10 @@
1
- export type DocumentSaveAction = 'delete-stale-parent-index' | 'save' | 'skip-empty-parent' | 'skip-parent-index-collision' | 'skip-unchanged';
1
+ export type DocumentSaveAction = 'delete-stale-parent-index' | 'save' | 'skip-empty-parent' | 'skip-fallback-collision' | 'skip-parent-index-collision' | 'skip-unchanged';
2
2
  export declare function planDocumentSave(input: {
3
+ alreadyWrittenThisRun: boolean;
3
4
  asParentIndex: boolean;
4
5
  body: null | string | undefined;
6
+ fileExists: boolean;
5
7
  lastUpdated?: string;
6
- parentIndexAlreadyWrittenThisRun: boolean;
7
- parentIndexExists: boolean;
8
+ missingFromTree: boolean;
8
9
  updated: string;
9
10
  }): DocumentSaveAction;
@@ -1,15 +1,21 @@
1
1
  // 保存/スキップ/親index削除の判断。本文が空の親はファイルを作らず、空に変更された場合は古い親indexを削除する
2
2
  export function planDocumentSave(input) {
3
- if (input.asParentIndex && input.parentIndexAlreadyWrittenThisRun) {
3
+ if (input.asParentIndex && input.alreadyWrittenThisRun) {
4
4
  return 'skip-parent-index-collision';
5
5
  }
6
- // 前回の更新日時チェック(親indexがまだ存在しない場合はバックフィルのため未更新でも保存する)
7
- const backfillParentIndex = input.asParentIndex && !input.parentIndexExists;
8
- if (input.lastUpdated && !backfillParentIndex && new Date(input.updated) <= new Date(input.lastUpdated)) {
6
+ // ツリーに現れないドキュメントは出力ルート直下に固定で置かれるため、同名の保存先が既に使われていたら譲る。
7
+ // ツリー上の位置が確かなドキュメントを、位置の分からないドキュメントで黙って上書きしないための保険
8
+ if (input.missingFromTree && input.alreadyWrittenThisRun) {
9
+ return 'skip-fallback-collision';
10
+ }
11
+ // 前回の更新日時チェック。親index・ツリーに現れないドキュメントは、そもそも過去のエクスポートで
12
+ // 取得できていない可能性があるため、ファイルが無い場合は未更新でもバックフィルとして保存する
13
+ const backfill = (input.asParentIndex || input.missingFromTree) && !input.fileExists;
14
+ if (input.lastUpdated && !backfill && new Date(input.updated) <= new Date(input.lastUpdated)) {
9
15
  return 'skip-unchanged';
10
16
  }
11
17
  if (input.asParentIndex && !input.body?.trim()) {
12
- return input.parentIndexExists ? 'delete-stale-parent-index' : 'skip-empty-parent';
18
+ return input.fileExists ? 'delete-stale-parent-index' : 'skip-empty-parent';
13
19
  }
14
20
  return 'save';
15
21
  }
@@ -0,0 +1,2 @@
1
+ import { DocumentSummary, DocumentTree } from './document.js';
2
+ export declare function findDocumentsMissingFromTree(tree: DocumentTree, titlesById: Map<string, string>): DocumentSummary[];
@@ -0,0 +1,21 @@
1
+ // Backlogのドキュメントツリーは、作成後に一度も再保存されていないドキュメントを返さないことがある。
2
+ // ツリーだけを辿るとそれらが構造的に取得できないため、全件が載る一覧APIとの差分で拾う。
3
+ // ゴミ箱のドキュメントを復活させないよう、trashTreeに載っているものは差分から除く。
4
+ export function findDocumentsMissingFromTree(tree, titlesById) {
5
+ const knownIds = new Set();
6
+ const collect = (nodes) => {
7
+ for (const node of nodes ?? []) {
8
+ knownIds.add(node.id);
9
+ collect(node.children);
10
+ }
11
+ };
12
+ collect(tree.activeTree.children);
13
+ collect(tree.trashTree?.children);
14
+ const missing = [];
15
+ for (const [id, title] of titlesById) {
16
+ if (!knownIds.has(id)) {
17
+ missing.push({ id, title });
18
+ }
19
+ }
20
+ return missing;
21
+ }
@@ -11,6 +11,10 @@ export interface DocumentTree {
11
11
  children: DocumentNode[];
12
12
  id: string;
13
13
  };
14
+ trashTree?: {
15
+ children: DocumentNode[];
16
+ id: string;
17
+ };
14
18
  }
15
19
  export interface DocumentSummary {
16
20
  id: string;
@@ -34,7 +38,7 @@ export interface DocumentDetail {
34
38
  };
35
39
  emoji?: string;
36
40
  id: string;
37
- json: string;
41
+ json: unknown;
38
42
  plain: string;
39
43
  statusId: number;
40
44
  tags: Array<{
@@ -2,9 +2,10 @@ import path from 'node:path';
2
2
  import { writeProgress } from '../../../shared/console/progress.js';
3
3
  import { deleteFile, ensureDirectory, fileExists, fileSize, writeBinaryFile, writeMarkdownFile, } from '../../../shared/storage/markdown-store.js';
4
4
  import { appendLog } from '../../../shared/storage/update-log.js';
5
- import { buildDocumentMarkdown } from '../domain/document-markdown.js';
6
- import { documentAttachmentMarkdownLink, documentAttachmentRelativePath, documentFileName, documentFolderPath, documentUrl, PARENT_DOCUMENT_INDEX_FILENAME, } from '../domain/document-path.js';
5
+ import { buildDocumentBody, buildDocumentMarkdown } from '../domain/document-markdown.js';
6
+ import { DOCUMENT_FALLBACK_PARENT_PATH, documentAttachmentMarkdownLink, documentAttachmentRelativePath, documentFileName, documentFolderPath, documentUrl, PARENT_DOCUMENT_INDEX_FILENAME, } from '../domain/document-path.js';
7
7
  import { planDocumentSave } from '../domain/document-save-plan.js';
8
+ import { findDocumentsMissingFromTree } from '../domain/document-tree-gap.js';
8
9
  export async function exportDocuments(deps, options) {
9
10
  const { documentRepository, logger } = deps;
10
11
  logger.log('ドキュメントの取得を開始します...');
@@ -13,13 +14,15 @@ export async function exportDocuments(deps, options) {
13
14
  logger.log('アクティブなドキュメントツリーを処理します...');
14
15
  const processedDocuments = [];
15
16
  const writtenFiles = new Set();
16
- const fetchAndSaveDocument = async (node, currentPath, asParentIndex = false) => {
17
+ // 戻り値はファイルを書き出したかどうか(保存件数の集計に使う)
18
+ const fetchAndSaveDocument = async (node, currentPath, placement = {}) => {
19
+ const asParentIndex = placement.asParentIndex ?? false;
17
20
  try {
18
21
  if (processedDocuments.includes(node.id)) {
19
- return;
22
+ return false;
20
23
  }
21
24
  if (options.documentIds && options.documentIds.length > 0 && !options.documentIds.includes(node.id)) {
22
- return;
25
+ return false;
23
26
  }
24
27
  processedDocuments.push(node.id);
25
28
  writeProgress(`ドキュメント「${node.name}」を処理中...`);
@@ -27,11 +30,12 @@ export async function exportDocuments(deps, options) {
27
30
  const fileName = documentFileName(documentDetail.title, asParentIndex);
28
31
  const filePath = path.join(options.outputDir, currentPath, fileName);
29
32
  const action = planDocumentSave({
33
+ alreadyWrittenThisRun: writtenFiles.has(filePath),
30
34
  asParentIndex,
31
- body: documentDetail.plain,
35
+ body: buildDocumentBody(documentDetail),
36
+ fileExists: await fileExists(filePath),
32
37
  lastUpdated: options.lastUpdated,
33
- parentIndexAlreadyWrittenThisRun: writtenFiles.has(filePath),
34
- parentIndexExists: asParentIndex && (await fileExists(filePath)),
38
+ missingFromTree: placement.missingFromTree ?? false,
35
39
  updated: documentDetail.updated,
36
40
  });
37
41
  switch (action) {
@@ -48,6 +52,10 @@ export async function exportDocuments(deps, options) {
48
52
  await writeMarkdownFile(filePath, buildDocumentMarkdown(documentDetail, backlogDocumentUrl, attachmentLinks));
49
53
  writtenFiles.add(filePath);
50
54
  await appendLog(options.outputDir, `ドキュメント「${documentDetail.title}」を更新しました: ${backlogDocumentUrl}`);
55
+ return true;
56
+ }
57
+ case 'skip-fallback-collision': {
58
+ logger.warn(`ツリーに現れないドキュメント「${documentDetail.title}」は、同名のファイルを既に出力しているため保存をスキップしました`);
51
59
  break;
52
60
  }
53
61
  case 'skip-parent-index-collision': {
@@ -60,6 +68,7 @@ export async function exportDocuments(deps, options) {
60
68
  catch (error) {
61
69
  logger.warn(`ドキュメント ${node.name} の取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
62
70
  }
71
+ return false;
63
72
  };
64
73
  /* eslint-disable no-await-in-loop */
65
74
  const processDocumentNode = async (node, currentPath) => {
@@ -70,7 +79,7 @@ export async function exportDocuments(deps, options) {
70
79
  await processDocumentNode(child, folderRelPath);
71
80
  }
72
81
  // 親自身の本文はフォルダ内の親indexとして子の後に保存する
73
- await fetchAndSaveDocument(node, folderRelPath, true);
82
+ await fetchAndSaveDocument(node, folderRelPath, { asParentIndex: true });
74
83
  }
75
84
  else {
76
85
  await fetchAndSaveDocument(node, currentPath);
@@ -79,10 +88,39 @@ export async function exportDocuments(deps, options) {
79
88
  for (const rootNode of documentTree.activeTree.children ?? []) {
80
89
  await processDocumentNode(rootNode, '');
81
90
  }
91
+ const missingFromTree = await findDocumentsOutsideTree(deps, documentTree, options, processedDocuments);
92
+ let savedMissingFromTree = 0;
93
+ for (const document of missingFromTree) {
94
+ const saved = await fetchAndSaveDocument({ children: [], id: document.id, name: document.title }, DOCUMENT_FALLBACK_PARENT_PATH, { missingFromTree: true });
95
+ if (saved) {
96
+ savedMissingFromTree++;
97
+ }
98
+ }
82
99
  /* eslint-enable no-await-in-loop */
100
+ // 検出件数ではなく実際に保存した件数を出す(未更新でスキップした分まで毎回報告しないため)
101
+ if (savedMissingFromTree > 0) {
102
+ logger.log(`ツリーに現れないドキュメント${savedMissingFromTree}件を出力ルート直下に保存しました`);
103
+ }
83
104
  logger.log(`\n合計 ${processedDocuments.length}件のドキュメントが処理されました。`);
84
105
  logger.log('ドキュメントのダウンロードが完了しました!');
85
106
  }
107
+ // ツリーに現れないドキュメントを一覧API(全件が載る)との差分から求める。
108
+ // 一覧の取得に失敗しても従来どおりツリー分のエクスポートは成立させるため、警告に留めて空を返す
109
+ async function findDocumentsOutsideTree(deps, documentTree, options, processedDocuments) {
110
+ // ID指定の取得で対象がすべてツリー内に見つかっている場合は、一覧APIを呼ぶ必要がない
111
+ const targetedIds = options.documentIds && options.documentIds.length > 0 ? options.documentIds : undefined;
112
+ if (targetedIds?.every((id) => processedDocuments.includes(id))) {
113
+ return [];
114
+ }
115
+ try {
116
+ const titlesById = await deps.documentRepository.fetchAllTitles(options.projectId);
117
+ return findDocumentsMissingFromTree(documentTree, titlesById);
118
+ }
119
+ catch (error) {
120
+ deps.logger.warn(`ドキュメント一覧の取得に失敗したため、ツリーに現れないドキュメントの確認をスキップします: ${error instanceof Error ? error.message : String(error)}`);
121
+ return [];
122
+ }
123
+ }
86
124
  // 保存できた添付のみリンク化する。個々の失敗は警告に留め、ドキュメント本体の保存は続行する
87
125
  async function downloadDocumentAttachments(deps, documentDetail, currentPath, outputDir) {
88
126
  const links = new Map();