导入导出

This commit is contained in:
王一之 2025-04-17 00:58:08 +08:00
parent e2832093f0
commit 07c4518cba
25 changed files with 1156 additions and 33 deletions

View File

@ -1,5 +1,3 @@
/* eslint-disable max-classes-per-file */
/* eslint-disable import/prefer-default-export */
import { calculateMd5 } from "@App/pkg/utils/utils";
import { MD5 } from "crypto-js";
import { File, FileReader, FileWriter } from "../filesystem";

View File

@ -1,9 +1,5 @@
import JSZip from "jszip";
import FileSystem, {
File,
FileReader,
FileWriter,
} from "@Pkg/filesystem/filesystem";
import FileSystem, { File, FileReader, FileWriter } from "@Packages/filesystem/filesystem";
import { ZipFileReader, ZipFileWriter } from "./rw";
export default class ZipFileSystem implements FileSystem {

View File

@ -36,6 +36,7 @@ export default defineConfig({
popup: `${src}/pages/popup/main.tsx`,
install: `${src}/pages/install/main.tsx`,
confirm: `${src}/pages/confirm/main.tsx`,
import: `${src}/pages/import/main.tsx`,
options: `${src}/pages/options/main.tsx`,
"editor.worker": "monaco-editor/esm/vs/editor/editor.worker.js",
"ts.worker": "monaco-editor/esm/vs/language/typescript/ts.worker.js",
@ -152,7 +153,6 @@ export default defineConfig({
minify: true,
chunks: ["install"],
}),
,
new rspack.HtmlRspackPlugin({
filename: `${dist}/ext/src/confirm.html`,
template: `${src}/pages/template.html`,
@ -161,6 +161,14 @@ export default defineConfig({
minify: true,
chunks: ["confirm"],
}),
new rspack.HtmlRspackPlugin({
filename: `${dist}/ext/src/import.html`,
template: `${src}/pages/template.html`,
inject: "head",
title: "Import - ScriptCat",
minify: true,
chunks: ["import"],
}),
new rspack.HtmlRspackPlugin({
filename: `${dist}/ext/src/options.html`,
template: `${src}/pages/options.html`,

View File

@ -1,4 +1,4 @@
import { ConfirmParam } from "@App/runtime/service_worker/permission_verify";
import { ConfirmParam } from "./service/service_worker/permission_verify";
export default class CacheKey {
// 加载脚本信息时的缓存
@ -9,4 +9,9 @@ export default class CacheKey {
static permissionConfirm(scriptUuid: string, confirm: ConfirmParam): string {
return `permission:${scriptUuid}:${confirm.permissionValue || ""}:${confirm.permission || ""}`;
}
// importFile 导入文件
static importFile(uuid: string): string {
return `importFile:${uuid}`;
}
}

View File

@ -1,7 +1,7 @@
import { WindowMessage } from "@Packages/message/window_message";
import { SCRIPT_RUN_STATUS, ScriptRunResouce } from "@App/app/repo/scripts";
import { sendMessage } from "@Packages/message/client";
import { MessageSend } from "@Packages/message/server";
import { MessageSend } from "@Packages/message/server";
export function preparationSandbox(msg: WindowMessage) {
return sendMessage(msg, "offscreen/preparationSandbox");
@ -31,3 +31,7 @@ export function runScript(msg: MessageSend, data: ScriptRunResouce) {
export function stopScript(msg: MessageSend, uuid: string) {
return sendMessage(msg, "offscreen/script/stopScript", uuid);
}
export function createObjectURL(msg: MessageSend, data: Blob) {
return sendMessage(msg, "offscreen/createObjectURL", data);
}

View File

@ -54,5 +54,14 @@ export class OffscreenManager {
const gmApi = new GMApi(this.windowServer.group("gmApi"));
gmApi.init();
this.windowServer.on("createObjectURL", (data: Blob) => {
console.log("createObjectURL", data);
const url = URL.createObjectURL(data);
setTimeout(() => {
URL.revokeObjectURL(url);
}, 1000 * 60);
return Promise.resolve(url);
});
}
}

View File

@ -150,3 +150,17 @@ export class PermissionClient extends Client {
return this.do("getInfo", uuid);
}
}
export class SynchronizeClient extends Client {
constructor(msg: MessageSend) {
super(msg, "serviceWorker/synchronize");
}
backup(uuids?: string[]) {
return this.do("backup", uuids);
}
openImportWindow(filename: string, url: string) {
return this.do("openImportWindow", { filename, url });
}
}

View File

@ -347,7 +347,7 @@ export default class GMApi {
const url = URL.createObjectURL(blob);
setTimeout(() => {
URL.revokeObjectURL(url);
}, 6000);
}, 30*1000);
return { action: "onload", data: url };
} catch (e: any) {
return { action: "error", data: { code: 5, error: e.message } };

View File

@ -25,6 +25,7 @@ export default class ServiceWorkerManager {
await this.sender.init();
this.mq.emit("preparationOffscreen", {});
});
this.sender.init();
const systemConfig = new SystemConfig(this.mq);
@ -38,7 +39,7 @@ export default class ServiceWorkerManager {
const popup = new PopupService(this.api.group("popup"), this.mq, runtime);
popup.init();
value.init(runtime, popup);
const synchronize = new SynchronizeService(this.api.group("synchronize"));
const synchronize = new SynchronizeService(this.sender, this.api.group("synchronize"), value, resource);
synchronize.init();
// 定时器处理

View File

@ -142,7 +142,7 @@ export class ScriptService {
setTimeout(() => {
// 清理缓存
Cache.getInstance().del(CacheKey.scriptInstallInfo(uuid));
}, 60 * 1000);
}, 30 * 1000);
openInCurrentTab(`/src/install.html?uuid=${uuid}`);
});
}

View File

@ -1,16 +1,202 @@
import LoggerCore from "@App/app/logger/core";
import Logger from "@App/app/logger/logger";
import { ScriptDAO } from "@App/app/repo/scripts";
import { Group } from "@Packages/message/server";
import { Resource } from "@App/app/repo/resource";
import { Script, SCRIPT_STATUS_ENABLE, ScriptCodeDAO, ScriptDAO } from "@App/app/repo/scripts";
import BackupExport from "@App/pkg/backup/export";
import { BackupData, ResourceBackup, ScriptBackupData, ScriptOptions, ValueStorage } from "@App/pkg/backup/struct";
import FileSystem from "@Packages/filesystem/filesystem";
import ZipFileSystem from "@Packages/filesystem/zip/zip";
import { Group, MessageSend } from "@Packages/message/server";
import JSZip from "jszip";
import { ValueService } from "./value";
import { ResourceService } from "./resource";
import dayjs from "dayjs";
import { createObjectURL } from "../offscreen/client";
export class SynchronizeService {
logger: Logger;
scriptDAO = new ScriptDAO();
scriptCodeDAO = new ScriptCodeDAO();
constructor(private group: Group) {
constructor(
private send: MessageSend,
private group: Group,
private value: ValueService,
private resource: ResourceService
) {
this.logger = LoggerCore.logger().with({ service: "synchronize" });
}
init() {}
// 生成备份文件到文件系统
async backup(fs: FileSystem, uuids?: string[]) {
// 生成导出数据
const data: BackupData = {
script: await this.getScriptBackupData(uuids),
subscribe: [],
};
await new BackupExport(fs).export(data);
}
// 获取脚本备份数据
async getScriptBackupData(uuids?: string[]) {
if (uuids) {
const rets: Promise<ScriptBackupData>[] = [];
uuids.forEach((uuid) => {
rets.push(
this.scriptDAO.get(uuid).then((script) => {
if (script) {
return this.generateScriptBackupData(script);
}
return Promise.reject(new Error(`Script ${uuid} not found`));
})
);
});
return Promise.all(rets);
}
// 获取所有脚本
const list = await this.scriptDAO.all();
return Promise.all(list.map(async (script): Promise<ScriptBackupData> => this.generateScriptBackupData(script)));
}
async generateScriptBackupData(script: Script): Promise<ScriptBackupData> {
const code = await this.scriptCodeDAO.get(script.uuid);
if (!code) {
throw new Error(`Script ${script.uuid} code not found`);
}
const ret = {
code: code.code,
options: {
options: this.scriptOption(script),
settings: {
enabled: script.status === SCRIPT_STATUS_ENABLE,
position: script.sort,
},
meta: {
name: script.name,
uuid: script.uuid,
sc_uuid: script.uuid,
modified: script.updatetime,
file_url: script.downloadUrl,
subscribe_url: script.subscribeUrl,
},
},
// storage,
requires: [],
requiresCss: [],
resources: [],
} as unknown as ScriptBackupData;
const storage: ValueStorage = {
data: {},
ts: new Date().getTime(),
};
const values = await this.value.getScriptValue(script);
Object.keys(values).forEach((key) => {
storage.data[key] = values[key];
});
const requires = await this.resource.getResourceByType(script, "require");
const requiresCss = await this.resource.getResourceByType(script, "require-css");
const resources = await this.resource.getResourceByType(script, "resource");
ret.requires = this.resourceToBackdata(requires);
ret.requiresCss = this.resourceToBackdata(requiresCss);
ret.resources = this.resourceToBackdata(resources);
ret.storage = storage;
return Promise.resolve(ret);
}
resourceToBackdata(resource: { [key: string]: Resource }) {
const ret: ResourceBackup[] = [];
Object.keys(resource).forEach((key) => {
ret.push({
meta: {
name: this.getUrlName(resource[key].url),
url: resource[key].url,
ts: resource[key].updatetime || resource[key].createtime,
mimetype: resource[key].contentType,
},
source: resource[key]!.content || undefined,
base64: resource[key]!.base64,
});
});
return ret;
}
getUrlName(url: string): string {
let index = url.indexOf("?");
if (index !== -1) {
url = url.substring(0, index);
}
index = url.lastIndexOf("/");
if (index !== -1) {
url = url.substring(index + 1);
}
return url;
}
// 为了兼容tm
scriptOption(script: Script): ScriptOptions {
return {
check_for_updates: false,
comment: null,
compat_foreach: false,
compat_metadata: false,
compat_prototypes: false,
compat_wrappedjsobject: false,
compatopts_for_requires: true,
noframes: null,
override: {
merge_connects: true,
merge_excludes: true,
merge_includes: true,
merge_matches: true,
orig_connects: script.metadata.connect || [],
orig_excludes: script.metadata.exclude || [],
orig_includes: script.metadata.include || [],
orig_matches: script.metadata.match || [],
orig_noframes: script.metadata.noframe ? true : null,
orig_run_at: (script.metadata.run_at && script.metadata.run_at[0]) || "document-idle",
use_blockers: [],
use_connects: [],
use_excludes: [],
use_includes: [],
use_matches: [],
},
run_at: null,
};
}
// 请求生成备份文件
async requestBackup(uuids?: string[]) {
const zip = new JSZip();
const fs = new ZipFileSystem(zip);
await this.backup(fs, uuids);
// 生成文件,并下载
const files = await zip.generateAsync({
type: "blob",
compression: "DEFLATE",
compressionOptions: {
level: 9,
},
comment: "Created by Scriptcat",
});
const url = await createObjectURL(this.send, files);
chrome.downloads.download({
url,
saveAs: true,
filename: `scriptcat-backup-${dayjs().format("YYYY-MM-DDTHH-mm-ss")}.zip`,
});
return Promise.resolve();
}
// 请求导入备份文件
async openImportWindow(url: string) {}
init() {
this.group.on("backup", this.requestBackup.bind(this));
this.group.on("import", this.openImportWindow.bind(this));
}
}

View File

@ -364,5 +364,6 @@
"menu_expand_num_before": "菜单项超过",
"menu_expand_num_after": "个时,自动隐藏",
"script_name_cannot_be_set_to_empty": "脚本name不可以设置为空",
"eslint_config_format_error": "eslint配置格式错误"
"eslint_config_format_error": "eslint配置格式错误",
"export_success": "导出成功"
}

View File

@ -129,7 +129,7 @@ const CloudScriptPlan: React.FC<{
const url = URL.createObjectURL(files);
setTimeout(() => {
URL.revokeObjectURL(url);
}, 60 * 1000);
}, 30 * 1000);
chrome.downloads.download({
url,
saveAs: true,

View File

@ -17,7 +17,7 @@ migrate();
// 初始化日志组件
const loggerCore = new LoggerCore({
writer: new DBWriter(new LoggerDAO()),
labels: { env: "install" },
labels: { env: "confirm" },
});
loggerCore.logger().debug("page start");

256
src/pages/import/App.tsx Normal file
View File

@ -0,0 +1,256 @@
import React, { useEffect, useState } from "react";
import { Button, Card, Checkbox, Divider, List, Message, Space, Switch, Typography } from "@arco-design/web-react";
import { useTranslation } from "react-i18next"; // 导入react-i18next的useTranslation钩子
import JSZip from "jszip";
import { ScriptBackupData, ScriptOptions, SubscribeBackupData } from "@App/pkg/backup/struct";
import { prepareScriptByCode } from "@App/pkg/utils/script";
import { Script, SCRIPT_STATUS_DISABLE, SCRIPT_STATUS_ENABLE } from "@App/app/repo/scripts";
import { Subscribe } from "@App/app/repo/subscribe";
import Cache from "@App/app/cache";
import CacheKey from "@App/app/cache_key";
import { parseBackupZipFile } from "@App/pkg/backup/utils";
import { scriptClient, valueClient } from "../store/features/script";
type ScriptData = ScriptBackupData & {
script?: { script: Script; oldScript?: Script };
install: boolean;
error?: string;
};
type SubscribeData = SubscribeBackupData & {
subscribe?: Subscribe;
install: boolean;
};
function App() {
const [scripts, setScripts] = useState<ScriptData[]>([]);
const [subscribes, setSubscribe] = useState<SubscribeData[]>([]);
const [selectAll, setSelectAll] = useState([true, true]);
const [installNum, setInstallNum] = useState([0, 0]);
const [loading, setLoading] = useState(true);
const url = new URL(window.location.href);
const uuid = url.searchParams.get("uuid") || "";
const { t } = useTranslation(); // 使用useTranslation钩子获取翻译函数
useEffect(() => {
Cache.getInstance()
.get(CacheKey.importFile(uuid))
.then(async (resp: { filename: string; url: string }) => {
const filedata = await fetch(resp.url).then((resp) => resp.blob());
const zip = await JSZip.loadAsync(filedata);
const backData = await parseBackupZipFile(zip);
const backDataScript = backData.script as ScriptData[];
setScripts(backDataScript);
// 获取各个脚本现在已经存在的信息
const result = await Promise.all(
backDataScript.map(async (item) => {
try {
const prepareScript = await prepareScriptByCode(
item.code,
item.options?.meta.file_url || "",
item.options?.meta.sc_uuid || undefined,
true
);
item.script = prepareScript;
} catch (e: any) {
item.error = e.toString();
return Promise.resolve(item);
}
if (!item.options) {
item.options = {
options: {} as ScriptOptions,
meta: {
name: item.script?.script.name,
// 此uuid是对tm的兼容处理
uuid: item.script?.script.uuid,
sc_uuid: item.script?.script.uuid,
file_url: item.script?.script.downloadUrl || "",
modified: item.script?.script.createtime,
subscribe_url: item.script?.script.subscribeUrl,
},
settings: {
enabled:
item.enabled === false
? false
: !(item.script?.script.metadata.background || item.script?.script.metadata.crontab),
position: 0,
},
};
}
item.script.script.status =
item.enabled !== false && item.options.settings.enabled ? SCRIPT_STATUS_ENABLE : SCRIPT_STATUS_DISABLE;
item.install = true;
return Promise.resolve(item);
})
);
setScripts(result);
setSelectAll([true, true]);
setLoading(false);
})
.catch((e) => {
Message.error(`获取导入文件失败: ${e}`);
});
}, []);
return (
<div>
<Card bordered={false} title={t("data_import")}>
<Space direction="vertical" style={{ width: "100%" }}>
<Space>
<Button
type="primary"
loading={loading}
onClick={async () => {
setInstallNum((prev) => {
return [0, prev[1]];
});
setLoading(true);
const result = scripts.map(async (item) => {
const ok = true;
if (item.install && !item.error) {
await scriptClient.install(item.script?.script!, item.code);
// 导入数据
const { data } = item.storage;
Object.keys(data).forEach((key) => {
valueClient.setScriptValue(item.script?.script.uuid!, key, data[key]);
});
}
setInstallNum((prev) => {
return [prev[0] + 1, prev[1]];
});
return Promise.resolve(ok);
});
await Promise.all(result);
setLoading(false);
Message.success(t("import_success")!);
}}
>
{t("import")}
</Button>
<Button type="primary" status="danger" loading={loading} onClick={() => window.close()}>
{t("close")}
</Button>
</Space>
<Typography.Text>
{t("select_scripts_to_import")}:{" "}
<Checkbox
checked={selectAll[0]}
onChange={() => {
setScripts((prev) => {
setSelectAll([!selectAll[0], selectAll[1]]);
return prev.map((item) => {
item.install = !selectAll[0];
return item;
});
});
}}
>
{t("select_all")}
</Checkbox>
<Divider type="vertical" />
{t("script_import_progress")}: {installNum[0]}/{scripts.length}
</Typography.Text>
<Typography.Text>
{t("select_subscribes_to_import")}:{" "}
<Checkbox
checked={selectAll[1]}
onChange={() => {
setSubscribe((prev) => {
setSelectAll([selectAll[0], !selectAll[1]]);
return prev.map((item) => {
item.install = !selectAll[1];
return item;
});
});
}}
>
{t("select_all")}
</Checkbox>
<Divider type="vertical" />
{t("subscribe_import_progress")}: {installNum[1]}/{subscribes.length}
</Typography.Text>
<List
className="import-list"
loading={loading}
bordered={false}
dataSource={scripts}
render={(item, index) => (
<div
className="flex flex-row justify-between p-2"
key={`e_${index}`}
style={{
background: item.error ? "rgb(var(--red-1))" : item.install ? "rgb(var(--arcoblue-1))" : "",
borderBottom: "1px solid rgb(var(--gray-3))",
cursor: "pointer",
}}
onClick={() => {
const install = item.install;
setScripts((prev) => {
prev[index].install = !install;
return [...prev];
});
}}
>
<Space
direction="vertical"
size={1}
style={{
overflow: "hidden",
}}
>
<Typography.Title
heading={6}
style={{
color: "rgb(var(--blue-5))",
}}
>
{item.script?.script?.name || item.error || t("unknown")}
</Typography.Title>
<span className="text-sm color-gray-5">
{t("author")}: {item.script?.script?.metadata.author && item.script?.script?.metadata.author[0]}
</span>
<span className="text-sm color-gray-5">
{t("description")}:{" "}
{item.script?.script?.metadata.description && item.script?.script?.metadata.description[0]}
</span>
<span className="text-sm color-gray-5">
{t("source")}: {item.options?.meta.file_url || t("local_creation")}
</span>
<span className="text-sm color-gray-5">
{t("operation")}:{" "}
{(item.install && (item.script?.oldScript ? t("update") : t("add_new"))) ||
(item.error
? `${t("error")}: ${item.options?.meta.name} - ${item.options?.meta.uuid}`
: t("no_operation"))}
</span>
</Space>
<div
className="flex flex-col justify-between"
style={{
minWidth: "80px",
textAlign: "center",
}}
>
<span className="text-sm color-gray-5">{t("enable_script")}</span>
<div className="text-center">
<Switch
size="small"
checked={item.script?.script?.status === SCRIPT_STATUS_ENABLE}
onChange={(checked) => {
setScripts((prev) => {
prev[index].script!.script.status = checked ? SCRIPT_STATUS_ENABLE : SCRIPT_STATUS_DISABLE;
return [...prev];
});
}}
/>
</div>
</div>
</div>
)}
/>
</Space>
</Card>
</div>
);
}
export default App;

View File

@ -0,0 +1,3 @@
.import-list .arco-typography {
margin: 0;
}

33
src/pages/import/main.tsx Normal file
View File

@ -0,0 +1,33 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import MainLayout from "../components/layout/MainLayout.tsx";
import "@arco-design/web-react/dist/css/arco.css";
import "@App/locales/locales";
import "@App/index.css";
import { Provider } from "react-redux";
import { store } from "@App/pages/store/store.ts";
import LoggerCore from "@App/app/logger/core.ts";
import migrate from "@App/app/migrate.ts";
import { LoggerDAO } from "@App/app/repo/logger.ts";
import DBWriter from "@App/app/logger/db_writer.ts";
// 初始化数据库
migrate();
// 初始化日志组件
const loggerCore = new LoggerCore({
writer: new DBWriter(new LoggerDAO()),
labels: { env: "import" },
});
loggerCore.logger().debug("page start");
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<Provider store={store}>
<MainLayout className="!flex-col !p-[10px] box-border h-auto overflow-auto">
<App />
</MainLayout>
</Provider>
</React.StrictMode>
);

View File

@ -83,7 +83,7 @@ import {
scriptClient,
} from "@App/pages/store/features/script";
import { message, systemConfig } from "@App/pages/store/global";
import { ValueClient } from "@App/app/service/service_worker/client";
import { SynchronizeClient, ValueClient } from "@App/app/service/service_worker/client";
type ListType = Script & { loading?: boolean };
@ -719,7 +719,17 @@ function ScriptList() {
select.forEach((item) => {
uuids.push(item.uuid);
});
synchronizeCtrl.backup(uuids);
Message.loading({
id: "export",
content: t("exporting"),
});
new SynchronizeClient(message).backup(uuids).then(() => {
Message.success({
id: "export",
content: t("export_success"),
duration: 3000,
});
});
break;
case "delete":
if (confirm(t("list.confirm_delete")!)) {

View File

@ -7,7 +7,13 @@ import { IconQuestionCircleFill } from "@arco-design/web-react/icon";
import { RefInputType } from "@arco-design/web-react/es/Input/interface";
import { useTranslation } from "react-i18next";
import { FileSystemType } from "@Packages/filesystem/factory";
import { systemConfig } from "@App/pages/store/global";
import { message, systemConfig } from "@App/pages/store/global";
import { SynchronizeClient } from "@App/app/service/service_worker/client";
import Cache from "@App/app/cache";
import CacheKey from "@App/app/cache_key";
import { v4 as uuidv4 } from "uuid";
const synchronizeClient = new SynchronizeClient(message);
function Tools() {
const [loading, setLoading] = useState<{ [key: string]: boolean }>({});
@ -49,7 +55,7 @@ function Tools() {
loading={loading.local}
onClick={async () => {
setLoading((prev) => ({ ...prev, local: true }));
await syncCtrl.backup();
await synchronizeClient.backup();
setLoading((prev) => ({ ...prev, local: false }));
}}
>
@ -58,14 +64,36 @@ function Tools() {
<Button
type="primary"
onClick={() => {
syncCtrl
.openImportFile(fileRef.current!)
.then(() => {
Message.success(t("select_import_script")!);
})
.then((e) => {
Message.error(`${t("import_error")}${e}`);
});
const el = fileRef.current!;
el.onchange = async () => {
const { files } = el;
if (!files) {
return;
}
const file = files[0];
if (!file) {
return;
}
const url = URL.createObjectURL(file);
try {
const uuid = uuidv4();
Cache.getInstance()
.set(CacheKey.importFile(uuid), {
filename: file.name,
url: url,
})
.then(() => {
Message.success(t("select_import_script")!);
// 打开导入窗口
chrome.tabs.create({
url: `/src/import.html?uuid=${uuid}`,
});
});
} catch (e) {
Message.error(`${t("import_error")}: ${e}`);
}
};
el.click();
}}
>
{t("import_file")}

View File

@ -8,13 +8,20 @@ import {
ScriptDAO,
} from "@App/app/repo/scripts";
import { arrayMove } from "@dnd-kit/sortable";
import { PermissionClient, PopupClient, RuntimeClient, ScriptClient } from "@App/app/service/service_worker/client";
import {
PermissionClient,
PopupClient,
RuntimeClient,
ScriptClient,
ValueClient,
} from "@App/app/service/service_worker/client";
import { message } from "../global";
export const scriptClient = new ScriptClient(message);
export const runtimeClient = new RuntimeClient(message);
export const popupClient = new PopupClient(message);
export const permissionClient = new PermissionClient(message);
export const valueClient = new ValueClient(message);
export const fetchAndSortScriptList = createAsyncThunk("script/fetchScriptList", async () => {
// 排序

View File

@ -0,0 +1,114 @@
import JSZip from "jszip";
import BackupExport from "./export";
import BackupImport from "./import";
import { BackupData } from "./struct";
import { describe, expect, it } from "vitest";
import { initTestEnv } from "@Tests/utils";
import ZipFileSystem from "@Packages/filesystem/zip/zip";
initTestEnv();
describe("backup", () => {
const zipFile = new JSZip();
const fs = new ZipFileSystem(zipFile);
it("empty", async () => {
await new BackupExport(fs).export({
script: [],
subscribe: [],
});
const resp = await new BackupImport(fs).parse();
expect(resp).toEqual({
script: [],
subscribe: [],
});
});
it("export and import script", async () => {
const data: BackupData = {
script: [
{
code: `// ==UserScript==
// @name New Userscript
// @namespace https://bbs.tampermonkey.net.cn/
// @version 0.1.0
// @description try to take over the world!
// @author You
// @match {{match}}
// ==/UserScript==
console.log('hello world')`,
options: {
options: {},
meta: {
name: "test",
modified: 1,
file_url: "",
},
settings: {
enabled: true,
position: 1,
},
},
resources: [
{
meta: { name: "test1", mimetype: "text/plain" },
base64: "data:text/plain;base64,aGVsbG8gd29ybGQ=",
source: "hello world",
},
],
requires: [
{
meta: { name: "test2", mimetype: "text/plain" },
base64: "data:text/plain;base64,aGVsbG8gd29ybGQ=",
source: "hello world",
},
],
requiresCss: [
{
meta: { name: "test3", mimetype: "application/javascript" },
base64: "data:application/javascript;base64,aGVsbG8gd29ybGQ=",
source: "hello world",
},
],
storage: {
ts: 1,
data: {
num: 1,
str: "data",
bool: false,
},
},
},
],
subscribe: [
{
source: `// ==UserSubscribe==
// @name New Usersubscribe
// @namespace https://bbs.tampermonkey.net.cn/
// @version 0.1.0
// @description try to take over the world!
// @author You
// ==/UserSubscribe==
console.log('hello world')`,
options: {
meta: {
name: "test",
modified: 1,
url: "",
},
},
},
],
} as unknown as BackupData;
await new BackupExport(fs).export(data);
expect(data.script[0].storage.data.num).toEqual("n1");
expect(data.script[0].storage.data.str).toEqual("sdata");
expect(data.script[0].storage.data.bool).toEqual("bfalse");
const resp = await new BackupImport(fs).parse();
data.script[0].storage.data.num = 1;
data.script[0].storage.data.str = "data";
data.script[0].storage.data.bool = false;
expect(resp).toEqual(data);
});
});

94
src/pkg/backup/export.ts Normal file
View File

@ -0,0 +1,94 @@
import FileSystem from "@Pkg/filesystem/filesystem";
import crypto from "crypto-js";
import { base64ToBlob } from "../utils/script";
import { toStorageValueStr } from "../utils/utils";
import {
BackupData,
ResourceBackup,
ScriptBackupData,
SubscribeBackupData,
} from "./struct";
export default class BackupExport {
fs: FileSystem;
constructor(fileSystem: FileSystem) {
this.fs = fileSystem;
}
// 导出备份数据
export(data: BackupData): Promise<void> {
// 写入脚本备份
const results: Promise<void>[] = [];
data.script.forEach((item) => {
results.push(this.writeScript(item));
});
data.subscribe.forEach((item) => {
results.push(this.writeSubscribe(item));
});
return Promise.all(results).then(() => undefined);
}
async writeScript(script: ScriptBackupData) {
const { name } = script.options!.meta;
// 写脚本文件
await (await this.fs.create(`${name}.user.js`)).write(script.code);
// 写入脚本options.json
await (
await this.fs.create(`${name}.options.json`)
).write(JSON.stringify(script.options));
// 写入脚本storage.json
// 不想兼容tm的导出规则了,直接写入storage.json
const storage = { ...script.storage };
Object.keys(storage.data).forEach((key: string) => {
storage.data[key] = toStorageValueStr(storage.data[key]);
});
await (
await this.fs.create(`${name}.storage.json`)
).write(JSON.stringify(storage));
// 写入脚本资源文件
await this.writeResource(name, script.resources, "resources");
await this.writeResource(name, script.requires, "requires");
await this.writeResource(name, script.requiresCss, "requires.css");
return Promise.resolve();
}
async writeResource(
name: string,
resources: ResourceBackup[],
type: "resources" | "requires" | "requires.css"
): Promise<void[]> {
const results: Promise<void>[] = resources.map(async (item) => {
// md5是tm的导出规则
const md5 = crypto.MD5(`${type}{val.meta.url}`).toString();
if (item.source) {
await (
await this.fs.create(`${name}.user.js-${md5}-${item.meta.name}`)
).write(item.source!);
} else {
await (
await this.fs.create(`${name}.user.js-${md5}-${item.meta.name}`)
).write(base64ToBlob(item.base64));
}
(
await this.fs.create(
`${name}.user.js-${md5}-${item.meta.name}.${type}.json`
)
).write(JSON.stringify(item.meta));
});
return Promise.all(results);
}
async writeSubscribe(subscribe: SubscribeBackupData) {
const { name } = subscribe.options!.meta;
// 写入订阅文件
await (await this.fs.create(`${name}.user.sub.js`)).write(subscribe.source);
// 写入订阅options.json
await (
await this.fs.create(`${name}.user.sub.options.json`)
).write(JSON.stringify(subscribe.options));
return Promise.resolve();
}
}

238
src/pkg/backup/import.ts Normal file
View File

@ -0,0 +1,238 @@
import LoggerCore from "@App/app/logger/core";
import Logger from "@App/app/logger/logger";
import FileSystem, { File } from "@Pkg/filesystem/filesystem";
import { isText } from "../utils/istextorbinary";
import { blobToBase64 } from "../utils/script";
import { parseStorageValue } from "../utils/utils";
import {
BackupData,
ResourceBackup,
ResourceMeta,
ScriptBackupData,
ScriptOptionsFile,
SubscribeBackupData,
SubscribeOptionsFile,
ValueStorage,
} from "./struct";
import JSZip from "jszip";
import ZipFileSystem from "@Packages/filesystem/zip/zip";
type ViolentmonkeyFile = {
scripts: {
[key: string]: {
config: {
enabled: boolean;
};
};
};
};
// 备份导入工具
export default class BackupImport {
fs: FileSystem;
logger: Logger;
constructor(fileSystem: FileSystem) {
this.fs = fileSystem;
this.logger = LoggerCore.logger({ component: "backupImport" });
}
// 解析出备份数据
async parse(): Promise<BackupData> {
const map = new Map<string, ScriptBackupData>();
const subscribe = new Map<string, SubscribeBackupData>();
let files = await this.fs.list();
// 处理订阅
files = await this.dealFile(files, async (file) => {
const { name } = file;
if (!name.endsWith(".user.sub.js")) {
return Promise.resolve(false);
}
const key = name.substring(0, name.length - 12);
const subData = {
source: await (await this.fs.open(file)).read(),
} as SubscribeBackupData;
subscribe.set(key, subData);
return Promise.resolve(true);
});
// 处理订阅options
files = await this.dealFile(files, async (file) => {
const { name } = file;
if (!name.endsWith(".user.sub.options.json")) {
return Promise.resolve(false);
}
const key = name.substring(0, name.length - 22);
const data = <SubscribeOptionsFile>JSON.parse(await (await this.fs.open(file)).read());
subscribe.get(key)!.options = data;
return Promise.resolve(true);
});
// 先处理*.user.js文件
files = await this.dealFile(files, async (file) => {
const { name } = file;
if (!name.endsWith(".user.js")) {
return Promise.resolve(false);
}
// 遍历与脚本同名的文件
const key = name.substring(0, name.length - 8);
const backupData = {
code: await (await this.fs.open(file)).read(),
storage: { data: {}, ts: 0 },
requires: [],
requiresCss: [],
resources: [],
} as ScriptBackupData;
map.set(key, backupData);
return Promise.resolve(true);
});
// 处理options.json文件
files = await this.dealFile(files, async (file) => {
const { name } = file;
if (!name.endsWith(".options.json")) {
return Promise.resolve(false);
}
const key = name.substring(0, name.length - 13);
const data = <ScriptOptionsFile>JSON.parse(await (await this.fs.open(file)).read());
map.get(key)!.options = data;
return Promise.resolve(true);
});
// 处理storage.json文件
files = await this.dealFile(files, async (file) => {
const { name } = file;
if (!name.endsWith(".storage.json")) {
return Promise.resolve(false);
}
const key = name.substring(0, name.length - 13);
const data = <ValueStorage>JSON.parse(await (await this.fs.open(file)).read());
Object.keys(data.data).forEach((dataKey) => {
data.data[dataKey] = parseStorageValue(data.data[dataKey]);
});
map.get(key)!.storage = data;
return Promise.resolve(true);
});
// 处理各种资源文件
// 将期望的资源文件名储存到map中, 以便后续处理
const resourceFilenameMap = new Map<
string,
{
index: number;
key: string;
type: "resources" | "requires" | "requiresCss";
}
>();
files = await this.dealFile(files, async (file) => {
const { name } = file;
const userJsIndex = name.indexOf(".user.js-");
if (userJsIndex === -1) {
return Promise.resolve(false);
}
const key = name.substring(0, userJsIndex);
let type: "resources" | "requires" | "requiresCss" | "" = "";
if (!name.endsWith(".resources.json")) {
if (!name.endsWith(".requires.json")) {
if (!name.endsWith(".requires.css.json")) {
return Promise.resolve(false);
}
type = "requiresCss";
resourceFilenameMap.set(name.substring(0, name.length - 18), {
index: map.get(key)!.requiresCss.length,
key,
type,
});
} else {
type = "requires";
resourceFilenameMap.set(name.substring(0, name.length - 14), {
index: map.get(key)!.requires.length,
key,
type,
});
}
} else {
type = "resources";
resourceFilenameMap.set(name.substring(0, name.length - 15), {
index: map.get(key)!.resources.length,
key,
type,
});
}
const data = <ResourceMeta>JSON.parse(await (await this.fs.open(file)).read());
map.get(key)![type].push({
meta: data,
} as never as ResourceBackup);
return Promise.resolve(true);
});
// 处理资源文件的内容
let violentmonkeyFile: File | undefined;
files = await this.dealFile(files, async (file) => {
if (file.name === "violentmonkey") {
violentmonkeyFile = file;
return Promise.resolve(true);
}
const info = resourceFilenameMap.get(file.name);
if (info === undefined) {
return Promise.resolve(false);
}
const resource = map.get(info.key)![info.type][info.index];
resource.base64 = await blobToBase64(await (await this.fs.open(file)).read("blob"));
if (resource.meta) {
// 存在meta
// 替换base64前缀
if (resource.meta.mimetype) {
resource.base64 = resource.base64.replace(/^data:.*?;base64,/, `data:${resource.meta.mimetype};base64,`);
}
if (isText(await (await this.fs.open(file)).read("blob"))) {
resource.source = await (await this.fs.open(file)).read();
}
}
return Promise.resolve(true);
});
files.length &&
this.logger.warn("unhandled files", {
num: files.length,
files: files.map((f) => f.name),
});
// 处理暴力猴导入资源
if (violentmonkeyFile) {
try {
const data = JSON.parse(await (await this.fs.open(violentmonkeyFile)).read("string")) as ViolentmonkeyFile;
// 设置开启状态
const keys = Object.keys(data.scripts);
keys.forEach((key) => {
const vioScript = data.scripts[key];
if (!vioScript.config.enabled) {
const script = map.get(key);
if (!script) {
return;
}
script.enabled = false;
}
});
} catch (e) {
this.logger.error("violentmonkey file parse error", Logger.E(e));
}
}
// 将map转化为数组
return Promise.resolve({
script: Array.from(map.values()),
subscribe: Array.from(subscribe.values()),
});
}
async dealFile(files: File[], handler: (file: File) => Promise<boolean>): Promise<File[]> {
const newFiles: File[] = [];
const results = await Promise.all(files.map(handler));
results.forEach((result, index) => {
if (!result) {
newFiles.push(files[index]);
}
});
return Promise.resolve(newFiles);
}
}

108
src/pkg/backup/struct.ts Normal file
View File

@ -0,0 +1,108 @@
/* eslint-disable camelcase */
export type ResourceMeta = {
name: string;
url: string;
ts: number;
mimetype?: string;
};
export type ResourceBackup = {
meta: ResourceMeta;
// text数据
source?: string;
// 二进制数据
base64: string;
};
export type ValueStorage = {
data: { [key: string]: any };
ts: number;
};
export type ScriptOptions = {
check_for_updates: boolean;
comment: string | null;
compat_foreach: boolean;
compat_metadata: boolean;
compat_prototypes: boolean;
compat_wrappedjsobject: boolean;
compatopts_for_requires: boolean;
noframes: boolean | null;
override: {
merge_connects: boolean;
merge_excludes: boolean;
merge_includes: boolean;
merge_matches: boolean;
orig_connects: Array<string>;
orig_excludes: Array<string>;
orig_includes: Array<string>;
orig_matches: Array<string>;
orig_noframes: boolean | null;
orig_run_at: string;
use_blockers: Array<string>;
use_connects: Array<string>;
use_excludes: Array<string>;
use_includes: Array<string>;
use_matches: Array<string>;
};
run_at: string | null;
};
export type ScriptMeta = {
name: string;
uuid: string; // 此uuid是对tm的兼容处理
sc_uuid: string; // 脚本猫uuid
modified: number;
file_url: string;
subscribe_url?: string;
};
export type ScriptOptionsFile = {
options: ScriptOptions;
settings: { enabled: boolean; position: number };
meta: ScriptMeta;
};
export type ScriptInfo = {
name: string;
code: string;
};
export type ScriptBackupData = {
code: string;
options?: ScriptOptionsFile;
storage: ValueStorage;
requires: ResourceBackup[];
requiresCss: ResourceBackup[];
resources: ResourceBackup[];
// 为了兼容暴力猴而设置的字段
enabled?: boolean;
};
export type SubscribeScript = {
uuid: string;
url: string;
};
export type SubscribeMeta = {
name: string;
modified: number;
url: string;
};
export type SubscribeOptionsFile = {
settings: { enabled: boolean };
scripts: { [key: string]: SubscribeScript };
meta: SubscribeMeta;
};
export type SubscribeBackupData = {
source: string;
options?: SubscribeOptionsFile;
};
export type BackupData = {
script: ScriptBackupData[];
subscribe: SubscribeBackupData[];
};

10
src/pkg/backup/utils.ts Normal file
View File

@ -0,0 +1,10 @@
import ZipFileSystem from "@Packages/filesystem/zip/zip";
import JSZip from "jszip";
import BackupImport from "./import";
// 解析备份文件
export function parseBackupZipFile(zip: JSZip) {
const fs = new ZipFileSystem(zip);
// 解析文件
return new BackupImport(fs).parse();
}