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, 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"; import { ModalExitCode, ModalService } from "./modale.service"; import { DeleteMapsModal } from "renderer/components/modal/modal-types/delete-maps-modal.component"; import { ProgressBarService } from "./progress-bar.service"; import { NotificationService } from "./notification.service"; import { ConfigurationService } from "./configuration.service"; 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; public static getInstance(): MapsManagerService { if (!MapsManagerService.instance) { MapsManagerService.instance = new MapsManagerService(); } return MapsManagerService.instance; } public static readonly REMEMBER_CHOICE_DELETE_MAP_KEY = "not-confirm-delete-map"; public static readonly RELATIVE_MAPS_FOLDER = window.electron.path.join("Beat Saber_Data", "CustomLevels"); private readonly ipcService: IpcService; private readonly modal: ModalService; private readonly progressBar: ProgressBarService; private readonly notifications: NotificationService; private readonly config: ConfigurationService; private readonly linker: VersionFolderLinkerService; private readonly lastLinkedVersion$: Subject = new Subject(); private readonly lastUnlinkedVersion$: Subject = new Subject(); 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(); } public getMaps(version?: BSVersion): Observable { return this.ipcService.sendV2("load-version-maps", version, { loaded: 0, total: 0, maps: [] }); } public async versionHaveMapsLinked(version: BSVersion): Promise { return this.linker.isVersionFolderLinked(version, MapsManagerService.RELATIVE_MAPS_FOLDER).toPromise(); } public async linkVersion(version: BSVersion): Promise { const modalRes = await this.modal.openModal(LinkMapsModal); if (modalRes.exitCode !== ModalExitCode.COMPLETED) { return Promise.resolve(false); } return this.linker.linkVersionFolder({ version, relativeFolder: MapsManagerService.RELATIVE_MAPS_FOLDER, options: { keepContents: !!modalRes.data }, }); } public async unlinkVersion(version: BSVersion): Promise { const modalRes = await this.modal.openModal(UnlinkMapsModal); if (modalRes.exitCode !== ModalExitCode.COMPLETED) { return Promise.resolve(false); } return this.linker.unlinkVersionFolder({ version, relativeFolder: MapsManagerService.RELATIVE_MAPS_FOLDER, options: { keepContents: !!modalRes.data }, }); } public async deleteMaps(maps: BsmLocalMap[], version?: BSVersion): Promise { const versionLinked = !version || (await this.versionHaveMapsLinked(version)); const askModal = maps.length > 1 || !this.config.get(MapsManagerService.REMEMBER_CHOICE_DELETE_MAP_KEY); if (askModal) { const modalRes = await this.modal.openModal(DeleteMapsModal, {data: { linked: versionLinked, maps }}); if (modalRes.exitCode !== ModalExitCode.COMPLETED) { return false; } } const showProgressBar = this.progressBar.require(); const progress$ = this.ipcService.sendV2("delete-maps", maps ).pipe(map(progress => (progress.deleted / progress.total) * 100)); if (showProgressBar) { this.progressBar.show(progress$); } progress$.toPromise().finally(() => this.progressBar.hide()); return progress$ .pipe( last(), map(() => true), catchError(() => of(false)) ) .toPromise() .catch(() => false); } public async exportMaps(version: BSVersion, maps?: BsmLocalMap[]): Promise { if (!this.progressBar.require()) { return; } const resFile = await lastValueFrom(this.ipcService.sendV2("save-file", { filename: version ? `${version.BSVersion}Maps` : "Maps", filters: [{ name: "zip", extensions: ["zip"] }]})).catch(() => null as string); if (!resFile) { return; } const exportProgress$: Observable = this.ipcService.sendV2("export-maps", { version, maps, outPath: resFile }).pipe( map(p => { return { progression: (p.current / p.total) * 100, label: `${p.current} / ${p.total}` } as ProgressionInterface; }) ); this.progressBar.show(exportProgress$); await exportProgress$ .toPromise() .catch(e => { this.notifications.notifyError({ title: "notifications.types.error", desc: e.message }); }) .then(() => { // TODO TRANSLATE this.notifications.notifySuccess({ title: "Export terminé 🎉", duration: 3000 }); this.progressBar.complete(); this.progressBar.hide(); }); } public deleteDuplicateMaps(maps: BsmLocalMap[]): Observable { 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(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 = 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 { return this.ipcService.sendV2("get-maps-info-from-cache", hashs); } public async isDeepLinksEnabled(): Promise { return lastValueFrom(this.ipcService.sendV2("is-map-deep-links-enabled")); } public async enableDeepLink(): Promise { return lastValueFrom(this.ipcService.sendV2("register-maps-deep-link")); } public async disableDeepLink(): Promise { return lastValueFrom(this.ipcService.sendV2("unregister-maps-deep-link")); } public get versionLinked$(): Observable { return this.lastLinkedVersion$.asObservable(); } public get versionUnlinked$(): Observable { return this.lastUnlinkedVersion$.asObservable(); } public $mapsFolderLinkState(version: BSVersion): Observable { return this.linker.$folderLinkedState(version, MapsManagerService.RELATIVE_MAPS_FOLDER); } }