convertor/public/transfer.js
Your Name 5322f85721 feat: 實作全域檔案傳輸機制與 .tar 封裝規範
## 主要變更

### 新增全域檔案傳輸模組 (src/transfer/)
- constants.ts: 定義傳輸常數(10MB 門檻、5MB chunk 大小)
- types.ts: 傳輸類型定義
- uploadManager.ts: 後端上傳管理器(支援直傳與 chunk)
- downloadManager.ts: 後端下載管理器(支援直傳與 chunk)
- archiveManager.ts: 封裝管理器(僅允許 .tar)
- index.ts: 統一匯出

### 新增 API 端點
- uploadChunk.tsx: Chunk 上傳 API
- downloadChunk.tsx: Chunk 下載 API

### 前端更新
- public/script.js: 整合智慧傳輸策略(≤10MB 直傳,>10MB chunk)
- public/transfer.js: 前端傳輸管理模組

### 轉換器更新
- mineru.ts: 改用 .tar 格式(不壓縮),禁止 .tar.gz
- download.tsx: 使用統一的封裝管理器

### 測試
- tests/transfer/: 完整傳輸機制測試套件
- tests/converters/mineru.test.ts: 更新以符合 .tar 規範

### 文件
- README.md: 新增檔案傳輸機制說明

## 設計原則
- 檔案 ≤10MB:直接傳輸
- 檔案 >10MB:使用 5MB chunks 分段傳輸
- 多檔輸出:僅允許 .tar 封裝(禁止 .tar.gz/.zip)
- 引擎層不感知 chunk(僅傳輸層處理)
2026-01-21 13:58:01 +08:00

