Files
2025-12-25 20:00:09 +08:00

583 lines
16 KiB
JavaScript
Raw Permalink 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 path = require("path");
const axios = require("axios");
const { app, dialog, shell } = require("electron");
const { createWriteStream } = require("fs");
const fs = require("fs").promises; // 使用 fs.promises 进行异步文件操作
const logger = require("./logger");
const { v4: uuidv4 } = require('uuid');
const RECORD_MAX = 500;
const LOG_MAX = 2000;
const axiosConfig = {
method: "GET",
timeout: 30000, // 30秒超时
headers: {
// 添加常见的浏览器User-Agent
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
},
};
function getConfigPath() {
return path.join(app.getPath("userData"), "app-config.json");
}
async function readConfig() {
const configPath = getConfigPath();
logger.info("[log] Config path:", configPath);
try {
const data = await fs.readFile(configPath, "utf8");
return JSON.parse(data) || {};
} catch (error) {
return {};
}
}
async function writeConfig(config) {
const configPath = getConfigPath();
try {
await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
return true;
} catch (error) {
logger.error("写入配置文件失败:", error);
return false;
}
}
function getDownloadLogPath() {
return path.join(app.getPath("userData"), "download-log.json");
}
async function readDownloadLog() {
const logPath = getDownloadLogPath();
logger.info("[log] Log path:", logPath);
try {
const data = await fs.readFile(logPath, "utf8");
return JSON.parse(data);
} catch (error) {
return [];
}
}
/**
*
* @param {*} entry {time: Date, level: 'error', message: '日志内容' }
*/
async function appendDownloadLog(entry, parentWindow) {
const logPath = getDownloadLogPath();
let logs = [];
try {
logs = await readDownloadLog();
} catch (error) {
// 如果文件不存在或无法读取,初始化为空数组
logs = [];
}
entry.time = new Date();
console.log(`appendDownloadLog: ${JSON.stringify(entry)}`);
await parentWindow.webContents.send("file-operation-log", entry);
logs.push(entry);
if (logs.length > LOG_MAX) {
logs.shift();
}
try {
await fs.writeFile(logPath, JSON.stringify(logs, null, 2), "utf8");
} catch (writeErr) {
logger.error("写入日志文件失败:", writeErr);
}
}
async function clearDownloadLog() {
const logPath = getDownloadLogPath();
try {
await fs.writeFile(logPath, JSON.stringify([], null, 2), "utf8");
return true;
} catch (error) {
logger.error("清空日志文件失败:", error);
return false;
}
}
function getHistoryRecordPath() {
return path.join(app.getPath("userData"), "history-record.json");
}
async function readHistoryRecord() {
const recordPath = getHistoryRecordPath();
console.info("[History] Record path:", recordPath);
try {
const data = await fs.readFile(recordPath, "utf8");
return JSON.parse(data);
} catch (error) {
return [];
}
}
/**
*
* @param {*} entry {id: 'uuid', time: Date, draft_id: 'draft_id' draft_url: 'draft_url' }
*/
async function appendHistoryRecord(entry) {
const recordPath = getHistoryRecordPath();
let records = [];
try {
records = await readHistoryRecord();
} catch (error) {
// 如果文件不存在或无法读取,初始化为空数组
records = [];
}
console.log(`appendHistoryRecord: ${JSON.stringify(entry)}`);
records.push(entry);
if (records.length > RECORD_MAX) {
records.shift();
}
try {
await fs.writeFile(recordPath, JSON.stringify(records, null, 2), "utf8");
} catch (writeErr) {
console.error("写入草稿历史记录文件失败:", writeErr);
}
}
// 更精确的错误处理
function errorHandler(error = {}, url = "") {
if (error.code === "ECONNREFUSED") {
throw new Error(`[error] not connect to server: ${url}`);
} else if (error.code === "ENOTFOUND") {
throw new Error(`[error] domain not found: ${url}`);
} else if (error.response) {
// 服务器返回了错误状态码如4xx, 5xx
throw new Error(`[error] server error (${error.response.status}): ${url}`);
} else {
throw error; // 重新抛出其他未知错误
}
}
async function getDraftUrls(remoteUrl, parentWindow) {
logger.info("[info] get draft url");
try {
const response = await axios({
...axiosConfig,
url: remoteUrl,
responseType: "json",
});
// 检查HTTP状态码
if (response.status !== 200) {
await appendDownloadLog(
{ level: "error", message: `获取草稿地址信息失败` },
parentWindow
);
throw new Error(
`[error] [draft url] request failed, status code: ${response.status}`
);
}
logger.info("[success] get draft url");
return response.data;
} catch (error) {
errorHandler(error, remoteUrl);
}
}
async function updateDraftPath(parentWindow) {
const targetDir = await getTargetDirectory(parentWindow, true);
if (!targetDir) {
return { success: false, error: "用户取消了目录选择" };
}
try {
const configPath = getConfigPath();
let config = {};
// 尝试读取现有配置
try {
const data = await fs.readFile(configPath, "utf8");
config = JSON.parse(data);
} catch (error) {
// 如果文件不存在保持config为空对象
}
config.targetDirectory = targetDir;
// 写回配置文件
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
logger.info('默认草稿路径已更新为:', targetDir);
return { success: true, targetDir };
} catch (error) {
logger.error('更新默认草稿路径失败:', error);
return { success: false, error: error.message };
}
}
// 提取出来的函数可选参数parentWindow用于显示对话框时附加到对话框
async function getTargetDirectory(parentWindow = null, isUpdate = false) {
let config = await readConfig();
if (!isUpdate && config.targetDirectory) {
try {
await fs.access(config.targetDirectory);
return config.targetDirectory;
} catch (accessErr) {
logger.warn("配置的目录已不存在,将重新选择。");
}
}
const dialogOptions = {
properties: ["openDirectory"],
title: "请选择目标目录",
buttonLabel: "选择此目录",
defaultPath: isUpdate ? config.targetDirectory : undefined
};
// 如果有父窗口,则附加到父窗口
if (parentWindow) {
dialogOptions.window = parentWindow;
}
const result = await dialog.showOpenDialog(dialogOptions);
if (!result.canceled && result.filePaths.length > 0) {
const selectedDir = result.filePaths[0];
config.targetDirectory = selectedDir;
await writeConfig(config);
return selectedDir;
} else {
return '';
}
}
function updateValue(current, finalKey, targetDir, oldVal, targetId) {
if (oldVal) {
// 找到ID在路径中的位置
const idIndex = oldVal.indexOf(targetId);
if (idIndex === -1) return;
// 提取ID及之后的部分作为将要下载的路径
const relativePath = oldVal.substring(idIndex).replaceAll("/", path.sep); // 替换为系统路径分隔符
// targetDir 已包含 targetId 目录所以relativePath中的targetId要去重
const newRelativePath = relativePath.replace(`${targetId}${path.sep}`, "");
const newValue = path.join(targetDir, newRelativePath);
current[finalKey] = newValue;
logger.info(`✅ newValue to:`, newValue);
}
}
// 递归遍历对象更新所有名为path的属性
function recursivelyUpdatePaths(obj, targetDir, targetId) {
// 处理数组
if (Array.isArray(obj)) {
obj.forEach((item) => {
recursivelyUpdatePaths(item, targetDir, targetId);
});
return;
}
// 处理对象
if (obj && typeof obj === "object") {
// 检查是否有path属性
if (obj.path && typeof obj.path === "string") {
updateValue(obj, "path", targetDir, obj.path, targetId);
}
// 递归处理所有属性
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
recursivelyUpdatePaths(obj[key], targetDir, targetId);
}
}
}
}
async function downloadJsonFile(
{ fileUrl, filePath, targetDir, targetId },
parentWindow
) {
// 1. 使用 Axios 下载 JSON 文件
try {
const response = await axios({
method: "GET",
url: fileUrl,
responseType: "json", // 直接告诉 Axios 解析 JSON
});
// 检查HTTP状态码
if (response.status !== 200) {
await appendDownloadLog(
{ level: "error", message: `下载草稿内容文件失败` },
parentWindow
);
throw new Error(
`[error] [json] request failed, status code: ${response.status}`
);
}
// 2. 解析获取到的数据Axios 会根据 responseType: 'json' 自动解析)
const jsonData = response.data;
// 3. 修改 JSON 数据中指定键的值
if (jsonData?.materials) {
logger.info(`[log] start modifyJsonValue: materials`);
recursivelyUpdatePaths(jsonData.materials, targetDir, targetId);
}
await appendDownloadLog(
{
level: "loading",
message: `正在将草稿内容文件写入本地草稿目录 ${targetDir}`,
},
parentWindow
);
// 4. 将修改后的 JSON 对象转换为格式化的字符串并写入本地文件
const jsonString = JSON.stringify(jsonData, null, 2); // 使用 2 个空格进行缩进,美化输出
await fs.writeFile(filePath, jsonString, "utf8"); // 指定编码为 utf8
} catch (error) {
errorHandler(error, fileUrl);
}
}
async function downloadNotJsonFile(
{ fileUrl, filePath, targetDir },
parentWindow
) {
try {
// 1. 使用 Axios 下载非 JSON 文件
const response = await axios({
...axiosConfig,
url: fileUrl,
responseType: "stream", // 设置响应类型为 'stream' 以处理大文件
});
// 检查HTTP状态码
if (response.status !== 200) {
await appendDownloadLog(
{ level: "error", message: `下载草稿内容文件失败` },
parentWindow
);
throw new Error(
`[error] [stream] request failed, status code: ${response.status}`
);
}
logger.info(`[log] start create writable stream: ${filePath}`);
await appendDownloadLog(
{
level: "loading",
message: `正在将草稿内容文件写入本地草稿目录 ${targetDir}`,
},
parentWindow
);
// 创建可写流
const writer = response.data.pipe(createWriteStream(filePath));
return new Promise((resolve, reject) => {
writer.on("finish", resolve);
writer.on("error", (err) => {
// 尝试删除可能不完整的文件
fs.unlink(filePath).catch(() => { });
reject(new Error(`[error] write file failed: ${err.message}`));
});
response.data.on("error", (err) => {
reject(new Error(`[error] download stream error: ${err.message}`));
});
});
} catch (error) {
errorHandler(error, fileUrl);
}
}
/**
* 下载单个文件并保存到指定路径的辅助函数
* @param {string} url 远程文件的URL
* @param {string} filePath 要保存到的本地文件路径
*/
async function downloadSingleFile(config, parentWindow) {
const filePath = config.filePath;
const fileUrl = config.fileUrl;
if (fileUrl.endsWith(".json")) {
logger.info(`[log] start download json file : ${filePath}`);
await downloadJsonFile(config, parentWindow);
} else {
logger.info(`[log] start download non-json file : ${filePath}`);
await downloadNotJsonFile(config, parentWindow);
}
}
// 打开目录
async function openDraftDirectory(dirPath) {
try {
const errorMsg = await shell.openPath(dirPath);
if (errorMsg) {
logger.error(`[error] Failed to open path: ${errorMsg}`);
return { success: false, error: errorMsg };
}
return { success: true };
} catch (error) {
logger.error(`[error] Error opening path: ${error}`);
return { success: false, error: error.message };
}
}
async function downloadFiles(
{ sourceUrl, remoteFileUrls, targetId, isOpenDir },
parentWindow
) {
try {
let baseTargetDir = "";
// 然后获取目标目录,将主窗口作为父窗口传递
try {
baseTargetDir = await getTargetDirectory(parentWindow);
} catch (error) {
logger.error("[log] get target dir fail:", error);
baseTargetDir = '';
}
if (!baseTargetDir) {
await appendDownloadLog(
{ level: "error", message: `获取目录失败:${error}` },
parentWindow
);
return;
}
logger.info("[log] get target dir:", baseTargetDir);
await appendDownloadLog(
{ level: "info", message: `创建剪映草稿目录:${targetId}` },
parentWindow
);
let i = 0;
let relativePath = "";
// 2. 遍历远程文件URL数组
for (const fileUrl of remoteFileUrls) {
try {
// 从URL中提取相对路径部分
// 假设你的URL结构固定可以从特定部分之后开始提取
const urlObj = new URL(fileUrl);
// 提取路径名中可能包含的部分路径根据你的URL结构调整
let fullPath = urlObj.pathname;
// 找到ID在路径中的位置
const idIndex = fullPath.indexOf(targetId);
if (idIndex === -1) continue;
// 提取ID及之后的部分作为将要下载的路径
relativePath = fullPath.substring(idIndex).replaceAll("/", path.sep); // 替换为系统路径分隔符
const fullTargetPath = path.join(baseTargetDir, relativePath);
const targetDir = path.dirname(fullTargetPath);
logger.info("[log] fullTargetPath: " + fullTargetPath);
logger.info("[log] targetDir: " + targetDir);
// 3. 确保目标目录存在
await fs.mkdir(targetDir, { recursive: true }); // recursive: true 可以创建多级目录
// return { success: true, message: targetDir };
// 4. 下载文件
logger.info(`[log] start get file context : ${fileUrl}`);
await appendDownloadLog(
{
level: "loading",
message: `正在下载草稿内容文件: ${relativePath}`,
},
parentWindow
);
await downloadSingleFile(
{ fileUrl, filePath: fullTargetPath, targetDir, targetId },
parentWindow
);
logger.info(`[log] file saved to : ${fullTargetPath}`);
await appendDownloadLog(
{ level: "success", message: `${++i} 个草稿信息文件保存成功` },
parentWindow
);
} catch (error) {
logger.error(`[error] download file ${fileUrl} failed:`, error);
await appendDownloadLog(
{ level: "error", message: `${++i} 个草稿信息文件保存失败` },
parentWindow
);
// 你可以决定是继续下载其他文件还是直接抛出错误
// 这里记录错误但继续尝试下载下一个文件
}
}
await appendDownloadLog(
{
level: "all",
message: `下载完成:所有 ${targetId} 中的剪映草稿已下载!`,
},
parentWindow
);
// {id: 'uuid', time: Date, draft_id: 'draft_id', draft_url: 'draft_url' }
await appendHistoryRecord({
id: uuidv4(),
time: new Date(),
draft_id: targetId,
draft_url: sourceUrl,
});
const jointPath = path.join(baseTargetDir, targetId);
logger.info(`[finish] all download: ${jointPath}`);
if (isOpenDir) await openDraftDirectory(jointPath);
return {
success: true,
message: `文件批量保存完成,保存至目录: ${jointPath}`,
};
} catch (error) {
logger.error(`[error] 批量保存过程发生错误:`, error);
await appendDownloadLog(
{
level: "error",
message: `下载失败:批量保存 ${targetId} 中的剪映草稿过程发生错误!`,
},
parentWindow
);
return { success: false, message: `保存失败: ${error.message} ` };
}
}
async function checkUrlAccessRight(url) {
try {
const response = await axios({
...axiosConfig,
method: 'HEAD',
url: url,
timeout: 5000
});
logger.info(`URL Accessibility Check Result: ${url} - ${response.status}`);
return { accessible: response.status < 400 };
} catch (error) {
logger.error(`URL Accessibility Check Failed: ${url}`, error);
return { accessible: false, error: error.message };
}
}
module.exports = {
readDownloadLog,
clearDownloadLog,
updateDraftPath,
readConfig,
getDraftUrls,
downloadFiles,
checkUrlAccessRight,
readHistoryRecord
};