Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
[feat-75] support import for maps exported by BSM
  • Loading branch information
silentrald committed Oct 8, 2024
commit f9bca2a14641c85d0f9c625c26ea3fe2068ac122
3 changes: 1 addition & 2 deletions assets/jsons/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -515,8 +515,7 @@
"success": "Alle Karten wurden erfolgreich importiert.",
"some-success": "Einige Karten wurden erfolgreich importiert.",
"only-accept-zip": "Nur ZIP-Dateien werden unterstützt.",
"not-found-zip": "Die ZIP-Datei existiert nicht.",
"invalid-zip": "Die ZIP-Datei enthält keine DAT-Dateien."
"invalid-zip": "Die Zip-Datei(en) enthält/en keine \"Info.dat\"-Datei."
}
}
},
Expand Down
3 changes: 1 addition & 2 deletions assets/jsons/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -522,8 +522,7 @@
"success": "Imported all maps successfully.",
"some-success": "Imported some maps successfully.",
"only-accept-zip": "Only zip files are supported.",
"not-found-zip": "Zip file does not exists.",
"invalid-zip": "Zip file does not contain any dat files."
"invalid-zip": "Zip/s file do not contain any \"Info.dat\" file."
}
}
},
Expand Down
3 changes: 1 addition & 2 deletions assets/jsons/translations/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -515,8 +515,7 @@
"success": "Se importaron todas las mapas con éxito.",
"some-success": "Se importaron algunos mapas con éxito.",
"only-accept-zip": "Solo se admiten archivos ZIP.",
"not-found-zip": "El archivo ZIP no existe.",
"invalid-zip": "El archivo ZIP no contiene archivos DAT."
"invalid-zip": "El/Los archivo(s) Zip no contienen ningún archivo \"Info.dat\"."
}
}
},
Expand Down
3 changes: 1 addition & 2 deletions assets/jsons/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -515,8 +515,7 @@
"success": "Toutes les cartes ont été importées avec succès.",
"some-success": "Certaines cartes ont été importées avec succès.",
"only-accept-zip": "Seules les fichiers ZIP sont pris en charge.",
"not-found-zip": "Le fichier ZIP n'existe pas.",
"invalid-zip": "Le fichier ZIP ne contient aucun fichier DAT."
"invalid-zip": "Le(s) fichier(s) Zip ne contient/contiennent aucun fichier \"Info.dat\"."
}
}
},
Expand Down
3 changes: 1 addition & 2 deletions assets/jsons/translations/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -515,8 +515,7 @@
"success": "すべてのマップが正常にインポートされました。",
"some-success": "一部のマップが正常にインポートされました。",
"only-accept-zip": "ZIPファイルのみがサポートされています。",
"not-found-zip": "ZIPファイルが存在しません。",
"invalid-zip": "ZIPファイルにはDATファイルが含まれていません。"
"invalid-zip": "ZIPファイルには「Info.dat」ファイルが含まれていません。"
}
}
},
Expand Down
3 changes: 1 addition & 2 deletions assets/jsons/translations/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -515,8 +515,7 @@
"success": "Все карты были успешно импортированы.",
"some-success": "Некоторые карты были успешно импортированы.",
"only-accept-zip": "Поддерживаются только ZIP-файлы.",
"not-found-zip": "ZIP-файл не существует.",
"invalid-zip": "ZIP-файл не содержит файлов DAT."
"invalid-zip": "Файл(ы) ZIP не содержат файл(ы) \"Info.dat\"."
}
}
},
Expand Down
3 changes: 1 addition & 2 deletions assets/jsons/translations/zh-tw.json
Original file line number Diff line number Diff line change
Expand Up @@ -515,8 +515,7 @@
"success": "所有地圖已成功導入。",
"some-success": "一些地圖已成功導入。",
"only-accept-zip": "只支持 ZIP 文件。",
"not-found-zip": "ZIP 文件不存在。",
"invalid-zip": "ZIP 文件不包含任何 DAT 文件。"
"invalid-zip": "ZIP 檔案不包含任何 \"Info.dat\" 檔案。"
}
}
},
Expand Down
3 changes: 1 addition & 2 deletions assets/jsons/translations/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -515,8 +515,7 @@
"success": "所有地图已成功导入。",
"some-success": "一些地图已成功导入。",
"only-accept-zip": "只支持 ZIP 文件。",
"not-found-zip": "ZIP 文件不存在。",
"invalid-zip": "ZIP 文件不包含任何 DAT 文件。"
"invalid-zip": "ZIP 文件不包含任何 \"Info.dat\" 文件。"
}
}
},
Expand Down
39 changes: 1 addition & 38 deletions src/main/helpers/zip.helpers.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,7 @@
import JSZip from "jszip";
import { pathExist } from "./fs.helpers";
import path from "path";
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}"`);
}
}
}
import { mkdir, writeFile } from "fs/promises";

