게시물 Import 호환성 보강 v1.5.28
This commit is contained in:
@@ -146,6 +146,55 @@ const parseYamlValue = (value) => {
|
||||
return unquoteYamlString(trimmed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown frontmatter 줄을 키와 값으로 나눈다.
|
||||
* @param {string} line - frontmatter 한 줄
|
||||
* @returns {{ key: string, value: string } | null} 키와 값
|
||||
*/
|
||||
const parseFrontmatterLine = (line) => {
|
||||
const match = String(line || '').match(/^([A-Za-z0-9_-]+):\s*(.*)$/)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
key: match[1].trim(),
|
||||
value: match[2]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* YAML 블록 배열 값을 수집한다.
|
||||
* @param {Array<string>} lines - frontmatter 줄 목록
|
||||
* @param {number} startIndex - 시작 인덱스
|
||||
* @returns {{ values: Array<string>, nextIndex: number }} 수집 결과
|
||||
*/
|
||||
const collectYamlBlockArray = (lines, startIndex) => {
|
||||
const values = []
|
||||
let index = startIndex
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index]
|
||||
const itemMatch = String(line || '').match(/^\s*-\s*(.*)$/)
|
||||
|
||||
if (!itemMatch) {
|
||||
break
|
||||
}
|
||||
|
||||
const value = unquoteYamlString(itemMatch[1])
|
||||
if (value) {
|
||||
values.push(value)
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
|
||||
return {
|
||||
values,
|
||||
nextIndex: index
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown frontmatter와 본문을 분리한다.
|
||||
* @param {string} markdown - Markdown 문서
|
||||
@@ -173,19 +222,26 @@ const parseMarkdownDocument = (markdown) => {
|
||||
const frontmatterText = normalized.slice(4, endIndex)
|
||||
const content = normalized.slice(endIndex + 4).replace(/^\n/, '')
|
||||
const frontmatter = {}
|
||||
const lines = frontmatterText.split('\n')
|
||||
|
||||
for (const line of frontmatterText.split('\n')) {
|
||||
const separatorIndex = line.indexOf(':')
|
||||
if (separatorIndex < 0) {
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const parsedLine = parseFrontmatterLine(lines[index])
|
||||
|
||||
if (!parsedLine) {
|
||||
continue
|
||||
}
|
||||
|
||||
const key = line.slice(0, separatorIndex).trim()
|
||||
const value = line.slice(separatorIndex + 1)
|
||||
const { key, value } = parsedLine
|
||||
const trimmedValue = String(value || '').trim()
|
||||
|
||||
if (key) {
|
||||
frontmatter[key] = parseYamlValue(value)
|
||||
if (!trimmedValue && lines[index + 1]?.match(/^\s*-\s*/)) {
|
||||
const blockArray = collectYamlBlockArray(lines, index + 1)
|
||||
frontmatter[key] = blockArray.values
|
||||
index = blockArray.nextIndex - 1
|
||||
continue
|
||||
}
|
||||
|
||||
frontmatter[key] = parseYamlValue(value)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -271,7 +327,7 @@ const pickUniqueDiskFileName = async (directoryPath, originalName) => {
|
||||
* @param {Map<string, Buffer>} input.entryMap - ZIP 엔트리 맵
|
||||
* @param {string} input.postFolder - 게시물 폴더
|
||||
* @param {Set<string>} input.assetPaths - 자산 경로 목록
|
||||
* @returns {Promise<Map<string, string>>} 원본 경로별 새 URL
|
||||
* @returns {Promise<{ replacements: Map<string, string>, missingAssets: Array<string> }>} 저장 결과
|
||||
*/
|
||||
const saveImportAssets = async ({ entryMap, postFolder, assetPaths }) => {
|
||||
const now = new Date()
|
||||
@@ -279,6 +335,7 @@ const saveImportAssets = async ({ entryMap, postFolder, assetPaths }) => {
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const directoryPath = join(process.cwd(), 'public', 'uploads', 'posts', year, month)
|
||||
const replacements = new Map()
|
||||
const missingAssets = []
|
||||
|
||||
await mkdir(directoryPath, { recursive: true })
|
||||
|
||||
@@ -287,6 +344,7 @@ const saveImportAssets = async ({ entryMap, postFolder, assetPaths }) => {
|
||||
const data = entryMap.get(entryPath)
|
||||
|
||||
if (!data) {
|
||||
missingAssets.push(assetPath)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -301,7 +359,10 @@ const saveImportAssets = async ({ entryMap, postFolder, assetPaths }) => {
|
||||
replacements.set(`./${assetPath.replace(/^\.\//, '')}`, publicUrl)
|
||||
}
|
||||
|
||||
return replacements
|
||||
return {
|
||||
replacements,
|
||||
missingAssets
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -464,7 +525,7 @@ const collectMarkdownPosts = (entries) => entries
|
||||
/**
|
||||
* Export ZIP을 게시물로 가져온다.
|
||||
* @param {{ zipBuffer: Buffer, authorId: string }} input - Import 입력
|
||||
* @returns {Promise<{ importedCount: number, assetCount: number, posts: Array<Object> }>} Import 결과
|
||||
* @returns {Promise<{ importedCount: number, assetCount: number, warningCount: number, warnings: Array<string>, posts: Array<Object> }>} Import 결과
|
||||
*/
|
||||
export const importPostsFromExportZip = async ({ zipBuffer, authorId }) => {
|
||||
const entries = readZipBufferEntries(zipBuffer)
|
||||
@@ -481,17 +542,21 @@ export const importPostsFromExportZip = async ({ zipBuffer, authorId }) => {
|
||||
|
||||
const reservedSlugs = new Set()
|
||||
const importedPosts = []
|
||||
const warnings = []
|
||||
let importedAssetCount = 0
|
||||
|
||||
for (const markdownPost of markdownPosts) {
|
||||
const { frontmatter, content, postFolder } = markdownPost
|
||||
const assetPaths = collectImportAssetPaths({ frontmatter, content })
|
||||
const replacements = await saveImportAssets({
|
||||
const { replacements, missingAssets } = await saveImportAssets({
|
||||
entryMap,
|
||||
postFolder,
|
||||
assetPaths
|
||||
})
|
||||
importedAssetCount += replacements.size ? new Set(replacements.values()).size : 0
|
||||
missingAssets.forEach((assetPath) => {
|
||||
warnings.push(`${markdownPost.path}: ZIP 내부 자산을 찾지 못했습니다. (${assetPath})`)
|
||||
})
|
||||
|
||||
const title = String(frontmatter.title || basename(markdownPost.path).replace(MARKDOWN_EXTENSION_PATTERN, '') || 'Imported Post').trim()
|
||||
const slug = await createUniquePostSlug(frontmatter.slug || title, reservedSlugs)
|
||||
@@ -525,6 +590,8 @@ export const importPostsFromExportZip = async ({ zipBuffer, authorId }) => {
|
||||
return {
|
||||
importedCount: importedPosts.length,
|
||||
assetCount: importedAssetCount,
|
||||
warningCount: warnings.length,
|
||||
warnings: warnings.slice(0, 20),
|
||||
posts: importedPosts.map((post) => ({
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
|
||||
@@ -7,7 +7,7 @@ const MAX_IMPORT_ZIP_BYTES = 300 * 1024 * 1024
|
||||
/**
|
||||
* 관리자 게시물 Import API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ importedCount: number, assetCount: number, posts: Array<Object> }>} Import 결과
|
||||
* @returns {Promise<{ importedCount: number, assetCount: number, warningCount: number, warnings: Array<string>, posts: Array<Object> }>} Import 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const adminSession = requireAdminSession(event)
|
||||
|
||||
Reference in New Issue
Block a user