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.
- package/README.md +109 -1
- package/bin/dev.js +6 -2
- package/dist/commands/all/index.d.ts +3 -0
- package/dist/commands/all/index.js +69 -127
- package/dist/commands/document/index.d.ts +1 -0
- package/dist/commands/document/index.js +26 -12
- package/dist/commands/issue/index.d.ts +1 -0
- package/dist/commands/issue/index.js +29 -12
- package/dist/commands/prune/index.d.ts +16 -0
- package/dist/commands/prune/index.js +86 -0
- package/dist/commands/update/index.d.ts +4 -7
- package/dist/commands/update/index.js +33 -260
- package/dist/commands/wiki/index.d.ts +1 -0
- package/dist/commands/wiki/index.js +25 -10
- package/dist/composition/backlog-repositories.d.ts +16 -0
- package/dist/composition/backlog-repositories.js +15 -0
- package/dist/modules/all/domain/content-selection.d.ts +4 -0
- package/dist/modules/all/domain/content-selection.js +16 -0
- package/dist/modules/all/use-case/export-all.d.ts +25 -0
- package/dist/modules/all/use-case/export-all.js +84 -0
- package/dist/modules/document/domain/document-markdown.d.ts +2 -0
- package/dist/modules/document/domain/document-markdown.js +40 -0
- package/dist/modules/document/domain/document-path.d.ts +23 -0
- package/dist/modules/document/domain/document-path.js +57 -0
- package/dist/modules/document/domain/document-repository.d.ts +7 -0
- package/dist/modules/document/domain/document-repository.js +1 -0
- package/dist/modules/document/domain/document-save-plan.d.ts +9 -0
- package/dist/modules/document/domain/document-save-plan.js +15 -0
- package/dist/modules/document/domain/document.d.ts +50 -0
- package/dist/modules/document/domain/document.js +1 -0
- package/dist/modules/document/repository/backlog-document-repository.d.ts +3 -0
- package/dist/modules/document/repository/backlog-document-repository.js +35 -0
- package/dist/modules/document/use-case/export-documents.d.ts +17 -0
- package/dist/modules/document/use-case/export-documents.js +107 -0
- package/dist/modules/issue/domain/issue-filter.d.ts +2 -0
- package/dist/modules/issue/domain/issue-filter.js +7 -0
- package/dist/modules/issue/domain/issue-markdown.d.ts +5 -0
- package/dist/modules/issue/domain/issue-markdown.js +127 -0
- package/dist/modules/issue/domain/issue-path.d.ts +34 -0
- package/dist/modules/issue/domain/issue-path.js +42 -0
- package/dist/modules/issue/domain/issue-repository.d.ts +12 -0
- package/dist/modules/issue/domain/issue-repository.js +1 -0
- package/dist/modules/issue/domain/issue.d.ts +53 -0
- package/dist/modules/issue/domain/issue.js +1 -0
- package/dist/modules/issue/repository/backlog-issue-repository.d.ts +3 -0
- package/dist/modules/issue/repository/backlog-issue-repository.js +41 -0
- package/dist/modules/issue/use-case/export-issues.d.ts +19 -0
- package/dist/modules/issue/use-case/export-issues.js +113 -0
- package/dist/modules/project/domain/project-repository.d.ts +3 -0
- package/dist/modules/project/domain/project-repository.js +1 -0
- package/dist/modules/project/repository/backlog-project-repository.d.ts +3 -0
- package/dist/modules/project/repository/backlog-project-repository.js +17 -0
- package/dist/modules/prune/domain/expected-paths.d.ts +4 -0
- package/dist/modules/prune/domain/expected-paths.js +1 -0
- package/dist/modules/prune/domain/prune-target.d.ts +3 -0
- package/dist/modules/prune/domain/prune-target.js +21 -0
- package/dist/modules/prune/repository/prune-walker.d.ts +7 -0
- package/dist/modules/prune/repository/prune-walker.js +44 -0
- package/dist/modules/prune/use-case/prune-directories.d.ts +28 -0
- package/dist/modules/prune/use-case/prune-directories.js +74 -0
- package/dist/modules/prune/use-case/prune-exports.d.ts +27 -0
- package/dist/modules/prune/use-case/prune-exports.js +74 -0
- package/dist/modules/settings/domain/settings.d.ts +16 -0
- package/dist/modules/settings/domain/settings.js +6 -0
- package/dist/modules/settings/repository/settings-store.d.ts +6 -0
- package/dist/modules/settings/repository/settings-store.js +56 -0
- package/dist/modules/update/domain/update-plan.d.ts +38 -0
- package/dist/modules/update/domain/update-plan.js +70 -0
- package/dist/modules/update/use-case/update-exports.d.ts +21 -0
- package/dist/modules/update/use-case/update-exports.js +139 -0
- package/dist/modules/wiki/domain/wiki-filter.d.ts +9 -0
- package/dist/modules/wiki/domain/wiki-filter.js +11 -0
- package/dist/modules/wiki/domain/wiki-markdown.d.ts +6 -0
- package/dist/modules/wiki/domain/wiki-markdown.js +19 -0
- package/dist/modules/wiki/domain/wiki-path.d.ts +7 -0
- package/dist/modules/wiki/domain/wiki-path.js +37 -0
- package/dist/modules/wiki/domain/wiki-repository.d.ts +6 -0
- package/dist/modules/wiki/domain/wiki-repository.js +1 -0
- package/dist/modules/wiki/domain/wiki.d.ts +16 -0
- package/dist/modules/wiki/domain/wiki.js +1 -0
- package/dist/modules/wiki/repository/backlog-wiki-repository.d.ts +3 -0
- package/dist/modules/wiki/repository/backlog-wiki-repository.js +13 -0
- package/dist/modules/wiki/use-case/export-wikis.d.ts +15 -0
- package/dist/modules/wiki/use-case/export-wikis.js +71 -0
- package/dist/shared/attachment.d.ts +8 -0
- package/dist/shared/attachment.js +37 -0
- package/dist/shared/backlog/http-client.d.ts +20 -0
- package/dist/shared/backlog/http-client.js +93 -0
- package/dist/shared/backlog/sleep.d.ts +1 -0
- package/dist/shared/backlog/sleep.js +3 -0
- package/dist/shared/backlog-url.d.ts +1 -0
- package/dist/shared/backlog-url.js +5 -0
- package/dist/shared/config/env.d.ts +3 -0
- package/dist/shared/config/env.js +21 -0
- package/dist/shared/console/progress.d.ts +1 -0
- package/dist/shared/console/progress.js +11 -0
- package/dist/shared/console/prompt.d.ts +3 -0
- package/dist/shared/console/prompt.js +19 -0
- package/dist/shared/file-name.d.ts +3 -0
- package/dist/shared/file-name.js +26 -0
- package/dist/shared/markdown/body-marker.d.ts +3 -0
- package/dist/shared/markdown/body-marker.js +5 -0
- package/dist/shared/ports.d.ts +4 -0
- package/dist/shared/ports.js +1 -0
- package/dist/shared/storage/markdown-store.d.ts +7 -0
- package/dist/shared/storage/markdown-store.js +32 -0
- package/dist/shared/storage/update-log.d.ts +1 -0
- package/dist/{utils/log.js → shared/storage/update-log.js} +1 -5
- package/oclif.manifest.json +160 -27
- package/package.json +15 -19
- package/dist/utils/backlog-api.d.ts +0 -74
- package/dist/utils/backlog-api.js +0 -453
- package/dist/utils/backlog.d.ts +0 -16
- package/dist/utils/backlog.js +0 -34
- package/dist/utils/common.d.ts +0 -25
- package/dist/utils/common.js +0 -54
- package/dist/utils/log.d.ts +0 -6
- package/dist/utils/settings.d.ts +0 -45
- package/dist/utils/settings.js +0 -63
- package/dist/utils/sleep.d.ts +0 -28
- package/dist/utils/sleep.js +0 -42
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { attachmentFileName, encodeLinkDestination } from '../../../shared/attachment.js';
|
|
3
|
+
import { backlogOrigin } from '../../../shared/backlog-url.js';
|
|
4
|
+
import { sanitizeFileName } from '../../../shared/file-name.js';
|
|
5
|
+
export function issueFileName(issue, useIssueKey) {
|
|
6
|
+
return useIssueKey ? `${issue.issueKey}.md` : `${sanitizeFileName(issue.summary)}.md`;
|
|
7
|
+
}
|
|
8
|
+
export function issueRelativeDir(issue, useIssueKeyFolder) {
|
|
9
|
+
const createdYear = new Date(issue.created).getFullYear().toString();
|
|
10
|
+
return useIssueKeyFolder ? path.join(createdYear, issue.issueKey) : createdYear;
|
|
11
|
+
}
|
|
12
|
+
export function issueRelativePath(issue, options) {
|
|
13
|
+
return path.join(issueRelativeDir(issue, options.issueKeyFolder ?? false), issueFileName(issue, options.issueKeyFileName ?? false));
|
|
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
|
+
}
|
|
26
|
+
export function issueUrl(domain, issueKey) {
|
|
27
|
+
return `${backlogOrigin(domain)}/view/${issueKey}`;
|
|
28
|
+
}
|
|
29
|
+
// prune用: 保存時と同じ命名ロジックで期待パス集合を構築する
|
|
30
|
+
export function buildIssueExpectedPaths(issues, options) {
|
|
31
|
+
const expectedFiles = new Set();
|
|
32
|
+
const expectedDirs = new Set();
|
|
33
|
+
for (const issue of issues) {
|
|
34
|
+
expectedFiles.add(issueRelativePath(issue, options).normalize('NFC'));
|
|
35
|
+
let dir = issueRelativeDir(issue, options.issueKeyFolder ?? false);
|
|
36
|
+
while (dir && dir !== '.') {
|
|
37
|
+
expectedDirs.add(dir.normalize('NFC'));
|
|
38
|
+
dir = path.dirname(dir);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { expectedDirs, expectedFiles };
|
|
42
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Issue, IssueComment } from './issue.js';
|
|
2
|
+
export interface IssueRepository {
|
|
3
|
+
downloadAttachment(issueIdOrKey: string, attachmentId: number): Promise<ArrayBuffer>;
|
|
4
|
+
fetchAllComments(issueKey: string): Promise<IssueComment[]>;
|
|
5
|
+
fetchByIdOrKey(issueIdOrKey: string): Promise<Issue>;
|
|
6
|
+
fetchPage(options: {
|
|
7
|
+
count: number;
|
|
8
|
+
offset: number;
|
|
9
|
+
projectId: number;
|
|
10
|
+
statusId?: string;
|
|
11
|
+
}): Promise<Issue[]>;
|
|
12
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export interface Issue {
|
|
2
|
+
assignee: null | {
|
|
3
|
+
id: number;
|
|
4
|
+
name: string;
|
|
5
|
+
};
|
|
6
|
+
attachments?: IssueAttachment[];
|
|
7
|
+
created: string;
|
|
8
|
+
customFields: CustomField[];
|
|
9
|
+
description: string;
|
|
10
|
+
dueDate: null | string;
|
|
11
|
+
id: number;
|
|
12
|
+
issueKey: string;
|
|
13
|
+
issueType: {
|
|
14
|
+
id: number;
|
|
15
|
+
name: string;
|
|
16
|
+
};
|
|
17
|
+
priority: {
|
|
18
|
+
id: number;
|
|
19
|
+
name: string;
|
|
20
|
+
};
|
|
21
|
+
startDate: null | string;
|
|
22
|
+
status: {
|
|
23
|
+
id: number;
|
|
24
|
+
name: string;
|
|
25
|
+
};
|
|
26
|
+
summary: string;
|
|
27
|
+
updated: string;
|
|
28
|
+
}
|
|
29
|
+
export interface IssueAttachment {
|
|
30
|
+
id: number;
|
|
31
|
+
name: string;
|
|
32
|
+
size: number;
|
|
33
|
+
}
|
|
34
|
+
export interface CustomField {
|
|
35
|
+
id: number;
|
|
36
|
+
name: string;
|
|
37
|
+
value: unknown;
|
|
38
|
+
}
|
|
39
|
+
export interface IssueComment {
|
|
40
|
+
changeLog?: IssueCommentChange[];
|
|
41
|
+
content: null | string;
|
|
42
|
+
created: string;
|
|
43
|
+
createdUser: {
|
|
44
|
+
id: number;
|
|
45
|
+
name: string;
|
|
46
|
+
};
|
|
47
|
+
id: number;
|
|
48
|
+
}
|
|
49
|
+
export interface IssueCommentChange {
|
|
50
|
+
field: string;
|
|
51
|
+
newValue: null | string;
|
|
52
|
+
originalValue: null | string;
|
|
53
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export function newBacklogIssueRepository(client) {
|
|
2
|
+
return {
|
|
3
|
+
async downloadAttachment(issueIdOrKey, attachmentId) {
|
|
4
|
+
return client.getBinary(`/issues/${issueIdOrKey}/attachments/${attachmentId}`);
|
|
5
|
+
},
|
|
6
|
+
async fetchAllComments(issueKey) {
|
|
7
|
+
const allComments = [];
|
|
8
|
+
let minId;
|
|
9
|
+
for (;;) {
|
|
10
|
+
const params = { count: '100' };
|
|
11
|
+
if (minId !== undefined) {
|
|
12
|
+
params.minId = minId.toString();
|
|
13
|
+
}
|
|
14
|
+
// eslint-disable-next-line no-await-in-loop
|
|
15
|
+
const comments = await client.getJson(`/issues/${issueKey}/comments`, params);
|
|
16
|
+
allComments.push(...comments);
|
|
17
|
+
if (comments.length < 100) {
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
minId = comments.at(-1).id + 1;
|
|
21
|
+
}
|
|
22
|
+
// コメントを古い順(昇順)に並び替える
|
|
23
|
+
allComments.sort((a, b) => new Date(a.created).getTime() - new Date(b.created).getTime());
|
|
24
|
+
return allComments;
|
|
25
|
+
},
|
|
26
|
+
async fetchByIdOrKey(issueIdOrKey) {
|
|
27
|
+
return client.getJson(`/issues/${issueIdOrKey}`);
|
|
28
|
+
},
|
|
29
|
+
async fetchPage(options) {
|
|
30
|
+
const params = {
|
|
31
|
+
count: options.count.toString(),
|
|
32
|
+
offset: options.offset.toString(),
|
|
33
|
+
'projectId[]': options.projectId.toString(),
|
|
34
|
+
};
|
|
35
|
+
if (options.statusId) {
|
|
36
|
+
params['statusId[]'] = options.statusId;
|
|
37
|
+
}
|
|
38
|
+
return client.getJson('/issues', params);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Logger } from '../../../shared/ports.js';
|
|
2
|
+
import { IssueRepository } from '../domain/issue-repository.js';
|
|
3
|
+
export interface ExportIssuesDeps {
|
|
4
|
+
issueRepository: IssueRepository;
|
|
5
|
+
logger: Logger;
|
|
6
|
+
}
|
|
7
|
+
export interface ExportIssuesOptions {
|
|
8
|
+
count?: number;
|
|
9
|
+
domain: string;
|
|
10
|
+
downloadAttachments?: boolean;
|
|
11
|
+
issueIdOrKeys?: string[];
|
|
12
|
+
issueKeyFileName?: boolean;
|
|
13
|
+
issueKeyFolder?: boolean;
|
|
14
|
+
lastUpdated?: string;
|
|
15
|
+
outputDir: string;
|
|
16
|
+
projectId: number;
|
|
17
|
+
statusId?: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function exportIssues(deps: ExportIssuesDeps, options: ExportIssuesOptions): Promise<void>;
|
|
@@ -0,0 +1,113 @@
|
|
|
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 { filterIssuesUpdatedSince } from '../domain/issue-filter.js';
|
|
6
|
+
import { buildIssueMarkdown } from '../domain/issue-markdown.js';
|
|
7
|
+
import { attachmentMarkdownLink, attachmentRelativePath, issueRelativePath, issueUrl } from '../domain/issue-path.js';
|
|
8
|
+
export async function exportIssues(deps, options) {
|
|
9
|
+
const { logger } = deps;
|
|
10
|
+
logger.log('課題の取得を開始します...');
|
|
11
|
+
const allIssues = options.issueIdOrKeys && options.issueIdOrKeys.length > 0
|
|
12
|
+
? await fetchIssuesByIdOrKeys(deps, options.issueIdOrKeys)
|
|
13
|
+
: await fetchAllIssues(deps, options);
|
|
14
|
+
logger.log(`\n合計 ${allIssues.length}件の課題が見つかりました。`);
|
|
15
|
+
const filteredIssues = filterIssuesUpdatedSince(allIssues, options.lastUpdated);
|
|
16
|
+
if (options.lastUpdated) {
|
|
17
|
+
logger.log(`前回の更新日時(${options.lastUpdated})以降に更新された${filteredIssues.length}件の課題を処理します。`);
|
|
18
|
+
}
|
|
19
|
+
if (filteredIssues.length === 0) {
|
|
20
|
+
logger.log('更新が必要な課題はありません。');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
logger.log('課題を保存しています...');
|
|
24
|
+
for (const [index, issue] of filteredIssues.entries()) {
|
|
25
|
+
try {
|
|
26
|
+
writeProgress(`課題を保存中... (${index + 1}/${filteredIssues.length}件)`);
|
|
27
|
+
// eslint-disable-next-line no-await-in-loop
|
|
28
|
+
await saveIssue(deps, issue, options);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
logger.warn(`課題 ${issue.issueKey} の保存に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
logger.log('\n課題のダウンロードが完了しました!');
|
|
35
|
+
}
|
|
36
|
+
async function fetchIssuesByIdOrKeys(deps, issueIdOrKeys) {
|
|
37
|
+
const issues = [];
|
|
38
|
+
for (const [index, issueIdOrKey] of issueIdOrKeys.entries()) {
|
|
39
|
+
try {
|
|
40
|
+
writeProgress(`課題を取得中... (${index + 1}/${issueIdOrKeys.length}件)`);
|
|
41
|
+
// eslint-disable-next-line no-await-in-loop
|
|
42
|
+
issues.push(await deps.issueRepository.fetchByIdOrKey(issueIdOrKey));
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
deps.logger.warn(`課題 ${issueIdOrKey} の取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return issues;
|
|
49
|
+
}
|
|
50
|
+
async function fetchAllIssues(deps, options) {
|
|
51
|
+
const pageSize = Math.min(options.count ?? 5000, 100);
|
|
52
|
+
const issues = [];
|
|
53
|
+
try {
|
|
54
|
+
for (;;) {
|
|
55
|
+
writeProgress(`課題を取得中... (${issues.length}件取得済み)`);
|
|
56
|
+
// eslint-disable-next-line no-await-in-loop
|
|
57
|
+
const page = await deps.issueRepository.fetchPage({
|
|
58
|
+
count: pageSize,
|
|
59
|
+
offset: issues.length,
|
|
60
|
+
projectId: options.projectId,
|
|
61
|
+
statusId: options.statusId,
|
|
62
|
+
});
|
|
63
|
+
issues.push(...page);
|
|
64
|
+
if (page.length < pageSize) {
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
throw new Error(`課題の取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
|
|
71
|
+
}
|
|
72
|
+
return issues;
|
|
73
|
+
}
|
|
74
|
+
async function saveIssue(deps, issue, options) {
|
|
75
|
+
const backlogIssueUrl = issueUrl(options.domain, issue.issueKey);
|
|
76
|
+
// コメント取得に失敗しても課題本体は保存する
|
|
77
|
+
let comments = [];
|
|
78
|
+
try {
|
|
79
|
+
comments = await deps.issueRepository.fetchAllComments(issue.issueKey);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
deps.logger.warn(`課題 ${issue.issueKey} のコメント取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
|
|
83
|
+
}
|
|
84
|
+
const attachmentLinks = options.downloadAttachments
|
|
85
|
+
? await downloadIssueAttachments(deps, issue, options)
|
|
86
|
+
: undefined;
|
|
87
|
+
const filePath = path.join(options.outputDir, issueRelativePath(issue, options));
|
|
88
|
+
await writeMarkdownFile(filePath, buildIssueMarkdown(issue, comments, backlogIssueUrl, attachmentLinks));
|
|
89
|
+
await appendLog(options.outputDir, `課題「${issue.summary}」を更新しました: ${backlogIssueUrl}`);
|
|
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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function newBacklogProjectRepository(client) {
|
|
2
|
+
return {
|
|
3
|
+
async resolveProjectId(projectIdOrKey) {
|
|
4
|
+
// 数値の場合はそのままプロジェクトIDとして返す
|
|
5
|
+
if (!Number.isNaN(Number(projectIdOrKey))) {
|
|
6
|
+
return Number(projectIdOrKey);
|
|
7
|
+
}
|
|
8
|
+
try {
|
|
9
|
+
const projectData = await client.getJson(`/projects/${projectIdOrKey}`);
|
|
10
|
+
return projectData.id;
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
throw new Error(`プロジェクトキー "${projectIdOrKey}" からプロジェクトIDの取得に失敗しました: ${error instanceof Error ? error.message : String(error)}`);
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { FolderType } from '../../settings/domain/settings.js';
|
|
2
|
+
// folderTypeが無いのは旧バージョンの設定ファイル。無言でスキップせず理由を伝えるためnot-targetと区別する
|
|
3
|
+
export function classifyPruneTarget(settings) {
|
|
4
|
+
switch (settings.folderType) {
|
|
5
|
+
case FolderType.DOCUMENT: {
|
|
6
|
+
return 'document';
|
|
7
|
+
}
|
|
8
|
+
case FolderType.ISSUE: {
|
|
9
|
+
return 'issue';
|
|
10
|
+
}
|
|
11
|
+
case FolderType.WIKI: {
|
|
12
|
+
return 'wiki';
|
|
13
|
+
}
|
|
14
|
+
case undefined: {
|
|
15
|
+
return 'missing-folder-type';
|
|
16
|
+
}
|
|
17
|
+
default: {
|
|
18
|
+
return 'not-target';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { appendLog } from '../../../shared/storage/update-log.js';
|
|
4
|
+
// 期待パス集合に含まれない .md ファイルと空ディレクトリを削除する。
|
|
5
|
+
// 削除対象は .md のみで、設定ファイル・ログ・ユーザーが置いた他のファイルには触れない。
|
|
6
|
+
// 比較はNFC正規化(macOSのNFDとAPIレスポンスのNFCの差異吸収)+小文字化
|
|
7
|
+
// (大文字小文字非区別FSでは大小文字のみのリネーム後もディスク上のエントリ名が旧表記のまま残るため。
|
|
8
|
+
// 区別するFSでは旧表記が残り得るが、誤削除より安全側に倒す)で行う。
|
|
9
|
+
export async function pruneLocalMarkdownFiles(options) {
|
|
10
|
+
const expectedFiles = new Set([...options.expected.expectedFiles].map((p) => p.toLowerCase()));
|
|
11
|
+
const expectedDirs = new Set([...options.expected.expectedDirs].map((p) => p.toLowerCase()));
|
|
12
|
+
let prunedCount = 0;
|
|
13
|
+
/* eslint-disable no-await-in-loop */
|
|
14
|
+
const pruneDirectory = async (dir) => {
|
|
15
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
16
|
+
for (const entry of entries) {
|
|
17
|
+
const fullPath = path.join(dir, entry.name);
|
|
18
|
+
const relativePath = path.relative(options.outputDir, fullPath).normalize('NFC');
|
|
19
|
+
if (entry.isDirectory()) {
|
|
20
|
+
// 添付ファイルの保存先。Wiki・ドキュメントのpruneは一覧APIしか呼ばず添付の期待パスを持てないため、
|
|
21
|
+
// attachments/ 配下は走査せず丸ごと保護する(.md形式の添付の誤削除防止)
|
|
22
|
+
if (entry.name === 'attachments') {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
await pruneDirectory(fullPath);
|
|
26
|
+
const remaining = await fs.readdir(fullPath);
|
|
27
|
+
if (remaining.length === 0 && !expectedDirs.has(relativePath.toLowerCase())) {
|
|
28
|
+
await fs.rmdir(fullPath);
|
|
29
|
+
options.log(`空のディレクトリを削除しました: ${relativePath}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
else if (entry.name.endsWith('.md') && !expectedFiles.has(relativePath.toLowerCase())) {
|
|
33
|
+
await fs.unlink(fullPath);
|
|
34
|
+
prunedCount++;
|
|
35
|
+
options.log(`Backlog上に存在しない${options.label}を削除しました: ${relativePath}`);
|
|
36
|
+
await appendLog(options.outputDir, `${options.label}「${relativePath}」を削除しました(Backlog上に存在しないため)`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
/* eslint-enable no-await-in-loop */
|
|
41
|
+
await pruneDirectory(options.outputDir);
|
|
42
|
+
options.log(`${prunedCount}件の不要な${options.label}ファイルを削除しました。`);
|
|
43
|
+
return prunedCount;
|
|
44
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
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
|
+
export interface PruneFlags {
|
|
7
|
+
apiKey?: string;
|
|
8
|
+
domain?: string;
|
|
9
|
+
projectIdOrKey?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface PruneDeps {
|
|
12
|
+
createRepositories: (connection: {
|
|
13
|
+
apiKey: string;
|
|
14
|
+
domain: string;
|
|
15
|
+
onRateLimitExceeded?: (waitSeconds: number) => void;
|
|
16
|
+
}) => {
|
|
17
|
+
documentRepository: DocumentRepository;
|
|
18
|
+
issueRepository: IssueRepository;
|
|
19
|
+
projectRepository: ProjectRepository;
|
|
20
|
+
wikiRepository: WikiRepository;
|
|
21
|
+
};
|
|
22
|
+
logger: Logger;
|
|
23
|
+
}
|
|
24
|
+
export declare function pruneDirectories(deps: PruneDeps, options: {
|
|
25
|
+
confirmDirectory: (directory: string) => Promise<boolean>;
|
|
26
|
+
flags: PruneFlags;
|
|
27
|
+
rootDir: string;
|
|
28
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { resolveApiKey } from '../../../shared/config/env.js';
|
|
2
|
+
import { assertDirectoryExists } from '../../../shared/storage/markdown-store.js';
|
|
3
|
+
import { findSettingsDirectories, loadSettings } from '../../settings/repository/settings-store.js';
|
|
4
|
+
import { classifyPruneTarget } from '../domain/prune-target.js';
|
|
5
|
+
import { pruneDocuments, pruneIssues, pruneWikis } from './prune-exports.js';
|
|
6
|
+
export async function pruneDirectories(deps, options) {
|
|
7
|
+
const { logger } = deps;
|
|
8
|
+
await assertDirectoryExists(options.rootDir);
|
|
9
|
+
const directories = await findSettingsDirectories(options.rootDir, (dir) => {
|
|
10
|
+
logger.warn(`ディレクトリの読み取りに失敗しました: ${dir}`);
|
|
11
|
+
});
|
|
12
|
+
if (directories.length === 0) {
|
|
13
|
+
logger.warn(`設定ファイル(backlog-settings.json)が見つかりませんでした: ${options.rootDir}`);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
for (const directory of directories) {
|
|
17
|
+
// eslint-disable-next-line no-await-in-loop
|
|
18
|
+
await pruneDirectory(deps, directory, options.flags, options.confirmDirectory);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function pruneDirectory(deps, targetDir, flags, confirmDirectory) {
|
|
22
|
+
const { logger } = deps;
|
|
23
|
+
const settings = await loadSettings(targetDir);
|
|
24
|
+
const target = classifyPruneTarget(settings);
|
|
25
|
+
if (target === 'missing-folder-type') {
|
|
26
|
+
logger.warn(`${targetDir}: 設定ファイルに folderType がないためスキップします(update コマンドを実行すると保存されます)`);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (target === 'not-target') {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const domain = flags.domain || settings.domain;
|
|
33
|
+
const projectIdOrKey = flags.projectIdOrKey || settings.projectIdOrKey;
|
|
34
|
+
if (!domain) {
|
|
35
|
+
logger.warn(`${targetDir}: ドメインが指定されていません。スキップします。`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (!projectIdOrKey) {
|
|
39
|
+
logger.warn(`${targetDir}: プロジェクトID/キーが指定されていません。スキップします。`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const apiKey = flags.apiKey ||
|
|
43
|
+
settings.apiKey ||
|
|
44
|
+
resolveApiKey(undefined, () => logger.log('環境変数 BACKLOG_API_KEY からAPIキーを使用します'));
|
|
45
|
+
if (!apiKey) {
|
|
46
|
+
logger.warn(`${targetDir}: APIキーが指定されていません。--apiKey フラグまたは BACKLOG_API_KEY 環境変数で設定してください。`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const confirmed = await confirmDirectory(targetDir);
|
|
50
|
+
if (!confirmed) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const { documentRepository, issueRepository, projectRepository, wikiRepository } = deps.createRepositories({
|
|
54
|
+
apiKey,
|
|
55
|
+
domain,
|
|
56
|
+
onRateLimitExceeded: (waitSeconds) => logger.log(`レート制限の上限に達しました。${waitSeconds}秒待機します...`),
|
|
57
|
+
});
|
|
58
|
+
if (target === 'wiki') {
|
|
59
|
+
await pruneWikis({ logger, wikiRepository }, { outputDir: targetDir, projectIdOrKey });
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
const projectId = await projectRepository.resolveProjectId(projectIdOrKey);
|
|
63
|
+
await (target === 'issue'
|
|
64
|
+
? pruneIssues({ issueRepository, logger }, {
|
|
65
|
+
// 保存時の命名を再現するため、エクスポート時に保存された設定を使う
|
|
66
|
+
issueKeyFileName: settings.issueKeyFileName,
|
|
67
|
+
issueKeyFolder: settings.issueKeyFolder,
|
|
68
|
+
outputDir: targetDir,
|
|
69
|
+
projectId,
|
|
70
|
+
})
|
|
71
|
+
: pruneDocuments({ documentRepository, logger }, { outputDir: targetDir, projectId }));
|
|
72
|
+
}
|
|
73
|
+
logger.log(`${targetDir} のpruneが完了しました!`);
|
|
74
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
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 { WikiRepository } from '../../wiki/domain/wiki-repository.js';
|
|
5
|
+
export declare function pruneDocuments(deps: {
|
|
6
|
+
documentRepository: DocumentRepository;
|
|
7
|
+
logger: Logger;
|
|
8
|
+
}, options: {
|
|
9
|
+
outputDir: string;
|
|
10
|
+
projectId: number;
|
|
11
|
+
}): Promise<number>;
|
|
12
|
+
export declare function pruneWikis(deps: {
|
|
13
|
+
logger: Logger;
|
|
14
|
+
wikiRepository: WikiRepository;
|
|
15
|
+
}, options: {
|
|
16
|
+
outputDir: string;
|
|
17
|
+
projectIdOrKey: string;
|
|
18
|
+
}): Promise<number>;
|
|
19
|
+
export declare function pruneIssues(deps: {
|
|
20
|
+
issueRepository: IssueRepository;
|
|
21
|
+
logger: Logger;
|
|
22
|
+
}, options: {
|
|
23
|
+
issueKeyFileName?: boolean;
|
|
24
|
+
issueKeyFolder?: boolean;
|
|
25
|
+
outputDir: string;
|
|
26
|
+
projectId: number;
|
|
27
|
+
}): Promise<number>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { collectDocumentTreePaths, resolveDocumentLeafPaths } from '../../document/domain/document-path.js';
|
|
2
|
+
import { buildIssueExpectedPaths } from '../../issue/domain/issue-path.js';
|
|
3
|
+
import { buildWikiExpectedPaths } from '../../wiki/domain/wiki-path.js';
|
|
4
|
+
import { pruneLocalMarkdownFiles } from '../repository/prune-walker.js';
|
|
5
|
+
export async function pruneDocuments(deps, options) {
|
|
6
|
+
const { documentRepository, logger } = deps;
|
|
7
|
+
logger.log('Backlogのドキュメントツリーを取得しています...');
|
|
8
|
+
const documentTree = await documentRepository.fetchTree(options.projectId);
|
|
9
|
+
const expected = collectDocumentTreePaths(documentTree.activeTree.children);
|
|
10
|
+
// 保存時のファイル名は詳細のtitle基準のため、一覧APIでタイトルを解決してから期待パスを確定する
|
|
11
|
+
let titlesById = new Map();
|
|
12
|
+
if (expected.leafNodes.length > 0) {
|
|
13
|
+
logger.log(`${expected.leafNodes.length}件のドキュメントの正規ファイル名を確認しています...`);
|
|
14
|
+
try {
|
|
15
|
+
titlesById = await documentRepository.fetchAllTitles(options.projectId);
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
// 一覧に欠けが生じると実在ドキュメントを誤削除するため、何も削除せずに中止する
|
|
19
|
+
throw new Error(`ドキュメント一覧の取得に失敗しました。誤削除を防ぐため、何も削除せずに中止します: ${error instanceof Error ? error.message : String(error)}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
resolveDocumentLeafPaths(expected, titlesById);
|
|
23
|
+
return pruneLocalMarkdownFiles({
|
|
24
|
+
expected,
|
|
25
|
+
label: 'ドキュメント',
|
|
26
|
+
log: (message) => logger.log(message),
|
|
27
|
+
outputDir: options.outputDir,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
export async function pruneWikis(deps, options) {
|
|
31
|
+
const { logger, wikiRepository } = deps;
|
|
32
|
+
logger.log('BacklogのWiki一覧を取得しています...');
|
|
33
|
+
const wikis = await wikiRepository.fetchWikis(options.projectIdOrKey);
|
|
34
|
+
const expected = buildWikiExpectedPaths(wikis.map((wiki) => wiki.name));
|
|
35
|
+
return pruneLocalMarkdownFiles({
|
|
36
|
+
expected,
|
|
37
|
+
label: 'Wiki',
|
|
38
|
+
log: (message) => logger.log(message),
|
|
39
|
+
outputDir: options.outputDir,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
export async function pruneIssues(deps, options) {
|
|
43
|
+
const { issueRepository, logger } = deps;
|
|
44
|
+
logger.log('Backlogの課題一覧を取得しています...');
|
|
45
|
+
// 期待集合が途中で切れると実在課題のファイルを誤削除するため、capを設けず全件を取得し、
|
|
46
|
+
// 取得に失敗した場合は何も削除せずに中止する
|
|
47
|
+
const issues = [];
|
|
48
|
+
const pageSize = 100;
|
|
49
|
+
try {
|
|
50
|
+
for (;;) {
|
|
51
|
+
// eslint-disable-next-line no-await-in-loop
|
|
52
|
+
const page = await issueRepository.fetchPage({
|
|
53
|
+
count: pageSize,
|
|
54
|
+
offset: issues.length,
|
|
55
|
+
projectId: options.projectId,
|
|
56
|
+
});
|
|
57
|
+
issues.push(...page);
|
|
58
|
+
if (page.length < pageSize) {
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
throw new Error(`課題一覧の取得に失敗しました。誤削除を防ぐため、何も削除せずに中止します: ${error instanceof Error ? error.message : String(error)}`);
|
|
65
|
+
}
|
|
66
|
+
logger.log(`${issues.length}件の課題が見つかりました。`);
|
|
67
|
+
const expected = buildIssueExpectedPaths(issues, options);
|
|
68
|
+
return pruneLocalMarkdownFiles({
|
|
69
|
+
expected,
|
|
70
|
+
label: '課題',
|
|
71
|
+
log: (message) => logger.log(message),
|
|
72
|
+
outputDir: options.outputDir,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare enum FolderType {
|
|
2
|
+
DOCUMENT = "document",
|
|
3
|
+
ISSUE = "issue",
|
|
4
|
+
WIKI = "wiki"
|
|
5
|
+
}
|
|
6
|
+
export interface Settings {
|
|
7
|
+
apiKey?: string;
|
|
8
|
+
domain?: string;
|
|
9
|
+
downloadAttachments?: boolean;
|
|
10
|
+
folderType?: FolderType;
|
|
11
|
+
issueKeyFileName?: boolean;
|
|
12
|
+
issueKeyFolder?: boolean;
|
|
13
|
+
lastUpdated?: string;
|
|
14
|
+
outputDir?: string;
|
|
15
|
+
projectIdOrKey?: string;
|
|
16
|
+
}
|