diff --git a/.eslintrc.js b/.eslintrc.js index 09892909..9577afe0 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -59,6 +59,7 @@ module.exports = { "react/function-component-definition": "off", "jsx-a11y/control-has-associated-label": "off", "react/button-has-type": "off", + "no-labels": ["error", { "allowLoop": true }] }, parserOptions: { ecmaVersion: 2020, diff --git a/assets/jsons/translations/de.json b/assets/jsons/translations/de.json index 42ba3b57..17b6b627 100644 --- a/assets/jsons/translations/de.json +++ b/assets/jsons/translations/de.json @@ -65,6 +65,10 @@ "empty-maps": { "text": "Keine Maps", "button": "Maps herunterladen" + }, + "drop-zone": { + "text": "Deine Karten importieren", + "subtext": "Lasse deine ZIP-Dateien hier fallen, um deine Karten zu importieren" } } } @@ -505,6 +509,18 @@ "duplicates-maps-deleted": { "title": "Duplikate gelöscht", "msg": "Duplikate wurden gelöscht" + }, + "import-map": { + "titles": { + "success": "Kartenimport abgeschlossen", + "error": "Beim Kartenimport ist ein Fehler aufgetreten" + }, + "msgs": { + "success": "Die Karten wurden erfolgreich importiert.", + "some-success": "Einige Karten wurden erfolgreich importiert.", + "only-accept-zip": "Nur Zip-Dateien werden unterstützt.", + "invalid-zip": "Die Zip-Datei(en) enthalten keine Karten." + } } }, "playlists": { diff --git a/assets/jsons/translations/en.json b/assets/jsons/translations/en.json index 49cd7bf7..5aa23418 100644 --- a/assets/jsons/translations/en.json +++ b/assets/jsons/translations/en.json @@ -65,7 +65,12 @@ "empty-maps": { "text": "No maps", "button": "Download maps" + }, + "drop-zone": { + "text": "Import your maps", + "subtext": "Drop your zip files here to import your maps" } + } } }, @@ -512,6 +517,18 @@ "duplicates-maps-deleted": { "title": "Duplicates Deleted", "msg": "Duplicates were deleted" + }, + "import-map": { + "titles": { + "success": "Maps import completed", + "error": "An error occurred during the maps import" + }, + "msgs": { + "success": "Maps successfully imported.", + "some-success": "Some maps were successfully imported.", + "only-accept-zip": "Only zip files are supported.", + "invalid-zip": "The zip file(s) do not contain any maps." + } } }, "playlists": { diff --git a/assets/jsons/translations/es.json b/assets/jsons/translations/es.json index 77885485..96b55364 100644 --- a/assets/jsons/translations/es.json +++ b/assets/jsons/translations/es.json @@ -65,6 +65,10 @@ "empty-maps": { "text": "No hay mapas", "button": "Descargar mapas" + }, + "drop-zone": { + "text": "Importar tus mapas", + "subtext": "Suelta tus archivos zip aquí para importar tus mapas" } } } @@ -505,6 +509,18 @@ "duplicates-maps-deleted": { "title": "Duplicados eliminados", "msg": "Se eliminaron los duplicados" + }, + "import-map": { + "titles": { + "success": "Importación de mapas completada", + "error": "Ocurrió un error durante la importación de mapas" + }, + "msgs": { + "success": "Los mapas se han importado con éxito.", + "some-success": "Algunos mapas se han importado con éxito.", + "only-accept-zip": "Solo se admiten archivos zip.", + "invalid-zip": "El o los archivos zip no contienen mapas." + } } }, "playlists": { diff --git a/assets/jsons/translations/fr.json b/assets/jsons/translations/fr.json index 9142162d..836dc4ae 100644 --- a/assets/jsons/translations/fr.json +++ b/assets/jsons/translations/fr.json @@ -65,6 +65,10 @@ "empty-maps": { "text": "Aucune map", "button": "Télécharger des maps" + }, + "drop-zone": { + "text": "Importer vos maps", + "subtext": "Déposez vos fichiers zip ici pour importer vos maps" } } } @@ -505,6 +509,18 @@ "duplicates-maps-deleted": { "title": "Doublons supprimés", "msg": "Les doublons ont été supprimés" + }, + "import-map": { + "titles": { + "success": "Importation des maps terminée", + "error": "Une erreur est survenue lors de l'importation des maps" + }, + "msgs": { + "success": "Les maps ont été importées avec succès.", + "some-success": "Certaines maps ont été importées avec succès.", + "only-accept-zip": "Seules les fichiers zip sont pris en charge.", + "invalid-zip": "Le ou les fichiers zip ne contiennent aucune maps." + } } }, "playlists": { diff --git a/assets/jsons/translations/ja.json b/assets/jsons/translations/ja.json index dd792379..f2c6672e 100644 --- a/assets/jsons/translations/ja.json +++ b/assets/jsons/translations/ja.json @@ -65,6 +65,10 @@ "empty-maps": { "text": "マップがありません", "button": "マップをダウンロードする" + }, + "drop-zone": { + "text": "マップをインポート", + "subtext": "ZIPファイルをここにドロップしてマップをインポート" } } } @@ -505,6 +509,18 @@ "duplicates-maps-deleted": { "title": "重複削除", "msg": "重複が削除されました" + }, + "import-map": { + "titles": { + "success": "マップのインポートが完了しました", + "error": "マップのインポート中にエラーが発生しました" + }, + "msgs": { + "success": "マップが正常にインポートされました。", + "some-success": "一部のマップが正常にインポートされました。", + "only-accept-zip": "zipファイルのみがサポートされています。", + "invalid-zip": "zipファイルにマップが含まれていません。" + } } }, "playlists": { diff --git a/assets/jsons/translations/ru.json b/assets/jsons/translations/ru.json index c4187b72..4ac52cb2 100644 --- a/assets/jsons/translations/ru.json +++ b/assets/jsons/translations/ru.json @@ -65,6 +65,10 @@ "empty-maps": { "text": "Нет карт", "button": "Скачать карты" + }, + "drop-zone": { + "text": "Импортируйте свои карты", + "subtext": "Перетащите сюда файлы ZIP, чтобы импортировать свои карты" } } } @@ -505,6 +509,18 @@ "duplicates-maps-deleted": { "title": "Дубликаты удалены", "msg": "Дубликаты были удалены" + }, + "import-map": { + "titles": { + "success": "Импорт карт завершен", + "error": "Произошла ошибка при импорте карт" + }, + "msgs": { + "success": "Карты были успешно импортированы.", + "some-success": "Некоторые карты успешно импортированы.", + "only-accept-zip": "Поддерживаются только zip-файлы.", + "invalid-zip": "В zip-файле(ах) нет карт." + } } }, "playlists": { diff --git a/assets/jsons/translations/zh-tw.json b/assets/jsons/translations/zh-tw.json index edaace1e..99c3206e 100644 --- a/assets/jsons/translations/zh-tw.json +++ b/assets/jsons/translations/zh-tw.json @@ -65,6 +65,10 @@ "empty-maps": { "text": "無譜面", "button": "下載譜面" + }, + "drop-zone": { + "text": "導入您的地圖", + "subtext": "將ZIP文件拖放到此處以導入您的地圖" } } } @@ -505,6 +509,18 @@ "duplicates-maps-deleted": { "title": "重複已刪除", "msg": "重複已刪除" + }, + "import-map": { + "titles": { + "success": "地圖匯入完成", + "error": "地圖匯入時發生錯誤" + }, + "msgs": { + "success": "地圖已成功匯入。", + "some-success": "部分地圖已成功匯入。", + "only-accept-zip": "僅支援zip檔案。", + "invalid-zip": "zip檔案中沒有地圖。" + } } }, "playlists": { diff --git a/assets/jsons/translations/zh.json b/assets/jsons/translations/zh.json index 3c9b8d07..b266d84a 100644 --- a/assets/jsons/translations/zh.json +++ b/assets/jsons/translations/zh.json @@ -65,6 +65,10 @@ "empty-maps": { "text": "无谱面", "button": "下载谱面" + }, + "drop-zone": { + "text": "导入您的地图", + "subtext": "将ZIP文件拖放到此处以导入您的地图" } } } @@ -505,6 +509,18 @@ "duplicates-maps-deleted": { "title": "重复已删除", "msg": "重复已删除" + }, + "import-map": { + "titles": { + "success": "地图导入完成", + "error": "导入地图时发生错误" + }, + "msgs": { + "success": "地图已成功导入。", + "some-success": "部分地图已成功导入。", + "only-accept-zip": "仅支持zip文件。", + "invalid-zip": "zip文件中没有地图。" + } } }, "playlists": { diff --git a/src/main/ipcs/bs-maps-ipcs.ts b/src/main/ipcs/bs-maps-ipcs.ts index 039a4465..2ab16487 100644 --- a/src/main/ipcs/bs-maps-ipcs.ts +++ b/src/main/ipcs/bs-maps-ipcs.ts @@ -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-maps", async (args, reply) => { + const maps = LocalMapsManagerService.getInstance(); + reply(maps.importMaps(args.paths, args.version)); +}) + +ipc.on("bs-maps.download-map", async (args, reply) => { const maps = LocalMapsManagerService.getInstance(); reply(from(maps.downloadMap(args.map, args.version))); }); diff --git a/src/main/preload.ts b/src/main/preload.ts index a4958a7f..30c5795f 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -1,4 +1,4 @@ -import { contextBridge, ipcRenderer, IpcRendererEvent } from "electron"; +import { contextBridge, ipcRenderer, IpcRendererEvent, webUtils } from "electron"; import { ProviderPlatform } from "shared/models/provider-platform.enum"; const sep = process.platform === ProviderPlatform.WINDOWS ? "\\" : "/"; @@ -30,5 +30,8 @@ contextBridge.exposeInMainWorld("electron", { join: (...args: string[]): string => { return args.join(sep); } + }, + webUtils: { + getPathForFile: webUtils.getPathForFile } }); diff --git a/src/main/services/additional-content/maps/local-maps-manager.service.ts b/src/main/services/additional-content/maps/local-maps-manager.service.ts index d186f953..8036141f 100644 --- a/src/main/services/additional-content/maps/local-maps-manager.service.ts +++ b/src/main/services/additional-content/maps/local-maps-manager.service.ts @@ -28,8 +28,8 @@ import { sToMs } from "../../../../shared/helpers/time.helpers"; 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 { CustomError } from "shared/models/exceptions/custom-error.class"; +import { tryit } from "shared/helpers/error.helpers"; export class LocalMapsManagerService { private static instance: LocalMapsManagerService; @@ -326,6 +326,110 @@ export class LocalMapsManagerService { return null; } + public importMaps(zipPaths: string[], version?: BSVersion): Observable> { + return new Observable>(obs => { + let unsubscribed = false; + let progress: Progression = { total: 0, current: 0 }; + let nbImportedMaps = 0; + + (async () => { + const mapsPath = await this.getMapsFolderPath(version); + for(const zipPath of zipPaths) { + + if(unsubscribed) { + log.info("Maps importation from zip has been cancelled"); + return; + } + + progress = { total: 0, current: 0 }; + obs.next(progress); // reset progress for each zip + + if(!pathExistsSync(zipPath)) { continue; } + + const zip = new StreamZip.async({ file: zipPath }); + const { result: zipEntries, error } = await tryit(() => zip.entries()); + const zipEntriesValues = Object.values(zipEntries); + + if(error) { + log.error("Could not read zip entries", zipPath, error); + await zip.close(); + continue; + } + + const mapsFolders = zipEntriesValues.reduce((acc, entry) => { + if(!/(^|\/)[Ii]nfo\.dat$/.test(entry.name)){ return acc; } + acc.push(path.dirname(entry.name)); + return acc; + }, []); + + if(mapsFolders.length === 0) { + log.warn("No maps \"info.dat\" found in zip", zipPath); + } + + progress.total = mapsFolders.length; + obs.next(progress); + + for(const folder of mapsFolders) { + + if(unsubscribed) { + log.info("Maps importation from zip has been cancelled"); + await zip.close(); + return; + } + + const isRoot = folder === "."; + const dest = isRoot ? path.join(mapsPath, path.basename(zipPath, ".zip")) : path.join(mapsPath, folder); + + let extract: () => Promise; + + if(isRoot){ + const entries = zipEntriesValues.filter(entry => entry.isFile && path.dirname(entry.name) === "."); + extract = async () => { + await Promise.all(entries.map(entry => { + log.info("Extracting", `"${entry.name}"`, "from", `"${zipPath}"`, "into", `"${path.join(dest, path.basename(entry.name))}"`); + return zip.extract(entry.name, path.join(dest, path.basename(entry.name))); + })); + return this.loadMapInfoFromPath(dest); + }; + } else { + extract = async () => { + log.info("Extracting", `"${folder}"`, "from", `"${zipPath}"`, "into", `"${mapsPath}"`); + await zip.extract(folder, dest); + return this.loadMapInfoFromPath(dest); + } + } + + await ensureDir(dest); + const { result: bsmMap, error } = await tryit(extract); + + if(error) { + log.error("Could not extract map", zipPath, folder, mapsPath, error); + continue; + } + + nbImportedMaps++; + progress.current++; + progress.data = bsmMap; + obs.next(progress); + } + + await zip.close(); + } + })() + .then(() => { + if(!nbImportedMaps){ + throw new CustomError("No \"Info.dat\" file located in any of the zip files", "invalid-zip"); + } + return log.info("Successfully imported", nbImportedMaps, "maps from", zipPaths.length, "zips"); + }) + .catch(e => obs.error(e)) + .finally(() => obs.complete()); + + return () => { + unsubscribed = true; + }; + }); + } public async downloadMap(map: BsvMapDetail, version?: BSVersion): Promise { if (!map.versions.at(0).hash) { diff --git a/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx b/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx index a431e56c..9aa29c5a 100644 --- a/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx @@ -14,7 +14,7 @@ import { BsmButton } from "../shared/bsm-button.component"; import { MapIcon } from "../svgs/icons/map-icon.component"; import { PlaylistIcon } from "../svgs/icons/playlist-icon.component"; import { useObservable } from "renderer/hooks/use-observable.hook"; -import { BehaviorSubject, of } from "rxjs"; +import { BehaviorSubject, lastValueFrom, of } from "rxjs"; import { LocalPlaylistsListPanel, LocalPlaylistsListRef } from "./playlists/local-playlists-list-panel.component"; import { PlaylistsManagerService } from "renderer/services/playlists-manager.service"; import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface"; @@ -23,6 +23,9 @@ 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"; +import { logRenderError } from "renderer"; type Props = { readonly version?: BSVersion; @@ -42,6 +45,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 +105,27 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) { return playlistsManager.unlinkVersion(version); } + const handleFileDrop = async (files: FileList) => { + const zipMimeTypes = ["application/zip", "application/zip-compressed", "application/x-zip-compressed"]; + const paths: string[] = Array.from(files).reduce((acc, file) => { + if (zipMimeTypes.includes(file.type)) { + acc.push(window.electron.webUtils.getPathForFile(file)); + } + return acc; + }, []); + + if (paths.length === 0) { + notifications.notifyError({ + title: "notifications.maps.import-map.titles.error", + desc: "notifications.maps.import-map.msgs.only-accept-zip" + }); + return; + } + + const import$ = mapsManager.importMaps(paths, version); + return lastValueFrom(import$).catch(logRenderError); + } + const dropDownItems = ((): DropDownItem[] => { if (tabIndex === 0) { return [ @@ -175,7 +200,17 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) { ]} > - + { + handleFileDrop(event.dataTransfer.files); + }} + text={t("pages.version-viewer.maps.tabs.maps.drop-zone.text")} + subtext={t("pages.version-viewer.maps.tabs.maps.drop-zone.subtext")} + > + + + diff --git a/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx b/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx index 75248ea6..f4a2a642 100644 --- a/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx @@ -5,7 +5,7 @@ import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface"; 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"; +import { bufferTime, last, tap } from "rxjs/operators"; import { useTranslation } from "renderer/hooks/use-translation.hook"; import BeatConflict from "../../../../../assets/images/apngs/beat-conflict.png"; import { BsmImage } from "../../shared/bsm-image.component"; @@ -104,19 +104,24 @@ export const LocalMapsListPanel = forwardRef(({ ve useEffect(() => { - let sub: Subscription; + const subs: Subscription[] = []; if(isActiveOnce){ - sub = mapsDownloader.lastDownloadedMap$.subscribe({ next: ({map, version: targetVersion}) => { + subs.push(mapsDownloader.lastDownloadedMap$.subscribe({ next: ({map, version: targetVersion}) => { if (!equal(targetVersion, version)) { return; } setMaps((maps$.value ? [map, ...maps$.value] : [map])); - }}); + }})); + + subs.push(mapsManager.$onMapImported(version).pipe(bufferTime(500)).subscribe({ next: newMaps => { + const maps = (maps$.value ?? []).filter(map => !newMaps.some(newMap => newMap.path === map.path)); + setMaps([...newMaps, ...maps]); + }})); } return () => { - sub?.unsubscribe(); + subs.forEach(s => s.unsubscribe()); } }, [isActiveOnce, version]) diff --git a/src/renderer/components/shared/dropzone.component.tsx b/src/renderer/components/shared/dropzone.component.tsx new file mode 100644 index 00000000..898db61b --- /dev/null +++ b/src/renderer/components/shared/dropzone.component.tsx @@ -0,0 +1,89 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { DragEvent, ReactNode, useCallback, useState } from "react"; +import { BsmImage } from "./bsm-image.component"; +import BeatImpatient from "../../../../assets/images/apngs/beat-impatient.png"; +import { useThemeColor } from "renderer/hooks/use-theme-color.hook"; +import Color from "color"; + +type Props = Readonly<{ + children?: ReactNode; + className?: string; + text?: string; + subtext?: string; + onDrop?: (event: DragEvent) => void; +}>; + +// Supports drop and drop functionality for files. +export function Dropzone({ + children, + className, + text, + subtext, + onDrop, +}: Props) { + + const [dragCount, setDragCount] = useState(0); + const [dropZoneDragging, setDropZoneDragging] = useState(false); + const themeColor = useThemeColor("first-color"); + const color = new Color(themeColor).lighten(.25).saturate(.8).hex(); + + const handleDrop = useCallback((event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setDragCount(0); + if (onDrop) onDrop(event); + }, []); + + const onDragOver = useCallback((event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + }, []); + + const onDragEnter = useCallback((event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setDragCount(prev => prev + 1); + }, []); + + const onDragLeave = useCallback((event: DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setDragCount(prev => prev - 1); + }, []); + + return ( +
+ + {dragCount ? ( +
+ + +
+
setDropZoneDragging(true)} + onDragLeave={() => setDropZoneDragging(false)} + className="relative size-full z-[1] border-dashed border-3 rounded-md flex flex-col gap-1 justify-center items-center" + style={{ borderColor: color }} + > +
+ + {text} + {subtext} +
+
+ +
+ ): undefined} + + {children} +
+ ); +} diff --git a/src/renderer/preload.d.ts b/src/renderer/preload.d.ts index 51f2568b..23e2b0ac 100644 --- a/src/renderer/preload.d.ts +++ b/src/renderer/preload.d.ts @@ -1,3 +1,5 @@ +import { webUtils } from "electron"; + declare global { interface Window { electron: { @@ -13,6 +15,9 @@ declare global { basename: (path: string) => string; join: (...args: string[]) => string; }; + webUtils: { + getPathForFile: typeof webUtils.getPathForFile; + }; }; } } diff --git a/src/renderer/services/maps-downloader.service.ts b/src/renderer/services/maps-downloader.service.ts index a8af459c..32218cb7 100644 --- a/src/renderer/services/maps-downloader.service.ts +++ b/src/renderer/services/maps-downloader.service.ts @@ -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> { diff --git a/src/renderer/services/maps-manager.service.ts b/src/renderer/services/maps-manager.service.ts index 850e5ba8..5494e785 100644 --- a/src/renderer/services/maps-manager.service.ts +++ b/src/renderer/services/maps-manager.service.ts @@ -9,12 +9,13 @@ import { DeleteMapsModal } from "renderer/components/modal/modal-types/delete-ma import { ProgressBarService } from "./progress-bar.service"; import { NotificationService } from "./notification.service"; import { ConfigurationService } from "./configuration.service"; -import { map, last, catchError } from "rxjs/operators"; +import { map, last, catchError, filter, distinctUntilChanged } 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"; +import equal from "fast-deep-equal"; export class MapsManagerService { private static instance: MapsManagerService; @@ -39,6 +40,8 @@ export class MapsManagerService { private readonly lastLinkedVersion$: Subject = new Subject(); private readonly lastUnlinkedVersion$: Subject = new Subject(); + private readonly lastImportedMap$: Subject<{ version: BSVersion, map: BsmLocalMap }> = new Subject(); + private constructor() { this.ipcService = IpcService.getInstance(); this.modal = ModalService.getInstance(); @@ -196,6 +199,64 @@ export class MapsManagerService { }) } + public importMaps(paths: string[], version?: BSVersion): Observable> { + if(!this.progressBar.require()){ + return throwError(() => new Error("Another operation is already in progress (importMaps)")); + } + + return new Observable>(obs => { + const subs: Subscription[] = []; + (async () => { + const import$ = this.ipcService.sendV2("bs-maps.import-maps", { paths, version }); + + this.progressBar.show(import$.pipe( + catchError(() => of()), + map(progress => ({ + progression: (progress.current / progress.total) * 100, + label: progress.data?.songDetails?.name + } as ProgressionInterface)) + )); + + subs.push(import$.subscribe(obs)); + + subs.push(import$.pipe(catchError(() => of()), map(progress => progress?.data), filter(Boolean), distinctUntilChanged((a, b) => equal(a, b))).subscribe({ next: map => { + this.lastImportedMap$.next({ version, map }); + }})); + + return lastValueFrom(import$); + })() + .then(progress => { + if (progress.current === progress.total) { + this.notifications.notifySuccess({ + title: "notifications.maps.import-map.titles.success", + desc: "notifications.maps.import-map.msgs.success", + }); + } else if (progress.current > 0) { + this.notifications.notifySuccess({ + title: "notifications.maps.import-map.titles.success", + desc: "notifications.maps.import-map.msgs.some-success", + }); + } + }).catch(err => { + this.notifications.notifyError({ + title: "notifications.maps.import-map.titles.error", + desc: ["invalid-zip"].includes(err?.code) + ? `notifications.maps.import-map.msgs.${err.code}` + : "misc.unknown" + }); + obs.error(err); + }) + .finally(() => obs.complete()); + + return () => { + if(subs.length){ + this.progressBar.hide(); + subs.forEach(s => s.unsubscribe()); + } + } + }); + } + public getMapsInfoFromHashs(hashs: string[]): Observable { return this.ipcService.sendV2("get-maps-info-from-cache", hashs); } @@ -220,6 +281,10 @@ export class MapsManagerService { return this.lastUnlinkedVersion$.asObservable(); } + public $onMapImported(version: BSVersion): Observable { + return this.lastImportedMap$.pipe(filter(newMap => equal(newMap.version, version)), map(m => m.map)); + } + public $mapsFolderLinkState(version: BSVersion): Observable { return this.linker.$folderLinkedState(version, MapsManagerService.RELATIVE_MAPS_FOLDER); } diff --git a/src/shared/models/ipc/ipc-routes.ts b/src/shared/models/ipc/ipc-routes.ts index 4b56d801..53538214 100644 --- a/src/shared/models/ipc/ipc-routes.ts +++ b/src/shared/models/ipc/ipc-routes.ts @@ -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-maps": { request: { paths: string[]; version: BSVersion | undefined }, response: Progression }; + "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 };