backlog-exporter 0.7.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +109 -1
  2. package/bin/dev.js +6 -2
  3. package/dist/commands/all/index.d.ts +3 -0
  4. package/dist/commands/all/index.js +69 -127
  5. package/dist/commands/document/index.d.ts +1 -0
  6. package/dist/commands/document/index.js +26 -12
  7. package/dist/commands/issue/index.d.ts +1 -0
  8. package/dist/commands/issue/index.js +29 -12
  9. package/dist/commands/prune/index.d.ts +16 -0
  10. package/dist/commands/prune/index.js +86 -0
  11. package/dist/commands/update/index.d.ts +4 -7
  12. package/dist/commands/update/index.js +33 -260
  13. package/dist/commands/wiki/index.d.ts +1 -0
  14. package/dist/commands/wiki/index.js +25 -10
  15. package/dist/composition/backlog-repositories.d.ts +16 -0
  16. package/dist/composition/backlog-repositories.js +15 -0
  17. package/dist/modules/all/domain/content-selection.d.ts +4 -0
  18. package/dist/modules/all/domain/content-selection.js +16 -0
  19. package/dist/modules/all/use-case/export-all.d.ts +25 -0
  20. package/dist/modules/all/use-case/export-all.js +84 -0
  21. package/dist/modules/document/domain/document-markdown.d.ts +2 -0
  22. package/dist/modules/document/domain/document-markdown.js +40 -0
  23. package/dist/modules/document/domain/document-path.d.ts +23 -0
  24. package/dist/modules/document/domain/document-path.js +57 -0
  25. package/dist/modules/document/domain/document-repository.d.ts +7 -0
  26. package/dist/modules/document/domain/document-repository.js +1 -0
  27. package/dist/modules/document/domain/document-save-plan.d.ts +9 -0
  28. package/dist/modules/document/domain/document-save-plan.js +15 -0
  29. package/dist/modules/document/domain/document.d.ts +50 -0
  30. package/dist/modules/document/domain/document.js +1 -0
  31. package/dist/modules/document/repository/backlog-document-repository.d.ts +3 -0
  32. package/dist/modules/document/repository/backlog-document-repository.js +35 -0
  33. package/dist/modules/document/use-case/export-documents.d.ts +17 -0
  34. package/dist/modules/document/use-case/export-documents.js +107 -0
  35. package/dist/modules/issue/domain/issue-filter.d.ts +2 -0
  36. package/dist/modules/issue/domain/issue-filter.js +7 -0
  37. package/dist/modules/issue/domain/issue-markdown.d.ts +5 -0
  38. package/dist/modules/issue/domain/issue-markdown.js +127 -0
  39. package/dist/modules/issue/domain/issue-path.d.ts +34 -0
  40. package/dist/modules/issue/domain/issue-path.js +42 -0
  41. package/dist/modules/issue/domain/issue-repository.d.ts +12 -0
  42. package/dist/modules/issue/domain/issue-repository.js +1 -0
  43. package/dist/modules/issue/domain/issue.d.ts +53 -0
  44. package/dist/modules/issue/domain/issue.js +1 -0
  45. package/dist/modules/issue/repository/backlog-issue-repository.d.ts +3 -0
  46. package/dist/modules/issue/repository/backlog-issue-repository.js +41 -0
  47. package/dist/modules/issue/use-case/export-issues.d.ts +19 -0
  48. package/dist/modules/issue/use-case/export-issues.js +113 -0
  49. package/dist/modules/project/domain/project-repository.d.ts +3 -0
  50. package/dist/modules/project/domain/project-repository.js +1 -0
  51. package/dist/modules/project/repository/backlog-project-repository.d.ts +3 -0
  52. package/dist/modules/project/repository/backlog-project-repository.js +17 -0
  53. package/dist/modules/prune/domain/expected-paths.d.ts +4 -0
  54. package/dist/modules/prune/domain/expected-paths.js +1 -0
  55. package/dist/modules/prune/domain/prune-target.d.ts +3 -0
  56. package/dist/modules/prune/domain/prune-target.js +21 -0
  57. package/dist/modules/prune/repository/prune-walker.d.ts +7 -0
  58. package/dist/modules/prune/repository/prune-walker.js +44 -0
  59. package/dist/modules/prune/use-case/prune-directories.d.ts +28 -0
  60. package/dist/modules/prune/use-case/prune-directories.js +74 -0
  61. package/dist/modules/prune/use-case/prune-exports.d.ts +27 -0
  62. package/dist/modules/prune/use-case/prune-exports.js +74 -0
  63. package/dist/modules/settings/domain/settings.d.ts +16 -0
  64. package/dist/modules/settings/domain/settings.js +6 -0
  65. package/dist/modules/settings/repository/settings-store.d.ts +6 -0
  66. package/dist/modules/settings/repository/settings-store.js +56 -0
  67. package/dist/modules/update/domain/update-plan.d.ts +38 -0
  68. package/dist/modules/update/domain/update-plan.js +70 -0
  69. package/dist/modules/update/use-case/update-exports.d.ts +21 -0
  70. package/dist/modules/update/use-case/update-exports.js +139 -0
  71. package/dist/modules/wiki/domain/wiki-filter.d.ts +9 -0
  72. package/dist/modules/wiki/domain/wiki-filter.js +11 -0
  73. package/dist/modules/wiki/domain/wiki-markdown.d.ts +6 -0
  74. package/dist/modules/wiki/domain/wiki-markdown.js +19 -0
  75. package/dist/modules/wiki/domain/wiki-path.d.ts +7 -0
  76. package/dist/modules/wiki/domain/wiki-path.js +37 -0
  77. package/dist/modules/wiki/domain/wiki-repository.d.ts +6 -0
  78. package/dist/modules/wiki/domain/wiki-repository.js +1 -0
  79. package/dist/modules/wiki/domain/wiki.d.ts +16 -0
  80. package/dist/modules/wiki/domain/wiki.js +1 -0
  81. package/dist/modules/wiki/repository/backlog-wiki-repository.d.ts +3 -0
  82. package/dist/modules/wiki/repository/backlog-wiki-repository.js +13 -0
  83. package/dist/modules/wiki/use-case/export-wikis.d.ts +15 -0
  84. package/dist/modules/wiki/use-case/export-wikis.js +71 -0
  85. package/dist/shared/attachment.d.ts +8 -0
  86. package/dist/shared/attachment.js +37 -0
  87. package/dist/shared/backlog/http-client.d.ts +20 -0
  88. package/dist/shared/backlog/http-client.js +93 -0
  89. package/dist/shared/backlog/sleep.d.ts +1 -0
  90. package/dist/shared/backlog/sleep.js +3 -0
  91. package/dist/shared/backlog-url.d.ts +1 -0
  92. package/dist/shared/backlog-url.js +5 -0
  93. package/dist/shared/config/env.d.ts +3 -0
  94. package/dist/shared/config/env.js +21 -0
  95. package/dist/shared/console/progress.d.ts +1 -0
  96. package/dist/shared/console/progress.js +11 -0
  97. package/dist/shared/console/prompt.d.ts +3 -0
  98. package/dist/shared/console/prompt.js +19 -0
  99. package/dist/shared/file-name.d.ts +3 -0
  100. package/dist/shared/file-name.js +26 -0
  101. package/dist/shared/markdown/body-marker.d.ts +3 -0
  102. package/dist/shared/markdown/body-marker.js +5 -0
  103. package/dist/shared/ports.d.ts +4 -0
  104. package/dist/shared/ports.js +1 -0
  105. package/dist/shared/storage/markdown-store.d.ts +7 -0
  106. package/dist/shared/storage/markdown-store.js +32 -0
  107. package/dist/shared/storage/update-log.d.ts +1 -0
  108. package/dist/{utils/log.js → shared/storage/update-log.js} +1 -5
  109. package/oclif.manifest.json +160 -27
  110. package/package.json +15 -19
  111. package/dist/utils/backlog-api.d.ts +0 -74
  112. package/dist/utils/backlog-api.js +0 -453
  113. package/dist/utils/backlog.d.ts +0 -16
  114. package/dist/utils/backlog.js +0 -34
  115. package/dist/utils/common.d.ts +0 -25
  116. package/dist/utils/common.js +0 -54
  117. package/dist/utils/log.d.ts +0 -6
  118. package/dist/utils/settings.d.ts +0 -45
  119. package/dist/utils/settings.js +0 -63
  120. package/dist/utils/sleep.d.ts +0 -28
  121. package/dist/utils/sleep.js +0 -42
