diff --git a/src/ui/flutter_app/lib/game_page/services/controller/game_controller.dart b/src/ui/flutter_app/lib/game_page/services/controller/game_controller.dart index b4f13ab88..d83a8a237 100644 --- a/src/ui/flutter_app/lib/game_page/services/controller/game_controller.dart +++ b/src/ui/flutter_app/lib/game_page/services/controller/game_controller.dart @@ -69,6 +69,7 @@ class GameController { ValueNotifier(null); String? get initialSharingMoveList => _initialSharingMoveList; + set initialSharingMoveList(String? list) { _initialSharingMoveList = list; initialSharingMoveListNotifier.value = list; @@ -79,9 +80,11 @@ class GameController { late AnimationManager animationManager; bool _isInitialized = false; + bool get initialized => _isInitialized; bool get isPositionSetup => gameRecorder.setupPosition != null; + void clearPositionSetupFlag() => gameRecorder.setupPosition = null; @visibleForTesting @@ -427,5 +430,5 @@ class GameController { /// Starts a game export. static Future export(BuildContext context) async => - ImportService.exportGame(context); + ExportService.exportGame(context); } diff --git a/src/ui/flutter_app/lib/game_page/services/controller/game_responses.dart b/src/ui/flutter_app/lib/game_page/services/controller/game_responses.dart index b7ccb0ba4..8a4c78d91 100644 --- a/src/ui/flutter_app/lib/game_page/services/controller/game_responses.dart +++ b/src/ui/flutter_app/lib/game_page/services/controller/game_responses.dart @@ -120,11 +120,3 @@ class HistoryRange implements HistoryResponse { return "${HistoryResponse.tag} Current is equal to moveIndex."; } } - -/// Custom response to throw when importing the game history. -abstract class ImportResponse {} - -class ImportFormatException extends FormatException { - const ImportFormatException([String? source, int? offset]) - : super("Cannot import ", source, offset); -} diff --git a/src/ui/flutter_app/lib/game_page/services/engine/position.dart b/src/ui/flutter_app/lib/game_page/services/engine/position.dart index 1ca00093c..a5a0c4bed 100644 --- a/src/ui/flutter_app/lib/game_page/services/engine/position.dart +++ b/src/ui/flutter_app/lib/game_page/services/engine/position.dart @@ -139,14 +139,17 @@ class Position { ExtMove? _record; static List>> get _millTable => _Mills.millTableInit; + static List> get _adjacentSquares => _Mills.adjacentSquaresInit; static List> get _millLinesHV => _Mills._horizontalAndVerticalLines; + static List> get _millLinesD => _Mills._diagonalLines; PieceColor pieceOnGrid(int index) => _grid[index]; PieceColor get sideToMove => _sideToMove; + set sideToMove(PieceColor color) { _sideToMove = color; _them = _sideToMove.opponent; diff --git a/src/ui/flutter_app/lib/game_page/services/import_export/export_service.dart b/src/ui/flutter_app/lib/game_page/services/import_export/export_service.dart new file mode 100644 index 000000000..fc388efe9 --- /dev/null +++ b/src/ui/flutter_app/lib/game_page/services/import_export/export_service.dart @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2019-2025 The Sanmill developers (see AUTHORS file) + +// export_service.dart + +part of '../mill.dart'; + +class ExportService { + const ExportService._(); + + /// Exports the game to the device's clipboard. + static Future exportGame(BuildContext context) async { + await Clipboard.setData( + ClipboardData(text: GameController().gameRecorder.moveHistoryText), + ); + + if (!context.mounted) { + return; + } + + rootScaffoldMessengerKey.currentState! + .showSnackBarClear(S.of(context).moveHistoryCopied); + + Navigator.pop(context); + } +} diff --git a/src/ui/flutter_app/lib/game_page/services/import_export/import_exceptions.dart b/src/ui/flutter_app/lib/game_page/services/import_export/import_exceptions.dart new file mode 100644 index 000000000..fee7a198e --- /dev/null +++ b/src/ui/flutter_app/lib/game_page/services/import_export/import_exceptions.dart @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2019-2025 The Sanmill developers (see AUTHORS file) + +// import_exceptions.dart + +part of '../mill.dart'; + +/// Custom response to throw when importing the game history. +abstract class ImportResponse {} + +class ImportFormatException extends FormatException { + const ImportFormatException([String? source, int? offset]) + : super("Cannot import ", source, offset); +} diff --git a/src/ui/flutter_app/lib/game_page/services/import_export/import_export_service.dart b/src/ui/flutter_app/lib/game_page/services/import_export/import_export_service.dart deleted file mode 100644 index ec537b1bb..000000000 --- a/src/ui/flutter_app/lib/game_page/services/import_export/import_export_service.dart +++ /dev/null @@ -1,471 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// Copyright (C) 2019-2025 The Sanmill developers (see AUTHORS file) - -// import_export_service.dart - -// ignore_for_file: use_build_context_synchronously - -part of '../mill.dart'; - -// TODO: [Leptopoda] Clean up the file -class ImportService { - const ImportService._(); - - static const String _logTag = "[Importer]"; - - /// Exports the game to the device's clipboard. - static Future exportGame(BuildContext context) async { - await Clipboard.setData( - ClipboardData(text: GameController().gameRecorder.moveHistoryText), - ); - - rootScaffoldMessengerKey.currentState! - .showSnackBarClear(S.of(context).moveHistoryCopied); - - Navigator.pop(context); - } - - /// Tries to import the game saved in the device's clipboard. - static Future importGame(BuildContext context) async { - rootScaffoldMessengerKey.currentState!.clearSnackBars(); - - final ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain); - - if (data?.text == null) { - Navigator.pop(context); - return; - } - - try { - import(data!.text!); // GameController().newRecorder = newHistory; - } catch (exception) { - final String tip = S.of(context).cannotImport(data!.text!); - GameController().headerTipNotifier.showTip(tip); - //GameController().animationController.forward(); - Navigator.pop(context); - return; - } - - await HistoryNavigator.takeBackAll(context, pop: false); - - if (await HistoryNavigator.stepForwardAll(context, pop: false) == - const HistoryOK()) { - GameController().headerTipNotifier.showTip(S.of(context).gameImported); - } else { - final String tip = - S.of(context).cannotImport(HistoryNavigator.importFailedStr); - GameController().headerTipNotifier.showTip(tip); - HistoryNavigator.importFailedStr = ""; - } - - Navigator.pop(context); - } - - static String _wmdNotationToMoveString(String wmd) { - if (wmd.length == 3 && wmd[0] == "x") { - if (wmdNotationToMove[wmd.substring(1, 3)] != null) { - return "-${wmdNotationToMove[wmd.substring(1, 3)]!}"; - } - } else if (wmd.length == 2) { - if (wmdNotationToMove[wmd] != null) { - return wmdNotationToMove[wmd]!; - } - } else if (wmd.length == 5 && wmd[2] == "-") { - if (wmdNotationToMove[wmd.substring(0, 2)] != null && - wmdNotationToMove[wmd.substring(3, 5)] != null) { - return "${wmdNotationToMove[wmd.substring(0, 2)]!}->${wmdNotationToMove[wmd.substring(3, 5)]!}"; - } - } else if ((wmd.length == 8 && wmd[2] == "-" && wmd[5] == "x") || - (wmd.length == 5 && wmd[2] == "x")) { - logger.w("$_logTag Not support parsing format oo-ooxo notation."); - throw ImportFormatException(wmd); - } - throw ImportFormatException(wmd); - } - - static String _playOkNotationToMoveString(String playOk) { - if (playOk.isEmpty) { - throw ImportFormatException(playOk); - } - - final int iDash = playOk.indexOf("-"); - final int iX = playOk.indexOf("x"); - - if (iDash == -1 && iX == -1) { - // 12 - final int val = int.parse(playOk); - if (val >= 1 && val <= 24) { - return playOkNotationToMove[playOk]!; - } else { - throw ImportFormatException(playOk); - } - } - - if (iX == 0) { - // x12 - final String sub = playOk.substring(1); - final int val = int.parse(sub); - if (val >= 1 && val <= 24) { - return "-${playOkNotationToMove[sub]!}"; - } else { - throw ImportFormatException(playOk); - } - } - if (iDash != -1 && iX == -1) { - String? move; - // 12-13 - final String sub1 = playOk.substring(0, iDash); - final int val1 = int.parse(sub1); - if (val1 >= 1 && val1 <= 24) { - move = playOkNotationToMove[sub1]; - } else { - throw ImportFormatException(playOk); - } - - final String sub2 = playOk.substring(iDash + 1); - final int val2 = int.parse(sub2); - if (val2 >= 1 && val2 <= 24) { - return "$move->${playOkNotationToMove[sub2]!}"; - } else { - throw ImportFormatException(playOk); - } - } - - logger.w("$_logTag Not support parsing format oo-ooxo PlayOK notation."); - throw ImportFormatException(playOk); - } - - static bool _isPureFen(String text) { - if (text.length >= - "********/********/******** w p p 9 0 9 0 0 0 0 0 0 0 0 0".length && - (text.contains("/") && - text[8] == "/" && - text[17] == "/" && - text[26] == " ")) { - return true; - } - - return false; - } - - static bool _isPgnMoveList(String text) { - if (text.length >= 15 && - (text.contains("[Event") || - text.contains("[White") || - text.contains("[FEN"))) { - return true; - } - - return false; - } - - static bool _isFenMoveList(String text) { - if (text.length >= 15 && (text.contains("[FEN"))) { - return true; - } - - return false; - } - - static bool _isPlayOkMoveList(String text) { - // See https://www.playok.com/en/mill/#t/f - - if (text.contains("PlayOK")) { - return true; - } - - final String noTag = removeTagPairs(text); - - if (noTag.contains("1.") == false) { - return false; - } - - if (noTag == "" || - noTag.contains("a") || - noTag.contains("b") || - noTag.contains("c") || - noTag.contains("d") || - noTag.contains("e") || - noTag.contains("f") || - noTag.contains("g")) { - return false; - } - - if (noTag == "" || - noTag.contains("A") || - noTag.contains("B") || - noTag.contains("C") || - noTag.contains("D") || - noTag.contains("E") || - noTag.contains("F") || - noTag.contains("G")) { - return false; - } - - return true; - } - - static bool _isGoldTokenMoveList(String text) { - // Example: https://www.goldtoken.com/games/play?g=13097650;print=yes - - return text.contains("GoldToken") || - text.contains("Past Moves") || - text.contains("Go to") || - text.contains("Turn") || - text.contains("(Player "); - } - - static String addTagPairs(String moveList) { - final DateTime dateTime = DateTime.now(); - final String date = "${dateTime.year}.${dateTime.month}.${dateTime.day}"; - - final int total = Position.score[PieceColor.white]! + - Position.score[PieceColor.black]! + - Position.score[PieceColor.draw]!; - - final Game gameInstance = GameController().gameInstance; - final Player whitePlayer = gameInstance.getPlayerByColor(PieceColor.white); - final Player blackPlayer = gameInstance.getPlayerByColor(PieceColor.black); - - String white; - String black; - String result; - - if (whitePlayer.isAi) { - white = "AI"; - } else { - white = "Human"; - } - - if (blackPlayer.isAi) { - black = "AI"; - } else { - black = "Human"; - } - - switch (GameController().position.winner) { - case PieceColor.white: - result = "1-0"; - break; - case PieceColor.black: - result = "0-1"; - break; - case PieceColor.draw: - result = "1/2-1/2"; - break; - case PieceColor.marked: - case PieceColor.none: - case PieceColor.nobody: - result = "*"; - break; - } - - String tagPairs = '[Event "Sanmill-Game"]\r\n' - '[Site "Sanmill"]\r\n' - '[Date "$date"]\r\n' - '[Round "$total"]\r\n' - '[White "$white"]\r\n' - '[Black "$black"]\r\n' - '[Result "$result"]\r\n'; - - if (!(moveList.length > 3 && moveList.startsWith("[FEN"))) { - tagPairs = "$tagPairs\r\n"; - } - - return tagPairs + moveList; - } - - static String getTagPairs(String pgn) { - return pgn.substring(0, pgn.lastIndexOf("]") + 1); - } - - static String removeTagPairs(String pgn) { - // If the string does not start with '[', return it as is - if (pgn.startsWith("[") == false) { - return pgn; - } - - // Find the position of the last ']' - final int lastBracketPos = pgn.lastIndexOf("]"); - if (lastBracketPos == -1) { - return pgn; // Return as is if there is no ']' - } - - // Get the substring after the last ']' - String ret = pgn.substring(lastBracketPos + 1); - - // Find the first position that is not a space or newline after the last ']' - int begin = 0; - while (begin < ret.length && - (ret[begin] == ' ' || ret[begin] == '\r' || ret[begin] == '\n')) { - begin++; - } - - // If no valid position is found, return an empty string - if (begin == ret.length) { - return ""; - } - - // Get the substring from the first non-space and non-newline character - ret = ret.substring(begin); - - return ret; - } - - static void import(String moveList) { - moveList = moveList.replaceAll(RegExp(r'^\s*[\r\n]+'), ''); - String ml = moveList; - final String? fen = GameController().position.fen; - String? setupFen; - - logger.t("Clipboard text: $moveList"); - - if (_isPlayOkMoveList(moveList)) { - return _importPlayOk(moveList); - } - - if (_isFenMoveList(moveList)) { - setupFen = moveList.substring(moveList.indexOf("FEN")); - setupFen = setupFen.substring(5); - setupFen = setupFen.substring(0, setupFen.indexOf('"]')); - GameController().position.setFen(setupFen); - } - - if (_isPureFen(moveList)) { - setupFen = moveList; - GameController().position.setFen(setupFen); - ml = ""; - } - - if (_isPgnMoveList(moveList)) { - ml = removeTagPairs(moveList); - } - - if (_isGoldTokenMoveList(moveList)) { - int start = moveList.indexOf("1\t"); - - if (start == -1) { - start = moveList.indexOf("1 "); - } - - if (start == -1) { - start = 0; - } - - ml = moveList.substring(start); - - // Remove "Quick Jump" and any text after it to ensure successful import - final int quickJumpIndex = ml.indexOf("Quick Jump"); - if (quickJumpIndex != -1) { - ml = ml.substring(0, quickJumpIndex).trim(); - } - } - - // TODO: Is it will cause what? - final GameRecorder newHistory = GameRecorder( - lastPositionWithRemove: setupFen ?? fen, setupPosition: setupFen); - final List list = ml - .toLowerCase() - .replaceAll("\n", " ") - .replaceAll(",", " ") - .replaceAll(";", " ") - .replaceAll("!", " ") - .replaceAll("?", " ") - .replaceAll("#", " ") - .replaceAll("()", " ") - .replaceAll("white", " ") - .replaceAll("black", " ") - .replaceAll("win", " ") - .replaceAll("lose", " ") - .replaceAll("draw", " ") - .replaceAll("resign", " ") - .replaceAll("-/x", "x") - .replaceAll("/x", "x") - .replaceAll("x", " x") - .replaceAll(".a", ". a") - .replaceAll(".b", ". b") - .replaceAll(".c", ". c") - .replaceAll(".d", ". d") - .replaceAll(".e", ". e") - .replaceAll(".f", ". f") - .replaceAll(".g", ". g") - // GoldToken - .replaceAll("\t", " ") - .replaceAll("place to ", "") - .replaceAll(" take ", " x") - .replaceAll(" -> ", "-") - // Finally - .split(" "); - - for (String i in list) { - i = i.trim(); - - if (int.tryParse(i) != null) { - i = "$i."; - } - - // TODO: [Leptopoda] Deduplicate - if (i.isNotEmpty && !i.endsWith(".")) { - final String m = _wmdNotationToMoveString(i); - newHistory.add(ExtMove(m)); - } - } - - // TODO: Is this judge necessary? - if (newHistory.isNotEmpty || setupFen != "") { - GameController().newGameRecorder = newHistory; - } - - // TODO: Just a patch. Let status is setupPosition. - // The judgment of whether it is in the setupPosition state is based on this, not newRecorder. - if (setupFen != "") { - GameController().gameRecorder.setupPosition = setupFen; - } - } - - static String removeGameResultAndReplaceLineBreaks(String moveList) { - final String ret = moveList - .replaceAll("\n", " ") - .replaceAll(" 1/2-1/2", "") - .replaceAll(" 1-0", "") - .replaceAll(" 0-1", "") - .replaceAll("TXT", ""); - return ret; - } - - static String cleanup(String moveList) { - return removeGameResultAndReplaceLineBreaks(removeTagPairs(moveList)); - } - - static void _importPlayOk(String moveList) { - final GameRecorder newHistory = - GameRecorder(lastPositionWithRemove: GameController().position.fen); - - final List list = cleanup(moveList).split(" "); - - for (String i in list) { - i = i.trim(); - - if (i.isNotEmpty && - !i.endsWith(".") && - !i.startsWith("[") && - !i.endsWith("]")) { - final int iX = i.indexOf("x"); - if (iX == -1) { - final String m = _playOkNotationToMoveString(i); - newHistory.add(ExtMove(m)); - } else if (iX != -1) { - final String m1 = _playOkNotationToMoveString(i.substring(0, iX)); - newHistory.add(ExtMove(m1)); - - final String m2 = _playOkNotationToMoveString(i.substring(iX)); - newHistory.add(ExtMove(m2)); - } - } - } - - if (newHistory.isNotEmpty) { - GameController().newGameRecorder = newHistory; - } - } -} diff --git a/src/ui/flutter_app/lib/game_page/services/import_export/import_helpers.dart b/src/ui/flutter_app/lib/game_page/services/import_export/import_helpers.dart new file mode 100644 index 000000000..afcaad5a2 --- /dev/null +++ b/src/ui/flutter_app/lib/game_page/services/import_export/import_helpers.dart @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2019-2025 The Sanmill developers (see AUTHORS file) + +// import_helpers.dart + +import '../../../shared/utils/helpers/string_helpers/string_helper.dart'; + +bool isPureFen(String text) { + if (text.length >= + "********/********/******** w p p 9 0 9 0 0 0 0 0 0 0 0 0".length && + (text.contains("/") && + text[8] == "/" && + text[17] == "/" && + text[26] == " ")) { + return true; + } + + return false; +} + +bool hasTagPairs(String text) { + if (text.length >= 15 && + (text.contains("[Event") || + text.contains("[White") || + text.contains("[FEN"))) { + return true; + } + + return false; +} + +bool isFenMoveList(String text) { + if (text.length >= 15 && (text.contains("[FEN"))) { + return true; + } + + return false; +} + +bool isPlayOkMoveList(String text) { + // See https://www.playok.com/en/mill/#t/f + + // Check for PlayOK identifier + if (text.contains('[Site "PlayOK"]')) { + return true; + } + + text = removeBracketedContent(text); + final String noTag = removeTagPairs(text); + + // Must contain "1." + if (!noTag.contains("1.")) { + return false; + } + + // Must not be empty and must not contain any letters a-g or A-G + if (noTag.isEmpty || RegExp(r'[a-gA-G]').hasMatch(noTag)) { + return false; + } + + return true; +} + +bool isGoldTokenMoveList(String text) { + // Example: https://www.goldtoken.com/games/play?g=13097650;print=yes + + text = removeBracketedContent(text); + + return text.contains("GoldToken") || + text.contains("Place to") || + text.contains(", take ") || + text.contains(" -> "); +} + +String getTagPairs(String pgn) { + // Find the index of the first '[' + final int firstBracket = pgn.indexOf('['); + + // Find the index of the last ']' + final int lastBracket = pgn.lastIndexOf(']'); + + // Check if both brackets are found and properly ordered + if (firstBracket != -1 && lastBracket != -1 && lastBracket > firstBracket) { + // Extract and return the substring from the first '[' to the last ']' + return pgn.substring(firstBracket, lastBracket + 1); + } + + // Return an empty string or handle the error as needed + return ''; +} + +String removeTagPairs(String pgn) { + // Check if the PGN starts with '[' indicating the presence of tag pairs + if (!pgn.startsWith("[")) { + return pgn; + } + + // Find the position of the last ']' + final int lastBracketPos = pgn.lastIndexOf("]"); + if (lastBracketPos == -1) { + return pgn; // No closing ']', return as is + } + + // Extract the substring after the last ']' and trim leading whitespace + return pgn.substring(lastBracketPos + 1).trimLeft(); +} diff --git a/src/ui/flutter_app/lib/game_page/services/import_export/import_service.dart b/src/ui/flutter_app/lib/game_page/services/import_export/import_service.dart new file mode 100644 index 000000000..eaeb92493 --- /dev/null +++ b/src/ui/flutter_app/lib/game_page/services/import_export/import_service.dart @@ -0,0 +1,361 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2019-2025 The Sanmill developers (see AUTHORS file) + +// import_service.dart + +part of '../mill.dart'; + +class ImportService { + const ImportService._(); + + static const String _logTag = "[Importer]"; + + /// Tries to import the game saved in the device's clipboard. + static Future importGame(BuildContext context) async { + // Clear snack bars before clipboard read + rootScaffoldMessengerKey.currentState?.clearSnackBars(); + + // Pre-fetch context-dependent data + final S s = S.of(context); + final NavigatorState navigator = Navigator.of(context); + + // Read clipboard data (async) + final ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain); + final String? text = data?.text; + + // Immediately check if context is still valid + if (!context.mounted) { + return; + } + + // If clipboard is empty or missing text, pop and return + if (text == null) { + navigator.pop(); + return; + } + + // Perform import logic + try { + import(text); // GameController().newRecorder = newHistory; + } catch (exception) { + if (!context.mounted) { + return; + } + final String tip = s.cannotImport(text); + GameController().headerTipNotifier.showTip(tip); + //GameController().animationController.forward(); + navigator.pop(); + return; + } + + // Check context again before using it in navigation or showing tips + if (!context.mounted) { + return; + } + + // Navigation or UI updates + await HistoryNavigator.takeBackAll(context, pop: false); + + if (!context.mounted) { + return; + } + + final HistoryResponse? historyResult = + await HistoryNavigator.stepForwardAll(context, + pop: false); // Error: Don't use 'BuildContext's across async gaps. + + if (historyResult == const HistoryOK()) { + GameController().headerTipNotifier.showTip(s.gameImported); + } else { + final String tip = s.cannotImport(HistoryNavigator.importFailedStr); + GameController().headerTipNotifier.showTip(tip); + HistoryNavigator.importFailedStr = ""; + } + + navigator.pop(); + } + + static String addTagPairs(String moveList) { + final DateTime dateTime = DateTime.now(); + final String date = "${dateTime.year}.${dateTime.month}.${dateTime.day}"; + + final int total = Position.score[PieceColor.white]! + + Position.score[PieceColor.black]! + + Position.score[PieceColor.draw]!; + + final Game gameInstance = GameController().gameInstance; + final Player whitePlayer = gameInstance.getPlayerByColor(PieceColor.white); + final Player blackPlayer = gameInstance.getPlayerByColor(PieceColor.black); + + String white; + String black; + String result; + + if (whitePlayer.isAi) { + white = "AI"; + } else { + white = "Human"; + } + + if (blackPlayer.isAi) { + black = "AI"; + } else { + black = "Human"; + } + + switch (GameController().position.winner) { + case PieceColor.white: + result = "1-0"; + break; + case PieceColor.black: + result = "0-1"; + break; + case PieceColor.draw: + result = "1/2-1/2"; + break; + case PieceColor.marked: + case PieceColor.none: + case PieceColor.nobody: + result = "*"; + break; + } + + String variantTag; + if (DB().ruleSettings.isLikelyNineMensMorris()) { + variantTag = '[Variant "Nine Men\'s Morris"]\r\n'; + } else if (DB().ruleSettings.isLikelyTwelveMensMorris()) { + variantTag = '[Variant "Twelve Men\'s Morris"]\r\n'; + } else if (DB().ruleSettings.isLikelyElFilja()) { + variantTag = '[Variant "El Filja"]\r\n'; + } else { + variantTag = ''; + } + + final String plyCountTag = + '[PlyCount "${GameController().gameRecorder.length}"]\r\n'; + + String tagPairs = '[Event "Sanmill-Game"]\r\n' + '[Site "Sanmill"]\r\n' + '[Date "$date"]\r\n' + '[Round "$total"]\r\n' + '[White "$white"]\r\n' + '[Black "$black"]\r\n' + '[Result "$result"]\r\n' + '$variantTag' + '$plyCountTag'; + + // Ensure an extra CRLF if moveList does not start with "[FEN" + if (!(moveList.length > 3 && moveList.startsWith("[FEN"))) { + tagPairs = "$tagPairs\r\n"; + } + + return tagPairs + moveList; + } + + static void import(String moveList) { + moveList = moveList.trim(); + String ml = moveList; + String? setupFen; + + logger.t("Clipboard text: $moveList"); + + // TODO: Improve this logic + if (isPlayOkMoveList(moveList)) { + return _importPlayOk(moveList); + } + + if (isPureFen(moveList)) { + setupFen = moveList; + GameController().position.setFen(setupFen); + ml = ""; + } + + if (isGoldTokenMoveList(moveList)) { + int start = moveList.indexOf("1\t"); + + if (start == -1) { + start = moveList.indexOf("1 "); + } + + if (start == -1) { + start = 0; + } + + ml = moveList.substring(start); + + // Remove "Quick Jump" and any text after it to ensure successful import + final int quickJumpIndex = ml.indexOf("Quick Jump"); + if (quickJumpIndex != -1) { + ml = ml.substring(0, quickJumpIndex).trim(); + } + } + + final Map replacements = { + "\n": " ", + "()": " ", + "white": " ", + "black": " ", + "win": " ", + "lose": " ", + "draw": " ", + "resign": " ", + "-/x": "x", + "/x": "x", + ".a": ". a", + ".b": ". b", + ".c": ". c", + ".d": ". d", + ".e": ". e", + ".f": ". f", + ".g": ". g", + // GoldToken + "\t": " ", + "Place to ": "", + ", take ": "x", + " -> ": "-" + }; + + ml = processOutsideBrackets(ml, replacements); + _importPgn(ml); + } + + static void _importPlayOk(String moveList) { + String cleanUpPlayOkMoveList(String moveList) { + moveList = removeTagPairs(moveList); + final String ret = moveList + .replaceAll("\n", " ") + .replaceAll(" 1/2-1/2", "") + .replaceAll(" 1-0", "") + .replaceAll(" 0-1", "") + .replaceAll("TXT", ""); + return ret; + } + + final GameRecorder newHistory = + GameRecorder(lastPositionWithRemove: GameController().position.fen); + + final List list = cleanUpPlayOkMoveList(moveList).split(" "); + + for (String i in list) { + i = i.trim(); + + if (i.isNotEmpty && + !i.endsWith(".") && + !i.startsWith("[") && + !i.endsWith("]")) { + final int iX = i.indexOf("x"); + if (iX == -1) { + final String m = _playOkNotationToMoveString(i); + newHistory.add(ExtMove(m)); + } else if (iX != -1) { + final String m1 = _playOkNotationToMoveString(i.substring(0, iX)); + newHistory.add(ExtMove(m1)); + + final String m2 = _playOkNotationToMoveString(i.substring(iX)); + newHistory.add(ExtMove(m2)); + } + } + } + + if (newHistory.isNotEmpty) { + GameController().newGameRecorder = newHistory; + } + } + + /// For standard PGN strings containing headers and moves, parse them + /// with the pgn.dart parser, convert SAN moves to UCI notation, and store. + static void _importPgn(String moveList) { + // Parse entire PGN (including headers) + final PgnGame game = PgnGame.parsePgn(moveList); + + // If there's a FEN in headers, set up the Position from that FEN + final String? fen = game.headers['FEN']; + final GameRecorder newHistory = GameRecorder( + lastPositionWithRemove: fen ?? GameController().position.fen, + setupPosition: fen, + ); + + if (fen != null && fen.isNotEmpty) { + GameController().position.setFen(fen); + } + + /// Helper function to split SAN moves correctly + List splitSan(String san) { + List segments = []; + + if (san.contains('x')) { + if (san.startsWith('x')) { + // All segments start with 'x' + final RegExp regex = RegExp(r'(x[a-g][1-7])'); + segments = regex + .allMatches(san) + .map((RegExpMatch m) => m.group(0)!) + .toList(); + } else { + final int firstX = san.indexOf('x'); + if (firstX > 0) { + // First segment is before the first 'x' + final String firstSegment = san.substring(0, firstX); + segments.add(firstSegment); + // Remaining part: extract all 'x' followed by two characters + final RegExp regex = RegExp(r'(x[a-g][1-7])'); + final String remainingSan = san.substring(firstX); + segments.addAll(regex + .allMatches(remainingSan) + .map((RegExpMatch m) => m.group(0)!) + .toList()); + } else { + // 'x' exists but at position 0 + final RegExp regex = RegExp(r'(x[a-g][1-7])'); + segments = regex + .allMatches(san) + .map((RegExpMatch m) => m.group(0)!) + .toList(); + } + } + } else { + // No 'x', process as single segment + segments.add(san); + } + + return segments; + } + + // Convert each SAN to your WMD notation and add to newHistory + for (final PgnNodeData node in game.moves.mainline()) { + final String san = node.san.trim().toLowerCase(); + if (san.isEmpty || + san == "*" || + san == "x" || + san == "xx" || + san == "xxx" || + san == "p") { + // Skip pass or asterisks + continue; + } + + final List segments = splitSan(san); + + for (final String segment in segments) { + if (segment.isEmpty) { + continue; + } + try { + final String uciMove = _wmdNotationToMoveString(segment); + newHistory.add(ExtMove(uciMove)); + } catch (e) { + logger.e("$_logTag Failed to parse move segment '$segment': $e"); + throw ImportFormatException("Invalid move segment: $segment"); + } + } + } + + if (newHistory.isNotEmpty || (fen != null && fen.isNotEmpty)) { + GameController().newGameRecorder = newHistory; + } + + if (fen != null && fen.isNotEmpty) { + GameController().gameRecorder.setupPosition = fen; + } + } +} diff --git a/src/ui/flutter_app/lib/game_page/services/import_export/notation_parsing.dart b/src/ui/flutter_app/lib/game_page/services/import_export/notation_parsing.dart new file mode 100644 index 000000000..47282e39b --- /dev/null +++ b/src/ui/flutter_app/lib/game_page/services/import_export/notation_parsing.dart @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2019-2025 The Sanmill developers (see AUTHORS file) + +// notation_parsing.dart + +part of '../mill.dart'; + +const String _logTag = "[NotationParsing]"; + +String _wmdNotationToMoveString(String wmd) { + // Handle capture moves starting with 'x' e.g., 'xd1' + if (wmd.startsWith('x') && wmd.length == 3) { + final String target = wmd.substring(1, 3); + final String? mapped = wmdNotationToMove[target]; + if (mapped != null) { + return "-$mapped"; + } + logger.w("$_logTag Unknown capture target: $wmd"); + throw ImportFormatException(wmd); + } + + // Handle from-to moves e.g., 'a1-a4' + if (wmd.length == 5 && wmd[2] == '-') { + final String from = wmd.substring(0, 2); + final String to = wmd.substring(3, 5); + final String? mappedFrom = wmdNotationToMove[from]; + final String? mappedTo = wmdNotationToMove[to]; + if (mappedFrom != null && mappedTo != null) { + return "$mappedFrom->$mappedTo"; + } + logger.w("$_logTag Unknown move from or to: $wmd"); + throw ImportFormatException(wmd); + } + + // Handle simple moves without captures e.g., 'a1' + if (wmd.length == 2) { + final String? mapped = wmdNotationToMove[wmd]; + if (mapped != null) { + return mapped; + } + logger.w("$_logTag Unknown move: $wmd"); + throw ImportFormatException(wmd); + } + + // Handle unsupported formats + if ((wmd.length == 8 && wmd[2] == '-' && wmd[5] == 'x') || + (wmd.length == 5 && wmd[2] == 'x')) { + logger.w("$_logTag Not support parsing format oo-ooxo notation: $wmd"); + throw ImportFormatException(wmd); + } + + // If none of the above conditions are met, throw an exception + logger.w("$_logTag Not support parsing format: $wmd"); + throw ImportFormatException(wmd); +} + +String _playOkNotationToMoveString(String playOk) { + if (playOk.isEmpty) { + throw ImportFormatException(playOk); + } + + final int iDash = playOk.indexOf("-"); + final int iX = playOk.indexOf("x"); + + if (iDash == -1 && iX == -1) { + // 12 + final int val = int.parse(playOk); + if (val >= 1 && val <= 24) { + return playOkNotationToMove[playOk]!; + } else { + throw ImportFormatException(playOk); + } + } + + if (iX == 0) { + // x12 + final String sub = playOk.substring(1); + final int val = int.parse(sub); + if (val >= 1 && val <= 24) { + return "-${playOkNotationToMove[sub]!}"; + } else { + throw ImportFormatException(playOk); + } + } + if (iDash != -1 && iX == -1) { + String? move; + // 12-13 + final String sub1 = playOk.substring(0, iDash); + final int val1 = int.parse(sub1); + if (val1 >= 1 && val1 <= 24) { + move = playOkNotationToMove[sub1]; + } else { + throw ImportFormatException(playOk); + } + + final String sub2 = playOk.substring(iDash + 1); + final int val2 = int.parse(sub2); + if (val2 >= 1 && val2 <= 24) { + return "$move->${playOkNotationToMove[sub2]!}"; + } else { + throw ImportFormatException(playOk); + } + } + + logger.w("$_logTag Not support parsing format oo-ooxo PlayOK notation."); + throw ImportFormatException(playOk); +} diff --git a/src/ui/flutter_app/lib/game_page/services/import_export/pgn.dart b/src/ui/flutter_app/lib/game_page/services/import_export/pgn.dart index 96607925e..2498cd60e 100644 --- a/src/ui/flutter_app/lib/game_page/services/import_export/pgn.dart +++ b/src/ui/flutter_app/lib/game_page/services/import_export/pgn.dart @@ -9,6 +9,48 @@ import '../mill.dart'; typedef PgnHeaders = Map; +/// A small stub to replace references to an `fromPgn`. +/// Adjust if you have a different result-handling approach. + +/// Minimal stub to parse a string like `1-0` / `0-1` / `1/2-1/2` / `*`. +String fromPgn(String? result) { + if (result == '1-0' || result == '0-1' || result == '1/2-1/2') { + return result!; + } + return '*'; +} + +/// Return the same string for PGN writing. +String toPgnString(String result) => result; + +/// Minimal stub for a `Square` class. +/// Nine Men's Morris typically uses a-g,1-7. You can adjust as needed. +@immutable +class Square { + const Square(this.name); + final String name; + + static Square? parse(String str) { + // For a-g1-7 + if (str.length == 2) { + final int file = str.codeUnitAt(0); // 'a'..'g' + final int rank = str.codeUnitAt(1); // '1'..'7' + if (file >= 97 && file <= 103 && rank >= 49 && rank <= 55) { + return Square(str); + } + } + return null; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Square && runtimeType == other.runtimeType && name == other.name; + + @override + int get hashCode => name.hashCode; +} + /// A Portable Game Notation (PGN) representation adapted for Nine Men's Morris. /// /// A PGN game is composed of [PgnHeaders] and moves represented by a [PgnNode] tree. @@ -81,7 +123,7 @@ class PgnGame { final PgnNode moves; /// Create default headers of a PGN. - static PgnHeaders defaultHeaders() => { + static PgnHeaders defaultHeaders() => { 'Event': '?', 'Site': '?', 'Date': '????.??.??', @@ -96,22 +138,24 @@ class PgnGame { /// Parse a PGN string and return a [PgnGame]. /// - /// Provide a optional function [initHeaders] to create different headers other than the default. + /// Provide an optional function [initHeaders] to create different headers other than the default. /// /// The parser will interpret any input as a PGN, creating a tree of /// syntactically valid (but not necessarily legal) moves, skipping any invalid /// tokens. static PgnGame parsePgn(String pgn, {PgnHeaders Function() initHeaders = defaultHeaders}) { - final List> games = []; + final List> games = >[]; _PgnParser((PgnGame game) { games.add(game); }, initHeaders) .parse(pgn); if (games.isEmpty) { - return PgnGame( - headers: initHeaders(), moves: PgnNode(), comments: const []); + return PgnGame( + headers: initHeaders(), + moves: PgnNode(), + comments: const []); } return games[0]; } @@ -119,18 +163,18 @@ class PgnGame { /// Parse a multi game PGN string. /// /// Returns a list of [PgnGame]. - /// Provide a optional function [initHeaders] to create different headers other than the default + /// Provide an optional function [initHeaders] to create different headers other than the default /// /// The parser will interpret any input as a PGN, creating a tree of /// syntactically valid (but not necessarily legal) moves, skipping any invalid /// tokens. static List> parseMultiGamePgn(String pgn, {PgnHeaders Function() initHeaders = defaultHeaders}) { - final multiGamePgnSplit = RegExp(r'\n\s+(?=\[)'); - final List> games = []; - final pgnGames = pgn.split(multiGamePgnSplit); - for (final pgnGame in pgnGames) { - final List> parsedGames = []; + final RegExp multiGamePgnSplit = RegExp(r'\n\s+(?=\[)'); + final List> games = >[]; + final List pgnGames = pgn.split(multiGamePgnSplit); + for (final String pgnGame in pgnGames) { + final List> parsedGames = >[]; _PgnParser((PgnGame game) { parsedGames.add(game); }, initHeaders) @@ -142,50 +186,47 @@ class PgnGame { return games; } - /// Create a [Position] for a Variant from the headers. + /// Create a [Position] for Nine Men's Morris from the headers. /// - /// Headers can include an optional 'Variant' and 'FEN' key. + /// Headers can include an optional 'FEN' key. + /// If present, sets the position from the FEN. Otherwise, sets a fresh position [pos.reset()]. /// - /// Throws a [PositionSetupException] if it does not meet basic validity requirements. - static Position startingPosition(PgnHeaders headers, - {bool? ignoreImpossibleCheck}) { - final rule = Rule.fromPgn(headers['Variant']); - if (rule == null) throw PositionSetupException.variant; - if (!headers.containsKey('FEN')) { - return Position.initialPosition(rule); - } - final fen = headers['FEN']!; - try { - return Position.setupPosition(rule, Setup.parseFen(fen), - ignoreImpossibleCheck: ignoreImpossibleCheck); - } catch (err) { - rethrow; + /// If the FEN is invalid, an [Exception] is thrown. + static Position startingPosition(PgnHeaders headers) { + final Position pos = Position(); + pos.reset(); + if (headers.containsKey('FEN')) { + final String fen = headers['FEN']!; + if (!pos.setFen(fen)) { + throw Exception("Invalid FEN: $fen"); + } } + return pos; } /// Make a PGN String from [PgnGame]. String makePgn() { - final builder = StringBuffer(); - final token = StringBuffer(); + final StringBuffer builder = StringBuffer(); + final StringBuffer token = StringBuffer(); if (headers.isNotEmpty) { - headers.forEach((key, value) { + headers.forEach((String key, String value) { builder.writeln('[$key "${_escapeHeader(value)}"]'); }); builder.write('\n'); } - for (final comment in comments) { + for (final String comment in comments) { builder.writeln('{ ${_safeComment(comment)} }'); } - final fen = headers['FEN']; - final initialPly = fen != null ? _getPlyFromSetup(fen) : 0; + final String? fen = headers['FEN']; + final int initialPly = fen != null ? _getPlyFromSetup(fen) : 0; - final List<_PgnFrame> stack = []; + final List<_PgnFrame> stack = <_PgnFrame>[]; if (moves.children.isNotEmpty) { - final variations = moves.children.iterator; + final Iterator> variations = moves.children.iterator; variations.moveNext(); stack.add(_PgnFrame( state: _PgnState.pre, @@ -198,7 +239,7 @@ class PgnGame { bool forceMoveNumber = true; while (stack.isNotEmpty) { - final frame = stack[stack.length - 1]; + final _PgnFrame frame = stack[stack.length - 1]; if (frame.inVariation) { token.write(') '); @@ -210,7 +251,7 @@ class PgnGame { case _PgnState.pre: { if (frame.node.data.startingComments != null) { - for (final comment in frame.node.data.startingComments!) { + for (final String comment in frame.node.data.startingComments!) { token.write('{ ${_safeComment(comment)} } '); } forceMoveNumber = true; @@ -222,13 +263,13 @@ class PgnGame { } token.write('${frame.node.data.san} '); if (frame.node.data.nags != null) { - for (final nag in frame.node.data.nags!) { + for (final int nag in frame.node.data.nags!) { token.write('\$$nag '); } forceMoveNumber = true; } if (frame.node.data.comments != null) { - for (final comment in frame.node.data.comments!) { + for (final String comment in frame.node.data.comments!) { token.write('{ ${_safeComment(comment)} } '); } } @@ -238,7 +279,7 @@ class PgnGame { case _PgnState.sidelines: { - final child = frame.sidelines.moveNext(); + final bool child = frame.sidelines.moveNext(); if (child) { token.write('( '); forceMoveNumber = true; @@ -253,7 +294,8 @@ class PgnGame { frame.inVariation = true; } else { if (frame.node.children.isNotEmpty) { - final variations = frame.node.children.iterator; + final Iterator> variations = + frame.node.children.iterator; variations.moveNext(); stack.add(_PgnFrame( state: _PgnState.pre, @@ -274,7 +316,7 @@ class PgnGame { } } } - token.write(Outcome.toPgnString(Outcome.fromPgn(headers['Result']))); + token.write(toPgnString(fromPgn(headers['Result']))); builder.writeln(token.toString()); return builder.toString(); } @@ -301,13 +343,13 @@ class PgnNodeData { /// Parent node containing a list of child nodes (does not contain any data itself). class PgnNode { - final List> children = []; + final List> children = >[]; /// Implements an [Iterable] to iterate the mainline. Iterable mainline() sync* { - var node = this; + PgnNode node = this; while (node.children.isNotEmpty) { - final child = node.children[0]; + final PgnChildNode child = node.children[0]; yield child.data; node = child; } @@ -320,21 +362,26 @@ class PgnNode { /// The callback should return a tuple of the updated context and node data. PgnNode transform( C context, (C, U)? Function(C context, T data, int childIndex) f) { - final root = PgnNode(); - final stack = [(before: this, after: root, context: context)]; + final PgnNode root = PgnNode(); + final List<({PgnNode after, PgnNode before, C context})> stack = <({ + PgnNode after, + PgnNode before, + C context + })>[(before: this, after: root, context: context)]; while (stack.isNotEmpty) { - final frame = stack.removeLast(); + final ({PgnNode after, PgnNode before, C context}) frame = + stack.removeLast(); for (int childIdx = 0; childIdx < frame.before.children.length; childIdx++) { C ctx = frame.context; - final childBefore = frame.before.children[childIdx]; - final transformData = f(ctx, childBefore.data, childIdx); + final PgnChildNode childBefore = frame.before.children[childIdx]; + final (C, U)? transformData = f(ctx, childBefore.data, childIdx); if (transformData != null) { - final (newCtx, data) = transformData; + final (C newCtx, U data) = transformData; ctx = newCtx; - final childAfter = PgnChildNode(data); + final PgnChildNode childAfter = PgnChildNode(data); frame.after.children.add(childAfter); stack.add((before: childBefore, after: childAfter, context: ctx)); } @@ -354,7 +401,7 @@ class PgnChildNode extends PgnNode { T data; } -/// Represents the color of a PGN comment. +/// Represents the color of a PGN comment shape. /// /// Can be green, red, yellow, and blue. enum CommentShapeColor { @@ -413,13 +460,16 @@ class PgnCommentShape { /// Parse the PGN for any comment or return null. static PgnCommentShape? fromPgn(String str) { - final color = CommentShapeColor.parseShapeColor(str.substring(0, 1)); - final from = Square.parse(str.substring(1, 3)); - if (color == null || from == null) return null; + final CommentShapeColor? color = + CommentShapeColor.parseShapeColor(str.substring(0, 1)); + final Square? from = Square.parse(str.substring(1, 3)); + if (color == null || from == null) { + return null; + } if (str.length == 3) { return PgnCommentShape(color: color, from: from, to: from); } - final to = Square.parse(str.substring(3, 5)); + final Square? to = Square.parse(str.substring(3, 5)); if (str.length == 5 && to != null) { return PgnCommentShape(color: color, from: from, to: to); } @@ -481,13 +531,15 @@ class PgnEvaluation { /// Create a PGN evaluation string String toPgn() { - var str = ''; + String str = ''; if (isPawns()) { str = pawns!.toStringAsFixed(2); } else { str = '#$mate'; } - if (depth != null) str = '$str,$depth'; + if (depth != null) { + str = '$str,$depth'; + } return str; } } @@ -497,43 +549,28 @@ class PgnEvaluation { class PgnComment { const PgnComment( {this.text, - this.shapes = const IListConst([]), + this.shapes = const IListConst([]), this.clock, this.emt, this.eval}) : assert(text == null || text != ''); - /// Comment string. - final String? text; - - /// List of comment shapes. - final IList shapes; - - /// Player's remaining time. - final Duration? clock; - - /// Player's elapsed move time. - final Duration? emt; - - /// Move evaluation. - final PgnEvaluation? eval; - /// Parses a PGN comment string to a [PgnComment]. factory PgnComment.fromPgn(String comment) { Duration? emt; Duration? clock; - final List shapes = []; + final List shapes = []; PgnEvaluation? eval; - final text = comment.replaceAllMapped( + final String text = comment.replaceAllMapped( RegExp( r'\s?\[%(emt|clk)\s(\d{1,5}):(\d{1,2}):(\d{1,2}(?:\.\d{0,3})?)\]\s?'), - (match) { - final annotation = match.group(1); - final hours = match.group(2); - final minutes = match.group(3); - final seconds = match.group(4); - final secondsValue = double.parse(seconds!); - final duration = Duration( + (Match match) { + final String? annotation = match.group(1); + final String? hours = match.group(2); + final String? minutes = match.group(3); + final String? seconds = match.group(4); + final double secondsValue = double.parse(seconds!); + final Duration duration = Duration( hours: int.parse(hours!), minutes: int.parse(minutes!), seconds: secondsValue.truncate(), @@ -547,24 +584,26 @@ class PgnComment { return ' '; }).replaceAllMapped( RegExp( - r'\s?\[%(?:csl|cal)\s([RGYB][a-h][1-8](?:[a-h][1-8])?(?:,[RGYB][a-h][1-8](?:[a-h][1-8])?)*)\]\s?'), - (match) { - final arrows = match.group(1); + r'\s?\[%(?:csl|cal)\s([RGYB][a-g][1-7](?:[a-g][1-7])?(?:,[RGYB][a-g][1-7](?:[a-g][1-7])?)*)\]\s?'), + (Match match) { + final String? arrows = match.group(1); if (arrows != null) { - for (final arrow in arrows.split(',')) { - final shape = PgnCommentShape.fromPgn(arrow); - if (shape != null) shapes.add(shape); + for (final String arrow in arrows.split(',')) { + final PgnCommentShape? shape = PgnCommentShape.fromPgn(arrow); + if (shape != null) { + shapes.add(shape); + } } } return ' '; }).replaceAllMapped( RegExp( r'\s?\[%eval\s(?:#([+-]?\d{1,5})|([+-]?(?:\d{1,5}|\d{0,5}\.\d{1,2})))(?:,(\d{1,5}))?\]\s?'), - (match) { - final mate = match.group(1); - final pawns = match.group(2); - final d = match.group(3); - final depth = d != null ? int.parse(d) : null; + (Match match) { + final String? mate = match.group(1); + final String? pawns = match.group(2); + final String? d = match.group(3); + final int? depth = d != null ? int.parse(d) : null; eval = mate != null ? PgnEvaluation.mate(mate: int.parse(mate), depth: depth) : PgnEvaluation.pawns( @@ -574,27 +613,54 @@ class PgnComment { return PgnComment( text: text.isNotEmpty ? text : null, - shapes: IList(shapes), + shapes: IList(shapes), emt: emt, clock: clock, eval: eval); } + /// Comment string. + final String? text; + + /// List of comment shapes. + final IList shapes; + + /// Player's remaining time. + final Duration? clock; + + /// Player's elapsed move time. + final Duration? emt; + + /// Move evaluation. + final PgnEvaluation? eval; + /// Make a PGN string from this comment. String makeComment() { - final List builder = []; - if (text != null) builder.add(text!); - final circles = shapes - .where((shape) => shape.to == shape.from) - .map((shape) => shape.toString()); - if (circles.isNotEmpty) builder.add('[%csl ${circles.join(",")}]'); - final arrows = shapes - .where((shape) => shape.to != shape.from) - .map((shape) => shape.toString()); - if (arrows.isNotEmpty) builder.add('[%cal ${arrows.join(",")}]'); - if (eval != null) builder.add('[%eval ${eval!.toPgn()}]'); - if (emt != null) builder.add('[%emt ${_makeClk(emt!)}]'); - if (clock != null) builder.add('[%clk ${_makeClk(clock!)}]'); + final List builder = []; + if (text != null) { + builder.add(text!); + } + final Iterable circles = shapes + .where((PgnCommentShape shape) => shape.to == shape.from) + .map((PgnCommentShape shape) => shape.toString()); + if (circles.isNotEmpty) { + builder.add('[%csl ${circles.join(",")}]'); + } + final Iterable arrows = shapes + .where((PgnCommentShape shape) => shape.to != shape.from) + .map((PgnCommentShape shape) => shape.toString()); + if (arrows.isNotEmpty) { + builder.add('[%cal ${arrows.join(",")}]'); + } + if (eval != null) { + builder.add('[%eval ${eval!.toPgn()}]'); + } + if (emt != null) { + builder.add('[%emt ${_makeClk(emt!)}]'); + } + if (clock != null) { + builder.add('[%clk ${_makeClk(clock!)}]'); + } return builder.join(' '); } @@ -619,12 +685,11 @@ class PgnComment { /// A frame used for parsing a line class _ParserFrame { + _ParserFrame({required this.parent, required this.root}); PgnNode parent; bool root; PgnChildNode? node; List? startingComments; - - _ParserFrame({required this.parent, required this.root}); } enum _ParserState { bom, pre, headers, moves, comment } @@ -633,13 +698,6 @@ enum _PgnState { pre, sidelines, end } /// A frame used for creating PGN class _PgnFrame { - _PgnState state; - int ply; - PgnChildNode node; - Iterator> sidelines; - bool startsVariation; - bool inVariation; - _PgnFrame( {required this.state, required this.ply, @@ -647,26 +705,27 @@ class _PgnFrame { required this.sidelines, required this.startsVariation, required this.inVariation}); + _PgnState state; + int ply; + PgnChildNode node; + Iterator> sidelines; + bool startsVariation; + bool inVariation; } /// Remove escape sequence from the string String _escapeHeader(String value) => - value.replaceAll(RegExp(r'\\'), '\\\\').replaceAll(RegExp('"'), '\\"'); + value.replaceAll(RegExp(r'\\'), r'\\').replaceAll(RegExp('"'), r'\"'); /// Remove '}' from the comment string String _safeComment(String value) => value.replaceAll(RegExp(r'\}'), ''); -/// Return ply from a fen if fen is valid else return 0 +/// For Nine Men's Morris, we don't do advanced FEN parsing for ply. Return 0 here. int _getPlyFromSetup(String fen) { - try { - final setup = Setup.parseFen(fen); - return (setup.fullmoves - 1) * 2 + (setup.turn == Side.white ? 0 : 1); - } catch (e) { - return 0; - } + return 0; // Minimal stub } -const _bom = '\ufeff'; +const String _bom = '\ufeff'; bool _isWhitespace(String line) => RegExp(r'^\s*$').hasMatch(line); @@ -674,7 +733,11 @@ bool _isCommentLine(String line) => line.startsWith('%'); /// A class to read a string and create a [PgnGame] (adapted for Nine Men's Morris). class _PgnParser { - List _lineBuf = []; + _PgnParser(this.emitGame, this.initHeaders) { + _resetGame(); + _state = _ParserState.bom; + } + List _lineBuf = []; late bool _found; late _ParserState _state = _ParserState.pre; late PgnHeaders _gameHeaders; @@ -683,25 +746,20 @@ class _PgnParser { late List<_ParserFrame> _stack; late List _commentBuf; - /// Function to which the parsed game is passed to + /// Function to which the parsed game is passed final void Function(PgnGame) emitGame; /// Function to create the headers final PgnHeaders Function() initHeaders; - _PgnParser(this.emitGame, this.initHeaders) { - _resetGame(); - _state = _ParserState.bom; - } - void _resetGame() { _found = false; _state = _ParserState.pre; _gameHeaders = initHeaders(); - _gameMoves = PgnNode(); - _gameComments = []; - _commentBuf = []; - _stack = [_ParserFrame(parent: _gameMoves, root: true)]; + _gameMoves = PgnNode(); + _gameComments = []; + _commentBuf = []; + _stack = <_ParserFrame>[_ParserFrame(parent: _gameMoves, root: true)]; } void _emit() { @@ -710,7 +768,7 @@ class _PgnParser { } if (_found) { emitGame( - PgnGame( + PgnGame( headers: _gameHeaders, moves: _gameMoves, comments: _gameComments), ); } @@ -719,13 +777,14 @@ class _PgnParser { /// Parse the PGN string void parse(String data) { - var idx = 0; + int idx = 0; for (;;) { - final nlIdx = data.indexOf('\n', idx); + final int nlIdx = data.indexOf('\n', idx); if (nlIdx == -1) { break; } - final crIdx = nlIdx > idx && data[nlIdx - 1] == '\r' ? nlIdx - 1 : nlIdx; + final int crIdx = + nlIdx > idx && data[nlIdx - 1] == '\r' ? nlIdx - 1 : nlIdx; _lineBuf.add(data.substring(idx, crIdx)); idx = nlIdx + 1; _handleLine(); @@ -737,9 +796,9 @@ class _PgnParser { } void _handleLine() { - var freshLine = true; - var line = _lineBuf.join(); - _lineBuf = []; + bool freshLine = true; + String line = _lineBuf.join(); + _lineBuf = []; continuedLine: for (;;) { switch (_state) { @@ -754,7 +813,9 @@ class _PgnParser { case _ParserState.pre: { - if (_isWhitespace(line) || _isCommentLine(line)) return; + if (_isWhitespace(line) || _isCommentLine(line)) { + return; + } _found = true; _state = _ParserState.headers; continue; @@ -762,23 +823,27 @@ class _PgnParser { case _ParserState.headers: { - if (_isCommentLine(line)) return; - var moreHeaders = true; - final headerReg = RegExp( + if (_isCommentLine(line)) { + return; + } + bool moreHeaders = true; + final RegExp headerReg = RegExp( r'^\s*\[([A-Za-z0-9][A-Za-z0-9_+#=:-]*)\s+"((?:[^"\\]|\\"|\\\\)*)"\]'); while (moreHeaders) { moreHeaders = false; - line = line.replaceFirstMapped(headerReg, (match) { + line = line.replaceFirstMapped(headerReg, (Match match) { if (match[1] != null && match[2] != null) { _gameHeaders[match[1]!] = - match[2]!.replaceAll('\\"', '"').replaceAll('\\\\', '\\'); + match[2]!.replaceAll(r'\"', '"').replaceAll(r'\\', r'\'); moreHeaders = true; freshLine = false; } return ''; }); } - if (_isWhitespace(line)) return; + if (_isWhitespace(line)) { + return; + } _state = _ParserState.moves; continue; } @@ -786,20 +851,22 @@ class _PgnParser { case _ParserState.moves: { if (freshLine) { - if (_isWhitespace(line) || _isCommentLine(line)) return; + if (_isWhitespace(line) || _isCommentLine(line)) { + return; + } } // Adapted Nine Men's Morris token regex: - final tokenRegex = RegExp( + final RegExp tokenRegex = RegExp( r'(?:p|x+|[a-g][1-7](?:[-x][a-g][1-7])*)|{|;|\$\d{1,4}|[?!]{1,2}|\(|\)|\*|1-0|0-1|1\/2-1\/2/'); - final matches = tokenRegex.allMatches(line); - for (final match in matches) { - final frame = _stack[_stack.length - 1]; - var token = match[0]; + final Iterable matches = tokenRegex.allMatches(line); + for (final RegExpMatch match in matches) { + final _ParserFrame frame = _stack[_stack.length - 1]; + final String? token = match[0]; if (token != null) { if (token == ';') { // Remainder of line is a comment return; - } else if (token.startsWith('\$')) { + } else if (token.startsWith(r'$')) { _handleNag(int.parse(token.substring(1))); } else if (token == '!') { _handleNag(1); @@ -823,12 +890,14 @@ class _PgnParser { } else if (token == '(') { _stack.add(_ParserFrame(parent: frame.parent, root: false)); } else if (token == ')') { - if (_stack.length > 1) _stack.removeLast(); + if (_stack.length > 1) { + _stack.removeLast(); + } } else if (token == '{') { - final openIndex = match.end; + final int openIndex = match.end; _state = _ParserState.comment; if (openIndex < line.length) { - final beginIndex = + final int beginIndex = line[openIndex] == ' ' ? openIndex + 1 : openIndex; line = line.substring(beginIndex); } else if (openIndex == line.length) { @@ -840,7 +909,7 @@ class _PgnParser { if (frame.node != null) { frame.parent = frame.node!; } - frame.node = PgnChildNode(PgnNodeData( + frame.node = PgnChildNode(PgnNodeData( san: token, startingComments: frame.startingComments)); frame.startingComments = null; frame.root = false; @@ -853,12 +922,12 @@ class _PgnParser { case _ParserState.comment: { - final closeIndex = line.indexOf('}'); + final int closeIndex = line.indexOf('}'); if (closeIndex == -1) { _commentBuf.add(line); return; } else { - final endIndex = closeIndex > 0 && line[closeIndex - 1] == ' ' + final int endIndex = closeIndex > 0 && line[closeIndex - 1] == ' ' ? closeIndex - 1 : closeIndex; _commentBuf.add(line.substring(0, endIndex)); @@ -873,24 +942,24 @@ class _PgnParser { } void _handleNag(int nag) { - final frame = _stack[_stack.length - 1]; + final _ParserFrame frame = _stack[_stack.length - 1]; if (frame.node != null) { - frame.node!.data.nags ??= []; + frame.node!.data.nags ??= []; frame.node!.data.nags?.add(nag); } } void _handleComment() { - final frame = _stack[_stack.length - 1]; - final comment = _commentBuf.join('\n'); - _commentBuf = []; + final _ParserFrame frame = _stack[_stack.length - 1]; + final String comment = _commentBuf.join('\n'); + _commentBuf = []; if (frame.node != null) { - frame.node!.data.comments ??= []; + frame.node!.data.comments ??= []; frame.node!.data.comments?.add(comment); } else if (frame.root) { _gameComments.add(comment); } else { - frame.startingComments ??= []; + frame.startingComments ??= []; frame.startingComments!.add(comment); } } @@ -898,17 +967,17 @@ class _PgnParser { /// Make the clock to string from seconds String _makeClk(Duration duration) { - final seconds = duration.inMilliseconds / 1000; - final positiveSecs = math.max(0, seconds); - final hours = (positiveSecs / 3600).floor(); - final minutes = ((positiveSecs % 3600) / 60).floor(); - final maxSec = (positiveSecs % 3600) % 60; - final intVal = maxSec.toInt(); - final frac = (maxSec - intVal) // get the fraction part of seconds + final double seconds = duration.inMilliseconds / 1000; + final num positiveSecs = math.max(0, seconds); + final int hours = (positiveSecs / 3600).floor(); + final int minutes = ((positiveSecs % 3600) / 60).floor(); + final num maxSec = (positiveSecs % 3600) % 60; + final int intVal = maxSec.toInt(); + final String frac = (maxSec - intVal) // get the fraction part of seconds .toStringAsFixed(3) .replaceAll(RegExp(r'\.?0+$'), '') .substring(1); - final dec = + final String dec = intVal.toString().padLeft(2, '0'); // get the decimal part of seconds return '$hours:${minutes.toString().padLeft(2, "0")}:$dec$frac'; } diff --git a/src/ui/flutter_app/lib/game_page/services/mill.dart b/src/ui/flutter_app/lib/game_page/services/mill.dart index 46d143d88..2e3fb2571 100644 --- a/src/ui/flutter_app/lib/game_page/services/mill.dart +++ b/src/ui/flutter_app/lib/game_page/services/mill.dart @@ -36,10 +36,13 @@ import '../../shared/themes/app_theme.dart'; import '../../shared/utils/helpers/array_helpers/array_helper.dart'; import '../../shared/utils/helpers/list_helpers/pointed_list.dart'; import '../../shared/utils/helpers/string_helpers/string_buffer_helper.dart'; +import '../../shared/utils/helpers/string_helpers/string_helper.dart'; import '../../shared/widgets/snackbars/scaffold_messenger.dart'; import 'animation/animation_manager.dart'; import 'engine/bitboard.dart'; import "gif_share/gif_share.dart"; +import 'import_export/import_helpers.dart'; +import 'import_export/pgn.dart'; part 'controller/game_controller.dart'; part 'controller/game_recorder.dart'; @@ -54,7 +57,10 @@ part 'engine/opening_book.dart'; part 'engine/position.dart'; part 'engine/types.dart'; part 'engine/zobrist.dart'; -part 'import_export/import_export_service.dart'; +part 'import_export/export_service.dart'; +part 'import_export/import_exceptions.dart'; +part 'import_export/import_service.dart'; +part 'import_export/notation_parsing.dart'; part 'notifiers/board_semantics_notifier.dart'; part 'notifiers/game_result_notifier.dart'; part 'notifiers/header_icons_notifier.dart'; diff --git a/src/ui/flutter_app/lib/game_page/services/save_load/save_load_service.dart b/src/ui/flutter_app/lib/game_page/services/save_load/save_load_service.dart index fec8106ba..f53d438cc 100644 --- a/src/ui/flutter_app/lib/game_page/services/save_load/save_load_service.dart +++ b/src/ui/flutter_app/lib/game_page/services/save_load/save_load_service.dart @@ -90,8 +90,8 @@ class LoadService { fsType: FilesystemType.file, showGoUp: !kIsWeb && !Platform.isLinux, allowedExtensions: [".pgn"], - fileTileSelectMode: - FileTileSelectMode.checkButton, // TODO: whole tile is better. + fileTileSelectMode: FileTileSelectMode.checkButton, + // TODO: whole tile is better. theme: const FilesystemPickerTheme( backgroundColor: Colors.greenAccent, ), @@ -257,7 +257,7 @@ class LoadService { try { ImportService.import(fileContent); logger.t('$_logTag File Content: $fileContent'); - final String tagPairs = ImportService.getTagPairs(fileContent); + final String tagPairs = getTagPairs(fileContent); if (tagPairs.isNotEmpty) { rootScaffoldMessengerKey.currentState! diff --git a/src/ui/flutter_app/lib/shared/utils/helpers/string_helpers/string_helper.dart b/src/ui/flutter_app/lib/shared/utils/helpers/string_helpers/string_helper.dart new file mode 100644 index 000000000..33dbc0900 --- /dev/null +++ b/src/ui/flutter_app/lib/shared/utils/helpers/string_helpers/string_helper.dart @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2019-2025 The Sanmill developers (see AUTHORS file) + +// string_helper.dart + +String removeBracketedContent(String input) { + // Define regex patterns for each type of bracket + final RegExp parentheses = RegExp(r'\([^()]*\)'); + final RegExp squareBrackets = RegExp(r'\[[^\[\]]*\]'); + final RegExp curlyBraces = RegExp(r'\{[^{}]*\}'); + + String result = input; + + // Remove content inside parentheses + result = result.replaceAll(parentheses, ''); + + // Remove content inside square brackets + result = result.replaceAll(squareBrackets, ''); + + // Remove content inside curly braces + result = result.replaceAll(curlyBraces, ''); + + return result; +} + +/// Applies the transformations you listed to the given text. +String transformOutside(String text, Map replacements) { + String result = text.toLowerCase(); + replacements.forEach((String pattern, String replacement) { + result = result.replaceAll(pattern, replacement); + }); + return result; +} + +/// Returns a new string in which the parts **outside** any brackets are +/// transformed using the provided replacements. The text +/// **inside** brackets (including nested) remains unchanged. +String processOutsideBrackets(String input, Map replacements) { + // A stack to track opening brackets and handle nesting + final List bracketStack = []; + + // Buffers to accumulate text + final StringBuffer finalOutput = StringBuffer(); + final StringBuffer outsideBuffer = StringBuffer(); + final StringBuffer insideBuffer = StringBuffer(); + + // Helper to flush the outside buffer with transformations + void flushOutsideBuffer() { + if (outsideBuffer.isEmpty) { + return; + } + // Apply transformations + final String transformed = + transformOutside(outsideBuffer.toString(), replacements); + finalOutput.write(transformed); + outsideBuffer.clear(); + } + + // Helper to flush the inside buffer without transformations + void flushInsideBuffer() { + if (insideBuffer.isEmpty) { + return; + } + finalOutput.write(insideBuffer.toString()); + insideBuffer.clear(); + } + + // A map for matching brackets + final Map matchingBrackets = { + ']': '[', + '}': '{', + ')': '(', + }; + + for (int i = 0; i < input.length; i++) { + final String c = input[i]; + + // Check if this character is an opening bracket + if (c == '[' || c == '{' || c == '(') { + // If we were outside, flush the outside text first + if (bracketStack.isEmpty) { + flushOutsideBuffer(); + } + + // Now we are switching to inside bracket + bracketStack.add(c); + // Write the bracket character itself to the inside buffer + insideBuffer.write(c); + } + // Check if this character is a closing bracket + else if (c == ']' || c == '}' || c == ')') { + if (bracketStack.isNotEmpty && bracketStack.last == matchingBrackets[c]) { + // We are inside brackets, so write to insideBuffer + insideBuffer.write(c); + bracketStack.removeLast(); + + // If we've just closed the last bracket, we move back to "outside" + if (bracketStack.isEmpty) { + flushInsideBuffer(); + } + } else { + // If mismatched or unexpected bracket, handle as normal char (or decide on error) + // Here we just treat it as text (outside or inside). + if (bracketStack.isEmpty) { + outsideBuffer.write(c); + } else { + insideBuffer.write(c); + } + } + } else { + // Normal character (not a bracket) + if (bracketStack.isEmpty) { + // We are outside any bracket + outsideBuffer.write(c); + } else { + // We are inside bracket(s) + insideBuffer.write(c); + } + } + } + + // If we end and there's still stuff outside + if (outsideBuffer.isNotEmpty) { + flushOutsideBuffer(); + } + + // If there's any remaining inside text (unclosed bracket), + // we’ll just flush it as-is: + if (insideBuffer.isNotEmpty) { + flushInsideBuffer(); + } + + return finalOutput.toString(); +}