atl-fetch 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.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +113 -0
  3. package/dist/cli/cli.d.ts +61 -0
  4. package/dist/cli/cli.js +131 -0
  5. package/dist/cli/index.d.ts +5 -0
  6. package/dist/cli/index.js +4 -0
  7. package/dist/index.d.ts +8 -0
  8. package/dist/index.js +13 -0
  9. package/dist/ports/file/file-port.d.ts +89 -0
  10. package/dist/ports/file/file-port.js +155 -0
  11. package/dist/ports/file/index.d.ts +1 -0
  12. package/dist/ports/file/index.js +1 -0
  13. package/dist/ports/http/http-port.d.ts +107 -0
  14. package/dist/ports/http/http-port.js +238 -0
  15. package/dist/ports/http/index.d.ts +1 -0
  16. package/dist/ports/http/index.js +1 -0
  17. package/dist/services/auth/auth-service.d.ts +79 -0
  18. package/dist/services/auth/auth-service.js +158 -0
  19. package/dist/services/auth/index.d.ts +1 -0
  20. package/dist/services/auth/index.js +1 -0
  21. package/dist/services/confluence/confluence-service.d.ts +152 -0
  22. package/dist/services/confluence/confluence-service.js +510 -0
  23. package/dist/services/confluence/index.d.ts +1 -0
  24. package/dist/services/confluence/index.js +1 -0
  25. package/dist/services/diff/diff-service.d.ts +84 -0
  26. package/dist/services/diff/diff-service.js +881 -0
  27. package/dist/services/diff/index.d.ts +1 -0
  28. package/dist/services/diff/index.js +1 -0
  29. package/dist/services/fetch/fetch-service.d.ts +112 -0
  30. package/dist/services/fetch/fetch-service.js +302 -0
  31. package/dist/services/fetch/index.d.ts +1 -0
  32. package/dist/services/fetch/index.js +1 -0
  33. package/dist/services/jira/index.d.ts +1 -0
  34. package/dist/services/jira/index.js +1 -0
  35. package/dist/services/jira/jira-service.d.ts +100 -0
  36. package/dist/services/jira/jira-service.js +354 -0
  37. package/dist/services/output/index.d.ts +4 -0
  38. package/dist/services/output/index.js +4 -0
  39. package/dist/services/output/output-service.d.ts +67 -0
  40. package/dist/services/output/output-service.js +228 -0
  41. package/dist/services/storage/index.d.ts +6 -0
  42. package/dist/services/storage/index.js +6 -0
  43. package/dist/services/storage/storage-service.d.ts +77 -0
  44. package/dist/services/storage/storage-service.js +738 -0
  45. package/dist/services/text-converter/index.d.ts +1 -0
  46. package/dist/services/text-converter/index.js +1 -0
  47. package/dist/services/text-converter/text-converter.d.ts +35 -0
  48. package/dist/services/text-converter/text-converter.js +681 -0
  49. package/dist/services/url-parser/index.d.ts +1 -0
  50. package/dist/services/url-parser/index.js +1 -0
  51. package/dist/services/url-parser/url-parser.d.ts +43 -0
  52. package/dist/services/url-parser/url-parser.js +283 -0
  53. package/dist/types/auth.d.ts +25 -0
  54. package/dist/types/auth.js +1 -0
  55. package/dist/types/confluence.d.ts +68 -0
  56. package/dist/types/confluence.js +1 -0
  57. package/dist/types/diff.d.ts +77 -0
  58. package/dist/types/diff.js +7 -0
  59. package/dist/types/fetch.d.ts +65 -0
  60. package/dist/types/fetch.js +1 -0
  61. package/dist/types/file.d.ts +22 -0
  62. package/dist/types/file.js +1 -0
  63. package/dist/types/http.d.ts +45 -0
  64. package/dist/types/http.js +1 -0
  65. package/dist/types/jira.d.ts +90 -0
  66. package/dist/types/jira.js +1 -0
  67. package/dist/types/output.d.ts +55 -0
  68. package/dist/types/output.js +7 -0
  69. package/dist/types/result.d.ts +104 -0
  70. package/dist/types/result.js +119 -0
  71. package/dist/types/storage.d.ts +209 -0
  72. package/dist/types/storage.js +6 -0
  73. package/dist/types/url-parser.d.ts +46 -0
  74. package/dist/types/url-parser.js +1 -0
  75. package/package.json +106 -0
