diff --git a/packages/filesystem/auth.ts b/packages/filesystem/auth.ts index a90cde8d2..2af922425 100644 --- a/packages/filesystem/auth.ts +++ b/packages/filesystem/auth.ts @@ -2,7 +2,7 @@ import { ExtServer, ExtServerApi } from "@App/app/const"; import { WarpTokenError } from "./error"; import { LocalStorageDAO } from "@App/app/repo/localStorage"; -type NetDiskType = "baidu" | "onedrive"; +type NetDiskType = "baidu" | "onedrive" | "googledrive"; export function GetNetDiskToken(netDiskType: NetDiskType): Promise<{ code: number; diff --git a/packages/filesystem/factory.ts b/packages/filesystem/factory.ts index 71e7c01de..e11b5e52d 100644 --- a/packages/filesystem/factory.ts +++ b/packages/filesystem/factory.ts @@ -1,12 +1,13 @@ import i18next from "i18next"; import BaiduFileSystem from "./baidu/baidu"; import FileSystem from "./filesystem"; +import GoogleDriveFileSystem from "./googledrive/googledrive"; import OneDriveFileSystem from "./onedrive/onedrive"; import WebDAVFileSystem from "./webdav/webdav"; import ZipFileSystem from "./zip/zip"; import i18n from "@App/locales/locales"; -export type FileSystemType = "zip" | "webdav" | "baidu-netdsik" | "onedrive"; +export type FileSystemType = "zip" | "webdav" | "baidu-netdsik" | "onedrive" | "googledrive"; export type FileSystemParams = { [key: string]: { @@ -37,6 +38,9 @@ export default class FileSystemFactory { case "onedrive": fs = new OneDriveFileSystem(); break; + case "googledrive": + fs = new GoogleDriveFileSystem(); + break; default: throw new Error("not found filesystem"); } @@ -57,6 +61,7 @@ export default class FileSystemFactory { }, "baidu-netdsik": {}, onedrive: {}, + googledrive: {}, }; } diff --git a/packages/filesystem/googledrive/googledrive.ts b/packages/filesystem/googledrive/googledrive.ts new file mode 100644 index 000000000..cca725a1d --- /dev/null +++ b/packages/filesystem/googledrive/googledrive.ts @@ -0,0 +1,293 @@ +import { AuthVerify } from "../auth"; +import FileSystem, { File, FileReader, FileWriter } from "../filesystem"; +import { joinPath } from "../utils"; +import { GoogleDriveFileReader, GoogleDriveFileWriter } from "./rw"; + +export default class GoogleDriveFileSystem implements FileSystem { + accessToken?: string; + + path: string; + + // 缓存路径到文件ID的映射 + private pathToIdCache: Map = new Map(); + + constructor(path?: string, accessToken?: string) { + this.path = path || "/"; + this.accessToken = accessToken; + } + + async verify(): Promise { + const token = await AuthVerify("googledrive"); + this.accessToken = token; + return this.list().then(); + } + + open(file: File): Promise { + return Promise.resolve(new GoogleDriveFileReader(this, file)); + } + + openDir(path: string): Promise { + return Promise.resolve(new GoogleDriveFileSystem(joinPath(this.path, path), this.accessToken)); + } + + create(path: string): Promise { + return Promise.resolve(new GoogleDriveFileWriter(this, joinPath(this.path, path))); + } async createDir(dir: string): Promise { + if (!dir) { + return Promise.resolve(); + } + + const fullPath = joinPath(this.path, dir); + const dirs = fullPath.split("/").filter(Boolean); + + // 从根目录开始逐级创建目录 + let parentId = "root"; + let currentPath = ""; + + // 逐级创建目录,使用缓存减少重复请求 + for (const dirName of dirs) { + currentPath = joinPath(currentPath, dirName); + + // 先检查缓存 + let folderId = this.pathToIdCache.get(currentPath); + + if (!folderId) { + // 缓存中没有,查找目录是否已存在 + let folder = await this.findFolderByName(dirName, parentId); + if (!folder) { + // 不存在则创建 + folder = await this.createFolder(dirName, parentId); + } + folderId = folder.id; + + // 更新缓存 + this.pathToIdCache.set(currentPath, folderId); + } + + parentId = folderId; + } + + return Promise.resolve(); + } async findFolderByName(name: string, parentId: string): Promise<{ id: string; name: string } | null> { + const query = `name='${name}' and mimeType='application/vnd.google-apps.folder' and '${parentId}' in parents and trashed=false`; + const response = await this.request( + `https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id,name)` + ); + + if (response.files && response.files.length > 0) { + return response.files[0]; + } + return null; + } + + async createFolder(name: string, parentId: string): Promise<{ id: string; name: string }> { + const myHeaders = new Headers(); + myHeaders.append("Content-Type", "application/json"); + + const response = await this.request("https://www.googleapis.com/drive/v3/files", { + method: "POST", + headers: myHeaders, + body: JSON.stringify({ + name: name, + mimeType: "application/vnd.google-apps.folder", + parents: [parentId], + }), + }); + + if (response.error) { + throw new Error(JSON.stringify(response)); + } + + return { + id: response.id, + name: response.name, + }; + } + + request(url: string, config?: RequestInit, nothen?: boolean) { + config = config || {}; + const headers = config.headers || new Headers(); + 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 === 401) { + // Token可能过期,尝试刷新 + const token = await AuthVerify("googledrive", 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 retryData; + }); + } + throw new Error(JSON.stringify(data)); + } + return data; + }); + } async delete(path: string): Promise { + const fullPath = joinPath(this.path, path); + + // 首先,找到要删除的文件或文件夹 + const fileId = await this.getFileId(fullPath); + if (!fileId) { + throw new Error(`File or directory not found: ${path}`); + } + + // 删除文件或文件夹 + await this.request( + `https://www.googleapis.com/drive/v3/files/${fileId}`, + { + method: "DELETE", + }, + true + ).then(async (resp) => { + if (resp.status !== 204 && resp.status !== 200) { + throw new Error(await resp.text()); + } + }); + + // 清除相关缓存 + this.clearRelatedCache(fullPath); + }async getFileId(path: string): Promise { + if (path === "/" || path === "") { + return "root"; + } + + // 先检查缓存 + const cachedId = this.pathToIdCache.get(path); + if (cachedId) { + return cachedId; + } + + // 从根目录开始逐级查找 + const pathParts = path.split("/").filter(Boolean); + let parentId = "root"; + let currentPath = ""; + + // 逐级查找路径 + for (const part of pathParts) { + currentPath = joinPath(currentPath, part); + + // 检查这个路径是否已经缓存 + const cachedPartId = this.pathToIdCache.get(currentPath); + if (cachedPartId) { + parentId = cachedPartId; + continue; + } + + const query = `name='${part}' and '${parentId}' in parents and trashed=false`; + const response = await this.request( + `https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id,name)` + ); + + if (!response.files || response.files.length === 0) { + return null; + } + + parentId = response.files[0].id; + + // 缓存这个路径的ID + this.pathToIdCache.set(currentPath, parentId); + } + + return parentId; + } async list(): Promise { + let folderId = "root"; + + // 获取当前目录的ID + if (this.path !== "/") { + const foundId = await this.getFileId(this.path); + if (!foundId) { + throw new Error(`Directory not found: ${this.path}`); + } + folderId = foundId; + } + + // 列出目录内容 + const query = `'${folderId}' in parents and trashed=false`; + const response = await this.request( + `https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id,name,mimeType,size,md5Checksum,createdTime,modifiedTime)` + ); + + const list: File[] = []; + if (response.files) { + for (const item of response.files) { + list.push({ + name: item.name, + path: this.path, + size: item.size ? parseInt(item.size, 10) : 0, + digest: item.md5Checksum || "", + createtime: new Date(item.createdTime).getTime(), + updatetime: new Date(item.modifiedTime).getTime(), + }); + } + } + + return list; + } + + // 辅助方法:在指定目录中查找文件 + async findFileInDirectory(fileName: string, parentId: string): Promise { + const query = `name='${fileName}' and '${parentId}' in parents and trashed=false and mimeType!='application/vnd.google-apps.folder'`; + const response = await this.request( + `https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id)` + ); + + if (response.files && response.files.length > 0) { + return response.files[0].id; + } + return null; + } + + // 清除相关缓存 + clearRelatedCache(path: string): void { + // 清除路径缓存 + const pathsToRemove = Array.from(this.pathToIdCache.keys()).filter(p => p.startsWith(path)); + pathsToRemove.forEach(p => this.pathToIdCache.delete(p)); + } + + async getDirUrl(): Promise { + // Retrieve the folder ID for the current path + const folderId = await this.getFileId(this.path); + if (!folderId) { + throw new Error(`Directory not found: ${this.path}`); + } + + // Construct and return the Google Drive folder URL + return `https://drive.google.com/drive/folders/${folderId}`; + } + + // 确保目录存在并返回目录ID,优化Writer避免重复获取 + async ensureDirExists(dirPath: string): Promise { + if (dirPath === "/" || dirPath === "") { + return "root"; + } + + // 先检查缓存 + const cachedId = this.pathToIdCache.get(dirPath); + if (cachedId) { + return cachedId; + } + + // 如果没有缓存,使用getFileId方法 + const foundId = await this.getFileId(dirPath); + if (!foundId) { + throw new Error(`Failed to create or find directory: ${dirPath}`); + } + + // 缓存结果 + this.pathToIdCache.set(dirPath, foundId); + return foundId; + } +} diff --git a/packages/filesystem/googledrive/rw.ts b/packages/filesystem/googledrive/rw.ts new file mode 100644 index 000000000..89a83ba4f --- /dev/null +++ b/packages/filesystem/googledrive/rw.ts @@ -0,0 +1,108 @@ +import { File, FileReader, FileWriter } from "../filesystem"; +import { joinPath } from "../utils"; +import GoogleDriveFileSystem from "./googledrive"; + +export class GoogleDriveFileReader implements FileReader { + file: File; + + fs: GoogleDriveFileSystem; + + constructor(fs: GoogleDriveFileSystem, file: File) { + this.fs = fs; + this.file = file; + } + + async read(type?: "string" | "blob"): Promise { + // 首先获取文件ID + const fileId = await this.fs.getFileId(joinPath(this.file.path, this.file.name)); + if (!fileId) { + return Promise.reject(new Error(`File not found: ${this.file.name}`)); + } + + // 获取文件内容 + const data = await this.fs.request(`https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`, {}, true); + + if (data.status !== 200) { + return Promise.reject(await data.text()); + } + + switch (type) { + case "string": + return data.text(); + default: { + return data.blob(); + } + } + } +} + +export class GoogleDriveFileWriter implements FileWriter { + path: string; + + fs: GoogleDriveFileSystem; + + constructor(fs: GoogleDriveFileSystem, path: string) { + this.fs = fs; + this.path = path; + } + + async write(content: string | Blob): Promise { + // 解析文件路径和文件名 + const pathParts = this.path.split("/").filter(Boolean); + const fileName = pathParts.pop() || ""; // 获取文件名 + const dirPath = "/" + pathParts.join("/"); // 重建目录路径 + + // 使用优化的方法确保目录存在并获取ID + const parentId = await this.fs.ensureDirExists(dirPath); + + // 使用优化的查找方法 + const existingFileId = await this.fs.findFileInDirectory(fileName, parentId); + + if (existingFileId) { + // 如果文件存在,则更新 + return this.updateFile(existingFileId, content); + } else { + // 如果文件不存在,则创建 + return this.createNewFile(fileName, parentId, content); + } + } + + private async updateFile(fileId: string, content: string | Blob): Promise { + // 不设置Content-Type,让浏览器自动处理multipart/form-data边界 + + const metadata = { + // 只更新内容,不更新元数据 + }; + + const formData = new FormData(); + formData.append("metadata", new Blob([JSON.stringify(metadata)], { type: "application/json" })); + formData.append("file", content instanceof Blob ? content : new Blob([content])); + + await this.fs.request(`https://www.googleapis.com/upload/drive/v3/files/${fileId}?uploadType=multipart`, { + method: "PATCH", + body: formData, + }); + + return Promise.resolve(); + } + + private async createNewFile(fileName: string, parentId: string, content: string | Blob): Promise { + // 不设置Content-Type,让浏览器自动处理multipart/form-data边界 + + const metadata = { + name: fileName, + parents: [parentId], + }; + + const formData = new FormData(); + formData.append("metadata", new Blob([JSON.stringify(metadata)], { type: "application/json" })); + formData.append("file", content instanceof Blob ? content : new Blob([content])); + + await this.fs.request(`https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart`, { + method: "POST", + body: formData, + }); + + return Promise.resolve(); + } +} diff --git a/src/pages/components/FileSystemParams/index.tsx b/src/pages/components/FileSystemParams/index.tsx index 308471d08..16c4049c9 100644 --- a/src/pages/components/FileSystemParams/index.tsx +++ b/src/pages/components/FileSystemParams/index.tsx @@ -37,6 +37,10 @@ const FileSystemParams: React.FC<{ key: "onedrive", name: "OneDrive", }, + { + key: "googledrive", + name: "Google Drive", + }, ]; return ( diff --git a/src/pages/options/routes/Tools.tsx b/src/pages/options/routes/Tools.tsx index 9071a7a6f..255cc12fa 100644 --- a/src/pages/options/routes/Tools.tsx +++ b/src/pages/options/routes/Tools.tsx @@ -162,9 +162,9 @@ function Tools() { list = list.filter((file) => file.name.endsWith(".zip")); if (list.length === 0) { Message.info(t("no_backup_files")!); - return; + } else { + setBackupFileList(list); } - setBackupFileList(list); } catch (e) { Message.error(`${t("get_backup_files_failed")}: ${e}`); }