From 0f3de55e9e8c2191de44caf2f17fca7950d24e34 Mon Sep 17 00:00:00 2001 From: silentrald Date: Fri, 6 Sep 2024 00:01:41 +0800 Subject: [PATCH] [feat-75] support drag and drop for map zip files --- assets/jsons/translations/de.json | 12 +++ assets/jsons/translations/en.json | 12 +++ assets/jsons/translations/es.json | 12 +++ assets/jsons/translations/fr.json | 12 +++ assets/jsons/translations/ja.json | 12 +++ assets/jsons/translations/ru.json | 12 +++ assets/jsons/translations/zh-tw.json | 12 +++ assets/jsons/translations/zh.json | 12 +++ src/main/helpers/zip.helpers.ts | 39 +++++++++- src/main/ipcs/bs-maps-ipcs.ts | 7 +- .../maps/local-maps-manager.service.ts | 53 +++++++++++++- .../maps-playlists-panel.component.tsx | 38 +++++++++- .../components/shared/dropzone.component.tsx | 73 +++++++++++++++++++ .../services/maps-downloader.service.ts | 2 +- src/renderer/services/maps-manager.service.ts | 21 ++++++ src/shared/models/ipc/ipc-routes.ts | 3 +- 16 files changed, 326 insertions(+), 6 deletions(-) create mode 100644 src/renderer/components/shared/dropzone.component.tsx diff --git a/assets/jsons/translations/de.json b/assets/jsons/translations/de.json index 42ba3b57..2e5afa25 100644 --- a/assets/jsons/translations/de.json +++ b/assets/jsons/translations/de.json @@ -505,6 +505,18 @@ "duplicates-maps-deleted": { "title": "Duplikate gelöscht", "msg": "Duplikate wurden gelöscht" + }, + "import-map": { + "titles": { + "success": "Map import complete", + "error": "An error occurred while importing the map" + }, + "msgs": { + "success": "Imported \"{mapName}\" map successfully", + "only-accept-zip": "Only zip files are supported", + "not-found-zip": "Zip file does not exists", + "invalid-zip": "Invalid zip file contents" + } } }, "playlists": { diff --git a/assets/jsons/translations/en.json b/assets/jsons/translations/en.json index 49cd7bf7..62cf79c3 100644 --- a/assets/jsons/translations/en.json +++ b/assets/jsons/translations/en.json @@ -512,6 +512,18 @@ "duplicates-maps-deleted": { "title": "Duplicates Deleted", "msg": "Duplicates were deleted" + }, + "import-map": { + "titles": { + "success": "Map import complete", + "error": "An error occurred while importing the map" + }, + "msgs": { + "success": "Imported \"{mapName}\" map successfully", + "only-accept-zip": "Only zip files are supported", + "not-found-zip": "Zip file does not exists", + "invalid-zip": "Invalid zip file contents" + } } }, "playlists": { diff --git a/assets/jsons/translations/es.json b/assets/jsons/translations/es.json index 77885485..81aadd83 100644 --- a/assets/jsons/translations/es.json +++ b/assets/jsons/translations/es.json @@ -505,6 +505,18 @@ "duplicates-maps-deleted": { "title": "Duplicados eliminados", "msg": "Se eliminaron los duplicados" + }, + "import-map": { + "titles": { + "success": "Map import complete", + "error": "An error occurred while importing the map" + }, + "msgs": { + "success": "Imported \"{mapName}\" map successfully", + "only-accept-zip": "Only zip files are supported", + "not-found-zip": "Zip file does not exists", + "invalid-zip": "Invalid zip file contents" + } } }, "playlists": { diff --git a/assets/jsons/translations/fr.json b/assets/jsons/translations/fr.json index 9142162d..a69d8945 100644 --- a/assets/jsons/translations/fr.json +++ b/assets/jsons/translations/fr.json @@ -505,6 +505,18 @@ "duplicates-maps-deleted": { "title": "Doublons supprimés", "msg": "Les doublons ont été supprimés" + }, + "import-map": { + "titles": { + "success": "Map import complete", + "error": "An error occurred while importing the map" + }, + "msgs": { + "success": "Imported \"{mapName}\" map successfully", + "only-accept-zip": "Only zip files are supported", + "not-found-zip": "Zip file does not exists", + "invalid-zip": "Invalid zip file contents" + } } }, "playlists": { diff --git a/assets/jsons/translations/ja.json b/assets/jsons/translations/ja.json index dd792379..af0cacad 100644 --- a/assets/jsons/translations/ja.json +++ b/assets/jsons/translations/ja.json @@ -505,6 +505,18 @@ "duplicates-maps-deleted": { "title": "重複削除", "msg": "重複が削除されました" + }, + "import-map": { + "titles": { + "success": "Map import complete", + "error": "An error occurred while importing the map" + }, + "msgs": { + "success": "Imported \"{mapName}\" map successfully", + "only-accept-zip": "Only zip files are supported", + "not-found-zip": "Zip file does not exists", + "invalid-zip": "Invalid zip file contents" + } } }, "playlists": { diff --git a/assets/jsons/translations/ru.json b/assets/jsons/translations/ru.json index c4187b72..5e29a651 100644 --- a/assets/jsons/translations/ru.json +++ b/assets/jsons/translations/ru.json @@ -505,6 +505,18 @@ "duplicates-maps-deleted": { "title": "Дубликаты удалены", "msg": "Дубликаты были удалены" + }, + "import-map": { + "titles": { + "success": "Map import complete", + "error": "An error occurred while importing the map" + }, + "msgs": { + "success": "Imported \"{mapName}\"map successfully", + "only-accept-zip": "Only zip files are supported", + "not-found-zip": "Zip file does not exists", + "invalid-zip": "Invalid zip file contents" + } } }, "playlists": { diff --git a/assets/jsons/translations/zh-tw.json b/assets/jsons/translations/zh-tw.json index edaace1e..741a5d73 100644 --- a/assets/jsons/translations/zh-tw.json +++ b/assets/jsons/translations/zh-tw.json @@ -505,6 +505,18 @@ "duplicates-maps-deleted": { "title": "重複已刪除", "msg": "重複已刪除" + }, + "import-map": { + "titles": { + "success": "Map import complete", + "error": "An error occurred while importing the map" + }, + "msgs": { + "success": "Imported \"{mapName}\" map successfully", + "only-accept-zip": "Only zip files are supported", + "not-found-zip": "Zip file does not exists", + "invalid-zip": "Invalid zip file contents" + } } }, "playlists": { diff --git a/assets/jsons/translations/zh.json b/assets/jsons/translations/zh.json index 3c9b8d07..52a36321 100644 --- a/assets/jsons/translations/zh.json +++ b/assets/jsons/translations/zh.json @@ -505,6 +505,18 @@ "duplicates-maps-deleted": { "title": "重复已删除", "msg": "重复已删除" + }, + "import-map": { + "titles": { + "success": "Map import complete", + "error": "An error occurred while importing the map" + }, + "msgs": { + "success": "Imported \"{mapName}\" map successfully", + "only-accept-zip": "Only zip files are supported", + "not-found-zip": "Zip file does not exists", + "invalid-zip": "Invalid zip file contents" + } } }, "playlists": { diff --git a/src/main/helpers/zip.helpers.ts b/src/main/helpers/zip.helpers.ts index f1587133..3a2604a0 100644 --- a/src/main/helpers/zip.helpers.ts +++ b/src/main/helpers/zip.helpers.ts @@ -1,7 +1,44 @@ import JSZip from "jszip"; import { pathExist } from "./fs.helpers"; import path from "path"; -import { mkdir, writeFile } from "fs/promises"; +import { mkdir, writeFile, readFile } from "fs/promises"; +import { pathExistsSync } from "fs-extra"; + +// JSZip config defaults for now to avoid zip bombs +const MAX_FILES = 1_000; +const MAX_SIZE = 1024 * 1024 * 100; // 100MB + + +export async function processZip( + // path to the zip or the JSZip object itself + zip: string | JSZip, + // Should return the number of bytes read + handleFile: (relativePath: string, file: JSZip.JSZipObject) => Promise | number +): Promise { + if (typeof zip === "string") { + if (!pathExistsSync(zip)) { + throw new Error(`Path ${zip} does not exists`); + } + + const data = await readFile(zip); + zip = await JSZip.loadAsync(data); + } + + let fileCount = 0; + let totalSize = 0; + + for (const [relativePath, file] of Object.entries(zip.files)) { + ++fileCount; + if (fileCount > MAX_FILES) { + throw new Error(`Reached maximum number of files on "${zip}"`); + } + + totalSize += await handleFile(relativePath, file); + if (totalSize > MAX_SIZE) { + throw new Error(`Reached maximum size on "${zip}"`); + } + } +} export async function extractZip(zip: JSZip, dest: string): Promise { if (!(await pathExist(dest))) { diff --git a/src/main/ipcs/bs-maps-ipcs.ts b/src/main/ipcs/bs-maps-ipcs.ts index 039a4465..46248b2b 100644 --- a/src/main/ipcs/bs-maps-ipcs.ts +++ b/src/main/ipcs/bs-maps-ipcs.ts @@ -22,7 +22,12 @@ ipc.on("export-maps", async (args, reply) => { reply(await maps.exportMaps(args.version, args.maps, args.outPath)); }); -ipc.on("download-map", async (args, reply) => { +ipc.on("bs-maps.import-map", async (args, reply) => { + const maps = LocalMapsManagerService.getInstance(); + reply(from(maps.importMap(args.path, args.version))); +}) + +ipc.on("bs-maps.download-map", async (args, reply) => { const maps = LocalMapsManagerService.getInstance(); reply(from(maps.downloadMap(args.map, args.version))); }); diff --git a/src/main/services/additional-content/maps/local-maps-manager.service.ts b/src/main/services/additional-content/maps/local-maps-manager.service.ts index d186f953..2e5595e5 100644 --- a/src/main/services/additional-content/maps/local-maps-manager.service.ts +++ b/src/main/services/additional-content/maps/local-maps-manager.service.ts @@ -7,7 +7,7 @@ import { InstallationLocationService } from "../../installation-location.service import { UtilsService } from "../../utils.service"; import crypto, { BinaryLike } from "crypto"; import { lstatSync } from "fs"; -import { copy, createReadStream, ensureDir, pathExists, pathExistsSync, realpath, unlink } from "fs-extra"; +import { copy, createReadStream, ensureDir, pathExists, pathExistsSync, realpath, unlink, writeFile } from "fs-extra"; import StreamZip from "node-stream-zip"; import { RequestService } from "../../request.service"; import sanitize from "sanitize-filename"; @@ -29,6 +29,8 @@ import { FieldRequired } from "shared/helpers/type.helpers"; import { MapInfo } from "shared/models/maps/info/map-info.model"; import { parseMapInfoDat } from "shared/parsers/maps/map-info.parser"; import { tryit } from "shared/helpers/error.helpers"; +import { processZip } from "main/helpers/zip.helpers"; +import JSZip from "jszip"; import { CustomError } from "shared/models/exceptions/custom-error.class"; export class LocalMapsManagerService { @@ -326,6 +328,55 @@ export class LocalMapsManagerService { return null; } + public async importMap(zipPath: string, version?: BSVersion): Promise { + try { + if (!pathExistsSync(zipPath)) { + throw new CustomError(`Zip file "${zipPath}" does not exist`, "not-found-zip"); + } + + const mapFolderName = path.basename(zipPath, ".zip"); + const mapsFolder = await this.getMapsFolderPath(version); + const mapPath = path.join(mapsFolder, mapFolderName); + + log.info(`Importing map "${zipPath}" to "${mapPath}"`); + + const zip = await JSZip.loadAsync(await readFile(zipPath)); + const infoFile = zip.file("Info.dat"); + if (!infoFile) { // Simple check for importing maps + throw new CustomError(`Invalid zip file "${zipPath}"`, "invalid-zip"); + } + + await ensureFolderExist(mapPath); + await processZip(zip, async (relativePath, file) => { + const filepath = path.join(mapPath, relativePath); + if (file.dir) { + await ensureFolderExist(filepath); + return 0; + } + + log.info(`Extracting "${filepath}"`); + const content = await file.async("nodebuffer"); + await writeFile(filepath, content); + + return content.length; + }); + + const localMap = await this.loadMapInfoFromPath(mapPath); + localMap.songDetails = this.songDetailsCache.getSongDetails(localMap.hash); + + this.ipc.send<{map: BsmLocalMap, version?: BSVersion}>( + "map-downloaded", + this.windows.getWindows("index.html").at(0), + { map: localMap, version } + ); + + return localMap; + } catch (error: any) { + throw error instanceof CustomError + ? error + : CustomError.fromError(error); + } + } public async downloadMap(map: BsvMapDetail, version?: BSVersion): Promise { if (!map.versions.at(0).hash) { diff --git a/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx b/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx index a431e56c..a9cb1d8a 100644 --- a/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx @@ -23,6 +23,8 @@ import { LocalBPListsDetails } from "shared/models/playlists/local-playlist.mode import { PlaylistDownloaderService } from "renderer/services/playlist-downloader.service"; import { LocalPlaylistFilter, LocalPlaylistFilterPanel } from "./playlists/local-playlist-filter-panel.component"; import { noop } from "shared/helpers/function.helpers"; +import { Dropzone } from "../shared/dropzone.component"; +import { NotificationService } from "renderer/services/notification.service"; type Props = { readonly version?: BSVersion; @@ -42,6 +44,7 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) { const mapsDownloader = useService(MapsDownloaderService); const playlistsManager = useService(PlaylistsManagerService); const playlistsDownloader = useService(PlaylistDownloaderService); + const notifications = useService(NotificationService); const t = useTranslation(); const [tabIndex, setTabIndex] = useState(0); @@ -101,6 +104,26 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) { return playlistsManager.unlinkVersion(version); } + const handleFileDrop = async (file: File) => { + if (file.type !== "application/zip") { + notifications.notifyError({ + title: "notifications.maps.import-map.titles.error", + desc: "notifications.maps.import-map.msgs.zip-accept-only" + }); + return; + } + + const localMap = await mapsManager.importMap(file.path, version); + if (localMap) { + notifications.notifySuccess({ + title: "notifications.maps.import-map.titles.success", + desc: t("notifications.maps.import-map.msgs.success", { + mapName: localMap.rawInfo._songName + }) + }); + } + } + const dropDownItems = ((): DropDownItem[] => { if (tabIndex === 0) { return [ @@ -175,7 +198,20 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) { ]} > - + { + handleFileDrop(event.dataTransfer.files[0]); + }} + overlay={ +
+ Drop map files here 😃 +
+ } + > + +
+
diff --git a/src/renderer/components/shared/dropzone.component.tsx b/src/renderer/components/shared/dropzone.component.tsx new file mode 100644 index 00000000..6fb5a6a1 --- /dev/null +++ b/src/renderer/components/shared/dropzone.component.tsx @@ -0,0 +1,73 @@ +import React, { ReactNode, useState } from "react"; + +type Props = Readonly<{ + children?: ReactNode; + className?: string; + overlay?: ReactNode; + overlayColor?: string; + overlayZIndex?: number; + onDrop?: (event: React.DragEvent) => void; +}>; + +// Supports drop and drop functionality for files. +export function Dropzone({ + children, + className, + overlay, + overlayColor, + overlayZIndex, + onDrop +}: Props) { + + const [dragging, setDragging] = useState(false); + + const normalizeZIndex = (zIndex?: number) => { + return (!zIndex || zIndex <= 0) ? 50 : zIndex; + } + + const renderFileOverlay = () => { + return ( +
{ + event.preventDefault(); + event.stopPropagation(); + setDragging(false); + if (onDrop) onDrop(event); + }} + onDragOver={event => { + event.preventDefault(); + event.stopPropagation(); + }} + onDragEnter={event => { + // Should consume the event 1 more time to avoid multiple onDragEnter + // triggers by the parent div + event.preventDefault(); + event.stopPropagation(); + }} + onDragLeave={event => { + event.preventDefault(); + event.stopPropagation(); + setDragging(false); + }} + > + {overlay} +
+ ) + } + + return ( +
{ + event.preventDefault(); + event.stopPropagation(); + setDragging(true); + }}> + {dragging && renderFileOverlay()} + {children} +
+ ); +} diff --git a/src/renderer/services/maps-downloader.service.ts b/src/renderer/services/maps-downloader.service.ts index a8af459c..32218cb7 100644 --- a/src/renderer/services/maps-downloader.service.ts +++ b/src/renderer/services/maps-downloader.service.ts @@ -71,7 +71,7 @@ export class MapsDownloaderService { if (this.os.isOffline) { return null; } - return this.ipc.sendV2("download-map", { map, version }); + return this.ipc.sendV2("bs-maps.download-map", { map, version }); } public async openDownloadMapModal(version?: BSVersion, ownedMaps: BsmLocalMap[] = []): Promise> { diff --git a/src/renderer/services/maps-manager.service.ts b/src/renderer/services/maps-manager.service.ts index 850e5ba8..55788bb9 100644 --- a/src/renderer/services/maps-manager.service.ts +++ b/src/renderer/services/maps-manager.service.ts @@ -196,6 +196,27 @@ export class MapsManagerService { }) } + public async importMap(path: string, version?: BSVersion): Promise { + try { + if (!this.progressBar.require()) { + return null; + } + + return await lastValueFrom(this.ipcService.sendV2( + "bs-maps.import-map", + { path, version } + )); + } catch (error: any) { + this.notifications.notifyError({ + title: "notifications.maps.import-map.titles.error", + desc: ["not-found-zip", "invalid-zip"].includes(error?.code) + ? `notifications.maps.import-map.msgs.${error.code}` + : "Unknown" + }); + return null; + } + } + public getMapsInfoFromHashs(hashs: string[]): Observable { return this.ipcService.sendV2("get-maps-info-from-cache", hashs); } diff --git a/src/shared/models/ipc/ipc-routes.ts b/src/shared/models/ipc/ipc-routes.ts index 4b56d801..412f6a22 100644 --- a/src/shared/models/ipc/ipc-routes.ts +++ b/src/shared/models/ipc/ipc-routes.ts @@ -58,7 +58,8 @@ export interface IpcChannelMapping { "load-version-maps": { request: BSVersion, response: BsmLocalMapsProgress}; "delete-maps": { request: BsmLocalMap[], response: DeleteMapsProgress }; "export-maps": { request: { version: BSVersion; maps: BsmLocalMap[]; outPath: string }, response: Progression }; - "download-map": { request: { map: BsvMapDetail; version: BSVersion }, response: BsmLocalMap }; + "bs-maps.import-map": { request: { path: string; version: BSVersion | undefined }, response: BsmLocalMap }; + "bs-maps.download-map": { request: { map: BsvMapDetail; version: BSVersion | undefined }, response: BsmLocalMap }; "last-downloaded-map": { request: void, response: { version?: BSVersion, map: BsmLocalMap } }; "one-click-install-map": { request: BsvMapDetail, response: void }; "register-maps-deep-link": { request: void, response: boolean };