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

Conversation

silentrald
Copy link
Contributor

@silentrald silentrald commented Sep 5, 2024

Scope

closes #75

Implementation

Implemented drag and drop for map zip files.

Added Dropzone component to support drag and drop of files.

Created processZip in zip helper file to safely handle zip files, although SonarCloud doesn't seem to like the handling.

Added LocalMapsManagerService.importMap to import the zip file and copied almost the same code in downloadMap for pathing and caching.

Screenshots

image

How to Test

Drag and drop the following files:

  • Valid map zip file
  • Invalid zip file
  • Any non-zip file

Check if it will be installed in either the specific version folder or shared folder as well.

Check if switching to the playlist tab is not affected by the dropzone.

New Resource Text

List of added resource text

notifications.maps.import-map.titles.success: Map import complete
notifications.maps.import-map.titles.error: An error occurred while importing the map
notifications.maps.import-map.msgs.success: Imported all maps successfully
notifications.maps.import-map.msgs.success: Imported some maps successfully
notifications.maps.import-map.msgs.only-accept-zip: Only zip files are supported
notifications.maps.import-map.msgs.not-found-zip: Zip file does not exists
notifications.maps.import-map.msgs.invalid-zip: Zip file does not contain any dat files

Emoji Guide

For reviewers: Emojis can be added to comments to call out blocking versus non-blocking feedback.

E.g: Praise, minor suggestions, or clarifying questions that don’t block merging the PR.

🟢 Nice refactor!

🟡 Why was the default value removed?

E.g: Blocking feedback must be addressed before merging.

🔴 This change will break something important

Blocking 🔴 ❌ 🚨 RED
Non-blocking 🟡 💡 🤔 💭 Yellow, thinking, etc
Praise 🟢 💚 😍 👍 🙌 Green, hearts, positive emojis, etc

log.info(`Importing map "${zipPath}" to "${mapPath}"`);

const zip = await JSZip.loadAsync(await readFile(zipPath));
const infoFile = zip.file("Info.dat");
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Info.dat can also exist as info.dat
image

@@ -306,6 +309,85 @@ export class LocalMapsManagerService {
return null;
}

public importMaps(zipPaths: string[], version?: BSVersion): Observable<Progression> {
const progress: Progression<string> = {
Copy link
Owner

@Zagrios Zagrios Sep 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function should handle zips containing multiple maps, not just one zip per map.
It should be compatible with the export maps feature of BSM, which creates a single zip file for all the selected maps.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to currently verify that the exportMaps zip file is generated by BSM? Something like a metadata within the zip file.

Also the only problem that I can see here is the zip bomb check I've implemented in processZip. Where there is a limit to the file count and file size. We can add that information in within the metadata I guess to bypass the check and a warn users about downloading other users exportMaps zip files.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can add a metadata file to the generated zip, but users can totally create zips that contain multiple maps on their own for plenty of reasons.

We can maybe split the zip verification to another ipc, and warn the user with a modal if the zip exeed a certain size or a certain amount of maps 🤔

Or we could let the user be responsible for what kind of zips they are importing (I mean, if they want to import 1TB of maps, they should be able to do it)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can add a metadata file to the generated zip, but users can totally create zips that contain multiple maps on their own for plenty of reasons.

Yeah fair point, the metadata would be either useless or redundant in any case. Might just do a simple verification on each folder on depth 1 if they contain the "I/info.dat" file.

We can maybe split the zip verification to another ipc, and warn the user with a modal if the zip exeed a certain size or a certain amount of maps 🤔
Or we could let the user be responsible for what kind of zips they are importing (I mean, if they want to import 1TB of maps, they should be able to do it)

I think the latter is probably the better option since the user should be responsible for what the zip files that are being imported is.

I've implemented that check is because sonarcloud suggested it so might ignore that in the future in any case.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think SonarCloud's suggestion is great when it’s for a website, but it doesn’t really apply to BSM

);
}
}
observer.complete();
Copy link
Owner

