backlog-exporter 1.0.0 → 1.1.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.
Files changed (70) hide show
  1. package/README.md +143 -636
  2. package/dist/commands/all/index.d.ts +1 -0
  3. package/dist/commands/all/index.js +8 -2
  4. package/dist/commands/document/index.d.ts +1 -0
  5. package/dist/commands/document/index.js +9 -2
  6. package/dist/commands/issue/index.d.ts +1 -0
  7. package/dist/commands/issue/index.js +12 -2
  8. package/dist/commands/update/index.d.ts +1 -0
  9. package/dist/commands/update/index.js +5 -0
  10. package/dist/commands/wiki/index.d.ts +1 -0
  11. package/dist/commands/wiki/index.js +9 -2
  12. package/dist/composition/backlog-repositories.d.ts +1 -1
  13. package/dist/composition/backlog-repositories.js +1 -1
  14. package/dist/modules/all/use-case/export-all.d.ts +1 -0
  15. package/dist/modules/all/use-case/export-all.js +7 -1
  16. package/dist/modules/document/domain/document-markdown.d.ts +1 -1
  17. package/dist/modules/document/domain/document-markdown.js +6 -3
  18. package/dist/modules/document/domain/document-path.d.ts +11 -1
  19. package/dist/modules/document/domain/document-path.js +17 -0
  20. package/dist/modules/document/domain/document-repository.d.ts +1 -0
  21. package/dist/modules/document/domain/document-save-plan.d.ts +4 -3
  22. package/dist/modules/document/domain/document-save-plan.js +11 -5
  23. package/dist/modules/document/domain/document-tree-gap.d.ts +2 -0
  24. package/dist/modules/document/domain/document-tree-gap.js +21 -0
  25. package/dist/modules/document/domain/document.d.ts +4 -0
  26. package/dist/modules/document/repository/backlog-document-repository.js +4 -0
  27. package/dist/modules/document/use-case/export-documents.d.ts +1 -0
  28. package/dist/modules/document/use-case/export-documents.js +72 -9
  29. package/dist/modules/issue/domain/issue-markdown.d.ts +4 -3
  30. package/dist/modules/issue/domain/issue-markdown.js +23 -8
  31. package/dist/modules/issue/domain/issue-path.d.ts +12 -1
  32. package/dist/modules/issue/domain/issue-path.js +12 -0
  33. package/dist/modules/issue/domain/issue-repository.d.ts +1 -0
  34. package/dist/modules/issue/domain/issue.d.ts +6 -0
  35. package/dist/modules/issue/repository/backlog-issue-repository.js +3 -0
  36. package/dist/modules/issue/use-case/export-issues.d.ts +1 -0
  37. package/dist/modules/issue/use-case/export-issues.js +29 -3
  38. package/dist/modules/prune/repository/prune-walker.js +5 -0
  39. package/dist/modules/prune/use-case/prune-directories.d.ts +1 -1
  40. package/dist/modules/prune/use-case/prune-directories.js +1 -1
  41. package/dist/modules/prune/use-case/prune-exports.js +15 -12
  42. package/dist/modules/settings/domain/settings.d.ts +1 -0
  43. package/dist/modules/update/domain/update-plan.d.ts +2 -0
  44. package/dist/modules/update/domain/update-plan.js +1 -0
  45. package/dist/modules/update/use-case/update-exports.d.ts +1 -1
  46. package/dist/modules/update/use-case/update-exports.js +4 -1
  47. package/dist/modules/wiki/domain/wiki-markdown.d.ts +6 -1
  48. package/dist/modules/wiki/domain/wiki-markdown.js +17 -2
  49. package/dist/modules/wiki/domain/wiki-path.d.ts +3 -0
  50. package/dist/modules/wiki/domain/wiki-path.js +12 -0
  51. package/dist/modules/wiki/domain/wiki-repository.d.ts +1 -0
  52. package/dist/modules/wiki/domain/wiki.d.ts +6 -0
  53. package/dist/modules/wiki/repository/backlog-wiki-repository.js +3 -0
  54. package/dist/modules/wiki/use-case/export-wikis.d.ts +1 -0
  55. package/dist/modules/wiki/use-case/export-wikis.js +31 -3
  56. package/dist/shared/attachment.d.ts +8 -0
  57. package/dist/shared/attachment.js +37 -0
  58. package/dist/shared/backlog/http-client.d.ts +5 -2
  59. package/dist/shared/backlog/http-client.js +41 -10
  60. package/dist/shared/backlog/sleep.d.ts +1 -0
  61. package/dist/shared/backlog/sleep.js +3 -0
  62. package/dist/shared/console/progress.js +8 -1
  63. package/dist/shared/file-name.d.ts +1 -0
  64. package/dist/shared/file-name.js +11 -0
  65. package/dist/shared/storage/markdown-store.d.ts +2 -0
  66. package/dist/shared/storage/markdown-store.js +11 -0
  67. package/oclif.manifest.json +43 -2
  68. package/package.json +1 -1
  69. package/dist/shared/backlog/rate-limiter.d.ts +0 -9
  70. package/dist/shared/backlog/rate-limiter.js +0 -25
