fix: resolve lint issues (XSS K601, knip unused exports, eslint errors)
- Fix XSS warnings in user.tsx by adding safe attribute to elements - Remove unused exports: ChunkUploadRequest, getArchiveInfo, getFileForDirectDownload, createDirectDownloadHeaders, createConverterArchive - Fix eslint no-unused-vars errors across multiple files - Fix pdfmathtranslate async Promise executor issue - Update tests to use toThrow instead of toMatch for Error objects - Apply prettier formatting
This commit is contained in:
parent
db5f47586e
commit
79fc6f7067
21 changed files with 439 additions and 439 deletions
|
|
@ -1,6 +1,5 @@
|
|||
const webroot = document.querySelector("meta[name='webroot']").content;
|
||||
const fileInput = document.querySelector('input[type="file"]');
|
||||
const dropZone = document.getElementById("dropzone");
|
||||
const convertButton = document.querySelector("input[type='submit']");
|
||||
const fileNames = [];
|
||||
let fileType;
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@
|
|||
applyTheme(preferredTheme);
|
||||
|
||||
// Listen for system preference changes
|
||||
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
|
||||
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
|
||||
// Only update if no manual preference is set
|
||||
if (!localStorage.getItem(THEME_KEY)) {
|
||||
applyTheme(null);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { type SupportedLocale, type Translator, createTranslator, defaultLocale } from "../i18n/index";
|
||||
import {
|
||||
type SupportedLocale,
|
||||
type Translator,
|
||||
createTranslator,
|
||||
defaultLocale,
|
||||
} from "../i18n/index";
|
||||
|
||||
export const ThemeToggle = ({
|
||||
locale = defaultLocale,
|
||||
t = createTranslator(defaultLocale),
|
||||
}: {
|
||||
locale?: SupportedLocale;
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@ import { convert as convertVcf, properties as propertiesVcf } from "./vcf";
|
|||
import { convert as convertxelatex, properties as propertiesxelatex } from "./xelatex";
|
||||
import { convert as convertMarkitdown, properties as propertiesMarkitdown } from "./markitdown";
|
||||
import { convert as convertMineru, properties as propertiesMineru } from "./mineru";
|
||||
import { convert as convertPDFMathTranslate, properties as propertiesPDFMathTranslate } from "./pdfmathtranslate";
|
||||
import {
|
||||
convert as convertPDFMathTranslate,
|
||||
properties as propertiesPDFMathTranslate,
|
||||
} from "./pdfmathtranslate";
|
||||
|
||||
// This should probably be reconstructed so that the functions are not imported instead the functions hook into this to make the converters more modular
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { execFile as execFileOriginal } from "node:child_process";
|
|||
import { mkdirSync, existsSync, readdirSync, unlinkSync, rmdirSync } from "node:fs";
|
||||
import { join, basename, dirname } from "node:path";
|
||||
import { ExecFileFn } from "./types";
|
||||
import { createConverterArchive, getArchiveFileName } from "../transfer";
|
||||
import { getArchiveFileName } from "../transfer";
|
||||
|
||||
export const properties = {
|
||||
from: {
|
||||
|
|
@ -16,7 +16,7 @@ export const properties = {
|
|||
|
||||
/**
|
||||
* Helper function to create a .tar archive from a directory (no compression)
|
||||
*
|
||||
*
|
||||
* ⚠️ 重要:僅使用 .tar 格式,禁止 .tar.gz / .tgz / .zip
|
||||
*/
|
||||
function createTarArchive(
|
||||
|
|
@ -28,23 +28,19 @@ function createTarArchive(
|
|||
// Use tar command to create archive (without gzip compression)
|
||||
// tar -cf <output.tar> -C <sourceDir> .
|
||||
// 注意:使用 -cf 而非 -czf,避免 gzip 壓縮
|
||||
execFile(
|
||||
"tar",
|
||||
["-cf", outputTar, "-C", sourceDir, "."],
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(`tar error: ${error}`);
|
||||
return;
|
||||
}
|
||||
if (stdout) {
|
||||
console.log(`tar stdout: ${stdout}`);
|
||||
}
|
||||
if (stderr) {
|
||||
console.error(`tar stderr: ${stderr}`);
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
execFile("tar", ["-cf", outputTar, "-C", sourceDir, "."], (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(`tar error: ${error}`);
|
||||
return;
|
||||
}
|
||||
if (stdout) {
|
||||
console.log(`tar stdout: ${stdout}`);
|
||||
}
|
||||
if (stderr) {
|
||||
console.error(`tar stderr: ${stderr}`);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -75,11 +71,6 @@ export async function convert(
|
|||
execFile: ExecFileFn = execFileOriginal,
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Determine mode based on target format
|
||||
// md-t = table mode (tables as markdown)
|
||||
// md-i = image mode (tables as images)
|
||||
const tableMode = convertTo === "md-t" ? "latex" : "html";
|
||||
|
||||
// Create a temporary output directory for MinerU
|
||||
const outputDir = dirname(targetPath);
|
||||
const inputFileName = basename(filePath, `.${fileType}`);
|
||||
|
|
@ -92,14 +83,7 @@ export async function convert(
|
|||
|
||||
// Build MinerU command arguments
|
||||
// MinerU CLI: magic-pdf -p <input> -o <output_dir> -m auto
|
||||
const args = [
|
||||
"-p",
|
||||
filePath,
|
||||
"-o",
|
||||
mineruOutputDir,
|
||||
"-m",
|
||||
"auto",
|
||||
];
|
||||
const args = ["-p", filePath, "-o", mineruOutputDir, "-m", "auto"];
|
||||
|
||||
// Add table mode option if md-i (render tables as images)
|
||||
if (convertTo === "md-i") {
|
||||
|
|
|
|||
|
|
@ -6,46 +6,47 @@ import { getArchiveFileName } from "../transfer";
|
|||
|
||||
/**
|
||||
* PDFMathTranslate Content Engine
|
||||
*
|
||||
*
|
||||
* 用於翻譯 PDF 文件同時保留數學公式的引擎。
|
||||
*
|
||||
*
|
||||
* 使用 pdf2zh CLI 工具進行翻譯,支援多種目標語言。
|
||||
* 所有輸出一律打包為 .tar 檔案,包含:
|
||||
* - original.pdf(原始 PDF)
|
||||
* - translated-<lang>.pdf(翻譯後的 PDF)
|
||||
*
|
||||
*
|
||||
* 必須在 Docker build 階段預先下載所需模型,
|
||||
* 不允許在 runtime 隱式下載模型。
|
||||
*/
|
||||
|
||||
// 支援的目標語言列表
|
||||
const SUPPORTED_LANGUAGES = [
|
||||
"en", // English
|
||||
"zh", // Chinese (Simplified)
|
||||
"en", // English
|
||||
"zh", // Chinese (Simplified)
|
||||
"zh-TW", // Chinese (Traditional)
|
||||
"ja", // Japanese
|
||||
"ko", // Korean
|
||||
"de", // German
|
||||
"fr", // French
|
||||
"es", // Spanish
|
||||
"it", // Italian
|
||||
"pt", // Portuguese
|
||||
"ru", // Russian
|
||||
"ar", // Arabic
|
||||
"hi", // Hindi
|
||||
"vi", // Vietnamese
|
||||
"th", // Thai
|
||||
"ja", // Japanese
|
||||
"ko", // Korean
|
||||
"de", // German
|
||||
"fr", // French
|
||||
"es", // Spanish
|
||||
"it", // Italian
|
||||
"pt", // Portuguese
|
||||
"ru", // Russian
|
||||
"ar", // Arabic
|
||||
"hi", // Hindi
|
||||
"vi", // Vietnamese
|
||||
"th", // Thai
|
||||
] as const;
|
||||
|
||||
type SupportedLanguage = typeof SUPPORTED_LANGUAGES[number];
|
||||
|
||||
// 模型路徑(Docker 環境中)
|
||||
const MODELS_PATH = process.env.PDFMATHTRANSLATE_MODELS_PATH || "/models/pdfmathtranslate";
|
||||
|
||||
// 生成 from/to 格式映射
|
||||
function generateLanguageMappings(): { from: Record<string, string[]>; to: Record<string, string[]> } {
|
||||
const outputFormats = SUPPORTED_LANGUAGES.map(lang => `pdf-${lang}`);
|
||||
|
||||
function generateLanguageMappings(): {
|
||||
from: Record<string, string[]>;
|
||||
to: Record<string, string[]>;
|
||||
} {
|
||||
const outputFormats = SUPPORTED_LANGUAGES.map((lang) => `pdf-${lang}`);
|
||||
|
||||
return {
|
||||
from: {
|
||||
document: ["pdf"],
|
||||
|
|
@ -91,7 +92,7 @@ function checkModelsExist(): boolean {
|
|||
|
||||
/**
|
||||
* Helper function to create a .tar archive from a directory (no compression)
|
||||
*
|
||||
*
|
||||
* ⚠️ 重要:僅使用 .tar 格式,禁止 .tar.gz / .tgz / .zip
|
||||
*/
|
||||
function createTarArchive(
|
||||
|
|
@ -103,23 +104,19 @@ function createTarArchive(
|
|||
// Use tar command to create archive (without gzip compression)
|
||||
// tar -cf <output.tar> -C <sourceDir> .
|
||||
// 注意:使用 -cf 而非 -czf,避免 gzip 壓縮
|
||||
execFile(
|
||||
"tar",
|
||||
["-cf", outputTar, "-C", sourceDir, "."],
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(`tar error: ${error}`);
|
||||
return;
|
||||
}
|
||||
if (stdout) {
|
||||
console.log(`tar stdout: ${stdout}`);
|
||||
}
|
||||
if (stderr) {
|
||||
console.error(`tar stderr: ${stderr}`);
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
execFile("tar", ["-cf", outputTar, "-C", sourceDir, "."], (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(`tar error: ${error}`);
|
||||
return;
|
||||
}
|
||||
if (stdout) {
|
||||
console.log(`tar stdout: ${stdout}`);
|
||||
}
|
||||
if (stderr) {
|
||||
console.error(`tar stderr: ${stderr}`);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -143,7 +140,7 @@ function removeDir(dirPath: string): void {
|
|||
|
||||
/**
|
||||
* 執行 pdf2zh 命令進行 PDF 翻譯
|
||||
*
|
||||
*
|
||||
* @param inputPath 輸入 PDF 路徑
|
||||
* @param outputDir 輸出目錄
|
||||
* @param targetLang 目標語言
|
||||
|
|
@ -160,16 +157,19 @@ function runPdf2zh(
|
|||
// -lo <lang>: 目標語言
|
||||
// -o <dir>: 輸出目錄
|
||||
// -s google: 使用 Google 翻譯(預設,免費)
|
||||
//
|
||||
//
|
||||
// 輸出檔案:
|
||||
// - <filename>-mono.pdf: 純翻譯版本
|
||||
// - <filename>-dual.pdf: 雙語對照版本
|
||||
|
||||
|
||||
const args = [
|
||||
inputPath,
|
||||
"-lo", targetLang,
|
||||
"-o", outputDir,
|
||||
"-s", process.env.PDFMATHTRANSLATE_SERVICE || "google",
|
||||
"-lo",
|
||||
targetLang,
|
||||
"-o",
|
||||
outputDir,
|
||||
"-s",
|
||||
process.env.PDFMATHTRANSLATE_SERVICE || "google",
|
||||
];
|
||||
|
||||
// 如果設定了自訂模型路徑,使用 --onnx 參數
|
||||
|
|
@ -205,9 +205,9 @@ function runPdf2zh(
|
|||
if (!existsSync(monoPath) && !existsSync(dualPath)) {
|
||||
// 嘗試在 outputDir 下尋找任何 PDF 檔案
|
||||
const files = readdirSync(outputDir);
|
||||
const pdfFiles = files.filter(f => f.endsWith(".pdf"));
|
||||
const pdfFiles = files.filter((f) => f.endsWith(".pdf"));
|
||||
console.log(`[PDFMathTranslate] Found PDF files: ${pdfFiles.join(", ")}`);
|
||||
|
||||
|
||||
if (pdfFiles.length === 0) {
|
||||
reject(`No output PDF files found in ${outputDir}`);
|
||||
return;
|
||||
|
|
@ -221,7 +221,7 @@ function runPdf2zh(
|
|||
|
||||
/**
|
||||
* 主要轉換函數
|
||||
*
|
||||
*
|
||||
* @param filePath 輸入 PDF 檔案路徑
|
||||
* @param fileType 檔案類型(應為 "pdf")
|
||||
* @param convertTo 目標格式(如 "pdf-zh")
|
||||
|
|
@ -234,93 +234,91 @@ export async function convert(
|
|||
fileType: string,
|
||||
convertTo: string,
|
||||
targetPath: string,
|
||||
options?: unknown,
|
||||
_options?: unknown,
|
||||
execFile: ExecFileFn = execFileOriginal,
|
||||
): Promise<string> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
// 1. 檢查模型(警告但不阻止,因為 pdf2zh 可能會自動下載)
|
||||
checkModelsExist();
|
||||
try {
|
||||
// 1. 檢查模型(警告但不阻止,因為 pdf2zh 可能會自動下載)
|
||||
checkModelsExist();
|
||||
|
||||
// 2. 提取目標語言
|
||||
const targetLang = extractTargetLanguage(convertTo);
|
||||
console.log(`[PDFMathTranslate] Translating to: ${targetLang}`);
|
||||
// 2. 提取目標語言
|
||||
const targetLang = extractTargetLanguage(convertTo);
|
||||
console.log(`[PDFMathTranslate] Translating to: ${targetLang}`);
|
||||
|
||||
// 3. 建立臨時輸出目錄
|
||||
const outputDir = dirname(targetPath);
|
||||
const inputFileName = basename(filePath, `.${fileType}`);
|
||||
const tempDir = join(outputDir, `${inputFileName}_pdfmathtranslate_${Date.now()}`);
|
||||
// 3. 建立臨時輸出目錄
|
||||
const outputDir = dirname(targetPath);
|
||||
const inputFileName = basename(filePath, `.${fileType}`);
|
||||
const tempDir = join(outputDir, `${inputFileName}_pdfmathtranslate_${Date.now()}`);
|
||||
|
||||
if (!existsSync(tempDir)) {
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 4. 建立封裝用目錄
|
||||
const archiveDir = join(tempDir, "archive");
|
||||
mkdirSync(archiveDir, { recursive: true });
|
||||
|
||||
// 5. 執行 pdf2zh 翻譯
|
||||
const { monoPath, dualPath } = await runPdf2zh(filePath, tempDir, targetLang, execFile);
|
||||
|
||||
// 6. 複製原始檔案到封裝目錄
|
||||
const originalDest = join(archiveDir, "original.pdf");
|
||||
copyFileSync(filePath, originalDest);
|
||||
|
||||
// 7. 複製翻譯後的檔案(優先使用 mono,因為它是純翻譯版本)
|
||||
const translatedDest = join(archiveDir, `translated-${targetLang}.pdf`);
|
||||
|
||||
// 檢查各種可能的輸出檔案名稱
|
||||
const possibleOutputs = [
|
||||
monoPath,
|
||||
dualPath,
|
||||
join(tempDir, `${inputFileName}-mono.pdf`),
|
||||
join(tempDir, `${inputFileName}-dual.pdf`),
|
||||
];
|
||||
|
||||
let foundOutput = false;
|
||||
for (const outputPath of possibleOutputs) {
|
||||
if (existsSync(outputPath)) {
|
||||
copyFileSync(outputPath, translatedDest);
|
||||
foundOutput = true;
|
||||
console.log(`[PDFMathTranslate] Using output: ${outputPath}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找不到預期的輸出檔案,嘗試找任何 PDF
|
||||
if (!foundOutput) {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundOutput) {
|
||||
throw new Error("No translated PDF output found");
|
||||
}
|
||||
|
||||
// 8. 建立 .tar 封裝
|
||||
const tarPath = getArchiveFileName(targetPath);
|
||||
const tarDir = dirname(tarPath);
|
||||
if (!existsSync(tarDir)) {
|
||||
mkdirSync(tarDir, { recursive: true });
|
||||
}
|
||||
|
||||
await createTarArchive(archiveDir, tarPath, execFile);
|
||||
console.log(`[PDFMathTranslate] Created archive: ${tarPath}`);
|
||||
|
||||
// 9. 清理臨時目錄
|
||||
removeDir(tempDir);
|
||||
|
||||
resolve("Done");
|
||||
} catch (error) {
|
||||
reject(`PDFMathTranslate error: ${error}`);
|
||||
if (!existsSync(tempDir)) {
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
// 4. 建立封裝用目錄
|
||||
const archiveDir = join(tempDir, "archive");
|
||||
mkdirSync(archiveDir, { recursive: true });
|
||||
|
||||
// 5. 執行 pdf2zh 翻譯
|
||||
const { monoPath, dualPath } = await runPdf2zh(filePath, tempDir, targetLang, execFile);
|
||||
|
||||
// 6. 複製原始檔案到封裝目錄
|
||||
const originalDest = join(archiveDir, "original.pdf");
|
||||
copyFileSync(filePath, originalDest);
|
||||
|
||||
// 7. 複製翻譯後的檔案(優先使用 mono,因為它是純翻譯版本)
|
||||
const translatedDest = join(archiveDir, `translated-${targetLang}.pdf`);
|
||||
|
||||
// 檢查各種可能的輸出檔案名稱
|
||||
const possibleOutputs = [
|
||||
monoPath,
|
||||
dualPath,
|
||||
join(tempDir, `${inputFileName}-mono.pdf`),
|
||||
join(tempDir, `${inputFileName}-dual.pdf`),
|
||||
];
|
||||
|
||||
let foundOutput = false;
|
||||
for (const outputPath of possibleOutputs) {
|
||||
if (existsSync(outputPath)) {
|
||||
copyFileSync(outputPath, translatedDest);
|
||||
foundOutput = true;
|
||||
console.log(`[PDFMathTranslate] Using output: ${outputPath}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找不到預期的輸出檔案,嘗試找任何 PDF
|
||||
if (!foundOutput) {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundOutput) {
|
||||
throw new Error("No translated PDF output found");
|
||||
}
|
||||
|
||||
// 8. 建立 .tar 封裝
|
||||
const tarPath = getArchiveFileName(targetPath);
|
||||
const tarDir = dirname(tarPath);
|
||||
if (!existsSync(tarDir)) {
|
||||
mkdirSync(tarDir, { recursive: true });
|
||||
}
|
||||
|
||||
await createTarArchive(archiveDir, tarPath, execFile);
|
||||
console.log(`[PDFMathTranslate] Created archive: ${tarPath}`);
|
||||
|
||||
// 9. 清理臨時目錄
|
||||
removeDir(tempDir);
|
||||
|
||||
return "Done";
|
||||
} catch (error) {
|
||||
throw new Error(`PDFMathTranslate error: ${error}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import path from "node:path";
|
||||
import { Elysia } from "elysia";
|
||||
import sanitize from "sanitize-filename";
|
||||
import { outputDir } from "..";
|
||||
import db from "../db/db";
|
||||
import { WEBROOT } from "../helpers/env";
|
||||
import { userService } from "./user";
|
||||
import { createJobArchive, shouldUseChunkedDownload } from "../transfer";
|
||||
import { createJobArchive } from "../transfer";
|
||||
|
||||
export const download = new Elysia()
|
||||
.use(userService)
|
||||
|
|
@ -45,10 +44,10 @@ export const download = new Elysia()
|
|||
|
||||
const jobId = decodeURIComponent(params.jobId);
|
||||
const outputPath = `${outputDir}${userId}/${jobId}`;
|
||||
|
||||
|
||||
// 使用統一的封裝管理器建立 .tar(不壓縮)
|
||||
const outputTar = await createJobArchive(outputPath, jobId);
|
||||
|
||||
|
||||
return Bun.file(outputTar);
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
/**
|
||||
* Contents.CN Chunk 下載 API
|
||||
*
|
||||
*
|
||||
* 處理大檔的分段下載
|
||||
*/
|
||||
|
||||
import { Elysia, t } from "elysia";
|
||||
import { join } from "node:path";
|
||||
import { Elysia } from "elysia";
|
||||
import { outputDir } from "..";
|
||||
import db from "../db/db";
|
||||
import { WEBROOT } from "../helpers/env";
|
||||
import { userService } from "./user";
|
||||
import sanitize from "sanitize-filename";
|
||||
import {
|
||||
|
|
@ -16,11 +14,8 @@ import {
|
|||
getChunkDownloadInfo,
|
||||
getChunk,
|
||||
createChunkDownloadHeaders,
|
||||
getFileForDirectDownload,
|
||||
createDirectDownloadHeaders,
|
||||
CHUNK_SIZE_BYTES,
|
||||
} from "../transfer";
|
||||
import { statSync, existsSync } from "node:fs";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
export const downloadChunk = new Elysia()
|
||||
.use(userService)
|
||||
|
|
@ -44,7 +39,7 @@ export const downloadChunk = new Elysia()
|
|||
const filePath = `${outputDir}${userId}/${jobId}/${fileName}`;
|
||||
|
||||
const info = getChunkDownloadInfo(filePath);
|
||||
|
||||
|
||||
if (!info) {
|
||||
return { error: "File not found" };
|
||||
}
|
||||
|
|
@ -54,7 +49,7 @@ export const downloadChunk = new Elysia()
|
|||
use_chunked: shouldUseChunkedDownload(filePath),
|
||||
};
|
||||
},
|
||||
{ auth: true }
|
||||
{ auth: true },
|
||||
)
|
||||
/**
|
||||
* 下載特定 chunk
|
||||
|
|
@ -90,14 +85,14 @@ export const downloadChunk = new Elysia()
|
|||
}
|
||||
|
||||
const headers = createChunkDownloadHeaders(info, chunkIndex, chunkData);
|
||||
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
set.headers[key] = value;
|
||||
}
|
||||
|
||||
return new Response(chunkData);
|
||||
},
|
||||
{ auth: true }
|
||||
{ auth: true },
|
||||
)
|
||||
/**
|
||||
* Archive chunk 下載資訊
|
||||
|
|
@ -122,7 +117,7 @@ export const downloadChunk = new Elysia()
|
|||
}
|
||||
|
||||
const info = getChunkDownloadInfo(archivePath);
|
||||
|
||||
|
||||
if (!info) {
|
||||
return { error: "Archive not found" };
|
||||
}
|
||||
|
|
@ -132,7 +127,7 @@ export const downloadChunk = new Elysia()
|
|||
use_chunked: shouldUseChunkedDownload(archivePath),
|
||||
};
|
||||
},
|
||||
{ auth: true }
|
||||
{ auth: true },
|
||||
)
|
||||
/**
|
||||
* Archive chunk 下載
|
||||
|
|
@ -167,12 +162,12 @@ export const downloadChunk = new Elysia()
|
|||
}
|
||||
|
||||
const headers = createChunkDownloadHeaders(info, chunkIndex, chunkData);
|
||||
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
set.headers[key] = value;
|
||||
}
|
||||
|
||||
return new Response(chunkData);
|
||||
},
|
||||
{ auth: true }
|
||||
{ auth: true },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
/**
|
||||
* Contents.CN Chunk 上傳 API
|
||||
*
|
||||
*
|
||||
* 處理大檔的分段上傳
|
||||
*/
|
||||
|
||||
import { Elysia, t } from "elysia";
|
||||
import { WEBROOT } from "../helpers/env";
|
||||
import { uploadsDir } from "../index";
|
||||
import { userService } from "./user";
|
||||
import sanitize from "sanitize-filename";
|
||||
|
|
@ -44,7 +43,7 @@ export const uploadChunk = new Elysia().use(userService).post(
|
|||
user.id,
|
||||
jobId.value,
|
||||
`${uploadsDir}${user.id}/`,
|
||||
userUploadsDir
|
||||
userUploadsDir,
|
||||
);
|
||||
|
||||
return result;
|
||||
|
|
@ -59,7 +58,7 @@ export const uploadChunk = new Elysia().use(userService).post(
|
|||
chunk: t.File(),
|
||||
}),
|
||||
auth: true,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
@ -72,7 +71,7 @@ export const uploadInfo = new Elysia().use(userService).post(
|
|||
const size = parseInt(file_size, 10);
|
||||
|
||||
const useChunked = shouldUseChunkedUpload(size);
|
||||
|
||||
|
||||
return {
|
||||
use_chunked: useChunked,
|
||||
chunk_size: CHUNK_SIZE_BYTES,
|
||||
|
|
@ -84,5 +83,5 @@ export const uploadInfo = new Elysia().use(userService).post(
|
|||
file_size: t.String(),
|
||||
}),
|
||||
auth: true,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -115,12 +115,16 @@ export const user = new Elysia()
|
|||
sm:px-4
|
||||
`}
|
||||
>
|
||||
<h1 class="my-8 text-3xl">{t("setup", "welcome")}</h1>
|
||||
<h1 class="my-8 text-3xl" safe>
|
||||
{t("setup", "welcome")}
|
||||
</h1>
|
||||
<article class="article p-0">
|
||||
<header class="w-full bg-neutral-800 p-4">{t("setup", "createYourAccount")}</header>
|
||||
<header class="w-full bg-neutral-800 p-4" safe>
|
||||
{t("setup", "createYourAccount")}
|
||||
</header>
|
||||
<form method="post" action={`${WEBROOT}/register`} class="p-4">
|
||||
<fieldset class="mb-4 flex flex-col gap-4">
|
||||
<label class="flex flex-col gap-1">
|
||||
<label class="flex flex-col gap-1" safe>
|
||||
{t("auth", "email")}
|
||||
<input
|
||||
type="email"
|
||||
|
|
@ -131,7 +135,7 @@ export const user = new Elysia()
|
|||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<label class="flex flex-col gap-1" safe>
|
||||
{t("auth", "password")}
|
||||
<input
|
||||
type="password"
|
||||
|
|
@ -146,13 +150,14 @@ export const user = new Elysia()
|
|||
<input type="submit" value={t("auth", "createAccount")} class="btn-primary" />
|
||||
</form>
|
||||
<footer class="p-4">
|
||||
{t("setup", "reportIssues")}{" "}
|
||||
<span safe>{t("setup", "reportIssues")}</span>{" "}
|
||||
<a
|
||||
class={`
|
||||
text-accent-500 underline
|
||||
hover:text-accent-400
|
||||
`}
|
||||
href="https://github.com/pi-docket/ConvertX-CN"
|
||||
safe
|
||||
>
|
||||
{t("setup", "github")}
|
||||
</a>
|
||||
|
|
@ -186,7 +191,7 @@ export const user = new Elysia()
|
|||
<article class="article">
|
||||
<form method="post" class="flex flex-col gap-4">
|
||||
<fieldset class="mb-4 flex flex-col gap-4">
|
||||
<label class="flex flex-col gap-1">
|
||||
<label class="flex flex-col gap-1" safe>
|
||||
{t("auth", "email")}
|
||||
<input
|
||||
type="email"
|
||||
|
|
@ -197,7 +202,7 @@ export const user = new Elysia()
|
|||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<label class="flex flex-col gap-1" safe>
|
||||
{t("auth", "password")}
|
||||
<input
|
||||
type="password"
|
||||
|
|
@ -308,7 +313,7 @@ export const user = new Elysia()
|
|||
<article class="article">
|
||||
<form method="post" class="flex flex-col gap-4">
|
||||
<fieldset class="mb-4 flex flex-col gap-4">
|
||||
<label class="flex flex-col gap-1">
|
||||
<label class="flex flex-col gap-1" safe>
|
||||
{t("auth", "email")}
|
||||
<input
|
||||
type="email"
|
||||
|
|
@ -319,7 +324,7 @@ export const user = new Elysia()
|
|||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<label class="flex flex-col gap-1" safe>
|
||||
{t("auth", "password")}
|
||||
<input
|
||||
type="password"
|
||||
|
|
@ -336,6 +341,7 @@ export const user = new Elysia()
|
|||
href={`${WEBROOT}/register`}
|
||||
role="button"
|
||||
class="w-full btn-secondary text-center"
|
||||
safe
|
||||
>
|
||||
{t("nav", "register")}
|
||||
</a>
|
||||
|
|
@ -444,7 +450,7 @@ export const user = new Elysia()
|
|||
<article class="article">
|
||||
<form method="post" class="flex flex-col gap-4">
|
||||
<fieldset class="mb-4 flex flex-col gap-4">
|
||||
<label class="flex flex-col gap-1">
|
||||
<label class="flex flex-col gap-1" safe>
|
||||
{t("auth", "email")}
|
||||
<input
|
||||
type="email"
|
||||
|
|
@ -456,7 +462,7 @@ export const user = new Elysia()
|
|||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<label class="flex flex-col gap-1" safe>
|
||||
{t("auth", "newPassword")}
|
||||
<input
|
||||
type="password"
|
||||
|
|
@ -466,7 +472,7 @@ export const user = new Elysia()
|
|||
autocomplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<label class="flex flex-col gap-1" safe>
|
||||
{t("auth", "currentPassword")}
|
||||
<input
|
||||
type="password"
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
/**
|
||||
* Contents.CN 封裝管理器
|
||||
*
|
||||
*
|
||||
* 統一處理多檔輸出的封裝:
|
||||
* - 唯一允許格式:.tar
|
||||
* - 禁止:.tar.gz, .tgz, .zip 等
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, basename } from "node:path";
|
||||
import { existsSync, mkdirSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import * as tar from "tar";
|
||||
import { ALLOWED_ARCHIVE_FORMAT, FORBIDDEN_ARCHIVE_FORMATS } from "./constants";
|
||||
|
||||
|
|
@ -16,14 +16,14 @@ import { ALLOWED_ARCHIVE_FORMAT, FORBIDDEN_ARCHIVE_FORMATS } from "./constants";
|
|||
*/
|
||||
export function validateArchiveFormat(fileName: string): boolean {
|
||||
const lowerName = fileName.toLowerCase();
|
||||
|
||||
|
||||
// 檢查是否使用禁止的格式
|
||||
for (const forbidden of FORBIDDEN_ARCHIVE_FORMATS) {
|
||||
if (lowerName.endsWith(forbidden)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 檢查是否是允許的格式
|
||||
return lowerName.endsWith(ALLOWED_ARCHIVE_FORMAT);
|
||||
}
|
||||
|
|
@ -33,7 +33,7 @@ export function validateArchiveFormat(fileName: string): boolean {
|
|||
*/
|
||||
export function getArchiveFileName(baseName: string): string {
|
||||
const lowerName = baseName.toLowerCase();
|
||||
|
||||
|
||||
// 移除任何禁止的副檔名
|
||||
let cleanName = baseName;
|
||||
for (const forbidden of FORBIDDEN_ARCHIVE_FORMATS) {
|
||||
|
|
@ -42,19 +42,19 @@ export function getArchiveFileName(baseName: string): string {
|
|||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 如果已經是 .tar 結尾,直接返回
|
||||
if (cleanName.toLowerCase().endsWith(ALLOWED_ARCHIVE_FORMAT)) {
|
||||
return cleanName;
|
||||
}
|
||||
|
||||
|
||||
// 否則加上 .tar
|
||||
return `${cleanName}${ALLOWED_ARCHIVE_FORMAT}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 .tar 封裝(不壓縮)
|
||||
*
|
||||
*
|
||||
* @param sourceDir - 來源目錄
|
||||
* @param outputPath - 輸出的 .tar 檔案路徑
|
||||
* @param options - 額外選項
|
||||
|
|
@ -65,11 +65,11 @@ export async function createTarArchive(
|
|||
options: {
|
||||
filter?: (path: string) => boolean;
|
||||
prefix?: string;
|
||||
} = {}
|
||||
} = {},
|
||||
): Promise<string> {
|
||||
// 強制確保輸出是 .tar 格式
|
||||
const finalOutputPath = getArchiveFileName(outputPath);
|
||||
|
||||
|
||||
// 確保輸出目錄存在
|
||||
const outputDir = join(finalOutputPath, "..");
|
||||
if (!existsSync(outputDir)) {
|
||||
|
|
@ -78,7 +78,7 @@ export async function createTarArchive(
|
|||
|
||||
// 取得要封裝的檔案列表
|
||||
const files = readdirSync(sourceDir);
|
||||
|
||||
|
||||
// 預設過濾器:排除其他 tar 檔案
|
||||
const defaultFilter = (path: string) => !path.match(/\.tar$/i);
|
||||
const filter = options.filter || defaultFilter;
|
||||
|
|
@ -91,7 +91,7 @@ export async function createTarArchive(
|
|||
// 不使用 gzip 壓縮
|
||||
gzip: false,
|
||||
},
|
||||
files.filter(f => filter(f))
|
||||
files.filter((f) => filter(f)),
|
||||
);
|
||||
|
||||
console.log(`Created tar archive: ${finalOutputPath}`);
|
||||
|
|
@ -100,7 +100,7 @@ export async function createTarArchive(
|
|||
|
||||
/**
|
||||
* 建立用於多檔輸出的 .tar 封裝
|
||||
*
|
||||
*
|
||||
* @param outputDir - 輸出目錄(包含多個轉換結果)
|
||||
* @param jobId - 任務 ID
|
||||
*/
|
||||
|
|
@ -114,41 +114,3 @@ export async function createJobArchive(outputDir: string, jobId: string): Promis
|
|||
|
||||
return archivePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 從目錄封裝 MinerU 等多檔輸出引擎的結果
|
||||
*
|
||||
* @param mineruOutputDir - MinerU 輸出目錄
|
||||
* @param targetPath - 目標 .tar 路徑
|
||||
*/
|
||||
export async function createConverterArchive(
|
||||
converterOutputDir: string,
|
||||
targetPath: string
|
||||
): Promise<string> {
|
||||
// 確保輸出使用 .tar 格式
|
||||
const tarPath = getArchiveFileName(targetPath);
|
||||
|
||||
await createTarArchive(converterOutputDir, tarPath);
|
||||
|
||||
return tarPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得封裝檔案資訊
|
||||
*/
|
||||
export function getArchiveInfo(archivePath: string): {
|
||||
exists: boolean;
|
||||
size: number;
|
||||
fileName: string;
|
||||
} | null {
|
||||
if (!existsSync(archivePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stats = statSync(archivePath);
|
||||
return {
|
||||
exists: true,
|
||||
size: stats.size,
|
||||
fileName: basename(archivePath),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* Contents.CN 全域檔案傳輸常數
|
||||
*
|
||||
*
|
||||
* 這些常數定義了整個系統的檔案傳輸規則
|
||||
*/
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* Contents.CN 後端下載管理器
|
||||
*
|
||||
*
|
||||
* 統一處理所有檔案下載,包含:
|
||||
* - 小檔(≤10MB):直接回傳
|
||||
* - 大檔(>10MB):chunk 分段下載
|
||||
|
|
@ -46,9 +46,9 @@ export function getChunkDownloadInfo(filePath: string): ChunkDownloadInfo | null
|
|||
* 取得特定 chunk 的資料
|
||||
*/
|
||||
export async function getChunk(
|
||||
filePath: string,
|
||||
chunkIndex: number,
|
||||
chunkSize: number = CHUNK_SIZE_BYTES
|
||||
filePath: string,
|
||||
chunkIndex: number,
|
||||
chunkSize: number = CHUNK_SIZE_BYTES,
|
||||
): Promise<Buffer | null> {
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
|
|
@ -78,23 +78,13 @@ export async function getChunk(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接取得完整檔案(小檔用)
|
||||
*/
|
||||
export function getFileForDirectDownload(filePath: string): ReturnType<typeof Bun.file> | null {
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
return Bun.file(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 chunk 下載回應的 headers
|
||||
*/
|
||||
export function createChunkDownloadHeaders(
|
||||
info: ChunkDownloadInfo,
|
||||
chunkIndex: number,
|
||||
chunkData: Buffer
|
||||
info: ChunkDownloadInfo,
|
||||
chunkIndex: number,
|
||||
chunkData: Buffer,
|
||||
): Record<string, string> {
|
||||
const start = chunkIndex * info.chunk_size;
|
||||
const end = start + chunkData.length - 1;
|
||||
|
|
@ -110,14 +100,3 @@ export function createChunkDownloadHeaders(
|
|||
"X-Total-Size": info.total_size.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立直接下載回應的 headers
|
||||
*/
|
||||
export function createDirectDownloadHeaders(fileName: string, fileSize: number): Record<string, string> {
|
||||
return {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": fileSize.toString(),
|
||||
"Content-Disposition": `attachment; filename="${encodeURIComponent(fileName)}"`,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* Contents.CN 全域檔案傳輸模組
|
||||
*
|
||||
*
|
||||
* 統一匯出所有傳輸相關功能
|
||||
*/
|
||||
|
||||
|
|
@ -16,7 +16,6 @@ export {
|
|||
|
||||
// 類型
|
||||
export type {
|
||||
ChunkUploadRequest,
|
||||
ChunkUploadResponse,
|
||||
DirectUploadResponse,
|
||||
ChunkDownloadInfo,
|
||||
|
|
@ -40,9 +39,7 @@ export {
|
|||
shouldUseChunkedDownload,
|
||||
getChunkDownloadInfo,
|
||||
getChunk,
|
||||
getFileForDirectDownload,
|
||||
createChunkDownloadHeaders,
|
||||
createDirectDownloadHeaders,
|
||||
} from "./downloadManager";
|
||||
|
||||
// 封裝管理
|
||||
|
|
@ -51,6 +48,4 @@ export {
|
|||
getArchiveFileName,
|
||||
createTarArchive,
|
||||
createJobArchive,
|
||||
createConverterArchive,
|
||||
getArchiveInfo,
|
||||
} from "./archiveManager";
|
||||
|
|
|
|||
|
|
@ -2,24 +2,6 @@
|
|||
* Contents.CN 檔案傳輸類型定義
|
||||
*/
|
||||
|
||||
/**
|
||||
* Chunk 上傳請求結構
|
||||
*/
|
||||
export interface ChunkUploadRequest {
|
||||
/** 上傳會話 ID(UUID) */
|
||||
upload_id: string;
|
||||
/** 當前 chunk 索引(從 0 開始) */
|
||||
chunk_index: number;
|
||||
/** 總 chunk 數量 */
|
||||
total_chunks: number;
|
||||
/** chunk 數據 */
|
||||
data: Blob | ArrayBuffer;
|
||||
/** 原始檔案名稱 */
|
||||
file_name: string;
|
||||
/** 檔案總大小 */
|
||||
total_size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk 上傳回應結構
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* Contents.CN 後端上傳管理器
|
||||
*
|
||||
*
|
||||
* 統一處理所有檔案上傳,包含:
|
||||
* - 小檔(≤10MB):直接接收
|
||||
* - 大檔(>10MB):chunk 接收與合併
|
||||
|
|
@ -8,7 +8,12 @@
|
|||
|
||||
import { existsSync, mkdirSync, rmSync, readdirSync, createWriteStream, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { CHUNK_THRESHOLD_BYTES, CHUNK_SIZE_BYTES, UPLOAD_SESSION_TIMEOUT_MS, CHUNK_TEMP_DIR } from "./constants";
|
||||
import {
|
||||
CHUNK_THRESHOLD_BYTES,
|
||||
CHUNK_SIZE_BYTES,
|
||||
UPLOAD_SESSION_TIMEOUT_MS,
|
||||
CHUNK_TEMP_DIR,
|
||||
} from "./constants";
|
||||
import type { UploadSession, ChunkUploadResponse, DirectUploadResponse } from "./types";
|
||||
import { getTransferMode } from "./types";
|
||||
|
||||
|
|
@ -22,9 +27,12 @@ class UploadSessionManager {
|
|||
|
||||
constructor() {
|
||||
// 定期清理過期的 sessions
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.cleanupExpiredSessions();
|
||||
}, 5 * 60 * 1000); // 每 5 分鐘清理一次
|
||||
this.cleanupInterval = setInterval(
|
||||
() => {
|
||||
this.cleanupExpiredSessions();
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
); // 每 5 分鐘清理一次
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -37,10 +45,10 @@ class UploadSessionManager {
|
|||
fileName: string,
|
||||
totalSize: number,
|
||||
totalChunks: number,
|
||||
baseTempDir: string
|
||||
baseTempDir: string,
|
||||
): UploadSession {
|
||||
const tempDir = join(baseTempDir, CHUNK_TEMP_DIR, uploadId);
|
||||
|
||||
|
||||
if (!existsSync(tempDir)) {
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
}
|
||||
|
|
@ -74,7 +82,7 @@ class UploadSessionManager {
|
|||
markChunkReceived(uploadId: string, chunkIndex: number): boolean {
|
||||
const session = this.sessions.get(uploadId);
|
||||
if (!session) return false;
|
||||
|
||||
|
||||
session.received_chunks.add(chunkIndex);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -85,7 +93,7 @@ class UploadSessionManager {
|
|||
isComplete(uploadId: string): boolean {
|
||||
const session = this.sessions.get(uploadId);
|
||||
if (!session) return false;
|
||||
|
||||
|
||||
return session.received_chunks.size === session.total_chunks;
|
||||
}
|
||||
|
||||
|
|
@ -140,11 +148,11 @@ export function shouldUseChunkedUpload(fileSize: number): boolean {
|
|||
export async function handleDirectUpload(
|
||||
file: File,
|
||||
targetDir: string,
|
||||
fileName: string
|
||||
fileName: string,
|
||||
): Promise<DirectUploadResponse> {
|
||||
try {
|
||||
const targetPath = join(targetDir, fileName);
|
||||
|
||||
|
||||
if (!existsSync(targetDir)) {
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
|
@ -178,12 +186,12 @@ export async function handleChunkUpload(
|
|||
userId: string,
|
||||
jobId: string,
|
||||
baseTempDir: string,
|
||||
targetDir: string
|
||||
targetDir: string,
|
||||
): Promise<ChunkUploadResponse> {
|
||||
try {
|
||||
// 取得或建立會話
|
||||
let session = uploadSessionManager.getSession(uploadId);
|
||||
|
||||
|
||||
if (!session) {
|
||||
session = uploadSessionManager.createSession(
|
||||
uploadId,
|
||||
|
|
@ -192,7 +200,7 @@ export async function handleChunkUpload(
|
|||
fileName,
|
||||
totalSize,
|
||||
totalChunks,
|
||||
baseTempDir
|
||||
baseTempDir,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -224,7 +232,7 @@ export async function handleChunkUpload(
|
|||
if (uploadSessionManager.isComplete(uploadId)) {
|
||||
// 合併所有 chunks
|
||||
const finalPath = await mergeChunks(session, targetDir);
|
||||
|
||||
|
||||
// 清理會話
|
||||
uploadSessionManager.removeSession(uploadId);
|
||||
|
||||
|
|
@ -257,14 +265,14 @@ export async function handleChunkUpload(
|
|||
*/
|
||||
async function mergeChunks(session: UploadSession, targetDir: string): Promise<string> {
|
||||
const targetPath = join(targetDir, session.file_name);
|
||||
|
||||
|
||||
if (!existsSync(targetDir)) {
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 讀取並排序所有 chunks
|
||||
const chunkFiles = readdirSync(session.temp_dir)
|
||||
.filter(f => f.startsWith("chunk_"))
|
||||
.filter((f) => f.startsWith("chunk_"))
|
||||
.sort();
|
||||
|
||||
// 建立輸出串流
|
||||
|
|
@ -298,6 +306,9 @@ async function mergeChunks(session: UploadSession, targetDir: string): Promise<s
|
|||
/**
|
||||
* 計算需要的 chunk 數量
|
||||
*/
|
||||
export function calculateChunkCount(fileSize: number, chunkSize: number = CHUNK_SIZE_BYTES): number {
|
||||
export function calculateChunkCount(
|
||||
fileSize: number,
|
||||
chunkSize: number = CHUNK_SIZE_BYTES,
|
||||
): number {
|
||||
return Math.ceil(fileSize / chunkSize);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ describe("MinerU converter properties", () => {
|
|||
|
||||
describe("MinerU converter md-t mode", () => {
|
||||
const testDir = "./test-output-mineru-t";
|
||||
|
||||
|
||||
beforeEach(() => {
|
||||
if (!existsSync(testDir)) {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
|
|
@ -46,12 +46,11 @@ describe("MinerU converter md-t mode", () => {
|
|||
|
||||
test("should call mineru with markdown table mode for md-t", async () => {
|
||||
let mineruArgs: string[] = [];
|
||||
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
options?: any,
|
||||
) => {
|
||||
if (cmd === "mineru") {
|
||||
mineruArgs = args;
|
||||
|
|
@ -64,7 +63,10 @@ describe("MinerU converter md-t mode", () => {
|
|||
if (!existsSync(autoDir)) {
|
||||
mkdirSync(autoDir, { recursive: true });
|
||||
}
|
||||
writeFileSync(join(autoDir, "output.md"), "# Test\n\n| Col1 | Col2 |\n|---|---|\n| A | B |");
|
||||
writeFileSync(
|
||||
join(autoDir, "output.md"),
|
||||
"# Test\n\n| Col1 | Col2 |\n|---|---|\n| A | B |",
|
||||
);
|
||||
callback(null, "MinerU conversion complete", "");
|
||||
} else if (cmd === "tar") {
|
||||
// Simulate .tar creation (no compression)
|
||||
|
|
@ -83,7 +85,7 @@ describe("MinerU converter md-t mode", () => {
|
|||
|
||||
describe("MinerU converter md-i mode", () => {
|
||||
const testDir = "./test-output-mineru-i";
|
||||
|
||||
|
||||
beforeEach(() => {
|
||||
if (!existsSync(testDir)) {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
|
|
@ -98,12 +100,11 @@ describe("MinerU converter md-i mode", () => {
|
|||
|
||||
test("should call mineru with image table mode for md-i", async () => {
|
||||
let capturedArgs: string[] = [];
|
||||
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
options?: any,
|
||||
) => {
|
||||
if (cmd === "mineru") {
|
||||
capturedArgs = args;
|
||||
|
|
@ -135,7 +136,7 @@ describe("MinerU converter md-i mode", () => {
|
|||
|
||||
describe("MinerU converter .tar output (no compression)", () => {
|
||||
const testDir = "./test-output-mineru-tar";
|
||||
|
||||
|
||||
beforeEach(() => {
|
||||
if (!existsSync(testDir)) {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
|
|
@ -151,12 +152,11 @@ describe("MinerU converter .tar output (no compression)", () => {
|
|||
test("should create .tar archive from output (without gzip)", async () => {
|
||||
let tarCalled = false;
|
||||
let tarArgs: string[] = [];
|
||||
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
options?: any,
|
||||
) => {
|
||||
if (cmd === "mineru") {
|
||||
// Simulate MinerU creating output
|
||||
|
|
@ -194,9 +194,8 @@ describe("MinerU converter .tar output (no compression)", () => {
|
|||
test("should reject on mineru error", async () => {
|
||||
const mockExecFile: ExecFileFn = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
_args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
options?: any,
|
||||
) => {
|
||||
if (cmd === "mineru") {
|
||||
callback(new Error("MinerU failed") as ExecFileException, "", "Error processing file");
|
||||
|
|
@ -204,18 +203,18 @@ describe("MinerU converter .tar output (no compression)", () => {
|
|||
};
|
||||
|
||||
const targetPath = join(testDir, "output.tar");
|
||||
expect(convert("test.pdf", "pdf", "md-t", targetPath, undefined, mockExecFile))
|
||||
.rejects.toMatch(/mineru error/);
|
||||
expect(convert("test.pdf", "pdf", "md-t", targetPath, undefined, mockExecFile)).rejects.toMatch(
|
||||
/mineru error/,
|
||||
);
|
||||
});
|
||||
|
||||
test("should use correct tar arguments without compression", async () => {
|
||||
let tarArgs: string[] = [];
|
||||
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
options?: any,
|
||||
) => {
|
||||
if (cmd === "mineru") {
|
||||
const outputDir = args[3];
|
||||
|
|
@ -248,12 +247,11 @@ describe("MinerU converter .tar output (no compression)", () => {
|
|||
|
||||
test("should convert .tar.gz target path to .tar", async () => {
|
||||
let tarOutputPath = "";
|
||||
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
options?: any,
|
||||
) => {
|
||||
if (cmd === "mineru") {
|
||||
const outputDir = args[3];
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { test, expect, describe, beforeEach, afterEach } from "bun:test";
|
|||
import { convert, properties } from "../../src/converters/pdfmathtranslate";
|
||||
import type { ExecFileException } from "node:child_process";
|
||||
import { ExecFileFn } from "../../src/converters/types";
|
||||
import { mkdirSync, existsSync, writeFileSync, rmSync, readFileSync } from "node:fs";
|
||||
import { mkdirSync, existsSync, writeFileSync, rmSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Skip common tests as PDFMathTranslate has different behavior (archive output)
|
||||
|
|
@ -31,7 +31,7 @@ describe("PDFMathTranslate converter properties", () => {
|
|||
describe("PDFMathTranslate converter - Chinese translation", () => {
|
||||
const testDir = "./test-output-pdfmathtranslate-zh";
|
||||
const testInputFile = join(testDir, "input.pdf");
|
||||
|
||||
|
||||
beforeEach(() => {
|
||||
if (!existsSync(testDir)) {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
|
|
@ -49,17 +49,16 @@ describe("PDFMathTranslate converter - Chinese translation", () => {
|
|||
test("should call pdf2zh with correct language argument for zh", async () => {
|
||||
let pdf2zhArgs: string[] = [];
|
||||
let pdf2zhCalled = false;
|
||||
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
options?: any,
|
||||
) => {
|
||||
if (cmd === "pdf2zh") {
|
||||
pdf2zhCalled = true;
|
||||
pdf2zhArgs = args;
|
||||
|
||||
|
||||
// Simulate pdf2zh creating output files
|
||||
const outputDirIndex = args.indexOf("-o");
|
||||
if (outputDirIndex !== -1 && args[outputDirIndex + 1]) {
|
||||
|
|
@ -95,7 +94,7 @@ describe("PDFMathTranslate converter - Chinese translation", () => {
|
|||
describe("PDFMathTranslate converter - English translation", () => {
|
||||
const testDir = "./test-output-pdfmathtranslate-en";
|
||||
const testInputFile = join(testDir, "input.pdf");
|
||||
|
||||
|
||||
beforeEach(() => {
|
||||
if (!existsSync(testDir)) {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
|
|
@ -111,12 +110,11 @@ describe("PDFMathTranslate converter - English translation", () => {
|
|||
|
||||
test("should call pdf2zh with correct language argument for en", async () => {
|
||||
let pdf2zhArgs: string[] = [];
|
||||
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
options?: any,
|
||||
) => {
|
||||
if (cmd === "pdf2zh") {
|
||||
pdf2zhArgs = args;
|
||||
|
|
@ -145,7 +143,7 @@ describe("PDFMathTranslate converter - English translation", () => {
|
|||
describe("PDFMathTranslate converter - Japanese translation", () => {
|
||||
const testDir = "./test-output-pdfmathtranslate-ja";
|
||||
const testInputFile = join(testDir, "input.pdf");
|
||||
|
||||
|
||||
beforeEach(() => {
|
||||
if (!existsSync(testDir)) {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
|
|
@ -161,12 +159,11 @@ describe("PDFMathTranslate converter - Japanese translation", () => {
|
|||
|
||||
test("should call pdf2zh with correct language argument for ja", async () => {
|
||||
let pdf2zhArgs: string[] = [];
|
||||
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
options?: any,
|
||||
) => {
|
||||
if (cmd === "pdf2zh") {
|
||||
pdf2zhArgs = args;
|
||||
|
|
@ -195,7 +192,7 @@ describe("PDFMathTranslate converter - Japanese translation", () => {
|
|||
describe("PDFMathTranslate converter - Traditional Chinese", () => {
|
||||
const testDir = "./test-output-pdfmathtranslate-zhtw";
|
||||
const testInputFile = join(testDir, "input.pdf");
|
||||
|
||||
|
||||
beforeEach(() => {
|
||||
if (!existsSync(testDir)) {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
|
|
@ -211,12 +208,11 @@ describe("PDFMathTranslate converter - Traditional Chinese", () => {
|
|||
|
||||
test("should call pdf2zh with correct language argument for zh-TW", async () => {
|
||||
let pdf2zhArgs: string[] = [];
|
||||
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
options?: any,
|
||||
) => {
|
||||
if (cmd === "pdf2zh") {
|
||||
pdf2zhArgs = args;
|
||||
|
|
@ -245,7 +241,7 @@ describe("PDFMathTranslate converter - Traditional Chinese", () => {
|
|||
describe("PDFMathTranslate converter - Output structure", () => {
|
||||
const testDir = "./test-output-pdfmathtranslate-structure";
|
||||
const testInputFile = join(testDir, "input.pdf");
|
||||
|
||||
|
||||
beforeEach(() => {
|
||||
if (!existsSync(testDir)) {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
|
|
@ -262,12 +258,11 @@ describe("PDFMathTranslate converter - Output structure", () => {
|
|||
test("should create archive with original.pdf and translated-<lang>.pdf", async () => {
|
||||
let tarSourceDir = "";
|
||||
let archiveContents: string[] = [];
|
||||
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
options?: any,
|
||||
) => {
|
||||
if (cmd === "pdf2zh") {
|
||||
const outputDirIndex = args.indexOf("-o");
|
||||
|
|
@ -286,7 +281,6 @@ describe("PDFMathTranslate converter - Output structure", () => {
|
|||
tarSourceDir = args[cIndex + 1];
|
||||
// Read the files in the archive source directory
|
||||
if (existsSync(tarSourceDir)) {
|
||||
const { readdirSync } = require("node:fs");
|
||||
archiveContents = readdirSync(tarSourceDir);
|
||||
}
|
||||
}
|
||||
|
|
@ -304,12 +298,11 @@ describe("PDFMathTranslate converter - Output structure", () => {
|
|||
|
||||
test("should only use .tar format, not .tar.gz", async () => {
|
||||
let tarArgs: string[] = [];
|
||||
|
||||
|
||||
const mockExecFile: ExecFileFn = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
options?: any,
|
||||
) => {
|
||||
if (cmd === "pdf2zh") {
|
||||
const outputDirIndex = args.indexOf("-o");
|
||||
|
|
@ -333,7 +326,7 @@ describe("PDFMathTranslate converter - Output structure", () => {
|
|||
// Verify tar is called with -cf (not -czf for gzip compression)
|
||||
expect(tarArgs[0]).toBe("-cf");
|
||||
expect(tarArgs[0]).not.toBe("-czf");
|
||||
|
||||
|
||||
// Verify output path ends with .tar not .tar.gz
|
||||
const outputTar = tarArgs[1];
|
||||
expect(outputTar).toMatch(/\.tar$/);
|
||||
|
|
@ -345,7 +338,7 @@ describe("PDFMathTranslate converter - Output structure", () => {
|
|||
describe("PDFMathTranslate converter - Error handling", () => {
|
||||
const testDir = "./test-output-pdfmathtranslate-error";
|
||||
const testInputFile = join(testDir, "input.pdf");
|
||||
|
||||
|
||||
beforeEach(() => {
|
||||
if (!existsSync(testDir)) {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
|
|
@ -362,9 +355,8 @@ describe("PDFMathTranslate converter - Error handling", () => {
|
|||
test("should reject with error when pdf2zh fails", async () => {
|
||||
const mockExecFile: ExecFileFn = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
_args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
options?: any,
|
||||
) => {
|
||||
if (cmd === "pdf2zh") {
|
||||
const error = new Error("Translation failed") as ExecFileException;
|
||||
|
|
@ -373,18 +365,17 @@ describe("PDFMathTranslate converter - Error handling", () => {
|
|||
};
|
||||
|
||||
const targetPath = join(testDir, "output.tar");
|
||||
|
||||
|
||||
await expect(
|
||||
convert(testInputFile, "pdf", "pdf-zh", targetPath, undefined, mockExecFile)
|
||||
).rejects.toMatch(/pdf2zh error/);
|
||||
convert(testInputFile, "pdf", "pdf-zh", targetPath, undefined, mockExecFile),
|
||||
).rejects.toThrow(/pdf2zh error|PDFMathTranslate error/);
|
||||
});
|
||||
|
||||
test("should reject when no output PDF is generated", async () => {
|
||||
const mockExecFile: ExecFileFn = (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
_args: string[],
|
||||
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
|
||||
options?: any,
|
||||
) => {
|
||||
if (cmd === "pdf2zh") {
|
||||
// Don't create any output files
|
||||
|
|
@ -395,9 +386,9 @@ describe("PDFMathTranslate converter - Error handling", () => {
|
|||
};
|
||||
|
||||
const targetPath = join(testDir, "output.tar");
|
||||
|
||||
|
||||
await expect(
|
||||
convert(testInputFile, "pdf", "pdf-zh", targetPath, undefined, mockExecFile)
|
||||
).rejects.toMatch(/No.*PDF.*found/);
|
||||
convert(testInputFile, "pdf", "pdf-zh", targetPath, undefined, mockExecFile),
|
||||
).rejects.toThrow(/No.*PDF.*found|No translated PDF/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* Contents.CN Chunk 下載整合測試
|
||||
*
|
||||
*
|
||||
* 測試大檔 chunk 下載的完整流程
|
||||
*/
|
||||
|
||||
|
|
@ -13,7 +13,7 @@ import {
|
|||
getChunk,
|
||||
createChunkDownloadHeaders,
|
||||
} from "../../src/transfer/downloadManager";
|
||||
import { CHUNK_SIZE_BYTES, CHUNK_THRESHOLD_BYTES } from "../../src/transfer/constants";
|
||||
import { CHUNK_SIZE_BYTES } from "../../src/transfer/constants";
|
||||
|
||||
const testDir = "./test-output-chunk-download";
|
||||
|
||||
|
|
@ -32,8 +32,7 @@ describe("Chunk 下載資訊測試", () => {
|
|||
|
||||
test("getChunkDownloadInfo 應返回正確的下載資訊", () => {
|
||||
const testFile = join(testDir, "large-file.bin");
|
||||
const fileSize = 25 * 1024 * 1024; // 25MB
|
||||
|
||||
|
||||
// 建立一個假檔案(只寫入少量資料模擬)
|
||||
const content = Buffer.alloc(1024, "X"); // 1KB
|
||||
writeFileSync(testFile, content);
|
||||
|
|
@ -68,7 +67,7 @@ describe("Chunk 讀取測試", () => {
|
|||
|
||||
test("getChunk 應正確讀取指定的 chunk", async () => {
|
||||
const testFile = join(testDir, "chunked-file.bin");
|
||||
|
||||
|
||||
// 建立測試檔案:20 bytes,分成 4 個 5-byte chunks
|
||||
const content = "AAAAABBBBBCCCCCDDDD"; // 19 bytes
|
||||
writeFileSync(testFile, content);
|
||||
|
|
@ -159,7 +158,7 @@ describe("傳輸模式判斷測試", () => {
|
|||
test("小檔(≤10MB)不應使用 chunk 下載", () => {
|
||||
const smallFile = join(testDir, "small.txt");
|
||||
writeFileSync(smallFile, "Small file content");
|
||||
|
||||
|
||||
expect(shouldUseChunkedDownload(smallFile)).toBe(false);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,13 @@
|
|||
/**
|
||||
* Contents.CN Chunk 上傳整合測試
|
||||
*
|
||||
*
|
||||
* 測試大檔 chunk 上傳的完整流程
|
||||
*/
|
||||
|
||||
import { test, expect, describe, beforeEach, afterEach } from "bun:test";
|
||||
import { mkdirSync, existsSync, writeFileSync, rmSync, readFileSync, statSync } from "node:fs";
|
||||
import { mkdirSync, existsSync, rmSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
handleChunkUpload,
|
||||
uploadSessionManager,
|
||||
calculateChunkCount,
|
||||
} from "../../src/transfer/uploadManager";
|
||||
import { handleChunkUpload, calculateChunkCount } from "../../src/transfer/uploadManager";
|
||||
import { CHUNK_SIZE_BYTES } from "../../src/transfer/constants";
|
||||
|
||||
const testDir = "./test-output-chunk-upload";
|
||||
|
|
@ -34,7 +30,7 @@ describe("Chunk 上傳整合測試", () => {
|
|||
const fileName = "large-file.bin";
|
||||
const userId = "test-user";
|
||||
const jobId = "test-job";
|
||||
|
||||
|
||||
// 建立測試資料(模擬 15MB 檔案,分 3 個 chunks)
|
||||
const chunkSize = 5 * 1024; // 使用較小的 chunk 以便測試
|
||||
const chunk1 = Buffer.alloc(chunkSize, "A");
|
||||
|
|
@ -42,7 +38,7 @@ describe("Chunk 上傳整合測試", () => {
|
|||
const chunk3 = Buffer.alloc(chunkSize, "C");
|
||||
const totalSize = chunkSize * 3;
|
||||
const totalChunks = 3;
|
||||
|
||||
|
||||
const baseTempDir = join(testDir, "temp");
|
||||
const targetDir = join(testDir, "uploads");
|
||||
mkdirSync(baseTempDir, { recursive: true });
|
||||
|
|
@ -50,8 +46,16 @@ describe("Chunk 上傳整合測試", () => {
|
|||
|
||||
// 上傳 chunk 1
|
||||
const result1 = await handleChunkUpload(
|
||||
uploadId, 0, totalChunks, chunk1, fileName, totalSize,
|
||||
userId, jobId, baseTempDir, targetDir
|
||||
uploadId,
|
||||
0,
|
||||
totalChunks,
|
||||
chunk1,
|
||||
fileName,
|
||||
totalSize,
|
||||
userId,
|
||||
jobId,
|
||||
baseTempDir,
|
||||
targetDir,
|
||||
);
|
||||
expect(result1.success).toBe(true);
|
||||
expect(result1.completed).toBe(false);
|
||||
|
|
@ -59,16 +63,32 @@ describe("Chunk 上傳整合測試", () => {
|
|||
|
||||
// 上傳 chunk 2
|
||||
const result2 = await handleChunkUpload(
|
||||
uploadId, 1, totalChunks, chunk2, fileName, totalSize,
|
||||
userId, jobId, baseTempDir, targetDir
|
||||
uploadId,
|
||||
1,
|
||||
totalChunks,
|
||||
chunk2,
|
||||
fileName,
|
||||
totalSize,
|
||||
userId,
|
||||
jobId,
|
||||
baseTempDir,
|
||||
targetDir,
|
||||
);
|
||||
expect(result2.success).toBe(true);
|
||||
expect(result2.completed).toBe(false);
|
||||
|
||||
// 上傳 chunk 3(最後一個)
|
||||
const result3 = await handleChunkUpload(
|
||||
uploadId, 2, totalChunks, chunk3, fileName, totalSize,
|
||||
userId, jobId, baseTempDir, targetDir
|
||||
uploadId,
|
||||
2,
|
||||
totalChunks,
|
||||
chunk3,
|
||||
fileName,
|
||||
totalSize,
|
||||
userId,
|
||||
jobId,
|
||||
baseTempDir,
|
||||
targetDir,
|
||||
);
|
||||
expect(result3.success).toBe(true);
|
||||
expect(result3.completed).toBe(true);
|
||||
|
|
@ -77,10 +97,10 @@ describe("Chunk 上傳整合測試", () => {
|
|||
// 驗證合併後的檔案
|
||||
const mergedFile = join(targetDir, fileName);
|
||||
expect(existsSync(mergedFile)).toBe(true);
|
||||
|
||||
|
||||
const mergedContent = readFileSync(mergedFile);
|
||||
expect(mergedContent.length).toBe(totalSize);
|
||||
|
||||
|
||||
// 驗證內容正確性
|
||||
const expectedContent = Buffer.concat([chunk1, chunk2, chunk3]);
|
||||
expect(mergedContent.equals(expectedContent)).toBe(true);
|
||||
|
|
@ -91,7 +111,7 @@ describe("Chunk 上傳整合測試", () => {
|
|||
const fileName = "out-of-order.bin";
|
||||
const userId = "test-user";
|
||||
const jobId = "test-job";
|
||||
|
||||
|
||||
const chunkSize = 1024;
|
||||
const chunk0 = Buffer.alloc(chunkSize, "0");
|
||||
const chunk1 = Buffer.alloc(chunkSize, "1");
|
||||
|
|
@ -99,17 +119,61 @@ describe("Chunk 上傳整合測試", () => {
|
|||
const chunk3 = Buffer.alloc(chunkSize, "3");
|
||||
const totalSize = chunkSize * 4;
|
||||
const totalChunks = 4;
|
||||
|
||||
|
||||
const baseTempDir = join(testDir, "temp2");
|
||||
const targetDir = join(testDir, "uploads2");
|
||||
mkdirSync(baseTempDir, { recursive: true });
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
// 亂序上傳:3, 1, 0, 2
|
||||
await handleChunkUpload(uploadId, 3, totalChunks, chunk3, fileName, totalSize, userId, jobId, baseTempDir, targetDir);
|
||||
await handleChunkUpload(uploadId, 1, totalChunks, chunk1, fileName, totalSize, userId, jobId, baseTempDir, targetDir);
|
||||
await handleChunkUpload(uploadId, 0, totalChunks, chunk0, fileName, totalSize, userId, jobId, baseTempDir, targetDir);
|
||||
const finalResult = await handleChunkUpload(uploadId, 2, totalChunks, chunk2, fileName, totalSize, userId, jobId, baseTempDir, targetDir);
|
||||
await handleChunkUpload(
|
||||
uploadId,
|
||||
3,
|
||||
totalChunks,
|
||||
chunk3,
|
||||
fileName,
|
||||
totalSize,
|
||||
userId,
|
||||
jobId,
|
||||
baseTempDir,
|
||||
targetDir,
|
||||
);
|
||||
await handleChunkUpload(
|
||||
uploadId,
|
||||
1,
|
||||
totalChunks,
|
||||
chunk1,
|
||||
fileName,
|
||||
totalSize,
|
||||
userId,
|
||||
jobId,
|
||||
baseTempDir,
|
||||
targetDir,
|
||||
);
|
||||
await handleChunkUpload(
|
||||
uploadId,
|
||||
0,
|
||||
totalChunks,
|
||||
chunk0,
|
||||
fileName,
|
||||
totalSize,
|
||||
userId,
|
||||
jobId,
|
||||
baseTempDir,
|
||||
targetDir,
|
||||
);
|
||||
const finalResult = await handleChunkUpload(
|
||||
uploadId,
|
||||
2,
|
||||
totalChunks,
|
||||
chunk2,
|
||||
fileName,
|
||||
totalSize,
|
||||
userId,
|
||||
jobId,
|
||||
baseTempDir,
|
||||
targetDir,
|
||||
);
|
||||
|
||||
expect(finalResult.success).toBe(true);
|
||||
expect(finalResult.completed).toBe(true);
|
||||
|
|
@ -117,7 +181,7 @@ describe("Chunk 上傳整合測試", () => {
|
|||
// 驗證檔案順序正確
|
||||
const mergedFile = join(targetDir, fileName);
|
||||
const mergedContent = readFileSync(mergedFile);
|
||||
|
||||
|
||||
// 即使亂序上傳,合併後應該按正確順序
|
||||
const expectedContent = Buffer.concat([chunk0, chunk1, chunk2, chunk3]);
|
||||
expect(mergedContent.equals(expectedContent)).toBe(true);
|
||||
|
|
@ -128,26 +192,59 @@ describe("Chunk 上傳整合測試", () => {
|
|||
const fileName = "duplicate-chunk.bin";
|
||||
const userId = "test-user";
|
||||
const jobId = "test-job";
|
||||
|
||||
|
||||
const chunkSize = 512;
|
||||
const chunk0 = Buffer.alloc(chunkSize, "X");
|
||||
const chunk1 = Buffer.alloc(chunkSize, "Y");
|
||||
const totalSize = chunkSize * 2;
|
||||
const totalChunks = 2;
|
||||
|
||||
|
||||
const baseTempDir = join(testDir, "temp3");
|
||||
const targetDir = join(testDir, "uploads3");
|
||||
mkdirSync(baseTempDir, { recursive: true });
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
// 上傳 chunk 0
|
||||
await handleChunkUpload(uploadId, 0, totalChunks, chunk0, fileName, totalSize, userId, jobId, baseTempDir, targetDir);
|
||||
|
||||
await handleChunkUpload(
|
||||
uploadId,
|
||||
0,
|
||||
totalChunks,
|
||||
chunk0,
|
||||
fileName,
|
||||
totalSize,
|
||||
userId,
|
||||
jobId,
|
||||
baseTempDir,
|
||||
targetDir,
|
||||
);
|
||||
|
||||
// 重複上傳 chunk 0
|
||||
await handleChunkUpload(uploadId, 0, totalChunks, chunk0, fileName, totalSize, userId, jobId, baseTempDir, targetDir);
|
||||
|
||||
await handleChunkUpload(
|
||||
uploadId,
|
||||
0,
|
||||
totalChunks,
|
||||
chunk0,
|
||||
fileName,
|
||||
totalSize,
|
||||
userId,
|
||||
jobId,
|
||||
baseTempDir,
|
||||
targetDir,
|
||||
);
|
||||
|
||||
// 上傳 chunk 1
|
||||
const result = await handleChunkUpload(uploadId, 1, totalChunks, chunk1, fileName, totalSize, userId, jobId, baseTempDir, targetDir);
|
||||
const result = await handleChunkUpload(
|
||||
uploadId,
|
||||
1,
|
||||
totalChunks,
|
||||
chunk1,
|
||||
fileName,
|
||||
totalSize,
|
||||
userId,
|
||||
jobId,
|
||||
baseTempDir,
|
||||
targetDir,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.completed).toBe(true);
|
||||
|
|
@ -162,16 +259,16 @@ describe("Chunk 上傳整合測試", () => {
|
|||
describe("Chunk 數量計算測試", () => {
|
||||
test("應正確計算不同大小檔案的 chunk 數量", () => {
|
||||
const chunkSize = CHUNK_SIZE_BYTES;
|
||||
|
||||
|
||||
// 剛好 1 個 chunk
|
||||
expect(calculateChunkCount(chunkSize)).toBe(1);
|
||||
|
||||
|
||||
// 多 1 byte 需要 2 個 chunks
|
||||
expect(calculateChunkCount(chunkSize + 1)).toBe(2);
|
||||
|
||||
|
||||
// 50MB 需要 10 個 chunks
|
||||
expect(calculateChunkCount(50 * 1024 * 1024)).toBe(10);
|
||||
|
||||
|
||||
// 1 byte 需要 1 個 chunk
|
||||
expect(calculateChunkCount(1)).toBe(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* Contents.CN 全域檔案傳輸機制測試
|
||||
*
|
||||
*
|
||||
* 測試項目:
|
||||
* 1. 小檔直傳測試(≤10MB)
|
||||
* 2. 大檔 chunk 上傳測試(>10MB)
|
||||
|
|
@ -16,7 +16,6 @@ import {
|
|||
shouldUseChunkedUpload,
|
||||
calculateChunkCount,
|
||||
handleDirectUpload,
|
||||
handleChunkUpload,
|
||||
uploadSessionManager,
|
||||
} from "../../src/transfer/uploadManager";
|
||||
import {
|
||||
|
|
@ -84,13 +83,13 @@ describe("Chunk 數量計算測試", () => {
|
|||
test("計算 chunk 數量應正確", () => {
|
||||
// 10MB / 5MB = 2 chunks
|
||||
expect(calculateChunkCount(10 * 1024 * 1024)).toBe(2);
|
||||
|
||||
|
||||
// 15MB / 5MB = 3 chunks
|
||||
expect(calculateChunkCount(15 * 1024 * 1024)).toBe(3);
|
||||
|
||||
|
||||
// 1 byte 也需要 1 chunk
|
||||
expect(calculateChunkCount(1)).toBe(1);
|
||||
|
||||
|
||||
// 5MB + 1 byte = 2 chunks
|
||||
expect(calculateChunkCount(5 * 1024 * 1024 + 1)).toBe(2);
|
||||
});
|
||||
|
|
@ -101,7 +100,7 @@ describe("封裝格式驗證測試", () => {
|
|||
// 允許的格式
|
||||
expect(validateArchiveFormat("output.tar")).toBe(true);
|
||||
expect(validateArchiveFormat("my_archive.tar")).toBe(true);
|
||||
|
||||
|
||||
// 禁止的格式
|
||||
expect(validateArchiveFormat("output.tar.gz")).toBe(false);
|
||||
expect(validateArchiveFormat("output.tgz")).toBe(false);
|
||||
|
|
@ -112,19 +111,19 @@ describe("封裝格式驗證測試", () => {
|
|||
test("getArchiveFileName 應強制轉換為 .tar", () => {
|
||||
// 已經是 .tar
|
||||
expect(getArchiveFileName("output.tar")).toBe("output.tar");
|
||||
|
||||
|
||||
// 轉換 .tar.gz -> .tar
|
||||
expect(getArchiveFileName("output.tar.gz")).toBe("output.tar");
|
||||
|
||||
|
||||
// 轉換 .tgz -> .tar
|
||||
expect(getArchiveFileName("output.tgz")).toBe("output.tar");
|
||||
|
||||
|
||||
// 無副檔名 -> 加上 .tar
|
||||
expect(getArchiveFileName("output")).toBe("output.tar");
|
||||
|
||||
|
||||
// .zip 也是禁止的格式,會被移除並加上 .tar
|
||||
expect(getArchiveFileName("output.zip")).toBe("output.tar");
|
||||
|
||||
|
||||
// .gz 也是禁止的格式
|
||||
expect(getArchiveFileName("output.gz")).toBe("output.tar");
|
||||
});
|
||||
|
|
@ -148,7 +147,7 @@ describe("上傳管理測試", () => {
|
|||
const testContent = "Hello, World!";
|
||||
const file = new File([testContent], "test.txt", { type: "text/plain" });
|
||||
|
||||
const result = await handleDirectUpload(file as any, targetDir, "test.txt");
|
||||
const result = await handleDirectUpload(file, targetDir, "test.txt");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(existsSync(join(targetDir, "test.txt"))).toBe(true);
|
||||
|
|
@ -172,7 +171,7 @@ describe("下載管理測試", () => {
|
|||
test("小檔不應使用 chunk 下載", () => {
|
||||
const smallFile = join(testDir, "small.txt");
|
||||
writeFileSync(smallFile, "Small content");
|
||||
|
||||
|
||||
expect(shouldUseChunkedDownload(smallFile)).toBe(false);
|
||||
});
|
||||
|
||||
|
|
@ -196,7 +195,7 @@ describe("下載管理測試", () => {
|
|||
|
||||
// 讀取第一個 chunk(整個檔案,因為小於 chunk size)
|
||||
const chunk = await getChunk(testFile, 0, 5);
|
||||
|
||||
|
||||
expect(chunk).not.toBeNull();
|
||||
expect(chunk!.toString()).toBe("ABCDE");
|
||||
|
||||
|
|
@ -222,7 +221,7 @@ describe(".tar 封裝測試", () => {
|
|||
test("createTarArchive 應建立 .tar 檔案", async () => {
|
||||
const sourceDir = join(testDir, "source");
|
||||
const outputTar = join(testDir, "output.tar");
|
||||
|
||||
|
||||
mkdirSync(sourceDir, { recursive: true });
|
||||
writeFileSync(join(sourceDir, "file1.txt"), "Content 1");
|
||||
writeFileSync(join(sourceDir, "file2.txt"), "Content 2");
|
||||
|
|
@ -238,7 +237,7 @@ describe(".tar 封裝測試", () => {
|
|||
const sourceDir = join(testDir, "source2");
|
||||
// 即使傳入 .tar.gz,也應該輸出 .tar
|
||||
const outputTar = join(testDir, "output.tar.gz");
|
||||
|
||||
|
||||
mkdirSync(sourceDir, { recursive: true });
|
||||
writeFileSync(join(sourceDir, "file.txt"), "Content");
|
||||
|
||||
|
|
@ -309,7 +308,7 @@ describe("上傳會話管理測試", () => {
|
|||
"large-file.pdf",
|
||||
50 * 1024 * 1024, // 50MB
|
||||
10, // 10 chunks
|
||||
testDir
|
||||
testDir,
|
||||
);
|
||||
|
||||
expect(session.upload_id).toBe(uploadId);
|
||||
|
|
@ -319,7 +318,7 @@ describe("上傳會話管理測試", () => {
|
|||
// 標記已接收 chunks
|
||||
uploadSessionManager.markChunkReceived(uploadId, 0);
|
||||
uploadSessionManager.markChunkReceived(uploadId, 1);
|
||||
|
||||
|
||||
const updatedSession = uploadSessionManager.getSession(uploadId);
|
||||
expect(updatedSession!.received_chunks.size).toBe(2);
|
||||
expect(uploadSessionManager.isComplete(uploadId)).toBe(false);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue