atl-fetch 1.1.0 → 1.2.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.
@@ -3,7 +3,7 @@ import { fetchConfluencePage } from '../confluence/confluence-service.js';
3
3
  import { fetchJiraIssue } from '../jira/jira-service.js';
4
4
  import { formatConfluencePage, formatJiraIssue, writeToFile } from '../output/output-service.js';
5
5
  import { saveConfluencePage, saveConfluenceVersions, saveJiraIssue } from '../storage/storage-service.js';
6
- import { convertAdfToPlainText, convertStorageFormatToPlainText } from '../text-converter/text-converter.js';
6
+ import { convertStorageFormatToPlainText } from '../text-converter/text-converter.js';
7
7
  import { parseUrl } from '../url-parser/url-parser.js';
8
8
  /**
9
9
  * Jira エラーを FetchError に変換する
@@ -230,15 +230,14 @@ export async function fetchAndSave(url, options) {
230
230
  return err(mapJiraErrorToFetchError(issueResult.error));
231
231
  }
232
232
  const issue = issueResult.value;
233
- // ADF をプレーンテキストに変換
234
- const descriptionPlainText = issue.description !== null ? convertAdfToPlainText(issue.description) : null;
235
233
  // Jira Issue をディレクトリ構造で保存
234
+ // description は ADF 形式で保存、descriptionPlainText は後方互換性のため維持
236
235
  const saveResult = await saveJiraIssue({
237
236
  attachments: issue.attachments,
238
237
  changelog: issue.changelog,
239
238
  comments: issue.comments,
240
- description: issue.description,
241
- descriptionPlainText,
239
+ description: issue.descriptionAdf,
240
+ descriptionPlainText: issue.description,
242
241
  key: issue.key,
243
242
  summary: issue.summary,
244
243
  }, {
@@ -108,6 +108,7 @@ function mapApiCommentToJiraComment(apiComment) {
108
108
  return {
109
109
  author: apiComment.author.displayName,
110
110
  body: extractTextFromAdf(apiComment.body),
111
+ bodyAdf: apiComment.body,
111
112
  created: apiComment.created,
112
113
  id: apiComment.id,
113
114
  updated: apiComment.updated,
@@ -254,6 +255,7 @@ export async function fetchJiraIssue(organization, issueKey) {
254
255
  changelog: (apiResponse.changelog?.histories ?? []).map(mapApiChangelogEntryToJiraChangelogEntry),
255
256
  comments: (apiResponse.fields.comment?.comments ?? []).map(mapApiCommentToJiraComment),
256
257
  description: apiResponse.fields.description ? extractTextFromAdf(apiResponse.fields.description) : null,
258
+ descriptionAdf: apiResponse.fields.description,
257
259
  key: apiResponse.key,
258
260
  summary: apiResponse.fields.summary,
259
261
  };
@@ -16,9 +16,11 @@ import type { ConfluenceSaveData, ConfluenceSaveResult, ConfluenceStorageOptions
16
16
  * ├── manifest.json # 取得メタデータ
17
17
  * ├── issue.json # Issue 全データ(JSON 形式)
18
18
  * ├── description.txt # 説明文のプレーンテキスト
19
- * ├── content.md # Markdown 形式
20
- * ├── changelog.json # 変更履歴
21
- * ├── comments.json # コメント一覧
19
+ * ├── content.md # Markdown 形式(Description + Attachments)
20
+ * ├── comments.md # コメント一覧(Markdown 形式)
21
+ * ├── changelog.md # 変更履歴(Markdown 形式)
22
+ * ├── changelog.json # 変更履歴(JSON 形式)
23
+ * ├── comments.json # コメント一覧(JSON 形式)
22
24
  * ├── attachments.json # 添付ファイル一覧メタデータ
23
25
  * └── attachments/ # 添付ファイル実体
24
26
  * └── {id}_{filename}
@@ -10,7 +10,7 @@ import { ensureDir, writeFileContent } from '../../ports/file/file-port.js';
10
10
  import { downloadConfluenceAttachment } from '../confluence/confluence-service.js';
11
11
  import { diffText } from '../diff/diff-service.js';
12
12
  import { downloadJiraAttachment } from '../jira/jira-service.js';
13
- import { convertStorageFormatToMarkdown } from '../text-converter/text-converter.js';
13
+ import { convertAdfToMarkdown, convertStorageFormatToMarkdown } from '../text-converter/text-converter.js';
14
14
  /**
15
15
  * 現在時刻を ISO 8601 形式で取得する
16
16
  *
@@ -51,9 +51,11 @@ const createJiraManifest = (data, options, attachmentResults) => {
51
51
  * ├── manifest.json # 取得メタデータ
52
52
  * ├── issue.json # Issue 全データ(JSON 形式)
53
53
  * ├── description.txt # 説明文のプレーンテキスト
54
- * ├── content.md # Markdown 形式
55
- * ├── changelog.json # 変更履歴
56
- * ├── comments.json # コメント一覧
54
+ * ├── content.md # Markdown 形式(Description + Attachments)
55
+ * ├── comments.md # コメント一覧(Markdown 形式)
56
+ * ├── changelog.md # 変更履歴(Markdown 形式)
57
+ * ├── changelog.json # 変更履歴(JSON 形式)
58
+ * ├── comments.json # コメント一覧(JSON 形式)
57
59
  * ├── attachments.json # 添付ファイル一覧メタデータ
58
60
  * └── attachments/ # 添付ファイル実体
59
61
  * └── {id}_{filename}
@@ -150,7 +152,7 @@ export const saveJiraIssue = async (data, options) => {
150
152
  path: descPath,
151
153
  });
152
154
  }
153
- // content.md を保存(Markdown 形式)
155
+ // content.md を保存(Markdown 形式:Description + Attachments)
154
156
  const markdownContent = formatJiraIssueAsMarkdown(data, attachmentResults);
155
157
  const markdownPath = join(issueDir, 'content.md');
156
158
  const markdownWriteResult = await writeFileContent(markdownPath, markdownContent);
@@ -161,6 +163,28 @@ export const saveJiraIssue = async (data, options) => {
161
163
  path: markdownPath,
162
164
  });
163
165
  }
166
+ // comments.md を保存(Markdown 形式)
167
+ const commentsMarkdownContent = formatCommentsAsMarkdown(data, attachmentResults);
168
+ const commentsMarkdownPath = join(issueDir, 'comments.md');
169
+ const commentsMarkdownWriteResult = await writeFileContent(commentsMarkdownPath, commentsMarkdownContent);
170
+ if (commentsMarkdownWriteResult.isErr()) {
171
+ return err({
172
+ kind: 'FILE_WRITE_FAILED',
173
+ message: `comments.md の書き込みに失敗しました: ${commentsMarkdownWriteResult.error.message}`,
174
+ path: commentsMarkdownPath,
175
+ });
176
+ }
177
+ // changelog.md を保存(Markdown 形式)
178
+ const changelogMarkdownContent = formatChangelogAsMarkdown(data);
179
+ const changelogMarkdownPath = join(issueDir, 'changelog.md');
180
+ const changelogMarkdownWriteResult = await writeFileContent(changelogMarkdownPath, changelogMarkdownContent);
181
+ if (changelogMarkdownWriteResult.isErr()) {
182
+ return err({
183
+ kind: 'FILE_WRITE_FAILED',
184
+ message: `changelog.md の書き込みに失敗しました: ${changelogMarkdownWriteResult.error.message}`,
185
+ path: changelogMarkdownPath,
186
+ });
187
+ }
164
188
  // changelog.json を保存
165
189
  const changelogPath = join(issueDir, 'changelog.json');
166
190
  const changelogWriteResult = await writeFileContent(changelogPath, JSON.stringify(data.changelog, null, 2));
@@ -199,54 +223,38 @@ export const saveJiraIssue = async (data, options) => {
199
223
  /**
200
224
  * Jira Issue を Markdown 形式にフォーマットする(ファイル保存用)
201
225
  *
226
+ * ADF (Atlassian Document Format) を Markdown に変換して保存する。
227
+ * Confluence と同様に構造を保持した Markdown を生成する。
228
+ *
202
229
  * @param data 保存データ
203
230
  * @param attachmentResults 添付ファイルのダウンロード結果
204
231
  * @returns Markdown 文字列
205
232
  */
