|
1 | | -import { ContentTypes } from '@/constants'; |
2 | | -import { downloadContent } from '@/utils'; |
| 1 | +import { downloadContent, downloadContentWithImages } from '@/utils'; |
3 | 2 | import { processHTMLLinks } from '@/utils/content'; |
| 3 | +import { downloadImageAsBlob, extractImageReferencesFromContent } from '@/utils/image'; |
4 | 4 | import { consoleApiClient, type Post } from '@halo-dev/api-client'; |
5 | 5 | import { Toast } from '@halo-dev/components'; |
6 | 6 | import { ConverterFactory } from './converterFactory'; |
7 | 7 |
|
8 | | -type ExportType = 'original' | 'markdown'; |
| 8 | +export type ExportType = 'markdown' | 'html' | 'pdf'; |
| 9 | +export type ImageExportMode = 'file' | 'inline'; |
| 10 | + |
| 11 | +const ExtensionMap: Record<ExportType, string> = { |
| 12 | + markdown: 'md', |
| 13 | + html: 'html', |
| 14 | + pdf: 'pdf', |
| 15 | +}; |
9 | 16 |
|
10 | 17 | export class ContentExporter { |
11 | | - static async export(post: Post, exportType: ExportType): Promise<void> { |
| 18 | + static async export( |
| 19 | + post: Post, |
| 20 | + exportType: ExportType, |
| 21 | + includeImages: boolean, |
| 22 | + imageExportMode: ImageExportMode |
| 23 | + ): Promise<void> { |
| 24 | + if (exportType === 'pdf') { |
| 25 | + await this.exportToPdf(post); |
| 26 | + return; |
| 27 | + } |
| 28 | + |
12 | 29 | const { data: content } = await consoleApiClient.content.post.fetchPostHeadContent({ |
13 | 30 | name: post.metadata.name, |
14 | 31 | }); |
15 | 32 |
|
16 | 33 | let exportContent: string; |
17 | | - let fileExtension: string; |
18 | 34 |
|
19 | | - if (exportType === 'original') { |
20 | | - exportContent = content.raw || ''; |
21 | | - fileExtension = |
22 | | - ContentTypes.find((type) => type.type === content.rawType?.toLowerCase())?.extension || ''; |
| 35 | + if (exportType === 'html') { |
| 36 | + exportContent = content.content || ''; |
23 | 37 | } else if (exportType === 'markdown') { |
24 | 38 | if (content.rawType?.toLowerCase() === 'html') { |
25 | 39 | const converter = ConverterFactory.getConverter('html', 'markdown'); |
26 | 40 | exportContent = converter.convert(post, content); |
27 | 41 | } else { |
28 | 42 | exportContent = content.raw || ''; |
29 | 43 | } |
30 | | - fileExtension = 'md'; |
31 | 44 | } else { |
32 | 45 | throw new Error('Unsupported export type'); |
33 | 46 | } |
34 | 47 |
|
35 | | - downloadContent(exportContent, post.spec.title, fileExtension); |
| 48 | + const fileExtension = ExtensionMap[exportType]; |
| 49 | + |
| 50 | + if (!includeImages) { |
| 51 | + downloadContent(exportContent, post.spec.title, ExtensionMap[exportType]); |
| 52 | + return; |
| 53 | + } |
| 54 | + |
| 55 | + if (imageExportMode === 'file') { |
| 56 | + await this.exportWithImages(post, exportContent, exportType, fileExtension); |
| 57 | + return; |
| 58 | + } |
| 59 | + |
| 60 | + if (imageExportMode === 'inline') { |
| 61 | + await this.exportWithInlineImages(post, exportContent, exportType, fileExtension); |
| 62 | + return; |
| 63 | + } |
| 64 | + } |
| 65 | + |
| 66 | + private static async exportWithInlineImages( |
| 67 | + post: Post, |
| 68 | + content: string, |
| 69 | + contentType: ExportType, |
| 70 | + fileExtension: string |
| 71 | + ): Promise<void> { |
| 72 | + const imageReferences = extractImageReferencesFromContent(content, contentType); |
| 73 | + |
| 74 | + if (imageReferences.length === 0) { |
| 75 | + downloadContent(content, post.spec.title, fileExtension); |
| 76 | + return; |
| 77 | + } |
| 78 | + |
| 79 | + let processedContent = content; |
| 80 | + |
| 81 | + for (const imagePath of imageReferences) { |
| 82 | + try { |
| 83 | + let absoluteImageUrl: string; |
| 84 | + |
| 85 | + if (imagePath.startsWith('/')) { |
| 86 | + absoluteImageUrl = `${location.origin}${imagePath}`; |
| 87 | + } else if (!imagePath.startsWith('http')) { |
| 88 | + absoluteImageUrl = `${location.origin}/${imagePath}`; |
| 89 | + } else { |
| 90 | + absoluteImageUrl = imagePath; |
| 91 | + } |
| 92 | + |
| 93 | + const imageBlob = await downloadImageAsBlob(absoluteImageUrl); |
| 94 | + const base64Data = await this.blobToBase64(imageBlob); |
| 95 | + const mimeType = imageBlob.type; |
| 96 | + const dataUrl = `data:${mimeType};base64,${base64Data}`; |
| 97 | + |
| 98 | + if (contentType === 'markdown') { |
| 99 | + const escapedImagePath = imagePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
| 100 | + const regex = new RegExp(`!\\[([^\\]]*)\\]\\(${escapedImagePath}\\)`, 'g'); |
| 101 | + processedContent = processedContent.replace(regex, ``); |
| 102 | + } else if (contentType === 'html') { |
| 103 | + const escapedImagePath = imagePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
| 104 | + const regex = new RegExp(`src=["']${escapedImagePath}["']`, 'g'); |
| 105 | + processedContent = processedContent.replace(regex, `src="${dataUrl}"`); |
| 106 | + } |
| 107 | + } catch (error) { |
| 108 | + console.warn(`Failed to inline image ${imagePath}:`, error); |
| 109 | + } |
| 110 | + } |
| 111 | + |
| 112 | + downloadContent(processedContent, post.spec.title, fileExtension); |
| 113 | + } |
| 114 | + |
| 115 | + private static blobToBase64(blob: Blob): Promise<string> { |
| 116 | + return new Promise((resolve, reject) => { |
| 117 | + const reader = new FileReader(); |
| 118 | + reader.onloadend = () => { |
| 119 | + if (reader.result) { |
| 120 | + const base64 = (reader.result as string).split(',')[1]; |
| 121 | + resolve(base64); |
| 122 | + } else { |
| 123 | + reject(new Error('Failed to convert blob to base64')); |
| 124 | + } |
| 125 | + }; |
| 126 | + reader.onerror = reject; |
| 127 | + reader.readAsDataURL(blob); |
| 128 | + }); |
36 | 129 | } |
37 | 130 |
|
38 | 131 | static async exportToPdf(post: Post): Promise<void> { |
@@ -91,6 +184,53 @@ export class ContentExporter { |
91 | 184 | } |
92 | 185 | } |
93 | 186 |
|
| 187 | + private static async exportWithImages( |
| 188 | + post: Post, |
| 189 | + content: string, |
| 190 | + contentType: ExportType, |
| 191 | + fileExtension: string |
| 192 | + ): Promise<void> { |
| 193 | + const imageReferences = extractImageReferencesFromContent(content, contentType); |
| 194 | + |
| 195 | + if (imageReferences.length === 0) { |
| 196 | + downloadContent(content, post.spec.title, fileExtension); |
| 197 | + return; |
| 198 | + } |
| 199 | + |
| 200 | + const images: Array<{ blob: Blob; filename: string; path?: string }> = []; |
| 201 | + |
| 202 | + for (const imagePath of imageReferences) { |
| 203 | + try { |
| 204 | + let absoluteImageUrl: string; |
| 205 | + |
| 206 | + if (imagePath.startsWith('/')) { |
| 207 | + absoluteImageUrl = `${location.origin}${imagePath}`; |
| 208 | + } else if (!imagePath.startsWith('http')) { |
| 209 | + absoluteImageUrl = `${location.origin}/${imagePath}`; |
| 210 | + } else { |
| 211 | + absoluteImageUrl = imagePath; |
| 212 | + } |
| 213 | + |
| 214 | + const imageBlob = await downloadImageAsBlob(absoluteImageUrl); |
| 215 | + |
| 216 | + const encodedFileName = imagePath.split('/').pop() || `image_${images.length + 1}.png`; |
| 217 | + const fileName = decodeURIComponent(encodedFileName); |
| 218 | + |
| 219 | + const decodedPath = decodeURIComponent(imagePath); |
| 220 | + |
| 221 | + images.push({ |
| 222 | + blob: imageBlob, |
| 223 | + filename: fileName, |
| 224 | + path: decodedPath, |
| 225 | + }); |
| 226 | + } catch (error) { |
| 227 | + console.warn(`Failed to download image ${imagePath}:`, error); |
| 228 | + } |
| 229 | + } |
| 230 | + |
| 231 | + await downloadContentWithImages(content, post.spec.title, fileExtension, images); |
| 232 | + } |
| 233 | + |
94 | 234 | private static waitForImages(iframe: HTMLIFrameElement): Promise<void> { |
95 | 235 | return new Promise((resolve) => { |
96 | 236 | const images = iframe.contentDocument?.images; |
|
0 commit comments