backlog-exporter 1.1.1 → 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.
- package/README.md +4 -3
- package/dist/modules/document/domain/document-json-markdown.d.ts +1 -0
- package/dist/modules/document/domain/document-json-markdown.js +389 -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/oclif.manifest.json +66 -66
- 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.2.0 linux-x64 node-v26.8.1
|
|
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` コマンドでも削除されず残ります
|
|
@@ -363,7 +364,7 @@ Backlog の書式がそのまま保持されます。
|
|
|
363
364
|
|
|
364
365
|
<!-- backlog-exporter:body:start -->
|
|
365
366
|
ここにドキュメントの本文内容が入ります。
|
|
366
|
-
Backlog
|
|
367
|
+
Backlog の構造化データから変換された Markdown が入ります。
|
|
367
368
|
<!-- backlog-exporter:body:end -->
|
|
368
369
|
|
|
369
370
|
## 添付ファイル
|
|
@@ -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 ? `` : ``;
|
|
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
|
|
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,
|
package/oclif.manifest.json
CHANGED
|
@@ -189,6 +189,71 @@
|
|
|
189
189
|
"index.js"
|
|
190
190
|
]
|
|
191
191
|
},
|
|
192
|
+
"prune": {
|
|
193
|
+
"aliases": [],
|
|
194
|
+
"args": {
|
|
195
|
+
"directory": {
|
|
196
|
+
"description": "対象ディレクトリ(設定ファイルが保存されている場所)",
|
|
197
|
+
"name": "directory",
|
|
198
|
+
"required": false
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
"description": "Backlog上で削除・移動された課題・ドキュメント・Wikiのローカルファイルを削除し、Backlogと同じ状態に揃える",
|
|
202
|
+
"examples": [
|
|
203
|
+
"<%= config.bin %> <%= command.id %>\nカレントディレクトリ配下の設定ファイルを探索し、Backlog上に存在しないドキュメントファイルを削除する\n",
|
|
204
|
+
"<%= config.bin %> <%= command.id %> ./backlog-documents\n指定したディレクトリを対象にする\n",
|
|
205
|
+
"<%= config.bin %> <%= command.id %> --force\n確認プロンプトをスキップして削除する\n"
|
|
206
|
+
],
|
|
207
|
+
"flags": {
|
|
208
|
+
"apiKey": {
|
|
209
|
+
"description": "Backlog API key (環境変数 BACKLOG_API_KEY からも自動読み取り可能)",
|
|
210
|
+
"name": "apiKey",
|
|
211
|
+
"required": false,
|
|
212
|
+
"hasDynamicHelp": false,
|
|
213
|
+
"multiple": false,
|
|
214
|
+
"type": "option"
|
|
215
|
+
},
|
|
216
|
+
"domain": {
|
|
217
|
+
"description": "Backlog domain (e.g. example.backlog.jp)",
|
|
218
|
+
"name": "domain",
|
|
219
|
+
"required": false,
|
|
220
|
+
"hasDynamicHelp": false,
|
|
221
|
+
"multiple": false,
|
|
222
|
+
"type": "option"
|
|
223
|
+
},
|
|
224
|
+
"force": {
|
|
225
|
+
"char": "f",
|
|
226
|
+
"description": "確認プロンプトをスキップする",
|
|
227
|
+
"name": "force",
|
|
228
|
+
"required": false,
|
|
229
|
+
"allowNo": false,
|
|
230
|
+
"type": "boolean"
|
|
231
|
+
},
|
|
232
|
+
"projectIdOrKey": {
|
|
233
|
+
"description": "Backlog project ID or key",
|
|
234
|
+
"name": "projectIdOrKey",
|
|
235
|
+
"required": false,
|
|
236
|
+
"hasDynamicHelp": false,
|
|
237
|
+
"multiple": false,
|
|
238
|
+
"type": "option"
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
"hasDynamicHelp": false,
|
|
242
|
+
"hiddenAliases": [],
|
|
243
|
+
"id": "prune",
|
|
244
|
+
"pluginAlias": "backlog-exporter",
|
|
245
|
+
"pluginName": "backlog-exporter",
|
|
246
|
+
"pluginType": "core",
|
|
247
|
+
"strict": true,
|
|
248
|
+
"enableJsonFlag": false,
|
|
249
|
+
"isESM": true,
|
|
250
|
+
"relativePath": [
|
|
251
|
+
"dist",
|
|
252
|
+
"commands",
|
|
253
|
+
"prune",
|
|
254
|
+
"index.js"
|
|
255
|
+
]
|
|
256
|
+
},
|
|
192
257
|
"issue": {
|
|
193
258
|
"aliases": [],
|
|
194
259
|
"args": {},
|
|
@@ -294,71 +359,6 @@
|
|
|
294
359
|
"index.js"
|
|
295
360
|
]
|
|
296
361
|
},
|
|
297
|
-
"prune": {
|
|
298
|
-
"aliases": [],
|
|
299
|
-
"args": {
|
|
300
|
-
"directory": {
|
|
301
|
-
"description": "対象ディレクトリ(設定ファイルが保存されている場所)",
|
|
302
|
-
"name": "directory",
|
|
303
|
-
"required": false
|
|
304
|
-
}
|
|
305
|
-
},
|
|
306
|
-
"description": "Backlog上で削除・移動された課題・ドキュメント・Wikiのローカルファイルを削除し、Backlogと同じ状態に揃える",
|
|
307
|
-
"examples": [
|
|
308
|
-
"<%= config.bin %> <%= command.id %>\nカレントディレクトリ配下の設定ファイルを探索し、Backlog上に存在しないドキュメントファイルを削除する\n",
|
|
309
|
-
"<%= config.bin %> <%= command.id %> ./backlog-documents\n指定したディレクトリを対象にする\n",
|
|
310
|
-
"<%= config.bin %> <%= command.id %> --force\n確認プロンプトをスキップして削除する\n"
|
|
311
|
-
],
|
|
312
|
-
"flags": {
|
|
313
|
-
"apiKey": {
|
|
314
|
-
"description": "Backlog API key (環境変数 BACKLOG_API_KEY からも自動読み取り可能)",
|
|
315
|
-
"name": "apiKey",
|
|
316
|
-
"required": false,
|
|
317
|
-
"hasDynamicHelp": false,
|
|
318
|
-
"multiple": false,
|
|
319
|
-
"type": "option"
|
|
320
|
-
},
|
|
321
|
-
"domain": {
|
|
322
|
-
"description": "Backlog domain (e.g. example.backlog.jp)",
|
|
323
|
-
"name": "domain",
|
|
324
|
-
"required": false,
|
|
325
|
-
"hasDynamicHelp": false,
|
|
326
|
-
"multiple": false,
|
|
327
|
-
"type": "option"
|
|
328
|
-
},
|
|
329
|
-
"force": {
|
|
330
|
-
"char": "f",
|
|
331
|
-
"description": "確認プロンプトをスキップする",
|
|
332
|
-
"name": "force",
|
|
333
|
-
"required": false,
|
|
334
|
-
"allowNo": false,
|
|
335
|
-
"type": "boolean"
|
|
336
|
-
},
|
|
337
|
-
"projectIdOrKey": {
|
|
338
|
-
"description": "Backlog project ID or key",
|
|
339
|
-
"name": "projectIdOrKey",
|
|
340
|
-
"required": false,
|
|
341
|
-
"hasDynamicHelp": false,
|
|
342
|
-
"multiple": false,
|
|
343
|
-
"type": "option"
|
|
344
|
-
}
|
|
345
|
-
},
|
|
346
|
-
"hasDynamicHelp": false,
|
|
347
|
-
"hiddenAliases": [],
|
|
348
|
-
"id": "prune",
|
|
349
|
-
"pluginAlias": "backlog-exporter",
|
|
350
|
-
"pluginName": "backlog-exporter",
|
|
351
|
-
"pluginType": "core",
|
|
352
|
-
"strict": true,
|
|
353
|
-
"enableJsonFlag": false,
|
|
354
|
-
"isESM": true,
|
|
355
|
-
"relativePath": [
|
|
356
|
-
"dist",
|
|
357
|
-
"commands",
|
|
358
|
-
"prune",
|
|
359
|
-
"index.js"
|
|
360
|
-
]
|
|
361
|
-
},
|
|
362
362
|
"update": {
|
|
363
363
|
"aliases": [],
|
|
364
364
|
"args": {
|
|
@@ -566,5 +566,5 @@
|
|
|
566
566
|
]
|
|
567
567
|
}
|
|
568
568
|
},
|
|
569
|
-
"version": "1.
|
|
569
|
+
"version": "1.2.0"
|
|
570
570
|
}
|