backlog-exporter 0.7.3 → 1.0.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 +87 -1
- package/bin/dev.js +6 -2
- package/dist/commands/all/index.d.ts +2 -0
- package/dist/commands/all/index.js +62 -126
- package/dist/commands/document/index.js +18 -11
- package/dist/commands/issue/index.js +18 -11
- package/dist/commands/prune/index.d.ts +16 -0
- package/dist/commands/prune/index.js +86 -0
- package/dist/commands/update/index.d.ts +3 -7
- package/dist/commands/update/index.js +28 -260
- package/dist/commands/wiki/index.js +17 -9
- 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 +24 -0
- package/dist/modules/all/use-case/export-all.js +78 -0
- package/dist/modules/document/domain/document-markdown.d.ts +2 -0
- package/dist/modules/document/domain/document-markdown.js +37 -0
- package/dist/modules/document/domain/document-path.d.ts +15 -0
- package/dist/modules/document/domain/document-path.js +48 -0
- package/dist/modules/document/domain/document-repository.d.ts +6 -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 +31 -0
- package/dist/modules/document/use-case/export-documents.d.ts +16 -0
- package/dist/modules/document/use-case/export-documents.js +82 -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 +4 -0
- package/dist/modules/issue/domain/issue-markdown.js +112 -0
- package/dist/modules/issue/domain/issue-path.d.ts +23 -0
- package/dist/modules/issue/domain/issue-path.js +30 -0
- package/dist/modules/issue/domain/issue-repository.d.ts +11 -0
- package/dist/modules/issue/domain/issue-repository.js +1 -0
- package/dist/modules/issue/domain/issue.d.ts +47 -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 +38 -0
- package/dist/modules/issue/use-case/export-issues.d.ts +18 -0
- package/dist/modules/issue/use-case/export-issues.js +87 -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 +39 -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 +15 -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 +36 -0
- package/dist/modules/update/domain/update-plan.js +69 -0
- package/dist/modules/update/use-case/update-exports.d.ts +21 -0
- package/dist/modules/update/use-case/update-exports.js +136 -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 +1 -0
- package/dist/modules/wiki/domain/wiki-markdown.js +4 -0
- package/dist/modules/wiki/domain/wiki-path.d.ts +4 -0
- package/dist/modules/wiki/domain/wiki-path.js +25 -0
- package/dist/modules/wiki/domain/wiki-repository.d.ts +5 -0
- package/dist/modules/wiki/domain/wiki-repository.js +1 -0
- package/dist/modules/wiki/domain/wiki.d.ts +10 -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 +10 -0
- package/dist/modules/wiki/use-case/export-wikis.d.ts +14 -0
- package/dist/modules/wiki/use-case/export-wikis.js +43 -0
- package/dist/shared/backlog/http-client.d.ts +17 -0
- package/dist/shared/backlog/http-client.js +62 -0
- package/dist/shared/backlog/rate-limiter.d.ts +9 -0
- package/dist/shared/backlog/rate-limiter.js +25 -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 +4 -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 +2 -0
- package/dist/shared/file-name.js +15 -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 +5 -0
- package/dist/shared/storage/markdown-store.js +21 -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 +118 -26
- 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 @@
|
|
|
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,39 @@
|
|
|
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
|
+
await pruneDirectory(fullPath);
|
|
21
|
+
const remaining = await fs.readdir(fullPath);
|
|
22
|
+
if (remaining.length === 0 && !expectedDirs.has(relativePath.toLowerCase())) {
|
|
23
|
+
await fs.rmdir(fullPath);
|
|
24
|
+
options.log(`空のディレクトリを削除しました: ${relativePath}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
else if (entry.name.endsWith('.md') && !expectedFiles.has(relativePath.toLowerCase())) {
|
|
28
|
+
await fs.unlink(fullPath);
|
|
29
|
+
prunedCount++;
|
|
30
|
+
options.log(`Backlog上に存在しない${options.label}を削除しました: ${relativePath}`);
|
|
31
|
+
await appendLog(options.outputDir, `${options.label}「${relativePath}」を削除しました(Backlog上に存在しないため)`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
/* eslint-enable no-await-in-loop */
|
|
36
|
+
await pruneDirectory(options.outputDir);
|
|
37
|
+
options.log(`${prunedCount}件の不要な${options.label}ファイルを削除しました。`);
|
|
38
|
+
return prunedCount;
|
|
39
|
+
}
|
|
@@ -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
|
+
onRateLimitWait?: () => 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
|
+
onRateLimitWait: () => logger.log('レート制限を回避するため15秒間待機します...'),
|
|
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,15 @@
|
|
|
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
|
+
folderType?: FolderType;
|
|
10
|
+
issueKeyFileName?: boolean;
|
|
11
|
+
issueKeyFolder?: boolean;
|
|
12
|
+
lastUpdated?: string;
|
|
13
|
+
outputDir?: string;
|
|
14
|
+
projectIdOrKey?: string;
|
|
15
|
+
}
|
|
@@ -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,36 @@
|
|
|
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
|
+
force?: boolean;
|
|
8
|
+
issueIdOrKey?: string;
|
|
9
|
+
issueKeyFileName?: boolean;
|
|
10
|
+
issueKeyFolder?: boolean;
|
|
11
|
+
issuesOnly?: boolean;
|
|
12
|
+
projectIdOrKey?: string;
|
|
13
|
+
wikiId?: string;
|
|
14
|
+
wikisOnly?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface UpdatePlan {
|
|
17
|
+
documentIds?: string[];
|
|
18
|
+
domain?: string;
|
|
19
|
+
folderType?: FolderType;
|
|
20
|
+
issueIdOrKeys?: string[];
|
|
21
|
+
issueKeyFileName: boolean;
|
|
22
|
+
issueKeyFolder: boolean;
|
|
23
|
+
lastUpdated?: string;
|
|
24
|
+
projectIdOrKey?: string;
|
|
25
|
+
updateDocuments: boolean;
|
|
26
|
+
updateIssues: boolean;
|
|
27
|
+
updateWikis: boolean;
|
|
28
|
+
wikiIds?: string[];
|
|
29
|
+
}
|
|
30
|
+
export declare function parseIds(value: string | undefined): string[] | undefined;
|
|
31
|
+
export declare function determineUpdateTargets(folderType: FolderType | undefined, documentsOnly: boolean | undefined, issuesOnly: boolean | undefined, wikisOnly: boolean | undefined): {
|
|
32
|
+
updateDocuments: boolean;
|
|
33
|
+
updateIssues: boolean;
|
|
34
|
+
updateWikis: boolean;
|
|
35
|
+
};
|
|
36
|
+
export declare function buildUpdatePlan(settings: Settings, flags: UpdateFlags): UpdatePlan;
|
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
folderType: settings.folderType,
|
|
60
|
+
issueIdOrKeys,
|
|
61
|
+
// 設定ファイルからオプションを読み込み、コマンドライン引数で上書き
|
|
62
|
+
issueKeyFileName: flags.issueKeyFileName ?? settings.issueKeyFileName ?? false,
|
|
63
|
+
issueKeyFolder: flags.issueKeyFolder ?? settings.issueKeyFolder ?? false,
|
|
64
|
+
lastUpdated: settings.lastUpdated,
|
|
65
|
+
projectIdOrKey: flags.projectIdOrKey || settings.projectIdOrKey,
|
|
66
|
+
wikiIds,
|
|
67
|
+
...targets,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -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
|
+
onRateLimitWait?: () => 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,136 @@
|
|
|
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
|
+
onRateLimitWait: () => logger.log('レート制限を回避するため15秒間待機します...'),
|
|
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
|
+
issueIdOrKeys: plan.issueIdOrKeys,
|
|
69
|
+
issueKeyFileName: plan.issueKeyFileName,
|
|
70
|
+
issueKeyFolder: plan.issueKeyFolder,
|
|
71
|
+
// ID指定時は該当項目のみを取得するため、lastUpdatedによる絞り込みと最終更新日時の記録は行わない
|
|
72
|
+
lastUpdated: plan.issueIdOrKeys ? undefined : plan.lastUpdated,
|
|
73
|
+
outputDir: targetDir,
|
|
74
|
+
projectId,
|
|
75
|
+
});
|
|
76
|
+
if (!plan.issueIdOrKeys) {
|
|
77
|
+
await saveFullUpdateResult(FolderType.ISSUE);
|
|
78
|
+
}
|
|
79
|
+
logger.log('課題の更新が完了しました');
|
|
80
|
+
}
|
|
81
|
+
if (plan.updateWikis) {
|
|
82
|
+
logger.log('Wikiの更新を開始します...');
|
|
83
|
+
await exportWikis({ logger, wikiRepository }, {
|
|
84
|
+
domain: plan.domain,
|
|
85
|
+
lastUpdated: plan.wikiIds ? undefined : plan.lastUpdated,
|
|
86
|
+
outputDir: targetDir,
|
|
87
|
+
projectIdOrKey: plan.projectIdOrKey,
|
|
88
|
+
wikiIds: plan.wikiIds,
|
|
89
|
+
});
|
|
90
|
+
if (!plan.wikiIds) {
|
|
91
|
+
await saveFullUpdateResult(FolderType.WIKI);
|
|
92
|
+
}
|
|
93
|
+
logger.log('Wikiの更新が完了しました');
|
|
94
|
+
}
|
|
95
|
+
if (plan.updateDocuments) {
|
|
96
|
+
logger.log('ドキュメントの更新を開始します...');
|
|
97
|
+
await exportDocuments({ documentRepository, logger }, {
|
|
98
|
+
documentIds: plan.documentIds,
|
|
99
|
+
domain: plan.domain,
|
|
100
|
+
lastUpdated: plan.documentIds ? undefined : plan.lastUpdated,
|
|
101
|
+
outputDir: targetDir,
|
|
102
|
+
projectId,
|
|
103
|
+
projectIdOrKey: plan.projectIdOrKey,
|
|
104
|
+
});
|
|
105
|
+
if (!plan.documentIds) {
|
|
106
|
+
await saveFullUpdateResult(FolderType.DOCUMENT);
|
|
107
|
+
}
|
|
108
|
+
logger.log('ドキュメントの更新が完了しました');
|
|
109
|
+
}
|
|
110
|
+
logger.log(`${targetDir} の更新が完了しました!`);
|
|
111
|
+
}
|
|
112
|
+
async function confirmUpdate(logger, targetDir, plan, force) {
|
|
113
|
+
if (force)
|
|
114
|
+
return true;
|
|
115
|
+
logger.log(`以下の設定で更新を実行します:`);
|
|
116
|
+
logger.log(`- ディレクトリ: ${targetDir}`);
|
|
117
|
+
logger.log(`- ドメイン: ${plan.domain}`);
|
|
118
|
+
logger.log(`- プロジェクト: ${plan.projectIdOrKey}`);
|
|
119
|
+
if (plan.folderType) {
|
|
120
|
+
logger.log(`- フォルダタイプ: ${plan.folderType}`);
|
|
121
|
+
}
|
|
122
|
+
const updateTargets = [];
|
|
123
|
+
if (plan.updateIssues)
|
|
124
|
+
updateTargets.push('課題');
|
|
125
|
+
if (plan.updateWikis)
|
|
126
|
+
updateTargets.push('Wiki');
|
|
127
|
+
if (plan.updateDocuments)
|
|
128
|
+
updateTargets.push('ドキュメント');
|
|
129
|
+
logger.log(`- 更新対象: ${updateTargets.join('・')}`);
|
|
130
|
+
logger.log('更新を実行しますか? (y/n)');
|
|
131
|
+
const response = await readYesNo();
|
|
132
|
+
if (!response) {
|
|
133
|
+
logger.log('更新をキャンセルしました');
|
|
134
|
+
}
|
|
135
|
+
return response;
|
|
136
|
+
}
|
|
@@ -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 @@
|
|
|
1
|
+
export declare function buildWikiMarkdown(wikiName: string, backlogWikiUrl: string, content: string): string;
|