backlog-exporter 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -59,7 +59,7 @@ $ npm install -g backlog-exporter
59
59
  $ backlog-exporter COMMAND
60
60
  running command...
61
61
  $ backlog-exporter (--version)
62
- backlog-exporter/0.1.1 linux-x64 node-v23.9.0
62
+ backlog-exporter/0.2.1 linux-x64 node-v23.9.0
63
63
  $ backlog-exporter --help [COMMAND]
64
64
  USAGE
65
65
  $ backlog-exporter COMMAND
@@ -196,9 +196,11 @@ Backlogから課題とWikiを取得してMarkdownファイルとして保存す
196
196
 
197
197
  ```
198
198
  USAGE
199
- $ backlog-exporter all --domain <value> --projectIdOrKey <value> [--apiKey <value>] [-o <value>]
199
+ $ backlog-exporter all --domain <value> --projectIdOrKey <value> [--apiKey <value>] [-m <value>] [-o
200
+ <value>]
200
201
 
201
202
  FLAGS
203
+ -m, --maxCount=<value> [default: 5000] 一度に取得する課題の最大数(デフォルト: 5000)
202
204
  -o, --output=<value> 出力ディレクトリパス
203
205
  --apiKey=<value> Backlog API key (環境変数 BACKLOG_API_KEY からも自動読み取り可能)
204
206
  --domain=<value> (required) Backlog domain (e.g. example.backlog.jp)
@@ -213,9 +215,12 @@ EXAMPLES
213
215
 
214
216
  $ backlog-exporter all --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --output ./my-project
215
217
  指定したディレクトリに課題とWikiを保存する
218
+
219
+ $ backlog-exporter all --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --maxCount 1000
220
+ 最大1000件の課題を取得する(デフォルトは100件)
216
221
  ```
217
222
 