@Zagrios Zagrios Sep 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer placing the .complete() in the finally block and add an .error() in the catch of the promise.
This way, it will handle all potential errors that are not caught by your try/catch block.
(Even though, I agree, in the current state, unhandled errors are shouldn't possible)

await this.importMap(zipPath, version);
} catch (error: any) {
log.error(`Could not import "${zipPath}"`, error);
this.ipc.send<{map: string, error: string}>(
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its preferable to remove this ipc.send cause' it can spam the user if all dropped zips are invalid.
Just handle errors on the frontend as normally and show a warning if the progression does not reach 100%
(This is even truer when this function will handle zips of multiple maps)

private constructor() {
this.ipcService = IpcService.getInstance();
this.modal = ModalService.getInstance();
this.progressBar = ProgressBarService.getInstance();
this.notifications = NotificationService.getInstance();
this.config = ConfigurationService.getInstance();
this.linker = VersionFolderLinkerService.getInstance();

this.ipcService.watch<{map: BsmLocalMap, version?: BSVersion}>("map-imported")
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need these watchers.
I will deprecate the watch function in the future.
Using it too much overcomplicates the code; the backend should never send random events like this. It should only send events when a function is called, until the end of the function’s execution.

In your importMaps, the "bs-maps.import-maps" should return a Progression<BsmLocalMap>, and then you can do something like this:

sendV2("bs-maps.import-maps", {...args}).pipe(
  tap( progress => importListerners.foreach(listener => listener(version, progress.data))
)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got this, might just do this on import-maps and not on download-maps. I can do the refactoring on download maps once I get this PR merged.

Copy link
Owner

@Zagrios Zagrios Sep 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me this 👌, there is few more places that still use this function, I will try to remove them all today

});

// NOTE: Generic notify call from server side
this.ipcService.watch<{map: string, error: string}>("map-not-imported")
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, but this should be removed anyway due to the spam of notifications

}

public removeImportListener(listener: (map: BsmLocalMap, version?: BSVersion) => void): void {
const index = this.importListeners.indexOf(listener);
Copy link
Owner

@Zagrios Zagrios Sep 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder what happen if the same callback is added multiple time 🤔

Like:

const cb = () => {};
addImportListener(cb);
addImportListener(cb);
addImportListener(cb);

removeImportListener(cb);

Maybe a Set of callbacks would work better here instead of an array 🤔
I let you test that 😉

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah might be better to do a Set in any case, but we'll see if that works.


Actually there was a bug is this code as well, where the listeners weren't removed properly. Not sure if the fix should be here since the PR might take some more time to get merged, but the snippet bugged code is here.

In master currently
local-maps-list-panel.component.tsx

    useEffect(() => {
        if (isActiveOnce) {
            loadMaps();
        }

        return () => {
            setMaps(null);
            loadPercent$.next(0);
            subs.forEach(s => s.unsubscribe());
            mapsDownloader.removeOnMapDownloadedListener(loadMaps); // should not be loadMaps
        };
    }, [isActiveOnce, version, linked]);

    useEffect(() => {
        if(isActiveOnce){
            // The listener that was being added multiple times
            mapsDownloader.addOnMapDownloadedListener((map, targetVersion) => {
                if (!equal(targetVersion, version)) {
                    return;
                }
                setMaps((maps ? [map, ...maps] : [map]));
            });
        }

        return () => {
            mapsDownloader.removeOnMapDownloadedListener(loadMaps); // should not be loadMaps
        }

    }, [isActiveOnce, version, maps])

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀 Yeah I will fix that in on master 😅

@silentrald
Copy link
Contributor Author

@Zagrios Side tangent, seems that the call from observer.error shows the call stack error in the renderer. Seems the same as well for the other sendV2 <-> Observer interaction in the codebase as well. Might be a bug in the integration? But might check with you if this is expected.

image

image

@Zagrios
Copy link
Owner

Zagrios commented Sep 29, 2024

@Zagrios Side tangent, seems that the call from observer.error shows the call stack error in the renderer. Seems the same as well for the other sendV2 <-> Observer interaction in the codebase as well. Might be a bug in the integration? But might check with you if this is expected.

image

image

Yeah you need to handle the error in the frontend as well

@silentrald
Copy link
Contributor Author

Yeah you need to handle the error in the frontend as well

Already did in the latest commit, the notification popup shows in the background when I close the error stack in the renderer so I'm not sure what part I've missed 🤔.

const importProgress$ = this.ipcService.sendV2(
"bs-maps.import-maps",
{ paths, version }
).pipe(
tap(progress => {
if (!progress.data) {
importTotal = progress.total;
return;
}
++importCount;
mapBatch.push(progress.data);
if (mapBatch.length >= this.IMPORT_BATCH_SIZE) {
const currentBatch = mapBatch.splice(0, this.IMPORT_BATCH_SIZE);
this.importListeners.forEach(
listeners => listeners(currentBatch, version)
);
}
}),
map(progress => ({
progression: (progress.current / progress.total) * 100,
label: progress.data?.songDetails?.name
} as ProgressionInterface)),
share(),
);
this.progressBar.show(importProgress$, true);
await lastValueFrom(importProgress$);
if (mapBatch.length > 0) {
this.importListeners.forEach(listeners => listeners(mapBatch, version));
}
if (importCount === importTotal) {
this.notifications.notifySuccess({
title: "notifications.maps.import-map.titles.success",
desc: "notifications.maps.import-map.msgs.success",
});
} else if (importCount > 0) {
this.notifications.notifySuccess({
title: "notifications.maps.import-map.titles.success",
desc: "notifications.maps.import-map.msgs.some-success",
});
}
} catch (error: any) {
this.notifications.notifyError({
title: "notifications.maps.import-map.titles.error",
desc: ["invalid-zip"].includes(error?.code)
? `notifications.maps.import-map.msgs.${error.code}`
: "misc.unknown"
});
} finally {
this.progressBar.hide(true);
}

@Zagrios
Copy link
Owner

Zagrios commented Sep 29, 2024

Yeah you need to handle the error in the frontend as well

Already did in the latest commit, the notification popup shows in the background when I close the error stack in the renderer so I'm not sure what part I've missed 🤔.

const importProgress$ = this.ipcService.sendV2(
"bs-maps.import-maps",
{ paths, version }
).pipe(
tap(progress => {
if (!progress.data) {
importTotal = progress.total;
return;
}
++importCount;
mapBatch.push(progress.data);
if (mapBatch.length >= this.IMPORT_BATCH_SIZE) {
const currentBatch = mapBatch.splice(0, this.IMPORT_BATCH_SIZE);
this.importListeners.forEach(
listeners => listeners(currentBatch, version)
);
}
}),
map(progress => ({
progression: (progress.current / progress.total) * 100,
label: progress.data?.songDetails?.name
} as ProgressionInterface)),
share(),
);
this.progressBar.show(importProgress$, true);
await lastValueFrom(importProgress$);
if (mapBatch.length > 0) {
this.importListeners.forEach(listeners => listeners(mapBatch, version));
}
if (importCount === importTotal) {
this.notifications.notifySuccess({
title: "notifications.maps.import-map.titles.success",
desc: "notifications.maps.import-map.msgs.success",
});
} else if (importCount > 0) {
this.notifications.notifySuccess({
title: "notifications.maps.import-map.titles.success",
desc: "notifications.maps.import-map.msgs.some-success",
});
}
} catch (error: any) {
this.notifications.notifyError({
title: "notifications.maps.import-map.titles.error",
desc: ["invalid-zip"].includes(error?.code)
? `notifications.maps.import-map.msgs.${error.code}`
: "misc.unknown"
});
} finally {
this.progressBar.hide(true);
}