@@ -1,10 +1,11 @@
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 { DOCUMENT_FALLBACK_PARENT_PATH, documentAttachmentMarkdownLink, documentAttachmentRelativePath, documentFileName, documentFolderPath, documentUrl, PARENT_DOCUMENT_INDEX_FILENAME, } from '../domain/document-path.js';
7
7
  import { planDocumentSave } from '../domain/document-save-plan.js';
8
+ import { findDocumentsMissingFromTree } from '../domain/document-tree-gap.js';
8
9
  export async function exportDocuments(deps, options) {
9
10
  const { documentRepository, logger } = deps;
10
11
  logger.log('ドキュメントの取得を開始します...');
@@ -13,13 +14,15 @@ export async function exportDocuments(deps, options) {
13
14
  logger.log('アクティブなドキュメントツリーを処理します...');
14
15
  const processedDocuments = [];
15
16
  const writtenFiles = new Set();
16
- const fetchAndSaveDocument = async (node, currentPath, asParentIndex = false) => {
17
+ // 戻り値はファイルを書き出したかどうか(保存件数の集計に使う)
18
+ const fetchAndSaveDocument = async (node, currentPath, placement = {}) => {
19
+ const asParentIndex = placement.asParentIndex ?? false;
17
20
  try {
18
21
  if (processedDocuments.includes(node.id)) {
19
- return;
22
+ return false;
20
23
  }
21
24
  if (options.documentIds && options.documentIds.length > 0 && !options.documentIds.includes(node.id)) {
22
- return;
25
+ return false;
23
26
  }
24
27
  processedDocuments.push(node.id);
25
28
  writeProgress(`ドキュメント「${node.name}」を処理中...`);
@@ -27,11 +30,12 @@ export async function exportDocuments(deps, options) {
27
30
  const fileName = documentFileName(documentDetail.title, asParentIndex);
28
31
  const filePath = path.join(options.outputDir, currentPath, fileName);
29
32
  const action = planDocumentSave({
33
+ alreadyWrittenThisRun: writtenFiles.has(filePath),
30
34
  asParentIndex,
31
35
  body: documentDetail.plain,
36
+ fileExists: await fileExists(filePath),
32
37
  lastUpdated: options.lastUpdated,
33
- parentIndexAlreadyWrittenThisRun: writtenFiles.has(filePath),
34
- parentIndexExists: asParentIndex && (await fileExists(filePath)),
38
+ missingFromTree: placement.missingFromTree ?? false,
35
39
  updated: documentDetail.updated,
36
40
  });
37
41
  switch (action) {
@@ -42,9 +46,16 @@ export async function exportDocuments(deps, options) {
42
46
  }
43
47
  case 'save': {
44
48
  const backlogDocumentUrl = documentUrl(options.domain, options.projectIdOrKey, node.id);
45
- await writeMarkdownFile(filePath, buildDocumentMarkdown(documentDetail, backlogDocumentUrl));
49
+ const attachmentLinks = options.downloadAttachments
50
+ ? await downloadDocumentAttachments(deps, documentDetail, currentPath, options.outputDir)
51
+ : undefined;
52
+ await writeMarkdownFile(filePath, buildDocumentMarkdown(documentDetail, backlogDocumentUrl, attachmentLinks));
46
53
  writtenFiles.add(filePath);
47
54
  await appendLog(options.outputDir, `ドキュメント「${documentDetail.title}」を更新しました: ${backlogDocumentUrl}`);
55
+ return true;
56
+ }
57
+ case 'skip-fallback-collision': {
58
+ logger.warn(`ツリーに現れないドキュメント「${documentDetail.title}」は、同名のファイルを既に出力しているため保存をスキップしました`);
48
59
  break;
49
60
  }
50
61
  case 'skip-parent-index-collision': {
@@ -57,6 +68,7 @@ export async function exportDocuments(deps, options) {
57
68
  catch (error) {
58
69
  logger.warn(`ドキュメント ${node.name} の取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
59
70
  }
71
+ return false;
60
72
  };
61
73
  /* eslint-disable no-await-in-loop */
62
74
  const processDocumentNode = async (node, currentPath) => {
@@ -67,7 +79,7 @@ export async function exportDocuments(deps, options) {
67
79
  await processDocumentNode(child, folderRelPath);
68
80
  }
69
81
  // 親自身の本文はフォルダ内の親indexとして子の後に保存する
70
- await fetchAndSaveDocument(node, folderRelPath, true);
82
+ await fetchAndSaveDocument(node, folderRelPath, { asParentIndex: true });
71
83
  }
72
84
  else {
73
85
  await fetchAndSaveDocument(node, currentPath);
@@ -76,7 +88,58 @@ export async function exportDocuments(deps, options) {
76
88
  for (const rootNode of documentTree.activeTree.children ?? []) {
77
89
  await processDocumentNode(rootNode, '');
78
90
  }
91
+ const missingFromTree = await findDocumentsOutsideTree(deps, documentTree, options, processedDocuments);
92
+ let savedMissingFromTree = 0;
93
+ for (const document of missingFromTree) {
94
+ const saved = await fetchAndSaveDocument({ children: [], id: document.id, name: document.title }, DOCUMENT_FALLBACK_PARENT_PATH, { missingFromTree: true });
95
+ if (saved) {
96
+ savedMissingFromTree++;
97
+ }
98
+ }
79
99
  /* eslint-enable no-await-in-loop */
100
+ // 検出件数ではなく実際に保存した件数を出す(未更新でスキップした分まで毎回報告しないため)
101
+ if (savedMissingFromTree > 0) {
102
+ logger.log(`ツリーに現れないドキュメント${savedMissingFromTree}件を出力ルート直下に保存しました`);
103
+ }
80
104
  logger.log(`\n合計 ${processedDocuments.length}件のドキュメントが処理されました。`);
81
105
  logger.log('ドキュメントのダウンロードが完了しました!');
82
106
  }
107
+ // ツリーに現れないドキュメントを一覧API(全件が載る)との差分から求める。
108
+ // 一覧の取得に失敗しても従来どおりツリー分のエクスポートは成立させるため、警告に留めて空を返す
109
+ async function findDocumentsOutsideTree(deps, documentTree, options, processedDocuments) {
110
+ // ID指定の取得で対象がすべてツリー内に見つかっている場合は、一覧APIを呼ぶ必要がない
111
+ const targetedIds = options.documentIds && options.documentIds.length > 0 ? options.documentIds : undefined;
112
+ if (targetedIds?.every((id) => processedDocuments.includes(id))) {
113
+ return [];
114
+ }
115
+ try {
116
+ const titlesById = await deps.documentRepository.fetchAllTitles(options.projectId);
117
+ return findDocumentsMissingFromTree(documentTree, titlesById);
118
+ }
119
+ catch (error) {
120
+ deps.logger.warn(`ドキュメント一覧の取得に失敗したため、ツリーに現れないドキュメントの確認をスキップします: ${error instanceof Error ? error.message : String(error)}`);
121
+ return [];
122
+ }
123
+ }
124
+ // 保存できた添付のみリンク化する。個々の失敗は警告に留め、ドキュメント本体の保存は続行する
125
+ async function downloadDocumentAttachments(deps, documentDetail, currentPath, outputDir) {
126
+ const links = new Map();
127
+ for (const attachment of documentDetail.attachments ?? []) {
128
+ const absolutePath = path.join(outputDir, documentAttachmentRelativePath(currentPath, documentDetail.title, attachment));
129
+ try {
130
+ // 添付IDは不変のため、サイズの一致するファイルが既にあれば再ダウンロードしない
131
+ // eslint-disable-next-line no-await-in-loop
132
+ if ((await fileSize(absolutePath)) !== attachment.size) {
133
+ // eslint-disable-next-line no-await-in-loop
134
+ const data = await deps.documentRepository.downloadAttachment(documentDetail.id, attachment.id);
135
+ // eslint-disable-next-line no-await-in-loop
136
+ await writeBinaryFile(absolutePath, data);
137
+ }
138
+ links.set(attachment.id, documentAttachmentMarkdownLink(documentDetail.title, attachment));
139
+ }
140
+ catch (error) {
141
+ deps.logger.warn(`ドキュメント「${documentDetail.title}」の添付ファイル「${attachment.name}」の取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
142
+ }
143
+ }
144
+ return links;
145
+ }
@@ -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 buildCommentsSection(comments: IssueComment[], backlogIssueUrl: string): string;
4
- export declare function buildIssueMarkdown(issue: Issue, comments: IssueComment[], backlogIssueUrl: string): string;
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
- export function buildCommentsSection(comments, backlogIssueUrl) {
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 commentsSection = buildCommentsSection(comments, backlogIssueUrl);
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 || '詳細情報なし')}${commentsSection}`;
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;
@@ -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
  export function issueFileName(issue, useIssueKey) {
@@ -11,6 +12,17 @@ export function issueRelativeDir(issue, useIssueKeyFolder) {
11
12
  export function issueRelativePath(issue, options) {
12
13
  return path.join(issueRelativeDir(issue, options.issueKeyFolder ?? false), issueFileName(issue, options.issueKeyFileName ?? false));
13
14
  }
15
+ // issueKeyFolderありなら課題フォルダ直下のattachments/、なしなら年フォルダのattachments/{課題キー}/
16
+ function attachmentDirSegments(issueKey, useIssueKeyFolder) {
17
+ return useIssueKeyFolder ? ['attachments'] : ['attachments', issueKey];
18
+ }
19
+ export function attachmentRelativePath(issue, attachment, options) {
20
+ return path.join(issueRelativeDir(issue, options.issueKeyFolder ?? false), ...attachmentDirSegments(issue.issueKey, options.issueKeyFolder ?? false), attachmentFileName(attachment));
21
+ }
22
+ export function attachmentMarkdownLink(issue, attachment, options) {
23
+ const segments = attachmentDirSegments(issue.issueKey, options.issueKeyFolder ?? false);
24
+ return encodeLinkDestination(['.', ...segments, attachmentFileName(attachment)].join('/'));
25
+ }
14
26
  export function issueUrl(domain, issueKey) {
15
27
  return `${backlogOrigin(domain)}/view/${issueKey}`;
16
28
  }
@@ -1,5 +1,6 @@
1
1
  import { Issue, IssueComment } from './issue.js';
2
2
  export interface IssueRepository {
3
+ downloadAttachment(issueIdOrKey: string, attachmentId: number): Promise<ArrayBuffer>;
3
4
  fetchAllComments(issueKey: string): Promise<IssueComment[]>;
4
5
  fetchByIdOrKey(issueIdOrKey: string): Promise<Issue>;
5
6
  fetchPage(options: {
@@ -3,6 +3,7 @@ export interface Issue {
3
3
  id: number;
4
4
  name: string;
5
5
  };
6
+ attachments?: IssueAttachment[];
6
7
  created: string;
7
8
  customFields: CustomField[];
8
9
  description: string;
@@ -25,6 +26,11 @@ export interface Issue {
25
26
  summary: string;
26
27
  updated: string;
27
28
  }
29
+ export interface IssueAttachment {
30
+ id: number;
31
+ name: string;
32
+ size: number;
33
+ }
28
34
  export interface CustomField {
29
35
  id: number;
30
36
  name: string;
@@ -1,5 +1,8 @@
1
1
  export function newBacklogIssueRepository(client) {
2
2
  return {
3
+ async downloadAttachment(issueIdOrKey, attachmentId) {
4
+ return client.getBinary(`/issues/${issueIdOrKey}/attachments/${attachmentId}`);
5
+ },
3
6
  async fetchAllComments(issueKey) {
4
7
  const allComments = [];
5
8
  let minId;
@@ -7,6 +7,7 @@ export interface ExportIssuesDeps {
7
7
  export interface ExportIssuesOptions {
8
8
  count?: number;
9
9
  domain: string;
10
+ downloadAttachments?: boolean;
10
11
  issueIdOrKeys?: string[];
11
12
  issueKeyFileName?: boolean;
12
13
  issueKeyFolder?: boolean;
@@ -1,10 +1,10 @@
1
1
  import path from 'node:path';
2
2
  import { writeProgress } from '../../../shared/console/progress.js';
3
- import { writeMarkdownFile } from '../../../shared/storage/markdown-store.js';
3
+ import { fileSize, writeBinaryFile, writeMarkdownFile } from '../../../shared/storage/markdown-store.js';
4
4
  import { appendLog } from '../../../shared/storage/update-log.js';
5
5
  import { filterIssuesUpdatedSince } from '../domain/issue-filter.js';
6
6
  import { buildIssueMarkdown } from '../domain/issue-markdown.js';
7
- import { issueRelativePath, issueUrl } from '../domain/issue-path.js';
7
+ import { attachmentMarkdownLink, attachmentRelativePath, issueRelativePath, issueUrl } from '../domain/issue-path.js';
8
8
  export async function exportIssues(deps, options) {
9
9
  const { logger } = deps;
10
10
  logger.log('課題の取得を開始します...');
@@ -81,7 +81,33 @@ async function saveIssue(deps, issue, options) {
81
81
  catch (error) {
82
82
  deps.logger.warn(`課題 ${issue.issueKey} のコメント取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
83
83
  }
84
+ const attachmentLinks = options.downloadAttachments
85
+ ? await downloadIssueAttachments(deps, issue, options)
86
+ : undefined;
84
87
  const filePath = path.join(options.outputDir, issueRelativePath(issue, options));
85
- await writeMarkdownFile(filePath, buildIssueMarkdown(issue, comments, backlogIssueUrl));
88
+ await writeMarkdownFile(filePath, buildIssueMarkdown(issue, comments, backlogIssueUrl, attachmentLinks));
86
89
  await appendLog(options.outputDir, `課題「${issue.summary}」を更新しました: ${backlogIssueUrl}`);
87
90
  }
91
+ // 保存できた添付のみリンク化する。個々の失敗は警告に留め、課題本体の保存は続行する
92
+ async function downloadIssueAttachments(deps, issue, options) {
93
+ const links = new Map();
94
+ for (const attachment of issue.attachments ?? []) {
95
+ const absolutePath = path.join(options.outputDir, attachmentRelativePath(issue, attachment, options));
96
+ try {
97
+ // 添付IDは不変のため、サイズの一致するファイルが既にあれば再ダウンロードしない
98
+ // (サイズ不一致は過去の中断等による破損とみなして取得し直す)
99
+ // eslint-disable-next-line no-await-in-loop
100
+ if ((await fileSize(absolutePath)) !== attachment.size) {
101
+ // eslint-disable-next-line no-await-in-loop
102
+ const data = await deps.issueRepository.downloadAttachment(issue.issueKey, attachment.id);
103
+ // eslint-disable-next-line no-await-in-loop
104
+ await writeBinaryFile(absolutePath, data);
105
+ }
106
+ links.set(attachment.id, attachmentMarkdownLink(issue, attachment, options));
107
+ }
108
+ catch (error) {
109
+ deps.logger.warn(`課題 ${issue.issueKey} の添付ファイル「${attachment.name}」の取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
110
+ }
111
+ }
112
+ return links;
113
+ }
@@ -17,6 +17,11 @@ export async function pruneLocalMarkdownFiles(options) {
17
17
  const fullPath = path.join(dir, entry.name);
18
18
  const relativePath = path.relative(options.outputDir, fullPath).normalize('NFC');
19
19
  if (entry.isDirectory()) {
20
+ // 添付ファイルの保存先。Wiki・ドキュメントのpruneは一覧APIしか呼ばず添付の期待パスを持てないため、
21
+ // attachments/ 配下は走査せず丸ごと保護する(.md形式の添付の誤削除防止)
22
+ if (entry.name === 'attachments') {
23
+ continue;
24
+ }
20
25
  await pruneDirectory(fullPath);
21
26
  const remaining = await fs.readdir(fullPath);
22
27
  if (remaining.length === 0 && !expectedDirs.has(relativePath.toLowerCase())) {
@@ -12,7 +12,7 @@ export interface PruneDeps {
12
12
  createRepositories: (connection: {
13
13
  apiKey: string;
14
14
  domain: string;
15
- onRateLimitWait?: () => void;
15
+ onRateLimitExceeded?: (waitSeconds: number) => void;
16
16
  }) => {
17
17
  documentRepository: DocumentRepository;
18
18
  issueRepository: IssueRepository;
@@ -53,7 +53,7 @@ async function pruneDirectory(deps, targetDir, flags, confirmDirectory) {
53
53
  const { documentRepository, issueRepository, projectRepository, wikiRepository } = deps.createRepositories({
54
54
  apiKey,
55
55
  domain,
56
- onRateLimitWait: () => logger.log('レート制限を回避するため15秒間待機します...'),
56
+ onRateLimitExceeded: (waitSeconds) => logger.log(`レート制限の上限に達しました。${waitSeconds}秒待機します...`),
57
57
  });
58
58
  if (target === 'wiki') {
59
59
  await pruneWikis({ logger, wikiRepository }, { outputDir: targetDir, projectIdOrKey });
@@ -1,4 +1,5 @@
1
- import { collectDocumentTreePaths, resolveDocumentLeafPaths } from '../../document/domain/document-path.js';
1
+ import { addFallbackDocumentPaths, collectDocumentTreePaths, resolveDocumentLeafPaths, } from '../../document/domain/document-path.js';
2
+ import { findDocumentsMissingFromTree } from '../../document/domain/document-tree-gap.js';
2
3
  import { buildIssueExpectedPaths } from '../../issue/domain/issue-path.js';
3
4
  import { buildWikiExpectedPaths } from '../../wiki/domain/wiki-path.js';
4
5
  import { pruneLocalMarkdownFiles } from '../repository/prune-walker.js';
@@ -7,19 +8,21 @@ export async function pruneDocuments(deps, options) {
7
8
  logger.log('Backlogのドキュメントツリーを取得しています...');
8
9
  const documentTree = await documentRepository.fetchTree(options.projectId);
9
10
  const expected = collectDocumentTreePaths(documentTree.activeTree.children);
10
- // 保存時のファイル名は詳細のtitle基準のため、一覧APIでタイトルを解決してから期待パスを確定する
11
- let titlesById = new Map();
12
- if (expected.leafNodes.length > 0) {
13
- logger.log(`${expected.leafNodes.length}件のドキュメントの正規ファイル名を確認しています...`);
14
- try {
15
- titlesById = await documentRepository.fetchAllTitles(options.projectId);
16
- }
17
- catch (error) {
18
- // 一覧に欠けが生じると実在ドキュメントを誤削除するため、何も削除せずに中止する
19
- throw new Error(`ドキュメント一覧の取得に失敗しました。誤削除を防ぐため、何も削除せずに中止します: ${error instanceof Error ? error.message : String(error)}`);
20
- }
11
+ // 保存時のファイル名は詳細のtitle基準のため、一覧APIでタイトルを解決してから期待パスを確定する。
12
+ // 一覧はツリーに現れないドキュメントを拾う台帳でもあるため、リーフの有無に関わらず取得する
13
+ logger.log('ドキュメント一覧でファイル名を確認しています...');
14
+ let titlesById;
15
+ try {
16
+ titlesById = await documentRepository.fetchAllTitles(options.projectId);
17
+ }
18
+ catch (error) {
19
+ // 一覧に欠けが生じると実在ドキュメントを誤削除するため、何も削除せずに中止する
20
+ throw new Error(`ドキュメント一覧の取得に失敗しました。誤削除を防ぐため、何も削除せずに中止します: ${error instanceof Error ? error.message : String(error)}`);
21
21
  }
22
22
  resolveDocumentLeafPaths(expected, titlesById);
23
+ // 取得側はツリーに現れないドキュメントを出力ルート直下に保存するため、同じ配置を期待集合に加える
24
+ // (加えないと、補完して取得したファイルをpruneが即座に削除してしまう)
25
+ addFallbackDocumentPaths(expected, findDocumentsMissingFromTree(documentTree, titlesById));
23
26
  return pruneLocalMarkdownFiles({
24
27
  expected,
25
28
  label: 'ドキュメント',
@@ -6,6 +6,7 @@ export declare enum FolderType {
6
6
  export interface Settings {
7
7
  apiKey?: string;
8
8
  domain?: string;
9
+ downloadAttachments?: boolean;
9
10
  folderType?: FolderType;
10
11
  issueKeyFileName?: boolean;
11
12
  issueKeyFolder?: boolean;
@@ -4,6 +4,7 @@ export interface UpdateFlags {
4
4
  documentId?: string;
5
5
  documentsOnly?: boolean;
6
6
  domain?: string;
7
+ downloadAttachments?: boolean;
7
8
  force?: boolean;
8
9
  issueIdOrKey?: string;
9
10
  issueKeyFileName?: boolean;
@@ -16,6 +17,7 @@ export interface UpdateFlags {
16
17
  export interface UpdatePlan {
17
18
  documentIds?: string[];
18
19
  domain?: string;
20
+ downloadAttachments: boolean;
19
21
  folderType?: FolderType;
20
22
  issueIdOrKeys?: string[];
21
23
  issueKeyFileName: boolean;
@@ -56,6 +56,7 @@ export function buildUpdatePlan(settings, flags) {
56
56
  documentIds,
57
57
  // コマンドライン引数と設定ファイルを組み合わせて使用する値を決定
58
58
  domain: flags.domain || settings.domain,
59
+ downloadAttachments: flags.downloadAttachments ?? settings.downloadAttachments ?? false,
59
60
  folderType: settings.folderType,
60
61
  issueIdOrKeys,
61
62
  // 設定ファイルからオプションを読み込み、コマンドライン引数で上書き
@@ -9,7 +9,7 @@ export interface UpdateDeps {
9
9
  createRepositories: (connection: {
10
10
  apiKey: string;
11
11
  domain: string;
12
- onRateLimitWait?: () => void;
12
+ onRateLimitExceeded?: (waitSeconds: number) => void;
13
13
  }) => {
14
14
  documentRepository: DocumentRepository;
15
15
  issueRepository: IssueRepository;
@@ -48,7 +48,7 @@ async function updateDirectory(deps, targetDir, flags) {
48
48
  const { documentRepository, issueRepository, projectRepository, wikiRepository } = deps.createRepositories({
49
49
  apiKey,
50
50
  domain: plan.domain,
51
- onRateLimitWait: () => logger.log('レート制限を回避するため15秒間待機します...'),
51
+ onRateLimitExceeded: (waitSeconds) => logger.log(`レート制限の上限に達しました。${waitSeconds}秒待機します...`),
52
52
  });
53
53
  const projectId = await projectRepository.resolveProjectId(plan.projectIdOrKey);
54
54
  logger.log(`プロジェクトID: ${projectId} を使用します`);
@@ -65,6 +65,7 @@ async function updateDirectory(deps, targetDir, flags) {
65
65
  await exportIssues({ issueRepository, logger }, {
66
66
  count: 100,
67
67
  domain: plan.domain,
68
+ downloadAttachments: plan.downloadAttachments,
68
69
  issueIdOrKeys: plan.issueIdOrKeys,
69
70
  issueKeyFileName: plan.issueKeyFileName,
70
71
  issueKeyFolder: plan.issueKeyFolder,
@@ -82,6 +83,7 @@ async function updateDirectory(deps, targetDir, flags) {
82
83
  logger.log('Wikiの更新を開始します...');
83
84
  await exportWikis({ logger, wikiRepository }, {
84
85
  domain: plan.domain,
86
+ downloadAttachments: plan.downloadAttachments,
85
87
  lastUpdated: plan.wikiIds ? undefined : plan.lastUpdated,
86
88
  outputDir: targetDir,
87
89
  projectIdOrKey: plan.projectIdOrKey,
@@ -97,6 +99,7 @@ async function updateDirectory(deps, targetDir, flags) {
97
99
  await exportDocuments({ documentRepository, logger }, {
98
100
  documentIds: plan.documentIds,
99
101
  domain: plan.domain,
102
+ downloadAttachments: plan.downloadAttachments,
100
103
  lastUpdated: plan.documentIds ? undefined : plan.lastUpdated,
101
104
  outputDir: targetDir,
102
105
  projectId,
@@ -1 +1,6 @@
1
- export declare function buildWikiMarkdown(wikiName: string, backlogWikiUrl: string, content: string): string;
1
+ import { WikiAttachment } from './wiki.js';
2
+ export interface WikiAttachmentsView {
3
+ items?: WikiAttachment[];
4
+ localLinks?: Map<number, string>;
5
+ }
6
+ export declare function buildWikiMarkdown(wikiName: string, backlogWikiUrl: string, content: string, attachments?: WikiAttachmentsView): string;
@@ -1,4 +1,19 @@
1
+ import { escapeLinkText } from '../../../shared/attachment.js';
1
2
  import { wrapBody } from '../../../shared/markdown/body-marker.js';
2
- export function buildWikiMarkdown(wikiName, backlogWikiUrl, content) {
3
- return `# ${wikiName}\n\n[Backlog Wiki Link](${backlogWikiUrl})\n\n${wrapBody(content || '(内容なし)')}`;
3
+ // ダウンロード済みの添付はローカルへの相対リンク付き、未ダウンロードはメタデータのみを出力する
4
+ function buildAttachmentsSection(attachments) {
5
+ if (!attachments.items || attachments.items.length === 0) {
6
+ return '';
7
+ }
8
+ const lines = attachments.items.map((attachment) => {
9
+ const fileSize = `${(attachment.size / 1024).toFixed(1)} KB`;
10
+ const link = attachments.localLinks?.get(attachment.id);
11
+ return link ? `- [${escapeLinkText(attachment.name)}](${link}) (${fileSize})` : `- ${attachment.name} (${fileSize})`;
12
+ });
13
+ return `## 添付ファイル\n\n${lines.join('\n')}\n\n`;
14
+ }
15
+ // 本文はBacklogの原文を維持する(添付参照記法の書き換えは行わない)
16
+ export function buildWikiMarkdown(wikiName, backlogWikiUrl, content, attachments = {}) {
17
+ const attachmentsSection = buildAttachmentsSection(attachments);
18
+ return `# ${wikiName}\n\n[Backlog Wiki Link](${backlogWikiUrl})\n\n${attachmentsSection}${wrapBody(content || '(内容なし)')}`;
4
19
  }
@@ -1,4 +1,7 @@
1
1
  import { ExpectedPaths } from '../../prune/domain/expected-paths.js';
2
+ import { WikiAttachment } from './wiki.js';
2
3
  export declare function wikiRelativePath(wikiName: string): string;
4
+ export declare function wikiAttachmentRelativePath(wikiName: string, attachment: Pick<WikiAttachment, 'id' | 'name'>): string;
5
+ export declare function wikiAttachmentMarkdownLink(wikiName: string, attachment: Pick<WikiAttachment, 'id' | 'name'>): string;
3
6
  export declare function wikiUrl(domain: string, wikiId: string): string;
4
7
  export declare function buildWikiExpectedPaths(wikiNames: string[]): ExpectedPaths;
@@ -1,9 +1,21 @@
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 { sanitizeWikiFileName } from '../../../shared/file-name.js';
4
5
  export function wikiRelativePath(wikiName) {
5
6
  return `${sanitizeWikiFileName(wikiName)}.md`;
6
7
  }
8
+ // 添付の保存先はMarkdownと同じディレクトリの attachments/{Wikiファイル名}/ 配下。
9
+ // Wiki名変更時は再ダウンロードになるが、Markdown本体({Wiki名}.md)と同じ挙動で閲覧性を優先する
10
+ function wikiAttachmentDirName(wikiName) {
11
+ return path.basename(wikiRelativePath(wikiName), '.md');
12
+ }
13
+ export function wikiAttachmentRelativePath(wikiName, attachment) {
14
+ return path.join(path.dirname(wikiRelativePath(wikiName)), 'attachments', wikiAttachmentDirName(wikiName), attachmentFileName(attachment));
15
+ }
16
+ export function wikiAttachmentMarkdownLink(wikiName, attachment) {
17
+ return encodeLinkDestination(['.', 'attachments', wikiAttachmentDirName(wikiName), attachmentFileName(attachment)].join('/'));
18
+ }
7
19
  export function wikiUrl(domain, wikiId) {
8
20
  return `${backlogOrigin(domain)}/alias/wiki/${wikiId}`;
9
21
  }
@@ -1,5 +1,6 @@
1
1
  import { WikiDetail, WikiSummary } from './wiki.js';
2
2
  export interface WikiRepository {
3
+ downloadAttachment(wikiId: string, attachmentId: number): Promise<ArrayBuffer>;
3
4
  fetchDetail(wikiId: string, projectIdOrKey: string): Promise<WikiDetail>;
4
5
  fetchWikis(projectIdOrKey: string): Promise<WikiSummary[]>;
5
6
  }
@@ -3,7 +3,13 @@ export interface WikiSummary {
3
3
  name: string;
4
4
  updated: string;
5
5
  }
6
+ export interface WikiAttachment {
7
+ id: number;
8
+ name: string;
9
+ size: number;
10
+ }
6
11
  export interface WikiDetail {
12
+ attachments?: WikiAttachment[];
7
13
  content?: string;
8
14
  id: string;
9
15
  name: string;
@@ -1,5 +1,8 @@
1
1
  export function newBacklogWikiRepository(client) {
2
2
  return {
3
+ async downloadAttachment(wikiId, attachmentId) {
4
+ return client.getBinary(`/wikis/${wikiId}/attachments/${attachmentId}`);
5
+ },
3
6
  async fetchDetail(wikiId, projectIdOrKey) {
4
7
  return client.getJson(`/wikis/${wikiId}`, { projectIdOrKey });
5
8
  },
@@ -6,6 +6,7 @@ export interface ExportWikisDeps {
6
6
  }
7
7
  export interface ExportWikisOptions {
8
8
  domain: string;
9
+ downloadAttachments?: boolean;
9
10
  lastUpdated?: string;
10
11
  outputDir: string;
11
12
  projectIdOrKey: string;