backlog-exporter 1.0.0 → 1.1.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 -1
- package/dist/commands/all/index.d.ts +1 -0
- package/dist/commands/all/index.js +8 -2
- package/dist/commands/document/index.d.ts +1 -0
- package/dist/commands/document/index.js +9 -2
- package/dist/commands/issue/index.d.ts +1 -0
- package/dist/commands/issue/index.js +12 -2
- package/dist/commands/update/index.d.ts +1 -0
- package/dist/commands/update/index.js +5 -0
- package/dist/commands/wiki/index.d.ts +1 -0
- package/dist/commands/wiki/index.js +9 -2
- package/dist/composition/backlog-repositories.d.ts +1 -1
- package/dist/composition/backlog-repositories.js +1 -1
- package/dist/modules/all/use-case/export-all.d.ts +1 -0
- package/dist/modules/all/use-case/export-all.js +7 -1
- package/dist/modules/document/domain/document-markdown.d.ts +1 -1
- package/dist/modules/document/domain/document-markdown.js +6 -3
- package/dist/modules/document/domain/document-path.d.ts +8 -0
- package/dist/modules/document/domain/document-path.js +9 -0
- package/dist/modules/document/domain/document-repository.d.ts +1 -0
- package/dist/modules/document/repository/backlog-document-repository.js +4 -0
- package/dist/modules/document/use-case/export-documents.d.ts +1 -0
- package/dist/modules/document/use-case/export-documents.js +28 -3
- package/dist/modules/issue/domain/issue-markdown.d.ts +4 -3
- package/dist/modules/issue/domain/issue-markdown.js +23 -8
- package/dist/modules/issue/domain/issue-path.d.ts +12 -1
- package/dist/modules/issue/domain/issue-path.js +12 -0
- package/dist/modules/issue/domain/issue-repository.d.ts +1 -0
- package/dist/modules/issue/domain/issue.d.ts +6 -0
- package/dist/modules/issue/repository/backlog-issue-repository.js +3 -0
- package/dist/modules/issue/use-case/export-issues.d.ts +1 -0
- package/dist/modules/issue/use-case/export-issues.js +29 -3
- package/dist/modules/prune/repository/prune-walker.js +5 -0
- package/dist/modules/prune/use-case/prune-directories.d.ts +1 -1
- package/dist/modules/prune/use-case/prune-directories.js +1 -1
- package/dist/modules/settings/domain/settings.d.ts +1 -0
- package/dist/modules/update/domain/update-plan.d.ts +2 -0
- package/dist/modules/update/domain/update-plan.js +1 -0
- package/dist/modules/update/use-case/update-exports.d.ts +1 -1
- package/dist/modules/update/use-case/update-exports.js +4 -1
- package/dist/modules/wiki/domain/wiki-markdown.d.ts +6 -1
- package/dist/modules/wiki/domain/wiki-markdown.js +17 -2
- package/dist/modules/wiki/domain/wiki-path.d.ts +3 -0
- package/dist/modules/wiki/domain/wiki-path.js +12 -0
- package/dist/modules/wiki/domain/wiki-repository.d.ts +1 -0
- package/dist/modules/wiki/domain/wiki.d.ts +6 -0
- package/dist/modules/wiki/repository/backlog-wiki-repository.js +3 -0
- package/dist/modules/wiki/use-case/export-wikis.d.ts +1 -0
- package/dist/modules/wiki/use-case/export-wikis.js +31 -3
- package/dist/shared/attachment.d.ts +8 -0
- package/dist/shared/attachment.js +37 -0
- package/dist/shared/backlog/http-client.d.ts +5 -2
- package/dist/shared/backlog/http-client.js +41 -10
- package/dist/shared/backlog/sleep.d.ts +1 -0
- package/dist/shared/backlog/sleep.js +3 -0
- package/dist/shared/console/progress.js +8 -1
- package/dist/shared/file-name.d.ts +1 -0
- package/dist/shared/file-name.js +11 -0
- package/dist/shared/storage/markdown-store.d.ts +2 -0
- package/dist/shared/storage/markdown-store.js +11 -0
- package/oclif.manifest.json +43 -2
- package/package.json +1 -1
- package/dist/shared/backlog/rate-limiter.d.ts +0 -9
- package/dist/shared/backlog/rate-limiter.js +0 -25
package/README.md
CHANGED
|
@@ -49,7 +49,7 @@ $ npm install -g backlog-exporter
|
|
|
49
49
|
$ backlog-exporter COMMAND
|
|
50
50
|
running command...
|
|
51
51
|
$ backlog-exporter (--version)
|
|
52
|
-
backlog-exporter/1.
|
|
52
|
+
backlog-exporter/1.1.0 linux-x64 node-v26.5.0
|
|
53
53
|
$ backlog-exporter --help [COMMAND]
|
|
54
54
|
USAGE
|
|
55
55
|
$ backlog-exporter COMMAND
|
|
@@ -159,8 +159,30 @@ $ backlog-exporter issue --domain example.backlog.jp --projectIdOrKey PROJECT_KE
|
|
|
159
159
|
$ backlog-exporter issue --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --issueKeyFileName --issueKeyFolder
|
|
160
160
|
```
|
|
161
161
|
|
|
162
|
+
**課題の添付ファイルもダウンロードする**
|
|
163
|
+
|
|
164
|
+
```sh
|
|
165
|
+
$ backlog-exporter issue --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --downloadAttachments
|
|
166
|
+
```
|
|
167
|
+
|
|
162
168
|
エクスポートされた課題は、指定したディレクトリ内に Markdown ファイルとして保存されます。ファイル名は課題のキーに基づいて自動的に生成されます。
|
|
163
169
|
|
|
170
|
+
## 添付ファイルのダウンロード
|
|
171
|
+
|
|
172
|
+
`--downloadAttachments`(短縮形: `-d`)フラグを指定すると、課題・Wiki・ドキュメントの添付ファイルもダウンロードされます(`all` / `issue` / `wiki` / `document` / `update` コマンドで使用可能)。
|
|
173
|
+
|
|
174
|
+
- 保存先:
|
|
175
|
+
- 課題: デフォルトは `{年}/attachments/{課題キー}/`、`--issueKeyFolder` 指定時は課題フォルダ直下の `attachments/`
|
|
176
|
+
- Wiki: Markdownと同じディレクトリの `attachments/{Wiki名}/`(階層Wikiは末尾のページ名)
|
|
177
|
+
- ドキュメント: Markdownと同じディレクトリの `attachments/{ドキュメント名}/`
|
|
178
|
+
- ファイル名は同名の衝突を避けるため `{添付ID}_{ファイル名}` になります
|
|
179
|
+
- Markdown の `## 添付ファイル` セクションにローカルファイルへの相対リンクが記載されます(フラグ未指定時はファイル名とサイズのみ記載)
|
|
180
|
+
- 課題の本文・コメント内の添付画像のインライン記法(`![image][ファイル名]` / `#image(ファイル名)`)は、ダウンロード済みファイルへの画像リンクに変換され、Markdownビューアでそのまま表示できます
|
|
181
|
+
- Wiki・ドキュメントの本文はBacklogの原文のまま維持されます(添付参照記法の書き換えは行いません)。添付ファイルへは `## 添付ファイル` セクションのリンクからアクセスできます
|
|
182
|
+
- ダウンロード済みのファイルは再ダウンロードされないため、`update` コマンドでの差分更新でも効率的に動作します
|
|
183
|
+
- 設定は `backlog-settings.json` に保存され、以降の `update` コマンドで自動的に引き継がれます(`update` コマンド自体でフラグを指定した場合はその実行のみ有効です)
|
|
184
|
+
- Backlog側で削除された添付ファイルは、誤削除防止のため `prune` コマンドでも削除されず残ります
|
|
185
|
+
|
|
164
186
|
## カスタム属性の対応
|
|
165
187
|
|
|
166
188
|
課題のエクスポートでは、Backlogのカスタム属性も含めて出力されます:
|
|
@@ -5,6 +5,7 @@ export default class All extends Command {
|
|
|
5
5
|
static flags: {
|
|
6
6
|
apiKey: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
7
|
domain: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
downloadAttachments: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
8
9
|
exclude: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
10
|
issueKeyFileName: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
11
|
issueKeyFolder: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
@@ -44,6 +44,11 @@ export default class All extends Command {
|
|
|
44
44
|
description: 'Backlog domain (e.g. example.backlog.jp)',
|
|
45
45
|
required: true,
|
|
46
46
|
}),
|
|
47
|
+
downloadAttachments: Flags.boolean({
|
|
48
|
+
char: 'd',
|
|
49
|
+
description: '課題の添付ファイルもダウンロードする',
|
|
50
|
+
required: false,
|
|
51
|
+
}),
|
|
47
52
|
exclude: Flags.string({
|
|
48
53
|
description: "Exclude the specified types, separated by commas (e.g., 'documents,wiki')",
|
|
49
54
|
required: false,
|
|
@@ -79,7 +84,7 @@ export default class All extends Command {
|
|
|
79
84
|
async run() {
|
|
80
85
|
const { flags } = await this.parse(All);
|
|
81
86
|
try {
|
|
82
|
-
const { domain, exclude, issueKeyFileName, issueKeyFolder, maxCount, only, projectIdOrKey } = flags;
|
|
87
|
+
const { domain, downloadAttachments, exclude, issueKeyFileName, issueKeyFolder, maxCount, only, projectIdOrKey } = flags;
|
|
83
88
|
const apiKey = resolveApiKey(flags.apiKey, () => this.log('環境変数 BACKLOG_API_KEY からAPIキーを使用します')) ??
|
|
84
89
|
this.error(API_KEY_NOT_FOUND_MESSAGE);
|
|
85
90
|
const outputRoot = flags.output || './backlog-data';
|
|
@@ -93,11 +98,12 @@ export default class All extends Command {
|
|
|
93
98
|
const repositories = createBacklogRepositories({
|
|
94
99
|
apiKey,
|
|
95
100
|
domain,
|
|
96
|
-
|
|
101
|
+
onRateLimitExceeded: (waitSeconds) => this.log(`レート制限の上限に達しました。${waitSeconds}秒待機します...`),
|
|
97
102
|
});
|
|
98
103
|
await exportAll({ ...repositories, logger }, {
|
|
99
104
|
apiKey,
|
|
100
105
|
domain,
|
|
106
|
+
downloadAttachments,
|
|
101
107
|
issueKeyFileName,
|
|
102
108
|
issueKeyFolder,
|
|
103
109
|
maxCount,
|
|
@@ -5,6 +5,7 @@ export default class Document extends Command {
|
|
|
5
5
|
static flags: {
|
|
6
6
|
apiKey: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
7
|
domain: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
downloadAttachments: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
8
9
|
keyword: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
10
|
output: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
11
|
projectIdOrKey: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
@@ -29,6 +29,11 @@ export default class Document extends Command {
|
|
|
29
29
|
description: 'Backlog domain (e.g. example.backlog.jp)',
|
|
30
30
|
required: true,
|
|
31
31
|
}),
|
|
32
|
+
downloadAttachments: Flags.boolean({
|
|
33
|
+
char: 'd',
|
|
34
|
+
description: '添付ファイルもダウンロードする',
|
|
35
|
+
required: false,
|
|
36
|
+
}),
|
|
32
37
|
keyword: Flags.string({
|
|
33
38
|
description: '検索キーワード',
|
|
34
39
|
required: false,
|
|
@@ -46,7 +51,7 @@ export default class Document extends Command {
|
|
|
46
51
|
async run() {
|
|
47
52
|
const { flags } = await this.parse(Document);
|
|
48
53
|
try {
|
|
49
|
-
const { domain, keyword, projectIdOrKey } = flags;
|
|
54
|
+
const { domain, downloadAttachments, keyword, projectIdOrKey } = flags;
|
|
50
55
|
const apiKey = resolveApiKey(flags.apiKey, () => this.log('環境変数 BACKLOG_API_KEY からAPIキーを使用します')) ??
|
|
51
56
|
this.error(API_KEY_NOT_FOUND_MESSAGE);
|
|
52
57
|
const outputDir = flags.output || './backlog-documents';
|
|
@@ -54,7 +59,7 @@ export default class Document extends Command {
|
|
|
54
59
|
const { documentRepository, projectRepository } = createBacklogRepositories({
|
|
55
60
|
apiKey,
|
|
56
61
|
domain,
|
|
57
|
-
|
|
62
|
+
onRateLimitExceeded: (waitSeconds) => this.log(`レート制限の上限に達しました。${waitSeconds}秒待機します...`),
|
|
58
63
|
});
|
|
59
64
|
// 出力ディレクトリの作成
|
|
60
65
|
await ensureDirectory(outputDir);
|
|
@@ -65,6 +70,7 @@ export default class Document extends Command {
|
|
|
65
70
|
await updateSettings(outputDir, {
|
|
66
71
|
apiKey,
|
|
67
72
|
domain,
|
|
73
|
+
downloadAttachments,
|
|
68
74
|
folderType: FolderType.DOCUMENT,
|
|
69
75
|
outputDir,
|
|
70
76
|
projectIdOrKey,
|
|
@@ -72,6 +78,7 @@ export default class Document extends Command {
|
|
|
72
78
|
// ドキュメントの取得と保存
|
|
73
79
|
await exportDocuments({ documentRepository, logger }, {
|
|
74
80
|
domain,
|
|
81
|
+
downloadAttachments,
|
|
75
82
|
keyword,
|
|
76
83
|
outputDir,
|
|
77
84
|
projectId,
|
|
@@ -5,6 +5,7 @@ export default class Issue extends Command {
|
|
|
5
5
|
static flags: {
|
|
6
6
|
apiKey: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
7
|
domain: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
downloadAttachments: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
8
9
|
issueKeyFileName: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
9
10
|
issueKeyFolder: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
11
|
maxCount: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
@@ -30,6 +30,9 @@ export default class Issue extends Command {
|
|
|
30
30
|
`,
|
|
31
31
|
`<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --issueKeyFileName --issueKeyFolder
|
|
32
32
|
課題キーでフォルダを作成し、ファイル名も課題キーにする
|
|
33
|
+
`,
|
|
34
|
+
`<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --downloadAttachments
|
|
35
|
+
課題の添付ファイルもダウンロードする
|
|
33
36
|
`,
|
|
34
37
|
];
|
|
35
38
|
static flags = {
|
|
@@ -41,6 +44,11 @@ export default class Issue extends Command {
|
|
|
41
44
|
description: 'Backlog domain (e.g. example.backlog.jp)',
|
|
42
45
|
required: true,
|
|
43
46
|
}),
|
|
47
|
+
downloadAttachments: Flags.boolean({
|
|
48
|
+
char: 'd',
|
|
49
|
+
description: '課題の添付ファイルもダウンロードする',
|
|
50
|
+
required: false,
|
|
51
|
+
}),
|
|
44
52
|
issueKeyFileName: Flags.boolean({
|
|
45
53
|
description: 'ファイル名を課題キーにする',
|
|
46
54
|
required: false,
|
|
@@ -72,7 +80,7 @@ export default class Issue extends Command {
|
|
|
72
80
|
async run() {
|
|
73
81
|
const { flags } = await this.parse(Issue);
|
|
74
82
|
try {
|
|
75
|
-
const { domain, issueKeyFileName, issueKeyFolder, maxCount, projectIdOrKey, statusId } = flags;
|
|
83
|
+
const { domain, downloadAttachments, issueKeyFileName, issueKeyFolder, maxCount, projectIdOrKey, statusId } = flags;
|
|
76
84
|
const apiKey = resolveApiKey(flags.apiKey, () => this.log('環境変数 BACKLOG_API_KEY からAPIキーを使用します')) ??
|
|
77
85
|
this.error(API_KEY_NOT_FOUND_MESSAGE);
|
|
78
86
|
const outputDir = flags.output || './backlog-issues';
|
|
@@ -80,7 +88,7 @@ export default class Issue extends Command {
|
|
|
80
88
|
const { issueRepository, projectRepository } = createBacklogRepositories({
|
|
81
89
|
apiKey,
|
|
82
90
|
domain,
|
|
83
|
-
|
|
91
|
+
onRateLimitExceeded: (waitSeconds) => this.log(`レート制限の上限に達しました。${waitSeconds}秒待機します...`),
|
|
84
92
|
});
|
|
85
93
|
// 出力ディレクトリの作成
|
|
86
94
|
await ensureDirectory(outputDir);
|
|
@@ -91,6 +99,7 @@ export default class Issue extends Command {
|
|
|
91
99
|
await updateSettings(outputDir, {
|
|
92
100
|
apiKey,
|
|
93
101
|
domain,
|
|
102
|
+
downloadAttachments,
|
|
94
103
|
folderType: FolderType.ISSUE,
|
|
95
104
|
issueKeyFileName,
|
|
96
105
|
issueKeyFolder,
|
|
@@ -101,6 +110,7 @@ export default class Issue extends Command {
|
|
|
101
110
|
await exportIssues({ issueRepository, logger }, {
|
|
102
111
|
count: maxCount,
|
|
103
112
|
domain,
|
|
113
|
+
downloadAttachments,
|
|
104
114
|
issueKeyFileName,
|
|
105
115
|
issueKeyFolder,
|
|
106
116
|
outputDir,
|
|
@@ -10,6 +10,7 @@ export default class Update extends Command {
|
|
|
10
10
|
documentId: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
11
|
documentsOnly: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
12
12
|
domain: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
downloadAttachments: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
13
14
|
force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
14
15
|
issueIdOrKey: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
16
|
issueKeyFileName: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
@@ -62,6 +62,11 @@ export default class Update extends Command {
|
|
|
62
62
|
description: 'Backlog domain (e.g. example.backlog.jp)',
|
|
63
63
|
required: false,
|
|
64
64
|
}),
|
|
65
|
+
downloadAttachments: Flags.boolean({
|
|
66
|
+
char: 'd',
|
|
67
|
+
description: '課題の添付ファイルもダウンロードする(未指定時は設定ファイルの値を使用)',
|
|
68
|
+
required: false,
|
|
69
|
+
}),
|
|
65
70
|
force: Flags.boolean({
|
|
66
71
|
char: 'f',
|
|
67
72
|
description: '確認プロンプトをスキップする',
|
|
@@ -5,6 +5,7 @@ export default class Wiki extends Command {
|
|
|
5
5
|
static flags: {
|
|
6
6
|
apiKey: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
7
|
domain: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
downloadAttachments: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
8
9
|
output: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
10
|
projectIdOrKey: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
11
|
};
|
|
@@ -26,6 +26,11 @@ Wikiをダウンロードする
|
|
|
26
26
|
description: 'Backlog domain (e.g. example.backlog.jp)',
|
|
27
27
|
required: true,
|
|
28
28
|
}),
|
|
29
|
+
downloadAttachments: Flags.boolean({
|
|
30
|
+
char: 'd',
|
|
31
|
+
description: '添付ファイルもダウンロードする',
|
|
32
|
+
required: false,
|
|
33
|
+
}),
|
|
29
34
|
output: Flags.string({
|
|
30
35
|
char: 'o',
|
|
31
36
|
description: '出力ディレクトリパス',
|
|
@@ -39,7 +44,7 @@ Wikiをダウンロードする
|
|
|
39
44
|
async run() {
|
|
40
45
|
const { flags } = await this.parse(Wiki);
|
|
41
46
|
try {
|
|
42
|
-
const { domain, projectIdOrKey } = flags;
|
|
47
|
+
const { domain, downloadAttachments, projectIdOrKey } = flags;
|
|
43
48
|
const apiKey = resolveApiKey(flags.apiKey, () => this.log('環境変数 BACKLOG_API_KEY からAPIキーを使用します')) ??
|
|
44
49
|
this.error(API_KEY_NOT_FOUND_MESSAGE);
|
|
45
50
|
const outputDir = flags.output || './wiki';
|
|
@@ -47,7 +52,7 @@ Wikiをダウンロードする
|
|
|
47
52
|
const { wikiRepository } = createBacklogRepositories({
|
|
48
53
|
apiKey,
|
|
49
54
|
domain,
|
|
50
|
-
|
|
55
|
+
onRateLimitExceeded: (waitSeconds) => this.log(`レート制限の上限に達しました。${waitSeconds}秒待機します...`),
|
|
51
56
|
});
|
|
52
57
|
// 出力ディレクトリの作成
|
|
53
58
|
await ensureDirectory(outputDir);
|
|
@@ -55,6 +60,7 @@ Wikiをダウンロードする
|
|
|
55
60
|
await updateSettings(outputDir, {
|
|
56
61
|
apiKey,
|
|
57
62
|
domain,
|
|
63
|
+
downloadAttachments,
|
|
58
64
|
folderType: FolderType.WIKI,
|
|
59
65
|
outputDir,
|
|
60
66
|
projectIdOrKey,
|
|
@@ -62,6 +68,7 @@ Wikiをダウンロードする
|
|
|
62
68
|
// Wikiの取得と保存
|
|
63
69
|
await exportWikis({ logger, wikiRepository }, {
|
|
64
70
|
domain,
|
|
71
|
+
downloadAttachments,
|
|
65
72
|
outputDir,
|
|
66
73
|
projectIdOrKey,
|
|
67
74
|
});
|
|
@@ -5,7 +5,7 @@ import { WikiRepository } from '../modules/wiki/domain/wiki-repository.js';
|
|
|
5
5
|
export interface BacklogConnection {
|
|
6
6
|
apiKey: string;
|
|
7
7
|
domain: string;
|
|
8
|
-
|
|
8
|
+
onRateLimitExceeded?: (waitSeconds: number) => void;
|
|
9
9
|
}
|
|
10
10
|
export interface BacklogRepositories {
|
|
11
11
|
documentRepository: DocumentRepository;
|
|
@@ -3,7 +3,7 @@ import { newBacklogIssueRepository } from '../modules/issue/repository/backlog-i
|
|
|
3
3
|
import { newBacklogProjectRepository } from '../modules/project/repository/backlog-project-repository.js';
|
|
4
4
|
import { newBacklogWikiRepository } from '../modules/wiki/repository/backlog-wiki-repository.js';
|
|
5
5
|
import { BacklogHttpClient } from '../shared/backlog/http-client.js';
|
|
6
|
-
// 1つのHTTP
|
|
6
|
+
// 1つのHTTPクライアントを全repositoryで共有する
|
|
7
7
|
export function createBacklogRepositories(connection) {
|
|
8
8
|
const client = new BacklogHttpClient(connection);
|
|
9
9
|
return {
|
|
@@ -7,7 +7,7 @@ import { updateSettings } from '../../settings/repository/settings-store.js';
|
|
|
7
7
|
import { exportWikis } from '../../wiki/use-case/export-wikis.js';
|
|
8
8
|
export async function exportAll(deps, options) {
|
|
9
9
|
const { documentRepository, issueRepository, logger, projectRepository, wikiRepository } = deps;
|
|
10
|
-
const { apiKey, domain, issueKeyFileName, issueKeyFolder, maxCount, outputRoot, projectIdOrKey, targets } = options;
|
|
10
|
+
const { apiKey, domain, downloadAttachments, issueKeyFileName, issueKeyFolder, maxCount, outputRoot, projectIdOrKey, targets } = options;
|
|
11
11
|
await ensureDirectory(outputRoot);
|
|
12
12
|
const projectId = await projectRepository.resolveProjectId(projectIdOrKey);
|
|
13
13
|
logger.log(`プロジェクトID: ${projectId} を使用します`);
|
|
@@ -17,6 +17,7 @@ export async function exportAll(deps, options) {
|
|
|
17
17
|
await updateSettings(issueOutput, {
|
|
18
18
|
apiKey,
|
|
19
19
|
domain,
|
|
20
|
+
downloadAttachments,
|
|
20
21
|
folderType: FolderType.ISSUE,
|
|
21
22
|
issueKeyFileName,
|
|
22
23
|
issueKeyFolder,
|
|
@@ -27,6 +28,7 @@ export async function exportAll(deps, options) {
|
|
|
27
28
|
await exportIssues({ issueRepository, logger }, {
|
|
28
29
|
count: maxCount,
|
|
29
30
|
domain,
|
|
31
|
+
downloadAttachments,
|
|
30
32
|
issueKeyFileName,
|
|
31
33
|
issueKeyFolder,
|
|
32
34
|
outputDir: issueOutput,
|
|
@@ -41,6 +43,7 @@ export async function exportAll(deps, options) {
|
|
|
41
43
|
await updateSettings(wikiOutput, {
|
|
42
44
|
apiKey,
|
|
43
45
|
domain,
|
|
46
|
+
downloadAttachments,
|
|
44
47
|
folderType: FolderType.WIKI,
|
|
45
48
|
outputDir: wikiOutput,
|
|
46
49
|
projectIdOrKey,
|
|
@@ -48,6 +51,7 @@ export async function exportAll(deps, options) {
|
|
|
48
51
|
logger.log('Wikiの取得を開始します...');
|
|
49
52
|
await exportWikis({ logger, wikiRepository }, {
|
|
50
53
|
domain,
|
|
54
|
+
downloadAttachments,
|
|
51
55
|
outputDir: wikiOutput,
|
|
52
56
|
projectIdOrKey,
|
|
53
57
|
});
|
|
@@ -60,6 +64,7 @@ export async function exportAll(deps, options) {
|
|
|
60
64
|
await updateSettings(documentOutput, {
|
|
61
65
|
apiKey,
|
|
62
66
|
domain,
|
|
67
|
+
downloadAttachments,
|
|
63
68
|
folderType: FolderType.DOCUMENT,
|
|
64
69
|
outputDir: documentOutput,
|
|
65
70
|
projectIdOrKey,
|
|
@@ -67,6 +72,7 @@ export async function exportAll(deps, options) {
|
|
|
67
72
|
logger.log('ドキュメントの取得を開始します...');
|
|
68
73
|
await exportDocuments({ documentRepository, logger }, {
|
|
69
74
|
domain,
|
|
75
|
+
downloadAttachments,
|
|
70
76
|
outputDir: documentOutput,
|
|
71
77
|
projectId,
|
|
72
78
|
projectIdOrKey,
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import { DocumentDetail } from './document.js';
|
|
2
|
-
export declare function buildDocumentMarkdown(documentDetail: DocumentDetail, backlogDocumentUrl: string): string;
|
|
2
|
+
export declare function buildDocumentMarkdown(documentDetail: DocumentDetail, backlogDocumentUrl: string, attachmentLinks?: Map<number, string>): string;
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
+
import { escapeLinkText } from '../../../shared/attachment.js';
|
|
1
2
|
import { wrapBody } from '../../../shared/markdown/body-marker.js';
|
|
2
|
-
export function buildDocumentMarkdown(documentDetail, backlogDocumentUrl) {
|
|
3
|
-
//
|
|
3
|
+
export function buildDocumentMarkdown(documentDetail, backlogDocumentUrl, attachmentLinks) {
|
|
4
|
+
// 添付ファイルリストの作成(ダウンロード済みはローカルへの相対リンク付き)
|
|
4
5
|
let attachmentsSection = '';
|
|
5
6
|
if (documentDetail.attachments && documentDetail.attachments.length > 0) {
|
|
6
7
|
attachmentsSection = '\n\n## 添付ファイル\n';
|
|
7
8
|
for (const attachment of documentDetail.attachments) {
|
|
8
9
|
const attachmentDate = new Date(attachment.created).toLocaleString('ja-JP');
|
|
9
10
|
const fileSize = (attachment.size / 1024).toFixed(1);
|
|
10
|
-
|
|
11
|
+
const link = attachmentLinks?.get(attachment.id);
|
|
12
|
+
const label = link ? `[${escapeLinkText(attachment.name)}](${link})` : `**${attachment.name}**`;
|
|
13
|
+
attachmentsSection += `- ${label} (${fileSize} KB) - 作成者: ${attachment.createdUser.name}, 作成日時: ${attachmentDate}\n`;
|
|
11
14
|
}
|
|
12
15
|
}
|
|
13
16
|
// タグリストの作成
|
|
@@ -4,6 +4,14 @@ export declare const PARENT_DOCUMENT_INDEX_FILENAME = "00_index.md";
|
|
|
4
4
|
export declare function documentFolderPath(currentPath: string, folderName: string): string;
|
|
5
5
|
export declare function documentFileName(title: string, asParentIndex: boolean): string;
|
|
6
6
|
export declare function documentUrl(domain: string, projectIdOrKey: string, documentId: string): string;
|
|
7
|
+
export declare function documentAttachmentRelativePath(currentPath: string, documentTitle: string, attachment: {
|
|
8
|
+
id: number;
|
|
9
|
+
name: string;
|
|
10
|
+
}): string;
|
|
11
|
+
export declare function documentAttachmentMarkdownLink(documentTitle: string, attachment: {
|
|
12
|
+
id: number;
|
|
13
|
+
name: string;
|
|
14
|
+
}): string;
|
|
7
15
|
export interface DocumentTreePaths extends ExpectedPaths {
|
|
8
16
|
leafNodes: Array<{
|
|
9
17
|
currentPath: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import { attachmentFileName, encodeLinkDestination } from '../../../shared/attachment.js';
|
|
2
3
|
import { backlogOrigin } from '../../../shared/backlog-url.js';
|
|
3
4
|
import { sanitizeFileName } from '../../../shared/file-name.js';
|
|
4
5
|
// 子を持つ親ドキュメント自身の本文の保存先。ダウンロード側とprune側でレイアウト定義を共有する
|
|
@@ -12,6 +13,14 @@ export function documentFileName(title, asParentIndex) {
|
|
|
12
13
|
export function documentUrl(domain, projectIdOrKey, documentId) {
|
|
13
14
|
return `${backlogOrigin(domain)}/document/${projectIdOrKey}/${documentId}`;
|
|
14
15
|
}
|
|
16
|
+
// 添付の保存先はMarkdownと同じディレクトリの attachments/{ドキュメント名}/ 配下。
|
|
17
|
+
// タイトル改名時は再ダウンロードになるが、Markdown本体({タイトル}.md)と同じ挙動で閲覧性を優先する
|
|
18
|
+
export function documentAttachmentRelativePath(currentPath, documentTitle, attachment) {
|
|
19
|
+
return path.join(currentPath, 'attachments', sanitizeFileName(documentTitle), attachmentFileName(attachment));
|
|
20
|
+
}
|
|
21
|
+
export function documentAttachmentMarkdownLink(documentTitle, attachment) {
|
|
22
|
+
return encodeLinkDestination(['.', 'attachments', sanitizeFileName(documentTitle), attachmentFileName(attachment)].join('/'));
|
|
23
|
+
}
|
|
15
24
|
export function collectDocumentTreePaths(rootNodes) {
|
|
16
25
|
const expectedFiles = new Set();
|
|
17
26
|
const expectedDirs = new Set();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DocumentDetail, DocumentTree } from './document.js';
|
|
2
2
|
export interface DocumentRepository {
|
|
3
|
+
downloadAttachment(documentId: string, attachmentId: number): Promise<ArrayBuffer>;
|
|
3
4
|
fetchAllTitles(projectId: number): Promise<Map<string, string>>;
|
|
4
5
|
fetchDetail(documentId: string): Promise<DocumentDetail>;
|
|
5
6
|
fetchTree(projectId: number): Promise<DocumentTree>;
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
export function newBacklogDocumentRepository(client) {
|
|
2
2
|
return {
|
|
3
|
+
// 公式ドキュメント未記載だが公式SDK(nulab/backlog-js)に存在するエンドポイント
|
|
4
|
+
async downloadAttachment(documentId, attachmentId) {
|
|
5
|
+
return client.getBinary(`/documents/${documentId}/attachments/${attachmentId}`);
|
|
6
|
+
},
|
|
3
7
|
async fetchAllTitles(projectId) {
|
|
4
8
|
const titlesById = new Map();
|
|
5
9
|
const pageSize = 100;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { writeProgress } from '../../../shared/console/progress.js';
|
|
3
|
-
import { deleteFile, ensureDirectory, fileExists, writeMarkdownFile } from '../../../shared/storage/markdown-store.js';
|
|
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
5
|
import { buildDocumentMarkdown } from '../domain/document-markdown.js';
|
|
6
|
-
import { documentFileName, documentFolderPath, documentUrl, PARENT_DOCUMENT_INDEX_FILENAME, } from '../domain/document-path.js';
|
|
6
|
+
import { 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
|
export async function exportDocuments(deps, options) {
|
|
9
9
|
const { documentRepository, logger } = deps;
|
|
@@ -42,7 +42,10 @@ export async function exportDocuments(deps, options) {
|
|
|
42
42
|
}
|
|
43
43
|
case 'save': {
|
|
44
44
|
const backlogDocumentUrl = documentUrl(options.domain, options.projectIdOrKey, node.id);
|
|
45
|
-
|
|
45
|
+
const attachmentLinks = options.downloadAttachments
|
|
46
|
+
? await downloadDocumentAttachments(deps, documentDetail, currentPath, options.outputDir)
|
|
47
|
+
: undefined;
|
|
48
|
+
await writeMarkdownFile(filePath, buildDocumentMarkdown(documentDetail, backlogDocumentUrl, attachmentLinks));
|
|
46
49
|
writtenFiles.add(filePath);
|
|
47
50
|
await appendLog(options.outputDir, `ドキュメント「${documentDetail.title}」を更新しました: ${backlogDocumentUrl}`);
|
|
48
51
|
break;
|
|
@@ -80,3 +83,25 @@ export async function exportDocuments(deps, options) {
|
|
|
80
83
|
logger.log(`\n合計 ${processedDocuments.length}件のドキュメントが処理されました。`);
|
|
81
84
|
logger.log('ドキュメントのダウンロードが完了しました!');
|
|
82
85
|
}
|
|
86
|
+
// 保存できた添付のみリンク化する。個々の失敗は警告に留め、ドキュメント本体の保存は続行する
|
|
87
|
+
async function downloadDocumentAttachments(deps, documentDetail, currentPath, outputDir) {
|
|
88
|
+
const links = new Map();
|
|
89
|
+
for (const attachment of documentDetail.attachments ?? []) {
|
|
90
|
+
const absolutePath = path.join(outputDir, documentAttachmentRelativePath(currentPath, documentDetail.title, attachment));
|
|
91
|
+
try {
|
|
92
|
+
// 添付IDは不変のため、サイズの一致するファイルが既にあれば再ダウンロードしない
|
|
93
|
+
// eslint-disable-next-line no-await-in-loop
|
|
94
|
+
if ((await fileSize(absolutePath)) !== attachment.size) {
|
|
95
|
+
// eslint-disable-next-line no-await-in-loop
|
|
96
|
+
const data = await deps.documentRepository.downloadAttachment(documentDetail.id, attachment.id);
|
|
97
|
+
// eslint-disable-next-line no-await-in-loop
|
|
98
|
+
await writeBinaryFile(absolutePath, data);
|
|
99
|
+
}
|
|
100
|
+
links.set(attachment.id, documentAttachmentMarkdownLink(documentDetail.title, attachment));
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
deps.logger.warn(`ドキュメント「${documentDetail.title}」の添付ファイル「${attachment.name}」の取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return links;
|
|
107
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { CustomField, Issue, IssueComment } from './issue.js';
|
|
1
|
+
import { CustomField, Issue, IssueAttachment, IssueComment } from './issue.js';
|
|
2
2
|
export declare function createCustomFieldsSection(customFields?: CustomField[]): string;
|
|
3
|
-
export declare function
|
|
4
|
-
export declare function
|
|
3
|
+
export declare function buildAttachmentsSection(attachments: IssueAttachment[] | undefined, localLinks?: Map<number, string>): string;
|
|
4
|
+
export declare function buildCommentsSection(comments: IssueComment[], backlogIssueUrl: string, rewriteBody?: (text: string) => string): string;
|
|
5
|
+
export declare function buildIssueMarkdown(issue: Issue, comments: IssueComment[], backlogIssueUrl: string, attachmentLinks?: Map<number, string>): string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { escapeLinkText, rewriteInlineImages } from '../../../shared/attachment.js';
|
|
1
2
|
import { wrapBody } from '../../../shared/markdown/body-marker.js';
|
|
2
3
|
// Backlogの変更履歴(changeLog)のfieldを画面表示に合わせた日本語ラベルへ変換する
|
|
3
4
|
const CHANGE_FIELD_LABELS = {
|
|
@@ -31,10 +32,10 @@ function formatChange(change) {
|
|
|
31
32
|
const label = CHANGE_FIELD_LABELS[change.field] ?? change.field;
|
|
32
33
|
return `- ${label}: ${formatChangeValue(change.originalValue)} → ${formatChangeValue(change.newValue)}`;
|
|
33
34
|
}
|
|
34
|
-
function buildCommentBody(comment) {
|
|
35
|
+
function buildCommentBody(comment, rewriteBody) {
|
|
35
36
|
const parts = [];
|
|
36
37
|
if (comment.content) {
|
|
37
|
-
parts.push(comment.content);
|
|
38
|
+
parts.push(rewriteBody(comment.content));
|
|
38
39
|
}
|
|
39
40
|
if (comment.changeLog && comment.changeLog.length > 0) {
|
|
40
41
|
parts.push(['**変更内容**', ...comment.changeLog.map((change) => formatChange(change))].join('\n'));
|
|
@@ -71,7 +72,19 @@ export function createCustomFieldsSection(customFields) {
|
|
|
71
72
|
}
|
|
72
73
|
return customFieldsSection;
|
|
73
74
|
}
|
|
74
|
-
|
|
75
|
+
// ダウンロード済みの添付はローカルへの相対リンク付き、未ダウンロードはメタデータのみを出力する
|
|
76
|
+
export function buildAttachmentsSection(attachments, localLinks) {
|
|
77
|
+
if (!attachments || attachments.length === 0) {
|
|
78
|
+
return '';
|
|
79
|
+
}
|
|
80
|
+
const lines = attachments.map((attachment) => {
|
|
81
|
+
const fileSize = `${(attachment.size / 1024).toFixed(1)} KB`;
|
|
82
|
+
const link = localLinks?.get(attachment.id);
|
|
83
|
+
return link ? `- [${escapeLinkText(attachment.name)}](${link}) (${fileSize})` : `- ${attachment.name} (${fileSize})`;
|
|
84
|
+
});
|
|
85
|
+
return `\n\n## 添付ファイル\n\n${lines.join('\n')}`;
|
|
86
|
+
}
|
|
87
|
+
export function buildCommentsSection(comments, backlogIssueUrl, rewriteBody = (text) => text) {
|
|
75
88
|
if (comments.length === 0) {
|
|
76
89
|
return '';
|
|
77
90
|
}
|
|
@@ -80,15 +93,17 @@ export function buildCommentsSection(comments, backlogIssueUrl) {
|
|
|
80
93
|
for (const comment of comments) {
|
|
81
94
|
const commentDate = new Date(comment.created).toLocaleString('ja-JP');
|
|
82
95
|
const backlogCommentUrl = `${backlogIssueUrl}#comment-${comment.id}`;
|
|
83
|
-
commentsSection += `\n### コメント ${commentIndex}\n- **投稿者**: ${comment.createdUser.name}\n- **日時**: ${commentDate}\n- [Backlog Comment Link](${backlogCommentUrl})\n\n${buildCommentBody(comment)}\n\n---\n`;
|
|
96
|
+
commentsSection += `\n### コメント ${commentIndex}\n- **投稿者**: ${comment.createdUser.name}\n- **日時**: ${commentDate}\n- [Backlog Comment Link](${backlogCommentUrl})\n\n${buildCommentBody(comment, rewriteBody)}\n\n---\n`;
|
|
84
97
|
commentIndex++;
|
|
85
98
|
}
|
|
86
99
|
// 最後の区切り線を削除
|
|
87
100
|
return commentsSection.slice(0, -5);
|
|
88
101
|
}
|
|
89
|
-
export function buildIssueMarkdown(issue, comments, backlogIssueUrl) {
|
|
90
|
-
const
|
|
102
|
+
export function buildIssueMarkdown(issue, comments, backlogIssueUrl, attachmentLinks) {
|
|
103
|
+
const rewriteBody = (text) => rewriteInlineImages(text, issue.attachments, attachmentLinks);
|
|
104
|
+
const commentsSection = buildCommentsSection(comments, backlogIssueUrl, rewriteBody);
|
|
91
105
|
const customFieldsSection = createCustomFieldsSection(issue.customFields);
|
|
106
|
+
const attachmentsSection = buildAttachmentsSection(issue.attachments, attachmentLinks);
|
|
92
107
|
const assigneeName = issue.assignee ? issue.assignee.name : '未割り当て';
|
|
93
108
|
const startDate = issue.startDate ? new Date(issue.startDate).toLocaleDateString('ja-JP') : '未設定';
|
|
94
109
|
const dueDate = issue.dueDate ? new Date(issue.dueDate).toLocaleDateString('ja-JP') : '未設定';
|
|
@@ -104,9 +119,9 @@ export function buildIssueMarkdown(issue, comments, backlogIssueUrl) {
|
|
|
104
119
|
- 期限日: ${dueDate}
|
|
105
120
|
- 作成日時: ${new Date(issue.created).toLocaleString('ja-JP')}
|
|
106
121
|
- 更新日時: ${new Date(issue.updated).toLocaleString('ja-JP')}
|
|
107
|
-
- [Backlog Issue Link](${backlogIssueUrl})${customFieldsSection}
|
|
122
|
+
- [Backlog Issue Link](${backlogIssueUrl})${customFieldsSection}${attachmentsSection}
|
|
108
123
|
|
|
109
124
|
## 詳細
|
|
110
125
|
|
|
111
|
-
${wrapBody(issue.description
|
|
126
|
+
${wrapBody(issue.description ? rewriteBody(issue.description) : '詳細情報なし')}${commentsSection}`;
|
|
112
127
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ExpectedPaths } from '../../prune/domain/expected-paths.js';
|
|
2
|
-
import { Issue } from './issue.js';
|
|
2
|
+
import { Issue, IssueAttachment } from './issue.js';
|
|
3
3
|
export declare function issueFileName(issue: {
|
|
4
4
|
issueKey: string;
|
|
5
5
|
summary: string;
|
|
@@ -16,6 +16,17 @@ export declare function issueRelativePath(issue: {
|
|
|
16
16
|
issueKeyFileName?: boolean;
|
|
17
17
|
issueKeyFolder?: boolean;
|
|
18
18
|
}): string;
|
|
19
|
+
export declare function attachmentRelativePath(issue: {
|
|
20
|
+
created: string;
|
|
21
|
+
issueKey: string;
|
|
22
|
+
}, attachment: Pick<IssueAttachment, 'id' | 'name'>, options: {
|
|
23
|
+
issueKeyFolder?: boolean;
|
|
24
|
+
}): string;
|
|
25
|
+
export declare function attachmentMarkdownLink(issue: {
|
|
26
|
+
issueKey: string;
|
|
27
|
+
}, attachment: Pick<IssueAttachment, 'id' | 'name'>, options: {
|
|
28
|
+
issueKeyFolder?: boolean;
|
|
29
|
+
}): string;
|
|
19
30
|
export declare function issueUrl(domain: string, issueKey: string): string;
|
|
20
31
|
export declare function buildIssueExpectedPaths(issues: Issue[], options: {
|
|
21
32
|
issueKeyFileName?: boolean;
|