diff --git a/packages/filesystem/README.md b/packages/filesystem/README.md new file mode 100644 index 0000000..5152ad5 --- /dev/null +++ b/packages/filesystem/README.md @@ -0,0 +1,8 @@ +# 文件系统 + +用于同步和备份至云端 + +- zip +- webdav +- 百度网盘 +- onedrive diff --git a/packages/filesystem/auth.ts b/packages/filesystem/auth.ts new file mode 100644 index 0000000..945b96e --- /dev/null +++ b/packages/filesystem/auth.ts @@ -0,0 +1,114 @@ +/* eslint-disable camelcase */ +/* eslint-disable import/prefer-default-export */ +import { ExtServer } from "@App/app/const"; +import { api } from "@App/pkg/axios"; +import { WarpTokenError } from "./error"; + +type NetDiskType = "baidu" | "onedrive"; + +export function GetNetDiskToken(netDiskType: NetDiskType): Promise<{ + code: number; + msg: string; + data: { token: { access_token: string; refresh_token: string } }; +}> { + return api + .get(`/auth/net-disk/token?netDiskType=${netDiskType}`) + .then((resp) => { + return resp.data; + }); +} + +export function RefreshToken( + netDiskType: NetDiskType, + refreshToken: string +): Promise<{ + code: number; + msg: string; + data: { token: { access_token: string; refresh_token: string } }; +}> { + return api + .post(`/auth/net-disk/token/refresh?netDiskType=${netDiskType}`, { + netDiskType, + refreshToken, + }) + .then((resp) => { + return resp.data; + }); +} + +export function NetDisk(netDiskType: NetDiskType) { + return new Promise((resolve) => { + const loginWindow = window.open( + `${ExtServer}api/v1/auth/net-disk?netDiskType=${netDiskType}` + ); + const t = setInterval(() => { + try { + if (loginWindow!.closed) { + clearInterval(t); + resolve(); + } + } catch (e) { + clearInterval(t); + resolve(); + } + }, 1000); + }); +} + +export type Token = { + accessToken: string; + refreshToken: string; + createtime: number; +}; + +export async function AuthVerify(netDiskType: NetDiskType, invalid?: boolean) { + let token: Token | undefined; + try { + token = JSON.parse(localStorage[`netdisk:token:${netDiskType}`]); + } catch (e) { + // ignore + } + // token不存在,或者没有accessToken,重新获取 + if (!token || !token.accessToken) { + // 强制重新获取token + await NetDisk(netDiskType); + const resp = await GetNetDiskToken(netDiskType); + if (resp.code !== 0) { + return Promise.reject(new WarpTokenError(new Error(resp.msg))); + } + token = { + accessToken: resp.data.token.access_token, + refreshToken: resp.data.token.refresh_token, + createtime: Date.now(), + }; + invalid = false; + localStorage[`netdisk:token:${netDiskType}`] = JSON.stringify(token); + } + // token过期或者失效 + if (Date.now() >= token.createtime + 3600000 || invalid) { + // 大于一小时刷新token + try { + const resp = await RefreshToken(netDiskType, token.refreshToken); + if (resp.code !== 0) { + localStorage.removeItem(`netdisk:token:${netDiskType}`); + // 刷新失败,并且标记失效,尝试重新获取token + if (invalid) { + return AuthVerify(netDiskType); + } + return Promise.reject(new WarpTokenError(new Error(resp.msg))); + } + token = { + accessToken: resp.data.token.access_token, + refreshToken: resp.data.token.refresh_token, + createtime: Date.now(), + }; + localStorage[`netdisk:token:${netDiskType}`] = JSON.stringify(token); + } catch (e) { + // 报错返回原token + return Promise.resolve(token.accessToken); + } + } else { + return Promise.resolve(token.accessToken); + } + return Promise.resolve(token.accessToken); +} diff --git a/packages/filesystem/baidu/baidu.ts b/packages/filesystem/baidu/baidu.ts new file mode 100644 index 0000000..e8a5d28 --- /dev/null +++ b/packages/filesystem/baidu/baidu.ts @@ -0,0 +1,154 @@ +/* eslint-disable no-unused-vars */ +import IoC from "@App/app/ioc"; +import { SystemConfig } from "@App/pkg/config/config"; +import { AuthVerify } from "../auth"; +import FileSystem, { File, FileReader, FileWriter } from "../filesystem"; +import { joinPath } from "../utils"; +import { BaiduFileReader, BaiduFileWriter } from "./rw"; + +export default class BaiduFileSystem implements FileSystem { + accessToken?: string; + + path: string; + + systemConfig: SystemConfig; + + constructor(path?: string, accessToken?: string) { + this.path = path || "/apps"; + this.accessToken = accessToken; + this.systemConfig = IoC.instance(SystemConfig) as SystemConfig; + } + + async verify(): Promise { + const token = await AuthVerify("baidu"); + this.accessToken = token; + return this.list().then(); + } + + open(file: File): Promise { + // 获取fsid + return Promise.resolve(new BaiduFileReader(this, file)); + } + + openDir(path: string): Promise { + return Promise.resolve( + new BaiduFileSystem(joinPath(this.path, path), this.accessToken) + ); + } + + create(path: string): Promise { + return Promise.resolve( + new BaiduFileWriter(this, joinPath(this.path, path)) + ); + } + + createDir(dir: string): Promise { + dir = joinPath(this.path, dir); + const urlencoded = new URLSearchParams(); + urlencoded.append("path", dir); + urlencoded.append("size", "0"); + urlencoded.append("isdir", "1"); + urlencoded.append("rtype", "3"); + const myHeaders = new Headers(); + myHeaders.append("Content-Type", "application/x-www-form-urlencoded"); + return this.request( + `https://pan.baidu.com/rest/2.0/xpan/file?method=create&access_token=${this.accessToken}`, + { + method: "POST", + headers: myHeaders, + body: urlencoded, + redirect: "follow", + } + ).then((data) => { + if (data.errno) { + throw new Error(JSON.stringify(data)); + } + return Promise.resolve(); + }); + } + + // eslint-disable-next-line no-undef + request(url: string, config?: RequestInit) { + config = config || {}; + const headers = config.headers || new Headers(); + // 利用GM函数的匿名实现不发送cookie,因为某些情况cookie会导致-6错误 + headers.append(`${this.systemConfig.scriptCatFlag}-gm-xhr`, "true"); + headers.append(`${this.systemConfig.scriptCatFlag}-anonymous`, "true"); + config.headers = headers; + return fetch(url, config) + .then((data) => data.json()) + .then(async (data) => { + if (data.errno === 111 || data.errno === -6) { + const token = await AuthVerify("baidu", true); + this.accessToken = token; + url = url.replace(/access_token=[^&]+/, `access_token=${token}`); + return fetch(url, config) + .then((data2) => data2.json()) + .then((data2) => { + if (data2.errno === 111 || data2.errno === -6) { + throw new Error(JSON.stringify(data2)); + } + return data2; + }); + } + return data; + }); + } + + delete(path: string): Promise { + const filelist = [joinPath(this.path, path)]; + const myHeaders = new Headers(); + myHeaders.append("Content-Type", "application/x-www-form-urlencoded"); + return this.request( + `https://pan.baidu.com/rest/2.0/xpan/file?method=filemanager&access_token=${this.accessToken}&opera=delete`, + { + method: "POST", + body: `async=0&filelist=${encodeURIComponent( + JSON.stringify(filelist) + )}`, + headers: myHeaders, + } + ).then((data) => { + if (data.errno) { + throw new Error(JSON.stringify(data)); + } + return data; + }); + } + + list(): Promise { + return this.request( + `https://pan.baidu.com/rest/2.0/xpan/file?method=list&dir=${encodeURIComponent( + this.path + )}&order=time&access_token=${this.accessToken}` + ).then((data) => { + if (data.errno) { + if (data.errno === -9) { + return []; + } + throw new Error(JSON.stringify(data)); + } + const list: File[] = []; + data.list.forEach((val: any) => { + list.push({ + fsid: val.fs_id, + name: val.server_filename, + path: this.path, + size: val.size, + digest: val.md5, + createtime: val.server_ctime * 1000, + updatetime: val.server_mtime * 1000, + }); + }); + return list; + }); + } + + getDirUrl(): Promise { + return Promise.resolve( + `https://pan.baidu.com/disk/main#/index?category=all&path=${encodeURIComponent( + this.path + )}` + ); + } +} diff --git a/packages/filesystem/baidu/rw.ts b/packages/filesystem/baidu/rw.ts new file mode 100644 index 0000000..88004e6 --- /dev/null +++ b/packages/filesystem/baidu/rw.ts @@ -0,0 +1,144 @@ +/* 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"; +import BaiduFileSystem from "./baidu"; + +export class BaiduFileReader implements FileReader { + file: File; + + fs: BaiduFileSystem; + + constructor(fs: BaiduFileSystem, file: File) { + this.fs = fs; + this.file = file; + } + + async read(type?: "string" | "blob"): Promise { + // 查询文件信息获取dlink + const data = await this.fs.request( + `https://pan.baidu.com/rest/2.0/xpan/multimedia?method=filemetas&access_token=${ + this.fs.accessToken + }&fsids=[${this.file.fsid!}]&dlink=1` + ); + if (!data.list.length) { + return Promise.reject(new Error("file not found")); + } + switch (type) { + case "string": + return fetch( + `${data.list[0].dlink}&access_token=${this.fs.accessToken}` + ).then((resp) => resp.text()); + default: { + return fetch( + `${data.list[0].dlink}&access_token=${this.fs.accessToken}` + ).then((resp) => resp.blob()); + } + } + } +} + +export class BaiduFileWriter implements FileWriter { + path: string; + + fs: BaiduFileSystem; + + constructor(fs: BaiduFileSystem, path: string) { + this.fs = fs; + this.path = path; + } + + size(content: string | Blob) { + if (content instanceof Blob) { + return content.size; + } + return new Blob([content]).size; + } + + async md5(content: string | Blob) { + if (content instanceof Blob) { + return calculateMd5(content); + } + return MD5(content).toString(); + } + + async write(content: string | Blob): Promise { + // 预上传获取id + const size = this.size(content).toString(); + const md5 = await this.md5(content); + const blockList: string[] = [md5]; + let urlencoded = new URLSearchParams(); + urlencoded.append("path", this.path); + urlencoded.append("size", size); + urlencoded.append("isdir", "0"); + urlencoded.append("autoinit", "1"); + urlencoded.append("rtype", "3"); + urlencoded.append("block_list", JSON.stringify(blockList)); + const myHeaders = new Headers(); + myHeaders.append("Content-Type", "application/x-www-form-urlencoded"); + const uploadid = await this.fs + .request( + `http://pan.baidu.com/rest/2.0/xpan/file?method=precreate&access_token=${this.fs.accessToken}`, + { + method: "POST", + headers: myHeaders, + body: urlencoded, + } + ) + .then((data) => { + if (data.errno) { + throw new Error(JSON.stringify(data)); + } + return data.uploadid; + }); + const body = new FormData(); + if (content instanceof Blob) { + // 分片上传 + body.append("file", content); + } else { + body.append("file", new Blob([content])); + } + + await this.fs + .request( + `${ + `https://d.pcs.baidu.com/rest/2.0/pcs/superfile2?method=upload&access_token=${this.fs.accessToken}` + + `&type=tmpfile&path=` + }${encodeURIComponent(this.path)}&uploadid=${uploadid}&partseq=0`, + { + method: "POST", + body, + } + ) + .then((data) => { + if (data.errno) { + throw new Error(JSON.stringify(data)); + } + return data; + }); + // 创建文件 + urlencoded = new URLSearchParams(); + urlencoded.append("path", this.path); + urlencoded.append("size", size); + urlencoded.append("isdir", "0"); + urlencoded.append("block_list", JSON.stringify(blockList)); + urlencoded.append("uploadid", uploadid); + urlencoded.append("rtype", "3"); + return this.fs + .request( + `https://pan.baidu.com/rest/2.0/xpan/file?method=create&access_token=${this.fs.accessToken}`, + { + method: "POST", + headers: myHeaders, + body: urlencoded, + } + ) + .then((data) => { + if (data.errno) { + throw new Error(JSON.stringify(data)); + } + return Promise.resolve(); + }); + } +} diff --git a/packages/filesystem/error.ts b/packages/filesystem/error.ts new file mode 100644 index 0000000..5cf23ae --- /dev/null +++ b/packages/filesystem/error.ts @@ -0,0 +1,24 @@ +// eslint-disable-next-line import/prefer-default-export, max-classes-per-file +export class WarpTokenError { + error: Error; + + constructor(error: Error) { + this.error = error; + } +} + +export function isWarpTokenError(error: any): error is WarpTokenError { + return error instanceof WarpTokenError; +} + +export class WarpNetworkError { + error: Error; + + constructor(error: Error) { + this.error = error; + } +} + +export function isNetworkError(error: any): error is WarpNetworkError { + return error instanceof WarpNetworkError; +} diff --git a/packages/filesystem/factory.ts b/packages/filesystem/factory.ts new file mode 100644 index 0000000..14eda4e --- /dev/null +++ b/packages/filesystem/factory.ts @@ -0,0 +1,84 @@ +import i18next from "i18next"; +import BaiduFileSystem from "./baidu/baidu"; +import FileSystem from "./filesystem"; +import OneDriveFileSystem from "./onedrive/onedrive"; +import WebDAVFileSystem from "./webdav/webdav"; +import ZipFileSystem from "./zip/zip"; + +export type FileSystemType = "zip" | "webdav" | "baidu-netdsik" | "onedrive"; + +export type FileSystemParams = { + [key: string]: { + title: string; + type?: "select" | "authorize" | "password"; + options?: string[]; + }; +}; + +export default class FileSystemFactory { + static create(type: FileSystemType, params: any): Promise { + let fs: FileSystem; + switch (type) { + case "zip": + fs = new ZipFileSystem(params); + break; + case "webdav": + fs = new WebDAVFileSystem( + params.authType, + params.url, + params.username, + params.password + ); + break; + case "baidu-netdsik": + fs = new BaiduFileSystem(); + break; + case "onedrive": + fs = new OneDriveFileSystem(); + break; + default: + throw new Error("not found filesystem"); + } + return fs.verify().then(() => fs); + } + + static params(): { [key: string]: FileSystemParams } { + return { + webdav: { + authType: { + title: i18next.t("auth_type"), + type: "select", + options: ["password", "digest", "none", "token"], + }, + url: { title: i18next.t("url") }, + username: { title: i18next.t("username") }, + password: { title: i18next.t("password"), type: "password" }, + }, + "baidu-netdsik": {}, + onedrive: {}, + }; + } + + static async mkdirAll(fs: FileSystem, path: string) { + return new Promise((resolve, reject) => { + const dirs = path.split("/"); + let i = 0; + const mkdir = () => { + if (i >= dirs.length) { + resolve(); + return; + } + const dir = dirs.slice(0, i + 1).join("/"); + fs.createDir(dir) + .then(() => { + i += 1; + mkdir(); + }) + .catch(() => { + reject(); + }); + }; + mkdir(); + }); + } +} diff --git a/packages/filesystem/filesystem.ts b/packages/filesystem/filesystem.ts new file mode 100644 index 0000000..0ee1847 --- /dev/null +++ b/packages/filesystem/filesystem.ts @@ -0,0 +1,48 @@ +export interface File { + fsid?: number; + // 文件名 + name: string; + // 文件路径 + path: string; + // 文件大小 + size: number; + // 文件摘要 + digest: string; + // 文件创建时间 + createtime: number; + // 文件修改时间 + updatetime: number; +} + +type ReadType = "string" | "blob"; +export interface FileReader { + // 读取文件内容 + read(type?: ReadType): Promise; +} + +export interface FileWriter { + // 写入文件内容 + write(content: string | Blob): Promise; +} + +export type FileReadWriter = FileReader & FileWriter; + +// 文件读取 +export default interface FileSystem { + // 授权验证 + verify(): Promise; + // 打开文件 + open(file: File): Promise; + // 打开目录 + openDir(path: string): Promise; + // 创建文件 + create(path: string): Promise; + // 创建目录 + createDir(dir: string): Promise; + // 删除文件 + delete(path: string): Promise; + // 文件列表 + list(): Promise; + // getDirUrl 获取目录的url + getDirUrl(): Promise; +} diff --git a/packages/filesystem/onedrive/onedrive.ts b/packages/filesystem/onedrive/onedrive.ts new file mode 100644 index 0000000..c48b1c6 --- /dev/null +++ b/packages/filesystem/onedrive/onedrive.ts @@ -0,0 +1,168 @@ +/* eslint-disable no-unused-vars */ +import IoC from "@App/app/ioc"; +import { SystemConfig } from "@App/pkg/config/config"; +import { AuthVerify } from "../auth"; +import FileSystem, { File, FileReader, FileWriter } from "../filesystem"; +import { joinPath } from "../utils"; +import { OneDriveFileReader, OneDriveFileWriter } from "./rw"; + +export default class OneDriveFileSystem implements FileSystem { + accessToken?: string; + + path: string; + + systemConfig: SystemConfig; + + constructor(path?: string, accessToken?: string) { + this.path = path || "/"; + this.accessToken = accessToken; + this.systemConfig = IoC.instance(SystemConfig) as SystemConfig; + } + + async verify(): Promise { + const token = await AuthVerify("onedrive"); + this.accessToken = token; + return this.list().then(); + } + + open(file: File): Promise { + return Promise.resolve(new OneDriveFileReader(this, file)); + } + + openDir(path: string): Promise { + if (path.startsWith("ScriptCat")) { + path = path.substring(9); + } + return Promise.resolve( + new OneDriveFileSystem(joinPath(this.path, path), this.accessToken) + ); + } + + create(path: string): Promise { + return Promise.resolve( + new OneDriveFileWriter(this, joinPath(this.path, path)) + ); + } + + createDir(dir: string): Promise { + if (dir && dir.startsWith("ScriptCat")) { + dir = dir.substring(9); + if (dir.startsWith("/")) { + dir = dir.substring(1); + } + } + if (!dir) { + return Promise.resolve(); + } + dir = joinPath(this.path, dir); + const dirs = dir.split("/"); + let parent = ""; + if (dirs.length > 2) { + parent = dirs.slice(0, dirs.length - 1).join("/"); + } + const myHeaders = new Headers(); + myHeaders.append("Content-Type", "application/json"); + if (parent !== "") { + parent = `:${parent}:`; + } + return this.request( + `https://graph.microsoft.com/v1.0/me/drive/special/approot${parent}/children`, + { + method: "POST", + headers: myHeaders, + body: JSON.stringify({ + name: dirs[dirs.length - 1], + folder: {}, + "@microsoft.graph.conflictBehavior": "replace", + }), + } + ).then((data: any) => { + if (data.errno) { + throw new Error(JSON.stringify(data)); + } + return Promise.resolve(); + }); + } + + // eslint-disable-next-line no-undef + request(url: string, config?: RequestInit, nothen?: boolean) { + config = config || {}; + const headers = config.headers || new Headers(); + if (url.indexOf("uploadSession") === -1) { + headers.append(`Authorization`, `Bearer ${this.accessToken}`); + } + config.headers = headers; + const ret = fetch(url, config); + if (nothen) { + return >ret; + } + return ret + .then((data) => data.json()) + .then(async (data) => { + if (data.error) { + if (data.error.code === "InvalidAuthenticationToken") { + const token = await AuthVerify("onedrive", true); + this.accessToken = token; + headers.set(`Authorization`, `Bearer ${this.accessToken}`); + return fetch(url, config) + .then((retryData) => retryData.json()) + .then((retryData) => { + if (retryData.error) { + throw new Error(JSON.stringify(retryData)); + } + return data; + }); + } + throw new Error(JSON.stringify(data)); + } + return data; + }); + } + + delete(path: string): Promise { + return this.request( + `https://graph.microsoft.com/v1.0/me/drive/special/approot:${joinPath( + this.path, + path + )}`, + { + method: "DELETE", + }, + true + ).then(async (resp) => { + if (resp.status !== 204) { + throw new Error(await resp.text()); + } + return resp; + }); + } + + list(): Promise { + let { path } = this; + if (path === "/") { + path = ""; + } else { + path = `:${path}:`; + } + return this.request( + `https://graph.microsoft.com/v1.0/me/drive/special/approot${path}/children` + ).then((data) => { + const list: File[] = []; + data.value.forEach((val: any) => { + list.push({ + name: val.name, + path: this.path, + size: val.size, + digest: val.eTag, + createtime: new Date(val.createdDateTime).getTime(), + updatetime: new Date(val.lastModifiedDateTime).getTime(), + }); + }); + return list; + }); + } + + getDirUrl(): Promise { + throw new Error("Method not implemented."); + } +} diff --git a/packages/filesystem/onedrive/rw.ts b/packages/filesystem/onedrive/rw.ts new file mode 100644 index 0000000..4f2029f --- /dev/null +++ b/packages/filesystem/onedrive/rw.ts @@ -0,0 +1,105 @@ +/* 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"; +import { joinPath } from "../utils"; +import OneDriveFileSystem from "./onedrive"; + +export class OneDriveFileReader implements FileReader { + file: File; + + fs: OneDriveFileSystem; + + constructor(fs: OneDriveFileSystem, file: File) { + this.fs = fs; + this.file = file; + } + + async read(type?: "string" | "blob"): Promise { + const data = await this.fs.request( + `https://graph.microsoft.com/v1.0/me/drive/special/approot:${joinPath( + this.file.path, + this.file.name + )}:/content`, + {}, + true + ); + if (data.status !== 200) { + return Promise.reject(await data.text()); + } + switch (type) { + case "string": + return data.text(); + default: { + return data.blob(); + } + } + } +} + +export class OneDriveFileWriter implements FileWriter { + path: string; + + fs: OneDriveFileSystem; + + constructor(fs: OneDriveFileSystem, path: string) { + this.fs = fs; + this.path = path; + } + + size(content: string | Blob) { + if (content instanceof Blob) { + return content.size; + } + return new Blob([content]).size; + } + + async md5(content: string | Blob) { + if (content instanceof Blob) { + return calculateMd5(content); + } + return MD5(content).toString(); + } + + async write(content: string | Blob): Promise { + // 预上传获取id + const size = this.size(content).toString(); + let myHeaders = new Headers(); + myHeaders.append("Content-Type", "application/json"); + const uploadUrl = await this.fs + .request( + `https://graph.microsoft.com/v1.0/me/drive/special/approot:${this.path}:/createUploadSession`, + { + method: "POST", + headers: myHeaders, + body: JSON.stringify({ + item: { + "@microsoft.graph.conflictBehavior": "replace", + // description: "description", + // fileSystemInfo: { + // "@odata.type": "microsoft.graph.fileSystemInfo", + // }, + // name: this.path.substring(this.path.lastIndexOf("/") + 1), + }, + }), + } + ) + .then((data) => { + if (data.error) { + throw new Error(JSON.stringify(data)); + } + return data.uploadUrl; + }); + myHeaders = new Headers(); + myHeaders.append( + "Content-Range", + `bytes 0-${parseInt(size, 10) - 1}/${size}` + ); + return this.fs.request(uploadUrl, { + method: "PUT", + body: content, + headers: myHeaders, + }); + } +} diff --git a/packages/filesystem/utils.ts b/packages/filesystem/utils.ts new file mode 100644 index 0000000..f65b475 --- /dev/null +++ b/packages/filesystem/utils.ts @@ -0,0 +1,18 @@ +/* eslint-disable import/prefer-default-export */ + +export function joinPath(...paths: string[]): string { + let path = ""; + paths.forEach((value) => { + if (!value) { + return; + } + if (!value.startsWith("/")) { + value = `/${value}`; + } + if (value.endsWith("/")) { + value = value.substring(0, value.length - 1); + } + path += value; + }); + return path; +} diff --git a/packages/filesystem/webdav/rw.ts b/packages/filesystem/webdav/rw.ts new file mode 100644 index 0000000..2489b1f --- /dev/null +++ b/packages/filesystem/webdav/rw.ts @@ -0,0 +1,57 @@ +/* eslint-disable max-classes-per-file */ +/* eslint-disable import/prefer-default-export */ +import { WebDAVClient } from "webdav/web"; +import { FileReader, FileWriter } from "../filesystem"; + +export class WebDAVFileReader implements FileReader { + client: WebDAVClient; + + path: string; + + constructor(client: WebDAVClient, path: string) { + this.client = client; + this.path = path; + } + + async read(type?: "string" | "blob"): Promise { + switch (type) { + case "string": + return this.client.getFileContents(this.path, { + format: "text", + }) as Promise; + default: { + const resp = (await this.client.getFileContents(this.path, { + format: "binary", + })) as ArrayBuffer; + return Promise.resolve(new Blob([resp])); + } + } + } +} + +export class WebDAVFileWriter implements FileWriter { + client: WebDAVClient; + + path: string; + + constructor(client: WebDAVClient, path: string) { + this.client = client; + this.path = path; + } + + async write(content: string | Blob): Promise { + let resp; + if (content instanceof Blob) { + resp = await this.client.putFileContents( + this.path, + await content.arrayBuffer() + ); + } else { + resp = await this.client.putFileContents(this.path, content); + } + if (resp) { + return Promise.resolve(); + } + return Promise.reject(new Error("write error")); + } +} diff --git a/packages/filesystem/webdav/webdav.ts b/packages/filesystem/webdav/webdav.ts new file mode 100644 index 0000000..e61012a --- /dev/null +++ b/packages/filesystem/webdav/webdav.ts @@ -0,0 +1,106 @@ +import { AuthType, createClient, FileStat, WebDAVClient } from "webdav/web"; +import FileSystem, { File, FileReader, FileWriter } from "../filesystem"; +import { joinPath } from "../utils"; +import { WebDAVFileReader, WebDAVFileWriter } from "./rw"; +import { WarpTokenError } from "../error"; + +export default class WebDAVFileSystem implements FileSystem { + client: WebDAVClient; + + url: string; + + basePath: string = "/"; + + constructor( + authType: AuthType | WebDAVClient, + url?: string, + username?: string, + password?: string + ) { + if (typeof authType === "object") { + this.client = authType; + this.basePath = joinPath(url || ""); + this.url = username!; + } else { + this.url = url!; + this.client = createClient(url!, { + authType, + username, + password, + }); + } + } + + async verify(): Promise { + try { + await this.client.getQuota(); + } catch (e: any) { + if (e.response && e.response.status === 401) { + throw new WarpTokenError(e); + } + throw new Error("verify failed"); + } + return Promise.resolve(); + } + + open(file: File): Promise { + return Promise.resolve( + new WebDAVFileReader(this.client, joinPath(file.path, file.name)) + ); + } + + openDir(path: string): Promise { + return Promise.resolve( + new WebDAVFileSystem(this.client, joinPath(this.basePath, path), this.url) + ); + } + + create(path: string): Promise { + return Promise.resolve( + new WebDAVFileWriter(this.client, joinPath(this.basePath, path)) + ); + } + + async createDir(path: string): Promise { + try { + return Promise.resolve( + await this.client.createDirectory(joinPath(this.basePath, path)) + ); + } catch (e: any) { + // 如果是405错误,则忽略 + if (e.message.includes("405")) { + return Promise.resolve(); + } + return Promise.reject(e); + } + } + + async delete(path: string): Promise { + return this.client.deleteFile(joinPath(this.basePath, path)); + } + + async list(): Promise { + const dir = (await this.client.getDirectoryContents( + this.basePath + )) as FileStat[]; + const ret: File[] = []; + dir.forEach((item: FileStat) => { + if (item.type !== "file") { + return; + } + ret.push({ + name: item.basename, + path: this.basePath, + digest: item.etag || "", + size: item.size, + createtime: new Date(item.lastmod).getTime(), + updatetime: new Date(item.lastmod).getTime(), + }); + }); + return Promise.resolve(ret); + } + + getDirUrl(): Promise { + return Promise.resolve(this.url + this.basePath); + } +} diff --git a/packages/filesystem/zip/rw.ts b/packages/filesystem/zip/rw.ts new file mode 100644 index 0000000..a5096ab --- /dev/null +++ b/packages/filesystem/zip/rw.ts @@ -0,0 +1,32 @@ +/* eslint-disable max-classes-per-file */ +/* eslint-disable import/prefer-default-export */ +import JSZip, { JSZipObject } from "jszip"; +import { FileReader, FileWriter } from "../filesystem"; + +export class ZipFileReader implements FileReader { + zipObject: JSZipObject; + + constructor(zipObject: JSZipObject) { + this.zipObject = zipObject; + } + + read(type?: "string" | "blob"): Promise { + return this.zipObject.async(type || "string"); + } +} + +export class ZipFileWriter implements FileWriter { + zip: JSZip; + + path: string; + + constructor(zip: JSZip, path: string) { + this.zip = zip; + this.path = path; + } + + write(content: string): Promise { + this.zip.file(this.path, content); + return Promise.resolve(); + } +} diff --git a/packages/filesystem/zip/zip.ts b/packages/filesystem/zip/zip.ts new file mode 100644 index 0000000..47467b9 --- /dev/null +++ b/packages/filesystem/zip/zip.ts @@ -0,0 +1,68 @@ +import JSZip from "jszip"; +import FileSystem, { + File, + FileReader, + FileWriter, +} from "@Pkg/filesystem/filesystem"; +import { ZipFileReader, ZipFileWriter } from "./rw"; + +export default class ZipFileSystem implements FileSystem { + zip: JSZip; + + basePath: string; + + // zip为空时,创建一个空的zip + constructor(zip?: JSZip, basePath?: string) { + this.zip = zip || new JSZip(); + this.basePath = basePath || ""; + } + + verify(): Promise { + return Promise.resolve(); + } + + open(info: File): Promise { + const path = info.name; + const file = this.zip.file(path); + if (file) { + return Promise.resolve(new ZipFileReader(file)); + } + return Promise.reject(new Error("File not found")); + } + + openDir(path: string): Promise { + return Promise.resolve(new ZipFileSystem(this.zip, path)); + } + + create(path: string): Promise { + return Promise.resolve(new ZipFileWriter(this.zip, path)); + } + + createDir(): Promise { + return Promise.resolve(); + } + + delete(path: string): Promise { + this.zip.remove(path); + return Promise.resolve(); + } + + list(): Promise { + const files: File[] = []; + Object.keys(this.zip.files).forEach((key) => { + files.push({ + name: key, + path: key, + size: 0, + digest: "", + createtime: this.zip.files[key].date.getTime(), + updatetime: this.zip.files[key].date.getTime(), + }); + }); + return Promise.resolve(files); + } + + getDirUrl(): Promise { + throw new Error("Method not implemented."); + } +} diff --git a/src/pages/options/routes/Tools.tsx b/src/pages/options/routes/Tools.tsx index 594526b..92a3d71 100644 --- a/src/pages/options/routes/Tools.tsx +++ b/src/pages/options/routes/Tools.tsx @@ -17,12 +17,11 @@ import FileSystemParams from "@App/pages/components/FileSystemParams"; 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"; function Tools() { const [loading, setLoading] = useState<{ [key: string]: boolean }>({}); - const syncCtrl = IoC.instance(SynchronizeController) as SynchronizeController; const fileRef = useRef(null); - const systemConfig = IoC.instance(SystemConfig) as SystemConfig; const [fileSystemType, setFilesystemType] = useState( systemConfig.backup.filesystem ); diff --git a/src/pages/options/routes/script/ScriptEditor.tsx b/src/pages/options/routes/script/ScriptEditor.tsx index e22e7b5..1d55b0f 100644 --- a/src/pages/options/routes/script/ScriptEditor.tsx +++ b/src/pages/options/routes/script/ScriptEditor.tsx @@ -777,14 +777,13 @@ function ScriptEditor() { setEditors((prev) => { const i = parseInt(index, 10); if (prev[i].isChanged) { - // eslint-disable-next-line no-restricted-globals, no-alert if (!confirm("脚本已修改, 关闭后会丢失修改, 是否继续?")) { return prev; } } if (prev.length === 1) { - // 如果是id打开的回退到列表 - if (id) { + // 如果是uuid打开的回退到列表 + if (uuid) { navigate("/"); return prev; } @@ -801,7 +800,7 @@ function ScriptEditor() { setSelectSciptButtonAndTab(prev[i - 1].script.uuid); } else { prev[i + 1].active = true; - setSelectSciptButtonAndTab(prev[i - 1].script.uuid); + setSelectSciptButtonAndTab(prev[i + 1].script.uuid); } } prev.splice(i, 1); diff --git a/src/pages/store/features/setting.ts b/src/pages/store/features/setting.ts index 5c70497..606d870 100644 --- a/src/pages/store/features/setting.ts +++ b/src/pages/store/features/setting.ts @@ -2,6 +2,33 @@ import { createAppSlice } from "../hooks"; import { PayloadAction } from "@reduxjs/toolkit"; import { editor } from "monaco-editor"; +function setAutoMode() { + const darkTheme = window.matchMedia("(prefers-color-scheme: dark)"); + const isMatch = (match: boolean) => { + if (match) { + document.body.setAttribute("arco-theme", "dark"); + editor.setTheme("vs-dark"); + } else { + document.body.removeAttribute("arco-theme"); + editor.setTheme("vs"); + } + }; + darkTheme.addEventListener("change", (e) => { + isMatch(e.matches); + }); + isMatch(darkTheme.matches); +} + +export type SystemConfig = { + lightMode: "light" | "dark" | "auto"; + eslint: { + enable: boolean; + config: string; + }; + scriptListColumnWidth: { [key: string]: number }; + menuExpandNum: number; +}; + export const settingSlice = createAppSlice({ name: "setting", initialState: { @@ -12,27 +39,22 @@ export const settingSlice = createAppSlice({ }, scriptListColumnWidth: {} as { [key: string]: number }, menuExpandNum: 5, - }, + } as SystemConfig, reducers: (create) => { // 初始化黑夜模式 - const setAutoMode = () => { - const darkTheme = window.matchMedia("(prefers-color-scheme: dark)"); - const isMatch = (match: boolean) => { - if (match) { - document.body.setAttribute("arco-theme", "dark"); - editor.setTheme("vs-dark"); - } else { - document.body.removeAttribute("arco-theme"); - editor.setTheme("vs"); - } - }; - darkTheme.addEventListener("change", (e) => { - isMatch(e.matches); - }); - isMatch(darkTheme.matches); - }; setAutoMode(); + // 加载配置 + chrome.storage.sync.get("systemSetting", (result) => { + const systemSetting = result.systemSetting as SystemConfig; + settingSlice.actions.initSetting(systemSetting); + if (systemSetting) { + localStorage.lightMode = systemSetting.lightMode; + } + }); return { + initSetting: create.reducer((state, action: PayloadAction) => { + state.menuExpandNum = action.payload.menuExpandNum; + }), setDarkMode: create.reducer((state, action: PayloadAction<"light" | "dark" | "auto">) => { localStorage.loghtMode = action.payload; state.lightMode = action.payload; diff --git a/src/pkg/utils/monaco-editor.ts b/src/pkg/utils/monaco-editor.ts index 81f3669..1a0650f 100644 --- a/src/pkg/utils/monaco-editor.ts +++ b/src/pkg/utils/monaco-editor.ts @@ -4,8 +4,6 @@ import { languages } from "monaco-editor"; // 注册eslint // const linterWorker = new Worker("/src/linter.worker.js"); -console.log(dts, dts.length); - export default function registerEditor() { window.MonacoEnvironment = { getWorkerUrl(moduleId: any, label: any) {