@@ -15,7 +15,12 @@ private static int Main(string[] args)
1515 var root = BuildRootCommand ( ) ;
1616 try
1717 {
18- return root . Parse ( args ) . Invoke ( ) ;
18+ var parseResult = root . Parse ( args ) ;
19+ var invocation = new InvocationConfiguration
20+ {
21+ EnableDefaultExceptionHandler = false
22+ } ;
23+ return parseResult . Invoke ( invocation ) ;
1924 }
2025 catch ( ConversionException ex )
2126 {
@@ -43,6 +48,17 @@ private static Command BuildRootCommand()
4348 HelpName = "N[,N...]"
4449 } ;
4550
51+ var outputOption = new Option < string ? > ( "--output" , "-o" )
52+ {
53+ Description =
54+ "输出位置:\n " +
55+ "· 省略:写入输入文件同目录,文件名按默认规则(maidata.txt、lv_N.ma2 等)。\n " +
56+ "· 目录:写入该目录,文件名同上按默认规则。\n " +
57+ "· 文件:仅当本次转换只会生成一个输出文件时可用;扩展名须为 .txt(输出 maidata)或 .ma2(输出 MA2)。\n " +
58+ "· \" -\" :仅当本次转换只会生成一个输出文件时可用;将输出内容写到stdout。" ,
59+ HelpName = "path"
60+ } ;
61+
4662 var inputArgument = new Argument < string > ( "path" )
4763 {
4864 Description = "可以输入以下几种情况:\n " +
@@ -54,19 +70,49 @@ private static Command BuildRootCommand()
5470 } ;
5571
5672 root . Options . Add ( levelsOption ) ;
73+ root . Options . Add ( outputOption ) ;
5774 root . Arguments . Add ( inputArgument ) ;
5875
5976 root . SetAction ( parseResult =>
6077 {
6178 var inputPath = parseResult . GetValue ( inputArgument )
6279 ?? throw new InvalidOperationException ( "缺少参数 path。" ) ;
6380 var levelsRaw = parseResult . GetValue ( levelsOption ) ;
81+ _outputSpec = OutputSpec . Parse ( parseResult . GetValue ( outputOption ) ) ;
6482 RunConvert ( inputPath , levelsRaw ) ;
6583 } ) ;
6684
6785 return root ;
6886 }
6987
88+ /// <summary>由 CLI 在每次 <c>SetAction</c> 入口赋值;转换逻辑只读此字段。</summary>
89+ private static OutputSpec _outputSpec ;
90+
91+ private enum OutputSinkKind { Default , Stdout , Directory , File }
92+
93+ private readonly record struct OutputSpec ( OutputSinkKind Kind , string ? FsPath )
94+ {
95+ internal static OutputSpec Parse ( string ? raw )
96+ {
97+ if ( string . IsNullOrWhiteSpace ( raw ) )
98+ return new OutputSpec ( OutputSinkKind . Default , null ) ;
99+ var t = raw . Trim ( ) ;
100+ if ( t == "-" )
101+ return new OutputSpec ( OutputSinkKind . Stdout , null ) ;
102+ var full = Path . GetFullPath ( t ) ;
103+ if ( Directory . Exists ( full ) )
104+ return new OutputSpec ( OutputSinkKind . Directory , full ) ;
105+ if ( File . Exists ( full ) )
106+ return new OutputSpec ( OutputSinkKind . File , full ) ;
107+ if ( ! string . IsNullOrEmpty ( Path . GetExtension ( full ) ) )
108+ return new OutputSpec ( OutputSinkKind . File , full ) ;
109+ return new OutputSpec ( OutputSinkKind . Directory , full ) ;
110+ }
111+
112+ internal string ResolveOutputDir ( string defaultDir ) =>
113+ Kind == OutputSinkKind . Directory ? FsPath ! : defaultDir ;
114+ }
115+
70116 private static void RunConvert ( string inputPath , string ? levelsRaw )
71117 {
72118 var fullPath = Path . GetFullPath ( inputPath . Trim ( ) ) ;
@@ -139,9 +185,22 @@ private static void RunConvertTxtFile(string inputPath, string? levelsRaw)
139185 var text = File . ReadAllText ( inputPath , Encoding . UTF8 ) ;
140186
141187 if ( LooksLikeMaidata ( text ) )
142- ConvertMaidata ( text , inputDir , levelFilter , inputPath ) ;
188+ {
189+ var maidata = new Maidata ( text ) ;
190+ var ids = maidata . Levels . Keys . OrderBy ( k => k ) . ToList ( ) ;
191+ if ( ids . Count == 0 ) throw new ArgumentException ( "maidata 中未找到任何 &inote_* 谱面。" ) ;
192+ var selected = levelFilter == null ? ids : ids . Where ( id => levelFilter . Contains ( id ) ) . ToList ( ) ;
193+ if ( selected . Count == 0 ) throw new ArgumentException ( "-l / --levels 指定的难度在文件中均不存在。" ) ;
194+ ValidateOutputForMa2Targets ( selected . Count ) ;
195+
196+ ConvertMaidata ( maidata , selected , inputDir , inputPath ) ;
197+ }
143198 else
144- ConvertPlainSimai ( text , inputDir , levelFilter , inputPath ) ;
199+ {
200+ if ( levelFilter != null ) throw new ArgumentException ( "纯 simai 单谱(非 maidata)不能使用 -l / --levels。" ) ;
201+ ValidateOutputForMa2Targets ( 1 ) ;
202+ ConvertPlainSimai ( text , inputDir , inputPath ) ;
203+ }
145204 }
146205
147206 /// <summary>
@@ -201,14 +260,20 @@ private static void ConvertMa2PathsToMaidata(string outputDir, string title, IRe
201260 . OrderBy ( t => t . LevelId )
202261 . Where ( ( _ , lv ) => levelFilter == null || levelFilter . Contains ( lv ) )
203262 . ToList ( ) ;
204- var outPath = Path . Combine ( outputDir , "maidata.txt" ) ;
263+
264+ if ( assignments . Count == 0 ) throw new ArgumentException ( "-l / --levels 过滤后没有可转换的 .ma2 文件。" ) ;
265+ ValidateOutputForMaidataTxt ( ) ;
266+
267+ var baseDir = _outputSpec . ResolveOutputDir ( outputDir ) ;
268+ var diskPath = _outputSpec . Kind == OutputSinkKind . File ? _outputSpec . FsPath ! : Path . Combine ( baseDir , "maidata.txt" ) ;
269+ var destNote = _outputSpec . Kind == OutputSinkKind . Stdout ? "(标准输出)" : diskPath ;
205270
206271 int clockCount = 4 ;
207272 var inoteBlocks = new List < ( int LevelId , string Inote ) > ( ) ;
208273
209274 foreach ( var ( fullPath , levelId ) in assignments )
210275 {
211- Console . WriteLine ( $ "Simai → MA2: { fullPath } (lv{ levelId } ) → { outPath } ") ;
276+ Console . Error . WriteLine ( $ "Simai → MA2: { fullPath } (lv{ levelId } ) → { destNote } ") ;
212277 var ma2Text = File . ReadAllText ( fullPath , Encoding . UTF8 ) ;
213278 var ( chart , parseAlerts ) = new MA2Parser ( ) . Parse ( ma2Text ) ;
214279 PrintAlerts ( parseAlerts ) ;
@@ -224,7 +289,10 @@ private static void ConvertMa2PathsToMaidata(string outputDir, string title, IRe
224289 maidata [ "clock_count" ] = clockCount . ToString ( ) ;
225290 foreach ( var ( levelId , inote ) in inoteBlocks )
226291 maidata . AddLevel ( levelId , new MaidataChart ( inote ) ) ;
227- File . WriteAllText ( outPath , maidata . ToString ( ) , new UTF8Encoding ( encoderShouldEmitUTF8Identifier : false ) ) ;
292+
293+ var maidataText = maidata . ToString ( ) ;
294+ if ( _outputSpec . Kind == OutputSinkKind . Stdout ) Console . Out . Write ( maidataText ) ;
295+ else File . WriteAllText ( diskPath , maidataText , new UTF8Encoding ( encoderShouldEmitUTF8Identifier : false ) ) ;
228296 }
229297
230298 private static HashSet < int > ParseLevelList ( string s )
@@ -272,42 +340,56 @@ private static void PrintAlerts(IReadOnlyList<Alert> alerts, string? header = nu
272340 Console . Error . WriteLine ( a . ToString ( ) ) ;
273341 }
274342
275- private static void ConvertMaidata ( string text , string outputDir , HashSet < int > ? levelFilter , string inputPath )
343+ private static void ConvertMaidata ( Maidata maidata , IReadOnlyList < int > selected , string inputDir , string inputPath )
276344 {
277- var maidata = new Maidata ( text ) ;
278- var ids = maidata . Levels . Keys . OrderBy ( k => k ) . ToList ( ) ;
279- if ( ids . Count == 0 )
280- throw new ArgumentException ( "maidata 中未找到任何 &inote_* 谱面。" ) ;
281-
282- var selected = levelFilter == null
283- ? ids
284- : ids . Where ( id => levelFilter . Contains ( id ) ) . ToList ( ) ;
285-
286- if ( selected . Count == 0 )
287- throw new ArgumentException ( "-l / --levels 指定的难度在文件中均不存在。" ) ;
288-
345+ var baseDir = _outputSpec . ResolveOutputDir ( inputDir ) ;
289346 foreach ( var id in selected )
290347 {
291- var outPath = Path . Combine ( outputDir , $ "lv_{ id } .ma2") ;
292- Console . WriteLine ( $ "Simai → MA2: { inputPath } (lv${ id } ) → { outPath } ") ;
348+ var outPath = _outputSpec . Kind == OutputSinkKind . File ? _outputSpec . FsPath ! : Path . Combine ( baseDir , $ "lv_{ id } .ma2") ;
349+ var destNote = _outputSpec . Kind == OutputSinkKind . Stdout ? "(标准输出)" : outPath ;
350+ Console . Error . WriteLine ( $ "Simai → MA2: { inputPath } (lv{ id } ) → { destNote } ") ;
293351 var chartInfo = maidata . Levels [ id ] ;
294352 var bigTouch = id is 2 or 3 ;
295353 var isUtage = IsUtageFromLevelString ( chartInfo . Level ) ;
296354 var ma2 = SimaiToMa2 ( chartInfo . Inote , maidata . ClockCount , bigTouch , isUtage ) ;
297- File . WriteAllText ( outPath , ma2 , new UTF8Encoding ( encoderShouldEmitUTF8Identifier : false ) ) ;
355+ if ( _outputSpec . Kind == OutputSinkKind . Stdout ) Console . Out . Write ( ma2 ) ;
356+ else File . WriteAllText ( outPath , ma2 , new UTF8Encoding ( encoderShouldEmitUTF8Identifier : false ) ) ;
298357 }
299358 }
300359
301- private static void ConvertPlainSimai ( string text , string outputDir , HashSet < int > ? levelFilter , string inputPath )
360+ private static void ConvertPlainSimai ( string text , string inputDir , string inputPath )
302361 {
303- if ( levelFilter != null )
304- throw new ArgumentException ( "纯 simai 单谱(非 maidata)不能使用 -l / --levels。" ) ;
305-
306362 const int outputLevel = 0 ;
307- var outPath = Path . Combine ( outputDir , $ "lv_{ outputLevel } .ma2") ;
308- Console . WriteLine ( $ "Simai → MA2: { inputPath } (lv${ outputLevel } ) → { outPath } ") ;
363+ var baseDir = _outputSpec . ResolveOutputDir ( inputDir ) ;
364+ var outPath = _outputSpec . Kind == OutputSinkKind . File ? _outputSpec . FsPath ! : Path . Combine ( baseDir , $ "lv_{ outputLevel } .ma2") ;
365+ var destNote = _outputSpec . Kind == OutputSinkKind . Stdout ? "(标准输出)" : outPath ;
366+ Console . Error . WriteLine ( $ "Simai → MA2: { inputPath } (lv{ outputLevel } ) → { destNote } ") ;
309367 var ma2 = SimaiToMa2 ( text ) ;
310- File . WriteAllText ( outPath , ma2 , new UTF8Encoding ( encoderShouldEmitUTF8Identifier : false ) ) ;
368+ if ( _outputSpec . Kind == OutputSinkKind . Stdout ) Console . Out . Write ( ma2 ) ;
369+ else File . WriteAllText ( outPath , ma2 , new UTF8Encoding ( encoderShouldEmitUTF8Identifier : false ) ) ;
370+ }
371+
372+ private static void ValidateOutputForMa2Targets ( int ma2FileCount )
373+ {
374+ if ( _outputSpec . Kind == OutputSinkKind . Stdout && ma2FileCount != 1 )
375+ throw new ArgumentException ( $ "-o \" -\" 仅适用于恰好输出一个 MA2 文件的情况(当前会输出 { ma2FileCount } 个)。请通过-l指定难度,或改为指定-o为一个目录。") ;
376+ if ( _outputSpec . Kind == OutputSinkKind . File && ma2FileCount != 1 )
377+ throw new ArgumentException ( $ "使用 -o 指定输出为文件时,本次必须只生成一个 MA2 文件(当前会生成 { ma2FileCount } 个)。请通过-l指定难度,或改为指定-o为一个目录。") ;
378+ if ( _outputSpec . Kind == OutputSinkKind . File )
379+ ValidateOutputFileExtension ( _outputSpec . FsPath ! , ".ma2" ) ;
380+ }
381+
382+ private static void ValidateOutputForMaidataTxt ( )
383+ {
384+ if ( _outputSpec . Kind == OutputSinkKind . File )
385+ ValidateOutputFileExtension ( _outputSpec . FsPath ! , ".txt" ) ;
386+ }
387+
388+ private static void ValidateOutputFileExtension ( string filePath , string requiredExt )
389+ {
390+ var ext = Path . GetExtension ( filePath ) ;
391+ if ( ! string . Equals ( ext , requiredExt , StringComparison . OrdinalIgnoreCase ) )
392+ throw new ArgumentException ( $ "输出文件扩展名须为「{ requiredExt } 」,当前为「{ ( string . IsNullOrEmpty ( ext ) ? "(无)" : ext ) } 」。") ;
311393 }
312394
313395 private static string SimaiToMa2 ( string inote , int clockCount = 4 , bool bigTouch = false , bool isUtage = false )
0 commit comments