export async function extractZip(zip: JSZip, dest: string): Promise<string[]> {
if (!(await pathExist(dest))) {
Expand Down
180 changes: 135 additions & 45 deletions src/main/services/additional-content/maps/local-maps-manager.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ import { InstallationLocationService } from "../../installation-location.service
import { UtilsService } from "../../utils.service";
import crypto from "crypto";
import { lstatSync } from "fs";
import { copy, createReadStream, ensureDir, pathExists, pathExistsSync, realpath, unlink, writeFile } from "fs-extra";
import { copy, createReadStream, ensureDir, mkdir, pathExists, pathExistsSync, realpath, unlink, unlinkSync, writeFile } from "fs-extra";
import StreamZip from "node-stream-zip";
import { RequestService } from "../../request.service";
import sanitize from "sanitize-filename";
import { DeepLinkService } from "../../deep-link.service";
import log from "electron-log";
import { WindowManagerService } from "../../window-manager.service";
import { Observable, Subject, lastValueFrom } from "rxjs";
import { Observable, Subject, Subscriber, lastValueFrom } from "rxjs";
import { Archive } from "../../../models/archive.class";
import { Progression, deleteFolder, ensureFolderExist, getFilesInFolder, getFoldersInFolder, pathExist } from "../../../helpers/fs.helpers";
import { readFile } from "fs/promises";
Expand All @@ -26,7 +26,6 @@ import { SongCacheService } from "./song-cache.service";
import { pathToFileURL } from "url";
import { sToMs } from "../../../../shared/helpers/time.helpers";
import { FieldRequired } from "shared/helpers/type.helpers";
import { processZip } from "main/helpers/zip.helpers";
import JSZip from "jszip";
import { CustomError } from "shared/models/exceptions/custom-error.class";

Expand All @@ -50,6 +49,10 @@ export class LocalMapsManagerService {
ScoreSaber: "web+bsmap",
};

private readonly INFO_DAT_REGEX = path.sep === "/"
? /(^|\/)(I|i)nfo.dat$/
: /(^|\\\\)(I|i)nfo.dat$/;

private readonly localVersion: BSLocalVersionService;
private readonly installLocation: InstallationLocationService;
private readonly utils: UtilsService;
Expand Down Expand Up @@ -308,76 +311,163 @@ export class LocalMapsManagerService {
return null;
}

public importMaps(zipPaths: string[], version?: BSVersion): Observable<Progression<BsmLocalMap>> {
return new Observable<Progression<BsmLocalMap>>(observer => {
this.handleZipPaths(zipPaths, observer, version)
.catch(error => observer.error(error))
.finally(() => observer.complete());
});
}

private async handleZipPaths(
zipPaths: string[],
observer: Subscriber<Progression<BsmLocalMap>>,
version?: BSVersion
): Promise<void> {
const info: {
zips: ({
path: string;
// if the zip contains single or multiple maps
single: boolean;
// paths within the zip file where a map is located
folders: string[];
})[];
total: number;
} = {
zips: [],
total: 0,
};

for (const zipPath of zipPaths) {
try {
const zip = await JSZip.loadAsync(await readFile(zipPath));
const files = zip.file(this.INFO_DAT_REGEX);
if (files.length === 0) {
log.warn(`Zip file "${zipPath}" does not contain any "Info.dat" file`);
continue;
}

public importMaps(zipPaths: string[], version?: BSVersion): Observable<Progression<BsmLocalMap>> {
info.total += files.length;
info.zips.push({
path: zipPath,
single: files.findIndex(file => file.name.includes(path.sep)) === -1,
folders: files.map(file => path.dirname(file.name)),
});
} catch (error: any) {
log.warn(`Could not count maps ${zipPath}`, error);
}
}

if (info.total === 0) {
throw new CustomError("No \"Info.dat\" file located in any of the zip files", "invalid-zip");
}

// Setting up the progress bar
const progress: Progression<BsmLocalMap> = {
total: zipPaths.length,
total: info.total,
current: 0,
};
const mapsFolder = await this.getMapsFolderPath(version);

return new Observable<Progression<BsmLocalMap>>(observer => {
(async () => {
try {
observer.next(progress); // 0%

for (const zipPath of zipPaths) {
++progress.current;
try {
progress.data = await this.importMap(zipPath, version);
} catch (error: any) {
log.error(`Could not import "${zipPath}"`, error);
progress.data = undefined;
}
observer.next(progress); // 0%

for (const zipInfo of info.zips) {
try {
const content = await readFile(zipInfo.path);
const zip = await JSZip.loadAsync(content);

// Zip containing only a single map
if (zipInfo.single) {
++progress.current;

progress.data = await this.importMap(
zip, zipInfo.path, "",
path.basename(zipInfo.path, ".zip"),
mapsFolder
)
observer.next(progress);
continue;
}

// Zip containing multiple maps
for (const relativeFolder of zipInfo.folders) {
++progress.current;
try {
progress.data = await this.importMap(
zip, zipInfo.path,
relativeFolder,
path.basename(relativeFolder),
mapsFolder
);
} catch (error: any) {
log.error(`Could not import "${zipInfo.path}"`, error);
progress.data = undefined;
} finally {
observer.next(progress);
}
} catch(error: any) {
observer.error(error);
} finally {
observer.complete();
}
})();
});
} catch (error: any) {
log.error(`Could not import "${zipInfo.path}"`, error);
progress.data = undefined;
observer.next(progress);
}
}
}

private async importMap(zipPath: string, version?: BSVersion): Promise<BsmLocalMap> {
private async importMap(
zip: JSZip,
zipPath: string, // where the zip file is located
relativeFolder: string, // where is the map relative in the zip file
mapName: string, // Map/Song name
mapsFolder: string // Maps folder depending on the version
): Promise<BsmLocalMap> {
let mapPath = "";
let existing = false;
try {
if (!pathExistsSync(zipPath)) {
throw new CustomError(`Zip file "${zipPath}" does not exist`, "not-found-zip");
}
mapPath = path.join(mapsFolder, mapName);
existing = pathExistsSync(mapPath);
log.info(`Importing map from "${zipPath}" in "${relativeFolder}" to "${mapPath}"`);

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}"`);
if (!existing) {
await mkdir(mapPath, { recursive: true });
}

const zip = await JSZip.loadAsync(await readFile(zipPath));
const infoFiles = zip.file(/(I|i)nfo.dat/);
if (infoFiles.length === 0) { // Simple check for importing maps
throw new CustomError(`Invalid zip file "${zipPath}"`, "invalid-zip");
// Prep the files
let files: { [key: string]: JSZip.JSZipObject; } = {};
if (relativeFolder === "") {
// Zip containing only a single map
files = zip.files;
} else {
// Zip containing multiple maps
// async doesn't work here and zip.folder().files does not work as you expect
zip.folder(relativeFolder).forEach((relativeFolder, file) => {
files[relativeFolder] = file;
});
}

await ensureFolderExist(mapPath);
await processZip(zip, async (relativePath, file) => {
for (const [relativePath, file] of Object.entries(files)) {
const filepath = path.join(mapPath, relativePath);
if (file.dir) {
await ensureFolderExist(filepath);
return 0;
continue;
}

log.info(`Extracting "${filepath}"`);
log.info(`Extracting to "${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);

return localMap;
} catch (error: any) {
// If the mapPath isn't yet added, delete the map folder
if (!existing && mapPath) {
unlinkSync(mapPath);
} else if (existing && mapPath) {
log.warn(`Map folder ${mapPath} could be broken`);
}

throw error instanceof CustomError
? error
: CustomError.fromError(error);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,23 +121,7 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) {
return;
}

const importCount = await mapsManager.importMaps(paths, version);
if (importCount === 0) {
return;
}

if (importCount < paths.length) {
notifications.notifySuccess({
title: "notifications.maps.import-map.titles.success",
desc: "notifications.maps.import-map.msgs.some-success",
});
return;
}

notifications.notifySuccess({
title: "notifications.maps.import-map.titles.success",
desc: "notifications.maps.import-map.msgs.success",
});
await mapsManager.importMaps(paths, version);
}

const dropDownItems = ((): DropDownItem[] => {
Expand Down
Loading