@@ -0,0 +1,84 @@
1
+ import path from 'node:path';
2
+ import { ensureDirectory } from '../../../shared/storage/markdown-store.js';
3
+ import { exportDocuments } from '../../document/use-case/export-documents.js';
4
+ import { exportIssues } from '../../issue/use-case/export-issues.js';
5
+ import { FolderType } from '../../settings/domain/settings.js';
6
+ import { updateSettings } from '../../settings/repository/settings-store.js';
7
+ import { exportWikis } from '../../wiki/use-case/export-wikis.js';
8
+ export async function exportAll(deps, options) {
9
+ const { documentRepository, issueRepository, logger, projectRepository, wikiRepository } = deps;
10
+ const { apiKey, domain, downloadAttachments, issueKeyFileName, issueKeyFolder, maxCount, outputRoot, projectIdOrKey, targets } = options;
11
+ await ensureDirectory(outputRoot);
12
+ const projectId = await projectRepository.resolveProjectId(projectIdOrKey);
13
+ logger.log(`プロジェクトID: ${projectId} を使用します`);
14
+ if (targets.includes('issues')) {
15
+ const issueOutput = path.join(outputRoot, 'issues');
16
+ await ensureDirectory(issueOutput);
17
+ await updateSettings(issueOutput, {
18
+ apiKey,
19
+ domain,
20
+ downloadAttachments,
21
+ folderType: FolderType.ISSUE,
22
+ issueKeyFileName,
23
+ issueKeyFolder,
24
+ outputDir: issueOutput,
25
+ projectIdOrKey,
26
+ });
27
+ logger.log('課題の取得を開始します...');
28
+ await exportIssues({ issueRepository, logger }, {
29
+ count: maxCount,
30
+ domain,
31
+ downloadAttachments,
32
+ issueKeyFileName,
33
+ issueKeyFolder,
34
+ outputDir: issueOutput,
35
+ projectId,
36
+ });
37
+ await updateSettings(issueOutput, { lastUpdated: new Date().toISOString() });
38
+ logger.log('課題の取得が完了しました');
39
+ }
40
+ if (targets.includes('wiki')) {
41
+ const wikiOutput = path.join(outputRoot, 'wiki');
42
+ await ensureDirectory(wikiOutput);
43
+ await updateSettings(wikiOutput, {
44
+ apiKey,
45
+ domain,
46
+ downloadAttachments,
47
+ folderType: FolderType.WIKI,
48
+ outputDir: wikiOutput,
49
+ projectIdOrKey,
50
+ });
51
+ logger.log('Wikiの取得を開始します...');
52
+ await exportWikis({ logger, wikiRepository }, {
53
+ domain,
54
+ downloadAttachments,
55
+ outputDir: wikiOutput,
56
+ projectIdOrKey,
57
+ });
58
+ await updateSettings(wikiOutput, { lastUpdated: new Date().toISOString() });
59
+ logger.log('Wikiの取得が完了しました');
60
+ }
61
+ if (targets.includes('documents')) {
62
+ const documentOutput = path.join(outputRoot, 'documents');
63
+ await ensureDirectory(documentOutput);
64
+ await updateSettings(documentOutput, {
65
+ apiKey,
66
+ domain,
67
+ downloadAttachments,
68
+ folderType: FolderType.DOCUMENT,
69
+ outputDir: documentOutput,
70
+ projectIdOrKey,
71
+ });
72
+ logger.log('ドキュメントの取得を開始します...');
73
+ await exportDocuments({ documentRepository, logger }, {
74
+ domain,
75
+ downloadAttachments,
76
+ outputDir: documentOutput,
77
+ projectId,
78
+ projectIdOrKey,
79
+ });
80
+ await updateSettings(documentOutput, { lastUpdated: new Date().toISOString() });
81
+ logger.log('ドキュメントの取得が完了しました');
82
+ }
83
+ logger.log('すべてのデータの取得が完了しました!');
84
+ }
@@ -0,0 +1,2 @@
1
+ import { DocumentDetail } from './document.js';
2
+ export declare function buildDocumentMarkdown(documentDetail: DocumentDetail, backlogDocumentUrl: string, attachmentLinks?: Map<number, string>): string;
@@ -0,0 +1,40 @@
1
+ import { escapeLinkText } from '../../../shared/attachment.js';
2
+ import { wrapBody } from '../../../shared/markdown/body-marker.js';
3
+ export function buildDocumentMarkdown(documentDetail, backlogDocumentUrl, attachmentLinks) {
4
+ // 添付ファイルリストの作成(ダウンロード済みはローカルへの相対リンク付き)
5
+ let attachmentsSection = '';
6
+ if (documentDetail.attachments && documentDetail.attachments.length > 0) {
7
+ attachmentsSection = '\n\n## 添付ファイル\n';
8
+ for (const attachment of documentDetail.attachments) {
9
+ const attachmentDate = new Date(attachment.created).toLocaleString('ja-JP');
10
+ const fileSize = (attachment.size / 1024).toFixed(1);
11
+ const link = attachmentLinks?.get(attachment.id);
12
+ const label = link ? `[${escapeLinkText(attachment.name)}](${link})` : `**${attachment.name}**`;
13
+ attachmentsSection += `- ${label} (${fileSize} KB) - 作成者: ${attachment.createdUser.name}, 作成日時: ${attachmentDate}\n`;
14
+ }
15
+ }
16
+ // タグリストの作成
17
+ let tagsSection = '';
18
+ if (documentDetail.tags && documentDetail.tags.length > 0) {
19
+ tagsSection = '\n\n## タグ\n';
20
+ for (const tag of documentDetail.tags) {
21
+ tagsSection += `- ${tag.name}\n`;
22
+ }
23
+ }
24
+ // 作成者・更新者情報
25
+ const createdDate = new Date(documentDetail.created).toLocaleString('ja-JP');
26
+ const updatedDate = new Date(documentDetail.updated).toLocaleString('ja-JP');
27
+ return `# ${documentDetail.title}
28
+
29
+ [Backlog Document Link](${backlogDocumentUrl})
30
+
31
+ **ステータス**: ${documentDetail.statusId}${documentDetail.emoji ? ` ${documentDetail.emoji}` : ''}
32
+ **作成者**: ${documentDetail.createdUser.name}
33
+ **作成日時**: ${createdDate}
34
+ **更新者**: ${documentDetail.updatedUser.name}
35
+ **更新日時**: ${updatedDate}
36
+
37
+ ## 内容
38
+
39
+ ${wrapBody(documentDetail.plain || '(内容なし)')}${attachmentsSection}${tagsSection}`;
40
+ }
@@ -0,0 +1,23 @@
1
+ import { ExpectedPaths } from '../../prune/domain/expected-paths.js';
2
+ import { DocumentNode } from './document.js';
3
+ export declare const PARENT_DOCUMENT_INDEX_FILENAME = "00_index.md";
4
+ export declare function documentFolderPath(currentPath: string, folderName: string): string;
5
+ export declare function documentFileName(title: string, asParentIndex: boolean): string;
6
+ export declare function documentUrl(domain: string, projectIdOrKey: string, documentId: string): string;
7
+ export declare function documentAttachmentRelativePath(currentPath: string, documentTitle: string, attachment: {
8
+ id: number;
9
+ name: string;
10
+ }): string;
11
+ export declare function documentAttachmentMarkdownLink(documentTitle: string, attachment: {
12
+ id: number;
13
+ name: string;
14
+ }): string;
15
+ export interface DocumentTreePaths extends ExpectedPaths {
16
+ leafNodes: Array<{
17
+ currentPath: string;
18
+ id: string;
19
+ name: string;
20
+ }>;
21
+ }
22
+ export declare function collectDocumentTreePaths(rootNodes: DocumentNode[]): DocumentTreePaths;
23
+ export declare function resolveDocumentLeafPaths(paths: DocumentTreePaths, titlesById: Map<string, string>): void;
@@ -0,0 +1,57 @@
1
+ import path from 'node:path';
2
+ import { attachmentFileName, encodeLinkDestination } from '../../../shared/attachment.js';
3
+ import { backlogOrigin } from '../../../shared/backlog-url.js';
4
+ import { sanitizeFileName } from '../../../shared/file-name.js';
5
+ // 子を持つ親ドキュメント自身の本文の保存先。ダウンロード側とprune側でレイアウト定義を共有する
6
+ export const PARENT_DOCUMENT_INDEX_FILENAME = '00_index.md';
7
+ export function documentFolderPath(currentPath, folderName) {
8
+ return path.join(currentPath, sanitizeFileName(folderName));
9
+ }
10
+ export function documentFileName(title, asParentIndex) {
11
+ return asParentIndex ? PARENT_DOCUMENT_INDEX_FILENAME : `${sanitizeFileName(title)}.md`;
12
+ }
13
+ export function documentUrl(domain, projectIdOrKey, documentId) {
14
+ return `${backlogOrigin(domain)}/document/${projectIdOrKey}/${documentId}`;
15
+ }
16
+ // 添付の保存先はMarkdownと同じディレクトリの attachments/{ドキュメント名}/ 配下。
17
+ // タイトル改名時は再ダウンロードになるが、Markdown本体({タイトル}.md)と同じ挙動で閲覧性を優先する
18
+ export function documentAttachmentRelativePath(currentPath, documentTitle, attachment) {
19
+ return path.join(currentPath, 'attachments', sanitizeFileName(documentTitle), attachmentFileName(attachment));
20
+ }
21
+ export function documentAttachmentMarkdownLink(documentTitle, attachment) {
22
+ return encodeLinkDestination(['.', 'attachments', sanitizeFileName(documentTitle), attachmentFileName(attachment)].join('/'));
23
+ }
24
+ export function collectDocumentTreePaths(rootNodes) {
25
+ const expectedFiles = new Set();
26
+ const expectedDirs = new Set();
27
+ const leafNodes = [];
28
+ const walk = (node, currentPath) => {
29
+ if (node.children && node.children.length > 0) {
30
+ const dirPath = documentFolderPath(currentPath, node.name).normalize('NFC');
31
+ expectedDirs.add(dirPath);
32
+ expectedFiles.add(path.join(dirPath, PARENT_DOCUMENT_INDEX_FILENAME).normalize('NFC'));
33
+ for (const child of node.children) {
34
+ walk(child, dirPath);
35
+ }
36
+ }
37
+ else {
38
+ leafNodes.push({ currentPath, id: node.id, name: node.name });
39
+ }
40
+ };
41
+ for (const rootNode of rootNodes) {
42
+ walk(rootNode, '');
43
+ }
44
+ return { expectedDirs, expectedFiles, leafNodes };
45
+ }
46
+ // 一覧に現れないリーフは空フォルダの可能性があるため、ディレクトリとname由来ファイルの両方を保護する(誤削除より安全側)
47
+ export function resolveDocumentLeafPaths(paths, titlesById) {
48
+ for (const leaf of paths.leafNodes) {
49
+ const title = titlesById.get(leaf.id);
50
+ if (title === undefined) {
51
+ paths.expectedDirs.add(path.join(leaf.currentPath, sanitizeFileName(leaf.name)).normalize('NFC'));
52
+ paths.expectedFiles.add(path.join(leaf.currentPath, `${sanitizeFileName(leaf.name)}.md`).normalize('NFC'));
53
+ continue;
54
+ }
55
+ paths.expectedFiles.add(path.join(leaf.currentPath, `${sanitizeFileName(title)}.md`).normalize('NFC'));
56
+ }
57
+ }
@@ -0,0 +1,7 @@
1
+ import { DocumentDetail, DocumentTree } from './document.js';
2
+ export interface DocumentRepository {
3
+ downloadAttachment(documentId: string, attachmentId: number): Promise<ArrayBuffer>;
4
+ fetchAllTitles(projectId: number): Promise<Map<string, string>>;
5
+ fetchDetail(documentId: string): Promise<DocumentDetail>;
6
+ fetchTree(projectId: number): Promise<DocumentTree>;
7
+ }
@@ -0,0 +1,9 @@
1
+ export type DocumentSaveAction = 'delete-stale-parent-index' | 'save' | 'skip-empty-parent' | 'skip-parent-index-collision' | 'skip-unchanged';
2
+ export declare function planDocumentSave(input: {
3
+ asParentIndex: boolean;
4
+ body: null | string | undefined;
5
+ lastUpdated?: string;
6
+ parentIndexAlreadyWrittenThisRun: boolean;
7
+ parentIndexExists: boolean;
8
+ updated: string;
9
+ }): DocumentSaveAction;
@@ -0,0 +1,15 @@
1
+ // 保存/スキップ/親index削除の判断。本文が空の親はファイルを作らず、空に変更された場合は古い親indexを削除する
2
+ export function planDocumentSave(input) {
3
+ if (input.asParentIndex && input.parentIndexAlreadyWrittenThisRun) {
4
+ return 'skip-parent-index-collision';
5
+ }
6
+ // 前回の更新日時チェック(親indexがまだ存在しない場合はバックフィルのため未更新でも保存する)
7
+ const backfillParentIndex = input.asParentIndex && !input.parentIndexExists;
8
+ if (input.lastUpdated && !backfillParentIndex && new Date(input.updated) <= new Date(input.lastUpdated)) {
9
+ return 'skip-unchanged';
10
+ }
11
+ if (input.asParentIndex && !input.body?.trim()) {
12
+ return input.parentIndexExists ? 'delete-stale-parent-index' : 'skip-empty-parent';
13
+ }
14
+ return 'save';
15
+ }
@@ -0,0 +1,50 @@
1
+ export interface DocumentNode {
2
+ children: DocumentNode[];
3
+ emoji?: string;
4
+ emojiType?: string;
5
+ id: string;
6
+ name: string;
7
+ updated?: string;
8
+ }
9
+ export interface DocumentTree {
10
+ activeTree: {
11
+ children: DocumentNode[];
12
+ id: string;
13
+ };
14
+ }
15
+ export interface DocumentSummary {
16
+ id: string;
17
+ title: string;
18
+ }
19
+ export interface DocumentDetail {
20
+ attachments: Array<{
21
+ created: string;
22
+ createdUser: {
23
+ id: number;
24
+ name: string;
25
+ };
26
+ id: number;
27
+ name: string;
28
+ size: number;
29
+ }>;
30
+ created: string;
31
+ createdUser: {
32
+ id: number;
33
+ name: string;
34
+ };
35
+ emoji?: string;
36
+ id: string;
37
+ json: string;
38
+ plain: string;
39
+ statusId: number;
40
+ tags: Array<{
41
+ id: number;
42
+ name: string;
43
+ }>;
44
+ title: string;
45
+ updated: string;
46
+ updatedUser: {
47
+ id: number;
48
+ name: string;
49
+ };
50
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import { BacklogHttpClient } from '../../../shared/backlog/http-client.js';
2
+ import { DocumentRepository } from '../domain/document-repository.js';
3
+ export declare function newBacklogDocumentRepository(client: BacklogHttpClient): DocumentRepository;
@@ -0,0 +1,35 @@
1
+ export function newBacklogDocumentRepository(client) {
2
+ return {
3
+ // 公式ドキュメント未記載だが公式SDK(nulab/backlog-js)に存在するエンドポイント
4
+ async downloadAttachment(documentId, attachmentId) {
5
+ return client.getBinary(`/documents/${documentId}/attachments/${attachmentId}`);
6
+ },
7
+ async fetchAllTitles(projectId) {
8
+ const titlesById = new Map();
9
+ const pageSize = 100;
10
+ let offset = 0;
11
+ for (;;) {
12
+ // eslint-disable-next-line no-await-in-loop
13
+ const page = await client.getJson('/documents', {
14
+ count: pageSize.toString(),
15
+ offset: offset.toString(),
16
+ 'projectId[]': projectId.toString(),
17
+ });
18
+ for (const doc of page) {
19
+ titlesById.set(doc.id, doc.title);
20
+ }
21
+ if (page.length < pageSize) {
22
+ break;
23
+ }
24
+ offset += pageSize;
25
+ }
26
+ return titlesById;
27
+ },
28
+ async fetchDetail(documentId) {
29
+ return client.getJson(`/documents/${documentId}`);
30
+ },
31
+ async fetchTree(projectId) {
32
+ return client.getJson('/documents/tree', { projectIdOrKey: projectId.toString() });
33
+ },
34
+ };
35
+ }
@@ -0,0 +1,17 @@
1
+ import { Logger } from '../../../shared/ports.js';
2
+ import { DocumentRepository } from '../domain/document-repository.js';
3
+ export interface ExportDocumentsDeps {
4
+ documentRepository: DocumentRepository;
5
+ logger: Logger;
6
+ }
7
+ export interface ExportDocumentsOptions {
8
+ documentIds?: string[];
9
+ domain: string;
10
+ downloadAttachments?: boolean;
11
+ keyword?: string;
12
+ lastUpdated?: string;
13
+ outputDir: string;
14
+ projectId: number;
15
+ projectIdOrKey: string;
16
+ }
17
+ export declare function exportDocuments(deps: ExportDocumentsDeps, options: ExportDocumentsOptions): Promise<void>;
@@ -0,0 +1,107 @@
1
+ import path from 'node:path';
2
+ import { writeProgress } from '../../../shared/console/progress.js';
3
+ import { deleteFile, ensureDirectory, fileExists, fileSize, writeBinaryFile, writeMarkdownFile, } from '../../../shared/storage/markdown-store.js';
4
+ import { appendLog } from '../../../shared/storage/update-log.js';
5
+ import { buildDocumentMarkdown } from '../domain/document-markdown.js';
6
+ import { documentAttachmentMarkdownLink, documentAttachmentRelativePath, documentFileName, documentFolderPath, documentUrl, PARENT_DOCUMENT_INDEX_FILENAME, } from '../domain/document-path.js';
7
+ import { planDocumentSave } from '../domain/document-save-plan.js';
8
+ export async function exportDocuments(deps, options) {
9
+ const { documentRepository, logger } = deps;
10
+ logger.log('ドキュメントの取得を開始します...');
11
+ logger.log('ドキュメントツリーを取得しています...');
12
+ const documentTree = await documentRepository.fetchTree(options.projectId);
13
+ logger.log('アクティブなドキュメントツリーを処理します...');
14
+ const processedDocuments = [];
15
+ const writtenFiles = new Set();
16
+ const fetchAndSaveDocument = async (node, currentPath, asParentIndex = false) => {
17
+ try {
18
+ if (processedDocuments.includes(node.id)) {
19
+ return;
20
+ }
21
+ if (options.documentIds && options.documentIds.length > 0 && !options.documentIds.includes(node.id)) {
22
+ return;
23
+ }
24
+ processedDocuments.push(node.id);
25
+ writeProgress(`ドキュメント「${node.name}」を処理中...`);
26
+ const documentDetail = await documentRepository.fetchDetail(node.id);
27
+ const fileName = documentFileName(documentDetail.title, asParentIndex);
28
+ const filePath = path.join(options.outputDir, currentPath, fileName);
29
+ const action = planDocumentSave({
30
+ asParentIndex,
31
+ body: documentDetail.plain,
32
+ lastUpdated: options.lastUpdated,
33
+ parentIndexAlreadyWrittenThisRun: writtenFiles.has(filePath),
34
+ parentIndexExists: asParentIndex && (await fileExists(filePath)),
35
+ updated: documentDetail.updated,
36
+ });
37
+ switch (action) {
38
+ case 'delete-stale-parent-index': {
39
+ await deleteFile(filePath);
40
+ await appendLog(options.outputDir, `ドキュメント「${documentDetail.title}」の本文が空になったため ${PARENT_DOCUMENT_INDEX_FILENAME} を削除しました`);
41
+ break;
42
+ }
43
+ case 'save': {
44
+ const backlogDocumentUrl = documentUrl(options.domain, options.projectIdOrKey, node.id);
45
+ const attachmentLinks = options.downloadAttachments
46
+ ? await downloadDocumentAttachments(deps, documentDetail, currentPath, options.outputDir)
47
+ : undefined;
48
+ await writeMarkdownFile(filePath, buildDocumentMarkdown(documentDetail, backlogDocumentUrl, attachmentLinks));
49
+ writtenFiles.add(filePath);
50
+ await appendLog(options.outputDir, `ドキュメント「${documentDetail.title}」を更新しました: ${backlogDocumentUrl}`);
51
+ break;
52
+ }
53
+ case 'skip-parent-index-collision': {
54
+ logger.warn(`「${node.name}」内に「${PARENT_DOCUMENT_INDEX_FILENAME}」と同名になる子ドキュメントが存在するため、親ドキュメント本文の保存をスキップしました`);
55
+ break;
56
+ }
57
+ // No default
58
+ }
59
+ }
60
+ catch (error) {
61
+ logger.warn(`ドキュメント ${node.name} の取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
62
+ }
63
+ };
64
+ /* eslint-disable no-await-in-loop */
65
+ const processDocumentNode = async (node, currentPath) => {
66
+ if (node.children && node.children.length > 0) {
67
+ const folderRelPath = documentFolderPath(currentPath, node.name);
68
+ await ensureDirectory(path.join(options.outputDir, folderRelPath));
69
+ for (const child of node.children) {
70
+ await processDocumentNode(child, folderRelPath);
71
+ }
72
+ // 親自身の本文はフォルダ内の親indexとして子の後に保存する
73
+ await fetchAndSaveDocument(node, folderRelPath, true);
74
+ }
75
+ else {
76
+ await fetchAndSaveDocument(node, currentPath);
77
+ }
78
+ };
79
+ for (const rootNode of documentTree.activeTree.children ?? []) {
80
+ await processDocumentNode(rootNode, '');
81
+ }
82
+ /* eslint-enable no-await-in-loop */
83
+ logger.log(`\n合計 ${processedDocuments.length}件のドキュメントが処理されました。`);
84
+ logger.log('ドキュメントのダウンロードが完了しました!');
85
+ }
86
+ // 保存できた添付のみリンク化する。個々の失敗は警告に留め、ドキュメント本体の保存は続行する
87
+ async function downloadDocumentAttachments(deps, documentDetail, currentPath, outputDir) {
88
+ const links = new Map();
89
+ for (const attachment of documentDetail.attachments ?? []) {
90
+ const absolutePath = path.join(outputDir, documentAttachmentRelativePath(currentPath, documentDetail.title, attachment));
91
+ try {
92
+ // 添付IDは不変のため、サイズの一致するファイルが既にあれば再ダウンロードしない
93
+ // eslint-disable-next-line no-await-in-loop
94
+ if ((await fileSize(absolutePath)) !== attachment.size) {
95
+ // eslint-disable-next-line no-await-in-loop
96
+ const data = await deps.documentRepository.downloadAttachment(documentDetail.id, attachment.id);
97
+ // eslint-disable-next-line no-await-in-loop
98
+ await writeBinaryFile(absolutePath, data);
99
+ }
100
+ links.set(attachment.id, documentAttachmentMarkdownLink(documentDetail.title, attachment));
101
+ }
102
+ catch (error) {
103
+ deps.logger.warn(`ドキュメント「${documentDetail.title}」の添付ファイル「${attachment.name}」の取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
104
+ }
105
+ }
106
+ return links;
107
+ }
@@ -0,0 +1,2 @@
1
+ import { Issue } from './issue.js';
2
+ export declare function filterIssuesUpdatedSince(issues: Issue[], lastUpdated?: string): Issue[];
@@ -0,0 +1,7 @@
1
+ export function filterIssuesUpdatedSince(issues, lastUpdated) {
2
+ if (!lastUpdated) {
3
+ return issues;
4
+ }
5
+ const lastUpdatedDate = new Date(lastUpdated);
6
+ return issues.filter((issue) => new Date(issue.updated) > lastUpdatedDate);
7
+ }
@@ -0,0 +1,5 @@
1
+ import { CustomField, Issue, IssueAttachment, IssueComment } from './issue.js';
2
+ export declare function createCustomFieldsSection(customFields?: CustomField[]): 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;
@@ -0,0 +1,127 @@
1
+ import { escapeLinkText, rewriteInlineImages } from '../../../shared/attachment.js';
2
+ import { wrapBody } from '../../../shared/markdown/body-marker.js';
3
+ // Backlogの変更履歴(changeLog)のfieldを画面表示に合わせた日本語ラベルへ変換する
4
+ const CHANGE_FIELD_LABELS = {
5
+ actualHours: '実績時間',
6
+ assigner: '担当者',
7
+ attachment: '添付ファイル',
8
+ component: 'カテゴリー',
9
+ description: '詳細',
10
+ estimatedHours: '予定時間',
11
+ issueType: '種別',
12
+ limitDate: '期限日',
13
+ milestone: 'マイルストーン',
14
+ notification: 'お知らせ',
15
+ parentIssue: '親課題',
16
+ priority: '優先度',
17
+ resolution: '完了理由',
18
+ startDate: '開始日',
19
+ status: '状態',
20
+ summary: '件名',
21
+ version: '発生バージョン',
22
+ };
23
+ // 詳細やカスタム属性の変更値には複数行の長文が入ることがあり、
24
+ // そのまま出すとリスト構造が崩れるため1行化して切り詰める
25
+ function formatChangeValue(value) {
26
+ if (!value)
27
+ return '未設定';
28
+ const singleLine = value.replaceAll(/\s*\n\s*/g, ' ').trim();
29
+ return singleLine.length > 50 ? `${singleLine.slice(0, 50)}…` : singleLine;
30
+ }
31
+ function formatChange(change) {
32
+ const label = CHANGE_FIELD_LABELS[change.field] ?? change.field;
33
+ return `- ${label}: ${formatChangeValue(change.originalValue)} → ${formatChangeValue(change.newValue)}`;
34
+ }
35
+ function buildCommentBody(comment, rewriteBody) {
36
+ const parts = [];
37
+ if (comment.content) {
38
+ parts.push(rewriteBody(comment.content));
39
+ }
40
+ if (comment.changeLog && comment.changeLog.length > 0) {
41
+ parts.push(['**変更内容**', ...comment.changeLog.map((change) => formatChange(change))].join('\n'));
42
+ }
43
+ return parts.length > 0 ? parts.join('\n\n') : '(内容なし)';
44
+ }
45
+ export function createCustomFieldsSection(customFields) {
46
+ if (!customFields || customFields.length === 0) {
47
+ return '';
48
+ }
49
+ let customFieldsSection = '\n\n## カスタム属性\n\n| 属性名 | 値 |\n|--------|----|\n';
50
+ for (const customField of customFields) {
51
+ let fieldValue = 'なし';
52
+ if (customField.value !== null && customField.value !== undefined) {
53
+ if (Array.isArray(customField.value)) {
54
+ // 配列の場合(複数選択など)
55
+ fieldValue = customField.value
56
+ .map((item) => item.name || item.value || String(item))
57
+ .join(', ');
58
+ }
59
+ else if (typeof customField.value === 'object' && customField.value !== null) {
60
+ // オブジェクトの場合(単一選択など)
61
+ const valueObj = customField.value;
62
+ fieldValue = valueObj.name || valueObj.value || String(customField.value);
63
+ }
64
+ else {
65
+ // プリミティブ値の場合
66
+ fieldValue = String(customField.value);
67
+ }
68
+ }
69
+ // テーブル内では改行をHTMLの<br>タグに変換し、パイプ文字をエスケープ
70
+ const escapedFieldValue = fieldValue.replaceAll('|', String.raw `\|`).replaceAll('\n', '<br>');
71
+ customFieldsSection += `| ${customField.name} | ${escapedFieldValue} |\n`;
72
+ }
73
+ return customFieldsSection;
74
+ }
75
+ // ダウンロード済みの添付はローカルへの相対リンク付き、未ダウンロードはメタデータのみを出力する
76
+ export function buildAttachmentsSection(attachments, localLinks) {
77
+ if (!attachments || attachments.length === 0) {
78
+ return '';
79
+ }
80
+ const lines = attachments.map((attachment) => {
81
+ const fileSize = `${(attachment.size / 1024).toFixed(1)} KB`;
82
+ const link = localLinks?.get(attachment.id);
83
+ return link ? `- [${escapeLinkText(attachment.name)}](${link}) (${fileSize})` : `- ${attachment.name} (${fileSize})`;
84
+ });
85
+ return `\n\n## 添付ファイル\n\n${lines.join('\n')}`;
86
+ }
87
+ export function buildCommentsSection(comments, backlogIssueUrl, rewriteBody = (text) => text) {
88
+ if (comments.length === 0) {
89
+ return '';
90
+ }
91
+ let commentsSection = '\n\n## コメント\n';
92
+ let commentIndex = 1;
93
+ for (const comment of comments) {
94
+ const commentDate = new Date(comment.created).toLocaleString('ja-JP');
95
+ const backlogCommentUrl = `${backlogIssueUrl}#comment-${comment.id}`;
96
+ commentsSection += `\n### コメント ${commentIndex}\n- **投稿者**: ${comment.createdUser.name}\n- **日時**: ${commentDate}\n- [Backlog Comment Link](${backlogCommentUrl})\n\n${buildCommentBody(comment, rewriteBody)}\n\n---\n`;
97
+ commentIndex++;
98
+ }
99
+ // 最後の区切り線を削除
100
+ return commentsSection.slice(0, -5);
101
+ }
102
+ export function buildIssueMarkdown(issue, comments, backlogIssueUrl, attachmentLinks) {
103
+ const rewriteBody = (text) => rewriteInlineImages(text, issue.attachments, attachmentLinks);
104
+ const commentsSection = buildCommentsSection(comments, backlogIssueUrl, rewriteBody);
105
+ const customFieldsSection = createCustomFieldsSection(issue.customFields);
106
+ const attachmentsSection = buildAttachmentsSection(issue.attachments, attachmentLinks);
107
+ const assigneeName = issue.assignee ? issue.assignee.name : '未割り当て';
108
+ const startDate = issue.startDate ? new Date(issue.startDate).toLocaleDateString('ja-JP') : '未設定';
109
+ const dueDate = issue.dueDate ? new Date(issue.dueDate).toLocaleDateString('ja-JP') : '未設定';
110
+ return `# ${issue.summary}
111
+
112
+ ## 基本情報
113
+ - 課題キー: ${issue.issueKey}
114
+ - 種別: ${issue.issueType.name}
115
+ - ステータス: ${issue.status.name}
116
+ - 優先度: ${issue.priority.name}
117
+ - 担当者: ${assigneeName}
118
+ - 開始日: ${startDate}
119
+ - 期限日: ${dueDate}
120
+ - 作成日時: ${new Date(issue.created).toLocaleString('ja-JP')}
121
+ - 更新日時: ${new Date(issue.updated).toLocaleString('ja-JP')}
122
+ - [Backlog Issue Link](${backlogIssueUrl})${customFieldsSection}${attachmentsSection}
123
+
124
+ ## 詳細
125
+
126
+ ${wrapBody(issue.description ? rewriteBody(issue.description) : '詳細情報なし')}${commentsSection}`;
127
+ }
@@ -0,0 +1,34 @@
1
+ import { ExpectedPaths } from '../../prune/domain/expected-paths.js';
2
+ import { Issue, IssueAttachment } from './issue.js';
3
+ export declare function issueFileName(issue: {
4
+ issueKey: string;
5
+ summary: string;
6
+ }, useIssueKey: boolean): string;
7
+ export declare function issueRelativeDir(issue: {
8
+ created: string;
9
+ issueKey: string;
10
+ }, useIssueKeyFolder: boolean): string;
11
+ export declare function issueRelativePath(issue: {
12
+ created: string;
13
+ issueKey: string;
14
+ summary: string;
15
+ }, options: {
16
+ issueKeyFileName?: boolean;
17
+ issueKeyFolder?: boolean;
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;
30
+ export declare function issueUrl(domain: string, issueKey: string): string;
31
+ export declare function buildIssueExpectedPaths(issues: Issue[], options: {
32
+ issueKeyFileName?: boolean;
33
+ issueKeyFolder?: boolean;
34
+ }): ExpectedPaths;