diff --git a/package.json b/package.json index 912b2be4e..aa057322f 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "style-loader": "4.0.0", "tasuku": "^2.0.4", "typescript": "5.9.3", + "uqr": "^0.1.3", "victory": "37.3.6", "webpack": "5.104.1", "webpack-cli": "6.0.1", diff --git a/songs.schema.json b/songs.schema.json index 399af5598..01d5df3c5 100644 --- a/songs.schema.json +++ b/songs.schema.json @@ -208,9 +208,6 @@ "jacket": { "type": "string" }, - "author": { - "type": "string" - }, "bpm": { "type": "string", "description": "per-chart BPM range, if one applies" diff --git a/src/assets/i18n.json b/src/assets/i18n.json index 75feeaf84..f35677a99 100644 --- a/src/assets/i18n.json +++ b/src/assets/i18n.json @@ -6,6 +6,19 @@ }, "clearDrawings": "Clear Draws", "clearDrawingsConfirm": "This will clear all songs drawn so far. Confirm?", + "dataImport": { + "title": "Data Import", + "smxEdits": "StepManiaX edits…", + "itgPack": "ITG / StepMania pack…" + }, + "smxImport": { + "detected": "{count, plural, =0 {No codes detected yet} one {# unique code detected} other {# unique codes detected}}", + "lookingUp": "Looking them up now…", + "found": "{count, plural, one {Found info for # chart.} other {Found info for # charts.}}", + "importButton": "{count, plural, =0 {Import} one {Import # edit} other {Import # edits}}", + "imported": "{count, plural, one {Imported # edit chart.} other {Imported # edit charts.}}", + "skipped": "{count, plural, one { # edit was skipped because its song isn't in the StepManiaX data yet.} other { # edits were skipped because their songs aren't in the StepManiaX data yet.}}" + }, "credits": "Credits", "help": "Help", "toggleTheme": "Toggle Theme", @@ -119,6 +132,19 @@ }, "clearDrawings": "全消去", "clearDrawingsConfirm": "これまでに引いた譜面をすべて消去します。よろしいですか?", + "dataImport": { + "title": "データの取り込み", + "smxEdits": "StepManiaX エディット…", + "itgPack": "ITG / StepMania パック…" + }, + "smxImport": { + "detected": "{count, plural, =0 {コードはまだ検出されていません} other {# 件のコードを検出しました}}", + "lookingUp": "照会中…", + "found": "{count, plural, other {# 件の譜面情報が見つかりました。}}", + "importButton": "{count, plural, =0 {取り込む} other {# 件のエディットを取り込む}}", + "imported": "{count, plural, other {# 件のエディット譜面を取り込みました。}}", + "skipped": "{count, plural, other { # 件のエディットは対象曲が StepManiaX データに未登録のためスキップされました。}}" + }, "credits": "クレジット", "help": "使い方 (English)", "toggleTheme": "テーマを切り替える", diff --git a/src/drop-handler.tsx b/src/drop-handler.tsx index 645468543..014fde8a8 100644 --- a/src/drop-handler.tsx +++ b/src/drop-handler.tsx @@ -2,48 +2,48 @@ import { Button, Classes, Dialog, + DialogBody, DialogFooter, FormGroup, Switch, } from "@blueprintjs/core"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { PackWithSongs } from "simfile-parser/browser"; import { useDrawState } from "./draw-state"; +import { useImportUi } from "./state/import-ui"; import { getDataFileFromPack } from "./utils/itg-import"; import { pause } from "./utils/pause"; import { convertErrorToString } from "./utils/error-to-string"; -import { Import } from "@blueprintjs/icons"; +import { FolderOpen, Import } from "@blueprintjs/icons"; +import { SmxEditImport } from "./smx-edit-import"; function loadParserModule() { return import("simfile-parser/browser"); } export function DropHandler() { - const [droppedFolder, setDroppedFolder] = useState( - null, - ); - - const handleClose = useCallback(() => { - setDroppedFolder(null); - }, []); + const setPackSource = useImportUi((s) => s.setPackSource); - const handleDrop = useCallback(async (evt: DragEvent) => { - console.log("handle drop"); - evt.preventDefault(); - if (!evt.dataTransfer) { - return; - } + const handleDrop = useCallback( + async (evt: DragEvent) => { + console.log("handle drop"); + evt.preventDefault(); + if (!evt.dataTransfer) { + return; + } - if (evt.dataTransfer.items.length !== 1) { - console.error("too many items dropped"); - return; - } - try { - setDroppedFolder(evt.dataTransfer.items[0]); - } catch (e) { - console.log(e); - } - }, []); + if (evt.dataTransfer.items.length !== 1) { + console.error("too many items dropped"); + return; + } + try { + setPackSource(evt.dataTransfer.items[0]); + } catch (e) { + console.log(e); + } + }, + [setPackSource], + ); const handleDragOver = useCallback(async (e: Event) => { e.preventDefault(); @@ -61,22 +61,79 @@ export function DropHandler() { }); return ( - + <> + + + + ); } -interface DialogProps { - droppedFolder: DataTransferItem | null; - onSave(this: void): void; - onClose(this: void): void; +/** + * First step of the manual ITG import: explain what to select, then let the user + * pick a pack folder with a native directory picker. The chosen folder flows + * into the same confirm dialog used by drag/drop. + */ +function ItgInstructionsDialog() { + const isOpen = useImportUi((s) => s.itgInstructionsOpen); + const onClose = useImportUi((s) => s.closeItgInstructions); + const setPackSource = useImportUi((s) => s.setPackSource); + const inputRef = useRef(null); + + const handleChange = useCallback(() => { + const input = inputRef.current; + if (input && input.files && input.files.length) { + setPackSource(input); + } + }, [setPackSource]); + + return ( + } + onClose={onClose} + > + +

+ Choose the folder of the pack you'd like to import. This is the + folder that contains one subfolder per song (each with its own{" "} + .sm or .ssc simfile). +

+

+ You can also drag and drop that folder anywhere onto this page to + start the same import. +

+ +
+ + + + + } + /> +
+ ); } function useDataParsing( - droppedFolder: DataTransferItem | null, + packSource: DataTransferItem | HTMLInputElement | null, setTiered: (next: boolean) => void, ) { const [parsedPack, setParsedPack] = useState(null); @@ -84,12 +141,12 @@ function useDataParsing( useEffect(() => { // oxlint-disable-next-line react-hooks-js/set-state-in-effect setParseError(null); - if (!droppedFolder) { + if (!packSource) { setParsedPack(null); return; } loadParserModule() - .then(({ parsePack }) => parsePack(droppedFolder)) + .then(({ parsePack }) => parsePack(packSource)) .then((pack) => { setParsedPack(pack); if ( @@ -107,19 +164,21 @@ function useDataParsing( console.error(rejection); setParseError(convertErrorToString(rejection)); }); - }, [droppedFolder, setTiered]); + }, [packSource, setTiered]); return { parsedPack, parseError, }; } -function ConfirmPackDialog({ droppedFolder, onClose, onSave }: DialogProps) { +function ConfirmPackDialog() { + const packSource = useImportUi((s) => s.packSource); + const clearPackSource = useImportUi((s) => s.clearPackSource); const [tiered, setTiered] = useState(false); const [saving, setSaving] = useState(false); const loadGameData = useDrawState((s) => s.addImportedData); - const { parsedPack, parseError } = useDataParsing(droppedFolder, setTiered); + const { parsedPack, parseError } = useDataParsing(packSource, setTiered); const derivedData = useMemo(() => { if (!parsedPack) { return; @@ -135,8 +194,8 @@ function ConfirmPackDialog({ droppedFolder, onClose, onSave }: DialogProps) { loadGameData(parsedPack.name, derivedData); await pause(500); setSaving(false); - onSave(); - }, [parsedPack, derivedData, loadGameData, onSave]); + clearPackSource(); + }, [parsedPack, derivedData, loadGameData, clearPackSource]); const maybeSkeleton = derivedData ? "" : Classes.SKELETON; @@ -180,9 +239,9 @@ function ConfirmPackDialog({ droppedFolder, onClose, onSave }: DialogProps) { return (
{body}
Import - + } /> diff --git a/src/header.tsx b/src/header.tsx index 290758428..2225d0064 100644 --- a/src/header.tsx +++ b/src/header.tsx @@ -7,7 +7,15 @@ import { Navbar, Popover, } from "@blueprintjs/core"; -import { Trash, InfoSign, Menu as MenuIcon, Help } from "@blueprintjs/icons"; +import { + Trash, + InfoSign, + Menu as MenuIcon, + Help, + Database, + Import, + FolderOpen, +} from "@blueprintjs/icons"; import { useState } from "react"; import { About } from "./about"; import { HeaderControls } from "./controls"; @@ -16,11 +24,14 @@ import { LastUpdate } from "./last-update"; import { ThemeToggle } from "./theme-toggle"; import { DataLoadingSpinner, VersionSelect } from "./version-select"; import { useDrawState } from "./draw-state"; +import { useImportUi } from "./state/import-ui"; export function Header() { const [aboutOpen, setAboutOpen] = useState(false); const clearDrawings = useDrawState((d) => d.clearDrawings); const haveDrawings = useDrawState((d) => !!d.drawings.length); + const openItgInstructions = useImportUi((s) => s.openItgInstructions); + const openSmxEdits = useImportUi((s) => s.openSmxEdits); const { t } = useIntl(); const menu = ( @@ -31,6 +42,21 @@ export function Header() { text={t("clearDrawings")} disabled={!haveDrawings} /> + } + text={t("dataImport.title", undefined, "Data Import")} + > + } + onClick={openSmxEdits} + text={t("dataImport.smxEdits", undefined, "StepManiaX edits…")} + /> + } + onClick={openItgInstructions} + text={t("dataImport.itgPack", undefined, "ITG / StepMania pack…")} + /> + } onClick={() => setAboutOpen(true)} diff --git a/src/models/SongData.ts b/src/models/SongData.ts index 6db8f5def..c019fc3b0 100644 --- a/src/models/SongData.ts +++ b/src/models/SongData.ts @@ -149,7 +149,6 @@ export interface Chart { drawGroup?: number; maxScore?: number; jacket?: string; - author?: string; /** * per-chart BPM range, if one applies */ diff --git a/src/smx-edit-import.tsx b/src/smx-edit-import.tsx new file mode 100644 index 000000000..c669685bf --- /dev/null +++ b/src/smx-edit-import.tsx @@ -0,0 +1,187 @@ +import { + Button, + Callout, + Dialog, + DialogBody, + DialogFooter, + FormGroup, + InputGroup, + TextArea, + Text, +} from "@blueprintjs/core"; +import { Import } from "@blueprintjs/icons"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useDrawState } from "./draw-state"; +import { useIntl } from "./hooks/useIntl"; +import { useImportUi } from "./state/import-ui"; +import type { GameData } from "./models/SongData"; +import { + buildEditDataFile, + fetchEditCharts, + parseEditCodes, + type BuildResult, + type FetchEditsResult, +} from "./utils/smx-edit-import"; +import { convertErrorToString } from "./utils/error-to-string"; +import { toaster } from "./toaster"; + +function loadBaseSmxData(): Promise { + return import(/* webpackChunkName: "songData" */ "./songs/smx.json").then( + (mod) => mod.default as GameData, + ); +} + +const defaultName = "SMX with Edits!"; + +/** + * Owns only the open/closed state of the dialog. The form (and all its state) is + * a child that mounts when opened and unmounts when closed, so its state resets + * implicitly on close — no manual reset logic. + */ +export function SmxEditImport() { + const isOpen = useImportUi((s) => s.smxEditsOpen); + const onClose = useImportUi((s) => s.closeSmxEdits); + return ( + } + onClose={onClose} + > + {isOpen && } + + ); +} + +function EditImportForm({ onClose }: { onClose: (this: void) => void }) { + const { t } = useIntl(); + const addImportedData = useDrawState((s) => s.addImportedData); + + const [text, setText] = useState(""); + const [name, setName] = useState(defaultName); + const [saving, setSaving] = useState(false); + const [fetching, setFetching] = useState(false); + const [error, setError] = useState(null); + const [fetched, setFetched] = useState(null); + + const codes = useMemo(() => parseEditCodes(text), [text]); + // stable primitive so the validation effect only re-runs when the set of + // codes actually changes, not on every keystroke + const codesKey = codes.join(","); + + // Validate against the API automatically (debounced) as the user pastes/edits, + // so the import button is gated on a completed lookup. + useEffect(() => { + if (!codesKey) { + setFetched(null); + setFetching(false); + setError(null); + return; + } + let ignore = false; + setFetching(true); + setError(null); + const handle = setTimeout(() => { + fetchEditCharts(codesKey.split(",")) + .then((res) => { + if (!ignore) setFetched(res); + }) + .catch((e) => { + if (ignore) return; + console.error(e); + setError(convertErrorToString(e)); + setFetched(null); + }) + .finally(() => { + if (!ignore) setFetching(false); + }); + }, 500); + return () => { + ignore = true; + clearTimeout(handle); + }; + }, [codesKey]); + + const handleImport = useCallback(async () => { + if (!fetched) { + return; + } + setError(null); + setSaving(true); + try { + const base = await loadBaseSmxData(); + const result: BuildResult = buildEditDataFile( + base, + fetched.charts, + name.trim() || defaultName, + ); + addImportedData(result.data.i18n.en.name as string, result.data); + const skipped = result.unknownSongs.length + ? t("smxImport.skipped", { count: result.unknownSongs.length }) + : ""; + toaster?.show({ + intent: result.unknownSongs.length ? "warning" : "success", + message: t("smxImport.imported", { count: result.matched }) + skipped, + }); + onClose(); + } catch (e) { + console.error(e); + setError(convertErrorToString(e)); + } finally { + setSaving(false); + } + }, [fetched, name, addImportedData, onClose, t]); + + return ( + <> + + + setName(e.target.value)} /> + + +