mirror of
https://github.com/Zagrios/bs-manager.git
synced 2026-07-03 14:08:25 +02:00
[feat-75] support drag and drop for map zip files
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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))) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user