@@ -0,0 +1,738 @@
1
+ /**
2
+ * ストレージサービス
3
+ *
4
+ * Jira Issue / Confluence ページをディレクトリ構造で保存する。
5
+ * design.md の Output Directory Structure に従って保存する。
6
+ */
7
+ import { join } from 'node:path';
8
+ import { err, ok } from 'neverthrow';
9
+ import { ensureDir, writeFileContent } from '../../ports/file/file-port.js';
10
+ import { downloadConfluenceAttachment } from '../confluence/confluence-service.js';
11
+ import { diffText } from '../diff/diff-service.js';
12
+ import { downloadJiraAttachment } from '../jira/jira-service.js';
13
+ import { convertStorageFormatToMarkdown } from '../text-converter/text-converter.js';
14
+ /**
15
+ * 現在時刻を ISO 8601 形式で取得する
16
+ *
17
+ * @returns ISO 8601 形式の日時文字列
18
+ */
19
+ const getCurrentTimestamp = () => {
20
+ return new Date().toISOString();
21
+ };
22
+ /**
23
+ * Jira Issue 用の Manifest を生成する
24
+ *
25
+ * @param data 保存データ
26
+ * @param options 保存オプション
27
+ * @param attachmentResults 添付ファイルのダウンロード結果
28
+ * @returns Manifest オブジェクト
29
+ */
30
+ const createJiraManifest = (data, options, attachmentResults) => {
31
+ return {
32
+ attachments: [...attachmentResults],
33
+ cliVersion: options.cliVersion,
34
+ fetchedAt: getCurrentTimestamp(),
35
+ issues: [],
36
+ resourceType: 'jiraIssue',
37
+ sourceUrl: options.sourceUrl,
38
+ summary: {
39
+ resourceId: data.key,
40
+ success: true,
41
+ title: data.summary,
42
+ },
43
+ };
44
+ };
45
+ /**
46
+ * Jira Issue をディレクトリ構造で保存する
47
+ *
48
+ * 以下のディレクトリ構造で保存する:
49
+ * ```
50
+ * {baseDir}/jira/{ISSUE-KEY}/
51
+ * ├── manifest.json # 取得メタデータ
52
+ * ├── issue.json # Issue 全データ(JSON 形式)
53
+ * ├── description.txt # 説明文のプレーンテキスト
54
+ * ├── content.md # Markdown 形式
55
+ * ├── changelog.json # 変更履歴
56
+ * ├── comments.json # コメント一覧
57
+ * ├── attachments.json # 添付ファイル一覧メタデータ
58
+ * └── attachments/ # 添付ファイル実体
59
+ * └── {id}_{filename}
60
+ * ```
61
+ *
62
+ * @param data 保存データ
63
+ * @param options 保存オプション
64
+ * @returns 成功時は Ok(JiraSaveResult)、失敗時は Err(StorageError)
65
+ */
66
+ export const saveJiraIssue = async (data, options) => {
67
+ // 保存先ディレクトリのパスを構築
68
+ const issueDir = join(options.baseDir, 'jira', data.key);
69
+ // ディレクトリを作成
70
+ const mkdirResult = await ensureDir(issueDir);
71
+ if (mkdirResult.isErr()) {
72
+ return err({
73
+ kind: 'DIRECTORY_CREATE_FAILED',
74
+ message: `ディレクトリの作成に失敗しました: ${mkdirResult.error.message}`,
75
+ path: issueDir,
76
+ });
77
+ }
78
+ // 添付ファイルディレクトリを作成
79
+ const attachmentsDir = join(issueDir, 'attachments');
80
+ const attachmentsDirResult = await ensureDir(attachmentsDir);
81
+ if (attachmentsDirResult.isErr()) {
82
+ return err({
83
+ kind: 'DIRECTORY_CREATE_FAILED',
84
+ message: `attachments ディレクトリの作成に失敗しました: ${attachmentsDirResult.error.message}`,
85
+ path: attachmentsDir,
86
+ });
87
+ }
88
+ // 各添付ファイルをダウンロード
89
+ const attachmentResults = await Promise.all(data.attachments.map(async (att) => {
90
+ // ファイル名を安全な形式に変換(ID_ファイル名)
91
+ const safeFilename = `${att.id}_${att.filename}`;
92
+ const destPath = join(attachmentsDir, safeFilename);
93
+ const downloadResult = await downloadJiraAttachment(att, destPath);
94
+ if (downloadResult.isOk()) {
95
+ return {
96
+ filename: att.filename,
97
+ id: att.id,
98
+ mimeType: att.mimeType,
99
+ savedPath: `attachments/${safeFilename}`,
100
+ size: att.size,
101
+ status: 'success',
102
+ };
103
+ }
104
+ return {
105
+ error: downloadResult.error.message,
106
+ filename: att.filename,
107
+ id: att.id,
108
+ mimeType: att.mimeType,
109
+ size: att.size,
110
+ status: 'failed',
111
+ };
112
+ }));
113
+ // Manifest を生成
114
+ const manifest = createJiraManifest(data, options, attachmentResults);
115
+ // manifest.json を保存
116
+ const manifestPath = join(issueDir, 'manifest.json');
117
+ const manifestWriteResult = await writeFileContent(manifestPath, JSON.stringify(manifest, null, 2));
118
+ if (manifestWriteResult.isErr()) {
119
+ return err({
120
+ kind: 'FILE_WRITE_FAILED',
121
+ message: `manifest.json の書き込みに失敗しました: ${manifestWriteResult.error.message}`,
122
+ path: manifestPath,
123
+ });
124
+ }
125
+ // issue.json を保存(Issue 全データ)
126
+ const issueData = {
127
+ attachments: data.attachments,
128
+ changelog: data.changelog,
129
+ comments: data.comments,
130
+ description: data.description,
131
+ key: data.key,
132
+ summary: data.summary,
133
+ };
134
+ const issuePath = join(issueDir, 'issue.json');
135
+ const issueWriteResult = await writeFileContent(issuePath, JSON.stringify(issueData, null, 2));
136
+ if (issueWriteResult.isErr()) {
137
+ return err({
138
+ kind: 'FILE_WRITE_FAILED',
139
+ message: `issue.json の書き込みに失敗しました: ${issueWriteResult.error.message}`,
140
+ path: issuePath,
141
+ });
142
+ }
143
+ // description.txt を保存(プレーンテキスト)
144
+ const descPath = join(issueDir, 'description.txt');
145
+ const descWriteResult = await writeFileContent(descPath, data.descriptionPlainText ?? '');
146
+ if (descWriteResult.isErr()) {
147
+ return err({
148
+ kind: 'FILE_WRITE_FAILED',
149
+ message: `description.txt の書き込みに失敗しました: ${descWriteResult.error.message}`,
150
+ path: descPath,
151
+ });
152
+ }
153
+ // content.md を保存(Markdown 形式)
154
+ const markdownContent = formatJiraIssueAsMarkdown(data, attachmentResults);
155
+ const markdownPath = join(issueDir, 'content.md');
156
+ const markdownWriteResult = await writeFileContent(markdownPath, markdownContent);
157
+ if (markdownWriteResult.isErr()) {
158
+ return err({
159
+ kind: 'FILE_WRITE_FAILED',
160
+ message: `content.md の書き込みに失敗しました: ${markdownWriteResult.error.message}`,
161
+ path: markdownPath,
162
+ });
163
+ }
164
+ // changelog.json を保存
165
+ const changelogPath = join(issueDir, 'changelog.json');
166
+ const changelogWriteResult = await writeFileContent(changelogPath, JSON.stringify(data.changelog, null, 2));
167
+ if (changelogWriteResult.isErr()) {
168
+ return err({
169
+ kind: 'FILE_WRITE_FAILED',
170
+ message: `changelog.json の書き込みに失敗しました: ${changelogWriteResult.error.message}`,
171
+ path: changelogPath,
172
+ });
173
+ }
174
+ // comments.json を保存
175
+ const commentsPath = join(issueDir, 'comments.json');
176
+ const commentsWriteResult = await writeFileContent(commentsPath, JSON.stringify(data.comments, null, 2));
177
+ if (commentsWriteResult.isErr()) {
178
+ return err({
179
+ kind: 'FILE_WRITE_FAILED',
180
+ message: `comments.json の書き込みに失敗しました: ${commentsWriteResult.error.message}`,
181
+ path: commentsPath,
182
+ });
183
+ }
184
+ // attachments.json を保存(メタデータのみ)
185
+ const attachmentsPath = join(issueDir, 'attachments.json');
186
+ const attachmentsWriteResult = await writeFileContent(attachmentsPath, JSON.stringify(data.attachments, null, 2));
187
+ if (attachmentsWriteResult.isErr()) {
188
+ return err({
189
+ kind: 'FILE_WRITE_FAILED',
190
+ message: `attachments.json の書き込みに失敗しました: ${attachmentsWriteResult.error.message}`,
191
+ path: attachmentsPath,
192
+ });
193
+ }
194
+ return ok({
195
+ directory: issueDir,
196
+ manifest,
197
+ });
198
+ };
199
+ /**
200
+ * Jira Issue を Markdown 形式にフォーマットする(ファイル保存用)
201
+ *
202
+ * @param data 保存データ
203
+ * @param attachmentResults 添付ファイルのダウンロード結果
204
+ * @returns Markdown 文字列
205
+ */
206
+ const formatJiraIssueAsMarkdown = (data, attachmentResults) => {
207
+ const lines = [];
208
+ // Title
209
+ lines.push(`# ${data.key}`);
210
+ lines.push('');
211
+ lines.push(`**${data.summary}**`);
212
+ lines.push('');
213
+ // Description
214
+ lines.push('## Description');
215
+ lines.push('');
216
+ lines.push(data.descriptionPlainText ?? '(No description)');
217
+ lines.push('');
218
+ // Comments
219
+ lines.push('## Comments');
220
+ lines.push('');
221
+ if (data.comments.length === 0) {
222
+ lines.push('No comments');
223
+ }
224
+ else {
225
+ for (const comment of data.comments) {
226
+ lines.push(`### ${comment.author} (${comment.created})`);
227
+ lines.push('');
228
+ lines.push(comment.body);
229
+ lines.push('');
230
+ }
231
+ }
232
+ // Changelog
233
+ lines.push('## Changelog');
234
+ lines.push('');
235
+ if (data.changelog.length === 0) {
236
+ lines.push('No changelog');
237
+ }
238
+ else {
239
+ for (const entry of data.changelog) {
240
+ lines.push(`### ${entry.author} (${entry.created})`);
241
+ lines.push('');
242
+ for (const item of entry.items) {
243
+ const from = item.fromString ?? '(empty)';
244
+ const to = item.toString ?? '(empty)';
245
+ lines.push(`- **${item.field}**: ${from} → ${to}`);
246
+ }
247
+ lines.push('');
248
+ }
249
+ }
250
+ // Attachments
251
+ lines.push('## Attachments');
252
+ lines.push('');
253
+ if (attachmentResults.length === 0) {
254
+ lines.push('No attachments');
255
+ }
256
+ else {
257
+ for (const att of attachmentResults) {
258
+ const sizeKB = (att.size / 1024).toFixed(1);
259
+ if (att.status === 'success' && att.savedPath !== undefined) {
260
+ // 画像の場合は Markdown 画像参照
261
+ if (att.mimeType.startsWith('image/')) {
262
+ lines.push(`![${att.filename}](${att.savedPath})`);
263
+ }
264
+ else {
265
+ lines.push(`- [${att.filename}](${att.savedPath}) (${att.mimeType}, ${sizeKB} KB)`);
266
+ }
267
+ }
268
+ else {
269
+ lines.push(`- **${att.filename}** (${att.mimeType}, ${sizeKB} KB) - ダウンロード失敗`);
270
+ }
271
+ }
272
+ }
273
+ return lines.join('\n');
274
+ };
275
+ /**
276
+ * Confluence ページ用の Manifest を生成する
277
+ *
278
+ * @param data 保存データ
279
+ * @param options 保存オプション
280
+ * @param attachmentResults 添付ファイルのダウンロード結果
281
+ * @returns Manifest オブジェクト
282
+ */
283
+ const createConfluenceManifest = (data, options, attachmentResults) => {
284
+ return {
285
+ attachments: [...attachmentResults],
286
+ cliVersion: options.cliVersion,
287
+ fetchedAt: getCurrentTimestamp(),
288
+ issues: [],
289
+ resourceType: 'confluencePage',
290
+ sourceUrl: options.sourceUrl,
291
+ summary: {
292
+ resourceId: data.id,
293
+ success: true,
294
+ title: data.title,
295
+ },
296
+ };
297
+ };
298
+ /**
299
+ * Confluence ページをディレクトリ構造で保存する
300
+ *
301
+ * 以下のディレクトリ構造で保存する:
302
+ * ```
303
+ * {baseDir}/confluence/{PAGE-ID}/
304
+ * ├── manifest.json # 取得メタデータ
305
+ * ├── page.json # ページ全データ(JSON 形式)
306
+ * ├── content.txt # 本文のプレーンテキスト(最新版)
307
+ * ├── versions.json # バージョン一覧メタデータ
308
+ * ├── attachments.json # 添付ファイル一覧メタデータ
309
+ * └── attachments/ # 添付ファイル実体
310
+ * └── {id}_{filename}
311
+ * ```
312
+ *
313
+ * @param data 保存データ
314
+ * @param options 保存オプション
315
+ * @returns 成功時は Ok(ConfluenceSaveResult)、失敗時は Err(StorageError)
316
+ */
317
+ export const saveConfluencePage = async (data, options) => {
318
+ // 保存先ディレクトリのパスを構築
319
+ const pageDir = join(options.baseDir, 'confluence', data.id);
320
+ // ディレクトリを作成
321
+ const mkdirResult = await ensureDir(pageDir);
322
+ if (mkdirResult.isErr()) {
323
+ return err({
324
+ kind: 'DIRECTORY_CREATE_FAILED',
325
+ message: `ディレクトリの作成に失敗しました: ${mkdirResult.error.message}`,
326
+ path: pageDir,
327
+ });
328
+ }
329
+ // 添付ファイルディレクトリを作成
330
+ const attachmentsDir = join(pageDir, 'attachments');
331
+ const attachmentsDirResult = await ensureDir(attachmentsDir);
332
+ if (attachmentsDirResult.isErr()) {
333
+ return err({
334
+ kind: 'DIRECTORY_CREATE_FAILED',
335
+ message: `attachments ディレクトリの作成に失敗しました: ${attachmentsDirResult.error.message}`,
336
+ path: attachmentsDir,
337
+ });
338
+ }
339
+ // 各添付ファイルをダウンロード
340
+ const attachmentResults = await Promise.all(data.attachments.map(async (att) => {
341
+ // ファイル名を安全な形式に変換(ID_タイトル)
342
+ const safeFilename = `${att.id}_${att.title}`;
343
+ const destPath = join(attachmentsDir, safeFilename);
344
+ const downloadResult = await downloadConfluenceAttachment(att, destPath);
345
+ if (downloadResult.isOk()) {
346
+ return {
347
+ filename: att.title,
348
+ id: att.id,
349
+ mimeType: att.mediaType,
350
+ savedPath: `attachments/${safeFilename}`,
351
+ size: att.fileSize,
352
+ status: 'success',
353
+ };
354
+ }
355
+ return {
356
+ error: downloadResult.error.message,
357
+ filename: att.title,
358
+ id: att.id,
359
+ mimeType: att.mediaType,
360
+ size: att.fileSize,
361
+ status: 'failed',
362
+ };
363
+ }));
364
+ // Manifest を生成
365
+ const manifest = createConfluenceManifest(data, options, attachmentResults);
366
+ // manifest.json を保存
367
+ const manifestPath = join(pageDir, 'manifest.json');
368
+ const manifestWriteResult = await writeFileContent(manifestPath, JSON.stringify(manifest, null, 2));
369
+ if (manifestWriteResult.isErr()) {
370
+ return err({
371
+ kind: 'FILE_WRITE_FAILED',
372
+ message: `manifest.json の書き込みに失敗しました: ${manifestWriteResult.error.message}`,
373
+ path: manifestPath,
374
+ });
375
+ }
376
+ // page.json を保存(ページ全データ)
377
+ const pageData = {
378
+ attachments: data.attachments,
379
+ body: data.body,
380
+ currentVersion: data.currentVersion,
381
+ id: data.id,
382
+ spaceKey: data.spaceKey,
383
+ title: data.title,
384
+ versions: data.versions,
385
+ };
386
+ const pagePath = join(pageDir, 'page.json');
387
+ const pageWriteResult = await writeFileContent(pagePath, JSON.stringify(pageData, null, 2));
388
+ if (pageWriteResult.isErr()) {
389
+ return err({
390
+ kind: 'FILE_WRITE_FAILED',
391
+ message: `page.json の書き込みに失敗しました: ${pageWriteResult.error.message}`,
392
+ path: pagePath,
393
+ });
394
+ }
395
+ // content.txt を保存(プレーンテキスト)
396
+ const contentPath = join(pageDir, 'content.txt');
397
+ const contentWriteResult = await writeFileContent(contentPath, data.bodyPlainText);
398
+ if (contentWriteResult.isErr()) {
399
+ return err({
400
+ kind: 'FILE_WRITE_FAILED',
401
+ message: `content.txt の書き込みに失敗しました: ${contentWriteResult.error.message}`,
402
+ path: contentPath,
403
+ });
404
+ }
405
+ // content.md を保存(Markdown 形式)
406
+ const markdownContent = formatConfluencePageAsMarkdown(data, attachmentResults);
407
+ const markdownPath = join(pageDir, 'content.md');
408
+ const markdownWriteResult = await writeFileContent(markdownPath, markdownContent);
409
+ if (markdownWriteResult.isErr()) {
410
+ return err({
411
+ kind: 'FILE_WRITE_FAILED',
412
+ message: `content.md の書き込みに失敗しました: ${markdownWriteResult.error.message}`,
413
+ path: markdownPath,
414
+ });
415
+ }
416
+ // versions.json を保存
417
+ const versionsPath = join(pageDir, 'versions.json');
418
+ const versionsWriteResult = await writeFileContent(versionsPath, JSON.stringify(data.versions, null, 2));
419
+ if (versionsWriteResult.isErr()) {
420
+ return err({
421
+ kind: 'FILE_WRITE_FAILED',
422
+ message: `versions.json の書き込みに失敗しました: ${versionsWriteResult.error.message}`,
423
+ path: versionsPath,
424
+ });
425
+ }
426
+ // attachments.json を保存(メタデータのみ)
427
+ const attachmentsPath = join(pageDir, 'attachments.json');
428
+ const attachmentsWriteResult = await writeFileContent(attachmentsPath, JSON.stringify(data.attachments, null, 2));
429
+ if (attachmentsWriteResult.isErr()) {
430
+ return err({
431
+ kind: 'FILE_WRITE_FAILED',
432
+ message: `attachments.json の書き込みに失敗しました: ${attachmentsWriteResult.error.message}`,
433
+ path: attachmentsPath,
434
+ });
435
+ }
436
+ return ok({
437
+ directory: pageDir,
438
+ manifest,
439
+ });
440
+ };
441
+ /**
442
+ * HTML タグを除去してプレーンテキストに変換する
443
+ *
444
+ * @param html HTML 文字列
445
+ * @returns プレーンテキスト
446
+ */
447
+ const stripHtmlTags = (html) => {
448
+ // HTML タグを除去
449
+ let text = html.replace(/<[^>]*>/g, ' ');
450
+ // HTML エンティティをデコード
451
+ text = text.replace(/&nbsp;/g, ' ');
452
+ text = text.replace(/&amp;/g, '&');
453
+ text = text.replace(/&lt;/g, '<');
454
+ text = text.replace(/&gt;/g, '>');
455
+ text = text.replace(/&quot;/g, '"');
456
+ text = text.replace(/&#39;/g, "'");
457
+ // 連続する空白を単一スペースに
458
+ text = text.replace(/\s+/g, ' ');
459
+ // 前後の空白を除去
460
+ text = text.trim();
461
+ return text;
462
+ };
463
+ /**
464
+ * Confluence ページを Markdown 形式にフォーマットする(ファイル保存用)
465
+ *
466
+ * content.md は本文(body)のみを Markdown 形式で保存する。
467
+ * メタデータ、Version History、Attachments は含めない。
468
+ *
469
+ * @param data 保存データ
470
+ * @param attachmentResults 添付ファイルのダウンロード結果
471
+ * @returns Markdown 文字列
472
+ */
473
+ const formatConfluencePageAsMarkdown = (data, attachmentResults) => {
474
+ // 添付ファイルマッピングを生成(filename → savedPath)
475
+ const attachmentPaths = {};
476
+ for (const att of attachmentResults) {
477
+ if (att.status === 'success' && att.savedPath !== undefined) {
478
+ attachmentPaths[att.filename] = att.savedPath;
479
+ }
480
+ }
481
+ // HTML → Markdown 変換(本文のみ)
482
+ return convertStorageFormatToMarkdown(data.body, attachmentPaths);
483
+ };
484
+ /**
485
+ * Confluence バージョンを Markdown 形式にフォーマットする
486
+ *
487
+ * versions/vN/content.md は本文(body)のみを Markdown 形式で保存する。
488
+ * メタデータや差分情報は含めない。
489
+ *
490
+ * @param version バージョン情報
491
+ * @param _diffFormatted 差分テキスト(未使用、互換性のため保持)
492
+ * @returns Markdown 文字列
493
+ */
494
+ const formatVersionAsMarkdown = (version, _diffFormatted) => {
495
+ // HTML → Markdown 変換(本文のみ)
496
+ // バージョンには添付ファイルマッピングがないため空のオブジェクトを渡す
497
+ return convertStorageFormatToMarkdown(version.body, {});
498
+ };
499
+ /**
500
+ * Confluence ページの各バージョンをディレクトリ構造で保存する
501
+ *
502
+ * 以下のディレクトリ構造で保存する:
503
+ * ```
504
+ * {pageDir}/versions/
505
+ * ├── v1/
506
+ * │ ├── content.json # v1 の全データ
507
+ * │ └── content.txt # v1 のプレーンテキスト
508
+ * ├── v2/
509
+ * │ ├── content.json # v2 の全データ
510
+ * │ ├── content.txt # v2 のプレーンテキスト
511
+ * │ ├── diff.txt # v1 → v2 の差分(unified diff形式)
512
+ * │ └── diff.json # v1 → v2 の差分メタデータ
513
+ * └── v3/
514
+ * ├── content.json
515
+ * ├── content.txt
516
+ * ├── diff.txt # v2 → v3 の差分
517
+ * └── diff.json
518
+ * ```
519
+ *
520
+ * @param versions バージョン配列(バージョン番号順にソートされていることを期待)
521
+ * @param pageDir 保存先のページディレクトリ
522
+ * @returns 成功時は Ok(void)、失敗時は Err(StorageError)
523
+ */
524
+ export const saveConfluenceVersions = async (versions, pageDir) => {
525
+ // 空配列の場合は何もしない
526
+ if (versions.length === 0) {
527
+ return ok(undefined);
528
+ }
529
+ // versions ディレクトリを作成
530
+ const versionsDir = join(pageDir, 'versions');
531
+ const mkdirResult = await ensureDir(versionsDir);
532
+ if (mkdirResult.isErr()) {
533
+ return err({
534
+ kind: 'DIRECTORY_CREATE_FAILED',
535
+ message: `versions ディレクトリの作成に失敗しました: ${mkdirResult.error.message}`,
536
+ path: versionsDir,
537
+ });
538
+ }
539
+ // バージョン番号順にソート
540
+ const sortedVersions = [...versions].sort((a, b) => a.number - b.number);
541
+ // 各バージョンを保存
542
+ for (let i = 0; i < sortedVersions.length; i++) {
543
+ const version = sortedVersions[i];
544
+ if (version === undefined) {
545
+ continue;
546
+ }
547
+ const versionDir = join(versionsDir, `v${version.number}`);
548
+ // バージョンディレクトリを作成
549
+ const versionMkdirResult = await ensureDir(versionDir);
550
+ if (versionMkdirResult.isErr()) {
551
+ return err({
552
+ kind: 'DIRECTORY_CREATE_FAILED',
553
+ message: `v${version.number} ディレクトリの作成に失敗しました: ${versionMkdirResult.error.message}`,
554
+ path: versionDir,
555
+ });
556
+ }
557
+ // content.json を保存
558
+ const contentJsonPath = join(versionDir, 'content.json');
559
+ const contentJsonData = {
560
+ body: version.body ?? '',
561
+ by: version.by,
562
+ message: version.message,
563
+ number: version.number,
564
+ when: version.when,
565
+ };
566
+ const contentJsonResult = await writeFileContent(contentJsonPath, JSON.stringify(contentJsonData, null, 2));
567
+ if (contentJsonResult.isErr()) {
568
+ return err({
569
+ kind: 'FILE_WRITE_FAILED',
570
+ message: `content.json の書き込みに失敗しました: ${contentJsonResult.error.message}`,
571
+ path: contentJsonPath,
572
+ });
573
+ }
574
+ // content.txt を保存(HTML をプレーンテキストに変換)
575
+ const contentTxtPath = join(versionDir, 'content.txt');
576
+ const bodyText = version.body !== undefined ? stripHtmlTags(version.body) : '';
577
+ const contentTxtResult = await writeFileContent(contentTxtPath, bodyText);
578
+ if (contentTxtResult.isErr()) {
579
+ return err({
580
+ kind: 'FILE_WRITE_FAILED',
581
+ message: `content.txt の書き込みに失敗しました: ${contentTxtResult.error.message}`,
582
+ path: contentTxtPath,
583
+ });
584
+ }
585
+ // v2 以降は差分を生成
586
+ let diffFormatted;
587
+ if (i > 0) {
588
+ const prevVersion = sortedVersions[i - 1];
589
+ if (prevVersion !== undefined) {
590
+ const prevBody = prevVersion.body ?? '';
591
+ const currentBody = version.body ?? '';
592
+ // 差分を計算
593
+ const diffResult = diffText(prevBody, currentBody, { colorEnabled: false });
594
+ diffFormatted = diffResult.formatted || '(変更なし)';
595
+ // diff.txt を保存
596
+ const diffTxtPath = join(versionDir, 'diff.txt');
597
+ const diffTxtWriteResult = await writeFileContent(diffTxtPath, diffFormatted);
598
+ if (diffTxtWriteResult.isErr()) {
599
+ return err({
600
+ kind: 'FILE_WRITE_FAILED',
601
+ message: `diff.txt の書き込みに失敗しました: ${diffTxtWriteResult.error.message}`,
602
+ path: diffTxtPath,
603
+ });
604
+ }
605
+ // diff.json を保存
606
+ const diffJsonPath = join(versionDir, 'diff.json');
607
+ const versionDiff = {
608
+ fromVersion: prevVersion.number,
609
+ generatedAt: getCurrentTimestamp(),
610
+ hunks: diffResult.hunks,
611
+ stats: diffResult.stats,
612
+ toVersion: version.number,
613
+ };
614
+ const diffJsonWriteResult = await writeFileContent(diffJsonPath, JSON.stringify(versionDiff, null, 2));
615
+ if (diffJsonWriteResult.isErr()) {
616
+ return err({
617
+ kind: 'FILE_WRITE_FAILED',
618
+ message: `diff.json の書き込みに失敗しました: ${diffJsonWriteResult.error.message}`,
619
+ path: diffJsonPath,
620
+ });
621
+ }
622
+ }
623
+ }
624
+ // content.md を保存(Markdown 形式、差分含む)
625
+ const versionMarkdownContent = formatVersionAsMarkdown(version, diffFormatted);
626
+ const versionMarkdownPath = join(versionDir, 'content.md');
627
+ const versionMarkdownResult = await writeFileContent(versionMarkdownPath, versionMarkdownContent);
628
+ if (versionMarkdownResult.isErr()) {
629
+ return err({
630
+ kind: 'FILE_WRITE_FAILED',
631
+ message: `content.md の書き込みに失敗しました: ${versionMarkdownResult.error.message}`,
632
+ path: versionMarkdownPath,
633
+ });
634
+ }
635
+ }
636
+ return ok(undefined);
637
+ };
638
+ // In-source testing for private functions
639
+ if (import.meta.vitest) {
640
+ const { describe, expect, it } = import.meta.vitest;
641
+ describe('stripHtmlTags (in-source testing)', () => {
642
+ describe('HTML タグの除去', () => {
643
+ // テストの目的: HTML タグが正しく除去されること
644
+ it('Given: 単純な HTML タグ, When: stripHtmlTags を呼び出す, Then: タグが除去される', () => {
645
+ expect(stripHtmlTags('<p>Hello</p>')).toBe('Hello');
646
+ });
647
+ // テストの目的: 複数の HTML タグが正しく除去されること
648
+ it('Given: 複数の HTML タグ, When: stripHtmlTags を呼び出す, Then: すべてのタグが除去される', () => {
649
+ expect(stripHtmlTags('<h1>Title</h1><p>Body</p>')).toBe('Title Body');
650
+ });
651
+ // テストの目的: ネストした HTML タグが正しく除去されること
652
+ it('Given: ネストした HTML タグ, When: stripHtmlTags を呼び出す, Then: すべてのタグが除去される', () => {
653
+ expect(stripHtmlTags('<div><p><strong>Bold</strong> text</p></div>')).toBe('Bold text');
654
+ });
655
+ });
656
+ describe('HTML エンティティのデコード', () => {
657
+ // テストの目的: &nbsp; が半角スペースに変換されること
658
+ it('Given: &nbsp; エンティティ, When: stripHtmlTags を呼び出す, Then: 半角スペースに変換される', () => {
659
+ expect(stripHtmlTags('Hello&nbsp;World')).toBe('Hello World');
660
+ });
661
+ // テストの目的: &amp; がアンパサンドに変換されること
662
+ it('Given: &amp; エンティティ, When: stripHtmlTags を呼び出す, Then: & に変換される', () => {
663
+ expect(stripHtmlTags('A &amp; B')).toBe('A & B');
664
+ });
665
+ // テストの目的: &lt; が < に変換されること
666
+ it('Given: &lt; エンティティ, When: stripHtmlTags を呼び出す, Then: < に変換される', () => {
667
+ expect(stripHtmlTags('1 &lt; 2')).toBe('1 < 2');
668
+ });
669
+ // テストの目的: &gt; が > に変換されること
670
+ it('Given: &gt; エンティティ, When: stripHtmlTags を呼び出す, Then: > に変換される', () => {
671
+ expect(stripHtmlTags('2 &gt; 1')).toBe('2 > 1');
672
+ });
673
+ // テストの目的: &quot; がダブルクォートに変換されること
674
+ it('Given: &quot; エンティティ, When: stripHtmlTags を呼び出す, Then: " に変換される', () => {
675
+ expect(stripHtmlTags('Say &quot;Hello&quot;')).toBe('Say "Hello"');
676
+ });
677
+ // テストの目的: &#39; がシングルクォートに変換されること
678
+ it("Given: &#39; エンティティ, When: stripHtmlTags を呼び出す, Then: ' に変換される", () => {
679
+ expect(stripHtmlTags('It&#39;s fine')).toBe("It's fine");
680
+ });
681
+ // テストの目的: 複数の HTML エンティティが正しく変換されること
682
+ it('Given: 複数の HTML エンティティ, When: stripHtmlTags を呼び出す, Then: すべて正しく変換される', () => {
683
+ expect(stripHtmlTags('&lt;div&gt; &amp; &quot;test&quot;')).toBe('<div> & "test"');
684
+ });
685
+ });
686
+ describe('連続空白の正規化', () => {
687
+ // テストの目的: 連続する空白が単一スペースに正規化されること
688
+ it('Given: 連続する空白, When: stripHtmlTags を呼び出す, Then: 単一スペースに正規化される', () => {
689
+ expect(stripHtmlTags('Hello World')).toBe('Hello World');
690
+ });
691
+ // テストの目的: 改行が空白に変換されること
692
+ it('Given: 改行を含む文字列, When: stripHtmlTags を呼び出す, Then: 単一スペースに正規化される', () => {
693
+ expect(stripHtmlTags('Hello\n\nWorld')).toBe('Hello World');
694
+ });
695
+ // テストの目的: タブが空白に変換されること
696
+ it('Given: タブを含む文字列, When: stripHtmlTags を呼び出す, Then: 単一スペースに正規化される', () => {
697
+ expect(stripHtmlTags('Hello\t\tWorld')).toBe('Hello World');
698
+ });
699
+ });
700
+ describe('前後空白の除去', () => {
701
+ // テストの目的: 先頭の空白が除去されること
702
+ it('Given: 先頭に空白がある文字列, When: stripHtmlTags を呼び出す, Then: 先頭の空白が除去される', () => {
703
+ expect(stripHtmlTags(' Hello')).toBe('Hello');
704
+ });
705
+ // テストの目的: 末尾の空白が除去されること
706
+ it('Given: 末尾に空白がある文字列, When: stripHtmlTags を呼び出す, Then: 末尾の空白が除去される', () => {
707
+ expect(stripHtmlTags('Hello ')).toBe('Hello');
708
+ });
709
+ // テストの目的: 前後の空白が除去されること
710
+ it('Given: 前後に空白がある文字列, When: stripHtmlTags を呼び出す, Then: 前後の空白が除去される', () => {
711
+ expect(stripHtmlTags(' Hello ')).toBe('Hello');
712
+ });
713
+ // テストの目的: 改行を含む前後の空白が除去されること
714
+ it('Given: 改行を含む前後の空白, When: stripHtmlTags を呼び出す, Then: 前後の空白が除去される', () => {
715
+ expect(stripHtmlTags('\n\nHello\n\n')).toBe('Hello');
716
+ });
717
+ });
718
+ describe('エッジケース', () => {
719
+ // テストの目的: 空文字列を正しく処理すること
720
+ it('Given: 空文字列, When: stripHtmlTags を呼び出す, Then: 空文字列を返す', () => {
721
+ expect(stripHtmlTags('')).toBe('');
722
+ });
723
+ // テストの目的: 空白のみの文字列を正しく処理すること
724
+ it('Given: 空白のみの文字列, When: stripHtmlTags を呼び出す, Then: 空文字列を返す', () => {
725
+ expect(stripHtmlTags(' ')).toBe('');
726
+ });
727
+ // テストの目的: タグのみの文字列を正しく処理すること
728
+ it('Given: タグのみの文字列, When: stripHtmlTags を呼び出す, Then: 空文字列を返す', () => {
729
+ expect(stripHtmlTags('<p></p><div></div>')).toBe('');
730
+ });
731
+ // テストの目的: 複雑な HTML を正しく処理すること
732
+ it('Given: 複雑な HTML, When: stripHtmlTags を呼び出す, Then: プレーンテキストに変換される', () => {
733
+ const html = '<div class="container"><h1>Title</h1><p>Hello &amp; <strong>World</strong>!</p></div>';
734
+ expect(stripHtmlTags(html)).toBe('Title Hello & World !');
735
+ });
736
+ });
737
+ });
738
+ }