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
@@ -5,6 +5,7 @@ export default class All extends Command {
5
5
  static flags: {
6
6
  apiKey: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
7
  domain: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
8
+ downloadAttachments: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
9
  exclude: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
10
  issueKeyFileName: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
11
  issueKeyFolder: import("@oclif/core/interfaces").BooleanFlag<boolean>;
@@ -44,6 +44,11 @@ export default class All extends Command {
44
44
  description: 'Backlog domain (e.g. example.backlog.jp)',
45
45
  required: true,
46
46
  }),
47
+ downloadAttachments: Flags.boolean({
48
+ char: 'd',
49
+ description: '課題の添付ファイルもダウンロードする',
50
+ required: false,
51
+ }),
47
52
  exclude: Flags.string({
48
53
  description: "Exclude the specified types, separated by commas (e.g., 'documents,wiki')",
49
54
  required: false,
@@ -79,7 +84,7 @@ export default class All extends Command {
79
84
  async run() {
80
85
  const { flags } = await this.parse(All);
81
86
  try {
82
- const { domain, exclude, issueKeyFileName, issueKeyFolder, maxCount, only, projectIdOrKey } = flags;
87
+ const { domain, downloadAttachments, exclude, issueKeyFileName, issueKeyFolder, maxCount, only, projectIdOrKey } = flags;
83
88
  const apiKey = resolveApiKey(flags.apiKey, () => this.log('環境変数 BACKLOG_API_KEY からAPIキーを使用します')) ??
84
89
  this.error(API_KEY_NOT_FOUND_MESSAGE);
85
90
  const outputRoot = flags.output || './backlog-data';
@@ -93,11 +98,12 @@ export default class All extends Command {
93
98
  const repositories = createBacklogRepositories({
94
99
  apiKey,
95
100
  domain,
96
- onRateLimitWait: () => this.log('レート制限を回避するため15秒間待機します...'),
101
+ onRateLimitExceeded: (waitSeconds) => this.log(`レート制限の上限に達しました。${waitSeconds}秒待機します...`),
97
102
  });
98
103
  await exportAll({ ...repositories, logger }, {
99
104
  apiKey,
100
105
  domain,
106
+ downloadAttachments,
101
107
  issueKeyFileName,
102
108
  issueKeyFolder,
103
109
  maxCount,
@@ -5,6 +5,7 @@ export default class Document extends Command {
5
5
  static flags: {
6
6
  apiKey: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
7
  domain: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
8
+ downloadAttachments: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
9
  keyword: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
10
  output: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
11
  projectIdOrKey: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
@@ -29,6 +29,11 @@ export default class Document extends Command {
29
29
  description: 'Backlog domain (e.g. example.backlog.jp)',
30
30
  required: true,
31
31
  }),
32
+ downloadAttachments: Flags.boolean({
33
+ char: 'd',
34
+ description: '添付ファイルもダウンロードする',
35
+ required: false,
36
+ }),
32
37
  keyword: Flags.string({
33
38
  description: '検索キーワード',
34
39
  required: false,
@@ -46,7 +51,7 @@ export default class Document extends Command {
46
51
  async run() {
47
52
  const { flags } = await this.parse(Document);
48
53
  try {
49
- const { domain, keyword, projectIdOrKey } = flags;
54
+ const { domain, downloadAttachments, keyword, projectIdOrKey } = flags;
50
55
  const apiKey = resolveApiKey(flags.apiKey, () => this.log('環境変数 BACKLOG_API_KEY からAPIキーを使用します')) ??
51
56
  this.error(API_KEY_NOT_FOUND_MESSAGE);
52
57
  const outputDir = flags.output || './backlog-documents';
@@ -54,7 +59,7 @@ export default class Document extends Command {
54
59
  const { documentRepository, projectRepository } = createBacklogRepositories({
55
60
  apiKey,
56
61
  domain,
57
- onRateLimitWait: () => this.log('レート制限を回避するため15秒間待機します...'),
62
+ onRateLimitExceeded: (waitSeconds) => this.log(`レート制限の上限に達しました。${waitSeconds}秒待機します...`),
58
63
  });
59
64
  // 出力ディレクトリの作成
60
65
  await ensureDirectory(outputDir);
@@ -65,6 +70,7 @@ export default class Document extends Command {
65
70
  await updateSettings(outputDir, {
66
71
  apiKey,
67
72
  domain,
73
+ downloadAttachments,
68
74
  folderType: FolderType.DOCUMENT,
69
75
  outputDir,
70
76
  projectIdOrKey,
@@ -72,6 +78,7 @@ export default class Document extends Command {
72
78
  // ドキュメントの取得と保存
73
79
  await exportDocuments({ documentRepository, logger }, {
74
80
  domain,
81
+ downloadAttachments,
75
82
  keyword,
76
83
  outputDir,
77
84
  projectId,
@@ -5,6 +5,7 @@ export default class Issue extends Command {
5
5
  static flags: {
6
6
  apiKey: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
7
  domain: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
8
+ downloadAttachments: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
9
  issueKeyFileName: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
10
  issueKeyFolder: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
11
  maxCount: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
@@ -30,6 +30,9 @@ export default class Issue extends Command {
30
30
  `,
31
31
  `<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --issueKeyFileName --issueKeyFolder
32
32
  課題キーでフォルダを作成し、ファイル名も課題キーにする
33
+ `,
34
+ `<%= config.bin %> <%= command.id %> --domain example.backlog.jp --projectIdOrKey PROJECT_KEY --apiKey YOUR_API_KEY --downloadAttachments
35
+ 課題の添付ファイルもダウンロードする
33
36
  `,
34
37
  ];
35
38
  static flags = {
@@ -41,6 +44,11 @@ export default class Issue extends Command {
41
44
  description: 'Backlog domain (e.g. example.backlog.jp)',
42
45
  required: true,
43
46
  }),
47
+ downloadAttachments: Flags.boolean({
48
+ char: 'd',
49
+ description: '課題の添付ファイルもダウンロードする',
50
+ required: false,
51
+ }),
44
52
  issueKeyFileName: Flags.boolean({
45
53
  description: 'ファイル名を課題キーにする',
46
54
  required: false,
@@ -72,7 +80,7 @@ export default class Issue extends Command {
72
80
  async run() {
73
81
  const { flags } = await this.parse(Issue);
74
82
  try {
75
- const { domain, issueKeyFileName, issueKeyFolder, maxCount, projectIdOrKey, statusId } = flags;
83
+ const { domain, downloadAttachments, issueKeyFileName, issueKeyFolder, maxCount, projectIdOrKey, statusId } = flags;
76
84
  const apiKey = resolveApiKey(flags.apiKey, () => this.log('環境変数 BACKLOG_API_KEY からAPIキーを使用します')) ??
77
85
  this.error(API_KEY_NOT_FOUND_MESSAGE);
78
86
  const outputDir = flags.output || './backlog-issues';
@@ -80,7 +88,7 @@ export default class Issue extends Command {
80
88
  const { issueRepository, projectRepository } = createBacklogRepositories({
81
89
  apiKey,
82
90
  domain,
83
- onRateLimitWait: () => this.log('レート制限を回避するため15秒間待機します...'),
91
+ onRateLimitExceeded: (waitSeconds) => this.log(`レート制限の上限に達しました。${waitSeconds}秒待機します...`),
84
92
  });
85
93
  // 出力ディレクトリの作成
86
94
  await ensureDirectory(outputDir);
@@ -91,6 +99,7 @@ export default class Issue extends Command {
91
99
  await updateSettings(outputDir, {
92
100
  apiKey,
93
101
  domain,
102
+ downloadAttachments,
94
103
  folderType: FolderType.ISSUE,
95
104
  issueKeyFileName,
96
105
  issueKeyFolder,
@@ -101,6 +110,7 @@ export default class Issue extends Command {
101
110
  await exportIssues({ issueRepository, logger }, {
102
111
  count: maxCount,
103
112
  domain,
113
+ downloadAttachments,
104
114
  issueKeyFileName,
105
115
  issueKeyFolder,
106
116
  outputDir,
@@ -10,6 +10,7 @@ export default class Update extends Command {
10
10
  documentId: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
11
  documentsOnly: import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
12
  domain: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ downloadAttachments: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
14
  force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
15
  issueIdOrKey: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
16
  issueKeyFileName: import("@oclif/core/interfaces").BooleanFlag<boolean>;
@@ -62,6 +62,11 @@ export default class Update extends Command {
62
62
  description: 'Backlog domain (e.g. example.backlog.jp)',
63
63
  required: false,
64
64
  }),
65
+ downloadAttachments: Flags.boolean({
66
+ char: 'd',
67
+ description: '課題の添付ファイルもダウンロードする(未指定時は設定ファイルの値を使用)',
68
+ required: false,
69
+ }),
65
70
  force: Flags.boolean({
66
71
  char: 'f',
67
72
  description: '確認プロンプトをスキップする',
@@ -5,6 +5,7 @@ export default class Wiki extends Command {
5
5
  static flags: {
6
6
  apiKey: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
7
  domain: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
8
+ downloadAttachments: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
9
  output: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
10
  projectIdOrKey: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
11
  };
@@ -26,6 +26,11 @@ Wikiをダウンロードする
26
26
  description: 'Backlog domain (e.g. example.backlog.jp)',
27
27
  required: true,
28
28
  }),
29
+ downloadAttachments: Flags.boolean({
30
+ char: 'd',
31
+ description: '添付ファイルもダウンロードする',
32
+ required: false,
33
+ }),
29
34
  output: Flags.string({
30
35
  char: 'o',
31
36
  description: '出力ディレクトリパス',
@@ -39,7 +44,7 @@ Wikiをダウンロードする
39
44
  async run() {
40
45
  const { flags } = await this.parse(Wiki);
41
46
  try {
42
- const { domain, projectIdOrKey } = flags;
47
+ const { domain, downloadAttachments, projectIdOrKey } = flags;
43
48
  const apiKey = resolveApiKey(flags.apiKey, () => this.log('環境変数 BACKLOG_API_KEY からAPIキーを使用します')) ??
44
49
  this.error(API_KEY_NOT_FOUND_MESSAGE);
45
50
  const outputDir = flags.output || './wiki';
@@ -47,7 +52,7 @@ Wikiをダウンロードする
47
52
  const { wikiRepository } = createBacklogRepositories({
48
53
  apiKey,
49
54
  domain,
50
- onRateLimitWait: () => this.log('レート制限を回避するため15秒間待機します...'),
55
+ onRateLimitExceeded: (waitSeconds) => this.log(`レート制限の上限に達しました。${waitSeconds}秒待機します...`),
51
56
  });
52
57
  // 出力ディレクトリの作成
53
58
  await ensureDirectory(outputDir);
@@ -55,6 +60,7 @@ Wikiをダウンロードする
55
60
  await updateSettings(outputDir, {
56
61
  apiKey,
57
62
  domain,
63
+ downloadAttachments,
58
64
  folderType: FolderType.WIKI,
59
65
  outputDir,
60
66
  projectIdOrKey,
@@ -62,6 +68,7 @@ Wikiをダウンロードする
62
68
  // Wikiの取得と保存
63
69
  await exportWikis({ logger, wikiRepository }, {
64
70
  domain,
71
+ downloadAttachments,
65
72
  outputDir,
66
73
  projectIdOrKey,
67
74
  });
@@ -5,7 +5,7 @@ import { WikiRepository } from '../modules/wiki/domain/wiki-repository.js';
5
5
  export interface BacklogConnection {
6
6
  apiKey: string;
7
7
  domain: string;
8
- onRateLimitWait?: () => void;
8
+ onRateLimitExceeded?: (waitSeconds: number) => void;
9
9
  }
10
10
  export interface BacklogRepositories {
11
11
  documentRepository: DocumentRepository;
@@ -3,7 +3,7 @@ import { newBacklogIssueRepository } from '../modules/issue/repository/backlog-i
3
3
  import { newBacklogProjectRepository } from '../modules/project/repository/backlog-project-repository.js';
4
4
  import { newBacklogWikiRepository } from '../modules/wiki/repository/backlog-wiki-repository.js';
5
5
  import { BacklogHttpClient } from '../shared/backlog/http-client.js';
6
- // 1つのHTTPクライアント(=レート制限カウンタ)を全repositoryで共有する
6
+ // 1つのHTTPクライアントを全repositoryで共有する
7
7
  export function createBacklogRepositories(connection) {
8
8
  const client = new BacklogHttpClient(connection);
9
9
  return {
@@ -14,6 +14,7 @@ export type ExportTarget = 'documents' | 'issues' | 'wiki';
14
14
  export interface ExportAllOptions {
15
15
  apiKey: string;
16
16
  domain: string;
17
+ downloadAttachments?: boolean;
17
18
  issueKeyFileName?: boolean;
18
19
  issueKeyFolder?: boolean;
19
20
  maxCount: number;
@@ -7,7 +7,7 @@ import { updateSettings } from '../../settings/repository/settings-store.js';
7
7
  import { exportWikis } from '../../wiki/use-case/export-wikis.js';
8
8
  export async function exportAll(deps, options) {
9
9
  const { documentRepository, issueRepository, logger, projectRepository, wikiRepository } = deps;
10
- const { apiKey, domain, issueKeyFileName, issueKeyFolder, maxCount, outputRoot, projectIdOrKey, targets } = options;
10
+ const { apiKey, domain, downloadAttachments, issueKeyFileName, issueKeyFolder, maxCount, outputRoot, projectIdOrKey, targets } = options;
11
11
  await ensureDirectory(outputRoot);
12
12
  const projectId = await projectRepository.resolveProjectId(projectIdOrKey);
13
13
  logger.log(`プロジェクトID: ${projectId} を使用します`);
@@ -17,6 +17,7 @@ export async function exportAll(deps, options) {
17
17
  await updateSettings(issueOutput, {
18
18
  apiKey,
19
19
  domain,
20
+ downloadAttachments,
20
21
  folderType: FolderType.ISSUE,
21
22
  issueKeyFileName,
22
23
  issueKeyFolder,
@@ -27,6 +28,7 @@ export async function exportAll(deps, options) {
27
28
  await exportIssues({ issueRepository, logger }, {
28
29
  count: maxCount,
29
30
  domain,
31
+ downloadAttachments,
30
32
  issueKeyFileName,
31
33
  issueKeyFolder,
32
34
  outputDir: issueOutput,
@@ -41,6 +43,7 @@ export async function exportAll(deps, options) {
41
43
  await updateSettings(wikiOutput, {
42
44
  apiKey,
43
45
  domain,
46
+ downloadAttachments,
44
47
  folderType: FolderType.WIKI,
45
48
  outputDir: wikiOutput,
46
49
  projectIdOrKey,
@@ -48,6 +51,7 @@ export async function exportAll(deps, options) {
48
51
  logger.log('Wikiの取得を開始します...');
49
52
  await exportWikis({ logger, wikiRepository }, {
50
53
  domain,
54
+ downloadAttachments,
51
55
  outputDir: wikiOutput,
52
56
  projectIdOrKey,
53
57
  });
@@ -60,6 +64,7 @@ export async function exportAll(deps, options) {
60
64
  await updateSettings(documentOutput, {
61
65
  apiKey,
62
66
  domain,
67
+ downloadAttachments,
63
68
  folderType: FolderType.DOCUMENT,
64
69
  outputDir: documentOutput,
65
70
  projectIdOrKey,
@@ -67,6 +72,7 @@ export async function exportAll(deps, options) {
67
72
  logger.log('ドキュメントの取得を開始します...');
68
73
  await exportDocuments({ documentRepository, logger }, {
69
74
  domain,
75
+ downloadAttachments,
70
76
  outputDir: documentOutput,
71
77
  projectId,
72
78
  projectIdOrKey,
@@ -1,2 +1,2 @@
1
1
  import { DocumentDetail } from './document.js';
2
- export declare function buildDocumentMarkdown(documentDetail: DocumentDetail, backlogDocumentUrl: string): string;
2
+ export declare function buildDocumentMarkdown(documentDetail: DocumentDetail, backlogDocumentUrl: string, attachmentLinks?: Map<number, string>): string;
@@ -1,13 +1,16 @@
1
+ import { escapeLinkText } from '../../../shared/attachment.js';
1
2
  import { wrapBody } from '../../../shared/markdown/body-marker.js';
2
- export function buildDocumentMarkdown(documentDetail, backlogDocumentUrl) {
3
- // 添付ファイルリストの作成
3
+ export function buildDocumentMarkdown(documentDetail, backlogDocumentUrl, attachmentLinks) {
4
+ // 添付ファイルリストの作成(ダウンロード済みはローカルへの相対リンク付き)
4
5
  let attachmentsSection = '';
5
6
  if (documentDetail.attachments && documentDetail.attachments.length > 0) {
6
7
  attachmentsSection = '\n\n## 添付ファイル\n';
7
8
  for (const attachment of documentDetail.attachments) {
8
9
  const attachmentDate = new Date(attachment.created).toLocaleString('ja-JP');
9
10
  const fileSize = (attachment.size / 1024).toFixed(1);
10
- attachmentsSection += `- **${attachment.name}** (${fileSize} KB) - 作成者: ${attachment.createdUser.name}, 作成日時: ${attachmentDate}\n`;
11
+ const link = attachmentLinks?.get(attachment.id);
12
+ const label = link ? `[${escapeLinkText(attachment.name)}](${link})` : `**${attachment.name}**`;
13
+ attachmentsSection += `- ${label} (${fileSize} KB) - 作成者: ${attachment.createdUser.name}, 作成日時: ${attachmentDate}\n`;
11
14
  }
12
15
  }
13
16
  // タグリストの作成
@@ -1,9 +1,18 @@
1
1
  import { ExpectedPaths } from '../../prune/domain/expected-paths.js';
2
- import { DocumentNode } from './document.js';
2
+ import { DocumentNode, DocumentSummary } from './document.js';
3
3
  export declare const PARENT_DOCUMENT_INDEX_FILENAME = "00_index.md";
4
+ export declare const DOCUMENT_FALLBACK_PARENT_PATH = "";
4
5
  export declare function documentFolderPath(currentPath: string, folderName: string): string;
5
6
  export declare function documentFileName(title: string, asParentIndex: boolean): string;
6
7
  export declare function documentUrl(domain: string, projectIdOrKey: string, documentId: string): string;
8
+ export declare function documentAttachmentRelativePath(currentPath: string, documentTitle: string, attachment: {
9
+ id: number;
10
+ name: string;
11
+ }): string;
12
+ export declare function documentAttachmentMarkdownLink(documentTitle: string, attachment: {
13
+ id: number;
14
+ name: string;
15
+ }): string;
7
16
  export interface DocumentTreePaths extends ExpectedPaths {
8
17
  leafNodes: Array<{
9
18
  currentPath: string;
@@ -13,3 +22,4 @@ export interface DocumentTreePaths extends ExpectedPaths {
13
22
  }
14
23
  export declare function collectDocumentTreePaths(rootNodes: DocumentNode[]): DocumentTreePaths;
15
24
  export declare function resolveDocumentLeafPaths(paths: DocumentTreePaths, titlesById: Map<string, string>): void;
25
+ export declare function addFallbackDocumentPaths(paths: ExpectedPaths, documents: DocumentSummary[]): void;
@@ -1,8 +1,11 @@
1
1
  import path from 'node:path';
2
+ import { attachmentFileName, encodeLinkDestination } from '../../../shared/attachment.js';
2
3
  import { backlogOrigin } from '../../../shared/backlog-url.js';
3
4
  import { sanitizeFileName } from '../../../shared/file-name.js';
4
5
  // 子を持つ親ドキュメント自身の本文の保存先。ダウンロード側とprune側でレイアウト定義を共有する
5
6
  export const PARENT_DOCUMENT_INDEX_FILENAME = '00_index.md';
7
+ // ツリーに現れないドキュメントの保存先。ツリー上の位置が分からないため出力ルート直下に置く
8
+ export const DOCUMENT_FALLBACK_PARENT_PATH = '';
6
9
  export function documentFolderPath(currentPath, folderName) {
7
10
  return path.join(currentPath, sanitizeFileName(folderName));
8
11
  }
@@ -12,6 +15,14 @@ export function documentFileName(title, asParentIndex) {
12
15
  export function documentUrl(domain, projectIdOrKey, documentId) {
13
16
  return `${backlogOrigin(domain)}/document/${projectIdOrKey}/${documentId}`;
14
17
  }
18
+ // 添付の保存先はMarkdownと同じディレクトリの attachments/{ドキュメント名}/ 配下。
19
+ // タイトル改名時は再ダウンロードになるが、Markdown本体({タイトル}.md)と同じ挙動で閲覧性を優先する
20
+ export function documentAttachmentRelativePath(currentPath, documentTitle, attachment) {
21
+ return path.join(currentPath, 'attachments', sanitizeFileName(documentTitle), attachmentFileName(attachment));
22
+ }
23
+ export function documentAttachmentMarkdownLink(documentTitle, attachment) {
24
+ return encodeLinkDestination(['.', 'attachments', sanitizeFileName(documentTitle), attachmentFileName(attachment)].join('/'));
25
+ }
15
26
  export function collectDocumentTreePaths(rootNodes) {
16
27
  const expectedFiles = new Set();
17
28
  const expectedDirs = new Set();
@@ -46,3 +57,9 @@ export function resolveDocumentLeafPaths(paths, titlesById) {
46
57
  paths.expectedFiles.add(path.join(leaf.currentPath, `${sanitizeFileName(title)}.md`).normalize('NFC'));
47
58
  }
48
59
  }
60
+ // ツリーに現れないドキュメントは出力ルート直下に保存されるため、同じ配置で期待パスに加える
61
+ export function addFallbackDocumentPaths(paths, documents) {
62
+ for (const document of documents) {
63
+ paths.expectedFiles.add(path.join(DOCUMENT_FALLBACK_PARENT_PATH, documentFileName(document.title, false)).normalize('NFC'));
64
+ }
65
+ }
@@ -1,5 +1,6 @@
1
1
  import { DocumentDetail, DocumentTree } from './document.js';
2
2
  export interface DocumentRepository {
3
+ downloadAttachment(documentId: string, attachmentId: number): Promise<ArrayBuffer>;
3
4
  fetchAllTitles(projectId: number): Promise<Map<string, string>>;
4
5
  fetchDetail(documentId: string): Promise<DocumentDetail>;
5
6
  fetchTree(projectId: number): Promise<DocumentTree>;
@@ -1,9 +1,10 @@
1
- export type DocumentSaveAction = 'delete-stale-parent-index' | 'save' | 'skip-empty-parent' | 'skip-parent-index-collision' | 'skip-unchanged';
1
+ export type DocumentSaveAction = 'delete-stale-parent-index' | 'save' | 'skip-empty-parent' | 'skip-fallback-collision' | 'skip-parent-index-collision' | 'skip-unchanged';
2
2
  export declare function planDocumentSave(input: {
3
+ alreadyWrittenThisRun: boolean;
3
4
  asParentIndex: boolean;
4
5
  body: null | string | undefined;
6
+ fileExists: boolean;
5
7
  lastUpdated?: string;
6
- parentIndexAlreadyWrittenThisRun: boolean;
7
- parentIndexExists: boolean;
8
+ missingFromTree: boolean;
8
9
  updated: string;
9
10
  }): DocumentSaveAction;
@@ -1,15 +1,21 @@
1
1
  // 保存/スキップ/親index削除の判断。本文が空の親はファイルを作らず、空に変更された場合は古い親indexを削除する
2
2
  export function planDocumentSave(input) {
3
- if (input.asParentIndex && input.parentIndexAlreadyWrittenThisRun) {
3
+ if (input.asParentIndex && input.alreadyWrittenThisRun) {
4
4
  return 'skip-parent-index-collision';
5
5
  }
6
- // 前回の更新日時チェック(親indexがまだ存在しない場合はバックフィルのため未更新でも保存する)
7
- const backfillParentIndex = input.asParentIndex && !input.parentIndexExists;
8
- if (input.lastUpdated && !backfillParentIndex && new Date(input.updated) <= new Date(input.lastUpdated)) {
6
+ // ツリーに現れないドキュメントは出力ルート直下に固定で置かれるため、同名の保存先が既に使われていたら譲る。
7
+ // ツリー上の位置が確かなドキュメントを、位置の分からないドキュメントで黙って上書きしないための保険
8
+ if (input.missingFromTree && input.alreadyWrittenThisRun) {
9
+ return 'skip-fallback-collision';
10
+ }
11
+ // 前回の更新日時チェック。親index・ツリーに現れないドキュメントは、そもそも過去のエクスポートで
12
+ // 取得できていない可能性があるため、ファイルが無い場合は未更新でもバックフィルとして保存する
13
+ const backfill = (input.asParentIndex || input.missingFromTree) && !input.fileExists;
14
+ if (input.lastUpdated && !backfill && new Date(input.updated) <= new Date(input.lastUpdated)) {
9
15
  return 'skip-unchanged';
10
16
  }
11
17
  if (input.asParentIndex && !input.body?.trim()) {
12
- return input.parentIndexExists ? 'delete-stale-parent-index' : 'skip-empty-parent';
18
+ return input.fileExists ? 'delete-stale-parent-index' : 'skip-empty-parent';
13
19
  }
14
20
  return 'save';
15
21
  }
@@ -0,0 +1,2 @@
1
+ import { DocumentSummary, DocumentTree } from './document.js';
2
+ export declare function findDocumentsMissingFromTree(tree: DocumentTree, titlesById: Map<string, string>): DocumentSummary[];
@@ -0,0 +1,21 @@
1
+ // Backlogのドキュメントツリーは、作成後に一度も再保存されていないドキュメントを返さないことがある。
2
+ // ツリーだけを辿るとそれらが構造的に取得できないため、全件が載る一覧APIとの差分で拾う。
3
+ // ゴミ箱のドキュメントを復活させないよう、trashTreeに載っているものは差分から除く。
4
+ export function findDocumentsMissingFromTree(tree, titlesById) {
5
+ const knownIds = new Set();
6
+ const collect = (nodes) => {
7
+ for (const node of nodes ?? []) {
8
+ knownIds.add(node.id);
9
+ collect(node.children);
10
+ }
11
+ };
12
+ collect(tree.activeTree.children);
13
+ collect(tree.trashTree?.children);
14
+ const missing = [];
15
+ for (const [id, title] of titlesById) {
16
+ if (!knownIds.has(id)) {
17
+ missing.push({ id, title });
18
+ }
19
+ }
20
+ return missing;
21
+ }
@@ -11,6 +11,10 @@ export interface DocumentTree {
11
11
  children: DocumentNode[];
12
12
  id: string;
13
13
  };
14
+ trashTree?: {
15
+ children: DocumentNode[];
16
+ id: string;
17
+ };
14
18
  }
15
19
  export interface DocumentSummary {
16
20
  id: string;
@@ -1,5 +1,9 @@
1
1
  export function newBacklogDocumentRepository(client) {
2
2
  return {
3
+ // 公式ドキュメント未記載だが公式SDK(nulab/backlog-js)に存在するエンドポイント
4
+ async downloadAttachment(documentId, attachmentId) {
5
+ return client.getBinary(`/documents/${documentId}/attachments/${attachmentId}`);
6
+ },
3
7
  async fetchAllTitles(projectId) {
4
8
  const titlesById = new Map();
5
9
  const pageSize = 100;
@@ -7,6 +7,7 @@ export interface ExportDocumentsDeps {
7
7
  export interface ExportDocumentsOptions {
8
8
  documentIds?: string[];
9
9
  domain: string;
10
+ downloadAttachments?: boolean;
10
11
  keyword?: string;
11
12
  lastUpdated?: string;
12
13
  outputDir: string;