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,6 @@
1
+ import { Settings } from '../domain/settings.js';
2
+ export declare function getSettingsFilePath(directory: string): string;
3
+ export declare function loadSettings(directory: string): Promise<Settings>;
4
+ export declare function saveSettings(directory: string, settings: Settings): Promise<void>;
5
+ export declare function updateSettings(directory: string, newSettings: Partial<Settings>): Promise<Settings>;
6
+ export declare function findSettingsDirectories(rootDir: string, onReadError?: (directory: string) => void): Promise<string[]>;
@@ -0,0 +1,56 @@
1
+ import { access, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ export function getSettingsFilePath(directory) {
4
+ return join(directory, 'backlog-settings.json');
5
+ }
6
+ export async function loadSettings(directory) {
7
+ const settingsPath = getSettingsFilePath(directory);
8
+ try {
9
+ const data = await readFile(settingsPath, 'utf8');
10
+ return JSON.parse(data);
11
+ }
12
+ catch {
13
+ return {};
14
+ }
15
+ }
16
+ export async function saveSettings(directory, settings) {
17
+ const settingsPath = getSettingsFilePath(directory);
18
+ // ディレクトリが存在しない場合は作成
19
+ await mkdir(directory, { recursive: true });
20
+ await writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
21
+ }
22
+ export async function updateSettings(directory, newSettings) {
23
+ const currentSettings = await loadSettings(directory);
24
+ // 新しい設定で更新(apiKeyは除外)
25
+ const { apiKey, ...settingsToSave } = newSettings;
26
+ const updatedSettings = { ...currentSettings, ...settingsToSave };
27
+ await saveSettings(directory, updatedSettings);
28
+ return { ...updatedSettings, apiKey };
29
+ }
30
+ // 設定ファイルを持つディレクトリを親→子の順に列挙する(update/pruneの処理順を保つ)
31
+ export async function findSettingsDirectories(rootDir, onReadError) {
32
+ const found = [];
33
+ const walk = async (dir) => {
34
+ try {
35
+ await access(getSettingsFilePath(dir));
36
+ found.push(dir);
37
+ }
38
+ catch {
39
+ // 設定ファイルが存在しないディレクトリはスキップ
40
+ }
41
+ try {
42
+ const entries = await readdir(dir, { withFileTypes: true });
43
+ for (const entry of entries) {
44
+ if (entry.isDirectory()) {
45
+ // eslint-disable-next-line no-await-in-loop
46
+ await walk(join(dir, entry.name));
47
+ }
48
+ }
49
+ }
50
+ catch {
51
+ onReadError?.(dir);
52
+ }
53
+ };
54
+ await walk(rootDir);
55
+ return found;
56
+ }
@@ -0,0 +1,38 @@
1
+ import { FolderType, Settings } from '../../settings/domain/settings.js';
2
+ export interface UpdateFlags {
3
+ apiKey?: string;
4
+ documentId?: string;
5
+ documentsOnly?: boolean;
6
+ domain?: string;
7
+ downloadAttachments?: boolean;
8
+ force?: boolean;
9
+ issueIdOrKey?: string;
10
+ issueKeyFileName?: boolean;
11
+ issueKeyFolder?: boolean;
12
+ issuesOnly?: boolean;
13
+ projectIdOrKey?: string;
14
+ wikiId?: string;
15
+ wikisOnly?: boolean;
16
+ }
17
+ export interface UpdatePlan {
18
+ documentIds?: string[];
19
+ domain?: string;
20
+ downloadAttachments: boolean;
21
+ folderType?: FolderType;
22
+ issueIdOrKeys?: string[];
23
+ issueKeyFileName: boolean;
24
+ issueKeyFolder: boolean;
25
+ lastUpdated?: string;
26
+ projectIdOrKey?: string;
27
+ updateDocuments: boolean;
28
+ updateIssues: boolean;
29
+ updateWikis: boolean;
30
+ wikiIds?: string[];
31
+ }
32
+ export declare function parseIds(value: string | undefined): string[] | undefined;
33
+ export declare function determineUpdateTargets(folderType: FolderType | undefined, documentsOnly: boolean | undefined, issuesOnly: boolean | undefined, wikisOnly: boolean | undefined): {
34
+ updateDocuments: boolean;
35
+ updateIssues: boolean;
36
+ updateWikis: boolean;
37
+ };
38
+ export declare function buildUpdatePlan(settings: Settings, flags: UpdateFlags): UpdatePlan;
@@ -0,0 +1,70 @@
1
+ import { FolderType } from '../../settings/domain/settings.js';
2
+ export function parseIds(value) {
3
+ if (!value)
4
+ return undefined;
5
+ const ids = value
6
+ .split(',')
7
+ .map((id) => id.trim())
8
+ .filter(Boolean);
9
+ return ids.length > 0 ? ids : undefined;
10
+ }
11
+ export function determineUpdateTargets(folderType, documentsOnly, issuesOnly, wikisOnly) {
12
+ let updateIssues = !wikisOnly && !documentsOnly;
13
+ let updateWikis = !issuesOnly && !documentsOnly;
14
+ let updateDocuments = !issuesOnly && !wikisOnly;
15
+ // フォルダタイプに応じて更新対象を決定
16
+ switch (folderType) {
17
+ case FolderType.DOCUMENT: {
18
+ updateIssues = false;
19
+ updateWikis = false;
20
+ updateDocuments = true;
21
+ break;
22
+ }
23
+ case FolderType.ISSUE: {
24
+ updateIssues = true;
25
+ updateWikis = false;
26
+ updateDocuments = false;
27
+ break;
28
+ }
29
+ case FolderType.WIKI: {
30
+ updateIssues = false;
31
+ updateWikis = true;
32
+ updateDocuments = false;
33
+ break;
34
+ }
35
+ }
36
+ return { updateDocuments, updateIssues, updateWikis };
37
+ }
38
+ export function buildUpdatePlan(settings, flags) {
39
+ const issueIdOrKeys = parseIds(flags.issueIdOrKey);
40
+ const wikiIds = parseIds(flags.wikiId);
41
+ const documentIds = parseIds(flags.documentId);
42
+ // 更新対象の決定
43
+ const targets = determineUpdateTargets(settings.folderType, flags.documentsOnly, flags.issuesOnly, flags.wikisOnly);
44
+ // いずれかのID指定がある場合は「指定した項目のみ再取得」モードとし、
45
+ // ID指定のない種別は更新対象から外す(例: --wikiId のみ指定時は課題・ドキュメントを更新しない)
46
+ const hasTargetedFetch = Boolean(issueIdOrKeys || wikiIds || documentIds);
47
+ if (hasTargetedFetch) {
48
+ if (!issueIdOrKeys)
49
+ targets.updateIssues = false;
50
+ if (!wikiIds)
51
+ targets.updateWikis = false;
52
+ if (!documentIds)
53
+ targets.updateDocuments = false;
54
+ }
55
+ return {
56
+ documentIds,
57
+ // コマンドライン引数と設定ファイルを組み合わせて使用する値を決定
58
+ domain: flags.domain || settings.domain,
59
+ downloadAttachments: flags.downloadAttachments ?? settings.downloadAttachments ?? false,
60
+ folderType: settings.folderType,
61
+ issueIdOrKeys,
62
+ // 設定ファイルからオプションを読み込み、コマンドライン引数で上書き
63
+ issueKeyFileName: flags.issueKeyFileName ?? settings.issueKeyFileName ?? false,
64
+ issueKeyFolder: flags.issueKeyFolder ?? settings.issueKeyFolder ?? false,
65
+ lastUpdated: settings.lastUpdated,
66
+ projectIdOrKey: flags.projectIdOrKey || settings.projectIdOrKey,
67
+ wikiIds,
68
+ ...targets,
69
+ };
70
+ }
@@ -0,0 +1,21 @@
1
+ import { Logger } from '../../../shared/ports.js';
2
+ import { DocumentRepository } from '../../document/domain/document-repository.js';
3
+ import { IssueRepository } from '../../issue/domain/issue-repository.js';
4
+ import { ProjectRepository } from '../../project/domain/project-repository.js';
5
+ import { WikiRepository } from '../../wiki/domain/wiki-repository.js';
6
+ import { UpdateFlags } from '../domain/update-plan.js';
7
+ export type { UpdateFlags } from '../domain/update-plan.js';
8
+ export interface UpdateDeps {
9
+ createRepositories: (connection: {
10
+ apiKey: string;
11
+ domain: string;
12
+ onRateLimitExceeded?: (waitSeconds: number) => void;
13
+ }) => {
14
+ documentRepository: DocumentRepository;
15
+ issueRepository: IssueRepository;
16
+ projectRepository: ProjectRepository;
17
+ wikiRepository: WikiRepository;
18
+ };
19
+ logger: Logger;
20
+ }
21
+ export declare function updateExports(deps: UpdateDeps, rootDir: string, flags: UpdateFlags): Promise<void>;
@@ -0,0 +1,139 @@
1
+ import { resolveApiKey } from '../../../shared/config/env.js';
2
+ import { readYesNo } from '../../../shared/console/prompt.js';
3
+ import { assertDirectoryExists, ensureDirectory } from '../../../shared/storage/markdown-store.js';
4
+ import { exportDocuments } from '../../document/use-case/export-documents.js';
5
+ import { exportIssues } from '../../issue/use-case/export-issues.js';
6
+ import { FolderType } from '../../settings/domain/settings.js';
7
+ import { findSettingsDirectories, loadSettings, updateSettings } from '../../settings/repository/settings-store.js';
8
+ import { exportWikis } from '../../wiki/use-case/export-wikis.js';
9
+ import { buildUpdatePlan } from '../domain/update-plan.js';
10
+ export async function updateExports(deps, rootDir, flags) {
11
+ const { logger } = deps;
12
+ await assertDirectoryExists(rootDir);
13
+ const directories = await findSettingsDirectories(rootDir, (dir) => {
14
+ logger.warn(`ディレクトリの読み取りに失敗しました: ${dir}`);
15
+ });
16
+ if (directories.length === 0) {
17
+ logger.warn(`設定ファイル(backlog-settings.json)が見つかりませんでした: ${rootDir}`);
18
+ return;
19
+ }
20
+ for (const directory of directories) {
21
+ // eslint-disable-next-line no-await-in-loop
22
+ await updateDirectory(deps, directory, flags);
23
+ }
24
+ }
25
+ async function updateDirectory(deps, targetDir, flags) {
26
+ const { logger } = deps;
27
+ const settings = await loadSettings(targetDir);
28
+ const plan = buildUpdatePlan(settings, flags);
29
+ if (!plan.domain) {
30
+ logger.warn(`${targetDir}: ドメインが指定されていません。スキップします。`);
31
+ return;
32
+ }
33
+ if (!plan.projectIdOrKey) {
34
+ logger.warn(`${targetDir}: プロジェクトIDまたはキーが指定されていません。スキップします。`);
35
+ return;
36
+ }
37
+ const apiKey = flags.apiKey ||
38
+ settings.apiKey ||
39
+ resolveApiKey(undefined, () => logger.log('環境変数 BACKLOG_API_KEY からAPIキーを使用します'));
40
+ if (!apiKey) {
41
+ logger.warn(`${targetDir}: APIキーが指定されていません。--apiKey フラグまたは BACKLOG_API_KEY 環境変数で設定してください。`);
42
+ return;
43
+ }
44
+ const confirmed = await confirmUpdate(logger, targetDir, plan, flags.force || false);
45
+ if (!confirmed)
46
+ return;
47
+ await ensureDirectory(targetDir);
48
+ const { documentRepository, issueRepository, projectRepository, wikiRepository } = deps.createRepositories({
49
+ apiKey,
50
+ domain: plan.domain,
51
+ onRateLimitExceeded: (waitSeconds) => logger.log(`レート制限の上限に達しました。${waitSeconds}秒待機します...`),
52
+ });
53
+ const projectId = await projectRepository.resolveProjectId(plan.projectIdOrKey);
54
+ logger.log(`プロジェクトID: ${projectId} を使用します`);
55
+ const saveFullUpdateResult = (folderType) => updateSettings(targetDir, {
56
+ apiKey,
57
+ domain: plan.domain,
58
+ folderType,
59
+ lastUpdated: new Date().toISOString(),
60
+ outputDir: targetDir,
61
+ projectIdOrKey: plan.projectIdOrKey,
62
+ });
63
+ if (plan.updateIssues) {
64
+ logger.log('課題の更新を開始します...');
65
+ await exportIssues({ issueRepository, logger }, {
66
+ count: 100,
67
+ domain: plan.domain,
68
+ downloadAttachments: plan.downloadAttachments,
69
+ issueIdOrKeys: plan.issueIdOrKeys,
70
+ issueKeyFileName: plan.issueKeyFileName,
71
+ issueKeyFolder: plan.issueKeyFolder,
72
+ // ID指定時は該当項目のみを取得するため、lastUpdatedによる絞り込みと最終更新日時の記録は行わない
73
+ lastUpdated: plan.issueIdOrKeys ? undefined : plan.lastUpdated,
74
+ outputDir: targetDir,
75
+ projectId,
76
+ });
77
+ if (!plan.issueIdOrKeys) {
78
+ await saveFullUpdateResult(FolderType.ISSUE);
79
+ }
80
+ logger.log('課題の更新が完了しました');
81
+ }
82
+ if (plan.updateWikis) {
83
+ logger.log('Wikiの更新を開始します...');
84
+ await exportWikis({ logger, wikiRepository }, {
85
+ domain: plan.domain,
86
+ downloadAttachments: plan.downloadAttachments,
87
+ lastUpdated: plan.wikiIds ? undefined : plan.lastUpdated,
88
+ outputDir: targetDir,
89
+ projectIdOrKey: plan.projectIdOrKey,
90
+ wikiIds: plan.wikiIds,
91
+ });
92
+ if (!plan.wikiIds) {
93
+ await saveFullUpdateResult(FolderType.WIKI);
94
+ }
95
+ logger.log('Wikiの更新が完了しました');
96
+ }
97
+ if (plan.updateDocuments) {
98
+ logger.log('ドキュメントの更新を開始します...');
99
+ await exportDocuments({ documentRepository, logger }, {
100
+ documentIds: plan.documentIds,
101
+ domain: plan.domain,
102
+ downloadAttachments: plan.downloadAttachments,
103
+ lastUpdated: plan.documentIds ? undefined : plan.lastUpdated,
104
+ outputDir: targetDir,
105
+ projectId,
106
+ projectIdOrKey: plan.projectIdOrKey,
107
+ });
108
+ if (!plan.documentIds) {
109
+ await saveFullUpdateResult(FolderType.DOCUMENT);
110
+ }
111
+ logger.log('ドキュメントの更新が完了しました');
112
+ }
113
+ logger.log(`${targetDir} の更新が完了しました!`);
114
+ }
115
+ async function confirmUpdate(logger, targetDir, plan, force) {
116
+ if (force)
117
+ return true;
118
+ logger.log(`以下の設定で更新を実行します:`);
119
+ logger.log(`- ディレクトリ: ${targetDir}`);
120
+ logger.log(`- ドメイン: ${plan.domain}`);
121
+ logger.log(`- プロジェクト: ${plan.projectIdOrKey}`);
122
+ if (plan.folderType) {
123
+ logger.log(`- フォルダタイプ: ${plan.folderType}`);
124
+ }
125
+ const updateTargets = [];
126
+ if (plan.updateIssues)
127
+ updateTargets.push('課題');
128
+ if (plan.updateWikis)
129
+ updateTargets.push('Wiki');
130
+ if (plan.updateDocuments)
131
+ updateTargets.push('ドキュメント');
132
+ logger.log(`- 更新対象: ${updateTargets.join('・')}`);
133
+ logger.log('更新を実行しますか? (y/n)');
134
+ const response = await readYesNo();
135
+ if (!response) {
136
+ logger.log('更新をキャンセルしました');
137
+ }
138
+ return response;
139
+ }
@@ -0,0 +1,9 @@
1
+ import { WikiSummary } from './wiki.js';
2
+ export interface WikiSelection {
3
+ reason: 'all' | 'ids' | 'updatedSince';
4
+ wikis: WikiSummary[];
5
+ }
6
+ export declare function selectWikisToExport(wikis: WikiSummary[], options: {
7
+ lastUpdated?: string;
8
+ wikiIds?: string[];
9
+ }): WikiSelection;
@@ -0,0 +1,11 @@
1
+ export function selectWikisToExport(wikis, options) {
2
+ if (options.wikiIds && options.wikiIds.length > 0) {
3
+ const wikiIdSet = new Set(options.wikiIds.map(String));
4
+ return { reason: 'ids', wikis: wikis.filter((wiki) => wikiIdSet.has(String(wiki.id))) };
5
+ }
6
+ if (options.lastUpdated) {
7
+ const lastUpdatedDate = new Date(options.lastUpdated);
8
+ return { reason: 'updatedSince', wikis: wikis.filter((wiki) => new Date(wiki.updated) > lastUpdatedDate) };
9
+ }
10
+ return { reason: 'all', wikis };
11
+ }
@@ -0,0 +1,6 @@
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;
@@ -0,0 +1,19 @@
1
+ import { escapeLinkText } from '../../../shared/attachment.js';
2
+ import { wrapBody } from '../../../shared/markdown/body-marker.js';
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 || '(内容なし)')}`;
19
+ }
@@ -0,0 +1,7 @@
1
+ import { ExpectedPaths } from '../../prune/domain/expected-paths.js';
2
+ import { WikiAttachment } from './wiki.js';
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;
6
+ export declare function wikiUrl(domain: string, wikiId: string): string;
7
+ export declare function buildWikiExpectedPaths(wikiNames: string[]): ExpectedPaths;
@@ -0,0 +1,37 @@
1
+ import path from 'node:path';
2
+ import { attachmentFileName, encodeLinkDestination } from '../../../shared/attachment.js';
3
+ import { backlogOrigin } from '../../../shared/backlog-url.js';
4
+ import { sanitizeWikiFileName } from '../../../shared/file-name.js';
5
+ export function wikiRelativePath(wikiName) {
6
+ return `${sanitizeWikiFileName(wikiName)}.md`;
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
+ }
19
+ export function wikiUrl(domain, wikiId) {
20
+ return `${backlogOrigin(domain)}/alias/wiki/${wikiId}`;
21
+ }
22
+ // 比較相手のpath.relativeはOS標準の区切り文字を返すため、path.normalizeで揃える(Windows対策)
23
+ export function buildWikiExpectedPaths(wikiNames) {
24
+ const expectedFiles = new Set();
25
+ const expectedDirs = new Set();
26
+ for (const name of wikiNames) {
27
+ const relativePath = path.normalize(wikiRelativePath(name)).normalize('NFC');
28
+ expectedFiles.add(relativePath);
29
+ // "親/子.md" のような名前では親ディレクトリも「あるべきディレクトリ」として登録する
30
+ let dir = path.dirname(relativePath);
31
+ while (dir && dir !== '.') {
32
+ expectedDirs.add(dir.normalize('NFC'));
33
+ dir = path.dirname(dir);
34
+ }
35
+ }
36
+ return { expectedDirs, expectedFiles };
37
+ }
@@ -0,0 +1,6 @@
1
+ import { WikiDetail, WikiSummary } from './wiki.js';
2
+ export interface WikiRepository {
3
+ downloadAttachment(wikiId: string, attachmentId: number): Promise<ArrayBuffer>;
4
+ fetchDetail(wikiId: string, projectIdOrKey: string): Promise<WikiDetail>;
5
+ fetchWikis(projectIdOrKey: string): Promise<WikiSummary[]>;
6
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,16 @@
1
+ export interface WikiSummary {
2
+ id: string;
3
+ name: string;
4
+ updated: string;
5
+ }
6
+ export interface WikiAttachment {
7
+ id: number;
8
+ name: string;
9
+ size: number;
10
+ }
11
+ export interface WikiDetail {
12
+ attachments?: WikiAttachment[];
13
+ content?: string;
14
+ id: string;
15
+ name: string;
16
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import { BacklogHttpClient } from '../../../shared/backlog/http-client.js';
2
+ import { WikiRepository } from '../domain/wiki-repository.js';
3
+ export declare function newBacklogWikiRepository(client: BacklogHttpClient): WikiRepository;
@@ -0,0 +1,13 @@
1
+ export function newBacklogWikiRepository(client) {
2
+ return {
3
+ async downloadAttachment(wikiId, attachmentId) {
4
+ return client.getBinary(`/wikis/${wikiId}/attachments/${attachmentId}`);
5
+ },
6
+ async fetchDetail(wikiId, projectIdOrKey) {
7
+ return client.getJson(`/wikis/${wikiId}`, { projectIdOrKey });
8
+ },
9
+ async fetchWikis(projectIdOrKey) {
10
+ return client.getJson('/wikis', { projectIdOrKey });
11
+ },
12
+ };
13
+ }
@@ -0,0 +1,15 @@
1
+ import { Logger } from '../../../shared/ports.js';
2
+ import { WikiRepository } from '../domain/wiki-repository.js';
3
+ export interface ExportWikisDeps {
4
+ logger: Logger;
5
+ wikiRepository: WikiRepository;
6
+ }
7
+ export interface ExportWikisOptions {
8
+ domain: string;
9
+ downloadAttachments?: boolean;
10
+ lastUpdated?: string;
11
+ outputDir: string;
12
+ projectIdOrKey: string;
13
+ wikiIds?: string[];
14
+ }
15
+ export declare function exportWikis(deps: ExportWikisDeps, options: ExportWikisOptions): Promise<void>;
@@ -0,0 +1,71 @@
1
+ import path from 'node:path';
2
+ import { writeProgress } from '../../../shared/console/progress.js';
3
+ import { fileSize, writeBinaryFile, writeMarkdownFile } from '../../../shared/storage/markdown-store.js';
4
+ import { appendLog } from '../../../shared/storage/update-log.js';
5
+ import { selectWikisToExport } from '../domain/wiki-filter.js';
6
+ import { buildWikiMarkdown } from '../domain/wiki-markdown.js';
7
+ import { wikiAttachmentMarkdownLink, wikiAttachmentRelativePath, wikiRelativePath, wikiUrl } from '../domain/wiki-path.js';
8
+ export async function exportWikis(deps, options) {
9
+ const { logger, wikiRepository } = deps;
10
+ logger.log('Wikiの取得を開始します...');
11
+ logger.log('Wiki一覧を取得しています...');
12
+ const allWikis = await wikiRepository.fetchWikis(options.projectIdOrKey);
13
+ logger.log(`${allWikis.length}件のWikiが見つかりました。`);
14
+ const { reason, wikis } = selectWikisToExport(allWikis, options);
15
+ if (reason === 'ids') {
16
+ logger.log(`指定された${wikis.length}件のWikiを処理します。`);
17
+ }
18
+ else if (reason === 'updatedSince') {
19
+ logger.log(`前回の更新日時(${options.lastUpdated})以降に更新された${wikis.length}件のWikiを処理します。`);
20
+ }
21
+ if (wikis.length === 0) {
22
+ logger.log('更新が必要なWikiはありません。');
23
+ return;
24
+ }
25
+ logger.log('Wiki詳細を取得しています...');
26
+ /* eslint-disable no-await-in-loop */
27
+ for (const [index, wiki] of wikis.entries()) {
28
+ try {
29
+ writeProgress(`Wikiを取得中... (${index + 1}/${wikis.length}件)`);
30
+ const wikiDetail = await wikiRepository.fetchDetail(wiki.id, options.projectIdOrKey);
31
+ const filePath = path.join(options.outputDir, wikiRelativePath(wiki.name));
32
+ const backlogWikiUrl = wikiUrl(options.domain, wiki.id);
33
+ const attachmentLinks = options.downloadAttachments
34
+ ? await downloadWikiAttachments(deps, wiki, wikiDetail.attachments ?? [], options.outputDir)
35
+ : undefined;
36
+ await writeMarkdownFile(filePath, buildWikiMarkdown(wiki.name, backlogWikiUrl, wikiDetail.content || '', {
37
+ items: wikiDetail.attachments,
38
+ localLinks: attachmentLinks,
39
+ }));
40
+ await appendLog(options.outputDir, `Wiki「${wiki.name}」を更新しました: ${backlogWikiUrl}`);
41
+ writeProgress(`Wikiを保存中... (${index + 1}/${wikis.length}件)`);
42
+ }
43
+ catch (error) {
44
+ logger.warn(`Wiki ${wiki.name} の取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
45
+ }
46
+ }
47
+ /* eslint-enable no-await-in-loop */
48
+ logger.log('\nWikiのダウンロードが完了しました!');
49
+ }
50
+ // 保存できた添付のみリンク化する。個々の失敗は警告に留め、Wiki本体の保存は続行する
51
+ async function downloadWikiAttachments(deps, wiki, attachments, outputDir) {
52
+ const links = new Map();
53
+ for (const attachment of attachments) {
54
+ const absolutePath = path.join(outputDir, wikiAttachmentRelativePath(wiki.name, attachment));
55
+ try {
56
+ // 添付IDは不変のため、サイズの一致するファイルが既にあれば再ダウンロードしない
57
+ // eslint-disable-next-line no-await-in-loop
58
+ if ((await fileSize(absolutePath)) !== attachment.size) {
59
+ // eslint-disable-next-line no-await-in-loop
60
+ const data = await deps.wikiRepository.downloadAttachment(wiki.id, attachment.id);
61
+ // eslint-disable-next-line no-await-in-loop
62
+ await writeBinaryFile(absolutePath, data);
63
+ }
64
+ links.set(attachment.id, wikiAttachmentMarkdownLink(wiki.name, attachment));
65
+ }
66
+ catch (error) {
67
+ deps.logger.warn(`Wiki「${wiki.name}」の添付ファイル「${attachment.name}」の取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
68
+ }
69
+ }
70
+ return links;
71
+ }
@@ -0,0 +1,8 @@
1
+ export interface AttachmentRef {
2
+ id: number;
3
+ name: string;
4
+ }
5
+ export declare function attachmentFileName(attachment: AttachmentRef): string;
6
+ export declare function encodeLinkDestination(linkPath: string): string;
7
+ export declare function escapeLinkText(name: string): string;
8
+ export declare function rewriteInlineImages(text: string, attachments: AttachmentRef[] | undefined, localLinks?: Map<number, string>): string;
@@ -0,0 +1,37 @@
1
+ import { sanitizeAttachmentFileName } from './file-name.js';
2
+ // 添付IDを前置して同名添付の衝突を防ぎ、存在チェックだけでDL済み判定できるようにする
3
+ export function attachmentFileName(attachment) {
4
+ return `${attachment.id}_${sanitizeAttachmentFileName(attachment.name)}`;
5
+ }
6
+ // Markdownリンク先の丸括弧はインラインリンクを壊すためエンコードする
7
+ export function encodeLinkDestination(linkPath) {
8
+ return linkPath.replaceAll('(', '%28').replaceAll(')', '%29');
9
+ }
10
+ // ファイル名中の角括弧はリンク構文を壊すためエスケープする
11
+ export function escapeLinkText(name) {
12
+ return name.replaceAll('[', String.raw `\[`).replaceAll(']', String.raw `\]`);
13
+ }
14
+ // Backlogの添付画像インライン記法(Markdown拡張の ![alt][ファイル名] とBacklog記法の #image(ファイル名))を
15
+ // ダウンロード済みファイルへのローカルリンクに変換する。未ダウンロードの参照は壊さずそのまま残す
16
+ export function rewriteInlineImages(text, attachments, localLinks) {
17
+ if (!text || !attachments || attachments.length === 0 || !localLinks || localLinks.size === 0) {
18
+ return text;
19
+ }
20
+ // 同名添付が複数ある場合は記法から特定できないため先勝ちで解決する。
21
+ // 記法内のファイル名はNFD(macOSからのD&D等)で入ることがあるため、照合はNFC正規化で行う
22
+ const linkByName = new Map();
23
+ for (const attachment of attachments) {
24
+ const link = localLinks.get(attachment.id);
25
+ const name = attachment.name.normalize('NFC');
26
+ if (link && !linkByName.has(name)) {
27
+ linkByName.set(name, link);
28
+ }
29
+ }
30
+ const toLocalImage = (match, name) => {
31
+ const link = linkByName.get(name.normalize('NFC'));
32
+ return link ? `![${escapeLinkText(name)}](${link})` : match;
33
+ };
34
+ return text
35
+ .replaceAll(/!\[[^\]]*\]\[([^\]]+)\]/g, toLocalImage)
36
+ .replaceAll(/#image\(([^)]+)\)/g, toLocalImage);
37
+ }
@@ -0,0 +1,20 @@
1
+ export declare class HttpError extends Error {
2
+ readonly status: number;
3
+ constructor(status: number, statusText: string, url: string);
4
+ }
5
+ export declare function rateLimitWaitMs(resetHeader: null | string, nowMs: number): number;
6
+ export declare class BacklogHttpClient {
7
+ private readonly apiKey;
8
+ private readonly baseUrl;
9
+ private readonly onRateLimitExceeded?;
10
+ constructor(options: {
11
+ apiKey: string;
12
+ domain: string;
13
+ onRateLimitExceeded?: (waitSeconds: number) => void;
14
+ });
15
+ getBinary(pathname: string, params?: Record<string, string>): Promise<ArrayBuffer>;
16
+ getJson<T>(pathname: string, params?: Record<string, string>): Promise<T>;
17
+ private fetchWithRetry;
18
+ private maskApiKey;
19
+ private requestUrl;
20
+ }