The error comes from this line this.progressBar.show(importProgress$, true); Since the progressBarService shares the same observer, it also receives the error from it.
You need to derive the main observer to create another one that doesn't receive any errors.

You can check the handleDownload() function in oculus-downloader.service.ts

@silentrald
Copy link
Contributor Author

Ahhh gotcha, will amend the last commit so that the PR should be ready for a re-review.

@Zagrios
Copy link
Owner

Zagrios commented Oct 8, 2024

@silentrald I've just pushed some fixes, but I have to go.
I'm still encountering an error when I drop my zip file, as it doesn't find any Info.dat (see the gif).
zip dont work
I get this error message:

[1] 17:37:30.357 > Zip file "C:\Users\Mathieu\Desktop\1.37.3Maps.zip" does not contain any "Info.dat" file
[1] 17:37:30.358 > Error: No "Info.dat" file located in any of the zip files

The const files = zip.file(this.INFO_DAT_REGEX); results as empty array.

@silentrald
Copy link
Contributor Author

@Zagrios might be related to the path separator. Not sure if Windows uses / or \, couldn't check this on my linux machine, though let me try to fix this in a Windows VM.

Copy link

sonarcloud bot commented Oct 8, 2024

Quality Gate Failed Quality Gate failed

Failed conditions
2 Security Hotspots

See analysis details on SonarCloud

@silentrald
Copy link
Contributor Author

@Zagrios got it to work on Windows.

Lost your commit though when I did a full rebase but I tried to reapply it on commit 615ac04. Might need to double check that, if I missed some lines, you can cherry pick the commit on your local to this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[FEAT.] : Drag drop map to install it
2 participants