[feat-75] support drag and drop for map zip files

This commit is contained in:
silentrald
2024-09-06 00:01:41 +08:00
parent d5b7dff7a9
commit 0f3de55e9e
16 changed files with 326 additions and 6 deletions
+12
View File
@@ -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": {
+12
View File
@@ -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": {
+12
View File
@@ -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": {
+12
View File
@@ -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": {
+12
View File
@@ -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": {
+12
View File
@@ -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": {
+12
View File
@@ -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": {
+12
View File
@@ -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": {
+38 -1
View File
@@ -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> | number
): Promise<void> {
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<string[]> {
if (!(await pathExist(dest))) {
+6 -1
View File
@@ -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)));
});
@@ -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<BsmLocalMap> {
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<BsmLocalMap> {
if (!map.versions.at(0).hash) {
@@ -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) {
]}
>
<InstalledMapsContext.Provider value={mapsContextValue}>
<LocalMapsListPanel className="w-full h-full shrink-0" isActive={isActive && tabIndex === 0} ref={mapsRef} version={version} filter={mapFilter} search={search} linkedState={mapsLinkedState} />
<Dropzone
className="w-full h-full shrink-0"
onDrop={event => {
handleFileDrop(event.dataTransfer.files[0]);
}}
overlay={
<div className="text-3xl pointer-events-none">
Drop map files here 😃
</div>
}
>
<LocalMapsListPanel className="w-full h-full shrink-0" isActive={isActive && tabIndex === 0} ref={mapsRef} version={version} filter={mapFilter} search={search} linkedState={mapsLinkedState} />
</Dropzone>
<LocalPlaylistsListPanel className="w-full h-full shrink-0" isActive={isActive && tabIndex === 1} ref={playlistsRef} version={version} linkedState={playlistLinkedState} filter={playlistFilter} search={search}/>
</InstalledMapsContext.Provider>
</BsContentTabPanel>
@@ -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<HTMLDivElement>) => 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 (
<div
className="absolute w-full h-full flex items-center justify-center"
style={{
backgroundColor: overlayColor || "#000000CC",
zIndex: normalizeZIndex(overlayZIndex),
}}
onDrop={event => {
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}
</div>
)
}
return (
<div className={className} onDragEnter={event => {
event.preventDefault();
event.stopPropagation();
setDragging(true);
}}>
{dragging && renderFileOverlay()}
{children}
</div>
);
}
@@ -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<ModalResponse<void>> {
@@ -196,6 +196,27 @@ export class MapsManagerService {
})
}
public async importMap(path: string, version?: BSVersion): Promise<BsmLocalMap | null> {
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<SongDetails[]> {
return this.ipcService.sendV2("get-maps-info-from-cache", hashs);
}
+2 -1
View File
@@ -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 };