diff --git a/.bun-version b/.bun-version index d2d61a7..6f96ed0 100644 --- a/.bun-version +++ b/.bun-version @@ -1 +1 @@ -1.2.2 \ No newline at end of file +1.3.6 \ No newline at end of file diff --git a/.github/workflows/docker-build-remote.yml b/.github/workflows/docker-build-remote.yml index aee0336..d07a1a5 100644 --- a/.github/workflows/docker-build-remote.yml +++ b/.github/workflows/docker-build-remote.yml @@ -691,3 +691,48 @@ jobs: generate_release_notes: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ======================================== + # Step 19: 更新遠端 ConvertX-CN 服務 + # 說明:拉取最新映像並重新啟動服務(所有步驟完成後執行) + # ======================================== + - name: 🔄 更新遠端 ConvertX-CN 服務 + run: | + echo "========================================" + echo "🔄 更新遠端 ConvertX-CN 服務" + echo "========================================" + + tailscale ssh ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} << 'REMOTE_EOF' + set -e + + # 1. 進入專案資料夾 + cd /home/bioailab/miniconda3/lid/app/convertx-cn + + # 2. 停止服務 + echo "📋 停止服務..." + docker compose down + + # 3. 拉取最新映像檔 + echo "" + echo "📋 拉取最新映像檔..." + docker compose pull + + # 4. 重新啟動 + echo "" + echo "📋 重新啟動服務..." + docker compose up -d + + # 5. 顯示服務狀態與 log + echo "" + echo "📋 服務狀態:" + docker compose ps + + echo "" + echo "📋 服務 log(最近 100 行):" + docker logs convertx-cn --tail=100 + + echo "" + echo "✅ ConvertX-CN 服務已更新完成" + REMOTE_EOF + + echo "========================================" diff --git a/mise.toml b/mise.toml index 7a64ecd..5849dc6 100644 --- a/mise.toml +++ b/mise.toml @@ -1,5 +1,5 @@ [tools] -bun = "1.2.2" +bun = "1.3.6" [env] JWT_SECRET = "aLongAndSecretStringUsedToSignTheJSONWebToken1234" diff --git a/src/converters/main.ts b/src/converters/main.ts index 6c2fa95..76e70dc 100644 --- a/src/converters/main.ts +++ b/src/converters/main.ts @@ -39,6 +39,7 @@ const properties: Record< properties: { from: Record; to: Record; + outputMode?: "archive"; options?: Record< string, Record< @@ -142,7 +143,7 @@ const properties: Record< properties: propertiesMarkitdown, converter: convertMarkitdown, }, - mineru: { + MinerU: { properties: propertiesMineru, converter: convertMineru, }, @@ -173,6 +174,10 @@ export async function handleConvert( "INSERT INTO file_names (job_id, file_name, output_file_name, status) VALUES (?1, ?2, ?3, ?4)", ); + // Check if the converter outputs an archive (.tar) + const converterProps = properties[converterName]?.properties; + const isArchiveOutput = converterProps?.outputMode === "archive"; + for (const chunk of chunks(fileNames, MAX_CONVERT_PROCESS)) { const toProcess: Promise[] = []; for (const fileName of chunk) { @@ -180,11 +185,17 @@ export async function handleConvert( const fileTypeOrig = fileName.split(".").pop() ?? ""; const fileType = normalizeFiletype(fileTypeOrig); const newFileExt = normalizeOutputFiletype(convertTo); - const newFileName = fileName.replace( + let newFileName = fileName.replace( new RegExp(`${fileTypeOrig}(?!.*${fileTypeOrig})`), newFileExt, ); - const targetPath = `${userOutputDir}${newFileName}`; + + // For archive output converters, the actual file will have .tar extension + if (isArchiveOutput) { + newFileName = `${newFileName}.tar`; + } + + const targetPath = `${userOutputDir}${newFileName.replace(/\.tar$/, "")}`; toProcess.push( new Promise((resolve, reject) => { mainConverter(filePath, fileType, convertTo, targetPath, {}, converterName) diff --git a/src/converters/mineru.ts b/src/converters/mineru.ts index db6e929..b56f80c 100644 --- a/src/converters/mineru.ts +++ b/src/converters/mineru.ts @@ -113,6 +113,7 @@ export async function convert( // Create .tar archive from the output directory (不使用壓縮) // 強制使用 .tar 格式,禁止 .tar.gz const tarPath = getArchiveFileName(targetPath); + console.log(`[MinerU] Target tar path: ${tarPath}`); // Ensure the parent directory exists const tarDir = dirname(tarPath); @@ -121,11 +122,21 @@ export async function convert( } // Use the actual MinerU output directory for archiving + // MinerU 產生完整資料夾結構,全部封裝進 .tar const outputToArchive = existsSync(mineruActualOutput) ? mineruActualOutput : mineruOutputDir; + console.log(`[MinerU] Archiving directory: ${outputToArchive}`); + + // 列出要封裝的內容 + if (existsSync(outputToArchive)) { + const contents = readdirSync(outputToArchive); + console.log(`[MinerU] Archive contents: ${contents.join(", ")}`); + } + await createTarArchive(outputToArchive, tarPath, execFile); + console.log(`[MinerU] Created archive: ${tarPath}`); // Clean up the temporary directory removeDir(mineruOutputDir); diff --git a/src/converters/pdfmathtranslate.ts b/src/converters/pdfmathtranslate.ts index db55cb4..6f65a26 100644 --- a/src/converters/pdfmathtranslate.ts +++ b/src/converters/pdfmathtranslate.ts @@ -261,49 +261,75 @@ export async function convert( // 5. 執行 pdf2zh 翻譯 const { monoPath, dualPath } = await runPdf2zh(filePath, tempDir, targetLang, execFile); - // 6. 複製原始檔案到封裝目錄 - const originalDest = join(archiveDir, "original.pdf"); - copyFileSync(filePath, originalDest); - - // 7. 複製翻譯後的檔案(優先使用 mono,因為它是純翻譯版本) + // 6. 複製翻譯後的檔案到封裝目錄 + // PDFMathTranslate 輸出: + // - mono: 純翻譯版本(translated-.pdf) + // - dual: 雙語對照版本(bilingual-.pdf) + // ⚠️ 不包含原始 PDF,只包含翻譯結果 + const translatedDest = join(archiveDir, `translated-${targetLang}.pdf`); + const bilingualDest = join(archiveDir, `bilingual-${targetLang}.pdf`); - // 檢查各種可能的輸出檔案名稱 - const possibleOutputs = [ + // 檢查各種可能的 mono 輸出檔案名稱 + const possibleMonoOutputs = [ monoPath, - dualPath, join(tempDir, `${inputFileName}-mono.pdf`), + ]; + + // 檢查各種可能的 dual 輸出檔案名稱 + const possibleDualOutputs = [ + dualPath, join(tempDir, `${inputFileName}-dual.pdf`), ]; - let foundOutput = false; - for (const outputPath of possibleOutputs) { + let foundMono = false; + let foundDual = false; + + // 複製 mono(翻譯版) + for (const outputPath of possibleMonoOutputs) { if (existsSync(outputPath)) { copyFileSync(outputPath, translatedDest); - foundOutput = true; - console.log(`[PDFMathTranslate] Using output: ${outputPath}`); + foundMono = true; + console.log(`[PDFMathTranslate] Copied mono (translated): ${outputPath}`); + break; + } + } + + // 複製 dual(對照版) + for (const outputPath of possibleDualOutputs) { + if (existsSync(outputPath)) { + copyFileSync(outputPath, bilingualDest); + foundDual = true; + console.log(`[PDFMathTranslate] Copied dual (bilingual): ${outputPath}`); break; } } // 如果找不到預期的輸出檔案,嘗試找任何 PDF - if (!foundOutput) { + if (!foundMono && !foundDual) { const allFiles = readdirSync(tempDir); const pdfFiles = allFiles.filter((f) => f.endsWith(".pdf") && f !== basename(filePath)); - const firstPdfName = pdfFiles[0]; - - if (firstPdfName) { - const firstPdf = join(tempDir, firstPdfName); - copyFileSync(firstPdf, translatedDest); - foundOutput = true; - console.log(`[PDFMathTranslate] Using fallback output: ${firstPdf}`); + + for (const pdfName of pdfFiles) { + const pdfPath = join(tempDir, pdfName); + if (pdfName.includes("mono") || pdfName.includes("translated")) { + copyFileSync(pdfPath, translatedDest); + foundMono = true; + console.log(`[PDFMathTranslate] Using fallback mono: ${pdfPath}`); + } else if (pdfName.includes("dual") || pdfName.includes("bilingual")) { + copyFileSync(pdfPath, bilingualDest); + foundDual = true; + console.log(`[PDFMathTranslate] Using fallback dual: ${pdfPath}`); + } } } - if (!foundOutput) { - throw new Error("No translated PDF output found"); + if (!foundMono && !foundDual) { + throw new Error("No translated PDF output found (neither mono nor dual)"); } + console.log(`[PDFMathTranslate] Archive contents: mono=${foundMono}, dual=${foundDual}`); + // 8. 建立 .tar 封裝 const tarPath = getArchiveFileName(targetPath); const tarDir = dirname(tarPath); diff --git a/src/helpers/normalizeFiletype.ts b/src/helpers/normalizeFiletype.ts index d719a71..5728100 100644 --- a/src/helpers/normalizeFiletype.ts +++ b/src/helpers/normalizeFiletype.ts @@ -31,10 +31,11 @@ export const normalizeOutputFiletype = (filetype: string): string => { case "markdown_mmd": case "markdown": return "md"; - // MinerU output formats - output as tar.gz + // MinerU output formats - 保持原始格式名稱(.tar 由 main.ts 自動添加) + // 因為 MinerU 是 archive-only 引擎,outputMode: "archive" case "md-t": case "md-i": - return "tar.gz"; + return lowercaseFiletype; default: return lowercaseFiletype; } diff --git a/src/pages/download.tsx b/src/pages/download.tsx index 55c2ac3..29fa6b1 100644 --- a/src/pages/download.tsx +++ b/src/pages/download.tsx @@ -1,3 +1,4 @@ +import { existsSync } from "node:fs"; import { Elysia } from "elysia"; import sanitize from "sanitize-filename"; import { outputDir } from ".."; @@ -10,7 +11,7 @@ export const download = new Elysia() .use(userService) .get( "/download/:userId/:jobId/:fileName", - async ({ params, redirect, user }) => { + async ({ params, redirect, set, user }) => { const userId = user.id; const job = await db .query("SELECT * FROM jobs WHERE user_id = ? AND id = ?") @@ -24,6 +25,14 @@ export const download = new Elysia() const fileName = sanitize(decodeURIComponent(params.fileName)); const filePath = `${outputDir}${userId}/${jobId}/${fileName}`; + + // 檢查檔案是否存在 + if (!existsSync(filePath)) { + console.error(`[Download] File not found: ${filePath}`); + set.status = 404; + return { error: "File not found", path: filePath }; + } + return Bun.file(filePath); }, { @@ -32,7 +41,7 @@ export const download = new Elysia() ) .get( "/archive/:jobId", - async ({ params, redirect, user }) => { + async ({ params, redirect, set, user }) => { const userId = user.id; const job = await db .query("SELECT * FROM jobs WHERE user_id = ? AND id = ?") @@ -45,9 +54,23 @@ export const download = new Elysia() const jobId = decodeURIComponent(params.jobId); const outputPath = `${outputDir}${userId}/${jobId}`; + // 檢查輸出目錄是否存在 + if (!existsSync(outputPath)) { + console.error(`[Archive] Output directory not found: ${outputPath}`); + set.status = 404; + return { error: "Output directory not found", path: outputPath }; + } + // 使用統一的封裝管理器建立 .tar(不壓縮) const outputTar = await createJobArchive(outputPath, jobId); + // 檢查 archive 是否成功建立 + if (!existsSync(outputTar)) { + console.error(`[Archive] Failed to create archive: ${outputTar}`); + set.status = 500; + return { error: "Failed to create archive", path: outputTar }; + } + return Bun.file(outputTar); }, { diff --git a/src/pages/results.tsx b/src/pages/results.tsx index a182c45..02201fc 100644 --- a/src/pages/results.tsx +++ b/src/pages/results.tsx @@ -97,35 +97,41 @@ function ResultsArticle({ - {files.map((file) => ( - - - {file.output_file_name} - - {file.status} - - - - - - - - - - ))} + {files.map((file) => { + const isTarFile = file.output_file_name.endsWith(".tar"); + return ( + + + {file.output_file_name} + + {file.status} + + {/* Hide preview icon for .tar files */} + {!isTarFile && ( + + + + )} + + + + + + ); + })} diff --git a/tests/converters/pdfmathtranslate.test.ts b/tests/converters/pdfmathtranslate.test.ts index 1cfef4a..1090b7d 100644 --- a/tests/converters/pdfmathtranslate.test.ts +++ b/tests/converters/pdfmathtranslate.test.ts @@ -255,7 +255,7 @@ describe("PDFMathTranslate converter - Output structure", () => { } }); - test("should create archive with original.pdf and translated-.pdf", async () => { + test("should create archive with translated-.pdf and bilingual-.pdf (no original)", async () => { let tarSourceDir = ""; let archiveContents: string[] = []; @@ -271,7 +271,9 @@ describe("PDFMathTranslate converter - Output structure", () => { if (!existsSync(outputDir)) { mkdirSync(outputDir, { recursive: true }); } + // 模擬 pdf2zh 產生 mono(翻譯版)和 dual(對照版) writeFileSync(join(outputDir, "input-mono.pdf"), "%PDF-1.4\n%Translated"); + writeFileSync(join(outputDir, "input-dual.pdf"), "%PDF-1.4\n%Bilingual"); } callback(null, "Translation complete", ""); } else if (cmd === "tar") { @@ -291,9 +293,11 @@ describe("PDFMathTranslate converter - Output structure", () => { const targetPath = join(testDir, "output.tar"); await convert(testInputFile, "pdf", "pdf-ko", targetPath, undefined, mockExecFile); - // Verify archive contains expected files - expect(archiveContents).toContain("original.pdf"); + // Verify archive contains expected files (翻譯版 + 對照版,不包含原始檔) expect(archiveContents).toContain("translated-ko.pdf"); + expect(archiveContents).toContain("bilingual-ko.pdf"); + // 不應包含原始檔案 + expect(archiveContents).not.toContain("original.pdf"); }); test("should only use .tar format, not .tar.gz", async () => {