backlog-exporter 1.1.1 → 1.3.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 +23 -3
- package/dist/modules/document/domain/document-json-markdown.d.ts +1 -0
- package/dist/modules/document/domain/document-json-markdown.js +415 -0
- package/dist/modules/document/domain/document-markdown.d.ts +2 -0
- package/dist/modules/document/domain/document-markdown.js +11 -1
- package/dist/modules/document/domain/document.d.ts +1 -1
- package/dist/modules/document/use-case/export-documents.js +2 -2
- package/dist/modules/issue/domain/issue-markdown.d.ts +11 -1
- package/dist/modules/issue/domain/issue-markdown.js +33 -3
- package/dist/modules/issue/domain/issue-relations.d.ts +19 -0
- package/dist/modules/issue/domain/issue-relations.js +42 -0
- package/dist/modules/issue/domain/issue-repository.d.ts +2 -0
- package/dist/modules/issue/domain/issue.d.ts +6 -0
- package/dist/modules/issue/repository/backlog-issue-repository.js +24 -0
- package/dist/modules/issue/use-case/export-issues.js +81 -10
- package/dist/shared/backlog/http-client.d.ts +3 -2
- package/dist/shared/backlog/http-client.js +6 -1
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@ $ npm install -g backlog-exporter
|
|
|
42
42
|
$ backlog-exporter COMMAND
|
|
43
43
|
running command...
|
|
44
44
|
$ backlog-exporter (--version)
|
|
45
|
-
backlog-exporter/1.
|
|
45
|
+
backlog-exporter/1.3.0 linux-x64 node-v26.10.0
|
|
46
46
|
$ backlog-exporter --help [COMMAND]
|
|
47
47
|
USAGE
|
|
48
48
|
$ backlog-exporter COMMAND
|
|
@@ -270,7 +270,8 @@ $ backlog-exporter issue --domain example.backlog.jp --projectIdOrKey PROJECT_KE
|
|
|
270
270
|
- ファイル名は同名の衝突を避けるため `{添付ID}_{ファイル名}` になります
|
|
271
271
|
- Markdown の `## 添付ファイル` セクションにローカルファイルへの相対リンクが記載されます(フラグ未指定時はファイル名とサイズのみ記載)
|
|
272
272
|
- 課題の本文・コメント内の添付画像のインライン記法(`![image][ファイル名]` / `#image(ファイル名)`)は、ダウンロード済みファイルへの画像リンクに変換され、Markdown ビューアでそのまま表示できます
|
|
273
|
-
- Wiki
|
|
273
|
+
- Wiki の本文は Backlog の原文のまま維持されます(添付参照記法の書き換えは行いません)。添付ファイルへは `## 添付ファイル` セクションのリンクからアクセスできます
|
|
274
|
+
- ドキュメントの本文は Backlog の構造化データ(JSON)から Markdown に変換されます(見出し・リスト・表・コードブロック等を再現します)。構造化データを取得できない場合はプレーンテキストの本文をそのまま使用します。添付参照記法の書き換えは行わないため、添付ファイルへは `## 添付ファイル` セクションのリンクからアクセスできます
|
|
274
275
|
- ダウンロード済みのファイルは再ダウンロードされないため、`update` コマンドでの差分更新でも効率的に動作します
|
|
275
276
|
- 設定は `backlog-settings.json` に保存され、以降の `update` コマンドで自動的に引き継がれます(`update` コマンド自体でフラグを指定した場合はその実行のみ有効です)
|
|
276
277
|
- Backlog 側で削除された添付ファイルは、誤削除防止のため `prune` コマンドでも削除されず残ります
|
|
@@ -302,13 +303,23 @@ $ backlog-exporter issue --domain example.backlog.jp --projectIdOrKey PROJECT_KE
|
|
|
302
303
|
## 基本情報
|
|
303
304
|
|
|
304
305
|
- 課題キー: PROJ-123
|
|
306
|
+
- 種別: タスク
|
|
307
|
+
- カテゴリー: 設計, 実装
|
|
305
308
|
- ステータス: 処理中
|
|
306
309
|
- 優先度: 高
|
|
307
310
|
- 担当者: 山田太郎
|
|
311
|
+
- 親課題: [PROJ-100 親課題のタイトル](https://example.backlog.jp/view/PROJ-100)
|
|
312
|
+
- 開始日: 2023/01/01
|
|
313
|
+
- 期限日: 2023/01/31
|
|
308
314
|
- 作成日時: 2023/01/01 10:00:00
|
|
309
315
|
- 更新日時: 2023/01/02 15:30:45
|
|
310
316
|
- [Backlog Issue Link](https://example.backlog.jp/view/PROJ-123)
|
|
311
317
|
|
|
318
|
+
## 子課題
|
|
319
|
+
|
|
320
|
+
- [PROJ-124 子課題のタイトル](https://example.backlog.jp/view/PROJ-124)
|
|
321
|
+
- [PROJ-125 もう 1 つの子課題](https://example.backlog.jp/view/PROJ-125)
|
|
322
|
+
|
|
312
323
|
## カスタム属性
|
|
313
324
|
|
|
314
325
|
| 属性名 | 値 |
|
|
@@ -333,6 +344,15 @@ $ backlog-exporter issue --domain example.backlog.jp --projectIdOrKey PROJECT_KE
|
|
|
333
344
|
コメントの内容がここに表示されます。
|
|
334
345
|
```
|
|
335
346
|
|
|
347
|
+
**親課題・子課題について**:
|
|
348
|
+
|
|
349
|
+
- 親課題がある場合のみ「親課題」の行を、子課題がある場合のみ「子課題」セクションを出力します
|
|
350
|
+
- 3 階層(親課題・子課題・孫課題)にも対応しています。各ファイルは 1 つ上と 1 つ下の階層を出力するため、2 階層目の課題には「親課題」の行と「子課題」セクションの両方が出ます
|
|
351
|
+
- 子課題は、プロジェクト全体のエクスポート(`issue` / `all`)では取得済みの課題から組み立てるため追加の API 呼び出しは発生しません。課題キーを指定した再取得(`update --issueIdOrKey`)でのみ API から取得します(対象課題を 100 件ずつまとめて引きます)
|
|
352
|
+
- ID を指定しない差分更新(`update`)では、更新された課題に加えてその直接の親課題・子課題も書き直すため、子課題の追加や件名の変更は親子どちらのファイルにも反映されます。ただし親課題を変更・解除した子課題の元の親と、削除された子課題の親は書き直されないため、揃えるには `issue` / `all` で再エクスポートしてください
|
|
353
|
+
- `--statusId` で状態を絞り込んだ場合、絞り込みから外れた子課題は「子課題」セクションに現れません
|
|
354
|
+
- 親課題が別プロジェクトにあるなどで課題キーを解決できなかった場合は `- 親課題: (ID: 100)` のように課題 ID のみを出力します
|
|
355
|
+
|
|
336
356
|
### Wiki の出力形式
|
|
337
357
|
|
|
338
358
|
```markdown
|
|
@@ -363,7 +383,7 @@ Backlog の書式がそのまま保持されます。
|
|
|
363
383
|
|
|
364
384
|
<!-- backlog-exporter:body:start -->
|
|
365
385
|
ここにドキュメントの本文内容が入ります。
|
|
366
|
-
Backlog
|
|
386
|
+
Backlog の構造化データから変換された Markdown が入ります。
|
|
367
387
|
<!-- backlog-exporter:body:end -->
|
|
368
388
|
|
|
369
389
|
## 添付ファイル
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function convertDocumentJsonToMarkdown(json: unknown): string;
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
const BLOCK_TYPES = new Set([
|
|
2
|
+
'blockquote',
|
|
3
|
+
'bulletList',
|
|
4
|
+
'codeBlock',
|
|
5
|
+
'heading',
|
|
6
|
+
'horizontalRule',
|
|
7
|
+
'orderedList',
|
|
8
|
+
'paragraph',
|
|
9
|
+
'table',
|
|
10
|
+
'taskList',
|
|
11
|
+
]);
|
|
12
|
+
// マーク適用順。codeは最内側に置き、他の記号がコードスパンの外に出るようにする
|
|
13
|
+
const MARK_ORDER = ['code', 'bold', 'italic', 'strike', 'link'];
|
|
14
|
+
function isNode(value) {
|
|
15
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
function isListNode(node) {
|
|
18
|
+
return node.type === 'bulletList' || node.type === 'orderedList' || node.type === 'taskList';
|
|
19
|
+
}
|
|
20
|
+
function isBlockLike(node) {
|
|
21
|
+
return typeof node.type === 'string' && BLOCK_TYPES.has(node.type);
|
|
22
|
+
}
|
|
23
|
+
function childNodes(node) {
|
|
24
|
+
return Array.isArray(node.content) ? node.content.filter((child) => isNode(child)) : [];
|
|
25
|
+
}
|
|
26
|
+
function nodeMarks(node) {
|
|
27
|
+
return (Array.isArray(node.marks) ? node.marks : []).filter((mark) => isNode(mark));
|
|
28
|
+
}
|
|
29
|
+
function attrString(node, key) {
|
|
30
|
+
const value = node.attrs?.[key];
|
|
31
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
32
|
+
}
|
|
33
|
+
function markOrder(mark) {
|
|
34
|
+
const index = MARK_ORDER.indexOf(mark.type ?? '');
|
|
35
|
+
// 未知のマークは最外側に置く(-1で最内側に来るのを防ぐ)
|
|
36
|
+
return index === -1 ? MARK_ORDER.length : index;
|
|
37
|
+
}
|
|
38
|
+
function sortMarks(marks) {
|
|
39
|
+
return [...marks].sort((a, b) => markOrder(a) - markOrder(b));
|
|
40
|
+
}
|
|
41
|
+
// 隣接テキストのマークが同一かの判定用。属性まで含めて比較する
|
|
42
|
+
function markKey(mark) {
|
|
43
|
+
return `${mark.type ?? ''}:${JSON.stringify(mark.attrs ?? {})}`;
|
|
44
|
+
}
|
|
45
|
+
function marksKey(marks) {
|
|
46
|
+
return sortMarks(marks)
|
|
47
|
+
.map((mark) => markKey(mark))
|
|
48
|
+
.join('|');
|
|
49
|
+
}
|
|
50
|
+
// 連続するバッククォートの最長連を求め、それより長いフェンス/デリミタを選ぶ
|
|
51
|
+
function longestBacktickRun(text) {
|
|
52
|
+
let longest = 0;
|
|
53
|
+
for (const run of text.matchAll(/`+/g)) {
|
|
54
|
+
longest = Math.max(longest, run[0].length);
|
|
55
|
+
}
|
|
56
|
+
return longest;
|
|
57
|
+
}
|
|
58
|
+
function inlineCode(text) {
|
|
59
|
+
const delimiter = '`'.repeat(longestBacktickRun(text) + 1);
|
|
60
|
+
// 内容の端がバッククォートの場合、区切りと繋がらないよう空白で隔てる
|
|
61
|
+
const pad = text.startsWith('`') || text.endsWith('`') ? ' ' : '';
|
|
62
|
+
return `${delimiter}${pad}${text}${pad}${delimiter}`;
|
|
63
|
+
}
|
|
64
|
+
// CommonMarkのリンク先は空白・括弧を含む場合<>で囲う必要がある
|
|
65
|
+
function linkDestination(destination) {
|
|
66
|
+
return /[\s()]/.test(destination) ? `<${destination}>` : destination;
|
|
67
|
+
}
|
|
68
|
+
function escapeBracketText(text) {
|
|
69
|
+
return text.replaceAll('[', String.raw `\[`).replaceAll(']', String.raw `\]`);
|
|
70
|
+
}
|
|
71
|
+
// 行頭に来るとブロック記法として解釈される文字をエスケープする。
|
|
72
|
+
// リスト内の字下げはリスト側で付けるため、ここではテキスト自身の先頭空白だけを見る
|
|
73
|
+
function escapeBlockStart(text) {
|
|
74
|
+
return (text
|
|
75
|
+
// 4スペース以上の字下げはコードブロックになるため3つまでに詰める
|
|
76
|
+
.replace(/^ {4,}/, ' ')
|
|
77
|
+
.replace(/^(\s*)([#>])/, String.raw `$1\$2`)
|
|
78
|
+
.replace(/^(\s*)(~~~)/, String.raw `$1\$2`)
|
|
79
|
+
.replace(/^(\s*)([+-])(\s)/, String.raw `$1\$2$3`)
|
|
80
|
+
.replace(/^(\s*)(\d{1,9})([).])(\s)/, String.raw `$1$2\$3$4`)
|
|
81
|
+
// setext見出しの下線(=== や ---)は行全体がその文字のときだけ成立する
|
|
82
|
+
.replace(/^(\s*)([=-])(?=[=-]*\s*$)/, String.raw `$1\$2`));
|
|
83
|
+
}
|
|
84
|
+
// 自動リンクされるURLはエスケープするとリンク先が壊れるため素通しする
|
|
85
|
+
const URL_PATTERN = /https?:\/\/[^\s<>]+/g;
|
|
86
|
+
// インラインの強調記法として解釈されうる文字のみを対象にする(CJK約物は対象外)
|
|
87
|
+
function escapeInline(text) {
|
|
88
|
+
let result = '';
|
|
89
|
+
let last = 0;
|
|
90
|
+
for (const match of text.matchAll(URL_PATTERN)) {
|
|
91
|
+
result += escapeInlineSpan(text.slice(last, match.index)) + match[0];
|
|
92
|
+
last = match.index + match[0].length;
|
|
93
|
+
}
|
|
94
|
+
return result + escapeInlineSpan(text.slice(last));
|
|
95
|
+
}
|
|
96
|
+
function escapeInlineSpan(text) {
|
|
97
|
+
return text.replaceAll(/[[\\\]_`*<]/g, String.raw `\$&`);
|
|
98
|
+
}
|
|
99
|
+
function applyMark(text, mark) {
|
|
100
|
+
switch (mark.type) {
|
|
101
|
+
case 'bold': {
|
|
102
|
+
return wrapKeepingWhitespace(text, '**');
|
|
103
|
+
}
|
|
104
|
+
case 'code': {
|
|
105
|
+
return inlineCode(text);
|
|
106
|
+
}
|
|
107
|
+
case 'italic': {
|
|
108
|
+
return wrapKeepingWhitespace(text, '*');
|
|
109
|
+
}
|
|
110
|
+
case 'link': {
|
|
111
|
+
const href = typeof mark.attrs?.href === 'string' ? mark.attrs.href : '';
|
|
112
|
+
return `[${text}](${linkDestination(href)})`;
|
|
113
|
+
}
|
|
114
|
+
case 'strike': {
|
|
115
|
+
return wrapKeepingWhitespace(text, '~~');
|
|
116
|
+
}
|
|
117
|
+
default: {
|
|
118
|
+
return text;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// 区切り記号の内側に空白があると強調が成立しないため、空白を外側へ追い出す
|
|
123
|
+
function wrapKeepingWhitespace(text, delimiter) {
|
|
124
|
+
const match = /^(\s*)([\s\S]*?)(\s*)$/.exec(text);
|
|
125
|
+
if (!match)
|
|
126
|
+
return `${delimiter}${text}${delimiter}`;
|
|
127
|
+
const [, leading, core, trailing] = match;
|
|
128
|
+
if (core === '')
|
|
129
|
+
return text;
|
|
130
|
+
return `${leading}${delimiter}${core}${delimiter}${trailing}`;
|
|
131
|
+
}
|
|
132
|
+
function renderMarkedText(text, marks, atLineStart) {
|
|
133
|
+
if (text === '')
|
|
134
|
+
return '';
|
|
135
|
+
const hasCode = marks.some((mark) => mark.type === 'code');
|
|
136
|
+
// コードマーク内はエスケープせず原文のまま出す
|
|
137
|
+
let result = hasCode ? text : escapeInline(text);
|
|
138
|
+
if (!hasCode && atLineStart)
|
|
139
|
+
result = escapeBlockStart(result);
|
|
140
|
+
for (const mark of sortMarks(marks)) {
|
|
141
|
+
result = applyMark(result, mark);
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
// 同じマークの連続テキストは1つに束ねる(**a****b** のような無効な出力を防ぐ)
|
|
146
|
+
function coalesceTextNodes(nodes) {
|
|
147
|
+
const result = [];
|
|
148
|
+
for (const node of nodes) {
|
|
149
|
+
const previous = result.at(-1);
|
|
150
|
+
if (node.type === 'text' &&
|
|
151
|
+
previous?.type === 'text' &&
|
|
152
|
+
marksKey(nodeMarks(previous)) === marksKey(nodeMarks(node))) {
|
|
153
|
+
result[result.length - 1] = { ...previous, text: (previous.text ?? '') + (node.text ?? '') };
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
result.push(node);
|
|
157
|
+
}
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
// 隣接テキストが共通の外側マーク(**等)を持つ場合、閉じて開き直すと強調が壊れる。
|
|
161
|
+
// 共通マークで全体を1度だけ包み、内側の差分マークだけを各テキストに適用する
|
|
162
|
+
function renderTextRun(nodes, atLineStart) {
|
|
163
|
+
const shared = [];
|
|
164
|
+
if (nodes.length > 1) {
|
|
165
|
+
const first = sortMarks(nodeMarks(nodes[0]));
|
|
166
|
+
for (const mark of first) {
|
|
167
|
+
// codeは内容ごとに区切りが変わるため共通化しない
|
|
168
|
+
if (mark.type === 'code')
|
|
169
|
+
continue;
|
|
170
|
+
if (nodes.every((node) => nodeMarks(node).some((other) => markKey(other) === markKey(mark)))) {
|
|
171
|
+
shared.push(mark);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const sharedKeys = new Set(shared.map((mark) => markKey(mark)));
|
|
176
|
+
let inner = '';
|
|
177
|
+
for (const [index, node] of nodes.entries()) {
|
|
178
|
+
const rest = nodeMarks(node).filter((mark) => !sharedKeys.has(markKey(mark)));
|
|
179
|
+
inner += renderMarkedText(node.text ?? '', rest, atLineStart && index === 0);
|
|
180
|
+
}
|
|
181
|
+
let result = inner;
|
|
182
|
+
for (const mark of sortMarks(shared)) {
|
|
183
|
+
result = applyMark(result, mark);
|
|
184
|
+
}
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
// 末尾/先頭のhardBreakは余分な空行になるため落とす
|
|
188
|
+
function trimHardBreaks(nodes) {
|
|
189
|
+
let start = 0;
|
|
190
|
+
let end = nodes.length;
|
|
191
|
+
while (start < end && nodes[start].type === 'hardBreak')
|
|
192
|
+
start++;
|
|
193
|
+
while (end > start && nodes[end - 1].type === 'hardBreak')
|
|
194
|
+
end--;
|
|
195
|
+
return nodes.slice(start, end);
|
|
196
|
+
}
|
|
197
|
+
function renderInline(nodes, atBlockStart = false) {
|
|
198
|
+
// 空テキストはマークの結合を妨げるだけなので取り除く
|
|
199
|
+
const meaningful = nodes.filter((node) => node.type !== 'text' || (node.text ?? '') !== '');
|
|
200
|
+
const prepared = coalesceTextNodes(trimHardBreaks(meaningful));
|
|
201
|
+
let result = '';
|
|
202
|
+
let atLineStart = atBlockStart;
|
|
203
|
+
for (let index = 0; index < prepared.length; index++) {
|
|
204
|
+
const node = prepared[index];
|
|
205
|
+
const isStart = atLineStart;
|
|
206
|
+
// 行頭とみなせるのはブロック先頭とhardBreak直後だけ
|
|
207
|
+
atLineStart = false;
|
|
208
|
+
switch (node.type) {
|
|
209
|
+
case 'attachmentBadge': {
|
|
210
|
+
result += renderAttachmentBadge(node);
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
case 'documentMention': {
|
|
214
|
+
result += renderDocumentMention(node);
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
case 'hardBreak': {
|
|
218
|
+
result += ' \n';
|
|
219
|
+
atLineStart = true;
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
case 'image': {
|
|
223
|
+
result += renderImage(node);
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
case 'issueMention': {
|
|
227
|
+
result += renderIssueMention(node);
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
case 'text': {
|
|
231
|
+
// 連続するテキストはまとめて処理し、共通マークを1度だけ適用する
|
|
232
|
+
let end = index;
|
|
233
|
+
while (end + 1 < prepared.length && prepared[end + 1].type === 'text')
|
|
234
|
+
end++;
|
|
235
|
+
result += renderTextRun(prepared.slice(index, end + 1), isStart);
|
|
236
|
+
index = end;
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
default: {
|
|
240
|
+
result += renderInline(childNodes(node), isStart);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return result;
|
|
245
|
+
}
|
|
246
|
+
function renderImage(node) {
|
|
247
|
+
const src = attrString(node, 'src') ?? '';
|
|
248
|
+
const alt = escapeBracketText(attrString(node, 'alt') ?? '');
|
|
249
|
+
const title = attrString(node, 'title');
|
|
250
|
+
const destination = linkDestination(src);
|
|
251
|
+
return title ? `` : ``;
|
|
252
|
+
}
|
|
253
|
+
// Backlog独自ノード。attrsの形が公開されていないため、ラベルになりうるキーを順に探す
|
|
254
|
+
function renderAttachmentBadge(node) {
|
|
255
|
+
const label = attrString(node, 'name') ??
|
|
256
|
+
attrString(node, 'title') ??
|
|
257
|
+
attrString(node, 'fileName') ??
|
|
258
|
+
attrString(node, 'text') ??
|
|
259
|
+
renderInline(childNodes(node));
|
|
260
|
+
const src = attrString(node, 'src') ?? attrString(node, 'href') ?? attrString(node, 'url');
|
|
261
|
+
const resolved = escapeBracketText(label.length > 0 ? label : '添付ファイル');
|
|
262
|
+
return src ? `[${resolved}](${linkDestination(src)})` : resolved;
|
|
263
|
+
}
|
|
264
|
+
// Backlog独自ノード。attrsにのみ値を持ち子ノードが無いためリンクに組み立てる
|
|
265
|
+
function renderDocumentMention(node) {
|
|
266
|
+
const label = attrString(node, 'label') ?? attrString(node, 'title') ?? attrString(node, 'text') ?? renderInline(childNodes(node));
|
|
267
|
+
const url = attrString(node, 'url') ?? attrString(node, 'href');
|
|
268
|
+
const resolved = escapeBracketText(label.length > 0 ? label : 'ドキュメント');
|
|
269
|
+
return url ? `[${resolved}](${linkDestination(url)})` : resolved;
|
|
270
|
+
}
|
|
271
|
+
// Backlog独自ノード。課題キーを持つキーを順に探す
|
|
272
|
+
function renderIssueMention(node) {
|
|
273
|
+
const key = attrString(node, 'issueKey') ??
|
|
274
|
+
attrString(node, 'key') ??
|
|
275
|
+
attrString(node, 'label') ??
|
|
276
|
+
attrString(node, 'text') ??
|
|
277
|
+
renderInline(childNodes(node));
|
|
278
|
+
return key;
|
|
279
|
+
}
|
|
280
|
+
function renderCodeBlock(node) {
|
|
281
|
+
const language = attrString(node, 'language');
|
|
282
|
+
// Backlogは言語未指定を"auto"として返す
|
|
283
|
+
const info = language && language !== 'auto' ? language : '';
|
|
284
|
+
const code = childNodes(node)
|
|
285
|
+
.map((child) => (typeof child.text === 'string' ? child.text : ''))
|
|
286
|
+
.join('');
|
|
287
|
+
// 内容に含まれるバッククォート連より長いフェンスにしてブロックが途中で閉じるのを防ぐ
|
|
288
|
+
const fence = '`'.repeat(Math.max(3, longestBacktickRun(code) + 1));
|
|
289
|
+
return `${fence}${info}\n${code}\n${fence}`;
|
|
290
|
+
}
|
|
291
|
+
function renderBlockquote(node) {
|
|
292
|
+
return renderBlocks(childNodes(node))
|
|
293
|
+
.split('\n')
|
|
294
|
+
.map((line) => (line === '' ? '>' : `> ${line}`))
|
|
295
|
+
.join('\n');
|
|
296
|
+
}
|
|
297
|
+
// チェックボックス記法の継続行はリスト記号"- "の幅で字下げする。
|
|
298
|
+
// マーカー全長(6桁)だとCommonMarkがコードブロックと解釈しネストが壊れる
|
|
299
|
+
const TASK_ITEM_INDENT = 2;
|
|
300
|
+
function renderListItem(node, marker, indentWidth = marker.length) {
|
|
301
|
+
const children = childNodes(node);
|
|
302
|
+
const rendered = children.map((child) => (isListNode(child) ? renderList(child) : renderBlock(child)));
|
|
303
|
+
let body = '';
|
|
304
|
+
for (const [index, block] of rendered.entries()) {
|
|
305
|
+
if (block === '')
|
|
306
|
+
continue;
|
|
307
|
+
if (body === '') {
|
|
308
|
+
body = block;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
// 段落直後のネストリストはtight listとして1改行で繋ぐ
|
|
312
|
+
body += isListNode(children[index]) ? `\n${block}` : `\n\n${block}`;
|
|
313
|
+
}
|
|
314
|
+
if (body === '')
|
|
315
|
+
return marker.trimEnd();
|
|
316
|
+
const indent = ' '.repeat(indentWidth);
|
|
317
|
+
return body
|
|
318
|
+
.split('\n')
|
|
319
|
+
.map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`))
|
|
320
|
+
.join('\n');
|
|
321
|
+
}
|
|
322
|
+
function taskMarker(item) {
|
|
323
|
+
return item.attrs?.checked === true ? '- [x] ' : '- [ ] ';
|
|
324
|
+
}
|
|
325
|
+
function renderList(node) {
|
|
326
|
+
if (node.type === 'taskList') {
|
|
327
|
+
return childNodes(node)
|
|
328
|
+
.map((item) => renderListItem(item, taskMarker(item), TASK_ITEM_INDENT))
|
|
329
|
+
.join('\n');
|
|
330
|
+
}
|
|
331
|
+
const ordered = node.type === 'orderedList';
|
|
332
|
+
const startAttr = node.attrs?.start;
|
|
333
|
+
const start = ordered && typeof startAttr === 'number' && Number.isInteger(startAttr) ? startAttr : 1;
|
|
334
|
+
return childNodes(node)
|
|
335
|
+
.map((item, index) => renderListItem(item, ordered ? `${start + index}. ` : '- '))
|
|
336
|
+
.join('\n');
|
|
337
|
+
}
|
|
338
|
+
function renderCell(node) {
|
|
339
|
+
return childNodes(node)
|
|
340
|
+
.map((child) => renderBlock(child))
|
|
341
|
+
.filter((block) => block !== '')
|
|
342
|
+
.join('<br>')
|
|
343
|
+
.replaceAll(/ {0,2}\n/g, '<br>')
|
|
344
|
+
.replaceAll('|', String.raw `\|`);
|
|
345
|
+
}
|
|
346
|
+
function renderTable(node) {
|
|
347
|
+
const rows = childNodes(node).filter((row) => row.type === 'tableRow' || childNodes(row).length > 0);
|
|
348
|
+
if (rows.length === 0)
|
|
349
|
+
return '';
|
|
350
|
+
const cellRows = rows.map((row) => childNodes(row));
|
|
351
|
+
const columnCount = Math.max(...cellRows.map((cells) => cells.length));
|
|
352
|
+
if (columnCount === 0)
|
|
353
|
+
return '';
|
|
354
|
+
const lines = cellRows.map((cells) => {
|
|
355
|
+
const rendered = cells.map((cell) => renderCell(cell));
|
|
356
|
+
while (rendered.length < columnCount)
|
|
357
|
+
rendered.push('');
|
|
358
|
+
return `| ${rendered.join(' | ')} |`;
|
|
359
|
+
});
|
|
360
|
+
// 1行目がtableHeaderでなくてもGitHub Markdownとして描画されるよう区切り行を必ず入れる
|
|
361
|
+
const separator = `| ${Array.from({ length: columnCount }, () => '---').join(' | ')} |`;
|
|
362
|
+
return [lines[0], separator, ...lines.slice(1)].join('\n');
|
|
363
|
+
}
|
|
364
|
+
function renderBlock(node) {
|
|
365
|
+
switch (node.type) {
|
|
366
|
+
case 'blockquote': {
|
|
367
|
+
return renderBlockquote(node);
|
|
368
|
+
}
|
|
369
|
+
case 'bulletList':
|
|
370
|
+
case 'orderedList':
|
|
371
|
+
case 'taskList': {
|
|
372
|
+
return renderList(node);
|
|
373
|
+
}
|
|
374
|
+
case 'codeBlock': {
|
|
375
|
+
return renderCodeBlock(node);
|
|
376
|
+
}
|
|
377
|
+
case 'heading': {
|
|
378
|
+
const levelAttr = node.attrs?.level;
|
|
379
|
+
const level = typeof levelAttr === 'number' && levelAttr >= 1 && levelAttr <= 6 ? Math.trunc(levelAttr) : 1;
|
|
380
|
+
const inline = renderInline(childNodes(node));
|
|
381
|
+
return inline === '' ? '' : `${'#'.repeat(level)} ${inline}`;
|
|
382
|
+
}
|
|
383
|
+
case 'horizontalRule': {
|
|
384
|
+
return '---';
|
|
385
|
+
}
|
|
386
|
+
case 'image': {
|
|
387
|
+
return renderImage(node);
|
|
388
|
+
}
|
|
389
|
+
case 'paragraph': {
|
|
390
|
+
return renderInline(childNodes(node), true);
|
|
391
|
+
}
|
|
392
|
+
case 'table': {
|
|
393
|
+
return renderTable(node);
|
|
394
|
+
}
|
|
395
|
+
default: {
|
|
396
|
+
const children = childNodes(node);
|
|
397
|
+
if (children.length === 0)
|
|
398
|
+
return renderInline([node], true);
|
|
399
|
+
// 未知のブロックは内容を落とさずそのまま連結する
|
|
400
|
+
return children.some((child) => isBlockLike(child)) ? renderBlocks(children) : renderInline(children, true);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
function renderBlocks(nodes) {
|
|
405
|
+
return nodes
|
|
406
|
+
.map((node) => renderBlock(node))
|
|
407
|
+
.filter((block) => block !== '')
|
|
408
|
+
.join('\n\n');
|
|
409
|
+
}
|
|
410
|
+
export function convertDocumentJsonToMarkdown(json) {
|
|
411
|
+
if (!isNode(json))
|
|
412
|
+
return '';
|
|
413
|
+
const blocks = json.type === 'doc' ? childNodes(json) : [json];
|
|
414
|
+
return renderBlocks(blocks);
|
|
415
|
+
}
|
|
@@ -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
|
|
49
|
+
${wrapBody(buildDocumentBody(documentDetail) || EMPTY_BODY_PLACEHOLDER)}${attachmentsSection}${tagsSection}`;
|
|
40
50
|
}
|
|
@@ -2,7 +2,7 @@ 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';
|
|
5
|
+
import { buildDocumentBody, buildDocumentMarkdown } from '../domain/document-markdown.js';
|
|
6
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
8
|
import { findDocumentsMissingFromTree } from '../domain/document-tree-gap.js';
|
|
@@ -32,7 +32,7 @@ export async function exportDocuments(deps, options) {
|
|
|
32
32
|
const action = planDocumentSave({
|
|
33
33
|
alreadyWrittenThisRun: writtenFiles.has(filePath),
|
|
34
34
|
asParentIndex,
|
|
35
|
-
body: documentDetail
|
|
35
|
+
body: buildDocumentBody(documentDetail),
|
|
36
36
|
fileExists: await fileExists(filePath),
|
|
37
37
|
lastUpdated: options.lastUpdated,
|
|
38
38
|
missingFromTree: placement.missingFromTree ?? false,
|
|
@@ -1,5 +1,15 @@
|
|
|
1
|
+
import { IssueParent, IssueRef, IssueRelations } from './issue-relations.js';
|
|
1
2
|
import { CustomField, Issue, IssueAttachment, IssueComment } from './issue.js';
|
|
2
3
|
export declare function createCustomFieldsSection(customFields?: CustomField[]): string;
|
|
3
4
|
export declare function buildAttachmentsSection(attachments: IssueAttachment[] | undefined, localLinks?: Map<number, string>): string;
|
|
4
5
|
export declare function buildCommentsSection(comments: IssueComment[], backlogIssueUrl: string, rewriteBody?: (text: string) => string): string;
|
|
5
|
-
export declare function
|
|
6
|
+
export declare function formatCategories(categories?: Issue['category']): string;
|
|
7
|
+
export type IssueUrlResolver = (issueKey: string) => string;
|
|
8
|
+
export declare function buildParentIssueLine(parent: IssueParent | null, issueUrl?: IssueUrlResolver): string;
|
|
9
|
+
export declare function buildChildIssuesSection(children: IssueRef[], issueUrl?: IssueUrlResolver): string;
|
|
10
|
+
export interface BuildIssueMarkdownOptions {
|
|
11
|
+
attachmentLinks?: Map<number, string>;
|
|
12
|
+
issueUrl?: IssueUrlResolver;
|
|
13
|
+
relations?: IssueRelations;
|
|
14
|
+
}
|
|
15
|
+
export declare function buildIssueMarkdown(issue: Issue, comments: IssueComment[], backlogIssueUrl: string, options?: BuildIssueMarkdownOptions): string;
|
|
@@ -99,11 +99,40 @@ export function buildCommentsSection(comments, backlogIssueUrl, rewriteBody = (t
|
|
|
99
99
|
// 最後の区切り線を削除
|
|
100
100
|
return commentsSection.slice(0, -5);
|
|
101
101
|
}
|
|
102
|
-
|
|
102
|
+
// Backlogのカテゴリーは複数設定できるため、カンマ区切りで並べる
|
|
103
|
+
export function formatCategories(categories) {
|
|
104
|
+
if (!categories || categories.length === 0)
|
|
105
|
+
return '未設定';
|
|
106
|
+
return categories.map((category) => category.name).join(', ');
|
|
107
|
+
}
|
|
108
|
+
// URL解決関数が無い場合は壊れたリンクを書かないよう、ラベルだけを出す
|
|
109
|
+
function issueRefLink(ref, issueUrl) {
|
|
110
|
+
const label = `${ref.issueKey} ${ref.summary}`.trim();
|
|
111
|
+
return issueUrl ? `[${escapeLinkText(label)}](${issueUrl(ref.issueKey)})` : label;
|
|
112
|
+
}
|
|
113
|
+
// 課題キーを解決できなかった親はリンクにできないため、Backlog上のIDだけを出す
|
|
114
|
+
export function buildParentIssueLine(parent, issueUrl) {
|
|
115
|
+
if (!parent)
|
|
116
|
+
return '';
|
|
117
|
+
if (!parent.ref)
|
|
118
|
+
return `\n- 親課題: (ID: ${parent.parentIssueId})`;
|
|
119
|
+
return `\n- 親課題: ${issueRefLink(parent.ref, issueUrl)}`;
|
|
120
|
+
}
|
|
121
|
+
// 件数が可変なので基本情報の箇条書きではなく独立セクションにする
|
|
122
|
+
export function buildChildIssuesSection(children, issueUrl) {
|
|
123
|
+
if (children.length === 0)
|
|
124
|
+
return '';
|
|
125
|
+
const lines = children.map((child) => `- ${issueRefLink(child, issueUrl)}`);
|
|
126
|
+
return `\n\n## 子課題\n\n${lines.join('\n')}`;
|
|
127
|
+
}
|
|
128
|
+
export function buildIssueMarkdown(issue, comments, backlogIssueUrl, options = {}) {
|
|
129
|
+
const { attachmentLinks, issueUrl, relations } = options;
|
|
103
130
|
const rewriteBody = (text) => rewriteInlineImages(text, issue.attachments, attachmentLinks);
|
|
104
131
|
const commentsSection = buildCommentsSection(comments, backlogIssueUrl, rewriteBody);
|
|
105
132
|
const customFieldsSection = createCustomFieldsSection(issue.customFields);
|
|
106
133
|
const attachmentsSection = buildAttachmentsSection(issue.attachments, attachmentLinks);
|
|
134
|
+
const parentIssueLine = buildParentIssueLine(relations?.parent ?? null, issueUrl);
|
|
135
|
+
const childIssuesSection = buildChildIssuesSection(relations?.children ?? [], issueUrl);
|
|
107
136
|
const assigneeName = issue.assignee ? issue.assignee.name : '未割り当て';
|
|
108
137
|
const startDate = issue.startDate ? new Date(issue.startDate).toLocaleDateString('ja-JP') : '未設定';
|
|
109
138
|
const dueDate = issue.dueDate ? new Date(issue.dueDate).toLocaleDateString('ja-JP') : '未設定';
|
|
@@ -112,14 +141,15 @@ export function buildIssueMarkdown(issue, comments, backlogIssueUrl, attachmentL
|
|
|
112
141
|
## 基本情報
|
|
113
142
|
- 課題キー: ${issue.issueKey}
|
|
114
143
|
- 種別: ${issue.issueType.name}
|
|
144
|
+
- カテゴリー: ${formatCategories(issue.category)}
|
|
115
145
|
- ステータス: ${issue.status.name}
|
|
116
146
|
- 優先度: ${issue.priority.name}
|
|
117
|
-
- 担当者: ${assigneeName}
|
|
147
|
+
- 担当者: ${assigneeName}${parentIssueLine}
|
|
118
148
|
- 開始日: ${startDate}
|
|
119
149
|
- 期限日: ${dueDate}
|
|
120
150
|
- 作成日時: ${new Date(issue.created).toLocaleString('ja-JP')}
|
|
121
151
|
- 更新日時: ${new Date(issue.updated).toLocaleString('ja-JP')}
|
|
122
|
-
- [Backlog Issue Link](${backlogIssueUrl})${customFieldsSection}${attachmentsSection}
|
|
152
|
+
- [Backlog Issue Link](${backlogIssueUrl})${childIssuesSection}${customFieldsSection}${attachmentsSection}
|
|
123
153
|
|
|
124
154
|
## 詳細
|
|
125
155
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Issue } from './issue.js';
|
|
2
|
+
export interface IssueRef {
|
|
3
|
+
issueKey: string;
|
|
4
|
+
summary: string;
|
|
5
|
+
}
|
|
6
|
+
export interface IssueParent {
|
|
7
|
+
parentIssueId: number;
|
|
8
|
+
ref: IssueRef | null;
|
|
9
|
+
}
|
|
10
|
+
export interface IssueRelations {
|
|
11
|
+
children: IssueRef[];
|
|
12
|
+
parent: IssueParent | null;
|
|
13
|
+
}
|
|
14
|
+
export declare function toIssueRef(issue: Pick<Issue, 'issueKey' | 'summary'>): IssueRef;
|
|
15
|
+
export declare function buildIssueRefIndex(issues: Issue[]): Map<number, IssueRef>;
|
|
16
|
+
export declare function buildChildIndex(issues: Issue[]): Map<number, IssueRef[]>;
|
|
17
|
+
export declare function withParentsAndChildren(allIssues: Issue[], updatedIssues: Issue[]): Issue[];
|
|
18
|
+
export declare function findChildren(issue: Issue, childIndex: Map<number, IssueRef[]>): IssueRef[];
|
|
19
|
+
export declare function findParent(issue: Issue, refIndex: Map<number, IssueRef>): IssueParent | null;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export function toIssueRef(issue) {
|
|
2
|
+
return { issueKey: issue.issueKey, summary: issue.summary };
|
|
3
|
+
}
|
|
4
|
+
export function buildIssueRefIndex(issues) {
|
|
5
|
+
return new Map(issues.map((issue) => [issue.id, toIssueRef(issue)]));
|
|
6
|
+
}
|
|
7
|
+
// parentIssueIdでグルーピングするだけなので、親子孫の3階層でもそのまま成立する
|
|
8
|
+
export function buildChildIndex(issues) {
|
|
9
|
+
const childIndex = new Map();
|
|
10
|
+
// 一覧APIの返却順はソート指定に依存するため、Backlogの画面に合わせて課題ID昇順に固定する
|
|
11
|
+
for (const issue of [...issues].sort((a, b) => a.id - b.id)) {
|
|
12
|
+
if (issue.parentIssueId === null || issue.parentIssueId === undefined)
|
|
13
|
+
continue;
|
|
14
|
+
const siblings = childIndex.get(issue.parentIssueId);
|
|
15
|
+
if (siblings) {
|
|
16
|
+
siblings.push(toIssueRef(issue));
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
childIndex.set(issue.parentIssueId, [toIssueRef(issue)]);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return childIndex;
|
|
23
|
+
}
|
|
24
|
+
// 課題のファイルには直接の親と子しか載らないため、更新された課題の上下1段を書き直せば親子の表示が揃う
|
|
25
|
+
export function withParentsAndChildren(allIssues, updatedIssues) {
|
|
26
|
+
const updatedIds = new Set(updatedIssues.map((issue) => issue.id));
|
|
27
|
+
const parentIds = new Set(updatedIssues
|
|
28
|
+
.map((issue) => issue.parentIssueId)
|
|
29
|
+
.filter((parentIssueId) => parentIssueId !== null));
|
|
30
|
+
return allIssues.filter((issue) => updatedIds.has(issue.id) ||
|
|
31
|
+
parentIds.has(issue.id) ||
|
|
32
|
+
(issue.parentIssueId !== null && updatedIds.has(issue.parentIssueId)));
|
|
33
|
+
}
|
|
34
|
+
export function findChildren(issue, childIndex) {
|
|
35
|
+
return childIndex.get(issue.id) ?? [];
|
|
36
|
+
}
|
|
37
|
+
export function findParent(issue, refIndex) {
|
|
38
|
+
const parentIssueId = issue.parentIssueId ?? null;
|
|
39
|
+
if (parentIssueId === null)
|
|
40
|
+
return null;
|
|
41
|
+
return { parentIssueId, ref: refIndex.get(parentIssueId) ?? null };
|
|
42
|
+
}
|
|
@@ -3,6 +3,8 @@ export interface IssueRepository {
|
|
|
3
3
|
downloadAttachment(issueIdOrKey: string, attachmentId: number): Promise<ArrayBuffer>;
|
|
4
4
|
fetchAllComments(issueKey: string): Promise<IssueComment[]>;
|
|
5
5
|
fetchByIdOrKey(issueIdOrKey: string): Promise<Issue>;
|
|
6
|
+
fetchByIds(ids: number[]): Promise<Issue[]>;
|
|
7
|
+
fetchChildren(parentIssueIds: number[]): Promise<Issue[]>;
|
|
6
8
|
fetchPage(options: {
|
|
7
9
|
count: number;
|
|
8
10
|
offset: number;
|
|
@@ -4,6 +4,7 @@ export interface Issue {
|
|
|
4
4
|
name: string;
|
|
5
5
|
};
|
|
6
6
|
attachments?: IssueAttachment[];
|
|
7
|
+
category: IssueCategory[];
|
|
7
8
|
created: string;
|
|
8
9
|
customFields: CustomField[];
|
|
9
10
|
description: string;
|
|
@@ -14,6 +15,7 @@ export interface Issue {
|
|
|
14
15
|
id: number;
|
|
15
16
|
name: string;
|
|
16
17
|
};
|
|
18
|
+
parentIssueId: null | number;
|
|
17
19
|
priority: {
|
|
18
20
|
id: number;
|
|
19
21
|
name: string;
|
|
@@ -31,6 +33,10 @@ export interface IssueAttachment {
|
|
|
31
33
|
name: string;
|
|
32
34
|
size: number;
|
|
33
35
|
}
|
|
36
|
+
export interface IssueCategory {
|
|
37
|
+
id: number;
|
|
38
|
+
name: string;
|
|
39
|
+
}
|
|
34
40
|
export interface CustomField {
|
|
35
41
|
id: number;
|
|
36
42
|
name: string;
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
const PAGE_SIZE = 100;
|
|
2
|
+
function listParams(key, values, offset) {
|
|
3
|
+
return {
|
|
4
|
+
count: PAGE_SIZE.toString(),
|
|
5
|
+
[key]: values.map((value) => value.toString()),
|
|
6
|
+
offset: offset.toString(),
|
|
7
|
+
};
|
|
8
|
+
}
|
|
1
9
|
export function newBacklogIssueRepository(client) {
|
|
2
10
|
return {
|
|
3
11
|
async downloadAttachment(issueIdOrKey, attachmentId) {
|
|
@@ -26,6 +34,22 @@ export function newBacklogIssueRepository(client) {
|
|
|
26
34
|
async fetchByIdOrKey(issueIdOrKey) {
|
|
27
35
|
return client.getJson(`/issues/${issueIdOrKey}`);
|
|
28
36
|
},
|
|
37
|
+
async fetchByIds(ids) {
|
|
38
|
+
return client.getJson('/issues', listParams('id[]', ids, 0));
|
|
39
|
+
},
|
|
40
|
+
// 課題キー指定エクスポートのように取得済み集合に子が含まれない場合の補完用。
|
|
41
|
+
// 複数の親をまとめて指定できるため、課題ごとではなく一括で引く
|
|
42
|
+
async fetchChildren(parentIssueIds) {
|
|
43
|
+
const children = [];
|
|
44
|
+
for (;;) {
|
|
45
|
+
// eslint-disable-next-line no-await-in-loop
|
|
46
|
+
const page = await client.getJson('/issues', listParams('parentIssueId[]', parentIssueIds, children.length));
|
|
47
|
+
children.push(...page);
|
|
48
|
+
if (page.length < PAGE_SIZE) {
|
|
49
|
+
return children;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
},
|
|
29
53
|
async fetchPage(options) {
|
|
30
54
|
const params = {
|
|
31
55
|
count: options.count.toString(),
|
|
@@ -5,6 +5,9 @@ import { appendLog } from '../../../shared/storage/update-log.js';
|
|
|
5
5
|
import { filterIssuesUpdatedSince } from '../domain/issue-filter.js';
|
|
6
6
|
import { buildIssueMarkdown } from '../domain/issue-markdown.js';
|
|
7
7
|
import { attachmentMarkdownLink, attachmentRelativePath, issueRelativePath, issueUrl } from '../domain/issue-path.js';
|
|
8
|
+
import { buildChildIndex, buildIssueRefIndex, findChildren, findParent, withParentsAndChildren, } from '../domain/issue-relations.js';
|
|
9
|
+
// Backlogの配列パラメータの上限に合わせ、一括取得は100件ずつに分ける
|
|
10
|
+
const BULK_FETCH_SIZE = 100;
|
|
8
11
|
export async function exportIssues(deps, options) {
|
|
9
12
|
const { logger } = deps;
|
|
10
13
|
logger.log('課題の取得を開始します...');
|
|
@@ -20,12 +23,18 @@ export async function exportIssues(deps, options) {
|
|
|
20
23
|
logger.log('更新が必要な課題はありません。');
|
|
21
24
|
return;
|
|
22
25
|
}
|
|
26
|
+
const updatedIssueIds = new Set(filteredIssues.map((issue) => issue.id));
|
|
27
|
+
const targetIssues = withParentsAndChildren(allIssues, filteredIssues);
|
|
28
|
+
if (targetIssues.length > filteredIssues.length) {
|
|
29
|
+
logger.log(`親子の表示を揃えるため、${targetIssues.length - filteredIssues.length}件の課題もあわせて書き直します。`);
|
|
30
|
+
}
|
|
23
31
|
logger.log('課題を保存しています...');
|
|
24
|
-
|
|
32
|
+
const relationIndex = await buildRelationIndex(deps, allIssues, targetIssues, options);
|
|
33
|
+
for (const [index, issue] of targetIssues.entries()) {
|
|
25
34
|
try {
|
|
26
|
-
writeProgress(`課題を保存中... (${index + 1}/${
|
|
35
|
+
writeProgress(`課題を保存中... (${index + 1}/${targetIssues.length}件)`);
|
|
27
36
|
// eslint-disable-next-line no-await-in-loop
|
|
28
|
-
await saveIssue(deps, issue, options);
|
|
37
|
+
await saveIssue(deps, issue, options, { relationIndex, relationOnly: !updatedIssueIds.has(issue.id) });
|
|
29
38
|
}
|
|
30
39
|
catch (error) {
|
|
31
40
|
logger.warn(`課題 ${issue.issueKey} の保存に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -71,23 +80,85 @@ async function fetchAllIssues(deps, options) {
|
|
|
71
80
|
}
|
|
72
81
|
return issues;
|
|
73
82
|
}
|
|
74
|
-
async function saveIssue(deps, issue, options) {
|
|
83
|
+
async function saveIssue(deps, issue, options, { relationIndex, relationOnly }) {
|
|
75
84
|
const backlogIssueUrl = issueUrl(options.domain, issue.issueKey);
|
|
76
|
-
// コメント取得に失敗しても課題本体は保存する
|
|
77
85
|
let comments = [];
|
|
78
86
|
try {
|
|
79
87
|
comments = await deps.issueRepository.fetchAllComments(issue.issueKey);
|
|
80
88
|
}
|
|
81
89
|
catch (error) {
|
|
82
|
-
|
|
90
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
91
|
+
// 更新された課題はコメントが欠けても本体を保存するが、親子の表示を揃えるためだけの書き直しでは既存のファイルを残す
|
|
92
|
+
if (relationOnly) {
|
|
93
|
+
deps.logger.warn(`課題 ${issue.issueKey} のコメント取得に失敗したため、親子の表示の更新を見送りました: ${reason}`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
deps.logger.warn(`課題 ${issue.issueKey} のコメント取得に失敗しました: ${reason}`);
|
|
83
97
|
}
|
|
84
|
-
const attachmentLinks = options.downloadAttachments
|
|
85
|
-
? await downloadIssueAttachments(deps, issue, options)
|
|
86
|
-
: undefined;
|
|
98
|
+
const attachmentLinks = options.downloadAttachments ? await downloadIssueAttachments(deps, issue, options) : undefined;
|
|
87
99
|
const filePath = path.join(options.outputDir, issueRelativePath(issue, options));
|
|
88
|
-
await writeMarkdownFile(filePath, buildIssueMarkdown(issue, comments, backlogIssueUrl,
|
|
100
|
+
await writeMarkdownFile(filePath, buildIssueMarkdown(issue, comments, backlogIssueUrl, {
|
|
101
|
+
attachmentLinks,
|
|
102
|
+
issueUrl: (issueKey) => issueUrl(options.domain, issueKey),
|
|
103
|
+
relations: {
|
|
104
|
+
children: findChildren(issue, relationIndex.childIndex),
|
|
105
|
+
parent: findParent(issue, relationIndex.refIndex),
|
|
106
|
+
},
|
|
107
|
+
}));
|
|
89
108
|
await appendLog(options.outputDir, `課題「${issue.summary}」を更新しました: ${backlogIssueUrl}`);
|
|
90
109
|
}
|
|
110
|
+
// 取得済みの課題だけでは足りない親子をAPIで補い、索引を組み立てる。
|
|
111
|
+
// 課題ごとに引くとN+1になるため、いずれも配列パラメータでまとめて取得する。
|
|
112
|
+
// 補完に失敗しても課題本体の保存は続行する(関連情報が欠けるだけに留める)
|
|
113
|
+
async function buildRelationIndex(deps, allIssues, targets, options) {
|
|
114
|
+
const sources = [...allIssues];
|
|
115
|
+
// 課題キー指定では取得済み集合に子が含まれないため、対象課題の子をまとめて引く
|
|
116
|
+
if (options.issueIdOrKeys && options.issueIdOrKeys.length > 0) {
|
|
117
|
+
sources.push(...(await fetchChildrenOf(deps, targets)));
|
|
118
|
+
}
|
|
119
|
+
sources.push(...(await fetchMissingParents(deps, targets, buildIssueRefIndex(sources))));
|
|
120
|
+
return { childIndex: buildChildIndex(sources), refIndex: buildIssueRefIndex(sources) };
|
|
121
|
+
}
|
|
122
|
+
async function fetchChildrenOf(deps, targets) {
|
|
123
|
+
const children = [];
|
|
124
|
+
for (const ids of chunkIds(targets.map((target) => target.id))) {
|
|
125
|
+
try {
|
|
126
|
+
// eslint-disable-next-line no-await-in-loop
|
|
127
|
+
children.push(...(await deps.issueRepository.fetchChildren(ids)));
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
deps.logger.warn(`子課題の取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return children;
|
|
134
|
+
}
|
|
135
|
+
// 別プロジェクトの親やstatusIdで絞り込まれた親は取得済み集合に現れないため、IDから引き直す
|
|
136
|
+
async function fetchMissingParents(deps, targets, refIndex) {
|
|
137
|
+
const missingIds = [
|
|
138
|
+
...new Set(targets
|
|
139
|
+
.map((target) => target.parentIssueId)
|
|
140
|
+
.filter((parentIssueId) => typeof parentIssueId === 'number')
|
|
141
|
+
.filter((parentIssueId) => !refIndex.has(parentIssueId))),
|
|
142
|
+
];
|
|
143
|
+
const parents = [];
|
|
144
|
+
for (const ids of chunkIds(missingIds)) {
|
|
145
|
+
try {
|
|
146
|
+
// eslint-disable-next-line no-await-in-loop
|
|
147
|
+
parents.push(...(await deps.issueRepository.fetchByIds(ids)));
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
deps.logger.warn(`親課題の取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return parents;
|
|
154
|
+
}
|
|
155
|
+
function chunkIds(ids) {
|
|
156
|
+
const chunks = [];
|
|
157
|
+
for (let index = 0; index < ids.length; index += BULK_FETCH_SIZE) {
|
|
158
|
+
chunks.push(ids.slice(index, index + BULK_FETCH_SIZE));
|
|
159
|
+
}
|
|
160
|
+
return chunks;
|
|
161
|
+
}
|
|
91
162
|
// 保存できた添付のみリンク化する。個々の失敗は警告に留め、課題本体の保存は続行する
|
|
92
163
|
async function downloadIssueAttachments(deps, issue, options) {
|
|
93
164
|
const links = new Map();
|
|
@@ -2,6 +2,7 @@ export declare class HttpError extends Error {
|
|
|
2
2
|
readonly status: number;
|
|
3
3
|
constructor(status: number, statusText: string, url: string);
|
|
4
4
|
}
|
|
5
|
+
export type QueryParams = Record<string, string | string[]>;
|
|
5
6
|
export declare function rateLimitWaitMs(resetHeader: null | string, nowMs: number): number;
|
|
6
7
|
export declare class BacklogHttpClient {
|
|
7
8
|
private readonly apiKey;
|
|
@@ -12,8 +13,8 @@ export declare class BacklogHttpClient {
|
|
|
12
13
|
domain: string;
|
|
13
14
|
onRateLimitExceeded?: (waitSeconds: number) => void;
|
|
14
15
|
});
|
|
15
|
-
getBinary(pathname: string, params?:
|
|
16
|
-
getJson<T>(pathname: string, params?:
|
|
16
|
+
getBinary(pathname: string, params?: QueryParams): Promise<ArrayBuffer>;
|
|
17
|
+
getJson<T>(pathname: string, params?: QueryParams): Promise<T>;
|
|
17
18
|
private fetchWithRetry;
|
|
18
19
|
private maskApiKey;
|
|
19
20
|
private requestUrl;
|
|
@@ -87,7 +87,12 @@ export class BacklogHttpClient {
|
|
|
87
87
|
return url.replace(/apiKey=[^&]*/, 'apiKey=***');
|
|
88
88
|
}
|
|
89
89
|
requestUrl(pathname, params) {
|
|
90
|
-
const searchParams = new URLSearchParams({ apiKey: this.apiKey
|
|
90
|
+
const searchParams = new URLSearchParams({ apiKey: this.apiKey });
|
|
91
|
+
for (const [key, value] of Object.entries(params)) {
|
|
92
|
+
for (const item of Array.isArray(value) ? value : [value]) {
|
|
93
|
+
searchParams.append(key, item);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
91
96
|
return `${this.baseUrl}${pathname}?${searchParams.toString()}`;
|
|
92
97
|
}
|
|
93
98
|
}
|
package/oclif.manifest.json
CHANGED