convertor/public/script.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

419 lines
12 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.

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;
let pendingFiles = 0;
let formatSelected = false;
// Get translation helper
const getTranslation = (category, key, params) => {
if (typeof window.t === "function") {
return window.t(category, key, params);
}
// Fallback to English if t is not available
const fallbacks = {
"common.remove": "Remove",
"convert.title": "Convert",
"convert.titleWithType": "Convert .{fileType}",
"convert.convertButton": "Convert",
"convert.uploading": "Uploading...",
};
let text = fallbacks[`${category}.${key}`] || key;
if (params) {
Object.entries(params).forEach(([k, v]) => {
text = text.replace(`{${k}}`, v);
});
}
return text;
};
// ===== 全頁拖曳上傳支援UI 零變動版)=====
// 只監聽 dragover 和 drop不操作任何 UI class
// 這樣虛線框不會變粗、不會閃爍
// 防重複機制:記錄最近處理的檔案
const recentlyProcessedFiles = new Set();
const getFileKey = (file) => `${file.name}_${file.size}_${file.lastModified}`;
const clearRecentFiles = () => {
setTimeout(() => recentlyProcessedFiles.clear(), 100);
};
// 全頁 dragover只做 preventDefault防止瀏覽器開啟檔案
document.addEventListener("dragover", (e) => {
e.preventDefault();
e.stopPropagation();
});
// 全頁 drop處理檔案上傳
document.addEventListener("drop", (e) => {
e.preventDefault();
e.stopPropagation();
const files = e.dataTransfer.files;
if (files.length === 0) {
console.warn("No files dropped — likely a URL or unsupported source.");
return;
}
for (const file of files) {
console.log("Handling dropped file:", file.name);
handleFile(file);
}
});
// ===== 全頁拖曳上傳支援結束 =====
// Extracted handleFile function for reusability in drag-and-drop and file input
function handleFile(file) {
// 防重複檢查:如果這個檔案剛剛已經處理過,直接跳過
const fileKey = getFileKey(file);
if (recentlyProcessedFiles.has(fileKey)) {
console.log("Skipping duplicate file:", file.name);
return;
}
recentlyProcessedFiles.add(fileKey);
clearRecentFiles();
const fileList = document.querySelector("#file-list");
const removeText = getTranslation("common", "remove");
const row = document.createElement("tr");
row.innerHTML = `
<td>${file.name}</td>
<td><progress max="100" class="inline-block h-2 appearance-none overflow-hidden rounded-full border-0 bg-neutral-700 bg-none text-accent-500 accent-accent-500 [&::-moz-progress-bar]:bg-accent-500 [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:[background:none] [&[value]::-webkit-progress-value]:bg-accent-500 [&[value]::-webkit-progress-value]:transition-[inline-size]"></progress></td>
<td>${(file.size / 1024).toFixed(2)} kB</td>
<td><a onclick="deleteRow(this)">${removeText}</a></td>
`;
if (!fileType) {
fileType = file.name.split(".").pop();
fileInput.setAttribute("accept", `.${fileType}`);
setTitle();
fetch(`${webroot}/conversions`, {
method: "POST",
body: JSON.stringify({ fileType }),
headers: { "Content-Type": "application/json" },
})
.then((res) => res.text())
.then((html) => {
selectContainer.innerHTML = html;
updateSearchBar();
})
.catch(console.error);
}
fileList.appendChild(row);
file.htmlRow = row;
fileNames.push(file.name);
uploadFile(file);
}
const selectContainer = document.querySelector("form .select_container");
const updateSearchBar = () => {
const convertToInput = document.querySelector("input[name='convert_to_search']");
const convertToPopup = document.querySelector(".convert_to_popup");
const convertToGroupElements = document.querySelectorAll(".convert_to_group");
const convertToGroups = {};
const convertToElement = document.querySelector("select[name='convert_to']");
const showMatching = (search) => {
for (const [targets, groupElement] of Object.values(convertToGroups)) {
let matchingTargetsFound = 0;
for (const target of targets) {
if (target.dataset.target.includes(search)) {
matchingTargetsFound++;
target.classList.remove("hidden");
target.classList.add("flex");
} else {
target.classList.add("hidden");
target.classList.remove("flex");
}
}
if (matchingTargetsFound === 0) {
groupElement.classList.add("hidden");
groupElement.classList.remove("flex");
} else {
groupElement.classList.remove("hidden");
groupElement.classList.add("flex");
}
}
};
for (const groupElement of convertToGroupElements) {
const groupName = groupElement.dataset.converter;
const targetElements = groupElement.querySelectorAll(".target");
const targets = Array.from(targetElements);
for (const target of targets) {
target.onmousedown = () => {
convertToElement.value = target.dataset.value;
convertToInput.value = `${target.dataset.target} using ${target.dataset.converter}`;
formatSelected = true;
if (pendingFiles === 0 && fileNames.length > 0) {
convertButton.disabled = false;
}
showMatching("");
};
}
convertToGroups[groupName] = [targets, groupElement];
}
convertToInput.addEventListener("input", (e) => {
showMatching(e.target.value.toLowerCase());
});
convertToInput.addEventListener("search", () => {
// when the user clears the search bar using the 'x' button
convertButton.disabled = true;
formatSelected = false;
});
convertToInput.addEventListener("blur", (e) => {
// Keep the popup open even when clicking on a target button
// for a split second to allow the click to go through
if (e?.relatedTarget?.classList?.contains("target")) {
convertToPopup.classList.add("hidden");
convertToPopup.classList.remove("flex");
return;
}
convertToPopup.classList.add("hidden");
convertToPopup.classList.remove("flex");
});
convertToInput.addEventListener("focus", () => {
convertToPopup.classList.remove("hidden");
convertToPopup.classList.add("flex");
});
};
// Add a 'change' event listener to the file input element
fileInput.addEventListener("change", (e) => {
const files = e.target.files;
for (const file of files) {
handleFile(file);
}
});
const setTitle = () => {
const title = document.querySelector("h1");
if (fileType) {
title.textContent = getTranslation("convert", "titleWithType", { fileType });
} else {
title.textContent = getTranslation("convert", "title");
}
};
// Add a onclick for the delete button
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const deleteRow = (target) => {
const filename = target.parentElement.parentElement.children[0].textContent;
const row = target.parentElement.parentElement;
row.remove();
// remove from fileNames
const index = fileNames.indexOf(filename);
fileNames.splice(index, 1);
// reset fileInput
fileInput.value = "";
// if fileNames is empty, reset fileType
if (fileNames.length === 0) {
fileType = null;
fileInput.removeAttribute("accept");
convertButton.disabled = true;
setTitle();
}
fetch(`${webroot}/delete`, {
method: "POST",
body: JSON.stringify({ filename: filename }),
headers: {
"Content-Type": "application/json",
},
}).catch((err) => console.log(err));
};
// ==================== 全域傳輸常數 ====================
const CHUNK_THRESHOLD_BYTES = 10 * 1024 * 1024; // 10MB
const CHUNK_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
/**
* 判斷是否需要使用 chunk 傳輸
*/
function shouldUseChunkedUpload(fileSize) {
return fileSize > CHUNK_THRESHOLD_BYTES;
}
/**
* 生成 UUID
*/
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
*
* @param {File} file - 要上傳的檔案
*/
const uploadFile = (file) => {
convertButton.disabled = true;
convertButton.value = getTranslation("convert", "uploading");
pendingFiles += 1;
if (shouldUseChunkedUpload(file.size)) {
// 大檔:使用 chunk 上傳
uploadFileChunked(file);
} else {
// 小檔:直接上傳
uploadFileDirect(file);
}
};
/**
* 直接上傳≤10MB
*/
const uploadFileDirect = (file) => {
const formData = new FormData();
formData.append("file", file, file.name);
let xhr = new XMLHttpRequest();
xhr.open("POST", `${webroot}/upload`, true);
xhr.onload = () => {
let data = {};
try {
data = JSON.parse(xhr.responseText);
} catch (e) {
console.log("Parse error:", e);
}
pendingFiles -= 1;
if (pendingFiles === 0) {
if (formatSelected) {
convertButton.disabled = false;
}
convertButton.value = getTranslation("convert", "convertButton");
}
// Remove the progress bar when upload is done
let progressbar = file.htmlRow.getElementsByTagName("progress");
if (progressbar[0]) {
progressbar[0].parentElement.remove();
}
console.log("Direct upload complete:", data);
};
xhr.upload.onprogress = (e) => {
let sent = e.loaded;
let total = e.total;
console.log(`upload progress (${file.name}):`, (100 * sent) / total);
let progressbar = file.htmlRow.getElementsByTagName("progress");
if (progressbar[0]) {
progressbar[0].value = (100 * sent) / total;
}
};
xhr.onerror = (e) => {
console.log("Upload error:", e);
pendingFiles -= 1;
if (pendingFiles === 0) {
convertButton.value = getTranslation("convert", "convertButton");
}
};
xhr.send(formData);
};
/**
* Chunk 上傳(>10MB
*/
const uploadFileChunked = async (file) => {
const uploadId = generateUploadId();
const totalChunks = Math.ceil(file.size / CHUNK_SIZE_BYTES);
console.log(`Starting chunked upload: ${file.name}, size: ${file.size}, chunks: ${totalChunks}`);
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);
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", file.name);
formData.append("total_size", file.size.toString());
formData.append("chunk", chunk);
const response = await fetch(`${webroot}/upload-chunk`, {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(`Chunk ${chunkIndex} upload failed: ${response.status}`);
}
// 更新進度
const percent = ((chunkIndex + 1) / totalChunks) * 100;
let progressbar = file.htmlRow.getElementsByTagName("progress");
if (progressbar[0]) {
progressbar[0].value = percent;
}
console.log(`Chunk ${chunkIndex + 1}/${totalChunks} uploaded (${percent.toFixed(1)}%)`);
}
// 完成
pendingFiles -= 1;
if (pendingFiles === 0) {
if (formatSelected) {
convertButton.disabled = false;
}
convertButton.value = getTranslation("convert", "convertButton");
}
// Remove the progress bar
let progressbar = file.htmlRow.getElementsByTagName("progress");
if (progressbar[0]) {
progressbar[0].parentElement.remove();
}
console.log("Chunked upload complete:", file.name);
} catch (error) {
console.error("Chunked upload failed:", error);
pendingFiles -= 1;
if (pendingFiles === 0) {
convertButton.value = getTranslation("convert", "convertButton");
}
}
};
const formConvert = document.querySelector(`form[action='${webroot}/convert']`);
formConvert.addEventListener("submit", () => {
const hiddenInput = document.querySelector("input[name='file_names']");
hiddenInput.value = JSON.stringify(fileNames);
});
updateSearchBar();