backlog-exporter 0.7.0 → 0.7.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.
@@ -0,0 +1,448 @@
1
+ import ky from 'ky';
2
+ import * as fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import process from 'node:process';
5
+ import { sanitizeFileName, sanitizeWikiFileName } from './common.js';
6
+ import { appendLog } from './log.js';
7
+ import { RateLimiter } from './sleep.js';
8
+ /**
9
+ * カスタム属性セクションを作成する
10
+ * @param customFields カスタム属性の配列
11
+ * @returns Markdownテーブル形式のカスタム属性セクション
12
+ */
13
+ export function createCustomFieldsSection(customFields) {
14
+ if (!customFields || customFields.length === 0) {
15
+ return '';
16
+ }
17
+ let customFieldsSection = '\n\n## カスタム属性\n\n| 属性名 | 値 |\n|--------|----|\n';
18
+ for (const customField of customFields) {
19
+ let fieldValue = 'なし';
20
+ if (customField.value !== null && customField.value !== undefined) {
21
+ if (Array.isArray(customField.value)) {
22
+ // 配列の場合(複数選択など)
23
+ fieldValue = customField.value
24
+ .map((item) => item.name || item.value || String(item))
25
+ .join(', ');
26
+ }
27
+ else if (typeof customField.value === 'object' && customField.value !== null) {
28
+ // オブジェクトの場合(単一選択など)
29
+ const valueObj = customField.value;
30
+ fieldValue = valueObj.name || valueObj.value || String(customField.value);
31
+ }
32
+ else {
33
+ // プリミティブ値の場合
34
+ fieldValue = String(customField.value);
35
+ }
36
+ }
37
+ // テーブル内では改行をHTMLの<br>タグに変換し、パイプ文字をエスケープ
38
+ const escapedFieldValue = fieldValue.replaceAll('|', String.raw `\|`).replaceAll('\n', '<br>');
39
+ customFieldsSection += `| ${customField.name} | ${escapedFieldValue} |\n`;
40
+ }
41
+ return customFieldsSection;
42
+ }
43
+ /**
44
+ * Backlogから課題をダウンロードする
45
+ * @param command コマンドインスタンス
46
+ * @param options 課題ダウンロードのオプション
47
+ * @param options.apiKey Backlog API key
48
+ * @param options.count 取得する課題の最大数
49
+ * @param options.domain Backlogのドメイン
50
+ * @param options.lastUpdated 最終更新日時
51
+ * @param options.outputDir 出力ディレクトリ
52
+ * @param options.projectId プロジェクトID
53
+ * @param options.statusId ステータスID
54
+ * @param options.issueKeyFileName ファイル名を課題キーにするかどうか
55
+ * @param options.issueKeyFolder 課題キーでフォルダを作成するかどうか
56
+ */
57
+ export async function downloadIssues(command, options) {
58
+ const baseUrl = `https://${options.domain}/api/v2`;
59
+ command.log('課題の取得を開始します...');
60
+ // 全ての課題を格納する配列
61
+ let allIssues = [];
62
+ // countのデフォルト値を5000に設定
63
+ const count = options.count ?? 5000;
64
+ const maxCount = Math.min(count, 100); // APIの制限は100件
65
+ // APIリクエスト数をカウントするためのRateLimiterを作成
66
+ const rateLimiter = new RateLimiter(command);
67
+ // 課題を取得する関数
68
+ const fetchIssues = async (offset) => {
69
+ // APIリクエスト数をインクリメント
70
+ await rateLimiter.increment();
71
+ // APIパラメータの構築
72
+ const params = new URLSearchParams({
73
+ apiKey: options.apiKey,
74
+ count: maxCount.toString(),
75
+ offset: offset.toString(),
76
+ 'projectId[]': options.projectId.toString(),
77
+ });
78
+ // ステータスIDが指定されている場合は追加
79
+ if (options.statusId) {
80
+ params.append('statusId[]', options.statusId);
81
+ }
82
+ // 進捗状況を一行で更新
83
+ process.stdout.write(`\r課題を取得中... (${allIssues.length}件取得済み)`);
84
+ return ky.get(`${baseUrl}/issues?${params.toString()}`).json();
85
+ };
86
+ // 再帰的に全ての課題を取得
87
+ const fetchAllIssues = async (offset) => {
88
+ try {
89
+ const issues = await fetchIssues(offset);
90
+ // 取得した課題を追加
91
+ allIssues = [...allIssues, ...issues];
92
+ // 次のページがあるかどうかを確認
93
+ if (issues.length === maxCount) {
94
+ // 次のページを取得
95
+ await fetchAllIssues(offset + maxCount);
96
+ }
97
+ }
98
+ catch (error) {
99
+ command.error(`課題の取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
100
+ }
101
+ };
102
+ // 課題取得開始
103
+ await fetchAllIssues(0);
104
+ command.log(`\n合計 ${allIssues.length}件の課題が見つかりました。`);
105
+ // 前回の更新日時より新しい課題のみをフィルタリング
106
+ let filteredIssues = allIssues;
107
+ if (options.lastUpdated) {
108
+ const lastUpdatedDate = new Date(options.lastUpdated);
109
+ filteredIssues = allIssues.filter((issue) => {
110
+ const issueUpdatedDate = new Date(issue.updated);
111
+ return issueUpdatedDate > lastUpdatedDate;
112
+ });
113
+ command.log(`前回の更新日時(${options.lastUpdated})以降に更新された${filteredIssues.length}件の課題を処理します。`);
114
+ }
115
+ if (filteredIssues.length === 0) {
116
+ command.log('更新が必要な課題はありません。');
117
+ return;
118
+ }
119
+ // 各課題の詳細情報を取得して保存
120
+ command.log('課題を保存しています...');
121
+ // 並列処理ではなく順次処理に変更
122
+ for (const issue of filteredIssues) {
123
+ try {
124
+ // 進捗状況を一行で更新
125
+ const currentIndex = filteredIssues.indexOf(issue) + 1;
126
+ process.stdout.write(`\r課題を保存中... (${currentIndex}/${filteredIssues.length}件)`);
127
+ // BacklogのIssueへのリンクを作成
128
+ const backlogIssueUrl = `https://${options.domain}/view/${issue.issueKey}`;
129
+ // コメントを取得する関数を呼び出し
130
+ // eslint-disable-next-line no-await-in-loop
131
+ const { comments: allComments } = await fetchAllCommentsForIssue({
132
+ apiKey: options.apiKey,
133
+ baseUrl,
134
+ command,
135
+ issueKey: issue.issueKey,
136
+ rateLimiter,
137
+ });
138
+ // コメントセクションを作成
139
+ let commentsSection = '';
140
+ if (allComments.length > 0) {
141
+ commentsSection = '\n\n## コメント\n';
142
+ let commentIndex = 1;
143
+ for (const comment of allComments) {
144
+ const commentDate = new Date(comment.created).toLocaleString('ja-JP');
145
+ commentsSection += `\n### コメント ${commentIndex}\n- **投稿者**: ${comment.createdUser.name}\n- **日時**: ${commentDate}\n\n${comment.content || '(内容なし)'}\n\n---\n`;
146
+ commentIndex++;
147
+ }
148
+ // 最後の区切り線を削除
149
+ commentsSection = commentsSection.slice(0, -5);
150
+ }
151
+ // 課題の作成年を取得
152
+ const createdYear = new Date(issue.created).getFullYear();
153
+ // 年ごとのフォルダパスを作成
154
+ const yearDirPath = path.join(options.outputDir, createdYear.toString());
155
+ // 年ごとのフォルダを作成
156
+ // eslint-disable-next-line no-await-in-loop
157
+ await fs.mkdir(yearDirPath, { recursive: true });
158
+ let issueFilePath;
159
+ let issueFileName;
160
+ if (options.issueKeyFolder) {
161
+ // 年ごとのフォルダ内に、課題キーでフォルダを作成
162
+ const issueKeyDirPath = path.join(yearDirPath, issue.issueKey);
163
+ // eslint-disable-next-line no-await-in-loop
164
+ await fs.mkdir(issueKeyDirPath, { recursive: true });
165
+ // ファイル名を課題名(標準)にするか課題キーにするかを決定
166
+ issueFileName = options.issueKeyFileName ? `${issue.issueKey}.md` : `${sanitizeFileName(issue.summary)}.md`;
167
+ issueFilePath = path.join(issueKeyDirPath, issueFileName);
168
+ }
169
+ else {
170
+ // 年ごとのフォルダ内に、Markdownファイルを作成
171
+ issueFileName = options.issueKeyFileName ? `${issue.issueKey}.md` : `${sanitizeFileName(issue.summary)}.md`;
172
+ issueFilePath = path.join(yearDirPath, issueFileName);
173
+ }
174
+ // カスタム属性セクションを作成
175
+ const customFieldsSection = createCustomFieldsSection(issue.customFields);
176
+ // Markdownファイルに書き込む
177
+ const assigneeName = issue.assignee ? issue.assignee.name : '未割り当て';
178
+ const markdownContent = `# ${issue.summary}
179
+
180
+ ## 基本情報
181
+ - 課題キー: ${issue.issueKey}
182
+ - ステータス: ${issue.status.name}
183
+ - 優先度: ${issue.priority.name}
184
+ - 担当者: ${assigneeName}
185
+ - 作成日時: ${new Date(issue.created).toLocaleString('ja-JP')}
186
+ - 更新日時: ${new Date(issue.updated).toLocaleString('ja-JP')}
187
+ - [Backlog Issue Link](${backlogIssueUrl})${customFieldsSection}
188
+
189
+ ## 詳細
190
+ ${issue.description || '詳細情報なし'}${commentsSection}`;
191
+ // eslint-disable-next-line no-await-in-loop
192
+ await fs.writeFile(issueFilePath, markdownContent);
193
+ // ログに記録
194
+ // eslint-disable-next-line no-await-in-loop
195
+ await appendLog(options.outputDir, `課題「${issue.summary}」を更新しました: ${backlogIssueUrl}`);
196
+ }
197
+ catch (error) {
198
+ command.warn(`課題 ${issue.issueKey} の保存に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
199
+ }
200
+ }
201
+ command.log('\n課題のダウンロードが完了しました!');
202
+ }
203
+ /**
204
+ * 課題のコメントを全て取得する
205
+ */
206
+ async function fetchAllCommentsForIssue({ apiKey, baseUrl, command, issueKey, rateLimiter, }) {
207
+ const allComments = [];
208
+ const fetchComments = async (minId) => {
209
+ // APIリクエスト数をインクリメント
210
+ await rateLimiter.increment();
211
+ let url = `${baseUrl}/issues/${issueKey}/comments?apiKey=${apiKey}&count=100`;
212
+ if (minId) {
213
+ url += `&minId=${minId}`;
214
+ }
215
+ const comments = await ky.get(url).json();
216
+ allComments.push(...comments);
217
+ if (comments.length === 100) {
218
+ // 取得したコメントの最後のIDを次のリクエストのminIdとして使用
219
+ const lastCommentId = comments.at(-1).id;
220
+ await fetchComments(lastCommentId + 1);
221
+ }
222
+ };
223
+ try {
224
+ await fetchComments();
225
+ // コメントを古い順(昇順)に並び替える
226
+ allComments.sort((a, b) => new Date(a.created).getTime() - new Date(b.created).getTime());
227
+ return { comments: allComments };
228
+ }
229
+ catch (error) {
230
+ command.warn(`課題 ${issueKey} のコメント取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
231
+ return { comments: allComments };
232
+ }
233
+ }
234
+ /**
235
+ * BacklogからWikiをダウンロードする
236
+ * @param command コマンドインスタンス
237
+ * @param options Wikiダウンロードのオプション
238
+ * @param options.apiKey Backlog API key
239
+ * @param options.domain Backlogのドメイン
240
+ * @param options.lastUpdated 最終更新日時
241
+ * @param options.outputDir 出力ディレクトリ
242
+ * @param options.projectIdOrKey プロジェクトIDまたはキー
243
+ */
244
+ export async function downloadWikis(command, options) {
245
+ const baseUrl = `https://${options.domain}/api/v2`;
246
+ command.log('Wikiの取得を開始します...');
247
+ // APIリクエスト数をカウントするためのRateLimiterを作成
248
+ const rateLimiter = new RateLimiter(command);
249
+ // Wiki一覧の取得
250
+ command.log('Wiki一覧を取得しています...');
251
+ // APIリクエスト数をインクリメント
252
+ await rateLimiter.increment();
253
+ const wikis = await ky
254
+ .get(`${baseUrl}/wikis?apiKey=${options.apiKey}&projectIdOrKey=${options.projectIdOrKey}`)
255
+ .json();
256
+ command.log(`${wikis.length}件のWikiが見つかりました。`);
257
+ // 前回の更新日時より新しいWikiのみをフィルタリング
258
+ let filteredWikis = wikis;
259
+ if (options.lastUpdated) {
260
+ const lastUpdatedDate = new Date(options.lastUpdated);
261
+ filteredWikis = wikis.filter((wiki) => {
262
+ const wikiUpdatedDate = new Date(wiki.updated);
263
+ return wikiUpdatedDate > lastUpdatedDate;
264
+ });
265
+ command.log(`前回の更新日時(${options.lastUpdated})以降に更新された${filteredWikis.length}件のWikiを処理します。`);
266
+ }
267
+ if (filteredWikis.length === 0) {
268
+ command.log('更新が必要なWikiはありません。');
269
+ return;
270
+ }
271
+ // 各Wikiの詳細情報を取得
272
+ command.log('Wiki詳細を取得しています...');
273
+ // 並列処理ではなく順次処理に変更
274
+ for (const wiki of filteredWikis) {
275
+ const wikiId = wiki.id;
276
+ try {
277
+ // APIリクエスト数をインクリメント
278
+ // eslint-disable-next-line no-await-in-loop
279
+ await rateLimiter.increment();
280
+ // 進捗状況を一行で更新
281
+ const currentIndex = filteredWikis.indexOf(wiki) + 1;
282
+ process.stdout.write(`\rWikiを取得中... (${currentIndex}/${filteredWikis.length}件)`);
283
+ // eslint-disable-next-line no-await-in-loop
284
+ const wikiDetail = await ky
285
+ .get(`${baseUrl}/wikis/${wikiId}?projectIdOrKey=${options.projectIdOrKey}&apiKey=${options.apiKey}`)
286
+ .json();
287
+ // Wikiの名前をファイルパスとして使用
288
+ const wikiName = wiki.name;
289
+ // ファイル名のサニタイズ
290
+ const sanitizedName = sanitizeWikiFileName(wikiName);
291
+ // ファイル名の拡張子を追加
292
+ const wikiFileName = `${sanitizedName}.md`;
293
+ // ディレクトリ構造を作成(必要な場合)
294
+ const dirPath = path.dirname(wikiFileName);
295
+ if (dirPath !== '.') {
296
+ // eslint-disable-next-line no-await-in-loop
297
+ await fs.mkdir(path.join(options.outputDir, dirPath), { recursive: true });
298
+ }
299
+ // Markdownファイルを保存
300
+ const wikiFilePath = path.join(options.outputDir, wikiFileName);
301
+ // JSONからcontentフィールドを取得
302
+ const content = wikiDetail.content || '';
303
+ // BacklogのWikiへのリンクを作成
304
+ const backlogWikiUrl = `https://${options.domain}/alias/wiki/${wikiId}`;
305
+ // Markdownファイルに書き込む(タイトルとBacklogリンクを追加)
306
+ const markdownContent = `# ${wiki.name}\n\n[Backlog Wiki Link](${backlogWikiUrl})\n\n${content}`;
307
+ // eslint-disable-next-line no-await-in-loop
308
+ await fs.writeFile(wikiFilePath, markdownContent);
309
+ // ログに記録
310
+ // eslint-disable-next-line no-await-in-loop
311
+ await appendLog(options.outputDir, `Wiki「${wiki.name}」を更新しました: ${backlogWikiUrl}`);
312
+ // 進捗状況を一行で更新
313
+ const wikiIndex = filteredWikis.indexOf(wiki) + 1;
314
+ process.stdout.write(`\rWikiを保存中... (${wikiIndex}/${filteredWikis.length}件)`);
315
+ }
316
+ catch (error) {
317
+ command.warn(`Wiki ${wiki.name} の取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
318
+ }
319
+ }
320
+ command.log('\nWikiのダウンロードが完了しました!');
321
+ }
322
+ /**
323
+ * Backlogからドキュメントをダウンロードする
324
+ * @param command コマンドインスタンス
325
+ * @param options ドキュメントダウンロードのオプション
326
+ * @param options.apiKey Backlog API key
327
+ * @param options.domain Backlogのドメイン
328
+ * @param options.keyword キーワードフィルター
329
+ * @param options.lastUpdated 最終更新日時
330
+ * @param options.outputDir 出力ディレクトリ
331
+ * @param options.projectId プロジェクトID
332
+ * @param options.projectIdOrKey プロジェクトIDまたはキー
333
+ */
334
+ export async function downloadDocuments(command, options) {
335
+ const baseUrl = `https://${options.domain}/api/v2`;
336
+ command.log('ドキュメントの取得を開始します...');
337
+ // APIリクエスト数をカウントするためのRateLimiterを作成
338
+ const rateLimiter = new RateLimiter(command);
339
+ // ドキュメントツリーの取得
340
+ command.log('ドキュメントツリーを取得しています...');
341
+ // APIリクエスト数をインクリメント
342
+ await rateLimiter.increment();
343
+ const documentTree = await ky
344
+ .get(`${baseUrl}/documents/tree?projectIdOrKey=${options.projectId}&apiKey=${options.apiKey}`)
345
+ .json();
346
+ command.log('アクティブなドキュメントツリーを処理します...');
347
+ // ツリー構造をトラバースして、各ドキュメントの詳細を取得・保存
348
+ const processedDocuments = [];
349
+ /**
350
+ * ドキュメントノードを再帰的に処理する
351
+ */
352
+ const processDocumentNode = async (node, currentPath) => {
353
+ // フォルダの場合
354
+ if (node.children && node.children.length > 0) {
355
+ // フォルダを作成
356
+ const folderPath = path.join(options.outputDir, currentPath, sanitizeFileName(node.name));
357
+ await fs.mkdir(folderPath, { recursive: true });
358
+ // 子ノードを処理
359
+ for (const child of node.children) {
360
+ // eslint-disable-next-line no-await-in-loop
361
+ await processDocumentNode(child, path.join(currentPath, sanitizeFileName(node.name)));
362
+ }
363
+ }
364
+ else {
365
+ // ドキュメントファイルの場合
366
+ try {
367
+ // 既に処理済みのドキュメントはスキップ
368
+ if (processedDocuments.includes(node.id)) {
369
+ return;
370
+ }
371
+ processedDocuments.push(node.id);
372
+ // APIリクエスト数をインクリメント
373
+ await rateLimiter.increment();
374
+ // 進捗状況を表示
375
+ process.stdout.write(`\rドキュメント「${node.name}」を処理中...`);
376
+ // ドキュメント詳細を取得
377
+ const documentDetail = await ky.get(`${baseUrl}/documents/${node.id}?apiKey=${options.apiKey}`).json();
378
+ // 前回の更新日時チェック
379
+ if (options.lastUpdated) {
380
+ const lastUpdatedDate = new Date(options.lastUpdated);
381
+ const documentUpdatedDate = new Date(documentDetail.updated);
382
+ if (documentUpdatedDate <= lastUpdatedDate) {
383
+ return; // 更新が必要ない場合はスキップ
384
+ }
385
+ }
386
+ // ファイルパスを構築
387
+ const sanitizedTitle = sanitizeFileName(documentDetail.title);
388
+ const documentFileName = `${sanitizedTitle}.md`;
389
+ const documentFilePath = path.join(options.outputDir, currentPath, documentFileName);
390
+ // ディレクトリを作成(必要に応じて)
391
+ const dirPath = path.dirname(documentFilePath);
392
+ await fs.mkdir(dirPath, { recursive: true });
393
+ // Backlogのドキュメントへのリンクを作成
394
+ const backlogDocumentUrl = `https://${options.domain}/document/${options.projectIdOrKey}/${node.id}`;
395
+ // 添付ファイルリストの作成
396
+ let attachmentsSection = '';
397
+ if (documentDetail.attachments && documentDetail.attachments.length > 0) {
398
+ attachmentsSection = '\n\n## 添付ファイル\n';
399
+ for (const attachment of documentDetail.attachments) {
400
+ const attachmentDate = new Date(attachment.created).toLocaleString('ja-JP');
401
+ const fileSize = (attachment.size / 1024).toFixed(1);
402
+ attachmentsSection += `- **${attachment.name}** (${fileSize} KB) - 作成者: ${attachment.createdUser.name}, 作成日時: ${attachmentDate}\n`;
403
+ }
404
+ }
405
+ // タグリストの作成
406
+ let tagsSection = '';
407
+ if (documentDetail.tags && documentDetail.tags.length > 0) {
408
+ tagsSection = '\n\n## タグ\n';
409
+ for (const tag of documentDetail.tags) {
410
+ tagsSection += `- ${tag.name}\n`;
411
+ }
412
+ }
413
+ // 作成者・更新者情報
414
+ const createdDate = new Date(documentDetail.created).toLocaleString('ja-JP');
415
+ const updatedDate = new Date(documentDetail.updated).toLocaleString('ja-JP');
416
+ // Markdownファイルに書き込む
417
+ const markdownContent = `# ${documentDetail.title}
418
+
419
+ [Backlog Document Link](${backlogDocumentUrl})
420
+
421
+ **ステータス**: ${documentDetail.statusId}${documentDetail.emoji ? ` ${documentDetail.emoji}` : ''}
422
+ **作成者**: ${documentDetail.createdUser.name}
423
+ **作成日時**: ${createdDate}
424
+ **更新者**: ${documentDetail.updatedUser.name}
425
+ **更新日時**: ${updatedDate}
426
+
427
+ ## 内容
428
+
429
+ ${documentDetail.plain || '(内容なし)'}${attachmentsSection}${tagsSection}`;
430
+ await fs.writeFile(documentFilePath, markdownContent);
431
+ // ログに記録
432
+ await appendLog(options.outputDir, `ドキュメント「${documentDetail.title}」を更新しました: ${backlogDocumentUrl}`);
433
+ }
434
+ catch (error) {
435
+ command.warn(`ドキュメント ${node.name} の取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
436
+ }
437
+ }
438
+ };
439
+ // アクティブツリーのルートから処理開始
440
+ if (documentTree.activeTree.children && documentTree.activeTree.children.length > 0) {
441
+ for (const rootNode of documentTree.activeTree.children) {
442
+ // eslint-disable-next-line no-await-in-loop
443
+ await processDocumentNode(rootNode, '');
444
+ }
445
+ }
446
+ command.log(`\n合計 ${processedDocuments.length}件のドキュメントが処理されました。`);
447
+ command.log('ドキュメントのダウンロードが完了しました!');
448
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * プロジェクトキーからプロジェクトIDを取得する
3
+ * @param domain Backlogドメイン (例: example.backlog.jp)
4
+ * @param projectKey プロジェクトキー
5
+ * @param apiKey BacklogのAPIキー
6
+ * @returns プロジェクトID
7
+ */
8
+ export declare function getProjectIdFromKey(domain: string, projectKey: string, apiKey: string): Promise<number>;
9
+ /**
10
+ * プロジェクトIDまたはキーを検証し、必要に応じてキーからIDを取得する
11
+ * @param domain Backlogドメイン
12
+ * @param projectIdOrKey プロジェクトIDまたはキー
13
+ * @param apiKey BacklogのAPIキー
14
+ * @returns プロジェクトID
15
+ */
16
+ export declare function validateAndGetProjectId(domain: string, projectIdOrKey: string, apiKey: string): Promise<number>;
@@ -0,0 +1,34 @@
1
+ import ky from 'ky';
2
+ /**
3
+ * プロジェクトキーからプロジェクトIDを取得する
4
+ * @param domain Backlogドメイン (例: example.backlog.jp)
5
+ * @param projectKey プロジェクトキー
6
+ * @param apiKey BacklogのAPIキー
7
+ * @returns プロジェクトID
8
+ */
9
+ export async function getProjectIdFromKey(domain, projectKey, apiKey) {
10
+ try {
11
+ const baseUrl = `https://${domain}/api/v2`;
12
+ const projectUrl = `${baseUrl}/projects/${projectKey}?apiKey=${apiKey}`;
13
+ const projectData = await ky.get(projectUrl).json();
14
+ return projectData.id;
15
+ }
16
+ catch (error) {
17
+ throw new Error(`プロジェクトキー "${projectKey}" からプロジェクトIDの取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
18
+ }
19
+ }
20
+ /**
21
+ * プロジェクトIDまたはキーを検証し、必要に応じてキーからIDを取得する
22
+ * @param domain Backlogドメイン
23
+ * @param projectIdOrKey プロジェクトIDまたはキー
24
+ * @param apiKey BacklogのAPIキー
25
+ * @returns プロジェクトID
26
+ */
27
+ export async function validateAndGetProjectId(domain, projectIdOrKey, apiKey) {
28
+ // 数値の場合はそのままプロジェクトIDとして返す
29
+ if (!Number.isNaN(Number(projectIdOrKey))) {
30
+ return Number(projectIdOrKey);
31
+ }
32
+ // 文字列の場合はプロジェクトキーとして扱い、IDを取得する
33
+ return getProjectIdFromKey(domain, projectIdOrKey, apiKey);
34
+ }
@@ -0,0 +1,25 @@
1
+ import { Command } from '@oclif/core';
2
+ /**
3
+ * APIキーを取得する(優先順位: コマンドライン引数 > 環境変数)
4
+ * @param command コマンドインスタンス
5
+ * @param providedApiKey コマンドライン引数から提供されたAPIキー
6
+ * @returns APIキー
7
+ */
8
+ export declare function getApiKey(command: Command, providedApiKey?: string): string;
9
+ /**
10
+ * ファイル名に使用できない文字を置換する関数
11
+ * @param name 元のファイル名
12
+ * @returns サニタイズされたファイル名
13
+ */
14
+ export declare function sanitizeFileName(name: string): string;
15
+ /**
16
+ * Wikiファイル名のサニタイズ(スラッシュはディレクトリ区切りとして使用するため残す)
17
+ * @param name 元のWiki名
18
+ * @returns サニタイズされたWikiファイル名
19
+ */
20
+ export declare function sanitizeWikiFileName(name: string): string;
21
+ /**
22
+ * 出力ディレクトリを作成する
23
+ * @param outputDir 出力ディレクトリパス
24
+ */
25
+ export declare function createOutputDirectory(outputDir: string): Promise<void>;
@@ -0,0 +1,54 @@
1
+ import * as fs from 'node:fs/promises';
2
+ /**
3
+ * APIキーを取得する(優先順位: コマンドライン引数 > 環境変数)
4
+ * @param command コマンドインスタンス
5
+ * @param providedApiKey コマンドライン引数から提供されたAPIキー
6
+ * @returns APIキー
7
+ */
8
+ export function getApiKey(command, providedApiKey) {
9
+ // コマンドライン引数からのAPIキー
10
+ if (providedApiKey) {
11
+ return providedApiKey;
12
+ }
13
+ // 環境変数からのAPIキー
14
+ const envApiKey = process.env.BACKLOG_API_KEY;
15
+ if (envApiKey) {
16
+ command.log('環境変数 BACKLOG_API_KEY からAPIキーを使用します');
17
+ return envApiKey;
18
+ }
19
+ // APIキーが見つからない場合はエラー
20
+ command.error('APIキーが見つかりません。--apiKey フラグまたは BACKLOG_API_KEY 環境変数で提供してください');
21
+ return ''; // この行は実行されないが、TypeScriptのエラーを回避するために必要
22
+ }
23
+ /**
24
+ * ファイル名に使用できない文字を置換する関数
25
+ * @param name 元のファイル名
26
+ * @returns サニタイズされたファイル名
27
+ */
28
+ export function sanitizeFileName(name) {
29
+ return name
30
+ .replaceAll(/[\\/:*?"<>|]/g, '_') // Windowsで使用できない文字を置換
31
+ .replaceAll(/\s+/g, '_') // スペースをアンダースコアに置換
32
+ .replaceAll('.', '_') // ドットを置換
33
+ .slice(0, 200); // 長すぎるファイル名を防ぐために200文字に制限
34
+ }
35
+ /**
36
+ * Wikiファイル名のサニタイズ(スラッシュはディレクトリ区切りとして使用するため残す)
37
+ * @param name 元のWiki名
38
+ * @returns サニタイズされたWikiファイル名
39
+ */
40
+ export function sanitizeWikiFileName(name) {
41
+ const invalidChars = ['\\', ':', '*', '?', '"', '<', '>', '|'];
42
+ let sanitizedName = name;
43
+ for (const char of invalidChars) {
44
+ sanitizedName = sanitizedName.replaceAll(char, '_');
45
+ }
46
+ return sanitizedName;
47
+ }
48
+ /**
49
+ * 出力ディレクトリを作成する
50
+ * @param outputDir 出力ディレクトリパス
51
+ */
52
+ export async function createOutputDirectory(outputDir) {
53
+ await fs.mkdir(outputDir, { recursive: true });
54
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * 更新ログを記録する
3
+ * @param outputDir 出力ディレクトリ
4
+ * @param message ログメッセージ
5
+ */
6
+ export declare function appendLog(outputDir: string, message: string): Promise<void>;
@@ -0,0 +1,18 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ /**
4
+ * 更新ログを記録する
5
+ * @param outputDir 出力ディレクトリ
6
+ * @param message ログメッセージ
7
+ */
8
+ export async function appendLog(outputDir, message) {
9
+ const logPath = path.join(outputDir, 'backlog-update.log');
10
+ const timestamp = new Date().toLocaleString('ja-JP');
11
+ const logMessage = `[${timestamp}] ${message}\n`;
12
+ try {
13
+ await fs.appendFile(logPath, logMessage, 'utf8');
14
+ }
15
+ catch (error) {
16
+ console.error(`ログの記録に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
17
+ }
18
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * フォルダタイプの定義
3
+ */
4
+ export declare enum FolderType {
5
+ DOCUMENT = "document",
6
+ ISSUE = "issue",
7
+ WIKI = "wiki"
8
+ }
9
+ /**
10
+ * 設定ファイルの型定義
11
+ */
12
+ export interface Settings {
13
+ apiKey?: string;
14
+ domain?: string;
15
+ folderType?: FolderType;
16
+ issueKeyFileName?: boolean;
17
+ issueKeyFolder?: boolean;
18
+ lastUpdated?: string;
19
+ outputDir?: string;
20
+ projectIdOrKey?: string;
21
+ }
22
+ /**
23
+ * 設定ファイルのパスを取得する
24
+ * @param directory 設定ファイルを保存するディレクトリ
25
+ * @returns 設定ファイルのパス
26
+ */
27
+ export declare function getSettingsFilePath(directory: string): string;
28
+ /**
29
+ * 設定ファイルを読み込む
30
+ * @param directory 設定ファイルが保存されているディレクトリ
31
+ * @returns 設定情報
32
+ */
33
+ export declare function loadSettings(directory: string): Promise<Settings>;
34
+ /**
35
+ * 設定ファイルを保存する
36
+ * @param directory 設定ファイルを保存するディレクトリ
37
+ * @param settings 保存する設定情報
38
+ */
39
+ export declare function saveSettings(directory: string, settings: Settings): Promise<void>;
40
+ /**
41
+ * 設定ファイルを更新する
42
+ * @param directory 設定ファイルを保存するディレクトリ
43
+ * @param newSettings 更新する設定情報
44
+ */
45
+ export declare function updateSettings(directory: string, newSettings: Partial<Settings>): Promise<Settings>;