diff --git a/assets/jsons/translations/de.json b/assets/jsons/translations/de.json index 6b7bab9e..42ba3b57 100644 --- a/assets/jsons/translations/de.json +++ b/assets/jsons/translations/de.json @@ -45,7 +45,8 @@ "filters-btn": "Filter", "dropdown": { "export-maps": "Maps exportieren", - "delete-maps": "Maps löschen" + "delete-maps": "Maps löschen", + "delete-duplicate-maps": "Duplikate löschen" } }, "tabs": { @@ -496,6 +497,14 @@ "one-click-install": { "success": "Map-Installation abgeschlossen", "error": "Ein Fehler ist während der Installation der Map aufgetreten." + }, + "no-duplicates-maps": { + "title": "Keine Duplikate", + "msg": "Keine Karten wurden gelöscht" + }, + "duplicates-maps-deleted": { + "title": "Duplikate gelöscht", + "msg": "Duplikate wurden gelöscht" } }, "playlists": { @@ -690,6 +699,11 @@ "title": "\"Maps behalten\" kopiert die Maps aus dem geteilten Maps-Ordner in den Ordner der ausgewählten Version. Andernfalls gehen sie verloren." }, "valid-btn": "Verknüpfung aufheben" + }, + "delete-duplicate-maps": { + "title": "Map entfernen?", + "desc": "Nur die Karte \"{map}\" ist ein Duplikat. Bist du sicher, dass du sie löschen möchtest?", + "desc-plural": "Es wurden {nb} doppelte Karten gefunden. Bist du sicher, dass du sie löschen möchtest?" } }, "download-maps": { diff --git a/assets/jsons/translations/en.json b/assets/jsons/translations/en.json index 29c239e9..49cd7bf7 100644 --- a/assets/jsons/translations/en.json +++ b/assets/jsons/translations/en.json @@ -45,7 +45,8 @@ "filters-btn": "Filters", "dropdown": { "export-maps": "Export maps", - "delete-maps": "Delete maps" + "delete-maps": "Delete maps", + "delete-duplicate-maps": "Delete duplicates" } }, "tabs": { @@ -503,6 +504,14 @@ "one-click-install": { "success": "Map installation complete", "error": "An error occurred while installing the map" + }, + "no-duplicates-maps": { + "title": "No Duplicates", + "msg": "No maps were deleted" + }, + "duplicates-maps-deleted": { + "title": "Duplicates Deleted", + "msg": "Duplicates were deleted" } }, "playlists": { @@ -697,6 +706,11 @@ "title": "Keeping maps will copy all maps from the shared folder to the current version after unlinking. Maps will not be lost if this is disabled." }, "valid-btn": "Unlink maps" + }, + "delete-duplicate-maps": { + "title": "Delete maps?", + "desc": "Only the map \"{map}\" is a duplicate. Are you sure you want to delete it?", + "desc-plural": "{nb} duplicate maps have been found. Are you sure you want to delete them?" } }, "download-maps": { diff --git a/assets/jsons/translations/es.json b/assets/jsons/translations/es.json index afc83813..77885485 100644 --- a/assets/jsons/translations/es.json +++ b/assets/jsons/translations/es.json @@ -45,7 +45,8 @@ "filters-btn": "Filtros", "dropdown": { "export-maps": "Exportar mapas", - "delete-maps": "Borrar mapas" + "delete-maps": "Borrar mapas", + "delete-duplicate-maps": "Borrar duplicados" } }, "tabs": { @@ -496,6 +497,14 @@ "one-click-install": { "success": "Instalación del mapa completada", "error": "Se produjo un error durante la instalación del mapa" + }, + "no-duplicates-maps": { + "title": "Sin duplicados", + "msg": "No se eliminó ninguna carta" + }, + "duplicates-maps-deleted": { + "title": "Duplicados eliminados", + "msg": "Se eliminaron los duplicados" } }, "playlists": { @@ -690,6 +699,11 @@ "title": "Mantener los mapas creará una copia de los mapas compartidos para la versión actual. De lo contrario, no se mantendrá ningún mapa para esta versión." }, "valid-btn": "Desvincular mapas" + }, + "delete-duplicate-maps": { + "title": "¿Borrar mapas?", + "desc": "Solo el mapa \"{map}\" está duplicado. ¿Estás seguro de que quieres eliminarlo?", + "desc-plural": "Se han encontrado {nb} mapas duplicados. ¿Estás seguro de que quieres eliminarlos?" } }, "download-maps": { diff --git a/assets/jsons/translations/fr.json b/assets/jsons/translations/fr.json index d2ad1e11..9142162d 100644 --- a/assets/jsons/translations/fr.json +++ b/assets/jsons/translations/fr.json @@ -45,7 +45,8 @@ "filters-btn": "Filtres", "dropdown": { "export-maps": "Exporter les maps", - "delete-maps": "Supprimer les maps" + "delete-maps": "Supprimer les maps", + "delete-duplicate-maps": "Supprimer les doublons" } }, "tabs": { @@ -496,6 +497,14 @@ "one-click-install": { "success": "Installation de la map terminée", "error": "Une erreur s'est produite lors de l'installation de la map" + }, + "no-duplicates-maps": { + "title": "Pas de doublons", + "msg": "Aucune carte n'a été supprimée" + }, + "duplicates-maps-deleted": { + "title": "Doublons supprimés", + "msg": "Les doublons ont été supprimés" } }, "playlists": { @@ -691,6 +700,11 @@ "title": "Conserver les maps créera une copie des maps partagées pour la version actuelle. Dans le cas contraire, aucune map ne sera conservée pour cette version." }, "valid-btn": "Délier les maps" + }, + "delete-duplicate-maps": { + "title": "Supprimer les maps ?", + "desc": "Seule la map \"{map}\" est en double. Es-tu sûr de vouloir la supprimer ?", + "desc-plural": "{nb} maps en double ont été trouvées. Es-tu sûr de vouloir les supprimer ?" } }, "download-maps": { diff --git a/assets/jsons/translations/ja.json b/assets/jsons/translations/ja.json index 6265ab68..dd792379 100644 --- a/assets/jsons/translations/ja.json +++ b/assets/jsons/translations/ja.json @@ -45,7 +45,8 @@ "filters-btn": "絞り込み", "dropdown": { "export-maps": "マップをエクスポート", - "delete-maps": "マップを削除" + "delete-maps": "マップを削除", + "delete-duplicate-maps": " 重複を削除" } }, "tabs": { @@ -496,6 +497,14 @@ "one-click-install": { "success": "マップのインストール完了", "error": "マップのインストール中にエラーが発生しました" + }, + "no-duplicates-maps": { + "title": "重複なし", + "msg": "マップは削除されませんでした" + }, + "duplicates-maps-deleted": { + "title": "重複削除", + "msg": "重複が削除されました" } }, "playlists": { @@ -690,6 +699,11 @@ "title": "マップを保持は、リンクを解除した後、共有フォルダから現在のバージョンにすべてのマップをコピーします。これを無効をしても共有マップは失われません。" }, "valid-btn": "マップのリンクを解除" + }, + "delete-duplicate-maps": { + "title": "マップを削除しますか?", + "desc": "マップ「{map}」のみが重複しています。削除してもよろしいですか?", + "desc-plural": "{nb}個の重複したマップが見つかりました。削除してもよろしいですか?" } }, "download-maps": { diff --git a/assets/jsons/translations/ru.json b/assets/jsons/translations/ru.json index 93b97afd..c4187b72 100644 --- a/assets/jsons/translations/ru.json +++ b/assets/jsons/translations/ru.json @@ -45,7 +45,8 @@ "filters-btn": "Фильтр", "dropdown": { "export-maps": "Экспорт карт", - "delete-maps": "Удалить карты" + "delete-maps": "Удалить карты", + "delete-duplicate-maps": "Удалить дубликаты" } }, "tabs": { @@ -496,6 +497,14 @@ "one-click-install": { "success": "Карта установлена", "error": "Ошибка установки карты" + }, + "no-duplicates-maps": { + "title": "Нет дубликатов", + "msg": "Ни одна карта не была удалена" + }, + "duplicates-maps-deleted": { + "title": "Дубликаты удалены", + "msg": "Дубликаты были удалены" } }, "playlists": { @@ -690,6 +699,11 @@ "title": "Общие карты будут скопированы в папку карт этой версии. Карты не будут потеряны, если это не выбрано." }, "valid-btn": "Отвязать карты" + }, + "delete-duplicate-maps": { + "title": "Удалить карту?", + "desc": "Только карта \"{map}\" является дубликатом. Вы уверены, что хотите ее удалить?", + "desc-plural": "Найдено {nb} дубликатов карт. Вы уверены, что хотите их удалить?" } }, "download-maps": { diff --git a/assets/jsons/translations/zh-tw.json b/assets/jsons/translations/zh-tw.json index 04b8bf7d..edaace1e 100644 --- a/assets/jsons/translations/zh-tw.json +++ b/assets/jsons/translations/zh-tw.json @@ -45,7 +45,8 @@ "filters-btn": "篩選", "dropdown": { "export-maps": "導出譜面", - "delete-maps": "刪除譜面" + "delete-maps": "刪除譜面", + "delete-duplicate-maps": "刪除重複項" } }, "tabs": { @@ -496,6 +497,14 @@ "one-click-install": { "success": "譜面安裝完成", "error": "安裝譜面時發生錯誤" + }, + "no-duplicates-maps": { + "title": "沒有重複", + "msg": "沒有刪除地圖" + }, + "duplicates-maps-deleted": { + "title": "重複已刪除", + "msg": "重複已刪除" } }, "playlists": { @@ -690,6 +699,11 @@ "title": "保留譜面將會在取消關聯後把共享文件夾的所有譜面複製到當前版本。如果此項被禁用,譜面也不會遺失。" }, "valid-btn": "取消關聯譜面" + }, + "delete-duplicate-maps": { + "title": "刪除地圖", + "desc": "只有地圖「{map}」是重複的。你確定要刪除它嗎?", + "desc-plural": "發現了 {nb} 個重複的地圖。你確定要刪除它們嗎?" } }, "download-maps": { diff --git a/assets/jsons/translations/zh.json b/assets/jsons/translations/zh.json index c7747ae9..3c9b8d07 100644 --- a/assets/jsons/translations/zh.json +++ b/assets/jsons/translations/zh.json @@ -45,7 +45,8 @@ "filters-btn": "筛选", "dropdown": { "export-maps": "导出谱面", - "delete-maps": "删除谱面" + "delete-maps": "删除谱面", + "delete-duplicate-maps": "删除重复项" } }, "tabs": { @@ -496,6 +497,14 @@ "one-click-install": { "success": "谱面安装完成", "error": "安装谱面时发生错误" + }, + "no-duplicates-maps": { + "title": "没有重复", + "msg": "没有删除地图" + }, + "duplicates-maps-deleted": { + "title": "重复已删除", + "msg": "重复已删除" } }, "playlists": { @@ -690,6 +699,11 @@ "title": "保留谱面将会在取消关联后把共享文件夹的所有谱面复制到当前版本。如果此项被禁用,谱面也不会丢失。" }, "valid-btn": "取消关联谱面" + }, + "delete-duplicate-maps": { + "title": "删除地图", + "desc": "只有地图 \"{map}\" 是重复的。你确定要删除它吗?", + "desc-plural": "发现了 {nb} 个重复的地图。你确定要删除它们吗?" } }, "download-maps": { 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 4460be9b..a431e56c 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 @@ -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(); + const mapsRef = useRef(); const playlistsRef = useRef(); const [mapFilter, setMapFilter] = useState({}); @@ -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?.() }, ]; } 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 f98e6659..cb0cb16e 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 @@ -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(({ version, className, filter, search, linkedState, isActive }, forwardRef) => { +export type LocalMapsListPanelRef = { + deleteMaps: () => void; + exportMaps: () => void; + removeDuplicates: () => void; +} + +export const LocalMapsListPanel = forwardRef(({ version, className, filter, search, linkedState, isActive }, forwardRef) => { const mapsManager = useService(MapsManagerService); const mapsDownloader = useService(MapsDownloaderService); @@ -63,6 +69,12 @@ export const LocalMapsListPanel = forwardRef(({ 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]); diff --git a/src/renderer/components/modal/modal-types/delete-duplicate-maps-modal.component.tsx b/src/renderer/components/modal/modal-types/delete-duplicate-maps-modal.component.tsx new file mode 100644 index 00000000..8ec8d6d8 --- /dev/null +++ b/src/renderer/components/modal/modal-types/delete-duplicate-maps-modal.component.tsx @@ -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 = ({ resolver, options: {data : { maps }}}) => { + + const t = useTranslation(); + const multiple = useConstant(() => maps.length > 1); + + return ( +
+

{t("modals.maps-actions.delete-duplicate-maps.title")}

+ +

{ + 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}` }) + }

+
+ resolver({ exitCode: ModalExitCode.CANCELED })} withBar={false} text="misc.cancel" /> + resolver({ exitCode: ModalExitCode.COMPLETED })} withBar={false} text="misc.delete" /> +
+ + ); +}; diff --git a/src/renderer/components/shared/bsm-dropdown-button.component.tsx b/src/renderer/components/shared/bsm-dropdown-button.component.tsx index 805c5041..a2141c03 100644 --- a/src/renderer/components/shared/bsm-dropdown-button.component.tsx +++ b/src/renderer/components/shared/bsm-dropdown-button.component.tsx @@ -79,7 +79,7 @@ export const BsmDropdownButton = forwardRef(({ className, classNames, buttonColo {items?.map( i => i && ( -
i.onClick?.()} className="flex w-full px-3 py-2 hover:backdrop-brightness-150"> +
{ setExpanded(() => false); i.onClick?.()}} className="flex w-full px-3 py-2 hover:backdrop-brightness-150"> {i.icon && } {t(i.text)}
diff --git a/src/renderer/components/svgs/bsm-icon.component.tsx b/src/renderer/components/svgs/bsm-icon.component.tsx index fad7b614..4e60186f 100644 --- a/src/renderer/components/svgs/bsm-icon.component.tsx +++ b/src/renderer/components/svgs/bsm-icon.component.tsx @@ -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 ; } + + if(icon === "clean") { + return ; + } + return ; diff --git a/src/renderer/components/svgs/icons/clean-icon.component.tsx b/src/renderer/components/svgs/icons/clean-icon.component.tsx new file mode 100644 index 00000000..f7430d42 --- /dev/null +++ b/src/renderer/components/svgs/icons/clean-icon.component.tsx @@ -0,0 +1,12 @@ +import { createSvgIcon } from "../svg-icon.type"; + +export const CleanIcon = createSvgIcon((props, ref) => { + return ( + + + + + + + ); +}); diff --git a/src/renderer/services/maps-manager.service.ts b/src/renderer/services/maps-manager.service.ts index a45cff27..850e5ba8 100644 --- a/src/renderer/services/maps-manager.service.ts +++ b/src/renderer/services/maps-manager.service.ts @@ -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 { + 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); }