diff --git a/MaiChartManager/Controllers/Charts/ChartController.cs b/MaiChartManager/Controllers/Charts/ChartController.cs index 91a726a9..add7dbf0 100644 --- a/MaiChartManager/Controllers/Charts/ChartController.cs +++ b/MaiChartManager/Controllers/Charts/ChartController.cs @@ -1,10 +1,11 @@ -using Microsoft.AspNetCore.Mvc; +using MaiChartManager.Services; +using Microsoft.AspNetCore.Mvc; namespace MaiChartManager.Controllers.Charts; [ApiController] [Route("MaiChartManagerServlet/[action]Api/{assetDir}/{id:int}/{level:int}")] -public class ChartController(StaticSettings settings, ILogger logger) : ControllerBase +public class ChartController(StaticSettings settings, ILogger logger, MaidataImportService importService) : ControllerBase { [HttpPost] public void EditChartLevel(int id, int level, [FromBody] int value, string assetDir) @@ -95,15 +96,53 @@ public void EditChartEnable(int id, int level, [FromBody] bool value, string ass } [HttpPost] - public void ReplaceChart(int id, int level, IFormFile file, string assetDir) + public ImportChartResult ReplaceChart(int id, int level, IFormFile file, string assetDir, + [FromForm] ShiftMethod? shift) { var music = settings.GetMusic(id, assetDir); - if (music == null || file == null) return; - var targetChart = music.Charts[level]; - targetChart.Path = $"{id:000000}_0{level}.ma2"; - using var stream = System.IO.File.Open(Path.Combine(StaticSettings.StreamingAssets, assetDir, "music", $"music{id:000000}", targetChart.Path), FileMode.Create); - file.CopyTo(stream); - targetChart.Problems.Clear(); - stream.Close(); + if (music == null || file == null) return new ImportChartResult([new ImportChartMessage("文件上传失败", MessageLevel.Fatal)], true); + if (file.FileName.EndsWith(".ma2")) + { + var targetChart = music.Charts[level]; + targetChart.Path = $"{id:000000}_0{level}.ma2"; + using var stream = System.IO.File.Open(Path.Combine(StaticSettings.StreamingAssets, assetDir, "music", $"music{id:000000}", targetChart.Path), FileMode.Create); + file.CopyTo(stream); + targetChart.Problems.Clear(); + + // 检查新谱面ma2的音符数量是否有变化,如果有修正之 + string fileContent; + using (var reader = new StreamReader(file.OpenReadStream())) + { + fileContent = reader.ReadToEnd(); + } + var newMaxNotes = MaidataImportService.ParseTNumAllFromMa2(fileContent); + if (newMaxNotes != 0 && targetChart.MaxNotes != newMaxNotes) + { + targetChart.MaxNotes = newMaxNotes; + } + music.Save(); + + return new ImportChartResult([], false); + } + else if (file.FileName.EndsWith("maidata.txt")) + { + if (level != -1) throw new NotImplementedException("使用maidata时暂不支持只替换单个难度谱面,只能同时替换全部的"); + // 通过此前的谱面的定数是否为0,判断是否需要ignoreLevelNum + bool ignoreLevelNum = true; + foreach (var chart in music.Charts) + { + if (music.Id < 100000 && chart.Enable && chart.Level > 0) ignoreLevelNum = false; + } + var importResult = importService.ImportMaidata(music, file, (ShiftMethod)shift, ignoreLevelNum, false, true); + if (!importResult.Fatal) + { + music.Save(); + music.Refresh(); + } + + return importResult; + } + // 正常来说是不会进到这里的,因为前端已经对文件名做了校验了,所以这个报错用户正常来说是看不到的,就不做i18n了。 + else return new ImportChartResult([new ImportChartMessage("不支持的文件格式!", MessageLevel.Fatal)], true); } } \ No newline at end of file diff --git a/MaiChartManager/Controllers/Charts/ImportChartController.cs b/MaiChartManager/Controllers/Charts/ImportChartController.cs index 20bf25a0..b3221004 100644 --- a/MaiChartManager/Controllers/Charts/ImportChartController.cs +++ b/MaiChartManager/Controllers/Charts/ImportChartController.cs @@ -1,6 +1,5 @@ using System.Text.RegularExpressions; -using MaiChartManager.Utils; -using MaiLib; +using MaiChartManager.Services; using Microsoft.AspNetCore.Mvc; using SimaiSharp; using SimaiSharp.Structures; @@ -9,152 +8,9 @@ namespace MaiChartManager.Controllers.Charts; [ApiController] [Route("MaiChartManagerServlet/[action]Api")] -public partial class ImportChartController(StaticSettings settings, ILogger logger) : ControllerBase +public class ImportChartController(StaticSettings settings, ILogger logger, + MaidataImportService importService) : ControllerBase { - public enum MessageLevel - { - Info, - Warning, - Fatal - } - - [GeneratedRegex(@"^\([\d\.]+\)")] - private static partial Regex BpmTagRegex(); - - [GeneratedRegex(@"\{([\d\.]+)\}")] - private static partial Regex MeasureTagRegex(); - - private static string Add1Bar(string maidata) - { - var regex = BpmTagRegex(); - var bpm = regex.Match(maidata).Value; - // 这里使用 {4},,,, 而不是 {1}, 因为要是谱面一开始根本没有写 {x} 的话,默认是 {4}。要是用了 {1}, 会覆盖默认的 {4} - return string.Concat(bpm, "{4},,,,", maidata.AsSpan(bpm.Length)); - } - - [GeneratedRegex(@"(\d){")] - private static partial Regex SimaiError1(); - - [GeneratedRegex(@"\[(\d+)-(\d+)]")] - private static partial Regex SimaiError2(); - - [GeneratedRegex(@"(\d)\(")] - private static partial Regex SimaiError3(); - - [GeneratedRegex(@",[csbx\.\{\}],")] - private static partial Regex SimaiError4(); - - [GeneratedRegex(@"(\d)qx(\d)")] - private static partial Regex SimaiError5(); - - private static string FixChartSimaiSharp(string chart) - { - chart = chart.Replace("\n", "").Replace("\r", "").Replace("{{", "{").Replace("}}", "}"); - chart = SimaiError1().Replace(chart, "$1,{"); - chart = SimaiError3().Replace(chart, "$1,("); - chart = SimaiError2().Replace(chart, "[$1:$2]"); - chart = SimaiError4().Replace(chart, ",,"); - chart = SimaiError5().Replace(chart, "$1xq$2"); - return chart; - } - - private MaiChart TryParseChartSimaiSharp(string chartText, int level, List errors) - { - chartText = chartText.ReplaceLineEndings(); - try - { - return SimaiConvert.Deserialize(chartText); - } - catch (Exception e) - { - logger.LogWarning(e, "SimaiSharp 无法直接解析谱面"); - } - - try - { - var chart = SimaiConvert.Deserialize(FixChartSimaiSharp(chartText)); - errors.Add(new ImportChartMessage(string.Format(Locale.ChartFixedMinorErrors, level), MessageLevel.Info)); - return chart; - } - catch (Exception e) - { - logger.LogWarning(e, "SimaiSharp 无法解析修复后谱面"); - throw; - } - } - - [NonAction] - private Chart? TryParseChart(string chartText, MaiChart? simaiSharpChart, int level, List errors) - { - chartText = chartText.ReplaceLineEndings(); - try - { - return new SimaiParser().ChartOfToken(new SimaiTokenizer().TokensFromText(chartText)); - } - catch (Exception e) - { - logger.LogWarning(e, "无法直接解析谱面"); - } - - try - { - var normalizedText = SimaiCommentRegex().Replace(chartText, ""); - normalizedText = SimaiCommentRegex2().Replace(normalizedText, ""); - return new SimaiParser().ChartOfToken(new SimaiTokenizer().TokensFromText(normalizedText)); - } - catch (Exception) - { - // ignored - } - - try - { - var normalizedText = FixChartSimaiSharp(chartText) - // 不飞的星星 - .Replace("-?", "?-"); - // 移除注释 - normalizedText = SimaiCommentRegex().Replace(normalizedText, ""); - normalizedText = SimaiCommentRegex2().Replace(normalizedText, ""); - var tokens = new SimaiTokenizer().TokensFromText(normalizedText); - for (var i = 0; i < tokens.Length; i++) - { - if (tokens[i].Contains("]b")) - { - tokens[i] = tokens[i].Replace("]b", "]").Replace("[", "b["); - } - } - - var maiLibChart = new SimaiParser().ChartOfToken(tokens); - errors.Add(new ImportChartMessage(string.Format(Locale.ChartFixedMinorErrors, level), MessageLevel.Info)); - return maiLibChart; - } - catch (Exception e) - { - errors.Add(new ImportChartMessage(string.Format(Locale.ChartMaiLibParseError, level, MaiLibErrMsgRegex().Replace(e.Message, "")), MessageLevel.Warning)); - logger.LogWarning(e, "无法在手动修正错误后解析谱面"); - } - - if (simaiSharpChart is null) - { - return null; - } - - try - { - var reSerialized = SimaiConvert.Serialize(simaiSharpChart); - reSerialized = reSerialized.Replace("{0}", "{4}"); - var maiLibChart = new SimaiParser().ChartOfToken(new SimaiTokenizer().TokensFromText(reSerialized)); - errors.Add(new ImportChartMessage(string.Format(Locale.ChartSimaiSharpFallback, level), MessageLevel.Warning)); - return maiLibChart; - } - catch (Exception e) - { - SentrySdk.CaptureException(e); - errors.Add(new ImportChartMessage(string.Format(Locale.ChartParseFailed, level), MessageLevel.Fatal)); - return null; - } - } - private static float getFirstBarFromChart(MaiChart chart) { var bpm = chart.TimingChanges[0].tempo; @@ -166,36 +22,21 @@ private static float getFirstBarFromChart(MaiChart chart) return 60 / bpm * 4; } -// v1.1.2 新增 - public enum ShiftMethod - { - // 之前的办法,把第一押准确的对在第二小节的开头 - // noShiftChart = false, padding = MusicPadding - Legacy, - - // 简单粗暴的办法,不需要让库来平移谱面,解决各种平移不兼容问题 - // 之前修库都白修了其实 - // bar - 休止符的长度 如果是正数,那就直接在前面加一个小节的空白 - // 判断一下 > 0.1 好了,因为 < 0.1 秒可以忽略不计 - // noShiftChart = true, padding = (bar - 休止符的长度 > 0.1 ? bar - first : 0) - // bar - 休止符的长度 = MusicPadding + first - Bar, - - // 把音频裁掉 &first 秒,完全不用动谱面 - // noShiftChart = true, padding = -first - NoShift - } - - public record ImportChartMessage(string Message, MessageLevel Level); - public record ImportChartCheckResult(bool Accept, IEnumerable Errors, float MusicPadding, bool IsDx, string? Title, float first, float bar); [HttpPost] - public ImportChartCheckResult ImportChartCheck(IFormFile file) + public ImportChartCheckResult ImportChartCheck(IFormFile file, [FromForm] bool isReplacement = false) { var errors = new List(); var fatal = false; + if (isReplacement) + { + // 替换谱面的操作也需要检查的过程,但检查的逻辑和导入谱面时可以说是一模一样的,故直接共用逻辑 + // 唯一的区别是给用户一个警告,明确说明直接替换谱面功能的适用范围 + errors.Add(new ImportChartMessage(Locale.NotesReplacementWarning, MessageLevel.Warning)); + } + try { var kvps = new SimaiFile(file.OpenReadStream()).ToKeyValuePairs(); @@ -279,7 +120,7 @@ public ImportChartCheckResult ImportChartCheck(IFormFile file) foreach (var kvp in allChartText) { var chartText = kvp.Value; - var measures = MeasureTagRegex().Matches(chartText); + var measures = MaidataImportService.MeasureTagRegex().Matches(chartText); foreach (Match measure in measures) { if (!float.TryParse(measure.Groups[1].Value, out var measureValue)) continue; @@ -293,10 +134,10 @@ public ImportChartCheckResult ImportChartCheck(IFormFile file) try { - var chart = TryParseChartSimaiSharp(chartText, kvp.Key, errors); - paddings.Add(CalcMusicPadding(chart, first)); + var chart = importService.TryParseChartSimaiSharp(chartText, kvp.Key, errors); + paddings.Add(MaidataImportService.CalcMusicPadding(chart, first)); - var candidate = TryParseChart(chartText, chart, kvp.Key, errors); + var candidate = importService.TryParseChart(chartText, chart, kvp.Key, errors); if (candidate is null) throw new Exception(Locale.ChartParseGenericError); isDx = isDx || candidate.IsDxChart; } @@ -313,7 +154,7 @@ public ImportChartCheckResult ImportChartCheck(IFormFile file) var padding = paddings.Max(); // 计算 bar - var bar = getFirstBarFromChart(TryParseChartSimaiSharp(allChartText.First().Value, allChartText.First().Key, errors)); + var bar = getFirstBarFromChart(importService.TryParseChartSimaiSharp(allChartText.First().Value, allChartText.First().Key, errors)); return new ImportChartCheckResult(!fatal, errors, padding, isDx, title, first, bar); } @@ -325,36 +166,7 @@ public ImportChartCheckResult ImportChartCheck(IFormFile file) return new ImportChartCheckResult(!fatal, errors, 0, false, "", 0, 0); } } - - public static float CalcMusicPadding(MaiChart chart, float first) - { - // TimingChanges 对应的是所有的 {int} - var bpm = chart.TimingChanges[0].tempo; - // 一小节多长 - var bar = 60 / bpm * 4; - - // 第一押什么时候出来 - var firstTiming = chart.NoteCollections[0].time + first; - return bar - firstTiming; - } - - public record ImportChartResult(IEnumerable Errors, bool Fatal); - - private record AllChartsEntry(string chartText, MaiChart simaiSharpChart); - - [GeneratedRegex(@"\|\|.*$", RegexOptions.Multiline)] - private static partial Regex SimaiCommentRegex(); - - /* - * 根据[simai文档](https://w.atwiki.jp/simai/pages/1002.html),井号如果出现在[]或{}内是合法语法,并非注释。 - * 因此在尝试匹配注释时,应该排除掉#前面有未闭合的[或{的情况。 - */ - [GeneratedRegex(@"(? 100000; - var errors = new List(); var music = settings.GetMusic(id, assetDir); - var kvps = new SimaiFile(file.OpenReadStream()).ToKeyValuePairs(); - var maiData = new Dictionary(); - foreach (var (key, value) in kvps) - { - maiData[key] = value; - } - - var allCharts = new Dictionary(); - for (var i = 2; i < 9; i++) - { - if (!string.IsNullOrWhiteSpace(maiData.GetValueOrDefault($"inote_{i}"))) - { - allCharts.Add(i, new AllChartsEntry(maiData[$"inote_{i}"], TryParseChartSimaiSharp(maiData[$"inote_{i}"], i, errors))); - } - } - - if (!string.IsNullOrWhiteSpace(maiData.GetValueOrDefault("inote_0"))) - { - allCharts.Add(0, new AllChartsEntry(maiData["inote_0"], TryParseChartSimaiSharp(maiData["inote_0"], 0, errors))); - } - - float.TryParse(maiData.GetValueOrDefault("first"), out var first); - // Mai 的歌曲是从两帧后开始播放的 - first -= 1 / 30f; - - var paddings = allCharts.Values.Select(chart => CalcMusicPadding(chart.simaiSharpChart, first)).ToList(); - // 音频前面被增加了多少 - var audioPadding = paddings.Max(); // bar - firstTiming = bar - 谱面前面休止符的时间 - &first - var shouldAddBar = false; - float chartPadding; - switch (shift) + var importMaidataResult = importService.ImportMaidata(music, file, shift, ignoreLevelNum, debug); + if (!importMaidataResult.Fatal) { - case ShiftMethod.Legacy: - chartPadding = audioPadding + first; - break; - case ShiftMethod.Bar when audioPadding + first > 0.1: - shouldAddBar = true; - chartPadding = 0f; - break; - default: - chartPadding = 0f; - break; - } - - if (shouldAddBar) - { - foreach (var (level, chart) in allCharts) - { - var newText = Add1Bar(chart.chartText); - allCharts[level] = new AllChartsEntry(newText, TryParseChartSimaiSharp(newText, level, errors)); - } + music.AddVersionId = addVersionId; + music.GenreId = genreId; + music.Version = version; + music.Save(); + music.Refresh(); } - foreach (var (level, chart) in allCharts) - { - // 宴会场只导入第一个谱面 - if (isUtage && music.Charts[0].Enable) break; - - // var levelPadding = CalcMusicPadding(chart, first); - var bpm = chart.simaiSharpChart.TimingChanges[0].tempo; - music.Bpm = bpm; - // 一个小节多少秒 - var bar = 60 / bpm * 4; - - // 我们要让这个谱面真正的内容(忽略 first)延后多少 - // levelPadding 似乎不需要算,因为每个谱面真正的内容都是从同一个地方开始 - // 所以只要在前面加上 audioPadding + first 时间的休止符 - // 最早出音符的那个谱面的第一押之前一定是 1bar(小节)的休止符 - // |_| levelPadding - // |_______| audioPadding - // |________________| bar - // |________________| bar - // |-------|---|----|-----|----- - // | | | | | 这个谱面的第一押 - // | | | | 可能是另一个谱面难度的第一押,firstTiming,它可能导致 audioPadding > levelPadding - // | | |____| 这一段是休止符 - // | | | 每个谱面真正的内容都是从这里开始 - // | |___| first skip 掉的部分 - // | | 原先音频的开头 - // | 加了 padding 的音频开头 - - # region 设定 targetLevel - - var targetLevel = level - 2; - - // 处理非标准难度 - if (level is > 6 or < 1) - { - // 分给 3 4 0 - if (!music.Charts[3].Enable) - { - targetLevel = 3; - } - else if (!music.Charts[4].Enable) - { - targetLevel = 4; - } - else if (!music.Charts[0].Enable) - { - targetLevel = 0; - } - else - { - continue; - } - } - - if (isUtage) targetLevel = 0; - - # endregion - - var targetChart = music.Charts[targetLevel]; - targetChart.Path = $"{id:000000}_0{targetLevel}.ma2"; - var levelNumStr = maiData.GetValueOrDefault($"lv_{level}"); - if (!string.IsNullOrWhiteSpace(levelNumStr)) - { - levelNumStr = levelNumStr.Replace("+", ".7"); - } - - float.TryParse(levelNumStr, out var levelNum); - targetChart.LevelId = MaiUtils.GetLevelId((int)(levelNum * 10)); - // 忽略定数 - if (!ignoreLevelNum) - { - targetChart.Level = (int)Math.Floor(levelNum); - targetChart.LevelDecimal = (int)Math.Floor(levelNum * 10 % 10); - } - - targetChart.Designer = maiData.GetValueOrDefault($"des_{level}") ?? maiData.GetValueOrDefault("des") ?? ""; - var maiLibChart = TryParseChart(chart.chartText, chart.simaiSharpChart, level, errors); - if (maiLibChart is null) - { - return new ImportChartResult(errors, true); - } - - var originalConverted = maiLibChart.Compose(ChartEnum.ChartVersion.Ma2_104); - - if (debug) - { - System.IO.File.WriteAllText(Path.Combine(Path.GetDirectoryName(music.FilePath), targetChart.Path + ".afterSimaiSharp.txt"), SimaiConvert.Serialize(chart.simaiSharpChart)); - System.IO.File.WriteAllText(Path.Combine(Path.GetDirectoryName(music.FilePath), targetChart.Path + ".preShift.ma2"), originalConverted); - System.IO.File.WriteAllText(Path.Combine(Path.GetDirectoryName(music.FilePath), targetChart.Path + ".preShift.txt"), maiLibChart.Compose(ChartEnum.ChartVersion.SimaiFes)); - } - - if (chartPadding != 0) - { - try - { - maiLibChart.ShiftByOffset((int)Math.Round(chartPadding / bar * maiLibChart.Definition)); - } - catch (Exception e) - { - SentrySdk.CaptureEvent(new SentryEvent(e) - { - Message = Locale.ChartShiftByOffsetError - }); - errors.Add(new ImportChartMessage(Locale.ChartShiftError, MessageLevel.Fatal)); - return new ImportChartResult(errors, true); - } - } - - var shiftedConverted = maiLibChart.Compose(ChartEnum.ChartVersion.Ma2_104); - - if (shiftedConverted.Split('\n').Length != originalConverted.Split('\n').Length) - { - errors.Add(new ImportChartMessage(Locale.ChartNotesMissing, MessageLevel.Warning)); - logger.LogWarning("BUG! shiftedConverted: {shiftedLen}, originalConverted: {originalLen}", shiftedConverted.Split('\n').Length, originalConverted.Split('\n').Length); - } - - // Just use T_NUM_ALL value in ma2 file - targetChart.MaxNotes = ParseTNumAllFromMa2(shiftedConverted); - // Fallback to maiLibChart if T_NUM_ALL not found - if (targetChart.MaxNotes == 0) targetChart.MaxNotes = maiLibChart.AllNoteNum; - - System.IO.File.WriteAllText(Path.Combine(Path.GetDirectoryName(music.FilePath), targetChart.Path), shiftedConverted); - if (debug) - { - System.IO.File.WriteAllText(Path.Combine(Path.GetDirectoryName(music.FilePath), targetChart.Path + ".afterShift.txt"), maiLibChart.Compose(ChartEnum.ChartVersion.SimaiFes)); - } - - targetChart.Enable = true; - } - - music.Name = maiData["title"]; - music.Artist = maiData.GetValueOrDefault("artist") ?? ""; - music.AddVersionId = addVersionId; - music.GenreId = genreId; - music.Version = version; - float wholebpm; - if (float.TryParse(maiData.GetValueOrDefault("wholebpm"), out wholebpm)) - music.Bpm = wholebpm; - music.Save(); - music.Refresh(); - return new ImportChartResult(errors, false); - } - - - private static int ParseTNumAllFromMa2(string ma2Content) - { - var lines = ma2Content.Split('\n'); - // 从后往前读取,因为 T_NUM_ALL 在文件最后 - for (int i = lines.Length - 1; i >= 0; i--) - { - var trimmedLine = lines[i].Trim(); - if (trimmedLine.StartsWith("T_NUM_ALL", StringComparison.OrdinalIgnoreCase)) - { - var parts = trimmedLine.Split(new char[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 2 && int.TryParse(parts[1], out int tNumAll)) - { - return tNumAll; - } - } - } - // Fallback to 0 in case - return 0; + return importMaidataResult; } } \ No newline at end of file diff --git a/MaiChartManager/Front/src/client/apiGen.ts b/MaiChartManager/Front/src/client/apiGen.ts index c0aa133d..442fbf58 100644 --- a/MaiChartManager/Front/src/client/apiGen.ts +++ b/MaiChartManager/Front/src/client/apiGen.ts @@ -266,6 +266,7 @@ export interface MusicXmlWithABJacket { subLockType?: number; disable?: boolean; longMusic?: boolean; + shiftMethod?: string | null; charts?: Chart[] | null; assetBundleJacket?: string | null; pseudoAssetBundleJacket?: string | null; @@ -965,14 +966,16 @@ export class Api extends HttpClient - this.request({ + this.request({ path: `/MaiChartManagerServlet/ReplaceChartApi/${assetDir}/${id}/${level}`, method: "POST", body: data, type: ContentType.FormData, + format: "json", ...params, }), @@ -1236,6 +1239,8 @@ export class Api extends HttpClient diff --git a/MaiChartManager/Front/src/components/DragDropDispatcher/ReplaceChartModal.tsx b/MaiChartManager/Front/src/components/DragDropDispatcher/ReplaceChartModal.tsx index a0cbf59f..cf1adcf0 100644 --- a/MaiChartManager/Front/src/components/DragDropDispatcher/ReplaceChartModal.tsx +++ b/MaiChartManager/Front/src/components/DragDropDispatcher/ReplaceChartModal.tsx @@ -1,61 +1,142 @@ import { t } from '@/locales'; import { globalCapture, selectedADir, selectedLevel, selectedMusic, selectMusicId, updateMusicList } from '@/store/refs'; -import { NButton, NFlex, NModal, useMessage } from 'naive-ui'; -import { defineComponent, PropType, ref, computed, watch, shallowRef } from 'vue'; +import { NButton, NFlex, NModal, useDialog, useMessage } from 'naive-ui'; +import { computed, defineComponent, ref, shallowRef } from 'vue'; import JacketBox from '../JacketBox'; -import { DIFFICULTY, LEVEL_COLOR } from '@/consts'; +import { DIFFICULTY } from '@/consts'; import api from '@/client/api'; +import CheckingModal from "@/components/ImportCreateChartButton/ImportChartButton/CheckingModal"; +import LevelTagsDisplay from "@/components/LevelTagsDisplay"; +import { Chart, ImportChartCheckResult, ImportChartResult, ShiftMethod } from "@/client/apiGen"; +import ImportAlert from "@/components/ImportCreateChartButton/ImportChartButton/ImportAlert"; +import { defaultTempOptions, ImportChartMessageEx, TempOptions } from "@/components/ImportCreateChartButton/ImportChartButton/types"; +import ShiftModeSelector from "@/components/ImportCreateChartButton/ImportChartButton/ShiftModeSelector"; -export const replaceChartFileHandle = shallowRef(null); +// noinspection JSUnusedLocalSymbols +export let prepareReplaceChart = async (fileHandle?: FileSystemFileHandle) => { +} export default defineComponent({ - // props: { - // }, - setup(props, { emit }) { - const message = useMessage(); + setup() { + const dialog = useDialog(); + + const checking = ref(false); + const fileHandle = shallowRef(null); + const show = ref<"" | "ma2" | "maidata" | "failed">(""); + + const apiResp = ref(null) + const checkErrors = computed(()=>{ + return apiResp.value?.errors?.map(it=>({...it, name: selectedMusic.value!.name!})) ?? [] + }) + const tempOption = ref({...defaultTempOptions}) + + // 注:本功能的逻辑是,如果选择的是ma2文件,则只替换指定难度的谱面;如果选择的是maidata,则替换整首歌的所有难度。 + prepareReplaceChart = async (fHandle?: FileSystemFileHandle) => { + if (!fHandle) { + [fHandle] = await window.showOpenFilePicker({ + id: 'chart', + startIn: 'downloads', + types: [ + { + description: t('music.edit.supportedFileTypes'), + accept: { + "application/x-supported": [".ma2", ".txt"], // 没办法限定只匹配maidata.txt,就只好先把一切txt都作为匹配 + }, + }, + ], + }); + } + if (!fHandle) return; // 用户未选择文件 + fileHandle.value = fHandle + + const name = fHandle.name; + // 对maidata.txt和ma2分类讨论,前者执行ImportCheck + if (name === "maidata.txt") { + try { + checking.value = true; + const file = await fHandle.getFile(); + const r = (await api.ImportChartCheck({file, isReplacement: true})).data; + if (!checking.value) return; // 说明检查期间用户点击了关闭按钮、取消了操作。则不再执行后续流程。 + + apiResp.value = r; + if (selectedMusic.value?.shiftMethod) { // 说明是新版导入的谱面、ShiftMethod已经被写入XML了。此时锁定ShiftMethod选项,不准用户自己选择 + tempOption.value = {shift: selectedMusic.value.shiftMethod as ShiftMethod, shiftLocked: true}; + } + show.value = "maidata"; + } finally { + checking.value = false; + } + } else if (name.endsWith(".ma2")) { + show.value = "ma2" + } else { + dialog.error({title: t('error.unsupportedFileType'), content: t('music.edit.notValidChartFile')}) + } + } const replaceChart = async () => { - if (!replaceChartFileHandle.value) return; + if (!fileHandle.value) return; try { - const file = await replaceChartFileHandle.value.getFile(); - replaceChartFileHandle.value = null; - await api.ReplaceChart(selectMusicId.value, selectedLevel.value, selectedADir.value, { file }); - message.success(t('music.edit.replaceChartSuccess')); - await updateMusicList(); + const file = await fileHandle.value.getFile(); + fileHandle.value = null; + const level = show.value === "maidata" ? -1 : selectedLevel.value; + show.value = ""; + const result = (await api.ReplaceChart(selectMusicId.value, level, selectedADir.value, { file, shift: tempOption.value.shift })).data; + if (!result.fatal) { + dialog.success({title: t('music.edit.replaceChartSuccess')}); + } else { + apiResp.value = result; // 用于在失败时显示错误信息 + show.value = "failed"; + } + await updateMusicList(true); } catch (error) { globalCapture(error, t('music.edit.replaceChartFailed')); console.error(error); } } - return () => replaceChartFileHandle.value = null} - >{{ - default: () =>
- {t('music.edit.replaceChartConfirm', { level: DIFFICULTY[selectedLevel.value!] })} -
{replaceChartFileHandle.value?.name}
-
-
- -
-
#{selectMusicId.value}
-
{selectedMusic.value!.name}
-
-
- {selectedMusic.value!.charts![selectedLevel.value!]?.level}.{selectedMusic.value!.charts![selectedLevel.value!]?.levelDecimal} -
+ const chartsForDisplayLevel = computed(()=>{ + const result: (Chart | undefined)[] = [...selectedMusic.value!.charts!] + if (show.value === "ma2") { // 此时只显示所选择的难度,其他难度不要显示 + for (let i=0; i
+ {{ + default: () =>
+ {show.value === "ma2" && <> + {t('music.edit.replaceChartConfirm', { level: DIFFICULTY[selectedLevel.value!] })} +
{fileHandle.value?.name}
+
+ } + {(show.value === "maidata" || show.value === "failed") && } + {show.value !== "failed" &&
+ +
+
#{selectMusicId.value}
+
{selectedMusic.value!.name}
+
-
-
-
, - footer: () => - replaceChartFileHandle.value = null}>{t('common.cancel')} - {t('common.confirm')} - - }}; +
} + {show.value === "maidata" &&
+ +
{t('music.edit.replaceChartShiftModeHint')}
+
} +
, + footer: () => + show.value = ""}>{show.value !== "failed" ? t('common.cancel') : t('common.close')} + {show.value !== "failed" && {t('common.confirm')}} + + }} + checking.value=false} /> +
; }, }); diff --git a/MaiChartManager/Front/src/components/DragDropDispatcher/index.tsx b/MaiChartManager/Front/src/components/DragDropDispatcher/index.tsx index 86e0d7e6..3cb9fb5a 100644 --- a/MaiChartManager/Front/src/components/DragDropDispatcher/index.tsx +++ b/MaiChartManager/Front/src/components/DragDropDispatcher/index.tsx @@ -5,7 +5,7 @@ import { uploadFlow as uploadFlowMovie } from '@/components/MusicEdit/SetMovieBu import { uploadFlow as uploadFlowAcbAwb } from '@/components/MusicEdit/AcbAwb'; import { selectedADir, selectedMusic } from '@/store/refs'; import { upload as uploadJacket } from '@/components/JacketBox'; -import ReplaceChartModal, { replaceChartFileHandle } from './ReplaceChartModal'; +import ReplaceChartModal, { prepareReplaceChart } from './ReplaceChartModal'; import AquaMaiManualInstaller, { setManualInstallAquaMai } from './AquaMaiManualInstaller'; export const mainDivRef = shallowRef(); @@ -48,8 +48,8 @@ export default defineComponent({ else if (file.kind === 'file' && (firstType.startsWith('image/') || file.name.endsWith('.jpeg') || file.name.endsWith('.jpg') || file.name.endsWith('.png'))) { uploadJacket(file); } - else if (file.kind === 'file' && file.name.endsWith('.ma2')) { - replaceChartFileHandle.value = file; + else if (file.kind === 'file' && (file.name.endsWith('.ma2') || file.name == "maidata.txt")) { + prepareReplaceChart(file); } } } diff --git a/MaiChartManager/Front/src/components/ImportCreateChartButton/ImportChartButton/ErrorDisplayIdInput.tsx b/MaiChartManager/Front/src/components/ImportCreateChartButton/ImportChartButton/ErrorDisplayIdInput.tsx index ab304095..c2b3c22e 100644 --- a/MaiChartManager/Front/src/components/ImportCreateChartButton/ImportChartButton/ErrorDisplayIdInput.tsx +++ b/MaiChartManager/Front/src/components/ImportCreateChartButton/ImportChartButton/ErrorDisplayIdInput.tsx @@ -9,6 +9,8 @@ import VersionInput from "@/components/VersionInput"; import { UTAGE_GENRE } from "@/consts"; import MusicIdConflictNotifier from "@/components/MusicIdConflictNotifier"; import { useI18n } from 'vue-i18n'; +import ImportAlert from "@/components/ImportCreateChartButton/ImportChartButton/ImportAlert"; +import ShiftModeSelector from "@/components/ImportCreateChartButton/ImportChartButton/ShiftModeSelector"; export default defineComponent({ props: { @@ -41,49 +43,7 @@ export default defineComponent({ v-model:show={show.value} >{{ default: () => - - - { - props.errors.map((error, i) => { - if ('first' in error) { - if (error.padding > 0 && props.tempOptions.shift === ShiftMethod.Legacy) { - return {t('chart.import.addPadding', {padding: error.padding.toFixed(3)})} - } - if (error.padding < 0 && props.tempOptions.shift === ShiftMethod.Legacy) { - return {t('chart.import.trimPadding', {padding: (-error.padding).toFixed(3)})} - } - if (error.first > 0 && props.tempOptions.shift === ShiftMethod.NoShift) { - return {t('chart.import.trimFirst', {first: error.first.toFixed(3)})} - } - if (error.first < 0 && props.tempOptions.shift === ShiftMethod.NoShift) { - return {t('chart.import.addFirst', {first: (-error.first).toFixed(3)})} - } - return <> - } - let type: "default" | "info" | "success" | "warning" | "error" = "default"; - switch (error.level) { - case MessageLevel.Info: - type = 'info'; - break; - case MessageLevel.Warning: - type = 'warning'; - break; - case MessageLevel.Fatal: - type = 'error'; - break; - } - return error.isPaid && (showNeedPurchaseDialog.value = true)} - > -
- {error.message} -
-
- }) - } -
-
+ {!!props.meta.length && <> {t('chart.import.assignId')} @@ -109,38 +69,7 @@ export default defineComponent({ - - - - - - {{ - trigger: () => , - default: () =>
- {t('chart.import.option.shiftByBarDesc')} -
- }} -
- - {{ - trigger: () => , - default: () =>
- {t('chart.import.option.shiftLegacyDesc')} -
- }} -
- - {{ - trigger: () => , - default: () =>
- {t('chart.import.option.shiftNoMoveDesc')} -
- }} -
-
-
-
-
+ {t('chart.import.option.noScale')} diff --git a/MaiChartManager/Front/src/components/ImportCreateChartButton/ImportChartButton/ImportAlert.tsx b/MaiChartManager/Front/src/components/ImportCreateChartButton/ImportChartButton/ImportAlert.tsx new file mode 100644 index 00000000..ab959e56 --- /dev/null +++ b/MaiChartManager/Front/src/components/ImportCreateChartButton/ImportChartButton/ImportAlert.tsx @@ -0,0 +1,64 @@ +import { defineComponent, PropType } from "vue"; +import { NAlert, NFlex, NScrollbar } from "naive-ui"; +import { MessageLevel, ShiftMethod } from "@/client/apiGen"; +import { ImportChartMessageEx, TempOptions } from "./types"; +import { showNeedPurchaseDialog } from "@/store/refs"; +import { useI18n } from 'vue-i18n'; + +export default defineComponent({ + props: { + tempOptions: {type: Object as PropType, required: true}, + errors: {type: Array as PropType, required: true} + }, + setup(props, {emit}) { + const {t} = useI18n(); + + return () => + + { + props.errors.map((error, i) => { + if ('first' in error) { + if (error.padding > 0 && props.tempOptions.shift === ShiftMethod.Legacy) { + return {t('chart.import.addPadding', {padding: error.padding.toFixed(3)})} + } + if (error.padding < 0 && props.tempOptions.shift === ShiftMethod.Legacy) { + return {t('chart.import.trimPadding', {padding: (-error.padding).toFixed(3)})} + } + if (error.first > 0 && props.tempOptions.shift === ShiftMethod.NoShift) { + return {t('chart.import.trimFirst', {first: error.first.toFixed(3)})} + } + if (error.first < 0 && props.tempOptions.shift === ShiftMethod.NoShift) { + return {t('chart.import.addFirst', {first: (-error.first).toFixed(3)})} + } + return <> + } + let type: "default" | "info" | "success" | "warning" | "error" = "default"; + switch (error.level) { + case MessageLevel.Info: + type = 'info'; + break; + case MessageLevel.Warning: + type = 'warning'; + break; + case MessageLevel.Fatal: + type = 'error'; + break; + } + return error.isPaid && (showNeedPurchaseDialog.value = true)} + > +
+ {error.message} +
+
+ }) + } +
+
+ } +}) diff --git a/MaiChartManager/Front/src/components/ImportCreateChartButton/ImportChartButton/ShiftModeSelector.tsx b/MaiChartManager/Front/src/components/ImportCreateChartButton/ImportChartButton/ShiftModeSelector.tsx new file mode 100644 index 00000000..f1600d61 --- /dev/null +++ b/MaiChartManager/Front/src/components/ImportCreateChartButton/ImportChartButton/ShiftModeSelector.tsx @@ -0,0 +1,47 @@ +import { defineComponent, PropType } from "vue"; +import { NFlex, NFormItem, NRadioGroup, NPopover, NRadio } from "naive-ui"; +import { ShiftMethod } from "@/client/apiGen"; +import { TempOptions } from "./types"; +import { useI18n } from 'vue-i18n'; + +export default defineComponent({ + props: { + tempOptions: {type: Object as PropType, required: true}, + }, + setup(props) { + const {t} = useI18n(); + + return () => + + + + + {{ + trigger: () => , + default: () =>
+ {props.tempOptions.shiftLocked ? t('chart.import.option.shiftModeLocked') : t('chart.import.option.shiftByBarDesc')} +
+ }} +
+ + {{ + trigger: () => , + default: () =>
+ {props.tempOptions.shiftLocked ? t('chart.import.option.shiftModeLocked') : t('chart.import.option.shiftLegacyDesc')} +
+ }} +
+ + {{ + trigger: () => , + default: () =>
+ {props.tempOptions.shiftLocked ? t('chart.import.option.shiftModeLocked') : t('chart.import.option.shiftNoMoveDesc')} +
+ }} +
+
+
+
+
+ } +}) diff --git a/MaiChartManager/Front/src/components/ImportCreateChartButton/ImportChartButton/types.ts b/MaiChartManager/Front/src/components/ImportCreateChartButton/ImportChartButton/types.ts index f6e749dc..79ef579d 100644 --- a/MaiChartManager/Front/src/components/ImportCreateChartButton/ImportChartButton/types.ts +++ b/MaiChartManager/Front/src/components/ImportCreateChartButton/ImportChartButton/types.ts @@ -38,7 +38,7 @@ export type ImportChartMessageEx = (ImportChartMessage | FirstPaddingMessage) & export const dummyMeta = {name: '', importStep: IMPORT_STEP.start} as ImportMeta -export const defaultTempOptions = { +export const defaultTempOptions: TempOptions = { shift: ShiftMethod.Bar, } @@ -60,5 +60,8 @@ export const defaultSavedOptions = { yuv420p: true, } -export type TempOptions = typeof defaultTempOptions; +export type TempOptions = { + shift: ShiftMethod, + shiftLocked?: boolean +}; export type SavedOptions = typeof defaultSavedOptions; diff --git a/MaiChartManager/Front/src/components/LevelTagsDisplay.tsx b/MaiChartManager/Front/src/components/LevelTagsDisplay.tsx new file mode 100644 index 00000000..e803b809 --- /dev/null +++ b/MaiChartManager/Front/src/components/LevelTagsDisplay.tsx @@ -0,0 +1,21 @@ +import { defineComponent, PropType } from "vue"; +import { NFlex } from "naive-ui"; +import { Chart } from "@/client/apiGen"; +import { LEVEL_COLOR, LEVELS } from "@/consts"; + +export default defineComponent({ + props: { + charts: {type: Array as PropType<(Chart | undefined)[]>, required: true}, + showLevelDecimal: {type: Boolean, default: false} // 如果为true,显示的是定数12.8,否则是12+。 + }, + setup(props) { + return () => + { + (props.charts || []).map((chart, index) => + chart && chart.enable &&
+ {props.showLevelDecimal ? chart.level + "." + chart.levelDecimal : LEVELS[chart.levelId!]} +
) + } +
+ } +}) diff --git a/MaiChartManager/Front/src/components/MusicEdit/ChartPanel.tsx b/MaiChartManager/Front/src/components/MusicEdit/ChartPanel.tsx index 1fd51cfd..64a0c20b 100644 --- a/MaiChartManager/Front/src/components/MusicEdit/ChartPanel.tsx +++ b/MaiChartManager/Front/src/components/MusicEdit/ChartPanel.tsx @@ -1,12 +1,13 @@ import { computed, defineComponent, PropType, watch } from "vue"; import { Chart } from "@/client/apiGen"; -import { NFlex, NForm, NFormItem, NInput, NInputNumber, NSelect, NSwitch } from "naive-ui"; +import { NButton, NFlex, NForm, NFormItem, NInput, NInputNumber, NSelect, NSwitch } from "naive-ui"; import api from "@/client/api"; -import { selectedADir, selectedMusic } from "@/store/refs"; +import { disableSync, selectedADir, selectedMusic } from "@/store/refs"; import { LEVELS } from "@/consts"; import ProblemsDisplay from "@/components/ProblemsDisplay"; import PreviewChartButton from "@/components/MusicEdit/PreviewChartButton"; import { useI18n } from 'vue-i18n'; +import { prepareReplaceChart } from "@/components/DragDropDispatcher/ReplaceChartModal"; const LEVELS_OPTIONS = LEVELS.map((level, index) => ({label: level, value: index})); @@ -27,7 +28,7 @@ export default defineComponent({ }) const sync = (key: keyof Chart, method: Function) => async () => { - if (!props.chart) return; + if (disableSync.value || !props.chart) return; selectedMusic.value!.modified = true; await method(props.songId, props.chartIndex, selectedADir.value, props.chart[key]!); } @@ -43,6 +44,9 @@ export default defineComponent({ + prepareReplaceChart()}> + {t('music.edit.replaceChart')} + diff --git a/MaiChartManager/Front/src/components/MusicEdit/index.tsx b/MaiChartManager/Front/src/components/MusicEdit/index.tsx index 9914f09b..e94ebbef 100644 --- a/MaiChartManager/Front/src/components/MusicEdit/index.tsx +++ b/MaiChartManager/Front/src/components/MusicEdit/index.tsx @@ -1,6 +1,6 @@ import { computed, defineComponent, onMounted, PropType, ref, watch } from "vue"; import { Chart, GenreXml, MusicXmlWithABJacket } from "@/client/apiGen"; -import { addVersionList, genreList, globalCapture, selectedADir, selectedMusic as info, selectMusicId, updateAddVersionList, updateGenreList, updateMusicList, selectedLevel } from "@/store/refs"; +import { addVersionList, genreList, globalCapture, selectedADir, selectedMusic as info, selectMusicId, updateAddVersionList, updateGenreList, updateMusicList, selectedLevel, disableSync } from "@/store/refs"; import api from "@/client/api"; import { NButton, NFlex, NForm, NFormItem, NInput, NInputNumber, NSelect, NSwitch, NTabPane, NTabs, SelectOption, useDialog, useMessage } from "naive-ui"; import JacketBox from "../JacketBox"; @@ -27,7 +27,7 @@ const Component = defineComponent({ } const sync = (key: keyof MusicXmlWithABJacket, method: Function) => async () => { - if (!info.value) return; + if (disableSync.value || !info.value) return; info.value!.modified = true; await method(info.value.id!, info.value.assetDir, (info.value as any)[key]!); } diff --git a/MaiChartManager/Front/src/components/MusicList/MusicEntry.tsx b/MaiChartManager/Front/src/components/MusicList/MusicEntry.tsx index 2b900362..31b2734e 100644 --- a/MaiChartManager/Front/src/components/MusicList/MusicEntry.tsx +++ b/MaiChartManager/Front/src/components/MusicList/MusicEntry.tsx @@ -2,12 +2,13 @@ import { computed, defineComponent, PropType } from "vue"; import { MusicXmlWithABJacket } from "@/client/apiGen"; import noJacket from '@/assets/noJacket.webp'; import { NBadge, NFlex } from "naive-ui"; -import { DIFFICULTY, LEVEL_COLOR, LEVELS } from "@/consts"; +import { DIFFICULTY } from "@/consts"; import ProblemsDisplay from "@/components/ProblemsDisplay"; import { musicListAll, selectedADir } from "@/store/refs"; import ConflictDisplay from "@/components/MusicList/ConflictDisplay"; import { getUrl } from "@/client/api"; import LongMusicIcon from "./LongMusicIcon"; +import LevelTagsDisplay from "@/components/LevelTagsDisplay"; export default defineComponent({ props: { @@ -42,12 +43,7 @@ export default defineComponent({ {props.music.id?.toString().padStart(6, '0')}
{props.music.name}
- - { - (props.music.charts || []).map((chart, index) => - chart.enable &&
{LEVELS[chart.levelId!]}
) - } -
+
{props.music.longMusic && } diff --git a/MaiChartManager/Front/src/locales/en.yaml b/MaiChartManager/Front/src/locales/en.yaml index 8dd7b277..f6983885 100644 --- a/MaiChartManager/Front/src/locales/en.yaml +++ b/MaiChartManager/Front/src/locales/en.yaml @@ -133,6 +133,8 @@ music: replaceChartConfirm: Confirm to replace {level}? replaceChartFailed: Failed to replace chart replaceChartSuccess: Chart replaced successfully + notValidChartFile: Chart file must be .ma2 or maidata.txt. + replaceChartShiftModeHint: Make sure the delay adjustment mode selected here matches the one used when importing the chart, otherwise notes may be positioned incorrectly! batch: title: Batch Actions batchAndSearch: Batch Actions & Search @@ -218,6 +220,7 @@ chart: option: advancedOptions: Advanced Options shiftMode: Delay Adjustment Mode + shiftModeLocked: The Delay Adjustment Mode is locked to the mode used when the chart was originally imported and cannot be changed. shiftByBar: By Bar shiftByBarDesc: >- If the rest at the beginning of the chart is less than one bar, add one @@ -456,6 +459,7 @@ error: confirm: Got it file: notSelected: No file selected + unsupportedFileType: 'Unsupported file type' message: notice: Notice saveSuccess: Saved successfully diff --git a/MaiChartManager/Front/src/locales/zh-TW.yaml b/MaiChartManager/Front/src/locales/zh-TW.yaml index da16da80..3c07380e 100644 --- a/MaiChartManager/Front/src/locales/zh-TW.yaml +++ b/MaiChartManager/Front/src/locales/zh-TW.yaml @@ -126,6 +126,8 @@ music: replaceChartConfirm: 確認要替換 {level} 譜面嗎? replaceChartFailed: 替換譜面失敗 replaceChartSuccess: 替換譜面成功 + notValidChartFile: 譜面檔案必須是 .ma2 或 maidata.txt。 + replaceChartShiftModeHint: 請確保這裡選擇的延遲調整模式與匯入譜面時使用的一致,否則會導致音符位置不正確! batch: title: 批次操作 batchAndSearch: 批次操作與搜尋 @@ -209,6 +211,7 @@ chart: option: advancedOptions: 進階選項 shiftMode: 延遲調整模式 + shiftModeLocked: 延遲調整模式已被鎖定為最初匯入譜面時所使用的模式,無法變更 shiftByBar: 按小節 shiftByBarDesc: |- 如果譜面前面的休止符長度小於一小節,那就在前面加上一小節的空白 @@ -417,6 +420,7 @@ error: feedbackError: 回饋錯誤 file: notSelected: 未選擇檔案 + unsupportedFileType: '不支援的檔案類型' message: notice: 提示 saveSuccess: 儲存成功 diff --git a/MaiChartManager/Front/src/locales/zh.yaml b/MaiChartManager/Front/src/locales/zh.yaml index f9c02ec5..e22479d2 100644 --- a/MaiChartManager/Front/src/locales/zh.yaml +++ b/MaiChartManager/Front/src/locales/zh.yaml @@ -126,6 +126,8 @@ music: replaceChartConfirm: 确认要替换 {level} 谱面吗? replaceChartFailed: 替换谱面失败 replaceChartSuccess: 替换谱面成功 + notValidChartFile: 谱面文件必须是.ma2或maidata.txt + replaceChartShiftModeHint: 请确保这里选择的延迟调整模式和导入谱面时使用的一致,否则会导致音符位置不正确! batch: title: 批量操作 batchAndSearch: 批量操作与搜索 @@ -209,6 +211,7 @@ chart: option: advancedOptions: 高级选项 shiftMode: 延迟调整模式 + shiftModeLocked: 延迟调整模式已被锁定为最初导入谱面时所使用的模式,无法修改 shiftByBar: 按小节 shiftByBarDesc: |- 如果谱面前面的休止符长度小于一小节,那就在前面加上一小节的空白 @@ -418,6 +421,7 @@ error: feedbackError: 反馈错误 file: notSelected: 未选择文件 + unsupportedFileType: 不支持的文件类型 message: notice: 提示 saveSuccess: 保存成功 diff --git a/MaiChartManager/Front/src/store/refs.ts b/MaiChartManager/Front/src/store/refs.ts index fe0d6f5f..345197ee 100644 --- a/MaiChartManager/Front/src/store/refs.ts +++ b/MaiChartManager/Front/src/store/refs.ts @@ -55,6 +55,10 @@ export const musicList = computed(() => musicListAll.value.filter(m => m.assetDi export const selectedMusic = computed(() => musicList.value.find(m => m.id === selectMusicId.value)); export const selectedLevel = ref(0); +// 如ReplaceChart等后端接口可能会涉及对MusicXml中的信息进行修改后保存。此时前端updateMusicList时会发现相关数据出现变更,触发了MusicEdit/ChartPanel中的watch,造成发送多余的edit请求、同时modified也被错误设置为true。 +// 因此,对于涉及内部对xml进行修改后会自动保存的后端接口,可以在updateMusicList期间打开本选项,以阻止MusicEdit/ChartPanel中的sync动作。 +export const disableSync = ref(false); + export const aquaMaiConfig = ref() export const modUpdateInfo = ref>['data']>([{ type: 'builtin', @@ -90,8 +94,11 @@ export const updateAddVersionList = async () => { addVersionList.value = response.data; } -export const updateMusicList = async () => { - musicListAll.value = (await api.GetMusicList()).data; +export const updateMusicList = async (disableAutoSync=false) => { + const data = (await api.GetMusicList()).data; + if (disableAutoSync) disableSync.value = true; + musicListAll.value = data; + setTimeout(()=>disableSync.value = false); // timeout=0表示在下一帧执行 } export const updateAssetDirs = async () => { diff --git a/MaiChartManager/Locale.Designer.cs b/MaiChartManager/Locale.Designer.cs index 98c5e497..4961999e 100644 --- a/MaiChartManager/Locale.Designer.cs +++ b/MaiChartManager/Locale.Designer.cs @@ -630,6 +630,15 @@ internal static string MusicNoTitle { } } + /// + /// Looks up a localized string similar to Caution! This "Replace Chart" function should only be used when modifying the note content of the existing chart, while the audio remains unchanged, the &first offset remains unchanged, and the timing of the first note remains unchanged. Otherwise, you will need to delete the entire chart and re-import it.. + /// + internal static string NotesReplacementWarning { + get { + return ResourceManager.GetString("NotesReplacementWarning", resourceCulture); + } + } + /// /// Looks up a localized string similar to Please enter activation code. /// diff --git a/MaiChartManager/Locale.resx b/MaiChartManager/Locale.resx index 20f9e434..b89915e8 100644 --- a/MaiChartManager/Locale.resx +++ b/MaiChartManager/Locale.resx @@ -293,4 +293,7 @@ If you notice any issues with the conversion result, you can try testing it in A Automatically run in server mode and minimize to the system tray upon startup. + + Caution! This "Replace Chart" function should only be used when modifying the note content of the existing chart, while the audio remains unchanged, the &first offset remains unchanged, and the timing of the first note remains unchanged. Otherwise, you will need to delete the entire chart and re-import it. + \ No newline at end of file diff --git a/MaiChartManager/Locale.zh-hans.resx b/MaiChartManager/Locale.zh-hans.resx index f6ddf3c9..810a0f5d 100644 --- a/MaiChartManager/Locale.zh-hans.resx +++ b/MaiChartManager/Locale.zh-hans.resx @@ -285,4 +285,7 @@ 开机自动以服务器模式运行并最小化到托盘 + + 注意!本“替换谱面”功能仅限用于:谱面音符内容在原来的基础上发生修改,且音频内容未变、&first偏移量未变、谱面中第一个音符的时刻未变的情况。否则,您需要删除整个谱面后重新导入。 + \ No newline at end of file diff --git a/MaiChartManager/Locale.zh-hant.resx b/MaiChartManager/Locale.zh-hant.resx index 91b66db7..c1282c2b 100644 --- a/MaiChartManager/Locale.zh-hant.resx +++ b/MaiChartManager/Locale.zh-hant.resx @@ -285,4 +285,7 @@ 開機自動以伺服器模式運作並最小化到托盤 + + >請注意!本「替換譜面」功能僅限用於:譜面音符內容在原來的基礎上發生修改,且音訊內容未變、&first偏移量未變、譜面中第一個音符的時刻未變的情況。否則,您需要刪除整個譜面後重新匯入。 + \ No newline at end of file diff --git a/MaiChartManager/Models/MusicXml.cs b/MaiChartManager/Models/MusicXml.cs index 86f6da87..7136519f 100644 --- a/MaiChartManager/Models/MusicXml.cs +++ b/MaiChartManager/Models/MusicXml.cs @@ -377,16 +377,7 @@ public string UtageKanji set { Modified = true; - var node = RootNode.SelectSingleNode(utageKanjiNode); - if (node is null) - { - node = xmlDoc.CreateNode(XmlNodeType.Element, utageKanjiNode, null); - node.InnerText = value; - RootNode.AppendChild(node); - return; - } - - RootNode.SelectSingleNode(utageKanjiNode).InnerText = value; + SelectSingleNodeOrCreate(RootNode, utageKanjiNode).InnerText = value; } } @@ -398,16 +389,7 @@ public string Comment set { Modified = true; - var node = RootNode.SelectSingleNode(commentNode); - if (node is null) - { - node = xmlDoc.CreateNode(XmlNodeType.Element, commentNode, null); - node.InnerText = value; - RootNode.AppendChild(node); - return; - } - - RootNode.SelectSingleNode(commentNode).InnerText = value; + SelectSingleNodeOrCreate(RootNode, commentNode).InnerText = value; } } @@ -466,6 +448,18 @@ public bool LongMusic node.InnerText = value ? "1" : "0"; } } + + // 以下是游戏本体不会使用、而是由MCM使用的扩展字段,统一放到"X-MCM"下。 + + public string ShiftMethod + { + get => RootNode.SelectSingleNode("X-MCM/shiftMethod")?.InnerText; + set + { + Modified = true; + SelectSingleNodeOrCreate(RootNode, "X-MCM/shiftMethod").InnerText = value; + } + } public class Chart { @@ -571,4 +565,22 @@ public void Save() Modified = false; xmlDoc.Save(FilePath); } + + private XmlNode SelectSingleNodeOrCreate(XmlNode parent, string xpath) + { + var node = parent.SelectSingleNode(xpath); + if (node is not null) return node; + // 由于xmlDoc.CreateNode不支持xpath,因此当xpath有多层时,必须对每一层依次分别创建 + foreach (var s in xpath.Split('/')) + { + node = parent.SelectSingleNode(s); + if (node is null) + { + node = xmlDoc.CreateNode(XmlNodeType.Element, s, null); + parent.AppendChild(node); + } + parent = node; // 继续下一层的处理 + } + return node; + } } \ No newline at end of file diff --git a/MaiChartManager/ServerManager.cs b/MaiChartManager/ServerManager.cs index 22d98a70..2169de1c 100644 --- a/MaiChartManager/ServerManager.cs +++ b/MaiChartManager/ServerManager.cs @@ -6,6 +6,7 @@ using System.Text.Json.Serialization; using System.Windows.Forms; using idunno.Authentication.Basic; +using MaiChartManager.Services; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Server.Kestrel.Https; using Microsoft.Extensions.FileProviders; @@ -111,6 +112,7 @@ public static void StartApp(bool export, Action? onStart = null) builder.Services .AddSingleton() + .AddSingleton() .AddEndpointsApiExplorer() .AddSwaggerGen(options => { options.CustomSchemaIds(type => type.Name == "Config" ? type.FullName : type.Name); }) .Configure(x => diff --git a/MaiChartManager/Services/MaidataImportService.cs b/MaiChartManager/Services/MaidataImportService.cs new file mode 100644 index 00000000..9306b3a6 --- /dev/null +++ b/MaiChartManager/Services/MaidataImportService.cs @@ -0,0 +1,441 @@ +using System.Text.RegularExpressions; +using MaiChartManager.Models; +using MaiChartManager.Utils; +using MaiLib; +using SimaiSharp; +using SimaiSharp.Structures; + +namespace MaiChartManager.Services; + +public enum MessageLevel +{ + Info, + Warning, + Fatal +} + +public record ImportChartMessage(string Message, MessageLevel Level); + +public record ImportChartResult(IEnumerable Errors, bool Fatal); + +// v1.1.2 新增 +public enum ShiftMethod +{ + // 之前的办法,把第一押准确的对在第二小节的开头 + // noShiftChart = false, padding = MusicPadding + Legacy, + + // 简单粗暴的办法,不需要让库来平移谱面,解决各种平移不兼容问题 + // 之前修库都白修了其实 + // bar - 休止符的长度 如果是正数,那就直接在前面加一个小节的空白 + // 判断一下 > 0.1 好了,因为 < 0.1 秒可以忽略不计 + // noShiftChart = true, padding = (bar - 休止符的长度 > 0.1 ? bar - first : 0) + // bar - 休止符的长度 = MusicPadding + first + Bar, + + // 把音频裁掉 &first 秒,完全不用动谱面 + // noShiftChart = true, padding = -first + NoShift +} + +public partial class MaidataImportService +{ + private readonly ILogger logger; + + public MaidataImportService(ILogger logger) + { + this.logger = logger; + } + + [GeneratedRegex(@"^\([\d\.]+\)")] + private static partial Regex BpmTagRegex(); + + [GeneratedRegex(@"\{([\d\.]+)\}")] + public static partial Regex MeasureTagRegex(); + + private static string Add1Bar(string maidata) + { + var regex = BpmTagRegex(); + var bpm = regex.Match(maidata).Value; + // 这里使用 {4},,,, 而不是 {1}, 因为要是谱面一开始根本没有写 {x} 的话,默认是 {4}。要是用了 {1}, 会覆盖默认的 {4} + return string.Concat(bpm, "{4},,,,", maidata.AsSpan(bpm.Length)); + } + + [GeneratedRegex(@"(\d){")] + private static partial Regex SimaiError1(); + + [GeneratedRegex(@"\[(\d+)-(\d+)]")] + private static partial Regex SimaiError2(); + + [GeneratedRegex(@"(\d)\(")] + private static partial Regex SimaiError3(); + + [GeneratedRegex(@",[csbx\.\{\}],")] + private static partial Regex SimaiError4(); + + [GeneratedRegex(@"(\d)qx(\d)")] + private static partial Regex SimaiError5(); + + private static string FixChartSimaiSharp(string chart) + { + chart = chart.Replace("\n", "").Replace("\r", "").Replace("{{", "{").Replace("}}", "}"); + chart = SimaiError1().Replace(chart, "$1,{"); + chart = SimaiError3().Replace(chart, "$1,("); + chart = SimaiError2().Replace(chart, "[$1:$2]"); + chart = SimaiError4().Replace(chart, ",,"); + chart = SimaiError5().Replace(chart, "$1xq$2"); + return chart; + } + + public MaiChart TryParseChartSimaiSharp(string chartText, int level, List errors) + { + chartText = chartText.ReplaceLineEndings(); + try + { + return SimaiConvert.Deserialize(chartText); + } + catch (Exception e) + { + logger.LogWarning(e, "SimaiSharp 无法直接解析谱面"); + } + + try + { + var chart = SimaiConvert.Deserialize(FixChartSimaiSharp(chartText)); + errors.Add(new ImportChartMessage(string.Format(Locale.ChartFixedMinorErrors, level), MessageLevel.Info)); + return chart; + } + catch (Exception e) + { + logger.LogWarning(e, "SimaiSharp 无法解析修复后谱面"); + throw; + } + } + + [GeneratedRegex(@"\|\|.*$", RegexOptions.Multiline)] + private static partial Regex SimaiCommentRegex(); + + /* + * 根据[simai文档](https://w.atwiki.jp/simai/pages/1002.html),井号如果出现在[]或{}内是合法语法,并非注释。 + * 因此在尝试匹配注释时,应该排除掉#前面有未闭合的[或{的情况。 + */ + [GeneratedRegex(@"(? errors) + { + chartText = chartText.ReplaceLineEndings(); + try + { + return new SimaiParser().ChartOfToken(new SimaiTokenizer().TokensFromText(chartText)); + } + catch (Exception e) + { + logger.LogWarning(e, "无法直接解析谱面"); + } + + try + { + var normalizedText = SimaiCommentRegex().Replace(chartText, ""); + normalizedText = SimaiCommentRegex2().Replace(normalizedText, ""); + return new SimaiParser().ChartOfToken(new SimaiTokenizer().TokensFromText(normalizedText)); + } + catch (Exception) + { + // ignored + } + + try + { + var normalizedText = FixChartSimaiSharp(chartText) + // 不飞的星星 + .Replace("-?", "?-"); + // 移除注释 + normalizedText = SimaiCommentRegex().Replace(normalizedText, ""); + normalizedText = SimaiCommentRegex2().Replace(normalizedText, ""); + var tokens = new SimaiTokenizer().TokensFromText(normalizedText); + for (var i = 0; i < tokens.Length; i++) + { + if (tokens[i].Contains("]b")) + { + tokens[i] = tokens[i].Replace("]b", "]").Replace("[", "b["); + } + } + + var maiLibChart = new SimaiParser().ChartOfToken(tokens); + errors.Add(new ImportChartMessage(string.Format(Locale.ChartFixedMinorErrors, level), MessageLevel.Info)); + return maiLibChart; + } + catch (Exception e) + { + errors.Add(new ImportChartMessage(string.Format(Locale.ChartMaiLibParseError, level, MaiLibErrMsgRegex().Replace(e.Message, "")), MessageLevel.Warning)); + logger.LogWarning(e, "无法在手动修正错误后解析谱面"); + } + + if (simaiSharpChart is null) + { + return null; + } + + try + { + var reSerialized = SimaiConvert.Serialize(simaiSharpChart); + reSerialized = reSerialized.Replace("{0}", "{4}"); + var maiLibChart = new SimaiParser().ChartOfToken(new SimaiTokenizer().TokensFromText(reSerialized)); + errors.Add(new ImportChartMessage(string.Format(Locale.ChartSimaiSharpFallback, level), MessageLevel.Warning)); + return maiLibChart; + } + catch (Exception e) + { + SentrySdk.CaptureException(e); + errors.Add(new ImportChartMessage(string.Format(Locale.ChartParseFailed, level), MessageLevel.Fatal)); + return null; + } + } + + public static float CalcMusicPadding(MaiChart chart, float first) + { + // TimingChanges 对应的是所有的 {int} + var bpm = chart.TimingChanges[0].tempo; + // 一小节多长 + var bar = 60 / bpm * 4; + + // 第一押什么时候出来 + var firstTiming = chart.NoteCollections[0].time + first; + return bar - firstTiming; + } + + private record AllChartsEntry(string chartText, MaiChart simaiSharpChart); + + public ImportChartResult ImportMaidata(MusicXml music, IFormFile file, ShiftMethod shift, + bool ignoreLevelNum, bool debug, bool isReplacement = false) + { + var id = music.Id; + var isUtage = id > 100000; + var errors = new List(); + var kvps = new SimaiFile(file.OpenReadStream()).ToKeyValuePairs(); + var maiData = new Dictionary(); + foreach (var (key, value) in kvps) + { + maiData[key] = value; + } + + var allCharts = new Dictionary(); + for (var i = 2; i < 9; i++) + { + if (!string.IsNullOrWhiteSpace(maiData.GetValueOrDefault($"inote_{i}"))) + { + allCharts.Add(i, new AllChartsEntry(maiData[$"inote_{i}"], TryParseChartSimaiSharp(maiData[$"inote_{i}"], i, errors))); + } + } + + if (!string.IsNullOrWhiteSpace(maiData.GetValueOrDefault("inote_0"))) + { + allCharts.Add(0, new AllChartsEntry(maiData["inote_0"], TryParseChartSimaiSharp(maiData["inote_0"], 0, errors))); + } + + float.TryParse(maiData.GetValueOrDefault("first"), out var first); + // Mai 的歌曲是从两帧后开始播放的 + first -= 1 / 30f; + + var paddings = allCharts.Values.Select(chart => CalcMusicPadding(chart.simaiSharpChart, first)).ToList(); + // 音频前面被增加了多少 + var audioPadding = paddings.Max(); // bar - firstTiming = bar - 谱面前面休止符的时间 - &first + var shouldAddBar = false; + float chartPadding; + switch (shift) + { + case ShiftMethod.Legacy: + chartPadding = audioPadding + first; + break; + case ShiftMethod.Bar when audioPadding + first > 0.1: + shouldAddBar = true; + chartPadding = 0f; + break; + default: + chartPadding = 0f; + break; + } + + if (shouldAddBar) + { + foreach (var (level, chart) in allCharts) + { + var newText = Add1Bar(chart.chartText); + allCharts[level] = new AllChartsEntry(newText, TryParseChartSimaiSharp(newText, level, errors)); + } + } + + foreach (var targetChart in music.Charts) + { + targetChart.Enable = false; + } + + float bpm = 0f; + foreach (var (level, chart) in allCharts) + { + // 宴会场只导入第一个谱面 + if (isUtage && music.Charts[0].Enable) break; + + // var levelPadding = CalcMusicPadding(chart, first); + bpm = chart.simaiSharpChart.TimingChanges[0].tempo; + // 一个小节多少秒 + var bar = 60 / bpm * 4; + + // 我们要让这个谱面真正的内容(忽略 first)延后多少 + // levelPadding 似乎不需要算,因为每个谱面真正的内容都是从同一个地方开始 + // 所以只要在前面加上 audioPadding + first 时间的休止符 + // 最早出音符的那个谱面的第一押之前一定是 1bar(小节)的休止符 + // |_| levelPadding + // |_______| audioPadding + // |________________| bar + // |________________| bar + // |-------|---|----|-----|----- + // | | | | | 这个谱面的第一押 + // | | | | 可能是另一个谱面难度的第一押,firstTiming,它可能导致 audioPadding > levelPadding + // | | |____| 这一段是休止符 + // | | | 每个谱面真正的内容都是从这里开始 + // | |___| first skip 掉的部分 + // | | 原先音频的开头 + // | 加了 padding 的音频开头 + + # region 设定 targetLevel + + var targetLevel = level - 2; + + // 处理非标准难度 + if (level is > 6 or < 1) + { + // 分给 3 4 0 + if (!music.Charts[3].Enable) + { + targetLevel = 3; + } + else if (!music.Charts[4].Enable) + { + targetLevel = 4; + } + else if (!music.Charts[0].Enable) + { + targetLevel = 0; + } + else + { + continue; + } + } + + if (isUtage) targetLevel = 0; + + # endregion + + var targetChart = music.Charts[targetLevel]; + targetChart.Path = $"{id:000000}_0{targetLevel}.ma2"; + var levelNumStr = maiData.GetValueOrDefault($"lv_{level}"); + if (!string.IsNullOrWhiteSpace(levelNumStr)) + { + levelNumStr = levelNumStr.Replace("+", ".7"); + } + + float.TryParse(levelNumStr, out var levelNum); + targetChart.LevelId = MaiUtils.GetLevelId((int)(levelNum * 10)); + // 忽略定数 + if (!ignoreLevelNum) + { + targetChart.Level = (int)Math.Floor(levelNum); + targetChart.LevelDecimal = (int)Math.Floor(levelNum * 10 % 10); + } + + targetChart.Designer = maiData.GetValueOrDefault($"des_{level}") ?? maiData.GetValueOrDefault("des") ?? ""; + var maiLibChart = TryParseChart(chart.chartText, chart.simaiSharpChart, level, errors); + if (maiLibChart is null) + { + return new ImportChartResult(errors, true); + } + + var originalConverted = maiLibChart.Compose(ChartEnum.ChartVersion.Ma2_104); + + if (debug) + { + File.WriteAllText(Path.Combine(Path.GetDirectoryName(music.FilePath)!, targetChart.Path + ".afterSimaiSharp.txt"), SimaiConvert.Serialize(chart.simaiSharpChart)); + File.WriteAllText(Path.Combine(Path.GetDirectoryName(music.FilePath)!, targetChart.Path + ".preShift.ma2"), originalConverted); + File.WriteAllText(Path.Combine(Path.GetDirectoryName(music.FilePath)!, targetChart.Path + ".preShift.txt"), maiLibChart.Compose(ChartEnum.ChartVersion.SimaiFes)); + } + + if (chartPadding != 0) + { + try + { + maiLibChart.ShiftByOffset((int)Math.Round(chartPadding / bar * maiLibChart.Definition)); + } + catch (Exception e) + { + SentrySdk.CaptureEvent(new SentryEvent(e) + { + Message = Locale.ChartShiftByOffsetError + }); + errors.Add(new ImportChartMessage(Locale.ChartShiftError, MessageLevel.Fatal)); + return new ImportChartResult(errors, true); + } + } + + var shiftedConverted = maiLibChart.Compose(ChartEnum.ChartVersion.Ma2_104); + + if (shiftedConverted.Split('\n').Length != originalConverted.Split('\n').Length) + { + errors.Add(new ImportChartMessage(Locale.ChartNotesMissing, MessageLevel.Warning)); + logger.LogWarning("BUG! shiftedConverted: {shiftedLen}, originalConverted: {originalLen}", shiftedConverted.Split('\n').Length, originalConverted.Split('\n').Length); + } + + // Just use T_NUM_ALL value in ma2 file + targetChart.MaxNotes = ParseTNumAllFromMa2(shiftedConverted); + // Fallback to maiLibChart if T_NUM_ALL not found + if (targetChart.MaxNotes == 0) targetChart.MaxNotes = maiLibChart.AllNoteNum; + + File.WriteAllText(Path.Combine(Path.GetDirectoryName(music.FilePath)!, targetChart.Path), shiftedConverted); + if (debug) + { + File.WriteAllText(Path.Combine(Path.GetDirectoryName(music.FilePath)!, targetChart.Path + ".afterShift.txt"), maiLibChart.Compose(ChartEnum.ChartVersion.SimaiFes)); + } + + targetChart.Enable = true; + } + + if (!isReplacement) + { + // 只在新建时设定曲目信息,替换时不设定 + music.Name = maiData["title"]; + music.Artist = maiData.GetValueOrDefault("artist") ?? ""; + music.ShiftMethod = shift.ToString(); + float wholebpm; + if (float.TryParse(maiData.GetValueOrDefault("wholebpm"), out wholebpm)) + music.Bpm = wholebpm; // 优先使用&wholebpm + else music.Bpm = bpm; // 如果不存在,则使用谱面中开头声明的bpm + } + + return new ImportChartResult(errors, false); + } + + public static int ParseTNumAllFromMa2(string ma2Content) + { + var lines = ma2Content.Split('\n'); + // 从后往前读取,因为 T_NUM_ALL 在文件最后 + for (int i = lines.Length - 1; i >= 0; i--) + { + var trimmedLine = lines[i].Trim(); + if (trimmedLine.StartsWith("T_NUM_ALL", StringComparison.OrdinalIgnoreCase)) + { + var parts = trimmedLine.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2 && int.TryParse(parts[1], out int tNumAll)) + { + return tNumAll; + } + } + } + // Fallback to 0 in case + return 0; + } +}