backlog-exporter 1.0.0 → 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.
- package/README.md +23 -1
- package/dist/commands/all/index.d.ts +1 -0
- package/dist/commands/all/index.js +8 -2
- package/dist/commands/document/index.d.ts +1 -0
- package/dist/commands/document/index.js +9 -2
- package/dist/commands/issue/index.d.ts +1 -0
- package/dist/commands/issue/index.js +12 -2
- package/dist/commands/update/index.d.ts +1 -0
- package/dist/commands/update/index.js +5 -0
- package/dist/commands/wiki/index.d.ts +1 -0
- package/dist/commands/wiki/index.js +9 -2
- package/dist/composition/backlog-repositories.d.ts +1 -1
- package/dist/composition/backlog-repositories.js +1 -1
- package/dist/modules/all/use-case/export-all.d.ts +1 -0
- package/dist/modules/all/use-case/export-all.js +7 -1
- package/dist/modules/document/domain/document-markdown.d.ts +1 -1
- package/dist/modules/document/domain/document-markdown.js +6 -3
- package/dist/modules/document/domain/document-path.d.ts +8 -0
- package/dist/modules/document/domain/document-path.js +9 -0
- package/dist/modules/document/domain/document-repository.d.ts +1 -0
- package/dist/modules/document/repository/backlog-document-repository.js +4 -0
- package/dist/modules/document/use-case/export-documents.d.ts +1 -0
- package/dist/modules/document/use-case/export-documents.js +28 -3
- package/dist/modules/issue/domain/issue-markdown.d.ts +4 -3
- package/dist/modules/issue/domain/issue-markdown.js +23 -8
- package/dist/modules/issue/domain/issue-path.d.ts +12 -1
- package/dist/modules/issue/domain/issue-path.js +12 -0
- package/dist/modules/issue/domain/issue-repository.d.ts +1 -0
- package/dist/modules/issue/domain/issue.d.ts +6 -0
- package/dist/modules/issue/repository/backlog-issue-repository.js +3 -0
- package/dist/modules/issue/use-case/export-issues.d.ts +1 -0
- package/dist/modules/issue/use-case/export-issues.js +29 -3
- package/dist/modules/prune/repository/prune-walker.js +5 -0
- package/dist/modules/prune/use-case/prune-directories.d.ts +1 -1
- package/dist/modules/prune/use-case/prune-directories.js +1 -1
- package/dist/modules/settings/domain/settings.d.ts +1 -0
- package/dist/modules/update/domain/update-plan.d.ts +2 -0
- package/dist/modules/update/domain/update-plan.js +1 -0
- package/dist/modules/update/use-case/update-exports.d.ts +1 -1
- package/dist/modules/update/use-case/update-exports.js +4 -1
- package/dist/modules/wiki/domain/wiki-markdown.d.ts +6 -1
- package/dist/modules/wiki/domain/wiki-markdown.js +17 -2
- package/dist/modules/wiki/domain/wiki-path.d.ts +3 -0
- package/dist/modules/wiki/domain/wiki-path.js +12 -0
- package/dist/modules/wiki/domain/wiki-repository.d.ts +1 -0
- package/dist/modules/wiki/domain/wiki.d.ts +6 -0
- package/dist/modules/wiki/repository/backlog-wiki-repository.js +3 -0
- package/dist/modules/wiki/use-case/export-wikis.d.ts +1 -0
- package/dist/modules/wiki/use-case/export-wikis.js +31 -3
- package/dist/shared/attachment.d.ts +8 -0
- package/dist/shared/attachment.js +37 -0
- package/dist/shared/backlog/http-client.d.ts +5 -2
- package/dist/shared/backlog/http-client.js +41 -10
- package/dist/shared/backlog/sleep.d.ts +1 -0
- package/dist/shared/backlog/sleep.js +3 -0
- package/dist/shared/console/progress.js +8 -1
- package/dist/shared/file-name.d.ts +1 -0
- package/dist/shared/file-name.js +11 -0
- package/dist/shared/storage/markdown-store.d.ts +2 -0
- package/dist/shared/storage/markdown-store.js +11 -0
- package/oclif.manifest.json +43 -2
- package/package.json +1 -1
- package/dist/shared/backlog/rate-limiter.d.ts +0 -9
- package/dist/shared/backlog/rate-limiter.js +0 -25
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import { attachmentFileName, encodeLinkDestination } from '../../../shared/attachment.js';
|
|
2
3
|
import { backlogOrigin } from '../../../shared/backlog-url.js';
|
|
3
4
|
import { sanitizeFileName } from '../../../shared/file-name.js';
|
|
4
5
|
export function issueFileName(issue, useIssueKey) {
|
|
@@ -11,6 +12,17 @@ export function issueRelativeDir(issue, useIssueKeyFolder) {
|
|
|
11
12
|
export function issueRelativePath(issue, options) {
|
|
12
13
|
return path.join(issueRelativeDir(issue, options.issueKeyFolder ?? false), issueFileName(issue, options.issueKeyFileName ?? false));
|
|
13
14
|
}
|
|
15
|
+
// issueKeyFolderありなら課題フォルダ直下のattachments/、なしなら年フォルダのattachments/{課題キー}/
|
|
16
|
+
function attachmentDirSegments(issueKey, useIssueKeyFolder) {
|
|
17
|
+
return useIssueKeyFolder ? ['attachments'] : ['attachments', issueKey];
|
|
18
|
+
}
|
|
19
|
+
export function attachmentRelativePath(issue, attachment, options) {
|
|
20
|
+
return path.join(issueRelativeDir(issue, options.issueKeyFolder ?? false), ...attachmentDirSegments(issue.issueKey, options.issueKeyFolder ?? false), attachmentFileName(attachment));
|
|
21
|
+
}
|
|
22
|
+
export function attachmentMarkdownLink(issue, attachment, options) {
|
|
23
|
+
const segments = attachmentDirSegments(issue.issueKey, options.issueKeyFolder ?? false);
|
|
24
|
+
return encodeLinkDestination(['.', ...segments, attachmentFileName(attachment)].join('/'));
|
|
25
|
+
}
|
|
14
26
|
export function issueUrl(domain, issueKey) {
|
|
15
27
|
return `${backlogOrigin(domain)}/view/${issueKey}`;
|
|
16
28
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Issue, IssueComment } from './issue.js';
|
|
2
2
|
export interface IssueRepository {
|
|
3
|
+
downloadAttachment(issueIdOrKey: string, attachmentId: number): Promise<ArrayBuffer>;
|
|
3
4
|
fetchAllComments(issueKey: string): Promise<IssueComment[]>;
|
|
4
5
|
fetchByIdOrKey(issueIdOrKey: string): Promise<Issue>;
|
|
5
6
|
fetchPage(options: {
|
|
@@ -3,6 +3,7 @@ export interface Issue {
|
|
|
3
3
|
id: number;
|
|
4
4
|
name: string;
|
|
5
5
|
};
|
|
6
|
+
attachments?: IssueAttachment[];
|
|
6
7
|
created: string;
|
|
7
8
|
customFields: CustomField[];
|
|
8
9
|
description: string;
|
|
@@ -25,6 +26,11 @@ export interface Issue {
|
|
|
25
26
|
summary: string;
|
|
26
27
|
updated: string;
|
|
27
28
|
}
|
|
29
|
+
export interface IssueAttachment {
|
|
30
|
+
id: number;
|
|
31
|
+
name: string;
|
|
32
|
+
size: number;
|
|
33
|
+
}
|
|
28
34
|
export interface CustomField {
|
|
29
35
|
id: number;
|
|
30
36
|
name: string;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export function newBacklogIssueRepository(client) {
|
|
2
2
|
return {
|
|
3
|
+
async downloadAttachment(issueIdOrKey, attachmentId) {
|
|
4
|
+
return client.getBinary(`/issues/${issueIdOrKey}/attachments/${attachmentId}`);
|
|
5
|
+
},
|
|
3
6
|
async fetchAllComments(issueKey) {
|
|
4
7
|
const allComments = [];
|
|
5
8
|
let minId;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { writeProgress } from '../../../shared/console/progress.js';
|
|
3
|
-
import { writeMarkdownFile } from '../../../shared/storage/markdown-store.js';
|
|
3
|
+
import { fileSize, writeBinaryFile, writeMarkdownFile } from '../../../shared/storage/markdown-store.js';
|
|
4
4
|
import { appendLog } from '../../../shared/storage/update-log.js';
|
|
5
5
|
import { filterIssuesUpdatedSince } from '../domain/issue-filter.js';
|
|
6
6
|
import { buildIssueMarkdown } from '../domain/issue-markdown.js';
|
|
7
|
-
import { issueRelativePath, issueUrl } from '../domain/issue-path.js';
|
|
7
|
+
import { attachmentMarkdownLink, attachmentRelativePath, issueRelativePath, issueUrl } from '../domain/issue-path.js';
|
|
8
8
|
export async function exportIssues(deps, options) {
|
|
9
9
|
const { logger } = deps;
|
|
10
10
|
logger.log('課題の取得を開始します...');
|
|
@@ -81,7 +81,33 @@ async function saveIssue(deps, issue, options) {
|
|
|
81
81
|
catch (error) {
|
|
82
82
|
deps.logger.warn(`課題 ${issue.issueKey} のコメント取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
|
|
83
83
|
}
|
|
84
|
+
const attachmentLinks = options.downloadAttachments
|
|
85
|
+
? await downloadIssueAttachments(deps, issue, options)
|
|
86
|
+
: undefined;
|
|
84
87
|
const filePath = path.join(options.outputDir, issueRelativePath(issue, options));
|
|
85
|
-
await writeMarkdownFile(filePath, buildIssueMarkdown(issue, comments, backlogIssueUrl));
|
|
88
|
+
await writeMarkdownFile(filePath, buildIssueMarkdown(issue, comments, backlogIssueUrl, attachmentLinks));
|
|
86
89
|
await appendLog(options.outputDir, `課題「${issue.summary}」を更新しました: ${backlogIssueUrl}`);
|
|
87
90
|
}
|
|
91
|
+
// 保存できた添付のみリンク化する。個々の失敗は警告に留め、課題本体の保存は続行する
|
|
92
|
+
async function downloadIssueAttachments(deps, issue, options) {
|
|
93
|
+
const links = new Map();
|
|
94
|
+
for (const attachment of issue.attachments ?? []) {
|
|
95
|
+
const absolutePath = path.join(options.outputDir, attachmentRelativePath(issue, attachment, options));
|
|
96
|
+
try {
|
|
97
|
+
// 添付IDは不変のため、サイズの一致するファイルが既にあれば再ダウンロードしない
|
|
98
|
+
// (サイズ不一致は過去の中断等による破損とみなして取得し直す)
|
|
99
|
+
// eslint-disable-next-line no-await-in-loop
|
|
100
|
+
if ((await fileSize(absolutePath)) !== attachment.size) {
|
|
101
|
+
// eslint-disable-next-line no-await-in-loop
|
|
102
|
+
const data = await deps.issueRepository.downloadAttachment(issue.issueKey, attachment.id);
|
|
103
|
+
// eslint-disable-next-line no-await-in-loop
|
|
104
|
+
await writeBinaryFile(absolutePath, data);
|
|
105
|
+
}
|
|
106
|
+
links.set(attachment.id, attachmentMarkdownLink(issue, attachment, options));
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
deps.logger.warn(`課題 ${issue.issueKey} の添付ファイル「${attachment.name}」の取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return links;
|
|
113
|
+
}
|
|
@@ -17,6 +17,11 @@ export async function pruneLocalMarkdownFiles(options) {
|
|
|
17
17
|
const fullPath = path.join(dir, entry.name);
|
|
18
18
|
const relativePath = path.relative(options.outputDir, fullPath).normalize('NFC');
|
|
19
19
|
if (entry.isDirectory()) {
|
|
20
|
+
// 添付ファイルの保存先。Wiki・ドキュメントのpruneは一覧APIしか呼ばず添付の期待パスを持てないため、
|
|
21
|
+
// attachments/ 配下は走査せず丸ごと保護する(.md形式の添付の誤削除防止)
|
|
22
|
+
if (entry.name === 'attachments') {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
20
25
|
await pruneDirectory(fullPath);
|
|
21
26
|
const remaining = await fs.readdir(fullPath);
|
|
22
27
|
if (remaining.length === 0 && !expectedDirs.has(relativePath.toLowerCase())) {
|
|
@@ -12,7 +12,7 @@ export interface PruneDeps {
|
|
|
12
12
|
createRepositories: (connection: {
|
|
13
13
|
apiKey: string;
|
|
14
14
|
domain: string;
|
|
15
|
-
|
|
15
|
+
onRateLimitExceeded?: (waitSeconds: number) => void;
|
|
16
16
|
}) => {
|
|
17
17
|
documentRepository: DocumentRepository;
|
|
18
18
|
issueRepository: IssueRepository;
|
|
@@ -53,7 +53,7 @@ async function pruneDirectory(deps, targetDir, flags, confirmDirectory) {
|
|
|
53
53
|
const { documentRepository, issueRepository, projectRepository, wikiRepository } = deps.createRepositories({
|
|
54
54
|
apiKey,
|
|
55
55
|
domain,
|
|
56
|
-
|
|
56
|
+
onRateLimitExceeded: (waitSeconds) => logger.log(`レート制限の上限に達しました。${waitSeconds}秒待機します...`),
|
|
57
57
|
});
|
|
58
58
|
if (target === 'wiki') {
|
|
59
59
|
await pruneWikis({ logger, wikiRepository }, { outputDir: targetDir, projectIdOrKey });
|
|
@@ -4,6 +4,7 @@ export interface UpdateFlags {
|
|
|
4
4
|
documentId?: string;
|
|
5
5
|
documentsOnly?: boolean;
|
|
6
6
|
domain?: string;
|
|
7
|
+
downloadAttachments?: boolean;
|
|
7
8
|
force?: boolean;
|
|
8
9
|
issueIdOrKey?: string;
|
|
9
10
|
issueKeyFileName?: boolean;
|
|
@@ -16,6 +17,7 @@ export interface UpdateFlags {
|
|
|
16
17
|
export interface UpdatePlan {
|
|
17
18
|
documentIds?: string[];
|
|
18
19
|
domain?: string;
|
|
20
|
+
downloadAttachments: boolean;
|
|
19
21
|
folderType?: FolderType;
|
|
20
22
|
issueIdOrKeys?: string[];
|
|
21
23
|
issueKeyFileName: boolean;
|
|
@@ -56,6 +56,7 @@ export function buildUpdatePlan(settings, flags) {
|
|
|
56
56
|
documentIds,
|
|
57
57
|
// コマンドライン引数と設定ファイルを組み合わせて使用する値を決定
|
|
58
58
|
domain: flags.domain || settings.domain,
|
|
59
|
+
downloadAttachments: flags.downloadAttachments ?? settings.downloadAttachments ?? false,
|
|
59
60
|
folderType: settings.folderType,
|
|
60
61
|
issueIdOrKeys,
|
|
61
62
|
// 設定ファイルからオプションを読み込み、コマンドライン引数で上書き
|
|
@@ -9,7 +9,7 @@ export interface UpdateDeps {
|
|
|
9
9
|
createRepositories: (connection: {
|
|
10
10
|
apiKey: string;
|
|
11
11
|
domain: string;
|
|
12
|
-
|
|
12
|
+
onRateLimitExceeded?: (waitSeconds: number) => void;
|
|
13
13
|
}) => {
|
|
14
14
|
documentRepository: DocumentRepository;
|
|
15
15
|
issueRepository: IssueRepository;
|
|
@@ -48,7 +48,7 @@ async function updateDirectory(deps, targetDir, flags) {
|
|
|
48
48
|
const { documentRepository, issueRepository, projectRepository, wikiRepository } = deps.createRepositories({
|
|
49
49
|
apiKey,
|
|
50
50
|
domain: plan.domain,
|
|
51
|
-
|
|
51
|
+
onRateLimitExceeded: (waitSeconds) => logger.log(`レート制限の上限に達しました。${waitSeconds}秒待機します...`),
|
|
52
52
|
});
|
|
53
53
|
const projectId = await projectRepository.resolveProjectId(plan.projectIdOrKey);
|
|
54
54
|
logger.log(`プロジェクトID: ${projectId} を使用します`);
|
|
@@ -65,6 +65,7 @@ async function updateDirectory(deps, targetDir, flags) {
|
|
|
65
65
|
await exportIssues({ issueRepository, logger }, {
|
|
66
66
|
count: 100,
|
|
67
67
|
domain: plan.domain,
|
|
68
|
+
downloadAttachments: plan.downloadAttachments,
|
|
68
69
|
issueIdOrKeys: plan.issueIdOrKeys,
|
|
69
70
|
issueKeyFileName: plan.issueKeyFileName,
|
|
70
71
|
issueKeyFolder: plan.issueKeyFolder,
|
|
@@ -82,6 +83,7 @@ async function updateDirectory(deps, targetDir, flags) {
|
|
|
82
83
|
logger.log('Wikiの更新を開始します...');
|
|
83
84
|
await exportWikis({ logger, wikiRepository }, {
|
|
84
85
|
domain: plan.domain,
|
|
86
|
+
downloadAttachments: plan.downloadAttachments,
|
|
85
87
|
lastUpdated: plan.wikiIds ? undefined : plan.lastUpdated,
|
|
86
88
|
outputDir: targetDir,
|
|
87
89
|
projectIdOrKey: plan.projectIdOrKey,
|
|
@@ -97,6 +99,7 @@ async function updateDirectory(deps, targetDir, flags) {
|
|
|
97
99
|
await exportDocuments({ documentRepository, logger }, {
|
|
98
100
|
documentIds: plan.documentIds,
|
|
99
101
|
domain: plan.domain,
|
|
102
|
+
downloadAttachments: plan.downloadAttachments,
|
|
100
103
|
lastUpdated: plan.documentIds ? undefined : plan.lastUpdated,
|
|
101
104
|
outputDir: targetDir,
|
|
102
105
|
projectId,
|
|
@@ -1 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
import { WikiAttachment } from './wiki.js';
|
|
2
|
+
export interface WikiAttachmentsView {
|
|
3
|
+
items?: WikiAttachment[];
|
|
4
|
+
localLinks?: Map<number, string>;
|
|
5
|
+
}
|
|
6
|
+
export declare function buildWikiMarkdown(wikiName: string, backlogWikiUrl: string, content: string, attachments?: WikiAttachmentsView): string;
|
|
@@ -1,4 +1,19 @@
|
|
|
1
|
+
import { escapeLinkText } from '../../../shared/attachment.js';
|
|
1
2
|
import { wrapBody } from '../../../shared/markdown/body-marker.js';
|
|
2
|
-
|
|
3
|
-
|
|
3
|
+
// ダウンロード済みの添付はローカルへの相対リンク付き、未ダウンロードはメタデータのみを出力する
|
|
4
|
+
function buildAttachmentsSection(attachments) {
|
|
5
|
+
if (!attachments.items || attachments.items.length === 0) {
|
|
6
|
+
return '';
|
|
7
|
+
}
|
|
8
|
+
const lines = attachments.items.map((attachment) => {
|
|
9
|
+
const fileSize = `${(attachment.size / 1024).toFixed(1)} KB`;
|
|
10
|
+
const link = attachments.localLinks?.get(attachment.id);
|
|
11
|
+
return link ? `- [${escapeLinkText(attachment.name)}](${link}) (${fileSize})` : `- ${attachment.name} (${fileSize})`;
|
|
12
|
+
});
|
|
13
|
+
return `## 添付ファイル\n\n${lines.join('\n')}\n\n`;
|
|
14
|
+
}
|
|
15
|
+
// 本文はBacklogの原文を維持する(添付参照記法の書き換えは行わない)
|
|
16
|
+
export function buildWikiMarkdown(wikiName, backlogWikiUrl, content, attachments = {}) {
|
|
17
|
+
const attachmentsSection = buildAttachmentsSection(attachments);
|
|
18
|
+
return `# ${wikiName}\n\n[Backlog Wiki Link](${backlogWikiUrl})\n\n${attachmentsSection}${wrapBody(content || '(内容なし)')}`;
|
|
4
19
|
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { ExpectedPaths } from '../../prune/domain/expected-paths.js';
|
|
2
|
+
import { WikiAttachment } from './wiki.js';
|
|
2
3
|
export declare function wikiRelativePath(wikiName: string): string;
|
|
4
|
+
export declare function wikiAttachmentRelativePath(wikiName: string, attachment: Pick<WikiAttachment, 'id' | 'name'>): string;
|
|
5
|
+
export declare function wikiAttachmentMarkdownLink(wikiName: string, attachment: Pick<WikiAttachment, 'id' | 'name'>): string;
|
|
3
6
|
export declare function wikiUrl(domain: string, wikiId: string): string;
|
|
4
7
|
export declare function buildWikiExpectedPaths(wikiNames: string[]): ExpectedPaths;
|
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import { attachmentFileName, encodeLinkDestination } from '../../../shared/attachment.js';
|
|
2
3
|
import { backlogOrigin } from '../../../shared/backlog-url.js';
|
|
3
4
|
import { sanitizeWikiFileName } from '../../../shared/file-name.js';
|
|
4
5
|
export function wikiRelativePath(wikiName) {
|
|
5
6
|
return `${sanitizeWikiFileName(wikiName)}.md`;
|
|
6
7
|
}
|
|
8
|
+
// 添付の保存先はMarkdownと同じディレクトリの attachments/{Wikiファイル名}/ 配下。
|
|
9
|
+
// Wiki名変更時は再ダウンロードになるが、Markdown本体({Wiki名}.md)と同じ挙動で閲覧性を優先する
|
|
10
|
+
function wikiAttachmentDirName(wikiName) {
|
|
11
|
+
return path.basename(wikiRelativePath(wikiName), '.md');
|
|
12
|
+
}
|
|
13
|
+
export function wikiAttachmentRelativePath(wikiName, attachment) {
|
|
14
|
+
return path.join(path.dirname(wikiRelativePath(wikiName)), 'attachments', wikiAttachmentDirName(wikiName), attachmentFileName(attachment));
|
|
15
|
+
}
|
|
16
|
+
export function wikiAttachmentMarkdownLink(wikiName, attachment) {
|
|
17
|
+
return encodeLinkDestination(['.', 'attachments', wikiAttachmentDirName(wikiName), attachmentFileName(attachment)].join('/'));
|
|
18
|
+
}
|
|
7
19
|
export function wikiUrl(domain, wikiId) {
|
|
8
20
|
return `${backlogOrigin(domain)}/alias/wiki/${wikiId}`;
|
|
9
21
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { WikiDetail, WikiSummary } from './wiki.js';
|
|
2
2
|
export interface WikiRepository {
|
|
3
|
+
downloadAttachment(wikiId: string, attachmentId: number): Promise<ArrayBuffer>;
|
|
3
4
|
fetchDetail(wikiId: string, projectIdOrKey: string): Promise<WikiDetail>;
|
|
4
5
|
fetchWikis(projectIdOrKey: string): Promise<WikiSummary[]>;
|
|
5
6
|
}
|
|
@@ -3,7 +3,13 @@ export interface WikiSummary {
|
|
|
3
3
|
name: string;
|
|
4
4
|
updated: string;
|
|
5
5
|
}
|
|
6
|
+
export interface WikiAttachment {
|
|
7
|
+
id: number;
|
|
8
|
+
name: string;
|
|
9
|
+
size: number;
|
|
10
|
+
}
|
|
6
11
|
export interface WikiDetail {
|
|
12
|
+
attachments?: WikiAttachment[];
|
|
7
13
|
content?: string;
|
|
8
14
|
id: string;
|
|
9
15
|
name: string;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export function newBacklogWikiRepository(client) {
|
|
2
2
|
return {
|
|
3
|
+
async downloadAttachment(wikiId, attachmentId) {
|
|
4
|
+
return client.getBinary(`/wikis/${wikiId}/attachments/${attachmentId}`);
|
|
5
|
+
},
|
|
3
6
|
async fetchDetail(wikiId, projectIdOrKey) {
|
|
4
7
|
return client.getJson(`/wikis/${wikiId}`, { projectIdOrKey });
|
|
5
8
|
},
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { writeProgress } from '../../../shared/console/progress.js';
|
|
3
|
-
import { writeMarkdownFile } from '../../../shared/storage/markdown-store.js';
|
|
3
|
+
import { fileSize, writeBinaryFile, writeMarkdownFile } from '../../../shared/storage/markdown-store.js';
|
|
4
4
|
import { appendLog } from '../../../shared/storage/update-log.js';
|
|
5
5
|
import { selectWikisToExport } from '../domain/wiki-filter.js';
|
|
6
6
|
import { buildWikiMarkdown } from '../domain/wiki-markdown.js';
|
|
7
|
-
import { wikiRelativePath, wikiUrl } from '../domain/wiki-path.js';
|
|
7
|
+
import { wikiAttachmentMarkdownLink, wikiAttachmentRelativePath, wikiRelativePath, wikiUrl } from '../domain/wiki-path.js';
|
|
8
8
|
export async function exportWikis(deps, options) {
|
|
9
9
|
const { logger, wikiRepository } = deps;
|
|
10
10
|
logger.log('Wikiの取得を開始します...');
|
|
@@ -30,7 +30,13 @@ export async function exportWikis(deps, options) {
|
|
|
30
30
|
const wikiDetail = await wikiRepository.fetchDetail(wiki.id, options.projectIdOrKey);
|
|
31
31
|
const filePath = path.join(options.outputDir, wikiRelativePath(wiki.name));
|
|
32
32
|
const backlogWikiUrl = wikiUrl(options.domain, wiki.id);
|
|
33
|
-
|
|
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
|
+
}));
|
|
34
40
|
await appendLog(options.outputDir, `Wiki「${wiki.name}」を更新しました: ${backlogWikiUrl}`);
|
|
35
41
|
writeProgress(`Wikiを保存中... (${index + 1}/${wikis.length}件)`);
|
|
36
42
|
}
|
|
@@ -41,3 +47,25 @@ export async function exportWikis(deps, options) {
|
|
|
41
47
|
/* eslint-enable no-await-in-loop */
|
|
42
48
|
logger.log('\nWikiのダウンロードが完了しました!');
|
|
43
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 ? `` : match;
|
|
33
|
+
};
|
|
34
|
+
return text
|
|
35
|
+
.replaceAll(/!\[[^\]]*\]\[([^\]]+)\]/g, toLocalImage)
|
|
36
|
+
.replaceAll(/#image\(([^)]+)\)/g, toLocalImage);
|
|
37
|
+
}
|
|
@@ -2,16 +2,19 @@ export declare class HttpError extends Error {
|
|
|
2
2
|
readonly status: number;
|
|
3
3
|
constructor(status: number, statusText: string, url: string);
|
|
4
4
|
}
|
|
5
|
+
export declare function rateLimitWaitMs(resetHeader: null | string, nowMs: number): number;
|
|
5
6
|
export declare class BacklogHttpClient {
|
|
6
7
|
private readonly apiKey;
|
|
7
8
|
private readonly baseUrl;
|
|
8
|
-
private readonly
|
|
9
|
+
private readonly onRateLimitExceeded?;
|
|
9
10
|
constructor(options: {
|
|
10
11
|
apiKey: string;
|
|
11
12
|
domain: string;
|
|
12
|
-
|
|
13
|
+
onRateLimitExceeded?: (waitSeconds: number) => void;
|
|
13
14
|
});
|
|
15
|
+
getBinary(pathname: string, params?: Record<string, string>): Promise<ArrayBuffer>;
|
|
14
16
|
getJson<T>(pathname: string, params?: Record<string, string>): Promise<T>;
|
|
15
17
|
private fetchWithRetry;
|
|
16
18
|
private maskApiKey;
|
|
19
|
+
private requestUrl;
|
|
17
20
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { backlogOrigin } from '../backlog-url.js';
|
|
2
|
-
import {
|
|
2
|
+
import { sleep } from './sleep.js';
|
|
3
3
|
// fetchは4xx/5xxでthrowしないため、response.okでない場合にこれを投げる
|
|
4
4
|
export class HttpError extends Error {
|
|
5
5
|
status;
|
|
@@ -9,30 +9,47 @@ export class HttpError extends Error {
|
|
|
9
9
|
this.name = 'HttpError';
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
-
const RETRYABLE_STATUSES = new Set([408,
|
|
12
|
+
const RETRYABLE_STATUSES = new Set([408, 500, 502, 503, 504]);
|
|
13
13
|
const MAX_RETRIES = 2;
|
|
14
|
+
// 429待機は1回ごとにレート制限ウィンドウ(1分)が更新されるため、通常リトライとは別枠で上限を設ける
|
|
15
|
+
const MAX_RATE_LIMIT_WAITS = 5;
|
|
14
16
|
const RETRY_BASE_DELAY_MS = 300;
|
|
15
|
-
|
|
17
|
+
const RATE_LIMIT_MIN_WAIT_MS = 1000;
|
|
18
|
+
const RATE_LIMIT_FALLBACK_WAIT_MS = 60_000;
|
|
19
|
+
const RATE_LIMIT_MAX_WAIT_MS = 120_000;
|
|
20
|
+
// レート制限(429)はウィンドウが1分単位のため、短いbackoffではなく
|
|
21
|
+
// X-RateLimit-Reset(UNIX秒)まで待つ。ヘッダーが無い/不正な場合は1分待つ
|
|
22
|
+
export function rateLimitWaitMs(resetHeader, nowMs) {
|
|
23
|
+
const resetSeconds = Number(resetHeader);
|
|
24
|
+
if (!resetHeader || !Number.isFinite(resetSeconds))
|
|
25
|
+
return RATE_LIMIT_FALLBACK_WAIT_MS;
|
|
26
|
+
// リセット直後の境界ずれで再度429にならないよう1秒の余裕を持たせる
|
|
27
|
+
const waitMs = resetSeconds * 1000 - nowMs + 1000;
|
|
28
|
+
return Math.min(Math.max(waitMs, RATE_LIMIT_MIN_WAIT_MS), RATE_LIMIT_MAX_WAIT_MS);
|
|
29
|
+
}
|
|
30
|
+
// apiKey付与・429時のX-RateLimit-Resetまでの待機・一時エラーのリトライを担うBacklog APIクライアント
|
|
16
31
|
export class BacklogHttpClient {
|
|
17
32
|
apiKey;
|
|
18
33
|
baseUrl;
|
|
19
|
-
|
|
34
|
+
onRateLimitExceeded;
|
|
20
35
|
constructor(options) {
|
|
21
36
|
this.apiKey = options.apiKey;
|
|
22
37
|
this.baseUrl = `${backlogOrigin(options.domain)}/api/v2`;
|
|
23
|
-
this.
|
|
38
|
+
this.onRateLimitExceeded = options.onRateLimitExceeded;
|
|
39
|
+
}
|
|
40
|
+
async getBinary(pathname, params = {}) {
|
|
41
|
+
const response = await this.fetchWithRetry(this.requestUrl(pathname, params));
|
|
42
|
+
return response.arrayBuffer();
|
|
24
43
|
}
|
|
25
44
|
async getJson(pathname, params = {}) {
|
|
26
|
-
await this.
|
|
27
|
-
const searchParams = new URLSearchParams({ apiKey: this.apiKey, ...params });
|
|
28
|
-
const url = `${this.baseUrl}${pathname}?${searchParams.toString()}`;
|
|
29
|
-
const response = await this.fetchWithRetry(url);
|
|
45
|
+
const response = await this.fetchWithRetry(this.requestUrl(pathname, params));
|
|
30
46
|
return (await response.json());
|
|
31
47
|
}
|
|
32
48
|
// リトライのため、ループ内のawaitは意図したもの
|
|
33
49
|
/* eslint-disable no-await-in-loop */
|
|
34
50
|
async fetchWithRetry(url) {
|
|
35
|
-
|
|
51
|
+
let rateLimitWaits = 0;
|
|
52
|
+
for (let attempt = 0;;) {
|
|
36
53
|
let response;
|
|
37
54
|
try {
|
|
38
55
|
response = await fetch(url);
|
|
@@ -41,6 +58,7 @@ export class BacklogHttpClient {
|
|
|
41
58
|
// ネットワークエラー
|
|
42
59
|
if (attempt < MAX_RETRIES) {
|
|
43
60
|
await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt);
|
|
61
|
+
attempt++;
|
|
44
62
|
continue;
|
|
45
63
|
}
|
|
46
64
|
throw error;
|
|
@@ -48,8 +66,17 @@ export class BacklogHttpClient {
|
|
|
48
66
|
if (response.ok) {
|
|
49
67
|
return response;
|
|
50
68
|
}
|
|
69
|
+
// 429は待機すれば解消する見込みが高いため、通常のリトライ回数を消費しない
|
|
70
|
+
if (response.status === 429 && rateLimitWaits < MAX_RATE_LIMIT_WAITS) {
|
|
71
|
+
const waitMs = rateLimitWaitMs(response.headers.get('x-ratelimit-reset'), Date.now());
|
|
72
|
+
this.onRateLimitExceeded?.(Math.ceil(waitMs / 1000));
|
|
73
|
+
await sleep(waitMs);
|
|
74
|
+
rateLimitWaits++;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
51
77
|
if (attempt < MAX_RETRIES && RETRYABLE_STATUSES.has(response.status)) {
|
|
52
78
|
await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt);
|
|
79
|
+
attempt++;
|
|
53
80
|
continue;
|
|
54
81
|
}
|
|
55
82
|
throw new HttpError(response.status, response.statusText, this.maskApiKey(url));
|
|
@@ -59,4 +86,8 @@ export class BacklogHttpClient {
|
|
|
59
86
|
maskApiKey(url) {
|
|
60
87
|
return url.replace(/apiKey=[^&]*/, 'apiKey=***');
|
|
61
88
|
}
|
|
89
|
+
requestUrl(pathname, params) {
|
|
90
|
+
const searchParams = new URLSearchParams({ apiKey: this.apiKey, ...params });
|
|
91
|
+
return `${this.baseUrl}${pathname}?${searchParams.toString()}`;
|
|
92
|
+
}
|
|
62
93
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const sleep: (ms: number) => Promise<void>;
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import process from 'node:process';
|
|
2
2
|
export function writeProgress(message) {
|
|
3
|
-
process.stdout.
|
|
3
|
+
if (process.stdout.isTTY) {
|
|
4
|
+
// ESC[K でカーソル位置から行末まで消去し、前の長いメッセージの残骸を防ぐ
|
|
5
|
+
process.stdout.write(`\r\u001B[K${message}`);
|
|
6
|
+
}
|
|
7
|
+
else {
|
|
8
|
+
// 非TTY(CIログ・パイプ)では \r が効かず1行に連結されるため改行して出力する
|
|
9
|
+
process.stdout.write(`${message}\n`);
|
|
10
|
+
}
|
|
4
11
|
}
|
package/dist/shared/file-name.js
CHANGED
|
@@ -5,6 +5,17 @@ export function sanitizeFileName(name) {
|
|
|
5
5
|
.replaceAll('.', '_') // ドットを置換
|
|
6
6
|
.slice(0, 200); // 長すぎるファイル名を防ぐために200文字に制限
|
|
7
7
|
}
|
|
8
|
+
// 添付ファイル用: sanitizeFileNameと異なり、拡張子が消えないようドットを保持する
|
|
9
|
+
export function sanitizeAttachmentFileName(name) {
|
|
10
|
+
const sanitized = name.replaceAll(/[\\/:*?"<>|]/g, '_').replaceAll(/\s+/g, '_');
|
|
11
|
+
if (sanitized.length <= 200)
|
|
12
|
+
return sanitized;
|
|
13
|
+
const dotIndex = sanitized.lastIndexOf('.');
|
|
14
|
+
const extension = dotIndex > 0 ? sanitized.slice(dotIndex) : '';
|
|
15
|
+
if (extension.length === 0 || extension.length >= 200)
|
|
16
|
+
return sanitized.slice(0, 200);
|
|
17
|
+
return sanitized.slice(0, 200 - extension.length) + extension;
|
|
18
|
+
}
|
|
8
19
|
export function sanitizeWikiFileName(name) {
|
|
9
20
|
const invalidChars = ['\\', ':', '*', '?', '"', '<', '>', '|'];
|
|
10
21
|
let sanitizedName = name;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export declare function ensureDirectory(directory: string): Promise<void>;
|
|
2
2
|
export declare function fileExists(filePath: string): Promise<boolean>;
|
|
3
3
|
export declare function writeMarkdownFile(filePath: string, content: string): Promise<void>;
|
|
4
|
+
export declare function writeBinaryFile(filePath: string, data: ArrayBuffer): Promise<void>;
|
|
5
|
+
export declare function fileSize(filePath: string): Promise<null | number>;
|
|
4
6
|
export declare function deleteFile(filePath: string): Promise<void>;
|
|
5
7
|
export declare function assertDirectoryExists(directory: string): Promise<void>;
|
|
@@ -10,6 +10,17 @@ export async function writeMarkdownFile(filePath, content) {
|
|
|
10
10
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
11
11
|
await fs.writeFile(filePath, content);
|
|
12
12
|
}
|
|
13
|
+
// 中断で壊れたファイルが完成品として残らないよう、一時ファイルに書いてからrenameする
|
|
14
|
+
export async function writeBinaryFile(filePath, data) {
|
|
15
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
16
|
+
const tempPath = `${filePath}.tmp`;
|
|
17
|
+
await fs.writeFile(tempPath, new Uint8Array(data));
|
|
18
|
+
await fs.rename(tempPath, filePath);
|
|
19
|
+
}
|
|
20
|
+
export async function fileSize(filePath) {
|
|
21
|
+
const stats = await fs.stat(filePath).catch(() => null);
|
|
22
|
+
return stats?.size ?? null;
|
|
23
|
+
}
|
|
13
24
|
export async function deleteFile(filePath) {
|
|
14
25
|
await fs.unlink(filePath);
|
|
15
26
|
}
|