388 lines
9.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Contents.CN 前端檔案傳輸管理器
*
* 統一處理所有檔案的上傳與下載:
* - 檔案 ≤ 10MB直接傳輸
* - 檔案 > 10MB使用 chunk 分段傳輸
*
* ⚠️ 重要:所有功能必須使用此模組,不得自行實作傳輸邏輯
*/
// ==================== 常數定義 ====================
/**
* 檔案大小門檻10MB
*/
const CHUNK_THRESHOLD_BYTES = 10 * 1024 * 1024;
/**
* 每個 chunk 的大小5MB
*/
const CHUNK_SIZE_BYTES = 5 * 1024 * 1024;
// ==================== 工具函數 ====================
/**
* 生成 UUID用於 upload_id
*/
function generateUploadId() {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID();
}
// 降級方案
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === "x" ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
/**
* 判斷是否需要使用 chunk 傳輸
*/
function shouldUseChunkedTransfer(fileSize) {
return fileSize > CHUNK_THRESHOLD_BYTES;
}
/**
* 計算 chunk 數量
*/
function calculateChunkCount(fileSize) {
return Math.ceil(fileSize / CHUNK_SIZE_BYTES);
}
// ==================== 上傳管理器 ====================
/**
* 上傳管理器類別
*/
class UploadManager {
/**
* @param {string} webroot - 網站根路徑
*/
constructor(webroot) {
this.webroot = webroot;
this.activeUploads = new Map();
}
/**
* 上傳檔案(自動判斷使用直傳或 chunk
*
* @param {File} file - 要上傳的檔案
* @param {object} options - 選項
* @param {function} options.onProgress - 進度回調 (percent: number) => void
* @param {function} options.onComplete - 完成回調 (response: object) => void
* @param {function} options.onError - 錯誤回調 (error: Error) => void
* @returns {Promise<object>} 上傳結果
*/
async uploadFile(file, options = {}) {
const { onProgress, onComplete, onError } = options;
try {
let result;
if (shouldUseChunkedTransfer(file.size)) {
// 大檔:使用 chunk 上傳
result = await this.uploadChunked(file, onProgress);
} else {
// 小檔:直接上傳
result = await this.uploadDirect(file, onProgress);
}
if (onComplete) onComplete(result);
return result;
} catch (error) {
if (onError) onError(error);
throw error;
}
}
/**
* 直接上傳(小檔)
*/
async uploadDirect(file, onProgress) {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append("file", file, file.name);
const xhr = new XMLHttpRequest();
xhr.open("POST", `${this.webroot}/upload`, true);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) {
const percent = (e.loaded / e.total) * 100;
onProgress(percent);
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const data = JSON.parse(xhr.responseText);
resolve(data);
} catch {
resolve({ success: true, message: "Upload completed" });
}
} else {
reject(new Error(`Upload failed with status ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error("Upload failed"));
xhr.send(formData);
});
}
/**
* Chunk 上傳(大檔)
*/
async uploadChunked(file, onProgress) {
const uploadId = generateUploadId();
const totalChunks = calculateChunkCount(file.size);
this.activeUploads.set(uploadId, {
file,
totalChunks,
uploadedChunks: 0,
status: "uploading"
});
try {
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const start = chunkIndex * CHUNK_SIZE_BYTES;
const end = Math.min(start + CHUNK_SIZE_BYTES, file.size);
const chunk = file.slice(start, end);
await this.uploadChunk(uploadId, chunkIndex, totalChunks, chunk, file.name, file.size);
// 更新進度
const uploadInfo = this.activeUploads.get(uploadId);
if (uploadInfo) {
uploadInfo.uploadedChunks = chunkIndex + 1;
const percent = ((chunkIndex + 1) / totalChunks) * 100;
if (onProgress) onProgress(percent);
}
}
this.activeUploads.delete(uploadId);
return { success: true, message: "Chunked upload completed", upload_id: uploadId };
} catch (error) {
this.activeUploads.delete(uploadId);
throw error;
}
}
/**
* 上傳單個 chunk
*/
async uploadChunk(uploadId, chunkIndex, totalChunks, chunkData, fileName, totalSize) {
const formData = new FormData();
formData.append("upload_id", uploadId);
formData.append("chunk_index", chunkIndex.toString());
formData.append("total_chunks", totalChunks.toString());
formData.append("file_name", fileName);
formData.append("total_size", totalSize.toString());
formData.append("chunk", chunkData);
const response = await fetch(`${this.webroot}/upload-chunk`, {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(`Chunk ${chunkIndex} upload failed: ${response.status}`);
}
return response.json();
}
/**
* 取消上傳
*/
cancelUpload(uploadId) {
const upload = this.activeUploads.get(uploadId);
if (upload) {
upload.status = "cancelled";
this.activeUploads.delete(uploadId);
}
}
}
// ==================== 下載管理器 ====================
/**
* 下載管理器類別
*/
class DownloadManager {
/**
* @param {string} webroot - 網站根路徑
*/
constructor(webroot) {
this.webroot = webroot;
}
/**
* 下載檔案(自動判斷使用直傳或 chunk
*
* @param {string} url - 下載 URL
* @param {string} fileName - 檔案名稱
* @param {object} options - 選項
* @param {function} options.onProgress - 進度回調
* @returns {Promise<Blob>} 下載的檔案
*/
async downloadFile(url, fileName, options = {}) {
const { onProgress } = options;
// 先取得檔案資訊
const info = await this.getFileInfo(url);
if (!info) {
// 無法取得資訊,使用直接下載
return this.downloadDirect(url, fileName);
}
if (shouldUseChunkedTransfer(info.total_size)) {
// 大檔:使用 chunk 下載
return this.downloadChunked(url, fileName, info, onProgress);
} else {
// 小檔:直接下載
return this.downloadDirect(url, fileName, onProgress);
}
}
/**
* 取得檔案資訊
*/
async getFileInfo(url) {
try {
const response = await fetch(`${url}/info`, { method: "GET" });
if (response.ok) {
return response.json();
}
} catch {
// 忽略錯誤,降級為直接下載
}
return null;
}
/**
* 直接下載(小檔)
*/
async downloadDirect(url, fileName, onProgress) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Download failed: ${response.status}`);
}
const contentLength = response.headers.get("Content-Length");
const total = contentLength ? parseInt(contentLength, 10) : 0;
const reader = response.body.getReader();
const chunks = [];
let loaded = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loaded += value.length;
if (onProgress && total > 0) {
onProgress((loaded / total) * 100);
}
}
const blob = new Blob(chunks);
this.triggerDownload(blob, fileName);
return blob;
}
/**
* Chunk 下載(大檔)
*/
async downloadChunked(url, fileName, info, onProgress) {
const { total_chunks, chunk_size, total_size } = info;
const chunks = [];
for (let i = 0; i < total_chunks; i++) {
const chunkData = await this.downloadChunk(url, i);
chunks.push(chunkData);
if (onProgress) {
const loaded = Math.min((i + 1) * chunk_size, total_size);
onProgress((loaded / total_size) * 100);
}
}
const blob = new Blob(chunks);
this.triggerDownload(blob, fileName);
return blob;
}
/**
* 下載單個 chunk
*/
async downloadChunk(url, chunkIndex) {
const response = await fetch(`${url}/chunk/${chunkIndex}`);
if (!response.ok) {
throw new Error(`Chunk ${chunkIndex} download failed: ${response.status}`);
}
return response.arrayBuffer();
}
/**
* 觸發瀏覽器下載
*/
triggerDownload(blob, fileName) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* 簡單下載(不使用 chunk用於向後相容
*/
async simpleDownload(url, fileName) {
const a = document.createElement("a");
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
}
// ==================== 全域實例 ====================
// 從 meta 標籤取得 webroot
const webrootMeta = document.querySelector("meta[name='webroot']");
const webroot = webrootMeta ? webrootMeta.content : "";
// 建立全域實例
window.ContentsTransfer = {
uploadManager: new UploadManager(webroot),
downloadManager: new DownloadManager(webroot),
// 工具函數
shouldUseChunkedTransfer,
calculateChunkCount,
// 常數
CHUNK_THRESHOLD_BYTES,
CHUNK_SIZE_BYTES,
};
// 向後相容:提供簡化的 API
window.uploadFile = (file, options) => window.ContentsTransfer.uploadManager.uploadFile(file, options);
window.downloadFile = (url, fileName, options) => window.ContentsTransfer.downloadManager.downloadFile(url, fileName, options);
console.log("Contents.CN Transfer Module initialized");