206
233
  const formatJiraIssueAsMarkdown = (data, attachmentResults) => {
234
+ // 添付ファイル ID → savedPath のマッピングを生成
235
+ const attachmentPaths = {};
236
+ for (const att of attachmentResults) {
237
+ if (att.status === 'success' && att.savedPath !== undefined) {
238
+ attachmentPaths[att.id] = att.savedPath;
239
+ }
240
+ }
207
241
  const lines = [];
208
242
  // Title
209
243
  lines.push(`# ${data.key}`);
210
244
  lines.push('');
211
245
  lines.push(`**${data.summary}**`);
212
246
  lines.push('');
213
- // Description
247
+ // Description - ADF を Markdown に変換
214
248
  lines.push('## Description');
215
249
  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');
250
+ if (data.description !== null && data.description !== undefined) {
251
+ const descriptionMarkdown = convertAdfToMarkdown(data.description, attachmentPaths);
252
+ lines.push(descriptionMarkdown || '(No description)');
223
253
  }
224
254
  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
- }
255
+ lines.push('(No description)');
231
256
  }
232
- // Changelog
233
- lines.push('## Changelog');
234
257
  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
258
  // Attachments
251
259
  lines.push('## Attachments');
252
260
  lines.push('');
@@ -272,6 +280,66 @@ const formatJiraIssueAsMarkdown = (data, attachmentResults) => {
272
280
  }
273
281
  return lines.join('\n');
274
282
  };
283
+ /**
284
+ * Jira コメントを Markdown 形式にフォーマットする(ファイル保存用)
285
+ *
286
+ * @param data 保存データ
287
+ * @param attachmentResults 添付ファイルのダウンロード結果
288
+ * @returns Markdown 文字列
289
+ */
290
+ const formatCommentsAsMarkdown = (data, attachmentResults) => {
291
+ // 添付ファイル ID → savedPath のマッピングを生成
292
+ const attachmentPaths = {};
293
+ for (const att of attachmentResults) {
294
+ if (att.status === 'success' && att.savedPath !== undefined) {
295
+ attachmentPaths[att.id] = att.savedPath;
296
+ }
297
+ }
298
+ const lines = [];
299
+ lines.push(`# ${data.key} - Comments`);
300
+ lines.push('');
301
+ if (data.comments.length === 0) {
302
+ lines.push('No comments');
303
+ }
304
+ else {
305
+ for (const comment of data.comments) {
306
+ lines.push(`## ${comment.author} (${comment.created})`);
307
+ lines.push('');
308
+ // コメント本文も ADF 形式なので Markdown に変換
309
+ const commentMarkdown = convertAdfToMarkdown(comment.bodyAdf, attachmentPaths);
310
+ lines.push(commentMarkdown || comment.body);
311
+ lines.push('');
312
+ }
313
+ }
314
+ return lines.join('\n');
315
+ };
316
+ /**
317
+ * Jira 変更履歴を Markdown 形式にフォーマットする(ファイル保存用)
318
+ *
319
+ * @param data 保存データ
320
+ * @returns Markdown 文字列
321
+ */
322
+ const formatChangelogAsMarkdown = (data) => {
323
+ const lines = [];
324
+ lines.push(`# ${data.key} - Changelog`);
325
+ lines.push('');
326
+ if (data.changelog.length === 0) {
327
+ lines.push('No changelog');
328
+ }
329
+ else {
330
+ for (const entry of data.changelog) {
331
+ lines.push(`## ${entry.author} (${entry.created})`);
332
+ lines.push('');
333
+ for (const item of entry.items) {
334
+ const from = item.fromString ?? '(empty)';
335
+ const to = item.toString ?? '(empty)';
336
+ lines.push(`- **${item.field}**: ${from} → ${to}`);
337
+ }
338
+ lines.push('');
339
+ }
340
+ }
341
+ return lines.join('\n');
342
+ };
275
343
  /**
276
344
  * Confluence ページ用の Manifest を生成する
277
345
  *
@@ -735,4 +803,197 @@ if (import.meta.vitest) {
735
803
  });
736
804
  });
737
805
  });
806
+ describe('formatCommentsAsMarkdown (in-source testing)', () => {
807
+ // テストの目的: コメントがない場合に "No comments" が出力されること
808
+ it('Given: コメントがない JiraSaveData, When: formatCommentsAsMarkdown を呼び出す, Then: "No comments" が出力される', () => {
809
+ const data = {
810
+ attachments: [],
811
+ changelog: [],
812
+ comments: [],
813
+ description: null,
814
+ descriptionPlainText: null,
815
+ key: 'TEST-001',
816
+ summary: 'テスト',
817
+ };
818
+ const result = formatCommentsAsMarkdown(data, []);
819
+ expect(result).toContain('# TEST-001 - Comments');
820
+ expect(result).toContain('No comments');
821
+ });
822
+ // テストの目的: コメントが正しくフォーマットされること
823
+ it('Given: コメントを含む JiraSaveData, When: formatCommentsAsMarkdown を呼び出す, Then: コメントが Markdown 形式でフォーマットされる', () => {
824
+ const data = {
825
+ attachments: [],
826
+ changelog: [],
827
+ comments: [
828
+ {
829
+ author: 'TestUser',
830
+ body: 'テストコメント',
831
+ bodyAdf: null, // bodyAdf が null の場合は body が使用される
832
+ created: '2024-01-15T10:30:00.000Z',
833
+ id: 'cmt-1',
834
+ updated: '2024-01-15T10:30:00.000Z',
835
+ },
836
+ ],
837
+ description: null,
838
+ descriptionPlainText: null,
839
+ key: 'TEST-002',
840
+ summary: 'テスト',
841
+ };
842
+ const result = formatCommentsAsMarkdown(data, []);
843
+ expect(result).toContain('# TEST-002 - Comments');
844
+ expect(result).toContain('## TestUser');
845
+ expect(result).toContain('2024-01-15T10:30:00.000Z');
846
+ expect(result).toContain('テストコメント');
847
+ });
848
+ // テストの目的: 複数のコメントが正しくフォーマットされること
849
+ it('Given: 複数のコメントを含む JiraSaveData, When: formatCommentsAsMarkdown を呼び出す, Then: すべてのコメントが Markdown 形式でフォーマットされる', () => {
850
+ const data = {
851
+ attachments: [],
852
+ changelog: [],
853
+ comments: [
854
+ {
855
+ author: 'User1',
856
+ body: 'コメント1',
857
+ bodyAdf: null,
858
+ created: '2024-01-15T10:00:00.000Z',
859
+ id: 'cmt-1',
860
+ updated: '2024-01-15T10:00:00.000Z',
861
+ },
862
+ {
863
+ author: 'User2',
864
+ body: 'コメント2',
865
+ bodyAdf: null,
866
+ created: '2024-01-16T11:00:00.000Z',
867
+ id: 'cmt-2',
868
+ updated: '2024-01-16T11:00:00.000Z',
869
+ },
870
+ ],
871
+ description: null,
872
+ descriptionPlainText: null,
873
+ key: 'TEST-003',
874
+ summary: 'テスト',
875
+ };
876
+ const result = formatCommentsAsMarkdown(data, []);
877
+ expect(result).toContain('## User1');
878
+ expect(result).toContain('## User2');
879
+ expect(result).toContain('コメント1');
880
+ expect(result).toContain('コメント2');
881
+ });
882
+ });
883
+ describe('formatChangelogAsMarkdown (in-source testing)', () => {
884
+ // テストの目的: 変更履歴がない場合に "No changelog" が出力されること
885
+ it('Given: 変更履歴がない JiraSaveData, When: formatChangelogAsMarkdown を呼び出す, Then: "No changelog" が出力される', () => {
886
+ const data = {
887
+ attachments: [],
888
+ changelog: [],
889
+ comments: [],
890
+ description: null,
891
+ descriptionPlainText: null,
892
+ key: 'TEST-001',
893
+ summary: 'テスト',
894
+ };
895
+ const result = formatChangelogAsMarkdown(data);
896
+ expect(result).toContain('# TEST-001 - Changelog');
897
+ expect(result).toContain('No changelog');
898
+ });
899
+ // テストの目的: 変更履歴が正しくフォーマットされること
900
+ it('Given: 変更履歴を含む JiraSaveData, When: formatChangelogAsMarkdown を呼び出す, Then: 変更履歴が Markdown 形式でフォーマットされる', () => {
901
+ const data = {
902
+ attachments: [],
903
+ changelog: [
904
+ {
905
+ author: 'ChangeUser',
906
+ created: '2024-01-15T10:00:00.000Z',
907
+ id: 'cl-1',
908
+ items: [{ field: 'status', fromString: 'Open', toString: 'In Progress' }],
909
+ },
910
+ ],
911
+ comments: [],
912
+ description: null,
913
+ descriptionPlainText: null,
914
+ key: 'TEST-002',
915
+ summary: 'テスト',
916
+ };
917
+ const result = formatChangelogAsMarkdown(data);
918
+ expect(result).toContain('# TEST-002 - Changelog');
919
+ expect(result).toContain('## ChangeUser');
920
+ expect(result).toContain('2024-01-15T10:00:00.000Z');
921
+ expect(result).toContain('**status**');
922
+ expect(result).toContain('Open');
923
+ expect(result).toContain('In Progress');
924
+ });
925
+ // テストの目的: null 値が (empty) として表示されること
926
+ it('Given: fromString が null の変更履歴, When: formatChangelogAsMarkdown を呼び出す, Then: (empty) が表示される', () => {
927
+ const data = {
928
+ attachments: [],
929
+ changelog: [
930
+ {
931
+ author: 'ChangeUser',
932
+ created: '2024-01-15T10:00:00.000Z',
933
+ id: 'cl-1',
934
+ items: [{ field: 'assignee', fromString: null, toString: 'Developer' }],
935
+ },
936
+ ],
937
+ comments: [],
938
+ description: null,
939
+ descriptionPlainText: null,
940
+ key: 'TEST-003',
941
+ summary: 'テスト',
942
+ };
943
+ const result = formatChangelogAsMarkdown(data);
944
+ expect(result).toContain('(empty)');
945
+ expect(result).toContain('Developer');
946
+ });
947
+ // テストの目的: toString が null の場合も (empty) として表示されること
948
+ it('Given: toString が null の変更履歴, When: formatChangelogAsMarkdown を呼び出す, Then: (empty) が表示される', () => {
949
+ const data = {
950
+ attachments: [],
951
+ changelog: [
952
+ {
953
+ author: 'ChangeUser',
954
+ created: '2024-01-15T10:00:00.000Z',
955
+ id: 'cl-1',
956
+ items: [{ field: 'assignee', fromString: 'Developer', toString: null }],
957
+ },
958
+ ],
959
+ comments: [],
960
+ description: null,
961
+ descriptionPlainText: null,
962
+ key: 'TEST-004',
963
+ summary: 'テスト',
964
+ };
965
+ const result = formatChangelogAsMarkdown(data);
966
+ expect(result).toContain('Developer');
967
+ expect(result).toContain('(empty)');
968
+ });
969
+ // テストの目的: 複数の変更項目が正しくフォーマットされること
970
+ it('Given: 複数の変更項目を含む変更履歴, When: formatChangelogAsMarkdown を呼び出す, Then: すべての項目が Markdown 形式でフォーマットされる', () => {
971
+ const data = {
972
+ attachments: [],
973
+ changelog: [
974
+ {
975
+ author: 'ChangeUser',
976
+ created: '2024-01-15T10:00:00.000Z',
977
+ id: 'cl-1',
978
+ items: [
979
+ { field: 'status', fromString: 'Open', toString: 'In Progress' },
980
+ { field: 'priority', fromString: 'Low', toString: 'High' },
981
+ ],
982
+ },
983
+ ],
984
+ comments: [],
985
+ description: null,
986
+ descriptionPlainText: null,
987
+ key: 'TEST-005',
988
+ summary: 'テスト',
989
+ };
990
+ const result = formatChangelogAsMarkdown(data);
991
+ expect(result).toContain('**status**');
992
+ expect(result).toContain('**priority**');
993
+ expect(result).toContain('Open');
994
+ expect(result).toContain('In Progress');
995
+ expect(result).toContain('Low');
996
+ expect(result).toContain('High');
997
+ });
998
+ });
738
999
  }
@@ -24,6 +24,14 @@ export declare const convertAdfToPlainText: (adf: unknown) => string;
24
24
  * @returns プレーンテキスト
25
25
  */
