mirror of
https://github.com/Hommy-master/capcut-mate.git
synced 2026-09-07 00:07:37 +08:00
583 lines
16 KiB
JavaScript
583 lines
16 KiB
JavaScript
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
|
||
};
|