[feature-593] We can now delete duplicates maps

This commit is contained in:
MathieuG-P
2024-10-06 18:22:28 +02:00
parent b97a58c278
commit 9d59bfb956
15 changed files with 237 additions and 15 deletions
@@ -1,6 +1,6 @@
import { createContext, useRef, useState } from "react";
import { BSVersion } from "shared/bs-version.interface";
import { LocalMapsListPanel } from "./maps/local-maps-list-panel.component";
import { LocalMapsListPanel, LocalMapsListPanelRef } from "./maps/local-maps-list-panel.component";
import { BsmDropdownButton, DropDownItem } from "../shared/bsm-dropdown-button.component";
import { FilterPanel } from "./maps/filter-panel.component";
import { MapFilter } from "shared/models/maps/beat-saver.model";
@@ -56,7 +56,7 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) {
setPlaylists: playlists$.next.bind(playlists$),
}));
const mapsRef = useRef<any>();
const mapsRef = useRef<LocalMapsListPanelRef>();
const playlistsRef = useRef<LocalPlaylistsListRef>();
const [mapFilter, setMapFilter] = useState<MapFilter>({});
@@ -106,6 +106,7 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) {
return [
{ icon: "export", text: "pages.version-viewer.maps.search-bar.dropdown.export-maps", onClick: () => mapsRef.current.exportMaps?.() },
{ icon: "trash", text: "pages.version-viewer.maps.search-bar.dropdown.delete-maps", onClick: () => mapsRef.current.deleteMaps?.() },
{ icon: "clean", text: "pages.version-viewer.maps.search-bar.dropdown.delete-duplicate-maps", onClick: () => mapsRef.current.removeDuplicates?.() },
];
}
@@ -2,7 +2,7 @@ import { MapsManagerService } from "renderer/services/maps-manager.service";
import { BSVersion } from "shared/bs-version.interface";
import { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useState } from "react";
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
import { Subscription, BehaviorSubject } from "rxjs";
import { Subscription, BehaviorSubject, lastValueFrom } from "rxjs";
import { MapFilter } from "shared/models/maps/beat-saver.model";
import { MapsDownloaderService } from "renderer/services/maps-downloader.service";
import { last, tap } from "rxjs/operators";
@@ -34,7 +34,13 @@ type Props = {
isActive?: boolean;
};
export const LocalMapsListPanel = forwardRef<unknown, Props>(({ version, className, filter, search, linkedState, isActive }, forwardRef) => {
export type LocalMapsListPanelRef = {
deleteMaps: () => void;
exportMaps: () => void;
removeDuplicates: () => void;
}
export const LocalMapsListPanel = forwardRef<LocalMapsListPanelRef, Props>(({ version, className, filter, search, linkedState, isActive }, forwardRef) => {
const mapsManager = useService(MapsManagerService);
const mapsDownloader = useService(MapsDownloaderService);
@@ -63,6 +69,12 @@ export const LocalMapsListPanel = forwardRef<unknown, Props>(({ version, classNa
},
exportMaps() {
mapsManager.exportMaps(version, selectedMaps);
},
removeDuplicates() {
return lastValueFrom(mapsManager.deleteDuplicateMaps(maps$.value)).then(res => {
if(!res?.current){ return; }
loadMaps();
}).catch(noop);
}
}),[selectedMaps, maps, version]);
@@ -0,0 +1,29 @@
import { BsmButton } from "renderer/components/shared/bsm-button.component";
import { BsmImage } from "renderer/components/shared/bsm-image.component";
import { useTranslation } from "renderer/hooks/use-translation.hook";
import { ModalComponent, ModalExitCode } from "renderer/services/modale.service";
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
import BeatConflict from "../../../../../assets/images/apngs/beat-conflict.png";
import { useConstant } from "renderer/hooks/use-constant.hook";
export const DeleteDuplicateMapsModal: ModalComponent<void, { maps: BsmLocalMap[] }> = ({ resolver, options: {data : { maps }}}) => {
const t = useTranslation();
const multiple = useConstant(() => maps.length > 1);
return (
<form className="text-gray-800 dark:text-gray-200 max-w-sm">
<h1 className="text-3xl uppercase tracking-wide w-full text-center">{t("modals.maps-actions.delete-duplicate-maps.title")}</h1>
<BsmImage className="mx-auto h-24" image={BeatConflict} />
<p>{
multiple
? t("modals.maps-actions.delete-duplicate-maps.desc-plural", { nb: `${maps.length}` })
: t("modals.maps-actions.delete-duplicate-maps.desc", { map: `${maps.at(0).rawInfo._songName}` })
}</p>
<div className="grid grid-flow-col grid-cols-2 gap-4 mt-4 h-8">
<BsmButton typeColor="cancel" className="rounded-md flex justify-center items-center transition-all" onClick={() => resolver({ exitCode: ModalExitCode.CANCELED })} withBar={false} text="misc.cancel" />
<BsmButton typeColor="primary" className="rounded-md flex justify-center items-center transition-all" onClick={() => resolver({ exitCode: ModalExitCode.COMPLETED })} withBar={false} text="misc.delete" />
</div>
</form>
);
};
@@ -79,7 +79,7 @@ export const BsmDropdownButton = forwardRef(({ className, classNames, buttonColo
{items?.map(
i =>
i && (
<div key={crypto.randomUUID()} onClick={() => i.onClick?.()} className="flex w-full px-3 py-2 hover:backdrop-brightness-150">
<div key={crypto.randomUUID()} onClick={() => { setExpanded(() => false); i.onClick?.()}} className="flex w-full px-3 py-2 hover:backdrop-brightness-150">
{i.icon && <BsmIcon icon={i.icon} className="h-5 w-5 mr-1 text-inherit" />}
<span className="w-max">{t(i.text)}</span>
</div>
@@ -61,9 +61,10 @@ import { EyeCrossIcon } from "./icons/eye-cross-icon.component";
import { ShortcutIcon } from "./icons/shortcut-icon.component";
import { BackupRestoreIcon } from "./icons/backup-restore-icon.component";
import { SongDetailDiffCharactertistic } from "shared/models/maps/song-details-cache/song-details-cache.model";
import { CleanIcon } from "./icons/clean-icon.component";
export type BsmIconType = SongDetailDiffCharactertistic | ("settings" | "trash" | "favorite" | "folder" | "bsNote" | "check" | "three-dots" | "twitch" | "eye" | "play" | "checkCircleIcon" | "discord" | "info" | "eye-cross" | "terminal" | "desktop" | "oculus" | "add" | "cross" | "task" | "github" | "close" | "thumbUpFill" | "timerFill" | "pause" | "twitter" | "sync" | "chevron-top" | "copy" | "steam" | "edit" | "export" | "patreon" | "search" | "bsMapDifficulty" | "link" | "unlink" | "download" | "filter" | "mee6" | "volume-up" | "volume-off" | "volume-down" | "shortcut" | "backup-restore" | "web-site" | "fr-FR-flag" | "es-ES-flag" | "en-US-flag" | "en-EN-flag" | "de-DE-flag" | "ru-RU-flag" | "zh-CN-flag" | "zh-TW-flag" | "ja-JP-flag");
export type BsmIconType = SongDetailDiffCharactertistic | ("settings" | "trash" | "favorite" | "folder" | "bsNote" | "check" | "three-dots" | "twitch" | "eye" | "play" | "checkCircleIcon" | "discord" | "info" | "eye-cross" | "terminal" | "desktop" | "oculus" | "add" | "cross" | "task" | "github" | "close" | "thumbUpFill" | "timerFill" | "pause" | "twitter" | "sync" | "chevron-top" | "copy" | "steam" | "edit" | "export" | "patreon" | "search" | "bsMapDifficulty" | "link" | "unlink" | "download" | "filter" | "mee6" | "volume-up" | "volume-off" | "volume-down" | "shortcut" | "backup-restore" | "web-site" | "clean" | "fr-FR-flag" | "es-ES-flag" | "en-US-flag" | "en-EN-flag" | "de-DE-flag" | "ru-RU-flag" | "zh-CN-flag" | "zh-TW-flag" | "ja-JP-flag");
export const BsmIcon = memo(({ className, icon, style }: { className?: string; icon: BsmIconType; style?: CSSProperties }) => {
// TODO : Very ugly very messy, need to find a better way to do this
@@ -256,6 +257,11 @@ export const BsmIcon = memo(({ className, icon, style }: { className?: string; i
if(icon === "web-site") {
return <WebSiteIcon className={className} style={style} />;
}
if(icon === "clean") {
return <CleanIcon className={className} style={style} />;
}
return <TrashIcon className={className} style={style} />;
@@ -0,0 +1,12 @@
import { createSvgIcon } from "../svg-icon.type";
export const CleanIcon = createSvgIcon((props, ref) => {
return (
<svg ref={ref} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"color="currentColor" fill="none" {...props}>
<path d="m21 3-8 8.5m-3.554-.415c-2.48.952-4.463.789-6.446.003.5 6.443 3.504 8.92 7.509 9.912 0 0 3.017-2.134 3.452-7.193.047-.548.07-.821-.043-1.13-.114-.309-.338-.53-.785-.973-.736-.728-1.103-1.092-1.54-1.184-.437-.09-1.007.128-2.147.565" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M4.5 16.446S7 16.93 9.5 15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M8.5 7.25a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0Z" stroke="currentColor" strokeWidth="2"/>
<path d="M11 4v.1" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
});
+51 -1
View File
@@ -1,6 +1,6 @@
import { LinkMapsModal } from "renderer/components/modal/modal-types/link-maps-modal.component";
import { UnlinkMapsModal } from "renderer/components/modal/modal-types/unlink-maps-modal.component";
import { Subject, Observable, of, lastValueFrom } from "rxjs";
import { Subject, Observable, of, lastValueFrom, Subscription, throwError } from "rxjs";
import { BSVersion } from "shared/bs-version.interface";
import { BsmLocalMapsProgress, BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
import { IpcService } from "./ipc.service";
@@ -13,6 +13,8 @@ import { map, last, catchError } from "rxjs/operators";
import { ProgressionInterface } from "shared/models/progress-bar";
import { FolderLinkState, VersionFolderLinkerService } from "./version-folder-linker.service";
import { SongDetails } from "shared/models/maps";
import { Progression } from "main/helpers/fs.helpers";
import { DeleteDuplicateMapsModal } from "renderer/components/modal/modal-types/delete-duplicate-maps-modal.component";
export class MapsManagerService {
private static instance: MapsManagerService;
@@ -146,6 +148,54 @@ export class MapsManagerService {
});
}
public deleteDuplicateMaps(maps: BsmLocalMap[]): Observable<Progression> {
const hashMap = Object.groupBy(maps, m => m.hash);
const mapsToDelete: BsmLocalMap[] = [];
for (const maps of Object.values(hashMap)) {
if(!Array.isArray(maps)){ continue; }
maps.sort((a, b) => b.path.length - a.path.length).pop(); // The longest paths are most likely to be the duplicate ones (path: '../Awesome maps (copy)')
mapsToDelete.push(...maps);
}
if(mapsToDelete.length === 0){
this.notifications.notifySuccess({ title: "notifications.maps.no-duplicates-maps.title", desc: "notifications.maps.no-duplicates-maps.msg", duration: 4000 });
return of({ current: 0, total: 0 });
}
if(!this.progressBar.require()){
return throwError(() => new Error("Progress bar is required to delete duplicate maps"));
}
return new Observable<Progression>(obs => {
let sub: Subscription;
(async () => {
const modalRes = await this.modal.openModal(DeleteDuplicateMapsModal, { data: { maps: mapsToDelete } });
if(modalRes.exitCode !== ModalExitCode.COMPLETED){
throw new Error("Operation to delete duplicate maps as been canceled by the user");
}
const progress$: Observable<Progression> = this.ipcService.sendV2("delete-maps", mapsToDelete).pipe(map(p => ({ total: p.total, current: p.deleted })));
this.progressBar.show(progress$);
sub = progress$.subscribe(obs);
await lastValueFrom(progress$);
})()
.then(() => this.notifications.notifySuccess({ title: "notifications.maps.duplicates-maps-deleted.title", desc: "notifications.maps.duplicates-maps-deleted.msg", duration: 4000 }))
.catch(e => obs.error(e))
.finally(() => obs.complete());
return () => {
if(sub){
this.progressBar.hide();
sub.unsubscribe();
}
}
})
}
public getMapsInfoFromHashs(hashs: string[]): Observable<SongDetails[]> {
return this.ipcService.sendV2("get-maps-info-from-cache", hashs);
}