26
26
  export declare const convertStorageFormatToPlainText: (storageFormat: string | null | undefined) => string;
27
+ /**
28
+ * ADF(Atlassian Document Format)を Markdown に変換する
29
+ *
30
+ * @param adf ADF ドキュメント(オブジェクトまたは JSON 文字列)
31
+ * @param attachmentPaths 添付ファイル ID → ローカルパスのマッピング
32
+ * @returns Markdown 文字列
33
+ */
34
+ export declare const convertAdfToMarkdown: (adf: unknown, attachmentPaths?: AttachmentPathMapping) => string;
27
35
  /**
28
36
  * Confluence Storage Format(XHTML)を Markdown に変換する
29
37
  *
@@ -135,6 +135,236 @@ export const convertAdfToPlainText = (adf) => {
135
135
  }
136
136
  return '';
137
137
  };
138
+ // ============================================================
139
+ // ADF → Markdown 変換(Confluence と同じ TurndownService を使用)
140
+ // ============================================================
141
+ /**
142
+ * HTML 特殊文字をエスケープする
143
+ *
144
+ * @param text エスケープ対象の文字列
145
+ * @returns エスケープ済み文字列
146
+ */
147
+ const escapeHtml = (text) => {
148
+ return text
149
+ .replace(/&/g, '&')
150
+ .replace(/</g, '&lt;')
151
+ .replace(/>/g, '&gt;')
152
+ .replace(/"/g, '&quot;')
153
+ .replace(/'/g, '&#39;');
154
+ };
155
+ /**
156
+ * ADF マークを HTML タグで囲む
157
+ *
158
+ * @param text 対象のテキスト
159
+ * @param marks 適用するマーク配列
160
+ * @returns マークを適用した HTML
161
+ */
162
+ const applyMarksToHtml = (text, marks) => {
163
+ let result = text;
164
+ for (const mark of marks) {
165
+ switch (mark.type) {
166
+ case 'strong':
167
+ result = `<strong>${result}</strong>`;
168
+ break;
169
+ case 'em':
170
+ result = `<em>${result}</em>`;
171
+ break;
172
+ case 'code':
173
+ result = `<code>${result}</code>`;
174
+ break;
175
+ case 'strike':
176
+ result = `<s>${result}</s>`;
177
+ break;
178
+ case 'underline':
179
+ result = `<u>${result}</u>`;
180
+ break;
181
+ case 'link': {
182
+ const href = mark.attrs?.['href'];
183
+ if (typeof href === 'string') {
184
+ result = `<a href="${escapeHtml(href)}">${result}</a>`;
185
+ }
186
+ break;
187
+ }
188
+ case 'textColor': {
189
+ const color = mark.attrs?.['color'];
190
+ if (typeof color === 'string') {
191
+ result = `<span style="color: ${escapeHtml(color)}">${result}</span>`;
192
+ }
193
+ break;
194
+ }
195
+ case 'subsup': {
196
+ const subType = mark.attrs?.['type'];
197
+ if (subType === 'sub') {
198
+ result = `<sub>${result}</sub>`;
199
+ }
200
+ else if (subType === 'sup') {
201
+ result = `<sup>${result}</sup>`;
202
+ }
203
+ break;
204
+ }
205
+ // 未知のマークタイプは無視
206
+ default:
207
+ break;
208
+ }
209
+ }
210
+ return result;
211
+ };
212
+ /**
213
+ * ADF ノードを HTML に変換する
214
+ *
215
+ * @param node ADF ノード
216
+ * @param attachmentPaths 添付ファイル ID → ローカルパスのマッピング
217
+ * @returns HTML 文字列
218
+ */
219
+ const convertAdfNodeToHtml = (node, attachmentPaths) => {
220
+ // テキストノードの場合
221
+ if (node.type === 'text' && node.text !== undefined) {
222
+ const escapedText = escapeHtml(node.text);
223
+ if (node.marks !== undefined && node.marks.length > 0) {
224
+ return applyMarksToHtml(escapedText, node.marks);
225
+ }
226
+ return escapedText;
227
+ }
228
+ // hardBreak の場合
229
+ if (node.type === 'hardBreak') {
230
+ return '<br>';
231
+ }
232
+ // rule(水平線)の場合
233
+ if (node.type === 'rule') {
234
+ return '<hr>';
235
+ }
236
+ // メンションの場合
237
+ if (node.type === 'mention' && node.attrs !== undefined) {
238
+ const text = node.attrs['text'];
239
+ if (typeof text === 'string') {
240
+ return escapeHtml(text);
241
+ }
242
+ return '@ユーザー';
243
+ }
244
+ // 絵文字の場合
245
+ if (node.type === 'emoji' && node.attrs !== undefined) {
246
+ const text = node.attrs['text'];
247
+ const shortName = node.attrs['shortName'];
248
+ if (typeof text === 'string') {
249
+ return text;
250
+ }
251
+ if (typeof shortName === 'string') {
252
+ return shortName;
253
+ }
254
+ return '';
255
+ }
256
+ // media ノードの場合(添付ファイル)
257
+ if (node.type === 'media' && node.attrs !== undefined) {
258
+ const mediaId = node.attrs['id'];
259
+ const mediaType = node.attrs['type'];
260
+ if (typeof mediaId === 'string' && attachmentPaths?.[mediaId] !== undefined) {
261
+ const localPath = attachmentPaths[mediaId];
262
+ const alt = typeof node.attrs['alt'] === 'string' ? node.attrs['alt'] : mediaId;
263
+ return `<img src="${escapeHtml(localPath)}" alt="${escapeHtml(alt)}">`;
264
+ }
265
+ // 外部リンクの場合
266
+ if (mediaType === 'external' || mediaType === 'link') {
267
+ const url = node.attrs['url'];
268
+ if (typeof url === 'string') {
269
+ return `<img src="${escapeHtml(url)}" alt="">`;
270
+ }
271
+ }
272
+ // マッピングがない場合はプレースホルダー
273
+ return '[添付ファイル]';
274
+ }
275
+ // mediaSingle(メディアコンテナ)の場合
276
+ if (node.type === 'mediaSingle' && node.content !== undefined) {
277
+ return node.content.map((child) => convertAdfNodeToHtml(child, attachmentPaths)).join('');
278
+ }
279
+ // mediaGroup の場合
280
+ if (node.type === 'mediaGroup' && node.content !== undefined) {
281
+ return node.content.map((child) => convertAdfNodeToHtml(child, attachmentPaths)).join('');
282
+ }
283
+ // inlineCard(インラインリンク)の場合
284
+ if (node.type === 'inlineCard' && node.attrs !== undefined) {
285
+ const url = node.attrs['url'];
286
+ if (typeof url === 'string') {
287
+ return `<a href="${escapeHtml(url)}">${escapeHtml(url)}</a>`;
288
+ }
289
+ return '';
290
+ }
291
+ // blockCard(ブロックリンク)の場合
292
+ if (node.type === 'blockCard' && node.attrs !== undefined) {
293
+ const url = node.attrs['url'];
294
+ if (typeof url === 'string') {
295
+ return `<p><a href="${escapeHtml(url)}">${escapeHtml(url)}</a></p>`;
296
+ }
297
+ return '';
298
+ }
299
+ // 子ノードがある場合
300
+ if (node.content !== undefined && Array.isArray(node.content)) {
301
+ const childrenHtml = node.content.map((child) => convertAdfNodeToHtml(child, attachmentPaths)).join('');
302
+ switch (node.type) {
303
+ case 'doc':
304
+ return childrenHtml;
305
+ case 'paragraph':
306
+ return `<p>${childrenHtml}</p>`;
307
+ case 'heading': {
308
+ const level = typeof node.attrs?.['level'] === 'number' ? node.attrs['level'] : 1;
309
+ const safeLevel = Math.max(1, Math.min(6, level));
310
+ return `<h${safeLevel}>${childrenHtml}</h${safeLevel}>`;
311
+ }
312
+ case 'bulletList':
313
+ return `<ul>${childrenHtml}</ul>`;
314
+ case 'orderedList':
315
+ return `<ol>${childrenHtml}</ol>`;
316
+ case 'listItem':
317
+ return `<li>${childrenHtml}</li>`;
318
+ case 'blockquote':
319
+ return `<blockquote>${childrenHtml}</blockquote>`;
320
+ case 'codeBlock': {
321
+ const language = typeof node.attrs?.['language'] === 'string' ? node.attrs['language'] : '';
322
+ const langClass = language ? ` class="language-${escapeHtml(language)}"` : '';
323
+ // コードブロック内のテキストは子ノードから取得
324
+ const codeText = node.content
325
+ .map((child) => (child.type === 'text' && child.text !== undefined ? child.text : ''))
326
+ .join('');
327
+ return `<pre><code${langClass}>${escapeHtml(codeText)}</code></pre>`;
328
+ }
329
+ case 'table':
330
+ return `<table>${childrenHtml}</table>`;
331
+ case 'tableRow':
332
+ return `<tr>${childrenHtml}</tr>`;
333
+ case 'tableHeader':
334
+ return `<th>${childrenHtml}</th>`;
335
+ case 'tableCell':
336
+ return `<td>${childrenHtml}</td>`;
337
+ case 'panel': {
338
+ // panel タイプを GitHub Alerts 形式に変換
339
+ const panelType = typeof node.attrs?.['panelType'] === 'string' ? node.attrs['panelType'] : 'info';
340
+ const alertTypeMap = {
341
+ error: 'WARNING',
342
+ info: 'NOTE',
343
+ note: 'NOTE',
344
+ success: 'TIP',
345
+ warning: 'WARNING',
346
+ };
347
+ const alertType = alertTypeMap[panelType] || 'NOTE';
348
+ return `<blockquote data-github-alert="${alertType}">${childrenHtml}</blockquote>`;
349
+ }
350
+ // 未知のノードタイプは子ノードの内容を返す
351
+ default:
352
+ return childrenHtml;
353
+ }
354
+ }
355
+ // 子ノードもテキストもない場合は空文字列
356
+ return '';
357
+ };
358
+ /**
359
+ * ADF ドキュメントを HTML に変換する
360
+ *
361
+ * @param content ADF ドキュメントのコンテンツ配列
362
+ * @param attachmentPaths 添付ファイル ID → ローカルパスのマッピング
363
+ * @returns HTML 文字列
364
+ */
365
+ const convertAdfContentToHtml = (content, attachmentPaths) => {
366
+ return content.map((node) => convertAdfNodeToHtml(node, attachmentPaths)).join('');
367
+ };
138
368
  /**
139
369
  * HTML エンティティをデコードする
140
370
  *
@@ -348,20 +578,12 @@ const preprocessHtmlForMarkdown = (html, attachmentPaths) => {
348
578
  return result;
349
579
  };
350
580
  /**
351
- * Confluence Storage Format(XHTML)を Markdown に変換する
581
+ * TurndownService インスタンスを作成する(共通設定)
582
+ * Jira ADF と Confluence Storage Format の両方で使用する
352
583
  *
353
- * @param storageFormat Storage Format 文字列(HTML/XHTML)
354
- * @param attachmentPaths 添付ファイル名 → ローカルパスのマッピング
355
- * @returns Markdown 文字列
584
+ * @returns 設定済みの TurndownService インスタンス
356
585
  */
357
- export const convertStorageFormatToMarkdown = (storageFormat, attachmentPaths) => {
358
- // null または undefined の場合は空文字列を返す
359
- if (storageFormat === null || storageFormat === undefined || storageFormat === '') {
360
- return '';
361
- }
362
- // 前処理
363
- const preprocessedHtml = preprocessHtmlForMarkdown(storageFormat, attachmentPaths);
364
- // Turndown インスタンス作成
586
+ const createTurndownService = () => {
365
587
  const turndownService = new TurndownService({
366
588
  bulletListMarker: '-',
367
589
  codeBlockStyle: 'fenced',
@@ -371,9 +593,7 @@ export const convertStorageFormatToMarkdown = (storageFormat, attachmentPaths) =
371
593
  });
372
594
  // GFM プラグイン(テーブル、取り消し線など)を使用
373
595
  turndownService.use(gfm);
374
- // --------------------------------------------------
375
596
  // カスタムルール: キャプション付き画像(<figure>)
376
- // --------------------------------------------------
377
597
  turndownService.addRule('figureWithCaption', {
378
598
  filter: (node) => {
379
599
  return node.nodeName === 'FIGURE';
@@ -397,9 +617,7 @@ export const convertStorageFormatToMarkdown = (storageFormat, attachmentPaths) =
397
617
  return '';
398
618
  },
399
619
  });
400
- // --------------------------------------------------
401
620
  // カスタムルール: GitHub Alerts(<blockquote data-github-alert="...">)
402
- // --------------------------------------------------
403
621
  turndownService.addRule('githubAlerts', {
404
622
  filter: (node) => {
405
623
  if (node.nodeName !== 'BLOCKQUOTE')
@@ -417,9 +635,7 @@ export const convertStorageFormatToMarkdown = (storageFormat, attachmentPaths) =
417
635
  return `\n> [!${alertType}]\n${quotedContent}\n`;
418
636
  },
419
637
  });
420
- // --------------------------------------------------
421
638
  // カスタムルール: 色変更テキスト(HTML のまま出力)
422
- // --------------------------------------------------
423
639
  turndownService.addRule('coloredText', {
424
640
  filter: (node) => {
425
641
  if (node.nodeName !== 'SPAN')
@@ -432,9 +648,7 @@ export const convertStorageFormatToMarkdown = (storageFormat, attachmentPaths) =
432
648
  return node.outerHTML;
433
649
  },
434
650
  });
435
- // --------------------------------------------------
436
651
  // カスタムルール: セル結合/セル内改行のあるテーブル(HTML のまま出力)
437
- // --------------------------------------------------
438
652
  turndownService.addRule('complexTable', {
439
653
  filter: (node) => {
440
654
  if (node.nodeName !== 'TABLE')
@@ -447,6 +661,65 @@ export const convertStorageFormatToMarkdown = (storageFormat, attachmentPaths) =
447
661
  return node.outerHTML;
448
662
  },
449
663
  });
664
+ return turndownService;
665
+ };
666
+ /**
667
+ * ADF(Atlassian Document Format)を Markdown に変換する
668
+ *
669
+ * @param adf ADF ドキュメント(オブジェクトまたは JSON 文字列)
670
+ * @param attachmentPaths 添付ファイル ID → ローカルパスのマッピング
671
+ * @returns Markdown 文字列
672
+ */
673
+ export const convertAdfToMarkdown = (adf, attachmentPaths) => {
674
+ // null または undefined の場合は空文字列を返す
675
+ if (adf === null || adf === undefined) {
676
+ return '';
677
+ }
678
+ // 文字列の場合は JSON としてパースを試みる
679
+ if (typeof adf === 'string') {
680
+ // 空文字列の場合
681
+ if (adf === '') {
682
+ return '';
683
+ }
684
+ try {
685
+ const parsed = JSON.parse(adf);
686
+ if (isAdfDocument(parsed)) {
687
+ const html = convertAdfContentToHtml(parsed.content, attachmentPaths);
688
+ const turndownService = createTurndownService();
689
+ return turndownService.turndown(html).trim();
690
+ }
691
+ }
692
+ catch {
693
+ // JSON パースに失敗した場合は元の文字列を返す
694
+ return adf;
695
+ }
696
+ // パースできたが ADF 形式でない場合は元の文字列を返す
697
+ return adf;
698
+ }
699
+ // オブジェクトの場合は ADF ドキュメントとして処理
700
+ if (isAdfDocument(adf)) {
701
+ const html = convertAdfContentToHtml(adf.content, attachmentPaths);
702
+ const turndownService = createTurndownService();
703
+ return turndownService.turndown(html).trim();
704
+ }
705
+ return '';
706
+ };
707
+ /**
708
+ * Confluence Storage Format(XHTML)を Markdown に変換する
709
+ *
710
+ * @param storageFormat Storage Format 文字列(HTML/XHTML)
711
+ * @param attachmentPaths 添付ファイル名 → ローカルパスのマッピング
712
+ * @returns Markdown 文字列
713
+ */
714
+ export const convertStorageFormatToMarkdown = (storageFormat, attachmentPaths) => {
715
+ // null または undefined の場合は空文字列を返す
716
+ if (storageFormat === null || storageFormat === undefined || storageFormat === '') {
717
+ return '';
718
+ }
719
+ // 前処理
720
+ const preprocessedHtml = preprocessHtmlForMarkdown(storageFormat, attachmentPaths);
721
+ // 共通の TurndownService を使用
722
+ const turndownService = createTurndownService();
450
723
  // Markdown に変換
451
724
  const markdown = turndownService.turndown(preprocessedHtml);
452
725
  // 末尾の空白を除去
@@ -6,8 +6,10 @@ export interface JiraComment {
6
6
  readonly id: string;
7
7
  /** 作成者の表示名 */
8
8
  readonly author: string;
9
- /** コメント本文 */
9
+ /** コメント本文(プレーンテキスト) */
10
10
  readonly body: string;
11
+ /** コメント本文(ADF 形式、Markdown 変換用) */
12
+ readonly bodyAdf: unknown;
11
13
  /** 作成日時(ISO 8601 形式) */
12
14
  readonly created: string;
13
15
  /** 更新日時(ISO 8601 形式) */
@@ -62,6 +64,8 @@ export interface JiraIssue {
62
64
  readonly summary: string;
63
65
  /** Issue 説明(null の場合は説明なし) */
64
66
  readonly description: string | null;
67
+ /** Issue 説明(ADF 形式、Markdown 変換用) */
68
+ readonly descriptionAdf: unknown;
65
69
  /** コメント一覧 */
66
70
  readonly comments: readonly JiraComment[];
67
71
  /** 変更履歴一覧 */
@@ -93,9 +93,9 @@ export interface JiraSaveData {
93
93
  readonly key: string;
94
94
  /** Issue タイトル(要約) */
95
95
  readonly summary: string;
96
- /** Issue 説明(null の場合は説明なし) */
97
- readonly description: string | null;
98
- /** 説明のプレーンテキスト(null の場合は説明なし) */
96
+ /** Issue 説明(ADF 形式、null の場合は説明なし) */
97
+ readonly description: unknown;
98
+ /** 説明のプレーンテキスト(null の場合は説明なし、後方互換性のため維持) */
99
99
  readonly descriptionPlainText: string | null;
100
100
  /** コメント一覧 */
101
101
  readonly comments: readonly JiraComment[];
package/package.json CHANGED
@@ -82,7 +82,7 @@
82
82
  },
83
83
  "type": "module",
84
84
  "types": "./dist/index.d.ts",
85
- "version": "1.1.0",
85
+ "version": "1.2.0",
86
86
  "scripts": {
87
87
  "build": "tsc",
88
88
  "dev": "tsc --watch",