218
- _See code: [src/commands/all/index.ts](https://github.com/ShuntaToda/backlog-exporter/blob/v0.1.1/src/commands/all/index.ts)_
223
+ _See code: [src/commands/all/index.ts](https://github.com/ShuntaToda/backlog-exporter/blob/v0.2.1/src/commands/all/index.ts)_
219
224
 
220
225
  ## `backlog-exporter help [COMMAND]`
221
226
 
@@ -243,13 +248,16 @@ Backlogから課題を取得してMarkdownファイルとして保存する
243
248
 
244
249
  ```
245
250
  USAGE
246
- $ backlog-exporter issue --domain <value> --projectIdOrKey <value> [--apiKey <value>] [-o <value>]
251
+ $ backlog-exporter issue --domain <value> --projectIdOrKey <value> [--apiKey <value>] [-m <value>] [-o
252
+ <value>] [--statusId <value>]
247
253
 
248
254
  FLAGS
255
+ -m, --maxCount=<value> [default: 5000] 一度に取得する課題の最大数(デフォルト: 5000)
249
256
  -o, --output=<value> 出力ディレクトリパス
250
257
  --apiKey=<value> Backlog API key (環境変数 BACKLOG_API_KEY からも自動読み取り可能)
251
258
  --domain=<value> (required) Backlog domain (e.g. example.backlog.jp)
252
259
  --projectIdOrKey=<value> (required) Backlog project ID or key
260
+ --statusId=<value> ステータスID(カンマ区切りで複数指定可能)
253
261
 
254
262
  DESCRIPTION
255
263
  Backlogから課題を取得してMarkdownファイルとして保存する
@@ -258,11 +266,17 @@ EXAMPLES
258
266
  $ backlog-exporter issue --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY
259
267
  課題をMarkdownファイルとして保存する
260
268
 
261
- $ backlog-exporter issue --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --output ./my-project
269
+ $ backlog-exporter issue --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --output ./my-issues
262
270
  指定したディレクトリに課題を保存する
271
+
272
+ $ backlog-exporter issue --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --statusId 1,2,3
273
+ 指定したステータスIDの課題のみを取得する
274
+
275
+ $ backlog-exporter issue --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --maxCount 10000
276
+ 最大10000件の課題を取得する(デフォルトは5000件)
263
277
  ```
264
278
 
265
- _See code: [src/commands/issue/index.ts](https://github.com/ShuntaToda/backlog-exporter/blob/v0.1.1/src/commands/issue/index.ts)_
279
+ _See code: [src/commands/issue/index.ts](https://github.com/ShuntaToda/backlog-exporter/blob/v0.2.1/src/commands/issue/index.ts)_
266
280
 
267
281
  ## `backlog-exporter plugins`
268
282
 
@@ -591,7 +605,7 @@ EXAMPLES
591
605
  指定したディレクトリの設定を使用して更新する
592
606
  ```
593
607
 
594
- _See code: [src/commands/update/index.ts](https://github.com/ShuntaToda/backlog-exporter/blob/v0.1.1/src/commands/update/index.ts)_
608
+ _See code: [src/commands/update/index.ts](https://github.com/ShuntaToda/backlog-exporter/blob/v0.2.1/src/commands/update/index.ts)_
595
609
 
596
610
  ## `backlog-exporter wiki`
597
611
 
@@ -618,7 +632,7 @@ EXAMPLES
618
632
  指定したディレクトリにWikiを保存する
619
633
  ```
620
634
 
621
- _See code: [src/commands/wiki/index.ts](https://github.com/ShuntaToda/backlog-exporter/blob/v0.1.1/src/commands/wiki/index.ts)_
635
+ _See code: [src/commands/wiki/index.ts](https://github.com/ShuntaToda/backlog-exporter/blob/v0.2.1/src/commands/wiki/index.ts)_
622
636
  <!-- commandsstop -->
623
637
 
624
638
  # 出力形式
@@ -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
+ maxCount: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
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
  };
@@ -15,6 +15,9 @@ export default class All extends Command {
15
15
  `,
16
16
  `<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --output ./my-project
17
17
  指定したディレクトリに課題とWikiを保存する
18
+ `,
19
+ `<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --maxCount 1000
20
+ 最大1000件の課題を取得する(デフォルトは100件)
18
21
  `,
19
22
  ];
20
23
  static flags = {
@@ -26,6 +29,12 @@ export default class All extends Command {
26
29
  description: 'Backlog domain (e.g. example.backlog.jp)',
27
30
  required: true,
28
31
  }),
32
+ maxCount: Flags.integer({
33
+ char: 'm',
34
+ default: 5000,
35
+ description: '一度に取得する課題の最大数(デフォルト: 5000)',
36
+ required: false,
37
+ }),
29
38
  output: Flags.string({
30
39
  char: 'o',
31
40
  description: '出力ディレクトリパス',
@@ -39,7 +48,7 @@ export default class All extends Command {
39
48
  async run() {
40
49
  const { flags } = await this.parse(All);
41
50
  try {
42
- const { domain, projectIdOrKey } = flags;
51
+ const { domain, maxCount, projectIdOrKey } = flags;
43
52
  const apiKey = flags.apiKey || getApiKey(this);
44
53
  const outputRoot = flags.output || './backlog-data';
45
54
  // 出力ディレクトリの作成
@@ -53,31 +62,48 @@ export default class All extends Command {
53
62
  // Wikiの出力ディレクトリ
54
63
  const wikiOutput = path.join(outputRoot, 'wiki');
55
64
  await createOutputDirectory(wikiOutput);
56
- // 課題の取得と保存
57
- this.log('課題の取得を開始します...');
58
- await downloadIssues(this, domain, projectId, apiKey, issueOutput, 100);
59
65
  // 課題フォルダに設定ファイルを保存
60
66
  await updateSettings(issueOutput, {
61
67
  apiKey,
62
68
  domain,
63
69
  folderType: FolderType.ISSUE,
64
- lastUpdated: new Date().toISOString(),
65
70
  outputDir: issueOutput,
66
71
  projectIdOrKey,
67
72
  });
68
- this.log('課題の取得が完了しました');
69
- // Wikiの取得と保存
70
- this.log('Wikiの取得を開始します...');
71
- await downloadWikis(this, domain, projectIdOrKey, apiKey, wikiOutput);
72
73
  // Wikiフォルダに設定ファイルを保存
73
74
  await updateSettings(wikiOutput, {
74
75
  apiKey,
75
76
  domain,
76
77
  folderType: FolderType.WIKI,
78
+ outputDir: wikiOutput,
79
+ projectIdOrKey,
80
+ });
81
+ // 課題の取得と保存
82
+ this.log('課題の取得を開始します...');
83
+ await downloadIssues(this, {
84
+ apiKey,
85
+ count: maxCount,
86
+ domain,
87
+ outputDir: issueOutput,
88
+ projectId,
89
+ });
90
+ // 課題フォルダの最終更新日時を更新
91
+ await updateSettings(issueOutput, {
77
92
  lastUpdated: new Date().toISOString(),
93
+ });
94
+ this.log('課題の取得が完了しました');
95
+ // Wikiの取得と保存
96
+ this.log('Wikiの取得を開始します...');
97
+ await downloadWikis(this, {
98
+ apiKey,
99
+ domain,
78
100
  outputDir: wikiOutput,
79
101
  projectIdOrKey,
80
102
  });
103
+ // Wikiフォルダの最終更新日時を更新
104
+ await updateSettings(wikiOutput, {
105
+ lastUpdated: new Date().toISOString(),
106
+ });
81
107
  this.log('Wikiの取得が完了しました');
82
108
  this.log('すべてのデータの取得が完了しました!');
83
109
  }
@@ -5,8 +5,10 @@ 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
+ maxCount: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
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>;
11
+ statusId: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
12
  };
11
13
  run(): Promise<void>;
12
14
  }
@@ -12,8 +12,14 @@ export default class Issue extends Command {
12
12
  `<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY
13
13
  課題をMarkdownファイルとして保存する
14
14
  `,
15
- `<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --output ./my-project
15
+ `<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --output ./my-issues
16
16
  指定したディレクトリに課題を保存する
17
+ `,
18
+ `<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --statusId 1,2,3
19
+ 指定したステータスIDの課題のみを取得する
20
+ `,
21
+ `<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --maxCount 10000
22
+ 最大10000件の課題を取得する(デフォルトは5000件)
17
23
  `,
18
24
  ];
19
25
  static flags = {
@@ -25,6 +31,12 @@ export default class Issue extends Command {
25
31
  description: 'Backlog domain (e.g. example.backlog.jp)',
26
32
  required: true,
27
33
  }),
34
+ maxCount: Flags.integer({
35
+ char: 'm',
36
+ default: 5000,
37
+ description: '一度に取得する課題の最大数(デフォルト: 5000)',
38
+ required: false,
39
+ }),
28
40
  output: Flags.string({
29
41
  char: 'o',
30
42
  description: '出力ディレクトリパス',
@@ -34,29 +46,43 @@ export default class Issue extends Command {
34
46
  description: 'Backlog project ID or key',
35
47
  required: true,
36
48
  }),
49
+ statusId: Flags.string({
50
+ description: 'ステータスID(カンマ区切りで複数指定可能)',
51
+ required: false,
52
+ }),
37
53
  };
38
54
  async run() {
39
55
  const { flags } = await this.parse(Issue);
40
56
  try {
41
- const { domain, projectIdOrKey } = flags;
57
+ const { domain, maxCount, projectIdOrKey, statusId } = flags;
42
58
  const apiKey = flags.apiKey || getApiKey(this);
43
- const outputDir = flags.output || './issues';
59
+ const outputDir = flags.output || './backlog-issues';
44
60
  // 出力ディレクトリの作成
45
61
  await createOutputDirectory(outputDir);
46
62
  // プロジェクトキーからプロジェクトIDを取得
47
63
  const projectId = await validateAndGetProjectId(domain, projectIdOrKey, apiKey);
48
64
  this.log(`プロジェクトID: ${projectId} を使用します`);
49
- // 課題の取得と保存
50
- await downloadIssues(this, domain, projectId, apiKey, outputDir, 100);
51
65
  // 設定ファイルを保存
52
66
  await updateSettings(outputDir, {
53
67
  apiKey,
54
68
  domain,
55
69
  folderType: FolderType.ISSUE,
56
- lastUpdated: new Date().toISOString(),
57
70
  outputDir,
58
71
  projectIdOrKey,
59
72
  });
73
+ // 課題の取得と保存
74
+ await downloadIssues(this, {
75
+ apiKey,
76
+ count: maxCount,
77
+ domain,
78
+ outputDir,
79
+ projectId,
80
+ statusId,
81
+ });
82
+ // 最終更新日時を更新
83
+ await updateSettings(outputDir, {
84
+ lastUpdated: new Date().toISOString(),
85
+ });
60
86
  this.log('課題の取得が完了しました!');
61
87
  }
62
88
  catch (error) {
@@ -207,7 +207,16 @@ export default class Update extends Command {
207
207
  // 課題の更新
208
208
  async updateIssues(options) {
209
209
  this.log('課題の更新を開始します...');
210
- await downloadIssues(this, options.domain, options.projectId, options.apiKey, options.targetDir, 100);
210
+ // 設定ファイルから前回の更新日時を取得
211
+ const { lastUpdated } = await loadSettings(options.targetDir);
212
+ await downloadIssues(this, {
213
+ apiKey: options.apiKey,
214
+ count: 100,
215
+ domain: options.domain,
216
+ lastUpdated,
217
+ outputDir: options.targetDir,
218
+ projectId: options.projectId,
219
+ });
211
220
  // 設定ファイルを更新
212
221
  await updateSettings(options.targetDir, {
213
222
  apiKey: options.apiKey,
@@ -222,7 +231,15 @@ export default class Update extends Command {
222
231
  // Wikiの更新
223
232
  async updateWikis(options) {
224
233
  this.log('Wikiの更新を開始します...');
225
- await downloadWikis(this, options.domain, options.projectIdOrKey, options.apiKey, options.targetDir);
234
+ // 設定ファイルから前回の更新日時を取得
235
+ const { lastUpdated } = await loadSettings(options.targetDir);
236
+ await downloadWikis(this, {
237
+ apiKey: options.apiKey,
238
+ domain: options.domain,
239
+ lastUpdated,
240
+ outputDir: options.targetDir,
241
+ projectIdOrKey: options.projectIdOrKey,
242
+ });
226
243
  // 設定ファイルを更新
227
244
  await updateSettings(options.targetDir, {
228
245
  apiKey: options.apiKey,
@@ -42,17 +42,25 @@ Wikiをダウンロードする
42
42
  const outputDir = flags.output || './wiki';
43
43
  // 出力ディレクトリの作成
44
44
  await createOutputDirectory(outputDir);
45
- // Wikiの取得と保存
46
- await downloadWikis(this, domain, projectIdOrKey, apiKey, outputDir);
47
45
  // 設定ファイルを保存
48
46
  await updateSettings(outputDir, {
49
47
  apiKey,
50
48
  domain,
51
49
  folderType: FolderType.WIKI,
52
- lastUpdated: new Date().toISOString(),
53
50
  outputDir,
54
51
  projectIdOrKey,
55
52
  });
53
+ // Wikiの取得と保存
54
+ await downloadWikis(this, {
55
+ apiKey,
56
+ domain,
57
+ outputDir,
58
+ projectIdOrKey,
59
+ });
60
+ // 最終更新日時を更新
61
+ await updateSettings(outputDir, {
62
+ lastUpdated: new Date().toISOString(),
63
+ });
56
64
  this.log('Wikiの取得が完了しました!');
57
65
  }
58
66
  catch (error) {
@@ -2,20 +2,26 @@ import { Command } from '@oclif/core';
2
2
  /**
3
3
  * Backlogから課題をダウンロードする
4
4
  * @param command コマンドインスタンス
5
- * @param domain Backlogドメイン
6
- * @param projectId プロジェクトID
7
- * @param apiKey APIキー
8
- * @param outputDir 出力ディレクトリ
9
- * @param count 一度に取得する課題数
10
- * @param statusId ステータスID(オプション)
5
+ * @param options 課題ダウンロードのオプション
11
6
  */
12
- export declare function downloadIssues(command: Command, domain: string, projectId: number, apiKey: string, outputDir: string, count: number, statusId?: string): Promise<void>;
7
+ export declare function downloadIssues(command: Command, options: {
8
+ apiKey: string;
9
+ count?: number;
10
+ domain: string;
11
+ lastUpdated?: string;
12
+ outputDir: string;
13
+ projectId: number;
14
+ statusId?: string;
15
+ }): Promise<void>;
13
16
  /**
14
17
  * BacklogからWikiをダウンロードする
15
18
  * @param command コマンドインスタンス
16
- * @param domain Backlogドメイン
17
- * @param projectIdOrKey プロジェクトIDまたはキー
18
- * @param apiKey APIキー
19
- * @param outputDir 出力ディレクトリ
19
+ * @param options Wikiダウンロードのオプション
20
20
  */
21
- export declare function downloadWikis(command: Command, domain: string, projectIdOrKey: string, apiKey: string, outputDir: string): Promise<void>;
21
+ export declare function downloadWikis(command: Command, options: {
22
+ apiKey: string;
23
+ domain: string;
24
+ lastUpdated?: string;
25
+ outputDir: string;
26
+ projectIdOrKey: string;
27
+ }): Promise<void>;
@@ -1,56 +1,101 @@
1
1
  import ky from 'ky';
2
2
  import * as fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import process from 'node:process';
4
5
  import { sanitizeFileName, sanitizeWikiFileName } from './common.js';
6
+ import { RateLimiter } from './sleep.js';
5
7
  /**
6
8
  * Backlogから課題をダウンロードする
7
9
  * @param command コマンドインスタンス
8
- * @param domain Backlogドメイン
9
- * @param projectId プロジェクトID
10
- * @param apiKey APIキー
11
- * @param outputDir 出力ディレクトリ
12
- * @param count 一度に取得する課題数
13
- * @param statusId ステータスID(オプション)
10
+ * @param options 課題ダウンロードのオプション
14
11
  */
15
- export async function downloadIssues(command, domain, projectId, apiKey, outputDir, count, statusId) {
16
- const baseUrl = `https://${domain}/api/v2`;
12
+ export async function downloadIssues(command, options) {
13
+ const baseUrl = `https://${options.domain}/api/v2`;
17
14
  command.log('課題の取得を開始します...');
18
- // APIパラメータの構築
19
- const params = new URLSearchParams({
20
- apiKey,
21
- count: count.toString(),
22
- 'projectId[]': projectId.toString(),
23
- });
24
- // ステータスIDが指定されている場合は追加
25
- if (statusId) {
26
- params.append('statusId[]', statusId);
15
+ // 全ての課題を格納する配列
16
+ let allIssues = [];
17
+ // countのデフォルト値を5000に設定
18
+ const count = options.count ?? 5000;
19
+ const maxCount = Math.min(count, 100); // APIの制限は100件
20
+ // APIリクエスト数をカウントするためのRateLimiterを作成
21
+ const rateLimiter = new RateLimiter(command);
22
+ // 課題を取得する関数
23
+ const fetchIssues = async (offset) => {
24
+ // APIリクエスト数をインクリメント
25
+ await rateLimiter.increment();
26
+ // APIパラメータの構築
27
+ const params = new URLSearchParams({
28
+ apiKey: options.apiKey,
29
+ count: maxCount.toString(),
30
+ offset: offset.toString(),
31
+ 'projectId[]': options.projectId.toString(),
32
+ });
33
+ // ステータスIDが指定されている場合は追加
34
+ if (options.statusId) {
35
+ params.append('statusId[]', options.statusId);
36
+ }
37
+ // 進捗状況を一行で更新
38
+ process.stdout.write(`\r課題を取得中... (${allIssues.length}件取得済み)`);
39
+ return ky.get(`${baseUrl}/issues?${params.toString()}`).json();
40
+ };
41
+ // 再帰的に全ての課題を取得
42
+ const fetchAllIssues = async (offset) => {
43
+ try {
44
+ const issues = await fetchIssues(offset);
45
+ // 取得した課題を追加
46
+ allIssues = [...allIssues, ...issues];
47
+ // 次のページがあるかどうかを確認
48
+ if (issues.length === maxCount) {
49
+ // 次のページを取得
50
+ await fetchAllIssues(offset + maxCount);
51
+ }
52
+ }
53
+ catch (error) {
54
+ command.error(`課題の取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
55
+ }
56
+ };
57
+ // 課題取得開始
58
+ await fetchAllIssues(0);
59
+ command.log(`\n合計 ${allIssues.length}件の課題が見つかりました。`);
60
+ // 前回の更新日時より新しい課題のみをフィルタリング
61
+ let filteredIssues = allIssues;
62
+ if (options.lastUpdated) {
63
+ const lastUpdatedDate = new Date(options.lastUpdated);
64
+ filteredIssues = allIssues.filter((issue) => {
65
+ const issueUpdatedDate = new Date(issue.updated);
66
+ return issueUpdatedDate > lastUpdatedDate;
67
+ });
68
+ command.log(`前回の更新日時(${options.lastUpdated})以降に更新された${filteredIssues.length}件の課題を処理します。`);
69
+ }
70
+ if (filteredIssues.length === 0) {
71
+ command.log('更新が必要な課題はありません。');
72
+ return;
27
73
  }
28
- const issues = await ky.get(`${baseUrl}/issues?${params.toString()}`).json();
29
- command.log(`${issues.length}件の課題が見つかりました。`);
30
74
  // 各課題の詳細情報を取得して保存
31
75
  command.log('課題を保存しています...');
32
- // Promise.allを使用して並列処理
33
- const issuePromises = issues.map(async (issue) => {
76
+ // 並列処理ではなく順次処理に変更
77
+ for (const issue of filteredIssues) {
34
78
  try {
35
- // 課題の詳細情報をJSONファイルとして保存
36
- const issueFileName = `${sanitizeFileName(issue.summary)}.md`;
37
- const issueFilePath = path.join(outputDir, issueFileName);
79
+ // 進捗状況を一行で更新
80
+ const currentIndex = filteredIssues.indexOf(issue) + 1;
81
+ process.stdout.write(`\r課題を保存中... (${currentIndex}/${filteredIssues.length}件)`);
38
82
  // BacklogのIssueへのリンクを作成
39
- const backlogIssueUrl = `https://${domain}/view/${issue.issueKey}`;
40
- // コメント一覧を取得
41
- command.log(`課題 ${issue.issueKey} のコメントを取得しています...`);
42
- const commentsParams = new URLSearchParams({
43
- apiKey,
44
- count: '100', // 最大100件のコメントを取得
45
- order: 'asc', // 古い順に取得
83
+ const backlogIssueUrl = `https://${options.domain}/view/${issue.issueKey}`;
84
+ // コメントを取得する関数を呼び出し
85
+ // eslint-disable-next-line no-await-in-loop
86
+ const { comments: allComments } = await fetchAllCommentsForIssue({
87
+ apiKey: options.apiKey,
88
+ baseUrl,
89
+ command,
90
+ issueKey: issue.issueKey,
91
+ rateLimiter,
46
92
  });
47
- const comments = await ky.get(`${baseUrl}/issues/${issue.issueKey}/comments?${commentsParams.toString()}`).json();
48
93
  // コメントセクションを作成
49
94
  let commentsSection = '';
50
- if (comments.length > 0) {
95
+ if (allComments.length > 0) {
51
96
  commentsSection = '\n\n## コメント\n';
52
97
  let commentIndex = 1;
53
- for (const comment of comments) {
98
+ for (const comment of allComments) {
54
99
  const commentDate = new Date(comment.created).toLocaleString('ja-JP');
55
100
  commentsSection += `\n### コメント ${commentIndex}\n- **投稿者**: ${comment.createdUser.name}\n- **日時**: ${commentDate}\n\n${comment.content || '(内容なし)'}\n\n---\n`;
56
101
  commentIndex++;
@@ -58,6 +103,16 @@ export async function downloadIssues(command, domain, projectId, apiKey, outputD
58
103
  // 最後の区切り線を削除
59
104
  commentsSection = commentsSection.slice(0, -5);
60
105
  }
106
+ // 課題の作成年を取得
107
+ const createdYear = new Date(issue.created).getFullYear();
108
+ // 年ごとのフォルダパスを作成
109
+ const yearDirPath = path.join(options.outputDir, createdYear.toString());
110
+ // 年ごとのフォルダを作成
111
+ // eslint-disable-next-line no-await-in-loop
112
+ await fs.mkdir(yearDirPath, { recursive: true });
113
+ // 年ごとのフォルダ内にMarkdownファイルを保存
114
+ const issueFileName = `${sanitizeFileName(issue.summary)}.md`;
115
+ const issueFilePath = path.join(yearDirPath, issueFileName);
61
116
  // Markdownファイルに書き込む
62
117
  const assigneeName = issue.assignee ? issue.assignee.name : '未割り当て';
63
118
  const markdownContent = `# ${issue.summary}
@@ -73,42 +128,91 @@ export async function downloadIssues(command, domain, projectId, apiKey, outputD
73
128
 
74
129
  ## 詳細
75
130
  ${issue.description || '詳細情報なし'}${commentsSection}`;
131
+ // eslint-disable-next-line no-await-in-loop
76
132
  await fs.writeFile(issueFilePath, markdownContent);
77
- command.log(`課題 "${issue.issueKey}: ${issue.summary}" を ${issueFilePath} に保存しました`);
78
133
  }
79
134
  catch (error) {
80
135
  command.warn(`課題 ${issue.issueKey} の保存に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
81
136
  }
82
- });
83
- await Promise.all(issuePromises);
84
- command.log('課題のダウンロードが完了しました!');
137
+ }
138
+ command.log('\n課題のダウンロードが完了しました!');
139
+ }
140
+ /**
141
+ * 課題のコメントを全て取得する
142
+ */
143
+ async function fetchAllCommentsForIssue({ apiKey, baseUrl, command, issueKey, rateLimiter, }) {
144
+ const allComments = [];
145
+ const fetchComments = async (minId) => {
146
+ // APIリクエスト数をインクリメント
147
+ await rateLimiter.increment();
148
+ let url = `${baseUrl}/issues/${issueKey}/comments?apiKey=${apiKey}&count=100`;
149
+ if (minId) {
150
+ url += `&minId=${minId}`;
151
+ }
152
+ const comments = await ky.get(url).json();
153
+ allComments.push(...comments);
154
+ if (comments.length === 100) {
155
+ // 取得したコメントの最後のIDを次のリクエストのminIdとして使用
156
+ const lastCommentId = comments.at(-1).id;
157
+ await fetchComments(lastCommentId + 1);
158
+ }
159
+ };
160
+ try {
161
+ await fetchComments();
162
+ return { comments: allComments };
163
+ }
164
+ catch (error) {
165
+ command.warn(`課題 ${issueKey} のコメント取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
166
+ return { comments: allComments };
167
+ }
85
168
  }
86
169
  /**
87
170
  * BacklogからWikiをダウンロードする
88
171
  * @param command コマンドインスタンス
89
- * @param domain Backlogドメイン
90
- * @param projectIdOrKey プロジェクトIDまたはキー
91
- * @param apiKey APIキー
92
- * @param outputDir 出力ディレクトリ
172
+ * @param options Wikiダウンロードのオプション
93
173
  */
94
- export async function downloadWikis(command, domain, projectIdOrKey, apiKey, outputDir) {
95
- const baseUrl = `https://${domain}/api/v2`;
174
+ export async function downloadWikis(command, options) {
175
+ const baseUrl = `https://${options.domain}/api/v2`;
96
176
  command.log('Wikiの取得を開始します...');
177
+ // APIリクエスト数をカウントするためのRateLimiterを作成
178
+ const rateLimiter = new RateLimiter(command);
97
179
  // Wiki一覧の取得
98
180
  command.log('Wiki一覧を取得しています...');
181
+ // APIリクエスト数をインクリメント
182
+ await rateLimiter.increment();
99
183
  const wikis = await ky
100
- .get(`${baseUrl}/wikis?apiKey=${apiKey}&projectIdOrKey=${projectIdOrKey}`)
184
+ .get(`${baseUrl}/wikis?apiKey=${options.apiKey}&projectIdOrKey=${options.projectIdOrKey}`)
101
185
  .json();
102
186
  command.log(`${wikis.length}件のWikiが見つかりました。`);
187
+ // 前回の更新日時より新しいWikiのみをフィルタリング
188
+ let filteredWikis = wikis;
189
+ if (options.lastUpdated) {
190
+ const lastUpdatedDate = new Date(options.lastUpdated);
191
+ filteredWikis = wikis.filter((wiki) => {
192
+ const wikiUpdatedDate = new Date(wiki.updated);
193
+ return wikiUpdatedDate > lastUpdatedDate;
194
+ });
195
+ command.log(`前回の更新日時(${options.lastUpdated})以降に更新された${filteredWikis.length}件のWikiを処理します。`);
196
+ }
197
+ if (filteredWikis.length === 0) {
198
+ command.log('更新が必要なWikiはありません。');
199
+ return;
200
+ }
103
201
  // 各Wikiの詳細情報を取得
104
202
  command.log('Wiki詳細を取得しています...');
105
- // Promise.allを使用して並列処理
106
- const wikiPromises = wikis.map(async (wiki) => {
203
+ // 並列処理ではなく順次処理に変更
204
+ for (const wiki of filteredWikis) {
107
205
  const wikiId = wiki.id;
108
- command.log(`Wiki: ${wiki.name} (ID: ${wikiId}) を取得しています`);
109
206
  try {
207
+ // APIリクエスト数をインクリメント
208
+ // eslint-disable-next-line no-await-in-loop
209
+ await rateLimiter.increment();
210
+ // 進捗状況を一行で更新
211
+ const currentIndex = filteredWikis.indexOf(wiki) + 1;
212
+ process.stdout.write(`\rWikiを取得中... (${currentIndex}/${filteredWikis.length}件)`);
213
+ // eslint-disable-next-line no-await-in-loop
110
214
  const wikiDetail = await ky
111
- .get(`${baseUrl}/wikis/${wikiId}?projectIdOrKey=${projectIdOrKey}&apiKey=${apiKey}`)
215
+ .get(`${baseUrl}/wikis/${wikiId}?projectIdOrKey=${options.projectIdOrKey}&apiKey=${options.apiKey}`)
112
216
  .json();
113
217
  // Wikiの名前をファイルパスとして使用
114
218
  const wikiName = wiki.name;
@@ -119,23 +223,26 @@ export async function downloadWikis(command, domain, projectIdOrKey, apiKey, out
119
223
  // ディレクトリ構造を作成(必要な場合)
120
224
  const dirPath = path.dirname(wikiFileName);
121
225
  if (dirPath !== '.') {
122
- await fs.mkdir(path.join(outputDir, dirPath), { recursive: true });
226
+ // eslint-disable-next-line no-await-in-loop
227
+ await fs.mkdir(path.join(options.outputDir, dirPath), { recursive: true });
123
228
  }
124
229
  // Markdownファイルを保存
125
- const wikiFilePath = path.join(outputDir, wikiFileName);
230
+ const wikiFilePath = path.join(options.outputDir, wikiFileName);
126
231
  // JSONからcontentフィールドを取得
127
232
  const content = wikiDetail.content || '';
128
233
  // BacklogのWikiへのリンクを作成
129
- const backlogWikiUrl = `https://${domain}/alias/wiki/${wikiId}`;
234
+ const backlogWikiUrl = `https://${options.domain}/alias/wiki/${wikiId}`;
130
235
  // Markdownファイルに書き込む(タイトルとBacklogリンクを追加)
131
236
  const markdownContent = `# ${wiki.name}\n\n[Backlog Wiki Link](${backlogWikiUrl})\n\n${content}`;
237
+ // eslint-disable-next-line no-await-in-loop
132
238
  await fs.writeFile(wikiFilePath, markdownContent);
133
- command.log(`Wiki "${wiki.name}" を ${wikiFilePath} に保存しました`);
239
+ // 進捗状況を一行で更新
240
+ const wikiIndex = filteredWikis.indexOf(wiki) + 1;
241
+ process.stdout.write(`\rWikiを保存中... (${wikiIndex}/${filteredWikis.length}件)`);
134
242
  }
135
243
  catch (error) {
136
- command.warn(`Wiki ID ${wikiId} の取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
244
+ command.warn(`Wiki ${wiki.name} の取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
137
245
  }
138
- });
139
- await Promise.all(wikiPromises);
140
- command.log('Wikiのダウンロードが完了しました!');
246
+ }
247
+ command.log('\nWikiのダウンロードが完了しました!');
141
248
  }
@@ -0,0 +1,28 @@
1
+ import { Command } from '@oclif/core';
2
+ /**
3
+ * 指定したミリ秒だけ待機する
4
+ * @param ms 待機時間(ミリ秒)
5
+ */
6
+ export declare const sleep: (ms: number) => Promise<void>;
7
+ /**
8
+ * APIリクエストのレート制限対策のためのユーティリティ
9
+ * 100リクエストごとに15秒間待機する
10
+ */
11
+ export declare class RateLimiter {
12
+ private readonly command;
13
+ private requestCount;
14
+ constructor(command: Command);
15
+ /**
16
+ * 現在のリクエスト数を取得する
17
+ */
18
+ getCount(): number;
19
+ /**
20
+ * APIリクエストをカウントし、必要に応じて待機する
21
+ * @returns 現在のリクエスト数
22
+ */
23
+ increment(): Promise<number>;
24
+ /**
25
+ * リクエスト数を設定する
26
+ */
27
+ setCount(count: number): void;
28
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * 指定したミリ秒だけ待機する
3
+ * @param ms 待機時間(ミリ秒)
4
+ */
5
+ export const sleep = (ms) => new Promise((resolve) => {
6
+ setTimeout(resolve, ms);
7
+ });
8
+ /**
9
+ * APIリクエストのレート制限対策のためのユーティリティ
10
+ * 100リクエストごとに15秒間待機する
11
+ */
12
+ export class RateLimiter {
13
+ command;
14
+ requestCount = 0;
15
+ constructor(command) {
16
+ this.command = command;
17
+ }
18
+ /**
19
+ * 現在のリクエスト数を取得する
20
+ */
21
+ getCount() {
22
+ return this.requestCount;
23
+ }
24
+ /**
25
+ * APIリクエストをカウントし、必要に応じて待機する
26
+ * @returns 現在のリクエスト数
27
+ */
28
+ async increment() {
29
+ this.requestCount++;
30
+ if (this.requestCount > 1 && this.requestCount % 100 === 0) {
31
+ this.command.log('レート制限を回避するため15秒間待機します...');
32
+ await sleep(15_000);
33
+ }
34
+ return this.requestCount;
35
+ }
36
+ /**
37
+ * リクエスト数を設定する
38
+ */
39
+ setCount(count) {
40
+ this.requestCount = count;
41
+ }
42
+ }
@@ -6,7 +6,8 @@
6
6
  "description": "Backlogから課題とWikiを取得してMarkdownファイルとして保存する",
7
7
  "examples": [
8
8
  "<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY\n課題とWikiをMarkdownファイルとして保存する\n",
9
- "<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --output ./my-project\n指定したディレクトリに課題とWikiを保存する\n"
9
+ "<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --output ./my-project\n指定したディレクトリに課題とWikiを保存する\n",
10
+ "<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --maxCount 1000\n最大1000件の課題を取得する(デフォルトは100件)\n"
10
11
  ],
11
12
  "flags": {
12
13
  "apiKey": {
@@ -25,6 +26,16 @@
25
26
  "multiple": false,
26
27
  "type": "option"
27
28
  },
29
+ "maxCount": {
30
+ "char": "m",
31
+ "description": "一度に取得する課題の最大数(デフォルト: 5000)",
32
+ "name": "maxCount",
33
+ "required": false,
34
+ "default": 5000,
35
+ "hasDynamicHelp": false,
36
+ "multiple": false,
37
+ "type": "option"
38
+ },
28
39
  "output": {
29
40
  "char": "o",
30
41
  "description": "出力ディレクトリパス",
@@ -59,21 +70,15 @@
59
70
  "index.js"
60
71
  ]
61
72
  },
62
- "update": {
73
+ "issue": {
63
74
  "aliases": [],
64
- "args": {
65
- "directory": {
66
- "description": "更新対象のディレクトリ(設定ファイルが保存されている場所)",
67
- "name": "directory",
68
- "required": false
69
- }
70
- },
71
- "description": "Backlogから最新データを取得して更新する",
75
+ "args": {},
76
+ "description": "Backlogから課題を取得してMarkdownファイルとして保存する",
72
77
  "examples": [
73
- "<%= config.bin %> <%= command.id %>\nカレントディレクトリの設定を使用して更新する\n",
74
- "<%= config.bin %> <%= command.id %> --force\n確認プロンプトをスキップする\n",
75
- "<%= config.bin %> <%= command.id %> --apiKey YOUR_API_KEY --domain example.backlog.jp --projectIdOrKey PROJECT_KEY\n指定したパラメータで更新する(設定ファイルが存在する場合は上書きされます)\n",
76
- "<%= config.bin %> <%= command.id %> ./my-project\n指定したディレクトリの設定を使用して更新する\n"
78
+ "<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY\n課題をMarkdownファイルとして保存する\n",
79
+ "<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --output ./my-issues\n指定したディレクトリに課題を保存する\n",
80
+ "<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --statusId 1,2,3\n指定したステータスIDの課題のみを取得する\n",
81
+ "<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --maxCount 10000\n最大10000件の課題を取得する(デフォルトは5000件)\n"
77
82
  ],
78
83
  "flags": {
79
84
  "apiKey": {
@@ -87,45 +92,50 @@
87
92
  "domain": {
88
93
  "description": "Backlog domain (e.g. example.backlog.jp)",
89
94
  "name": "domain",
90
- "required": false,
95
+ "required": true,
91
96
  "hasDynamicHelp": false,
92
97
  "multiple": false,
93
98
  "type": "option"
94
99
  },
95
- "force": {
96
- "char": "f",
97
- "description": "確認プロンプトをスキップする",
98
- "name": "force",
100
+ "maxCount": {
101
+ "char": "m",
102
+ "description": "一度に取得する課題の最大数(デフォルト: 5000)",
103
+ "name": "maxCount",
99
104
  "required": false,
100
- "allowNo": false,
101
- "type": "boolean"
105
+ "default": 5000,
106
+ "hasDynamicHelp": false,
107
+ "multiple": false,
108
+ "type": "option"
102
109
  },
103
- "issuesOnly": {
104
- "description": "課題のみを更新する",
105
- "name": "issuesOnly",
110
+ "output": {
111
+ "char": "o",
112
+ "description": "出力ディレクトリパス",
113
+ "name": "output",
106
114
  "required": false,
107
- "allowNo": false,
108
- "type": "boolean"
115
+ "hasDynamicHelp": false,
116
+ "multiple": false,
117
+ "type": "option"
109
118
  },
110
119
  "projectIdOrKey": {
111
120
  "description": "Backlog project ID or key",
112
121
  "name": "projectIdOrKey",
113
- "required": false,
122
+ "required": true,
114
123
  "hasDynamicHelp": false,
115
124
  "multiple": false,
116
125
  "type": "option"
117
126
  },
118
- "wikisOnly": {
119
- "description": "Wikiのみを更新する",
120
- "name": "wikisOnly",
127
+ "statusId": {
128
+ "description": "ステータスID(カンマ区切りで複数指定可能)",
129
+ "name": "statusId",
121
130
  "required": false,
122
- "allowNo": false,
123
- "type": "boolean"
131
+ "hasDynamicHelp": false,
132
+ "multiple": false,
133
+ "type": "option"
124
134
  }
125
135
  },
126
136
  "hasDynamicHelp": false,
127
137
  "hiddenAliases": [],
128
- "id": "update",
138
+ "id": "issue",
129
139
  "pluginAlias": "backlog-exporter",
130
140
  "pluginName": "backlog-exporter",
131
141
  "pluginType": "core",
@@ -135,17 +145,25 @@
135
145
  "relativePath": [
136
146
  "dist",
137
147
  "commands",
138
- "update",
148
+ "issue",
139
149
  "index.js"
140
150
  ]
141
151
  },
142
- "issue": {
152
+ "update": {
143
153
  "aliases": [],
144
- "args": {},
145
- "description": "Backlogから課題を取得してMarkdownファイルとして保存する",
154
+ "args": {
155
+ "directory": {
156
+ "description": "更新対象のディレクトリ(設定ファイルが保存されている場所)",
157
+ "name": "directory",
158
+ "required": false
159
+ }
160
+ },
161
+ "description": "Backlogから最新データを取得して更新する",
146
162
  "examples": [
147
- "<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY\n課題をMarkdownファイルとして保存する\n",
148
- "<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --output ./my-project\n指定したディレクトリに課題を保存する\n"
163
+ "<%= config.bin %> <%= command.id %>\nカレントディレクトリの設定を使用して更新する\n",
164
+ "<%= config.bin %> <%= command.id %> --force\n確認プロンプトをスキップする\n",
165
+ "<%= config.bin %> <%= command.id %> --apiKey YOUR_API_KEY --domain example.backlog.jp --projectIdOrKey PROJECT_KEY\n指定したパラメータで更新する(設定ファイルが存在する場合は上書きされます)\n",
166
+ "<%= config.bin %> <%= command.id %> ./my-project\n指定したディレクトリの設定を使用して更新する\n"
149
167
  ],
150
168
  "flags": {
151
169
  "apiKey": {
@@ -159,32 +177,45 @@
159
177
  "domain": {
160
178
  "description": "Backlog domain (e.g. example.backlog.jp)",
161
179
  "name": "domain",
162
- "required": true,
180
+ "required": false,
163
181
  "hasDynamicHelp": false,
164
182
  "multiple": false,
165
183
  "type": "option"
166
184
  },
167
- "output": {
168
- "char": "o",
169
- "description": "出力ディレクトリパス",
170
- "name": "output",
185
+ "force": {
186
+ "char": "f",
187
+ "description": "確認プロンプトをスキップする",
188
+ "name": "force",
171
189
  "required": false,
172
- "hasDynamicHelp": false,
173
- "multiple": false,
174
- "type": "option"
190
+ "allowNo": false,
191
+ "type": "boolean"
192
+ },
193
+ "issuesOnly": {
194
+ "description": "課題のみを更新する",
195
+ "name": "issuesOnly",
196
+ "required": false,
197
+ "allowNo": false,
198
+ "type": "boolean"
175
199
  },
176
200
  "projectIdOrKey": {
177
201
  "description": "Backlog project ID or key",
178
202
  "name": "projectIdOrKey",
179
- "required": true,
203
+ "required": false,
180
204
  "hasDynamicHelp": false,
181
205
  "multiple": false,
182
206
  "type": "option"
207
+ },
208
+ "wikisOnly": {
209
+ "description": "Wikiのみを更新する",
210
+ "name": "wikisOnly",
211
+ "required": false,
212
+ "allowNo": false,
213
+ "type": "boolean"
183
214
  }
184
215
  },
185
216
  "hasDynamicHelp": false,
186
217
  "hiddenAliases": [],
187
- "id": "issue",
218
+ "id": "update",
188
219
  "pluginAlias": "backlog-exporter",
189
220
  "pluginName": "backlog-exporter",
190
221
  "pluginType": "core",
@@ -194,7 +225,7 @@
194
225
  "relativePath": [
195
226
  "dist",
196
227
  "commands",
197
- "issue",
228
+ "update",
198
229
  "index.js"
199
230
  ]
200
231
  },
@@ -258,5 +289,5 @@
258
289
  ]
259
290
  }
260
291
  },
261
- "version": "0.1.1"
292
+ "version": "0.2.1"
262
293
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "backlog-exporter",
3
3
  "description": "A new CLI generated with oclif",
4
- "version": "0.1.1",
4
+ "version": "0.2.1",
5
5
  "author": "ShuntaToda",
6
6
  "bin": {
7
7
  "backlog-exporter": "bin/run.js"