From a933aa454178d9a3344030dc65be8ed2a371cc61 Mon Sep 17 00:00:00 2001 From: Taka Date: Sun, 25 Nov 2018 11:42:41 +0000 Subject: [PATCH] Automatically insert/update labels in a VASSAL save file. --- .gitignore | 1 + .pylintrc | 3 +- _freeze.py | 1 + conftest.py | 9 + setup.py | 2 +- vasl_templates/file_dialog.py | 83 ++ vasl_templates/main.py | 6 +- vasl_templates/main_window.py | 26 +- vasl_templates/server_settings.py | 84 +- vasl_templates/ui/server_settings.ui | 329 +++++-- vasl_templates/web_channel.py | 96 +- vasl_templates/webapp/__init__.py | 1 + vasl_templates/webapp/config/constants.py | 2 + vasl_templates/webapp/config/site.cfg.example | 8 +- .../webapp/data/default-template-pack/atmm.j2 | 2 +- .../webapp/data/default-template-pack/baz.j2 | 2 +- .../extras/blank-space.j2 | 2 +- .../default-template-pack/extras/hip-guns.j2 | 2 +- .../extras/kgs/grenade-bundles.j2 | 2 +- .../extras/kgs/molotov-cocktails.j2 | 2 +- .../default-template-pack/extras/pf-count.j2 | 2 +- .../extras/turn-track-shading.j2 | 2 +- .../data/default-template-pack/mol-p.j2 | 2 +- .../webapp/data/default-template-pack/mol.j2 | 2 +- .../data/default-template-pack/ob_note.j2 | 2 +- .../data/default-template-pack/ob_ordnance.j2 | 2 +- .../data/default-template-pack/ob_setup.j2 | 2 +- .../data/default-template-pack/ob_vehicles.j2 | 2 +- .../webapp/data/default-template-pack/pf.j2 | 2 +- .../webapp/data/default-template-pack/piat.j2 | 2 +- .../data/default-template-pack/players.j2 | 2 +- .../webapp/data/default-template-pack/psk.j2 | 2 +- .../data/default-template-pack/scenario.j2 | 2 +- .../default-template-pack/scenario_note.j2 | 2 +- .../webapp/data/default-template-pack/ssr.j2 | 2 +- .../victory_conditions.j2 | 2 +- vasl_templates/webapp/file_server/vasl_mod.py | 4 +- vasl_templates/webapp/static/css/main.css | 5 +- vasl_templates/webapp/static/css/vassal.css | 9 + vasl_templates/webapp/static/extras.js | 18 +- vasl_templates/webapp/static/help/index.html | 10 + vasl_templates/webapp/static/main.js | 17 +- vasl_templates/webapp/static/simple_notes.js | 38 +- vasl_templates/webapp/static/snippets.js | 154 ++- vasl_templates/webapp/static/utils.js | 2 +- vasl_templates/webapp/static/vassal.js | 291 ++++++ vasl_templates/webapp/templates/index.html | 6 + vasl_templates/webapp/templates/testing.html | 1 + vasl_templates/webapp/templates/vassal.html | 9 + .../tests/fixtures/dump-vsav/labels.txt | 57 ++ .../tests/fixtures/dump-vsav/labels.vsav | Bin 0 -> 3351 bytes .../tests/fixtures/update-vsav/empty.vsav | Bin 0 -> 733 bytes .../tests/fixtures/update-vsav/full.json | 1 + .../tests/fixtures/update-vsav/full.vsav | Bin 0 -> 5462 bytes .../fixtures/update-vsav/hill621-legacy.json | 1 + .../fixtures/update-vsav/hill621-legacy.vsav | Bin 0 -> 31765 bytes .../fixtures/update-vsav/latw-legacy.vsav | Bin 0 -> 2558 bytes .../tests/fixtures/update-vsav/latw.vsav | Bin 0 -> 2538 bytes .../webapp/tests/test_capabilities.py | 9 +- vasl_templates/webapp/tests/test_counters.py | 4 +- vasl_templates/webapp/tests/test_ob.py | 21 +- .../webapp/tests/test_scenario_persistence.py | 21 +- vasl_templates/webapp/tests/test_snippets.py | 62 +- vasl_templates/webapp/tests/test_vassal.py | 574 ++++++++++++ vasl_templates/webapp/tests/utils.py | 22 + vasl_templates/webapp/utils.py | 117 +++ vasl_templates/webapp/vassal.py | 349 +++++++ vassal-shim/.gitignore | 3 + vassal-shim/Makefile | 27 + vassal-shim/data/boardNames.txt | 208 +++++ vassal-shim/release/vassal-shim.jar | Bin 0 -> 26840 bytes .../src/vassal_shim/GamePieceLabelFields.java | 51 + vassal-shim/src/vassal_shim/LabelArea.java | 107 +++ vassal-shim/src/vassal_shim/Main.java | 62 ++ .../vassal_shim/ModuleManagerMenuManager.java | 17 + vassal-shim/src/vassal_shim/ReportNode.java | 20 + vassal-shim/src/vassal_shim/Snippet.java | 57 ++ vassal-shim/src/vassal_shim/Utils.java | 45 + vassal-shim/src/vassal_shim/VassalShim.java | 884 ++++++++++++++++++ 79 files changed, 3688 insertions(+), 290 deletions(-) create mode 100644 vasl_templates/file_dialog.py mode change 100755 => 100644 vasl_templates/webapp/data/default-template-pack/extras/hip-guns.j2 create mode 100644 vasl_templates/webapp/static/css/vassal.css create mode 100644 vasl_templates/webapp/static/vassal.js create mode 100644 vasl_templates/webapp/templates/vassal.html create mode 100644 vasl_templates/webapp/tests/fixtures/dump-vsav/labels.txt create mode 100644 vasl_templates/webapp/tests/fixtures/dump-vsav/labels.vsav create mode 100644 vasl_templates/webapp/tests/fixtures/update-vsav/empty.vsav create mode 100644 vasl_templates/webapp/tests/fixtures/update-vsav/full.json create mode 100644 vasl_templates/webapp/tests/fixtures/update-vsav/full.vsav create mode 100644 vasl_templates/webapp/tests/fixtures/update-vsav/hill621-legacy.json create mode 100644 vasl_templates/webapp/tests/fixtures/update-vsav/hill621-legacy.vsav create mode 100644 vasl_templates/webapp/tests/fixtures/update-vsav/latw-legacy.vsav create mode 100644 vasl_templates/webapp/tests/fixtures/update-vsav/latw.vsav create mode 100644 vasl_templates/webapp/tests/test_vassal.py create mode 100644 vasl_templates/webapp/utils.py create mode 100644 vasl_templates/webapp/vassal.py create mode 100644 vassal-shim/.gitignore create mode 100644 vassal-shim/Makefile create mode 100644 vassal-shim/data/boardNames.txt create mode 100644 vassal-shim/release/vassal-shim.jar create mode 100644 vassal-shim/src/vassal_shim/GamePieceLabelFields.java create mode 100644 vassal-shim/src/vassal_shim/LabelArea.java create mode 100644 vassal-shim/src/vassal_shim/Main.java create mode 100644 vassal-shim/src/vassal_shim/ModuleManagerMenuManager.java create mode 100644 vassal-shim/src/vassal_shim/ReportNode.java create mode 100644 vassal-shim/src/vassal_shim/Snippet.java create mode 100644 vassal-shim/src/vassal_shim/Utils.java create mode 100644 vassal-shim/src/vassal_shim/VassalShim.java diff --git a/.gitignore b/.gitignore index f59e6d2..125b476 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ _work_/ +_releases_/ .venv* *.pyc diff --git a/.pylintrc b/.pylintrc index f0690bf..c863eaf 100644 --- a/.pylintrc +++ b/.pylintrc @@ -139,7 +139,8 @@ disable=print-statement, invalid-name, wrong-import-position, global-statement, - too-few-public-methods + too-few-public-methods, + duplicate-code, # can't get it to shut up about @pytest.mark.skipif's :-/ # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option diff --git a/_freeze.py b/_freeze.py index 8b2bb94..a657250 100755 --- a/_freeze.py +++ b/_freeze.py @@ -73,6 +73,7 @@ args = [ "--onefile", "--name", target_name, ] +args.extend( [ "--add-data", "vassal-shim/release/vassal-shim.jar" + os.pathsep + "vasl_templates/webapp" ] ) # NOTE: We also need to include the config/ and data/ subdirectories, but we would like to # make them available to the user, so we include them ourself in the final release archive. def map_dir( src, dest ): #pylint: disable=missing-docstring diff --git a/conftest.py b/conftest.py index 03b9155..697889b 100644 --- a/conftest.py +++ b/conftest.py @@ -43,12 +43,21 @@ def pytest_addoption( parser ): "--short-tests", action="store_true", dest="short_tests", default=False, help="Run a shorter version of the test suite." ) + # NOTE: Some tests require the VASL module file(s). We don't want to put these into source control, # so we provide this option to allow the caller to specify where they live. parser.addoption( "--vasl-mods", action="store", dest="vasl_mods", default=None, help="Directory containing the VASL .vmod file(s)." ) + + # NOTE: Some tests require VASSAL to be installed. This option allows the caller to specify + # where it is (multiple installations can be placed in sub-directories). + parser.addoption( + "--vassal", action="store", dest="vassal", default=None, + help="Directory containing VASSAL installation(s)." + ) + # NOTE: It's not good to have the code run differently to how it will normally, # but using the clipboard to retrieve snippets causes more trouble than it's worth :-/ # since any kind of clipboard activity while the tests are running could cause them to fail diff --git a/setup.py b/setup.py index 013a76d..626fea6 100644 --- a/setup.py +++ b/setup.py @@ -22,13 +22,13 @@ setup( "PyQT5==5.10.0", "pyyaml==3.13", "pillow==5.3.0", + "selenium==3.12.0", "click==6.7", ], extras_require = { "dev": [ "pytest==3.6.0", "tabulate==0.8.2", - "selenium==3.12.0", "lxml==4.2.4", "pylint==1.9.2", "pytest-pylint==0.9.0", diff --git a/vasl_templates/file_dialog.py b/vasl_templates/file_dialog.py new file mode 100644 index 0000000..0af494f --- /dev/null +++ b/vasl_templates/file_dialog.py @@ -0,0 +1,83 @@ +""" Manage loading and saving files. """ + +import os + +from PyQt5.QtWidgets import QFileDialog + +# --------------------------------------------------------------------- + +# NOTE: While loading/saving files works fine when handled by the embedded browser, +# we can't get the full path of the file loaded (because of browser security). +# This means that we can't do things like default to saving a scenario to the same file +# it was loaded from, or retrying a failed save. This is such a lousy UX, +# we handle load/save operations ourself, where we can manage things like this. + +class FileDialog: + """Manage loading and saving files.""" + + def __init__( self, parent, object_name, default_extn, filters, default_fname ): + self.parent = parent + self.object_name = object_name + self.default_extn = default_extn + self.filters = filters + self.curr_fname = default_fname + + def load_file( self, binary ): + """Load a file.""" + + # ask the user which file to load + fname, _ = QFileDialog.getOpenFileName( + self.parent, "Load {}".format( self.object_name ), + self.curr_fname, + self.filters + ) + if not fname: + return None + + # load the file + try: + with open( fname, "rb" ) as fp: + data = fp.read() + except Exception as ex: #pylint: disable=broad-except + self.parent.showErrorMsg( "Can't load the {}:\n\n{}".format( self.object_name, ex ) ) + return None + if not binary: + data = data.decode( "utf-8" ) + self.curr_fname = fname + + return data + + def save_file( self, data ): + """Save data to a file.""" + + # initialize + if isinstance( data, str ): + data = data.encode( "utf-8" ) + + while True: # nb: keep trying until the save succeeds or the user cancels the operation + + # ask the user where to save the file + fname, _ = QFileDialog.getSaveFileName( + self.parent, "Save {}".format( self.object_name), + self.curr_fname, + self.filters + ) + if not fname: + return False + + # check the file extension + extn = os.path.splitext( fname )[1] + if not extn: + fname += self.default_extn + elif fname.endswith( "." ): + fname = fname[:-1] + + # save the file + try: + with open( fname, "wb", ) as fp: + fp.write( data ) + except Exception as ex: #pylint: disable=broad-except + self.parent.showErrorMsg( "Can't save the {}:\n\n{}".format( self.object_name, ex ) ) + continue + self.curr_fname = fname + return True diff --git a/vasl_templates/main.py b/vasl_templates/main.py index 8c73f03..14b7573 100755 --- a/vasl_templates/main.py +++ b/vasl_templates/main.py @@ -15,6 +15,8 @@ from PyQt5.QtCore import Qt, QSettings, QDir import PyQt5.QtCore import click +from vasl_templates.webapp.utils import SimpleError + # FUDGE! This needs to be created before showing any UI elements e.g. an error message box. qt_app = QApplication( sys.argv ) @@ -86,7 +88,7 @@ def _do_main( template_pack, default_scenario, remote_debugging, debug ): #pylin # configure the default scenario if default_scenario: if not os.path.isfile( default_scenario ): - raise RuntimeError( "Can't find the default scenario file." ) + raise SimpleError( "Can't find the default scenario file." ) webapp_main.default_scenario = default_scenario # configure remote debugging @@ -134,7 +136,7 @@ def _do_main( template_pack, default_scenario, remote_debugging, debug ): #pylin except: #pylint: disable=bare-except resp = None if resp: - raise RuntimeError( "The application is already running." ) + raise SimpleError( "The application is already running." ) # start the webapp server def webapp_thread(): diff --git a/vasl_templates/main_window.py b/vasl_templates/main_window.py index 6e119e7..6d90ea1 100644 --- a/vasl_templates/main_window.py +++ b/vasl_templates/main_window.py @@ -5,15 +5,16 @@ import os import re import json import io +import base64 import logging from PyQt5.QtWidgets import QWidget, QVBoxLayout, QMenuBar, QAction, QLabel, QMessageBox from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEngineProfile, QWebEnginePage from PyQt5.QtWebChannel import QWebChannel from PyQt5.QtGui import QDesktopServices, QIcon -from PyQt5.QtCore import Qt, QUrl, QMargins, pyqtSlot +from PyQt5.QtCore import Qt, QUrl, QMargins, pyqtSlot, QVariant -from vasl_templates.webapp.config.constants import APP_NAME +from vasl_templates.webapp.config.constants import APP_NAME, IS_FROZEN from vasl_templates.main import app_settings from vasl_templates.web_channel import WebChannelHandler from vasl_templates.utils import log_exceptions @@ -55,7 +56,7 @@ class MainWindow( QWidget ): # initialize the main window self.setWindowTitle( APP_NAME ) - if getattr( sys, "frozen", False ): + if IS_FROZEN: dname = sys._MEIPASS #pylint: disable=no-member,protected-access else: dname = os.path.join( os.path.split(__file__)[0], "webapp" ) @@ -249,6 +250,25 @@ class MainWindow( QWidget ): """Called when the user wants to save a scenario.""" return self._web_channel_handler.save_scenario( data ) + @pyqtSlot( result=QVariant ) + @log_exceptions( caption="SLOT EXCEPTION" ) + def load_vsav( self ): + """Called when the user wants to update a VASL scenario.""" + fname, data = self._web_channel_handler.load_vsav() + if data is None: + return None + return QVariant( { + "filename": fname, + "data": base64.b64encode( data ).decode( "utf-8" ) + } ) + + @pyqtSlot( str, str, result=bool ) + @log_exceptions( caption="SLOT EXCEPTION" ) + def save_updated_vsav( self, fname, data ): + """Called when a VASL scenario has been updated and is ready to be saved.""" + data = base64.b64decode( data ) + return self._web_channel_handler.save_updated_vsav( fname, data ) + @pyqtSlot( str ) @log_exceptions( caption="SLOT EXCEPTION" ) def on_user_settings_change( self, user_settings ): #pylint: disable=no-self-use diff --git a/vasl_templates/server_settings.py b/vasl_templates/server_settings.py index bd165f0..7fd6cb0 100644 --- a/vasl_templates/server_settings.py +++ b/vasl_templates/server_settings.py @@ -9,6 +9,7 @@ from PyQt5.QtGui import QIcon from vasl_templates.main import app_settings from vasl_templates.main_window import MainWindow from vasl_templates.webapp.config.constants import DATA_DIR +from vasl_templates.webapp.vassal import SUPPORTED_VASSAL_VERSIONS_DISPLAY from vasl_templates.webapp.file_server.vasl_mod import VaslMod, SUPPORTED_VASL_MOD_VERSIONS_DISPLAY from vasl_templates.webapp.files import install_vasl_mod @@ -26,39 +27,96 @@ class ServerSettingsDialog( QDialog ): base_dir = os.path.split( __file__ )[0] dname = os.path.join( base_dir, "ui/server_settings.ui" ) uic.loadUi( dname, self ) - self.select_vasl_mod_button.setIcon( - QIcon( os.path.join( base_dir, "resources/file_browser.png" ) ) - ) + for btn in ["vassal_dir","vasl_mod","boards_dir","java","webdriver"]: + getattr( self, "select_{}_button".format(btn) ).setIcon( + QIcon( os.path.join( base_dir, "resources/file_browser.png" ) ) + ) self.setMinimumSize( self.size() ) # initialize handlers + self.select_vassal_dir_button.clicked.connect( self.on_select_vassal_dir ) self.select_vasl_mod_button.clicked.connect( self.on_select_vasl_mod ) + self.select_boards_dir_button.clicked.connect( self.on_select_boards_dir ) + self.select_java_button.clicked.connect( self.on_select_java ) + self.select_webdriver_button.clicked.connect( self.on_select_webdriver ) self.ok_button.clicked.connect( self.on_ok ) self.cancel_button.clicked.connect( self.on_cancel ) # load the current server settings + self.vassal_dir.setText( app_settings.value( "ServerSettings/vassal-dir" ) ) + self.vassal_dir.setToolTip( + "Supported versions: {}".format( SUPPORTED_VASSAL_VERSIONS_DISPLAY ) + ) self.vasl_mod.setText( app_settings.value( "ServerSettings/vasl-mod" ) ) self.vasl_mod.setToolTip( "Supported versions: {}".format( SUPPORTED_VASL_MOD_VERSIONS_DISPLAY ) ) + self.boards_dir.setText( app_settings.value( "ServerSettings/boards-dir" ) ) + self.java_path.setText( app_settings.value( "ServerSettings/java-path" ) ) + self.webdriver_path.setText( app_settings.value( "ServerSettings/webdriver-path" ) ) + self.webdriver_path.setToolTip( "Configure either geckodriver or chromedriver here." ) + + def on_select_vassal_dir( self ): + """Let the user locate the VASSAL installation directory.""" + dname = QFileDialog.getExistingDirectory( + self, "Select VASSAL installation directory", + self.vassal_dir.text(), + QFileDialog.ShowDirsOnly + ) + if dname: + self.vassal_dir.setText( dname ) def on_select_vasl_mod( self ): """Let the user select a VASL module.""" fname = QFileDialog.getOpenFileName( self, "Select VASL module", - app_settings.value( "ServerSettings/vasl-mod" ), - "VASL module files (*.vmod)|All files (*.*)" + self.vasl_mod.text(), + "VASL module files (*.vmod);;All files (*.*)" )[0] if fname: self.vasl_mod.setText( fname ) + def on_select_boards_dir( self ): + """Let the user locate the VASL boards directory.""" + dname = QFileDialog.getExistingDirectory( + self, "Select VASL boards directory", + self.boards_dir.text(), + QFileDialog.ShowDirsOnly + ) + if dname: + self.boards_dir.setText( dname ) + + def on_select_java( self ): + """Let the user locate the Java executable.""" + fname = QFileDialog.getOpenFileName( + self, "Select Java executable", + self.java_path.text(), + _make_exe_filter_string() + )[0] + if fname: + self.java_path.setText( fname ) + + def on_select_webdriver( self ): + """Let the user locate the webdriver executable.""" + fname = QFileDialog.getOpenFileName( + self, "Select webdriver", + self.webdriver_path.text(), + _make_exe_filter_string() + )[0] + if fname: + self.webdriver_path.setText( fname ) + def on_ok( self ): """Accept the new server settings.""" # save the new settings + app_settings.setValue( "ServerSettings/vassal-dir", self.vassal_dir.text() ) fname = self.vasl_mod.text().strip() vasl_mod_changed = fname != app_settings.value( "ServerSettings/vasl-mod" ) app_settings.setValue( "ServerSettings/vasl-mod", fname ) + app_settings.setValue( "ServerSettings/boards-dir", self.boards_dir.text() ) + app_settings.setValue( "ServerSettings/java-path", self.java_path.text() ) + app_settings.setValue( "ServerSettings/webdriver-path", self.webdriver_path.text() ) # install the new settings # NOTE: We should really do this before saving the new settings, but that's more trouble @@ -80,11 +138,27 @@ class ServerSettingsDialog( QDialog ): """Cancel the dialog.""" self.close() +def _make_exe_filter_string(): + """Make a file filter string for executables.""" + buf = [] + if os.name == "nt": + buf.append( "Executable files (*.exe)" ) + buf.append( "All files (*.*)" ) + return ";;".join( buf ) + # --------------------------------------------------------------------- def install_server_settings(): """Install the server settings.""" + # install the server settings + from vasl_templates.webapp import app as app + app.config["VASSAL_DIR"] = app_settings.value( "ServerSettings/vassal-dir" ) + app.config["VASL_MOD"] = app_settings.value( "ServerSettings/vasl-mod" ) + app.config["BOARDS_DIR"] = app_settings.value( "ServerSettings/boards-dir" ) + app.config["JAVA_PATH"] = app_settings.value( "ServerSettings/java-path" ) + app.config["WEBDRIVER_PATH"] = app_settings.value( "ServerSettings/webdriver-path" ) + # load the VASL module fname = app_settings.value( "ServerSettings/vasl-mod" ) if fname: diff --git a/vasl_templates/ui/server_settings.ui b/vasl_templates/ui/server_settings.ui index 0e4a54d..025b9ed 100644 --- a/vasl_templates/ui/server_settings.ui +++ b/vasl_templates/ui/server_settings.ui @@ -10,7 +10,7 @@ 0 0 500 - 90 + 199 @@ -21,100 +21,227 @@ - - - - 0 - 0 - - - - - 0 - 30 - - - - - 16777215 - 30 - - - - - 5 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - &VASL module: - - - vasl_mod - - - - - - - - - - - 0 - 0 - - - - - 25 - 25 - - - - - 25 - 25 - - - - - - - true - - - - - vasl_mod - label - select_vasl_mod_button - - - - - - Qt::Vertical - - - - 20 - 40 - + + + 2 - + + + + VA&SSAL installation: + + + vassal_dir + + + + + + + 2 + + + + + + + + + 22 + 22 + + + + + 22 + 22 + + + + + + + + + + + + + &VASL module: + + + vasl_mod + + + + + + + 2 + + + + + + + + + 0 + 0 + + + + + 22 + 22 + + + + + 22 + 22 + + + + + + + true + + + + + + + + + VASL &boards: + + + boards_dir + + + + + + + 2 + + + + + + + + + 0 + 0 + + + + + 22 + 22 + + + + + 22 + 22 + + + + + + + + + + + + + &Java: + + + java_path + + + + + + + 2 + + + + + + + + + 0 + 0 + + + + + 22 + 22 + + + + + 22 + 22 + + + + + + + + + + + + + &Web driver: + + + webdriver_path + + + + + + + 2 + + + + + + + + + 22 + 22 + + + + + 22 + 22 + + + + + + + + + + @@ -199,6 +326,20 @@ + + vassal_dir + select_vassal_dir_button + vasl_mod + select_vasl_mod_button + boards_dir + select_boards_dir_button + java_path + select_java_button + webdriver_path + select_webdriver_button + ok_button + cancel_button + diff --git a/vasl_templates/web_channel.py b/vasl_templates/web_channel.py index 9a39b7b..a69f638 100644 --- a/vasl_templates/web_channel.py +++ b/vasl_templates/web_channel.py @@ -2,87 +2,57 @@ import os -from PyQt5.QtWidgets import QFileDialog - from vasl_templates.webapp.config.constants import APP_NAME +from vasl_templates.file_dialog import FileDialog # --------------------------------------------------------------------- class WebChannelHandler: """Handle web channel requests.""" - _FILE_FILTERS = "Scenario files (*.json);;All files (*)" - - def __init__( self, window ): - - # initialize - self._window = window - - # NOTE: While loading/saving scenarios works fine when handled by the embedded browser, - # we can't get the full path of the file saved loaded (because of browser security). - # This means that we can't e.g. default saving a scenario to the same file it was loaded from. - # This is such a lousy UX, we handle load/save operations ourself, where we can manage this. - self._curr_scenario_fname = None + def __init__( self, parent ): + self.parent = parent + self.scenario_file_dialog = FileDialog( + self.parent, + "scenario", ".json", + "Scenario files (*.json);;All files (*)", + "scenario.json" + ) + self.updated_vsav_file_dialog = FileDialog( + self.parent, + "VASL scenario", ".vsav", + "VASL scenario files (*.vsav);;All files (*)", + "scenario.vsav" + ) def on_new_scenario( self ): """Called when the scenario is reset.""" - self._curr_scenario_fname = None + self.scenario_file_dialog.curr_fname = None def load_scenario( self ): """Called when the user wants to load a scenario.""" - - # ask the user which file to load - fname, _ = QFileDialog.getOpenFileName( - self._window, "Load scenario", - os.path.split(self._curr_scenario_fname)[0] if self._curr_scenario_fname else None, - WebChannelHandler._FILE_FILTERS - ) - if not fname: - return None - - # load the scenario - try: - with open( fname, "r", encoding="utf-8" ) as fp: - data = fp.read() - except Exception as ex: #pylint: disable=broad-except - self._window.showErrorMsg( "Can't load the scenario:\n\n{}".format( ex ) ) - return None - self._curr_scenario_fname = fname - - return data + return self.scenario_file_dialog.load_file( False ) def save_scenario( self, data ): """Called when the user wants to save a scenario.""" - - # ask the user where to save the scenario - fname, _ = QFileDialog.getSaveFileName( - self._window, "Save scenario", - self._curr_scenario_fname, - WebChannelHandler._FILE_FILTERS - ) - if not fname: - return False - - # check the file extension - extn = os.path.splitext( fname )[1] - if not extn: - fname += ".json" - elif fname.endswith( "." ): - fname = fname[:-1] - - # save the file - try: - with open( fname, "w", encoding="utf-8" ) as fp: - fp.write( data ) - except Exception as ex: #pylint: disable=broad-except - self._window.showErrorMsg( "Can't save the scenario:\n\n{}".format( ex ) ) - return False - self._curr_scenario_fname = fname - - return True + return self.scenario_file_dialog.save_file( data ) def on_scenario_name_change( self, val ): """Update the main window title to show the scenario name.""" - self._window.setWindowTitle( + self.parent.setWindowTitle( "{} - {}".format( APP_NAME, val ) if val else APP_NAME ) + + def load_vsav( self ): + """Called when the user wants to load a VASL scenario to update.""" + data = self.updated_vsav_file_dialog.load_file( True ) + if data is None: + return None, None + fname = os.path.split( self.updated_vsav_file_dialog.curr_fname )[1] + return fname, data + + def save_updated_vsav( self, fname, data ): + """Called when a VASL scenario has been updated and is ready to be saved.""" + dname = os.path.split( self.updated_vsav_file_dialog.curr_fname )[0] + self.updated_vsav_file_dialog.curr_fname = os.path.join( dname, fname ) + return self.updated_vsav_file_dialog.save_file( data ) diff --git a/vasl_templates/webapp/__init__.py b/vasl_templates/webapp/__init__.py index baaa53e..4abf579 100644 --- a/vasl_templates/webapp/__init__.py +++ b/vasl_templates/webapp/__init__.py @@ -59,6 +59,7 @@ import vasl_templates.webapp.main #pylint: disable=cyclic-import import vasl_templates.webapp.vo #pylint: disable=cyclic-import import vasl_templates.webapp.snippets #pylint: disable=cyclic-import import vasl_templates.webapp.files #pylint: disable=cyclic-import +import vasl_templates.webapp.vassal #pylint: disable=cyclic-import # --------------------------------------------------------------------- diff --git a/vasl_templates/webapp/config/constants.py b/vasl_templates/webapp/config/constants.py index 3bd8c93..317bb79 100644 --- a/vasl_templates/webapp/config/constants.py +++ b/vasl_templates/webapp/config/constants.py @@ -8,7 +8,9 @@ APP_VERSION = "v0.5" # nb: also update setup.py APP_DESCRIPTION = "Generate HTML for use in VASL scenarios." if getattr( sys, "frozen", False ): + IS_FROZEN = True BASE_DIR = os.path.split( sys.executable )[0] else: + IS_FROZEN = False BASE_DIR = os.path.abspath( os.path.join( os.path.split(__file__)[0], ".." ) ) DATA_DIR = os.path.join( BASE_DIR, "data" ) diff --git a/vasl_templates/webapp/config/site.cfg.example b/vasl_templates/webapp/config/site.cfg.example index a36efd2..ef2e777 100644 --- a/vasl_templates/webapp/config/site.cfg.example +++ b/vasl_templates/webapp/config/site.cfg.example @@ -1,4 +1,10 @@ [Site Config] ; Enable VASL counter images in the UI by configuring a VASL .vmod file here. -VASL_MOD = ... +VASL_MOD = ...configure the VASL module (e.g. vasl-6.4.3.vmod)... + +; Configure VASSAL to be able to automatically update labels in a VASL scenario. +VASSAL_DIR = ...configure the VASSAL installation directory... +BOARDS_DIR = ...configure the VASL boards directory... +WEBDRIVER_PATH = ...configure either geckodriver or chromedriver here... +; JAVA_PATH = ...configure the Java executable here (optional, must be in the PATH otherwise)... diff --git a/vasl_templates/webapp/data/default-template-pack/atmm.j2 b/vasl_templates/webapp/data/default-template-pack/atmm.j2 index 4a58873..a800135 100644 --- a/vasl_templates/webapp/data/default-template-pack/atmm.j2 +++ b/vasl_templates/webapp/data/default-template-pack/atmm.j2 @@ -1,4 +1,4 @@ - + diff --git a/vasl_templates/webapp/data/default-template-pack/baz.j2 b/vasl_templates/webapp/data/default-template-pack/baz.j2 index 2b1db6b..1b11cc1 100644 --- a/vasl_templates/webapp/data/default-template-pack/baz.j2 +++ b/vasl_templates/webapp/data/default-template-pack/baz.j2 @@ -1,4 +1,4 @@ - + diff --git a/vasl_templates/webapp/data/default-template-pack/extras/blank-space.j2 b/vasl_templates/webapp/data/default-template-pack/extras/blank-space.j2 index 6832123..e2e7c74 100644 --- a/vasl_templates/webapp/data/default-template-pack/extras/blank-space.j2 +++ b/vasl_templates/webapp/data/default-template-pack/extras/blank-space.j2 @@ -1,4 +1,4 @@ - + diff --git a/vasl_templates/webapp/data/default-template-pack/extras/hip-guns.j2 b/vasl_templates/webapp/data/default-template-pack/extras/hip-guns.j2 old mode 100755 new mode 100644 index a13de2c..a46a49c --- a/vasl_templates/webapp/data/default-template-pack/extras/hip-guns.j2 +++ b/vasl_templates/webapp/data/default-template-pack/extras/hip-guns.j2 @@ -1,4 +1,4 @@ - + diff --git a/vasl_templates/webapp/data/default-template-pack/extras/kgs/grenade-bundles.j2 b/vasl_templates/webapp/data/default-template-pack/extras/kgs/grenade-bundles.j2 index 999f7f3..1a29d0a 100644 --- a/vasl_templates/webapp/data/default-template-pack/extras/kgs/grenade-bundles.j2 +++ b/vasl_templates/webapp/data/default-template-pack/extras/kgs/grenade-bundles.j2 @@ -1,4 +1,4 @@ - + diff --git a/vasl_templates/webapp/data/default-template-pack/extras/kgs/molotov-cocktails.j2 b/vasl_templates/webapp/data/default-template-pack/extras/kgs/molotov-cocktails.j2 index dfff9ab..b62f371 100644 --- a/vasl_templates/webapp/data/default-template-pack/extras/kgs/molotov-cocktails.j2 +++ b/vasl_templates/webapp/data/default-template-pack/extras/kgs/molotov-cocktails.j2 @@ -1,4 +1,4 @@ - + diff --git a/vasl_templates/webapp/data/default-template-pack/extras/pf-count.j2 b/vasl_templates/webapp/data/default-template-pack/extras/pf-count.j2 index cda7253..b146f71 100644 --- a/vasl_templates/webapp/data/default-template-pack/extras/pf-count.j2 +++ b/vasl_templates/webapp/data/default-template-pack/extras/pf-count.j2 @@ -1,4 +1,4 @@ - + diff --git a/vasl_templates/webapp/data/default-template-pack/extras/turn-track-shading.j2 b/vasl_templates/webapp/data/default-template-pack/extras/turn-track-shading.j2 index f76afee..2dda338 100644 --- a/vasl_templates/webapp/data/default-template-pack/extras/turn-track-shading.j2 +++ b/vasl_templates/webapp/data/default-template-pack/extras/turn-track-shading.j2 @@ -1,4 +1,4 @@ - + diff --git a/vasl_templates/webapp/data/default-template-pack/mol-p.j2 b/vasl_templates/webapp/data/default-template-pack/mol-p.j2 index 7ae4e4c..b6ba16e 100644 --- a/vasl_templates/webapp/data/default-template-pack/mol-p.j2 +++ b/vasl_templates/webapp/data/default-template-pack/mol-p.j2 @@ -1,4 +1,4 @@ - + diff --git a/vasl_templates/webapp/data/default-template-pack/mol.j2 b/vasl_templates/webapp/data/default-template-pack/mol.j2 index 7854db6..09cbb18 100644 --- a/vasl_templates/webapp/data/default-template-pack/mol.j2 +++ b/vasl_templates/webapp/data/default-template-pack/mol.j2 @@ -1,4 +1,4 @@ - + diff --git a/vasl_templates/webapp/data/default-template-pack/ob_note.j2 b/vasl_templates/webapp/data/default-template-pack/ob_note.j2 index c83315f..36baf07 100644 --- a/vasl_templates/webapp/data/default-template-pack/ob_note.j2 +++ b/vasl_templates/webapp/data/default-template-pack/ob_note.j2 @@ -1,4 +1,4 @@ - + diff --git a/vasl_templates/webapp/data/default-template-pack/ob_ordnance.j2 b/vasl_templates/webapp/data/default-template-pack/ob_ordnance.j2 index d390d79..52052a8 100644 --- a/vasl_templates/webapp/data/default-template-pack/ob_ordnance.j2 +++ b/vasl_templates/webapp/data/default-template-pack/ob_ordnance.j2 @@ -1,4 +1,4 @@ - + diff --git a/vasl_templates/webapp/data/default-template-pack/ob_setup.j2 b/vasl_templates/webapp/data/default-template-pack/ob_setup.j2 index 2990e5d..6cb9242 100644 --- a/vasl_templates/webapp/data/default-template-pack/ob_setup.j2 +++ b/vasl_templates/webapp/data/default-template-pack/ob_setup.j2 @@ -1,4 +1,4 @@ - + diff --git a/vasl_templates/webapp/data/default-template-pack/ob_vehicles.j2 b/vasl_templates/webapp/data/default-template-pack/ob_vehicles.j2 index ab3eb98..88f32a3 100644 --- a/vasl_templates/webapp/data/default-template-pack/ob_vehicles.j2 +++ b/vasl_templates/webapp/data/default-template-pack/ob_vehicles.j2 @@ -1,4 +1,4 @@ - + diff --git a/vasl_templates/webapp/data/default-template-pack/pf.j2 b/vasl_templates/webapp/data/default-template-pack/pf.j2 index 4319337..cba73db 100644 --- a/vasl_templates/webapp/data/default-template-pack/pf.j2 +++ b/vasl_templates/webapp/data/default-template-pack/pf.j2 @@ -1,4 +1,4 @@ - + diff --git a/vasl_templates/webapp/data/default-template-pack/piat.j2 b/vasl_templates/webapp/data/default-template-pack/piat.j2 index 219bfb0..5a12515 100644 --- a/vasl_templates/webapp/data/default-template-pack/piat.j2 +++ b/vasl_templates/webapp/data/default-template-pack/piat.j2 @@ -1,4 +1,4 @@ - + diff --git a/vasl_templates/webapp/data/default-template-pack/players.j2 b/vasl_templates/webapp/data/default-template-pack/players.j2 index a04d8e0..be51942 100644 --- a/vasl_templates/webapp/data/default-template-pack/players.j2 +++ b/vasl_templates/webapp/data/default-template-pack/players.j2 @@ -1,4 +1,4 @@ - + diff --git a/vasl_templates/webapp/data/default-template-pack/psk.j2 b/vasl_templates/webapp/data/default-template-pack/psk.j2 index d250d98..398eb50 100644 --- a/vasl_templates/webapp/data/default-template-pack/psk.j2 +++ b/vasl_templates/webapp/data/default-template-pack/psk.j2 @@ -1,4 +1,4 @@ - + diff --git a/vasl_templates/webapp/data/default-template-pack/scenario.j2 b/vasl_templates/webapp/data/default-template-pack/scenario.j2 index 412f030..e6d2588 100644 --- a/vasl_templates/webapp/data/default-template-pack/scenario.j2 +++ b/vasl_templates/webapp/data/default-template-pack/scenario.j2 @@ -1,4 +1,4 @@ - + diff --git a/vasl_templates/webapp/data/default-template-pack/scenario_note.j2 b/vasl_templates/webapp/data/default-template-pack/scenario_note.j2 index 3d905ee..4c5b8af 100644 --- a/vasl_templates/webapp/data/default-template-pack/scenario_note.j2 +++ b/vasl_templates/webapp/data/default-template-pack/scenario_note.j2 @@ -1,4 +1,4 @@ - + diff --git a/vasl_templates/webapp/data/default-template-pack/ssr.j2 b/vasl_templates/webapp/data/default-template-pack/ssr.j2 index f325a3c..d989502 100644 --- a/vasl_templates/webapp/data/default-template-pack/ssr.j2 +++ b/vasl_templates/webapp/data/default-template-pack/ssr.j2 @@ -1,4 +1,4 @@ - + diff --git a/vasl_templates/webapp/data/default-template-pack/victory_conditions.j2 b/vasl_templates/webapp/data/default-template-pack/victory_conditions.j2 index 2640bcc..bed9d31 100644 --- a/vasl_templates/webapp/data/default-template-pack/victory_conditions.j2 +++ b/vasl_templates/webapp/data/default-template-pack/victory_conditions.j2 @@ -1,4 +1,4 @@ - + diff --git a/vasl_templates/webapp/file_server/vasl_mod.py b/vasl_templates/webapp/file_server/vasl_mod.py index 7b30edd..f91936d 100644 --- a/vasl_templates/webapp/file_server/vasl_mod.py +++ b/vasl_templates/webapp/file_server/vasl_mod.py @@ -11,8 +11,8 @@ _logger = logging.getLogger( "vasl_mod" ) from vasl_templates.webapp.file_server.utils import get_vo_gpids, get_effective_gpid -SUPPORTED_VASL_MOD_VERSIONS = [ "6.3.3", "6.4.0", "6.4.1", "6.4.2", "6.4.3" ] -SUPPORTED_VASL_MOD_VERSIONS_DISPLAY = "6.3.3, 6.4.0-6.4.3" +SUPPORTED_VASL_MOD_VERSIONS = [ "6.4.0", "6.4.1", "6.4.2", "6.4.3" ] +SUPPORTED_VASL_MOD_VERSIONS_DISPLAY = "6.4.0-6.4.3" # --------------------------------------------------------------------- diff --git a/vasl_templates/webapp/static/css/main.css b/vasl_templates/webapp/static/css/main.css index bf2c7f5..7578a1b 100644 --- a/vasl_templates/webapp/static/css/main.css +++ b/vasl_templates/webapp/static/css/main.css @@ -14,7 +14,7 @@ label { height: 1.25em ; margin-top: -3px ; } #menu { position: absolute ; top: 15px ; right: 8px ; z-index: 1 ; } #menu input[type='image'] { height: 30px ; } -.PopMenu-Item { width: 11em ; } +.PopMenu-Item { width: 12em ; } .PopMenu-Item a { padding: 5px 10px 5px 10px ; } .PopMenu-Icon { display: none ; } @@ -26,7 +26,8 @@ label { height: 1.25em ; margin-top: -3px ; } .select2-dropdown { color: #444 ; } -.snippet-control button.generate { height: 26px ; padding: 2px 10px 2px 5px ; } +.snippet-control button.generate { height: 26px ; padding: 2px 10px 2px 5px ; color: #000 ; } +.snippet-control button.generate.inactive { color: #aaa ; } .snippet-control button.generate img { height: 20px ; margin-right: 5px ; vertical-align: middle ; } .snippet-control .ui-selectmenu-button { padding: 2px 10px ; } .snippet-control-menu-item { font-size: 75% ; font-style: italic ; } diff --git a/vasl_templates/webapp/static/css/vassal.css b/vasl_templates/webapp/static/css/vassal.css new file mode 100644 index 0000000..96b7ebd --- /dev/null +++ b/vasl_templates/webapp/static/css/vassal.css @@ -0,0 +1,9 @@ +.ui-dialog.update-vsav .ui-dialog-titlebar { display: none ; } +#update-vsav { display: flex ; align-items: center ; } +#update-vsav img { margin-right: 1em ; } + +#vassal-shim-error textarea { width: 100% ; height: 15em ; min-height: 5em ; resize: none ; padding: 2px ; font-family: monospace ; font-size: 80% ; } +.ui-dialog.vassal-shim-error .ui-dialog-titlebar { background: #f5af41 ; } +.ui-dialog.vassal-shim-error .ui-dialog-content { display: flex ; flex-direction: column ; } +.ui-dialog.vassal-shim-error .ui-dialog-content textarea { flex-grow: 1 ; } +.ui-dialog.vassal-shim-error .ui-dialog-buttonpane { border: none ; margin-top: 0 !important ; padding-top: 0 !important ; } diff --git a/vasl_templates/webapp/static/extras.js b/vasl_templates/webapp/static/extras.js index 2b6647f..44a0bec 100644 --- a/vasl_templates/webapp/static/extras.js +++ b/vasl_templates/webapp/static/extras.js @@ -147,20 +147,30 @@ function _parse_extra_template( template_id, template ) function fixup_template_parameters( template ) { // identify any non-standard template parameters - var matches = [] ; var regex = /\{\{([A-Z0-9_]+?):.*?\}\}/g ; + var matches = [] ; var match ; while( (match = regex.exec( template )) !== null ) matches.push( [ regex.lastIndex-match[0].length, match[0].length, match[1] ] ) ; // fix them up + var i ; if ( matches.length > 0 ) { - for ( var i=matches.length-1 ; i >= 0 ; --i ) + for ( i=matches.length-1 ; i >= 0 ; --i ) template = template.substr(0,matches[i][0]) + "{{"+matches[i][2]+"}}" + template.substr(matches[i][0]+matches[i][1]) ; } - // remove comments - template = template.replace( /\n*/g, "" ) ; + // remove all our special comments, except for the snippet ID + regex = /\n*/g ; + matches = [] ; + while( (match = regex.exec( template )) !== null ) { + if ( match[1] !== "id" ) + matches.push( [ regex.lastIndex-match[0].length, match[0].length ] ) ; + } + if ( matches.length > 0 ) { + for ( i=matches.length-1 ; i >= 0 ; --i ) + template = template.substr(0,matches[i][0]) + template.substr(matches[i][0]+matches[i][1]) ; + } return template ; } diff --git a/vasl_templates/webapp/static/help/index.html b/vasl_templates/webapp/static/help/index.html index d89321c..e360d4a 100644 --- a/vasl_templates/webapp/static/help/index.html +++ b/vasl_templates/webapp/static/help/index.html @@ -296,6 +296,16 @@ pytest --webdriver chrome --headless

NOTE: Internet Explorer is also supported as a WebDriver, but due to differences in the way it works, some tests are currently failing for this. +

Compiling the VASSAL shim

+ +

The program uses VASSAL to update VASL scenarios (.vsav files), and since this is written in Java, a helper program has been written in Java to do this. +

To compile the program, go to the $/vassal-shim directory and type: +

+make all VASSAL_DIR=... +
+where VASSAL_DIR points to VASSAL's lib/ directory (the program needs Vengine.jar). +

Since this program doesn't change very often, the resulting artifact (vassal-shim.jar) is checked into source control, so that it can be used without needing to install a JDK and compiling it first. +

Code lint'ing

Python code is checked using pylint (installed during the pip install above), which should be run from the root directory of the repo. diff --git a/vasl_templates/webapp/static/main.js b/vasl_templates/webapp/static/main.js index f0a2b7a..6899c61 100644 --- a/vasl_templates/webapp/static/main.js +++ b/vasl_templates/webapp/static/main.js @@ -9,7 +9,7 @@ gVaslPieceInfo = {} ; gWebChannelHandler = null ; gEmSize = null ; -var _NATIONALITY_SPECIFIC_BUTTONS = { +var NATIONALITY_SPECIFIC_BUTTONS = { "russian": [ "mol", "mol-p" ], "german": [ "pf", "psk", "atmm" ], "american": [ "baz" ], @@ -39,6 +39,7 @@ $(document).ready( function () { new_scenario: { label: "New scenario", action: function() { on_new_scenario() ; } }, load_scenario: { label: "Load scenario", action: on_load_scenario }, save_scenario: { label: "Save scenario", action: on_save_scenario }, + update_vsav: { label: "Update VASL scenario", action: on_update_vsav }, separator: { type: "separator" }, template_pack: { label: "Load template pack", action: on_template_pack }, separator2: { type: "separator" }, @@ -63,10 +64,10 @@ $(document).ready( function () { } ) ; } } ) ; - // add a handler for when the "load scenario" file has been selected + // add handlers $("#load-scenario").change( on_load_scenario_file_selected ) ; - // add a handler for when the "load template pack" file has been selected $("#load-template-pack").change( on_template_pack_file_selected ) ; + $("#load-vsav").change( on_load_vsav_file_selected ) ; // all done - we can show the menu now $("#menu").show() ; @@ -421,10 +422,10 @@ function update_page_load_status( id ) // check if the vehicle/ordnance listings have finished loading if ( gPageLoadStatus.indexOf( "vehicle-listings" ) === -1 && gPageLoadStatus.indexOf( "ordnance-listings" ) === -1 ) { - // NOTE: If the default scanerio contains any vehicles or ordnance, it will look up the V/O listings, + // NOTE: If the default scenario contains any vehicles or ordnance, it will look up the V/O listings, // so we need to wait until those have arrived. Note that while the default scenario will normally // be empty, having stuff in it is very useful during development. - do_on_new_scenario() ; + do_on_new_scenario( false ) ; } // check if the page has finished loading @@ -555,9 +556,9 @@ function on_player_change( player_no ) var player_nat = update_ob_tab_header( player_no ) ; // show/hide the nationality-specific buttons - for ( var nat in _NATIONALITY_SPECIFIC_BUTTONS ) { - for ( var i=0 ; i < _NATIONALITY_SPECIFIC_BUTTONS[nat].length ; ++i ) { - var button_id = _NATIONALITY_SPECIFIC_BUTTONS[nat][i] ; + for ( var nat in NATIONALITY_SPECIFIC_BUTTONS ) { + for ( var i=0 ; i < NATIONALITY_SPECIFIC_BUTTONS[nat].length ; ++i ) { + var button_id = NATIONALITY_SPECIFIC_BUTTONS[nat][i] ; var $elem = $( "#panel-ob_notes_" + player_no + " div.snippet-control[data-id='" + button_id + "']" ) ; $elem.css( "display", nat == player_nat ? "inline-block" : "none" ) ; } diff --git a/vasl_templates/webapp/static/simple_notes.js b/vasl_templates/webapp/static/simple_notes.js index 6234a19..c12fd10 100644 --- a/vasl_templates/webapp/static/simple_notes.js +++ b/vasl_templates/webapp/static/simple_notes.js @@ -88,6 +88,13 @@ function _do_edit_simple_note( $sortable2, $entry, default_width ) // create a new note if ( caption !== "" ) { data = { caption: caption, width: width } ; + if ( note_type === "scenario_notes" || note_type === "ob_setups" || note_type === "ob_notes" ) { + var usedIds = {} ; + $sortable2.find( "li" ).each( function() { + usedIds[ $(this).data("sortable2-data").id ] = true ; + } ) ; + data.id = auto_assign_id( usedIds ) ; + } _do_add_simple_note( $sortable2, data ) ; } } @@ -132,23 +139,32 @@ function _make_simple_note( note_type, caption ) // add a handler for the snippet button $content.children("img.snippet").click( function() { - var data = $(this).parent().parent().data( "sortable2-data" ) ; - var key ; - if ( note_type === "scenario_notes" ) - key = "SCENARIO_NOTE" ; - else if ( note_type === "ob_setups" ) - key = "OB_SETUP" ; - else if ( note_type == "ob_notes" ) - key = "OB_NOTE" ; - var extra_params = {} ; - extra_params[key] = data.caption ; - extra_params[key+"_WIDTH"] = data.width ; + var extra_params = get_simple_note_snippet_extra_params( $(this) ) ; generate_snippet( $(this), extra_params ) ; } ) ; return $content ; } +function get_simple_note_snippet_extra_params( $img ) +{ + // get the extra parameters needed to generate the simple note's snippet + var extra_params = {} ; + var $sortable2 = $img.closest( ".sortable" ) ; + var note_type = _get_note_type_for_sortable( $sortable2 ) ; + var key ; + if ( note_type === "scenario_notes" ) + key = "SCENARIO_NOTE" ; + else if ( note_type === "ob_setups" ) + key = "OB_SETUP" ; + else if ( note_type == "ob_notes" ) + key = "OB_NOTE" ; + var data = $img.parent().parent().data( "sortable2-data" ) ; + extra_params[key] = data.caption ; + extra_params[key+"_WIDTH"] = data.width ; + return extra_params ; +} + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - function _get_note_type_for_sortable( $sortable2 ) diff --git a/vasl_templates/webapp/static/snippets.js b/vasl_templates/webapp/static/snippets.js index c01526a..fd83256 100644 --- a/vasl_templates/webapp/static/snippets.js +++ b/vasl_templates/webapp/static/snippets.js @@ -22,29 +22,48 @@ var gLastSavedScenarioFilename = null; function generate_snippet( $btn, extra_params ) { - // unload the template parameters + // generate the snippet + var snippet = make_snippet( $btn, extra_params, true ) ; + + // copy the snippet to the clipboard + try { + copyToClipboard( snippet ) ; + } + catch( ex ) { + showErrorMsg( "Can't copy to the clipboard:

" + escapeHTML(ex) + "
" ) ; + return ; + } + showInfoMsg( "The HTML snippet has been copied to the clipboard." ) ; +} + +function make_snippet( $btn, extra_params, show_date_warnings ) +{ + // initialize var template_id = $btn.data( "id" ) ; var params = unload_snippet_params( true, template_id ) ; // set player-specific parameters - var curr_tab = $("#tabs .ui-tabs-active a").attr( "href" ) ; - var colors ; - if ( curr_tab === "#tabs-ob1" ) { - params.PLAYER_NAME = get_nationality_display_name( params.PLAYER_1 ) ; - colors = get_player_colors( 1 ) ; - params.OB_COLOR = colors[0] ; - params.OB_COLOR_2 = colors[2] ; - if ( gUserSettings["include-flags-in-snippets"] ) - params.PLAYER_FLAG = make_player_flag_url( get_player_nat( 1 ) ) ; - } else if ( curr_tab === "#tabs-ob2" ) { - params.PLAYER_NAME = get_nationality_display_name( params.PLAYER_2 ) ; - colors = get_player_colors( 2 ) ; + var player_no = get_player_no_for_element( $btn ) ; + if ( player_no ) { + params.PLAYER_NAME = get_nationality_display_name( params["PLAYER_"+player_no] ) ; + var colors = get_player_colors( player_no ) ; params.OB_COLOR = colors[0] ; params.OB_COLOR_2 = colors[2] ; if ( gUserSettings["include-flags-in-snippets"] ) - params.PLAYER_FLAG = make_player_flag_url( get_player_nat( 2 ) ) ; + params.PLAYER_FLAG = make_player_flag_url( get_player_nat( player_no ) ) ; } + // set the snippet ID + var data ; + if ( template_id === "ob_setup" || template_id === "ob_note" ) { + data = $btn.parent().parent().data( "sortable2-data" ) ; + params.SNIPPET_ID = template_id + "_" + player_no + "." + data.id ; + } else if ( template_id === "scenario_note" ) { + data = $btn.parent().parent().data( "sortable2-data" ) ; + params.SNIPPET_ID = template_id + "." + data.id ; + } else + params.SNIPPET_ID = template_id ; + // set player-specific parameters if ( template_id == "ob_vehicles_1" ) { template_id = "ob_vehicles" ; @@ -129,14 +148,16 @@ function generate_snippet( $btn, extra_params ) } // check for date-specific parameters - if ( template_id === "pf" && ! is_pf_available() ) - showWarningMsg( "PF are only available after September 1943." ) ; - if ( template_id === "psk" && ! is_psk_available() ) - showWarningMsg( "PSK are only available after September 1943." ) ; - if ( template_id === "baz" && ! is_baz_available() ) - showWarningMsg( "BAZ are only available from November 1942." ) ; - if ( template_id === "atmm" && ! is_atmm_available() ) - showWarningMsg( "ATMM are only available from 1944." ) ; + if ( show_date_warnings ) { + if ( template_id === "pf" && ! is_pf_available() ) + showWarningMsg( "PF are only available after September 1943." ) ; + if ( template_id === "psk" && ! is_psk_available() ) + showWarningMsg( "PSK are only available after September 1943." ) ; + if ( template_id === "baz" && ! is_baz_available() ) + showWarningMsg( "BAZ are only available from November 1942." ) ; + if ( template_id === "atmm" && ! is_atmm_available() ) + showWarningMsg( "ATMM are only available from 1944." ) ; + } // add in any extra parameters if ( extra_params ) @@ -149,44 +170,38 @@ function generate_snippet( $btn, extra_params ) // get the template to generate the snippet from var templ = get_template( template_id, true ) ; if ( templ === null ) - return ; + return "" ; var func ; try { func = jinja.compile( templ ).render ; } catch( ex ) { showErrorMsg( "Can't compile template:
" + escapeHTML(ex) + "
" ) ; - return ; + return "[error: can't compile template]" ; } // process the template - var val ; + var snippet ; try { // NOTE: While it's generally not a good idea to disable auto-escaping, the whole purpose // of this application is to generate HTML snippets, and so virtually every single // template parameter would have to be piped through the "safe" filter :-/ We never render // any of the generated HTML, so any risk exists only when the user pastes the HTML snippet // into a VASL scenario, which uses an ancient HTML engine (with probably no Javascript)... - val = func( params, { + snippet = func( params, { autoEscape: false, filters: { - join: function(val,sep) { return val.join(sep) ; } + join: function(snippet,sep) { return snippet.join(sep) ; } } , } ) ; - val = val.trim() ; + snippet = snippet.trim() ; } catch( ex ) { showErrorMsg( "Can't process template: " + template_id + "
" + escapeHTML(ex) + "
" ) ; - return ; + return "[error: can't process template'" ; } - try { - copyToClipboard( val ) ; - } - catch( ex ) { - showErrorMsg( "Can't copy to the clipboard:
" + escapeHTML(ex) + "
" ) ; - return ; - } - showInfoMsg( "The HTML snippet has been copied to the clipboard." ) ; + + return snippet ; } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -696,7 +711,7 @@ function do_load_scenario( data, fname ) { // NOTE: We reset the scenario first, in case the loaded scenario is missing fields, // so that those fields will be reset to their default values (instead of just staying unchanged). - do_on_new_scenario() ; + do_on_new_scenario( false ) ; // load the scenario try { @@ -715,6 +730,14 @@ function do_load_scenario_data( params ) // reset the scenario reset_scenario() ; + // auto-assign ID's to the OB setup notes and notes + // NOTE: We do this here to handle scenarios that were created before these ID's were implemented. + auto_assign_ids( params.SCENARIO_NOTES ) ; + auto_assign_ids( params.OB_SETUPS_1 ) ; + auto_assign_ids( params.OB_NOTES_1 ) ; + auto_assign_ids( params.OB_SETUPS_2 ) ; + auto_assign_ids( params.OB_NOTES_2 ) ; + // load the scenario parameters var params_loaded = {} ; var warnings = [] ; @@ -860,6 +883,50 @@ function do_load_scenario_data( params ) on_scenario_date_change() ; } +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +function auto_assign_ids( vals ) +{ + if ( ! vals ) + return ; + + // NOTE: These ID's are used to uniquely identify OB setup notes and OB notes, since they are generated + // from the same template ("ob_setup" and "ob_note") and so the template_id alone won't be enough. We need + // to be able to uniquely identify each snippet so that we can match them with labels in the VASL scenario. + // However, we need to be able to handle the following situation: + // - the scenario has, say, 5 OB notes, with ID's 1-5 + // - the user deletes #3, and creates a new one + // If we track the highest ID ever used across the life of the scenario, the new snippet will be assigned ID #6, + // but when we inject the snippets into the VASL scenario, the label corresponding to snippet #3 will be left + // as it is, and a new label created for snippet #6, which is not what the user will want. Instead, we re-use + // ID 3 and give it to the new snippet, so that when we inject snippets, the old label corresponding to snippet #3 + // will simply be updated with the contents of the new snippet #6. + + // identify which ID's are currently in use + var usedIds = {} ; + for ( var i=0 ; i < vals.length ; ++i ) { + if ( vals[i].id ) + usedIds[ vals[i].id ] = true ; + } + + // assign ID's to entries that don't have one + for ( i=0 ; i < vals.length ; ++i ) { + if ( ! vals[i].id ) + vals[i].id = auto_assign_id( usedIds ) ; + } +} + +function auto_assign_id( usedIds ) +{ + // assign the next available ID + for ( var i=1 ; ; ++i ) { + if ( ! usedIds[i] ) { + usedIds[i] = true ; + return i ; + } + } +} + // -------------------------------------------------------------------- function on_save_scenario() @@ -959,7 +1026,7 @@ function on_new_scenario() // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -function do_on_new_scenario( verbose ) { +function do_on_new_scenario( user_requested ) { // load the default scenario if ( gDefaultScenario ) do_load_scenario_data( gDefaultScenario ) ; @@ -976,11 +1043,11 @@ function do_on_new_scenario( verbose ) { // flag that we have a new scenario gLastSavedScenarioFilename = null ; - if ( gWebChannelHandler ) + if ( gWebChannelHandler && user_requested ) gWebChannelHandler.on_new_scenario() ; // provide some feedback to the user - if ( verbose ) + if ( user_requested ) showInfoMsg( "The scenario was reset." ) ; } @@ -1218,7 +1285,10 @@ function on_scenario_date_change() // (by SSR) even outside the normal time. function update_ui( id, is_available ) { var $btn = $( "button.generate[data-id='" + id + "']" ) ; - $btn.css( "color", is_available?"#000":"#aaa" ) ; + if ( is_available ) + $btn.removeClass( "inactive" ) ; + else + $btn.addClass( "inactive" ) ; $btn.children( "img" ).each( function() { $(this).attr( "src", gImagesBaseUrl + (is_available?"/snippet.png":"/snippet-disabled.png") ) ; } ) ; diff --git a/vasl_templates/webapp/static/utils.js b/vasl_templates/webapp/static/utils.js index 8f98f34..06a6ff9 100644 --- a/vasl_templates/webapp/static/utils.js +++ b/vasl_templates/webapp/static/utils.js @@ -37,7 +37,7 @@ function make_player_flag_url( player_nat ) { function get_player_no_for_element( $elem ) { - // get the player colors (if any) for the specified element + // get the player that owns the specified element if ( $.contains( $("#tabs-ob1")[0], $elem[0] ) ) return 1 ; if ( $.contains( $("#tabs-ob2")[0], $elem[0] ) ) diff --git a/vasl_templates/webapp/static/vassal.js b/vasl_templates/webapp/static/vassal.js new file mode 100644 index 0000000..7fb4eb9 --- /dev/null +++ b/vasl_templates/webapp/static/vassal.js @@ -0,0 +1,291 @@ + +// -------------------------------------------------------------------- + +function on_update_vsav() +{ + // FOR TESTING PORPOISES! We can't control a file upload from Selenium (since + // the browser will use native controls), so we get the data from a + diff --git a/vasl_templates/webapp/templates/vassal.html b/vasl_templates/webapp/templates/vassal.html new file mode 100644 index 0000000..db43e19 --- /dev/null +++ b/vasl_templates/webapp/templates/vassal.html @@ -0,0 +1,9 @@ + + + diff --git a/vasl_templates/webapp/tests/fixtures/dump-vsav/labels.txt b/vasl_templates/webapp/tests/fixtures/dump-vsav/labels.txt new file mode 100644 index 0000000..093783f --- /dev/null +++ b/vasl_templates/webapp/tests/fixtures/dump-vsav/labels.txt @@ -0,0 +1,57 @@ +SetupCommand: starting=false + AddPiece: DynamicProperty/81* MTR GrW 34 ca3 + AddPiece: DynamicProperty/3-3-8 Ehs + AddPiece: DynamicProperty/PzKw VG vca3 tca3 BU + AddPiece: DynamicProperty/HMG + AddPiece: Stack + AddPiece: DynamicProperty/User-Labeled + - test<09>global + - null + - + - Label + - Line 2 + - Map0;84;76;6292 + AddPiece: Stack + AddPiece: DynamicProperty/User-Labeled + - test<09>global + - null + - + - Label + - no background + - Map0;231;79;6293 + AddPiece: Stack + AddPiece: DynamicProperty/User-Labeled + - test<09>global + - null + - + - Label + - Line 2 + - Map0;366;79;6294 + AddPiece: Stack + AddPiece: DynamicProperty/User-Labeled + - test<09>global + - null + - + - Label + - no background + - Map0;498;82;6295 + AddPiece: DynamicProperty/User-Labeled + - test<09>global + - null + - + - Label + - Line 2 + - Map0;648;85;6296 + AddPiece: DynamicProperty/User-Labeled + - test<09>global + - null + - + - Label + - no background + - Map0;777;90;6297 + SetInfo + SetAllowed: [] + SetBoards + SetScenarioNote + SetPublicNote + SetupCommand: starting=true diff --git a/vasl_templates/webapp/tests/fixtures/dump-vsav/labels.vsav b/vasl_templates/webapp/tests/fixtures/dump-vsav/labels.vsav new file mode 100644 index 0000000000000000000000000000000000000000..dfcd39ecd23755df74b5e16b16b4b434615cb64c GIT binary patch literal 3351 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 Th0xNS|52Z<+_Te?>HPICVgpGf literal 0 HcmV?d00001 diff --git a/vasl_templates/webapp/tests/fixtures/update-vsav/empty.vsav b/vasl_templates/webapp/tests/fixtures/update-vsav/empty.vsav new file mode 100644 index 0000000000000000000000000000000000000000..1db5a4701c51e279002301cc854e833447b1857b GIT binary patch literal 733 zcmWIWW@Zs#;Nak3c(}9JmjMZI0@=lhWvMCdiMgr0emeyl40vX@@)qPoE?KtZNuOO~ z#eYEq(J3KSe(IT4*J>}fu{LsZ6-T&k7Y<^kU992Sh0)p0;8cLQ+%PDgRT9FD>8yft9c_Qy{*d3x%2x&$p}5eW|1 z(8zV;=8MA-2@f|MdGzSW9D@}THqM(kZ{f~fyHy$yiWjb2d2nRY6%lLaU}0rrW$$8U zWo~a}>Dm^S3wZ+if+s*uW9KluX}P2n=#aH|oyrB2&CO3K%}GrGIySWbG+&c}fa`a) zf6kZp>himKcyUUHzX|vzY-Q-XKx6%npVPVo%gfKzswi&?zdb?nw_~~5QoGL@j=|+# zv#Y)Gub)<2HuKK=mL@?n%PdL7Bvzv&cQLMiwsXS|8aM>6)ktCde7@;_u=tkC5r=xb zekBSVxp$I#(UUnG|BUS{WnRVEA6pvnvTn`-#htSbURmMsbB@DACdb75*|S%?mdXeW ztBmA$x8!feOm^*iARivRzwN;)p!=B_K|W?=5@A535#)#hr4dvBPci7)kOK`AiwH0k n$OLNx1{1n2WQTwP6agH7PD2E5fHx}}NRAl@mjY=eCJ+w*(h~5N literal 0 HcmV?d00001 diff --git a/vasl_templates/webapp/tests/fixtures/update-vsav/full.json b/vasl_templates/webapp/tests/fixtures/update-vsav/full.json new file mode 100644 index 0000000..53908c9 --- /dev/null +++ b/vasl_templates/webapp/tests/fixtures/update-vsav/full.json @@ -0,0 +1 @@ +{"SCENARIO_NAME":"test scenario","SCENARIO_ID":"TEST-01","SCENARIO_LOCATION":"Somewhere","SCENARIO_DATE":"2001-02-03","SCENARIO_WIDTH":"123","VICTORY_CONDITIONS_WIDTH":"300px","SSR_WIDTH":"300px","OB_VEHICLES_WIDTH_1":"","OB_ORDNANCE_WIDTH_1":"","OB_VEHICLES_WIDTH_2":"","OB_ORDNANCE_WIDTH_2":"","VICTORY_CONDITIONS":"Make the other guy die for his country!","SCENARIO_THEATER":"Burma","PLAYER_1":"american","PLAYER_1_ELR":"1","PLAYER_1_SAN":"2","PLAYER_2":"belgian","PLAYER_2_ELR":"3","PLAYER_2_SAN":"4","SSR":["SSR #1","SSR #2","SSR #3"],"OB_VEHICLES_1":[{"id":"am/v:008","name":"M4A1"},{"id":"am/v:021","name":"Sherman Crab"}],"OB_VEHICLES_2":[{"id":"alc/v:006","name":"R-35(f)"},{"id":"alc/v:012","name":"Medium Truck"}],"OB_ORDNANCE_1":[{"id":"am/o:002","name":"M1 81mm Mortar"}],"OB_ORDNANCE_2":[{"id":"alc/o:006","name":"Bofors M34"},{"id":"be/o:000","name":"DBT"}],"SCENARIO_NOTES":[{"caption":"scenario note #1","width":"200px","id":1}],"OB_SETUPS_1":[{"caption":"U.S. setup #1","id":1,"width":"111"},{"caption":"U.S. setup #2","id":2,"width":""},{"caption":"U.S. setup #3","id":3,"width":""}],"OB_SETUPS_2":[{"caption":"Belgian setup #1","id":1,"width":""},{"caption":"Belgian setup #2","id":2,"width":"222"},{"caption":"Belgian setup #3","id":3,"width":""}],"OB_NOTES_1":[{"caption":"U.S. note #1","id":1,"width":"111"},{"caption":"U.S. note #2","id":2,"width":""}],"OB_NOTES_2":[{"caption":"Belgian note #1","id":1,"width":""},{"caption":"Belgian note #2","id":2,"width":"222"}]} \ No newline at end of file diff --git a/vasl_templates/webapp/tests/fixtures/update-vsav/full.vsav b/vasl_templates/webapp/tests/fixtures/update-vsav/full.vsav new file mode 100644 index 0000000000000000000000000000000000000000..b3df730028b86fbd0b0c90d3bb46ffafb34ff002 GIT binary patch literal 5462 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 literal 0 HcmV?d00001 diff --git a/vasl_templates/webapp/tests/fixtures/update-vsav/hill621-legacy.json b/vasl_templates/webapp/tests/fixtures/update-vsav/hill621-legacy.json new file mode 100644 index 0000000..bf8ab57 --- /dev/null +++ b/vasl_templates/webapp/tests/fixtures/update-vsav/hill621-legacy.json @@ -0,0 +1 @@ +{"SCENARIO_NAME":"Hill 621","SCENARIO_ID":"ASL E","SCENARIO_LOCATION":"Near Minsk, Russia","SCENARIO_DATE":"1944-06-29","SCENARIO_WIDTH":"","VICTORY_CONDITIONS_WIDTH":"240px","SSR_WIDTH":"300px","OB_VEHICLES_WIDTH_1":"","OB_ORDNANCE_WIDTH_1":"","OB_VEHICLES_WIDTH_2":"","OB_ORDNANCE_WIDTH_2":"","VICTORY_CONDITIONS":"The Russians win at Game End if they Control ≥ five Level 3 hill hexes on Board 2.","PLAYER_1":"russian","PLAYER_1_ELR":"4","PLAYER_1_SAN":"3","PLAYER_2":"german","PLAYER_2_ELR":"3","PLAYER_2_SAN":"4","SSR":["EC are Moderate, with no wind at start.","After \"At Start\" placement, each German infantry unit must take a TC. The only possible consequence of failure is that the unit must begin the scenario broken. Those units which break during this pre-game TC are not subject to DM in the initial German RPh.","The Germans receive one module of 80+mm Battalion Mortar OBA (HE and Smoke) with the radio in the initial OB.","The Germans receive one module of 100+mm OBA (HE and Smoke) with the Turn 4 reinforcements."],"OB_VEHICLES_1":[{"name":"T-34 M43"},{"name":"SU-152"},{"name":"SU-122"},{"name":"ZIS-5"}],"OB_VEHICLES_2":[{"name":"PzKpfw IVH"},{"name":"PzKpfw IIIN"},{"name":"StuG IIIG (L)"},{"name":"StuH 42"},{"name":"SPW 250/1"},{"name":"SPW 251/1"},{"name":"SPW 251/sMG"}],"OB_ORDNANCE_2":[{"name":"7.5cm PaK 40"},{"name":"5cm PaK 38"}],"SCENARIO_NOTES":[{"caption":"Download the scenario card from Multi-Man Publishing (ASL Classic pack).","width":"300px"}],"OB_SETUPS_1":[{"caption":"Set up on any whole hex of Board 3","width":""},{"caption":"Enter on Turn 2 on any single road hex
\non the east edge of Board 3","width":""},{"caption":"Enter on Turn 5 on any single road hex
\non the east edge of Board 3","width":""}],"OB_SETUPS_2":[{"caption":"Set up in any whole hex of Board 4","width":""},{"caption":"Enter on Turn 1 on any single road hex
\non any edge of Board 2","width":""},{"caption":"Enter on Turn 2 on any single road hex
\non the north or south edge of Board 4","width":""},{"caption":"Enter on Turn 4 on any single road hex
\non the west edge of Board 2","width":""},{"caption":"Enter on Turn 5 on any single road hex
\nalong the north, south or west edge of Board 2","width":""},{"caption":"Enter on Turn 8 along
\nthe west edge of Board 2","width":""}],"OB_NOTES_1":[],"OB_NOTES_2":[{"caption":"80+mm Battalion Mortar
OBA (HE/Smoke)","width":""},{"caption":"100+mm OBA (HE/Smoke)","width":""}]} \ No newline at end of file diff --git a/vasl_templates/webapp/tests/fixtures/update-vsav/hill621-legacy.vsav b/vasl_templates/webapp/tests/fixtures/update-vsav/hill621-legacy.vsav new file mode 100644 index 0000000000000000000000000000000000000000..4da72de0cfa703efa478d3d625dbd00ab7441ab9 GIT binary patch literal 31765 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 literal 0 HcmV?d00001 diff --git a/vasl_templates/webapp/tests/test_capabilities.py b/vasl_templates/webapp/tests/test_capabilities.py index ee68a33..8cd42e5 100644 --- a/vasl_templates/webapp/tests/test_capabilities.py +++ b/vasl_templates/webapp/tests/test_capabilities.py @@ -8,7 +8,8 @@ from selenium.webdriver.common.keys import Keys from vasl_templates.webapp.tests.utils import \ init_webapp, select_menu_option, select_tab, click_dialog_button, \ - load_vasl_mod, find_child, find_children, wait_for_clipboard + load_vasl_mod, find_child, find_children, wait_for_clipboard, \ + set_scenario_date from vasl_templates.webapp.tests.test_vo_reports import get_vo_report from vasl_templates.webapp.tests.test_vehicles_ordnance import add_vo from vasl_templates.webapp.tests.test_scenario_persistence import save_scenario, load_scenario @@ -583,11 +584,7 @@ def test_capability_updates_in_ui( webapp, webdriver, monkeypatch ): def check_capabilities( scenario_date, expected ): """Get the vehicle/ordnance capabilities from the UI.""" # set the scenario date - if scenario_date: - elem = find_child( "input[name='SCENARIO_DATE']" ) - elem.clear() - elem.send_keys( scenario_date ) - elem.send_keys( Keys.TAB ) + set_scenario_date( scenario_date ) # check the vehicle/ordnance capabilities results = [] for sortable in sortables: diff --git a/vasl_templates/webapp/tests/test_counters.py b/vasl_templates/webapp/tests/test_counters.py index 423561d..6145dec 100644 --- a/vasl_templates/webapp/tests/test_counters.py +++ b/vasl_templates/webapp/tests/test_counters.py @@ -20,7 +20,7 @@ from vasl_templates.webapp.tests.test_scenario_persistence import load_scenario @pytest.mark.skipif( not pytest.config.option.vasl_mods, #pylint: disable=no-member - reason = "--vasl-mods-tests not specified" + reason = "--vasl-mods not specified" ) @pytest.mark.skipif( pytest.config.option.short_tests, #pylint: disable=no-member @@ -94,7 +94,7 @@ def _dump_pieces( vasl_mod, out ): @pytest.mark.skipif( not pytest.config.option.vasl_mods, #pylint: disable=no-member - reason = "--vasl-mods-tests not specified" + reason = "--vasl-mods not specified" ) def test_gpid_remapping( webapp, webdriver, monkeypatch ): """Test GPID remapping.""" diff --git a/vasl_templates/webapp/tests/test_ob.py b/vasl_templates/webapp/tests/test_ob.py index 21b8825..40dc7bd 100644 --- a/vasl_templates/webapp/tests/test_ob.py +++ b/vasl_templates/webapp/tests/test_ob.py @@ -9,7 +9,7 @@ from vasl_templates.webapp.tests.utils import \ get_nationalities, wait_for_clipboard, get_stored_msg, set_stored_msg_marker, select_tab, \ find_child, find_children, \ add_simple_note, edit_simple_note, get_sortable_entry_count, drag_sortable_entry_to_trash, \ - select_droplist_val, init_webapp, wait_for, adjust_html + select_droplist_val, init_webapp, wait_for, adjust_html, set_scenario_date # --------------------------------------------------------------------- @@ -96,19 +96,18 @@ def test_nationality_specific( webapp, webdriver ): #pylint: disable=too-many-lo init_webapp( webapp, webdriver ) nationalities = get_nationalities( webapp ) - # initialize - scenario_date = find_child( "#panel-scenario input[name='SCENARIO_DATE']" ) - def set_scenario_date( date ): - """Set the scenario date.""" - select_tab( "scenario" ) - scenario_date.clear() - scenario_date.send_keys( "{:02}/01/{:04}".format( date[1], date[0] ) ) - def do_check_snippets( btn, date, expected, warning ): """Check that snippets are being generated correctly.""" - # test snippet generation - set_scenario_date( date ) + # change the scenario date, check that the button is displayed correctly + set_scenario_date( "{:02}/01/{:04}".format( date[1], date[0] ) ) select_tab( "ob1" ) + classes = btn.get_attribute( "class" ) + classes = classes.split() if classes else [] + if warning: + assert "inactive" in classes + else: + assert "inactive" not in classes + # test snippet generation marker = set_stored_msg_marker( "_last-warning_" ) btn.click() wait_for_clipboard( 2, expected ) diff --git a/vasl_templates/webapp/tests/test_scenario_persistence.py b/vasl_templates/webapp/tests/test_scenario_persistence.py index 896cad1..476fca3 100644 --- a/vasl_templates/webapp/tests/test_scenario_persistence.py +++ b/vasl_templates/webapp/tests/test_scenario_persistence.py @@ -38,7 +38,7 @@ ALL_SCENARIO_PARAMS = { # --------------------------------------------------------------------- -def test_scenario_persistence( webapp, webdriver ): #pylint: disable=too-many-statements,too-many-locals +def test_scenario_persistence( webapp, webdriver ): #pylint: disable=too-many-statements,too-many-locals,too-many-branches """Test loading/saving scenarios.""" # initialize @@ -104,11 +104,7 @@ def test_scenario_persistence( webapp, webdriver ): #pylint: disable=too-many-st load_scenario_params( SCENARIO_PARAMS ) check_window_title( "my test scenario" ) check_ob_tabs( "russian", "german" ) - - # make sure that our test scenario includes everything - lhs = { k: set(v) for k,v in SCENARIO_PARAMS.items() } - rhs = { k: set(v) for k,v in ALL_SCENARIO_PARAMS.items() } - assert lhs == rhs + assert_scenario_params_complete( SCENARIO_PARAMS ) # save the scenario and check the results saved_scenario = save_scenario() @@ -124,6 +120,13 @@ def test_scenario_persistence( webapp, webdriver ): #pylint: disable=too-many-st for key in expected: if re.search( r"^OB_(VEHICLES|ORDNANCE)_\d$", key ): expected[key] = [ { "name": name } for name in expected[key] ] + for player_no in (1,2): + for vo_type in ("OB_SETUPS","OB_NOTES"): + entries = expected[ "{}_{}".format( vo_type, player_no ) ] + for i,entry in enumerate(entries): + entry["id"] = 1+i + for i,entry in enumerate(expected["SCENARIO_NOTES"]): + entry["id"] = 1+i assert saved_scenario == expected # make sure that our list of scenario parameters is correct @@ -190,6 +193,12 @@ def test_scenario_persistence( webapp, webdriver ): #pylint: disable=too-many-st assert get_sortable_vo_names(vehicles2) == SCENARIO_PARAMS["ob2"]["OB_VEHICLES_2"] assert get_sortable_vo_names(ordnance2) == SCENARIO_PARAMS["ob2"]["OB_ORDNANCE_2"] +def assert_scenario_params_complete( scenario_params ): + """Check that a set of scenario parameters is complete.""" + lhs = { k: set(v) for k,v in scenario_params.items() } + rhs = { k: set(v) for k,v in ALL_SCENARIO_PARAMS.items() } + assert lhs == rhs + # --------------------------------------------------------------------- def test_loading_ssrs( webapp, webdriver ): diff --git a/vasl_templates/webapp/tests/test_snippets.py b/vasl_templates/webapp/tests/test_snippets.py index 84305f4..bf28680 100644 --- a/vasl_templates/webapp/tests/test_snippets.py +++ b/vasl_templates/webapp/tests/test_snippets.py @@ -2,11 +2,67 @@ from selenium.webdriver.common.keys import Keys +from vasl_templates.webapp.config.constants import DATA_DIR as REAL_DATA_DIR from vasl_templates.webapp.tests.utils import \ - init_webapp, select_tab, set_template_params, wait_for_clipboard, \ - get_stored_msg, set_stored_msg_marker, find_child, adjust_html, \ + init_webapp, select_tab, select_tab_for_elem, set_template_params, wait_for_clipboard, \ + get_stored_msg, set_stored_msg_marker, find_child, find_children, adjust_html, \ for_each_template, add_simple_note, edit_simple_note, \ - get_sortable_entry_count, generate_sortable_entry_snippet, drag_sortable_entry_to_trash + get_sortable_entry_count, generate_sortable_entry_snippet, drag_sortable_entry_to_trash, \ + new_scenario, set_scenario_date +from vasl_templates.webapp.tests.test_scenario_persistence import load_scenario, load_scenario_params + +# --------------------------------------------------------------------- + +def test_snippet_ids( webapp, webdriver, monkeypatch ): + """Check that snippet ID's are generated correctly.""" + + # initialize + monkeypatch.setitem( webapp.config, "DATA_DIR", REAL_DATA_DIR ) + init_webapp( webapp, webdriver, scenario_persistence=1 ) + + # load a scenario (so that we get some sortable's) + scenario_data = { + "SCENARIO_NOTES": [ { "caption": "Scenario note #1" } ], + "OB_SETUPS_1": [ { "caption": "OB setup note #1" } ], + "OB_NOTES_1": [ { "caption": "OB note #1" } ], + "OB_SETUPS_2": [ { "caption": "OB setup note #2" } ], + "OB_NOTES_2": [ { "caption": "OB note #2" } ], + } + load_scenario( scenario_data ) + + def check_snippet( btn ): + """Generate a snippet and check that it has an ID.""" + select_tab_for_elem( btn ) + if not btn.is_displayed(): + # FUDGE! All nationality-specific buttons are created on each OB tab, and the ones not relevant + # to each player are just hidden. This is not real good since we have multiple elements with the same ID :-/ + # but we work around this by checking if the button is visible. Sigh... + return + btn.click() + wait_for_clipboard( 2, "", label, re.DOTALL ) + if not mo2: + continue # nb: this is not one of ours + snippet_id = mo2.group( 1 ) + if snippet_id.startswith( "extras/" ): + continue + labels[snippet_id] = label + + # compare what we extracted from the dump with what's expected + for snippet_id in expected: + if isinstance( expected[snippet_id], typing.re.Pattern ): + rc = expected[snippet_id].search( labels[snippet_id] ) is not None + else: + assert isinstance( expected[snippet_id], str ) + rc = expected[snippet_id] in labels[snippet_id] + if not rc: + print( "Can't find {} in label: {}".format( expected[snippet_id], labels[snippet_id] ) ) + assert False + del labels[snippet_id] + + # check for unexpected extra labels in the VASL scenario + if ignore: + labels = [ lbl for lbl in labels if lbl not in ignore ] + if labels: + for snippet_id in labels: + print( "Extra label in the VASL scenario: {}".format( snippet_id ) ) + assert False + +def _get_vsav_labels( vsav_dump ): + """Extract the labels from a VSAV dump.""" + matches = re.finditer( r"AddPiece: DynamicProperty/User-Labeled.*?- Map", vsav_dump, re.DOTALL ) + labels = [ mo.group() for mo in matches ] + regex = re.compile( r".*?" ) + matches = [ regex.search(label) for label in labels ] + return [ mo.group() if mo else "" for mo in matches ] diff --git a/vasl_templates/webapp/tests/utils.py b/vasl_templates/webapp/tests/utils.py index 8b85677..86f24f9 100644 --- a/vasl_templates/webapp/tests/utils.py +++ b/vasl_templates/webapp/tests/utils.py @@ -125,6 +125,16 @@ def select_tab( tab_id ): elem = find_child( "#tabs .ui-tabs-nav a[href='#tabs-{}']".format( tab_id ) ) elem.click() +def select_tab_for_elem( elem ): + """Select the tab that contains the specified element.""" + while True: + elem = elem.find_element_by_xpath( ".." ) + if elem.tag_name == "div": + div_id = elem.get_attribute( "id" ) + if div_id.startswith( "tabs-" ): + select_tab( div_id[5:] ) + break + def select_menu_option( menu_id ): """Select a menu option.""" elem = find_child( "#menu" ) @@ -214,6 +224,18 @@ def set_template_params( params ): #pylint: disable=too-many-branches # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +def set_scenario_date( scenario_date ): + """Set the scenario date.""" + if scenario_date is None: + return + select_tab( "scenario" ) + elem = find_child( "input[name='SCENARIO_DATE']" ) + elem.clear() + elem.send_keys( scenario_date ) + elem.send_keys( Keys.TAB ) # nb: force the calendar popup to close :-/ + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + _nationalities = None def get_nationality_display_name( nat_id ): diff --git a/vasl_templates/webapp/utils.py b/vasl_templates/webapp/utils.py new file mode 100644 index 0000000..a665ab2 --- /dev/null +++ b/vasl_templates/webapp/utils.py @@ -0,0 +1,117 @@ +""" Miscellaneous utilities. """ + +import os +import tempfile +import pathlib + +from selenium import webdriver +from PIL import Image, ImageChops + +from vasl_templates.webapp import app + +# --------------------------------------------------------------------- + +class TempFile: + """Manage a temp file that can be closed while it's still being used.""" + + def __init__( self, mode="wb", extn=None ): + self.mode = mode + self.extn = extn + self.temp_file = None + self.name = None + + def __enter__( self ): + """Allocate a temp file.""" + self.temp_file = tempfile.NamedTemporaryFile( mode=self.mode, suffix=self.extn, delete=False ) + self.name = self.temp_file.name + return self + + def __exit__( self, exc_type, exc_val, exc_tb ): + """Clean up the temp file.""" + self.close() + os.unlink( self.temp_file.name ) + + def write( self, data ): + """Write data to the temp file.""" + self.temp_file.write( data ) + + def close( self ): + """Close the temp file.""" + self.temp_file.close() + +# --------------------------------------------------------------------- + +class HtmlScreenshots: + """Generate preview screenshots of HTML.""" + + def __init__( self ): + self.webdriver = None + + def __enter__( self ): + """Initialize the HTML screenshot engine.""" + webdriver_path = app.config.get( "WEBDRIVER_PATH" ) + if not webdriver_path: + raise SimpleError( "No webdriver has been configured." ) + # NOTE: If we are being run on Windows without a console (e.g. the frozen PyQt desktop app), + # Selenium will launch the webdriver in a visible DOS box :-( There's no way to turn this off, + # but it can be disabled by modifying the Selenium source code. Find the subprocess.Popen() call + # in $/site-packages/selenium/webdriver/common/service.py and add the following parameter: + # creationflags = 0x8000000 # win32process.CREATE_NO_WINDOW + # It's pretty icky to have to do this, but since we're in a virtualenv, it's not too bad... + kwargs = { "executable_path": webdriver_path } + if "chromedriver" in webdriver_path: + options = webdriver.ChromeOptions() + options.set_headless( headless=True ) + kwargs["chrome_options"] = options + self.webdriver = webdriver.Chrome( **kwargs ) + elif "geckodriver" in webdriver_path: + options = webdriver.FirefoxOptions() + options.set_headless( headless=True ) + kwargs["firefox_options"] = options + kwargs["log_path"] = app.config.get( "GECKODRIVER_LOG", + os.path.join( tempfile.gettempdir(), "geckodriver.log" ) + ) + self.webdriver = webdriver.Firefox( **kwargs ) + else: + raise SimpleError( "Can't identify webdriver: {}".format( webdriver_path ) ) + return self + + def __exit__( self, exc_type, exc_val, exc_tb ): + """Clean up.""" + if self.webdriver: + self.webdriver.quit() + + def get_screenshot( self, html, window_size ): + """Get a preview screenshot of the specified HTML.""" + + self.webdriver.set_window_size( window_size[0], window_size[1] ) + with TempFile( extn=".html", mode="w" ) as html_tempfile: + + # take a screenshot of the HTML + # NOTE: We could do some funky Javascript stuff to load the browser directly from the string, + # but using a temp file is straight-forward and pretty much guaranteed to work :-/ + html_tempfile.write( html ) + html_tempfile.close() + self.webdriver.get( "file://{}".format( html_tempfile.name ) ) + with TempFile( extn=".png" ) as screenshot_tempfile: + screenshot_tempfile.close() + self.webdriver.save_screenshot( screenshot_tempfile.name ) + img = Image.open( screenshot_tempfile.name ) + + # trim the screenshot (nb: we assume a white background) + bgd = Image.new( img.mode, img.size, (255,255,255,255) ) + diff = ImageChops.difference( img, bgd ) + bbox = diff.getbbox() + return img.crop( bbox ) + +# --------------------------------------------------------------------- + +def change_extn( fname, extn ): + """Change a filename's extension.""" + return pathlib.Path( fname ).with_suffix( extn ) + +# --------------------------------------------------------------------- + +class SimpleError( Exception ): + """Represents a simple error that doesn't require a stack trace (e.g. bad configuration).""" + pass diff --git a/vasl_templates/webapp/vassal.py b/vasl_templates/webapp/vassal.py new file mode 100644 index 0000000..6cd2dc8 --- /dev/null +++ b/vasl_templates/webapp/vassal.py @@ -0,0 +1,349 @@ +""" Webapp handlers. """ +# Kathmandu, Nepal (NOV/18). + +import sys +import os +import subprocess +import traceback +import json +import re +import logging +import base64 +import time +import xml.etree.cElementTree as ET + +from flask import request + +from vasl_templates.webapp import app +from vasl_templates.webapp.config.constants import BASE_DIR, IS_FROZEN +from vasl_templates.webapp.utils import TempFile, HtmlScreenshots, SimpleError + +_logger = logging.getLogger( "update_vsav" ) + +SUPPORTED_VASSAL_VERSIONS = [ "3.2.15" ,"3.2.16", "3.2.17" ] +SUPPORTED_VASSAL_VERSIONS_DISPLAY = "3.2.15-.17" + +# --------------------------------------------------------------------- + +@app.route( "/update-vsav", methods=["POST"] ) +def update_vsav(): #pylint: disable=too-many-statements + """Update labels in a VASL scenario file.""" + + # parse the request + start_time = time.time() + vsav_data = request.json[ "vsav_data" ] + vsav_filename = request.json[ "filename" ] + snippets = request.json[ "snippets" ] + + # update the VASL scenario file + try: + + # get the VSAV data (we do this inside the try block so that the user gets shown + # a proper error dialog if there's a problem decoding the base64 data) + vsav_data = base64.b64decode( vsav_data ) + _logger.info( "Updating VSAV (#bytes=%d): %s", len(vsav_data), vsav_filename ) + + with TempFile() as input_file: + # save the VSAV data in a temp file + input_file.write( vsav_data ) + input_file.close() + fname = app.config.get( "UPDATE_VSAV_INPUT" ) # nb: for diagnosing problems + if fname: + _logger.debug( "Saving a copy of the VSAV data: %s", fname ) + with open( fname, "wb" ) as fp: + fp.write( vsav_data ) + with TempFile() as snippets_file: + # save the snippets in a temp file + xml = _save_snippets( snippets, snippets_file ) + snippets_file.close() + fname = app.config.get( "UPDATE_VSAV_SNIPPETS" ) # nb: for diagnosing problems + if fname: + _logger.debug( "Saving a copy of the snippets: %s", fname ) + with open( fname, "wb" ) as fp: + ET.ElementTree( xml ).write( fp ) + # run the VASSAL shim to update the VSAV file + with TempFile() as output_file, TempFile() as report_file: + output_file.close() + report_file.close() + vassal_shim = VassalShim() + vassal_shim.update_scenario( + input_file.name, snippets_file.name, output_file.name, report_file.name + ) + # read the updated VSAV data + with open( output_file.name, "rb" ) as fp: + vsav_data = fp.read() + fname = app.config.get( "UPDATE_VSAV_RESULT" ) # nb: for diagnosing problems + if fname: + _logger.debug( "Saving a copy of the update VSAV: %s", fname ) + with open( app.config.get("UPDATE_VSAV_RESULT"), "wb" ) as fp: + fp.write( vsav_data ) + # read the report + label_report = _parse_label_report( report_file.name ) + except VassalShimError as ex: + _logger.error( "VASSAL shim error: rc=%d", ex.retcode ) + if ex.retcode != 0: + return json.dumps( { + "error": "Unexpected return code from the VASSAL shim: {}".format( ex.retcode ), + "stdout": ex.stdout, + "stderr": ex.stderr, + } ) + return json.dumps( { + "error": "Unexpected error output from the VASSAL shim.", + "stdout": ex.stdout, + "stderr": ex.stderr, + } ) + except subprocess.TimeoutExpired: + return json.dumps( { + "error": "

The updater took too long to run, please try again." \ + "

If this problem persists, try configuring a longer timeout." + } ) + except SimpleError as ex: + _logger.error( "VSAV update error: %s", ex ) + return json.dumps( { "error": str(ex) } ) + except Exception as ex: #pylint: disable=broad-except + _logger.error( "Unexpected VSAV update error: %s", ex ) + return json.dumps( { + "error": str(ex), + "stdout": traceback.format_exc(), + } ) + + # return the results + _logger.debug( "Updated the VSAV file OK: elapsed=%.3fs", time.time()-start_time ) + # NOTE: We adjust the recommended save filename to encourage users to not overwrite the original file :-/ + vsav_filename = os.path.split( vsav_filename )[1] + fname, extn = os.path.splitext( vsav_filename ) + return json.dumps( { + "vsav_data": base64.b64encode(vsav_data).decode( "utf-8" ), + "filename": fname+" (updated)" + extn, + "report": { + "was_modified": label_report["was_modified"], + "labels_created": len(label_report["created"]), + "labels_updated": len(label_report["updated"]), + "labels_deleted": len(label_report["deleted"]), + "labels_unchanged": len(label_report["unchanged"]), + }, + } ) + +def _save_snippets( snippets, fp ): + """Save the snippets in a file. + + NOTE: We save the snippets as XML because Java :-/ + """ + + def get_html_size( snippet_id, html, window_size ): + """Get the size of the specified HTML.""" + start_time = time.time() + img = html_screenshots.get_screenshot( html, window_size ) + elapsed_time = time.time() - start_time + width, height = img.size + _logger.debug( "Generated screenshot for %s (%.3fs): %dx%d", snippet_id, elapsed_time, width, height ) + return width, height + + def do_save_snippets( html_screenshots ): + """Save the snippets.""" + + root = ET.Element( "snippets" ) + for key,val in snippets.items(): + + # add the next snippet + auto_create = "true" if val["auto_create"] else "false" + elem = ET.SubElement( root, "snippet", id=key, autoCreate=auto_create ) + elem.text = val["content"] + label_area = val.get( "label_area" ) + if label_area: + elem.set( "labelArea", label_area ) + + # add the raw content + elem2 = ET.SubElement( elem, "rawContent" ) + for node in val["raw_content"]: + ET.SubElement( elem2, "phrase" ).text = node + + # include the size of the snippet + if html_screenshots: + try: + # NOTE: Screenshots take significantly longer for larger window sizes. Since most of our snippets + # will be small, we first try with a smaller window, and switch to a larger one if necessary. + width, height = get_html_size( key, val["content"], (500,500) ) + if width >= 450 or height >= 450: + # NOTE: While it's tempting to set the browser window really large here, if the label ends up + # filling/overflowing the available space (e.g. because its width/height has been set to 100%), + # then the auto-created label will push any subsequent labels far down the map, possibly to + # somewhere unreachable. So, we set it somewhat more conservatively, so that if this happens, + # the user still has a chance to recover from it. Note that this doesn't mean that they can't + # have really large labels, it just affects the positioning of auto-created labels. + width, height = get_html_size( key, val["content"], (1500,1500) ) + # FUDGE! There's something weird going on in VASSAL e.g. "" gives us something + # very different to "
" :-/ Changing the font size also causes problems. + # The following fudging seems to give us something that's somewhat reasonable... :-/ + if re.search( r"width:\s*?\d+?px", val["content"] ): + width = int( width * 140 / 100 ) + elem.set( "width", str(width) ) + elem.set( "height", str(height) ) + except Exception as ex: #pylint: disable=broad-except + # NOTE: Don't let an error here stop the process. + logging.error( "Can't get snippet screenshot: %s", ex ) + logging.error( traceback.format_exc() ) + + ET.ElementTree( root ).write( fp ) + return root + + # save the snippets + if app.config.get( "DISABLE_UPDATE_VSAV_SCREENSHOTS" ): + return do_save_snippets( None ) + else: + with HtmlScreenshots() as html_screenshots: + return do_save_snippets( html_screenshots ) + +def _parse_label_report( fname ): + """Read the label report generated by the VASSAL shim.""" + doc = ET.parse( fname ) + report = { + "was_modified": doc.getroot().attrib["wasModified"] == "true" + } + for action in doc.getroot(): + nodes = [] + for node in action: + nodes.append( { "id": node.attrib["id"] } ) + if "x" in node.attrib and "y" in node.attrib: + nodes[-1]["pos"] = ( node.attrib["x"], node.attrib["y"] ) + report[ action.tag ] = nodes + return report + +# --------------------------------------------------------------------- + +class VassalShim: + """Provide access to VASSAL via the Java shim.""" + + def __init__( self ): + + # locate the VASSAL engine + vassal_dir = app.config.get( "VASSAL_DIR" ) + if not vassal_dir: + raise SimpleError( "The VASSAL installation directory has not been configured." ) + self.vengine_jar = None + for root,_,fnames in os.walk( vassal_dir ): + for fname in fnames: + if fname == "Vengine.jar": + self.vengine_jar = os.path.join( root, fname ) + break + if not self.vengine_jar: + raise SimpleError( "Can't find Vengine.jar: {}".format( vassal_dir ) ) + + # locate the boards + self.boards_dir = app.config.get( "BOARDS_DIR" ) + if not self.boards_dir: + raise SimpleError( "The VASL boards directory has not been configured." ) + if not os.path.isdir( self.boards_dir ): + raise SimpleError( "Can't find the VASL boards: {}".format( self.boards_dir ) ) + + # locate the VASL module + self.vasl_mod = app.config.get( "VASL_MOD" ) + if not self.vasl_mod: + raise SimpleError( "The VASL module has not been configured." ) + if not os.path.isfile( self.vasl_mod ): + raise SimpleError( "Can't find VASL module: {}".format( self.vasl_mod ) ) + + # locate the VASSAL shim JAR + if IS_FROZEN: + meipass = sys._MEIPASS #pylint: disable=no-member,protected-access + self.shim_jar = os.path.join( meipass, "vasl_templates/webapp/vassal-shim.jar" ) + else: + self.shim_jar = os.path.join( os.path.split(__file__)[0], "../../vassal-shim/release/vassal-shim.jar" ) + if not os.path.isfile( self.shim_jar ): + raise SimpleError( "Can't find the VASSAL shim JAR." ) + + def dump_scenario( self, fname ): + """Dump a scenario file.""" + return self._run_vassal_shim( "dump", fname ) + + def update_scenario( self, vsav_fname, snippets_fname, output_fname, report_fname ): + """Update a scenario file.""" + return self._run_vassal_shim( + "update", self.boards_dir, vsav_fname, snippets_fname, output_fname, report_fname + ) + + def _run_vassal_shim( self, *args ): #pylint: disable=too-many-locals + """Run the VASSAL shim.""" + + # prepare the command + java_path = app.config.get( "JAVA_PATH" ) + if not java_path: + java_path = "java" # nb: this must be in the PATH + class_path = app.config.get( "JAVA_CLASS_PATH" ) + if not class_path: + class_path = [ self.vengine_jar, self.shim_jar ] + class_path.append( os.path.split( self.shim_jar )[0] ) # nb: to find logback(-test).xml + if IS_FROZEN: + class_path.append( BASE_DIR ) # nb: also to find logback(-test).xml + sep = ";" if os.name == "nt" else ":" + class_path = sep.join( class_path ) + args2 = [ + java_path, "-classpath", class_path, "vassal_shim.Main", + args[0], self.vasl_mod + ] + args2.extend( args[1:] ) + + # figure out how long to the let the VASSAL shim run + timeout = int( app.config.get( "VASSAL_SHIM_TIMEOUT", 120 ) ) + if timeout <= 0: + timeout = None + + # run the VASSAL shim + _logger.debug( "Running VASSAL shim (timeout=%s): %s", str(timeout), " ".join(args2) ) + start_time = time.time() + # NOTE: We can't use pipes to capture the output here when we're frozen on Windows ("invalid handle" errors), + # I suspect because we freeze the application using --noconsole, which causes problems when + # the child process tries to inherit handles. Capturing the output in temp files also fails (!), + # as does using subprocess.DEVNULL (!!!) Setting close_fds when calling Popen() also made no difference. + # The only thing that worked was removing "--noconsole" when freezing the application, but that causes + # a DOS box to appear when we are run :-/ + # However, we can also not specify any stdout/stderr, and since we don't actually check the output, + # we can get away with this, even if it is a bit icky :-/ However, if the VASSAL shim throws an error, + # we won't be able to show the stack trace, just a generic "VASSAL shim failed" message :-( + with TempFile() as buf1, TempFile() as buf2: + kwargs = {} + if not IS_FROZEN: + kwargs = { "stdout": buf1.temp_file, "stderr": buf2.temp_file } + if os.name == "nt": + # NOTE: Using CREATE_NO_WINDOW doesn't fix the problem of VASSAL's UI sometimes appearing, + # but it does hide the DOS box if the user has configured java.exe instead of javaw.exe. + kwargs["creationflags"] = 0x8000000 # nb: win32process.CREATE_NO_WINDOW + proc = subprocess.Popen( args2, **kwargs ) + try: + proc.wait( timeout ) + except subprocess.TimeoutExpired: + proc.kill() + raise + buf1.close() + stdout = open( buf1.name, "r", encoding="utf-8" ).read() + buf2.close() + stderr = open( buf2.name, "r", encoding="utf-8" ).read() + elapsed_time = time.time() - start_time + _logger.debug( "- Completed OK: %.3fs", elapsed_time ) + + # check the result + stderr = stderr.replace( "Warning: Could not get charToByteConverterClass!", "" ).strip() + # NOTE: VASSAL's internal representation of a scenario seems to be tightly coupled with its UI, + # which means that when we load a scenario, bits of the UI sometimes start appearing (although not always, + # presumably because there's a race between how fast we can make our changes and save the scenario + # vs. how fast the UI can start up :-/). When the UI does start to appear, it fails, presumably because + # we haven't performed the necessary startup incantations, and dumps a stack trace to stderr. + # The upshot is that the only thing we look for is an exit code of 0, which means that the VASSAL shim + # saved the scenario successfully and exited cleanly; any output on stderr means that some part + # of VASSAL barfed as it was trying to start up and can (hopefully) be safely ignored. + if stderr: + _logger.info( "VASSAL shim stderr output:\n%s", stderr ) + if proc.returncode != 0: + raise VassalShimError( proc.returncode, stdout, stderr ) + return stdout + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +class VassalShimError( Exception ): + """Represents an error returned by the VASSAL shim.""" + + def __init__( self, retcode, stdout, stderr ): + super().__init__() + self.retcode = retcode + self.stdout = stdout + self.stderr = stderr diff --git a/vassal-shim/.gitignore b/vassal-shim/.gitignore new file mode 100644 index 0000000..b5b0242 --- /dev/null +++ b/vassal-shim/.gitignore @@ -0,0 +1,3 @@ +output/ +release/vassal-shim.properties +logback-test.xml diff --git a/vassal-shim/Makefile b/vassal-shim/Makefile new file mode 100644 index 0000000..2196299 --- /dev/null +++ b/vassal-shim/Makefile @@ -0,0 +1,27 @@ +# Define VASSAL_DIR in the command line arguments to point to the directory that contains Vengine.jar e.g. +# make all VASSAL_DIR=... + +SRC_DIR:=src +DATA_DIR:=data +OUTPUT_DIR:=output +RELEASE_DIR:=release + +JAVAC:=javac +JAR:=jar +CLASSPATH:=$(VASSAL_DIR)/Vengine.jar:$(OUTPUT_DIR) +JAVAC_FLAGS:=-d $(OUTPUT_DIR) -classpath $(CLASSPATH) -sourcepath $(SRC_DIR) -Xlint:unchecked + +all: init compile + +init: + mkdir -p $(OUTPUT_DIR) + mkdir -p $(RELEASE_DIR) + +compile: init + $(JAVAC) $(JAVAC_FLAGS) $(shell find $(SRC_DIR) -name '*.java') + cp -r $(DATA_DIR) $(OUTPUT_DIR) + $(JAR) cfe $(RELEASE_DIR)/vassal-shim.jar vassal_shim.Main -C $(OUTPUT_DIR) . + +clean: + rm -r $(OUTPUT_DIR) + rm -r $(RELEASE_DIR) diff --git a/vassal-shim/data/boardNames.txt b/vassal-shim/data/boardNames.txt new file mode 100644 index 0000000..aa60baf --- /dev/null +++ b/vassal-shim/data/boardNames.txt @@ -0,0 +1,208 @@ +; This file defines VASL board names. +; nb: bd00-99 are handled automatically in the code. + +bd1a +bd1b +bd2a +bd2b +bd3a +bd3b +bd4a +bd4b +bd5a +bd5b +bd6a +bd6b +bd7a +bd7b +bd8a +bd8b +bd9a +bd9b +bd10z +bd17z + +bda +bdb +bdc +bdd +bdd1 +bdd2 +bdd3 +bdd4 +bdd5 +bdd6 +bdd7 +bde +bdf +bdg +bdh +bdp +bdq +bdr +bds +bdt +bdu +bdv +bdw +bdx +bdy +bdz + +bdABTF +bdASLNews +bdB&I +bdbatisse02 +bdbatisse03 +bdbatisse04 +bdBB +bdBDF +bdBF1 +bdBFPA +bdBFPB +bdBFPC +bdBFPD +bdBFPDW1a +bdBFPDW1b +bdBFPDW2a +bdBFPDW2b +bdBFPDW3a +bdBFPDW3b +bdBFPDW4a +bdBFPDW4b +bdBFPDW5a +bdBFPDW5b +bdBFPDW6a +bdBFPDW6b +bdBFPE +bdBFPF +bdBFPG +bdBFPH +bdBFPI +bdBFPJ +bdBFPK +bdBFPL +bdBFPM +bdBFPN +bdBFPO +bdBFPP +bdBFPQ +bdBFPR +bdBlank0 +bdBlank1 +bdBRT +bdBRV +bdCemHill +bdCH +bdCH1 +bdCH2 +bdCH3 +bdCH4 +bdCM +bdCRS +bdDaE +bdDBP +bdER +bdFB_CG2 +bdFB_CG3 +bdFB_NE +bdFB_NW +bdFB_SE +bdFB_SW +bdFC +bdfe3 +bdfe4 +bdfe5 +bdfe6 +bdfe8 +bdfe9 +bdff1 +bdFF10 +bdFF11 +bdFF12 +bdFF13 +bdFF14 +bdFF15 +bdff2 +bdff3 +bdff4 +bdff5 +bdff6 +bdff9 +bdFrFA +bdGB1 +bdGB2 +bdGB3 +bdGB4 +bdGB5 +bdGT +bdHH +bdHOBI +bdHOBII +bdHoBIII +bdHoBIV +bdJ1 +bdKB +bdkholmS +bdkholmW +bdkholmWO +bdKOTH +bdKR +bdKreta +bdLCP-Caslo +bdLCP-Crossroads +bdLCP-GYMO +bdLCP-JAVA09 +bdLCP-JAVA10 +bdLCP-JAVA11 +bdLCP-JAVA12 +bdLCP-JAVA13 +bdLCP-JAVA14 +bdLCPDtO +bdLFT1 +bdLFT2 +bdLG +bdNG +bdNUL +bdNULV +bdOTO +bdoto2 +bdOzB +bdPB +bdPBR +bdPdH +bdPHD +bdRaM +bdRB +bdRBv2 +bdRees +bdRileys +bdRR +bdSC +bdSCW0 +bdSCW1 +bdSCW2 +bdSenno +bdSG +bdSH +bdST +bdStN +bdTauroggen +bdTCRS +bdThinLine +bdTO +bdTR1 +bdTR2 +bdTR3 +bdTR4 +bdTR5 +bdTR6 +bdTR7 +bdTR8 +bdTRBH +bdVotG +bdWittLastBattle +KB +RR +SH +TRBH diff --git a/vassal-shim/release/vassal-shim.jar b/vassal-shim/release/vassal-shim.jar new file mode 100644 index 0000000000000000000000000000000000000000..e1d4324f18f26fa8f7a03155be7a62d6151fb793 GIT binary patch literal 26840 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 literal 0 HcmV?d00001 diff --git a/vassal-shim/src/vassal_shim/GamePieceLabelFields.java b/vassal-shim/src/vassal_shim/GamePieceLabelFields.java new file mode 100644 index 0000000..cd330af --- /dev/null +++ b/vassal-shim/src/vassal_shim/GamePieceLabelFields.java @@ -0,0 +1,51 @@ +package vassal_shim ; + +import java.util.ArrayList ; + +import VASSAL.counters.GamePiece ; + +// -------------------------------------------------------------------- + +public class GamePieceLabelFields +{ + // Holds the individual fields in a GamePiece label. + // A GamePiece's state is a string, consisting of a number of separated fields. + // We parse the string into its constituent parts, so that we can make changes + // to some fields (i.e. the label content), and re-constitute the state string. + + // These fields contain label #1 and #2. + public static final int FIELD_INDEX_LABEL1 = 3 ; + public static final int FIELD_INDEX_LABEL2 = 4 ; + + private GamePiece gamePiece ; + private ArrayList fields ; + private ArrayList separators ; + private int fieldIndex ; + + public GamePiece gamePiece() { return gamePiece ; } + public String getLabelContent() { return getLabelContent( this.fieldIndex ) ; } + public String getLabelContent( int fieldIndex ) { return fieldIndex < fields.size() ? fields.get(fieldIndex) : null ; } + public void setFieldIndex( int fieldIndex ) { this.fieldIndex = fieldIndex ; } + + public GamePieceLabelFields( GamePiece gamePiece, ArrayList separators, ArrayList fields, int fieldIndex ) + { + this.gamePiece = gamePiece ; + this.separators = separators ; + this.fields = fields ; + this.fieldIndex = fieldIndex ; + } + + public String getNewGamePieceState( String newField ) + { + // get the GamePiece's state wih the new field + fields.set( fieldIndex, newField ) ; + StringBuilder buf = new StringBuilder() ; + for ( int i=0 ; i < fields.size() ; ++i ) { + buf.append( fields.get( i ) ) ; + if ( i < separators.size() ) + buf.append( separators.get( i ) ) ; + } + return buf.toString() ; + } +} + diff --git a/vassal-shim/src/vassal_shim/LabelArea.java b/vassal-shim/src/vassal_shim/LabelArea.java new file mode 100644 index 0000000..3548425 --- /dev/null +++ b/vassal-shim/src/vassal_shim/LabelArea.java @@ -0,0 +1,107 @@ +package vassal_shim ; + +import java.awt.Point ; + +import org.slf4j.Logger ; +import org.slf4j.LoggerFactory ; + +// -------------------------------------------------------------------- + +public class LabelArea +{ + // Represents a rectangular area on the map in which we will put labels. + + private static final Logger logger = LoggerFactory.getLogger( LabelArea.class ) ; + + private String labelAreaName ; + private Point topLeft ; + private int areaWidth, areaHeight ; + private int xMargin, yMargin ; + private Point currPos ; + private int currRowHeight ; + + public String getName() { return labelAreaName ; } + + public LabelArea( String name, Point topLeft, int width, int height, int xMargin, int yMargin ) + { + logger.info( "Creating LabelArea '{}': topLeft=[{},{}] ; size={}x{} ; xMargin={} ; yMargin={}", + name, topLeft.x, topLeft.y, width, height, xMargin, yMargin + ) ; + this.labelAreaName = name ; + this.topLeft = topLeft ; + this.areaWidth = width ; + this.areaHeight = height ; + this.xMargin = xMargin ; + this.yMargin = yMargin ; + this.currPos = new Point( 0, 0 ) ; // nb: relative to topLeft + this.currRowHeight = 0 ; + } + + public Point getNextPosition( String snippet_id, int labelWidth, int labelHeight ) + { + // NOTE: When trying to position the label, we allow overflow of up to 40% of the label's width, + // since that will still put the label's centre (which is the click target) in a clear part of the map. + + // check if the label will fit in the next available position + logger.debug( "Getting next label position ({}): label={}x{}, currPos=[{},{}]", + labelAreaName, labelWidth, labelHeight, currPos.x, currPos.y + ) ; + int overflow = (currPos.x + labelWidth) - areaWidth ; + logger.debug( "- h.overflow = {}", overflow ) ; + if ( overflow < 0.4 * labelWidth ) { + // we have enough horizontal space to place the label, check vertically + overflow = (currPos.y + labelHeight) - areaHeight ; + logger.debug( "- can use current row, v.overflow={}", overflow ) ; + if ( overflow < 0.4 * labelHeight ) { + // we have enough vertical space as well, put the label in the next available position + logger.debug( "- can use next available position: [{},{}]", currPos.x, currPos.y ) ; + Point assignedPos = new Point( topLeft.x+currPos.x, topLeft.y+currPos.y ) ; + currPos.x += labelWidth + xMargin ; + currRowHeight = Math.max( currRowHeight, labelHeight ) ; + return assignedPos ; + } else { + // the LabelArea is full - notify the caller + logger.debug( "- LabelArea is full!" ) ; + return null ; + } + } else { + // there isn't enough horizontal space to place the label, start a new row + doStartNewRow() ; + logger.debug( "- starting a new row: y={}",currPos.y ) ; + // put the label at the start of the new row + if ( labelWidth > areaWidth ) { + // the label is wider than the available width- centre it + currPos.x = (areaWidth - labelWidth) / 2 ; + } + overflow = (currPos.y + labelHeight) - areaHeight ; + logger.debug( "- v.overflow = {}", overflow ) ; + if ( overflow >= 0.4 * labelHeight ) { + // the LabelArea is full - notify the caller + logger.debug( "- LabelArea is full!" ) ; + return null ; + } + logger.debug( "- assigning position: [{},{}]", currPos.x, currPos.y ) ; + Point assignedPos = new Point( topLeft.x+currPos.x, topLeft.y+currPos.y ) ; + currPos.x += labelWidth + xMargin ; + currRowHeight = Math.max( currRowHeight, labelHeight ) ; + return assignedPos ; + } + } + + public void startNewRow( String snippetId ) + { + // start a new row + doStartNewRow() ; + logger.debug( "Started a new row for '{}': y={}", snippetId, currPos.y ) ; + } + + private void doStartNewRow() + { + // start a new row + if ( currPos.x == 0 ) + return ; + currPos.x = 0 ; + currPos.y += currRowHeight + yMargin ; + currRowHeight = 0 ; + } +} diff --git a/vassal-shim/src/vassal_shim/Main.java b/vassal-shim/src/vassal_shim/Main.java new file mode 100644 index 0000000..f8ba7e6 --- /dev/null +++ b/vassal-shim/src/vassal_shim/Main.java @@ -0,0 +1,62 @@ +package vassal_shim ; + +import vassal_shim.VassalShim ; + +// -------------------------------------------------------------------- + +public class Main +{ + public static void main( String[] args ) + { + // parse the command line arguments + if ( args.length == 0 ) { + printHelp() ; + System.exit( 0 ) ; + } + + // execute the specified command + try { + String cmd = args[0].toLowerCase() ; + if ( cmd.equals( "dump" ) ) { + checkArgs( args, 3, "the VASL .vmod file and scenario file" ) ; + VassalShim shim = new VassalShim( args[1], null ) ; + shim.dumpScenario( args[2] ) ; + System.exit( 0 ) ; + } + else if ( cmd.equals( "update" ) ) { + checkArgs( args, 7, "the VASL .vmod file, boards directory, scenario file, snippets file and output/report files" ) ; + VassalShim shim = new VassalShim( args[1], args[2] ) ; + shim.updateScenario( args[3], args[4], args[5], args[6] ) ; + System.exit( 0 ) ; + } + else { + System.out.println( "Unknown command: " + cmd ) ; + System.exit( 1 ) ; + } + } catch( Exception ex ) { + System.out.println( "ERROR: " + ex ) ; + ex.printStackTrace( System.out ) ; + System.exit( -1 ) ; + } + } + + private static void checkArgs( String[]args, int expected, String hint ) + { + // check the number of arguments + if ( args.length != expected ) { + System.out.println( "Incorrect number of arguments, please specify " + hint + "." ) ; + System.exit( 2 ) ; + } + } + + private static void printHelp() + { + // show program usage + System.out.println( Main.class.getName() + " {command} {options}" ) ; + System.out.println( " Provide access to VASSAL functionality." ) ; + System.out.println() ; + System.out.println( "Available commands:" ) ; + System.out.println( " dump: Dump a .vsav file." ) ; + System.out.println( " update: Update the labels in a .vsav file." ) ; + } +} diff --git a/vassal-shim/src/vassal_shim/ModuleManagerMenuManager.java b/vassal-shim/src/vassal_shim/ModuleManagerMenuManager.java new file mode 100644 index 0000000..9fb6f95 --- /dev/null +++ b/vassal-shim/src/vassal_shim/ModuleManagerMenuManager.java @@ -0,0 +1,17 @@ +package vassal_shim ; + +import javax.swing.JFrame ; +import javax.swing.JMenuBar ; + +import VASSAL.tools.menu.MenuManager ; +import VASSAL.tools.menu.MenuBarProxy ; + +// -------------------------------------------------------------------- + +public class ModuleManagerMenuManager extends MenuManager +{ + private final MenuBarProxy menuBar = new MenuBarProxy() ; + + public JMenuBar getMenuBarFor( JFrame fc ) { return null ; } + public MenuBarProxy getMenuBarProxyFor( JFrame fc ) { return menuBar ; } +} diff --git a/vassal-shim/src/vassal_shim/ReportNode.java b/vassal-shim/src/vassal_shim/ReportNode.java new file mode 100644 index 0000000..4d772d8 --- /dev/null +++ b/vassal-shim/src/vassal_shim/ReportNode.java @@ -0,0 +1,20 @@ +package vassal_shim ; + +import java.awt.Point ; + +// -------------------------------------------------------------------- + +public class ReportNode +{ + // POD container that holds information about what was done. + + String snippetId ; + Point labelPos ; + + public ReportNode( String snippetId, Point labelPos ) + { + // initialize the ReportNode + this.snippetId = snippetId ; + this.labelPos = labelPos ; + } +} diff --git a/vassal-shim/src/vassal_shim/Snippet.java b/vassal-shim/src/vassal_shim/Snippet.java new file mode 100644 index 0000000..bb3c18f --- /dev/null +++ b/vassal-shim/src/vassal_shim/Snippet.java @@ -0,0 +1,57 @@ +package vassal_shim ; + +import java.util.Properties ; +import java.util.ArrayList ; +import org.w3c.dom.NodeList ; +import org.w3c.dom.Node ; +import org.w3c.dom.Element ; +import javax.xml.xpath.XPathFactory ; +import javax.xml.xpath.XPath ; +import javax.xml.xpath.XPathExpression ; +import javax.xml.xpath.XPathExpressionException ; +import javax.xml.xpath.XPathConstants ; + +import vassal_shim.Utils ; + +// -------------------------------------------------------------------- + +public class Snippet +{ + // POD container that holds the snippet information sent to us from the web server. + + public String snippetId ; + public String content ; + public ArrayList rawContent ; + public int width, height ; + public boolean autoCreate ; + public String labelArea ; + + public Snippet( Element elem, Properties config ) throws XPathExpressionException + { + // initialize + XPathFactory xpathFactory = XPathFactory.newInstance() ; + + // initialize the Snippet + this.snippetId = elem.getAttribute( "id" ) ; + this.content = Utils.getNodeTextContent( elem ) ; + String snippetWidth = elem.getAttribute( "width" ) ; + this.width = Integer.parseInt( + snippetWidth != "" ? snippetWidth : config.getProperty("AUTOCREATE_LABEL_DEFAULT_WIDTH","300") + ) ; + String snippetHeight = elem.getAttribute( "height" ) ; + this.height = Integer.parseInt( + snippetHeight != "" ? snippetHeight : config.getProperty("AUTOCREATE_LABEL_DEFAULT_HEIGHT","300") + ) ; + this.autoCreate = elem.getAttribute( "autoCreate" ).equals( "true" ) ; + this.labelArea = elem.getAttribute( "labelArea" ) ; + + // initialize the Snippet + this.rawContent = new ArrayList() ; + XPathExpression expr = xpathFactory.newXPath().compile( "rawContent/phrase/text()" ) ; + NodeList nodes = (NodeList) expr.evaluate( elem, XPathConstants.NODESET ) ; + for ( int i=0 ; i < nodes.getLength() ; ++i ) { + Node node = nodes.item( i ) ; + this.rawContent.add( nodes.item(i).getTextContent() ) ; + } + } +} diff --git a/vassal-shim/src/vassal_shim/Utils.java b/vassal-shim/src/vassal_shim/Utils.java new file mode 100644 index 0000000..6c39b96 --- /dev/null +++ b/vassal-shim/src/vassal_shim/Utils.java @@ -0,0 +1,45 @@ +package vassal_shim ; + +import org.w3c.dom.NodeList ; +import org.w3c.dom.Node ; + +// -------------------------------------------------------------------- + +public class Utils +{ + public static String getNodeTextContent( Node node ) + { + // get the text content for an XML node (just itself, no descendants) + StringBuilder buf = new StringBuilder() ; + NodeList childNodes = node.getChildNodes() ; + for ( int i=0 ; i < childNodes.getLength() ; ++i ) { + Node childNode = childNodes.item( i ) ; + if ( childNode.getNodeName().equals( "#text" ) ) + buf.append( childNode.getTextContent() ) ; + } + return buf.toString() ; + } + + public static boolean startsWith( String val, String target ) + { + // check if a string starts with a target substring + if ( val.length() < target.length() ) + return false ; + return val.substring( 0, target.length() ).equals( target ) ; + } + + public static String printableString( String val ) + { + // encode non-ASCII characters + if ( val == null ) + return "" ; + StringBuilder buf = new StringBuilder() ; + for ( char ch: val.toCharArray() ) { + if ( (int)ch >= 32 && (int)ch <= 127 ) + buf.append( ch ) ; + else + buf.append( String.format( "<%02x>", (int)ch ) ) ; + } + return buf.toString() ; + } +} diff --git a/vassal-shim/src/vassal_shim/VassalShim.java b/vassal-shim/src/vassal_shim/VassalShim.java new file mode 100644 index 0000000..71eeb3b --- /dev/null +++ b/vassal-shim/src/vassal_shim/VassalShim.java @@ -0,0 +1,884 @@ +package vassal_shim ; + +import java.io.File ; +import java.io.FileInputStream ; +import java.io.InputStream ; +import java.io.InputStreamReader ; +import java.io.FileOutputStream ; +import java.io.OutputStream ; +import java.io.BufferedReader ; +import java.io.IOException ; +import java.io.FileNotFoundException ; +import java.net.URISyntaxException ; +import java.util.Collections ; +import java.util.Arrays ; +import java.util.List ; +import java.util.ArrayList ; +import java.util.Map ; +import java.util.HashMap ; +import java.util.Set ; +import java.util.HashSet ; +import java.util.Iterator ; +import java.util.Comparator ; +import java.util.Properties ; +import java.util.regex.Pattern ; +import java.util.regex.Matcher ; +import java.awt.Point ; +import java.awt.Dimension ; + +import javax.xml.parsers.DocumentBuilderFactory ; +import javax.xml.parsers.DocumentBuilder ; +import javax.xml.parsers.ParserConfigurationException ; +import javax.xml.transform.Transformer ; +import javax.xml.transform.TransformerException ; +import javax.xml.transform.TransformerConfigurationException ; +import javax.xml.transform.TransformerFactory ; +import javax.xml.transform.OutputKeys ; +import javax.xml.transform.dom.DOMSource ; +import javax.xml.transform.stream.StreamResult ; +import javax.xml.xpath.XPathExpressionException ; +import org.w3c.dom.Document ; +import org.w3c.dom.NodeList ; +import org.w3c.dom.Node ; +import org.w3c.dom.Element ; +import org.xml.sax.SAXException ; +import org.slf4j.Logger ; +import org.slf4j.LoggerFactory ; + +import VASSAL.build.GameModule ; +import VASSAL.build.GpIdChecker ; +import VASSAL.build.module.GameState ; +import VASSAL.build.module.GameComponent ; +import VASSAL.build.module.ModuleExtension ; +import VASSAL.build.module.ObscurableOptions ; +import VASSAL.build.module.metadata.SaveMetaData ; +import VASSAL.build.widget.PieceSlot ; +import VASSAL.launch.BasicModule ; +import VASSAL.command.Command ; +import VASSAL.command.AddPiece ; +import VASSAL.command.RemovePiece ; +import VASSAL.command.ConditionalCommand ; +import VASSAL.command.AlertCommand ; +import VASSAL.build.module.map.boardPicker.Board ; +import VASSAL.counters.GamePiece ; +import VASSAL.counters.DynamicProperty ; +import VASSAL.counters.PieceCloner ; +import VASSAL.preferences.Prefs ; +import VASSAL.tools.DataArchive ; +import VASSAL.tools.DialogUtils ; +import VASSAL.tools.io.FileArchive ; +import VASSAL.tools.io.IOUtils ; +import VASSAL.tools.io.FastByteArrayOutputStream ; +import VASSAL.tools.io.ObfuscatingOutputStream ; +import VASSAL.tools.io.ZipArchive ; +import VASSAL.i18n.Resources ; + +import vassal_shim.Snippet ; +import vassal_shim.GamePieceLabelFields ; +import vassal_shim.LabelArea ; +import vassal_shim.ReportNode ; +import vassal_shim.ModuleManagerMenuManager ; +import vassal_shim.Utils ; + +// -------------------------------------------------------------------- + +public class VassalShim +{ + private static final Logger logger = LoggerFactory.getLogger( VassalShim.class ) ; + + private String baseDir ; + private Properties config ; + private String vmodFilename ; + private String boardsDir ; + + public VassalShim( String vmodFilename, String boardsDir ) throws IOException + { + // initialize + this.vmodFilename = vmodFilename ; + this.boardsDir = boardsDir ; + + // figure out where we live + baseDir = null ; + try { + String jarFilename = this.getClass().getProtectionDomain().getCodeSource().getLocation().toURI().getPath() ; + logger.debug( "Loaded from JAR: {}", jarFilename ) ; + baseDir = new File( jarFilename ).getParent() ; + logger.debug( "Base directory: {}", baseDir ) ; + } catch( URISyntaxException ex ) { + logger.error( "Can't locate JAR file:", ex ) ; + } + + // load any config settings + config = new Properties() ; + if ( baseDir != null ) { + File configFile = new File( baseDir + File.separator + "vassal-shim.properties" ) ; + if ( configFile.isFile() ) { + logger.info( "Loading properties: {}", configFile.getAbsolutePath() ) ; + config.load( new FileInputStream( configFile ) ) ; + for ( String key: config.stringPropertyNames() ) + logger.debug( "- {} = {}", key, config.getProperty(key) ) ; + } + } + + // FUDGE! Need this to be able to load the VASL module :-/ + logger.debug( "Creating the menu manager." ) ; + new ModuleManagerMenuManager() ; + + // initialize VASL + logger.info( "Loading VASL module: {}", vmodFilename ) ; + if ( ! ((new File(vmodFilename)).isFile() ) ) + throw new IllegalArgumentException( "Can't find VASL module: " + vmodFilename ) ; + DataArchive dataArchive = new DataArchive( vmodFilename ) ; + logger.debug( "- Initializing module." ) ; + BasicModule basicModule = new BasicModule( dataArchive ) ; + logger.debug( "- Installing module." ) ; + GameModule.init( basicModule ) ; + logger.debug( "- Loaded OK." ) ; + } + + public void dumpScenario( String scenarioFilename ) throws IOException + { + // load the scenario and dump its commands + Command cmd = loadScenario( scenarioFilename ) ; + dumpCommand( cmd, "" ) ; + } + + public void updateScenario( String scenarioFilename, String snippetsFilename, String saveFilename, String reportFilename ) + throws IOException, ParserConfigurationException, SAXException, XPathExpressionException, TransformerException + { + // load the snippets supplied to us by the web server + Map snippets = parseSnippets( snippetsFilename ) ; + + // NOTE: While we can get away with just disabling warnings about missing boards when dumping scenarios, + // they need to be present when we update a scenario, otherwise they get removed from the scenario :-/ + logger.info( "Configuring boards directory: {}", boardsDir ) ; + Prefs prefs = GameModule.getGameModule().getPrefs() ; + String BOARD_DIR = "boardURL" ; + prefs.setValue( BOARD_DIR, new File(boardsDir) ) ; + + // load the scenario + Command cmd = loadScenario( scenarioFilename ) ; + // NOTE: The call to execute() is what's causing the VASSAL UI to appear on-screen. If we take it out, + // label creation still works, but any boards and existing labels are not detected, presumably because + // their Command's need to be executed to take effect. + cmd.execute() ; + + // extract the labels from the scenario + Map ourLabels = new HashMap() ; + ArrayList otherLabels = new ArrayList() ; + logger.info( "Searching the VASL scenario for labels..." ) ; + extractLabels( cmd, ourLabels, otherLabels ) ; + + // update the labels from the snippets + Map< String, ArrayList > labelReport = processSnippets( ourLabels, otherLabels, snippets ) ; + + // save the scenario + saveScenario( saveFilename ) ; + + // generate the report + generateLabelReport( labelReport, reportFilename ) ; + + // NOTE: The test suite always dumps the scenario after updating it, so we could save a lot of time + // by dumping it here, thus avoiding the need to run this shim again to do the dump (and spinning up + // a JVM, initializing VASSAL/VASL, etc.) but it's probably worth doing things the slow way, to avoid + // any possible problems caused by reusing the current session (e.g. there might be some saved state somewhere). + } + + private Map parseSnippets( String snippetsFilename ) throws IOException, ParserConfigurationException, SAXException, XPathExpressionException + { + logger.info( "Loading snippets: {}", snippetsFilename ) ; + Map snippets = new HashMap() ; + + // load the snippets + DocumentBuilderFactory docBuilderFactory = DocumentBuilderFactory.newInstance() ; + DocumentBuilder docBuilder = docBuilderFactory.newDocumentBuilder() ; + Document doc = docBuilder.parse( new File( snippetsFilename ) ) ; + doc.getDocumentElement().normalize() ; + NodeList nodes = doc.getElementsByTagName( "snippet" ) ; + for ( int i=0 ; i < nodes.getLength() ; ++i ) { + Node node = nodes.item( i ) ; + if ( node.getNodeType() != Node.ELEMENT_NODE ) + continue ; + Snippet snippet = new Snippet( (Element)node, config ) ; + logger.debug( "- Added snippet '{}' [{}x{}] (labelArea={}) (autoCreate={}):\n{}", + snippet.snippetId, + snippet.width, snippet.height, + snippet.labelArea, + snippet.autoCreate, snippet.content + ) ; + snippets.put( snippet.snippetId, snippet ) ; + } + + return snippets ; + } + + private void extractLabels( Command cmd, Map ourLabels, ArrayList otherLabels ) + { + // check if this command is a label we're interested in + if ( cmd instanceof AddPiece ) { + AddPiece addPieceCmd = (AddPiece) cmd ; + if ( addPieceCmd.getTarget() instanceof DynamicProperty ) { + GamePiece target = addPieceCmd.getTarget() ; + // NOTE: We can't check for target.getName() == "User-Labeled", it seems to get changed to the first label :shrug: + + // yup - parse the label content + ArrayList separators = new ArrayList() ; + ArrayList fields = new ArrayList() ; + parseGamePieceState( target.getState(), separators, fields ) ; + + // check if the label is one of ours + String snippetId = isVaslTemplatesLabel( fields, GamePieceLabelFields.FIELD_INDEX_LABEL1 ) ; + if ( snippetId != null ) { + logger.debug( "- Found label (1): {}", snippetId ) ; + ourLabels.put( snippetId, + new GamePieceLabelFields( target, separators, fields, GamePieceLabelFields.FIELD_INDEX_LABEL1 ) + ) ; + } + else { + snippetId = isVaslTemplatesLabel( fields, GamePieceLabelFields.FIELD_INDEX_LABEL2 ) ; + if ( snippetId != null ) { + logger.debug( "- Found label (2): {}", snippetId ) ; + ourLabels.put( snippetId, + new GamePieceLabelFields( target, separators, fields, GamePieceLabelFields.FIELD_INDEX_LABEL2 ) + ) ; + } else { + otherLabels.add( + new GamePieceLabelFields( target, separators, fields, -1 ) + ) ; + } + } + } + } + + // extract labels in sub-commands + for ( Command c: cmd.getSubCommands() ) + extractLabels( c, ourLabels, otherLabels ) ; + } + + private String isVaslTemplatesLabel( ArrayList fields, int fieldIndex ) + { + // check if a label is one of ours + if ( fieldIndex >= fields.size() ) + return null ; + Matcher matcher = Pattern.compile( "" comment. However, for labels created with older versions of vasl-templates, + // this comment won't be present, so we try to match labels based on the raw content the user entered + // in the UI of the main program. + + // NOTE: Since we are dealing with labels that don't have a snippet ID, the GamePieceLabelField's won't have + // their fieldIndex set. We set this if and when we match a legacy label, but we don't handle the case + // where some phrases are found in label1 and some in label2 :-/ It doesn't really matter which one we use, + // since one of the fields will be used to store the snippet, and the other one will be blanked out. + int fieldIndex = -1 ; + + // check each label and record which ones match the snippets's raw content + ArrayList matches = new ArrayList() ; + for ( GamePieceLabelFields labelFields: otherLabels ) { + + // check if all the snippet raw content phrases are present in the label + if ( snippet.rawContent.size() == 0 ) { + // nb: we can get here for snippets that are always passed through, even if they have no content + continue ; + } + boolean allFound = true ; + for ( String phrase: snippet.rawContent ) { + phrase = phrase.replace( "\n", " " ) ; + String labelContent = labelFields.getLabelContent( GamePieceLabelFields.FIELD_INDEX_LABEL1 ) ; + if ( labelContent != null && labelContent.indexOf( phrase ) >= 0 ) { + fieldIndex = GamePieceLabelFields.FIELD_INDEX_LABEL1 ; + continue ; + } + labelContent = labelFields.getLabelContent( GamePieceLabelFields.FIELD_INDEX_LABEL2 ) ; + if ( labelContent != null && labelContent.indexOf( phrase ) >= 0 ) { + fieldIndex = GamePieceLabelFields.FIELD_INDEX_LABEL2 ; + continue ; + } + allFound = false ; + break ; + } + + // yup - all phrases were found, record the label as a match + if ( allFound ) + matches.add( labelFields ) ; + } + + // NOTE: Exactly one label must match for us to consider it a match (i.e. if there are + // multiple matches, we do nothing and leave it to the user to sort it out). + if ( matches.size() == 1 ) { + GamePieceLabelFields labelFields = matches.get( 0 ) ; + labelFields.setFieldIndex( fieldIndex ) ; + return labelFields ; + } + + return null ; + } + + private void generateLabelReport( Map> labelReport, String reportFilename ) + throws TransformerException, TransformerConfigurationException, ParserConfigurationException, FileNotFoundException + { + // generate the report + Document doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument() ; + Element rootElem = doc.createElement( "report" ) ; + doc.appendChild( rootElem ) ; + boolean wasModified = false ; + for ( String key: labelReport.keySet() ) { + ArrayList reportNodes = labelReport.get( key ) ; + Element elem = doc.createElement( key ) ; + for ( ReportNode reportNode: reportNodes ) { + Element reportNodeElem = doc.createElement( "label" ) ; + reportNodeElem.setAttribute( "id", reportNode.snippetId ) ; + if ( reportNode.labelPos != null ) { + reportNodeElem.setAttribute( "x", Integer.toString( reportNode.labelPos.x ) ) ; + reportNodeElem.setAttribute( "y", Integer.toString( reportNode.labelPos.y ) ) ; + } + elem.appendChild( reportNodeElem ) ; + if ( ! key.equals( "unchanged" ) ) + wasModified = true ; + } + rootElem.appendChild( elem ) ; + } + rootElem.setAttribute( "wasModified", wasModified?"true":"false" ) ; + + // save the report + Transformer trans = TransformerFactory.newInstance().newTransformer() ; + trans.setOutputProperty( OutputKeys.INDENT, "yes" ) ; + trans.setOutputProperty( "{http://xml.apache.org/xslt}indent-amount", "4" ) ; + trans.setOutputProperty( OutputKeys.METHOD, "xml" ) ; + trans.setOutputProperty( OutputKeys.ENCODING, "UTF-8" ) ; + trans.transform( new DOMSource(doc), new StreamResult(new FileOutputStream(reportFilename)) ) ; + } + + private boolean isForceNewRow( Set forceNewRowFor, String snippetId ) + { + // check if we should start a new row when creating labels + if ( forceNewRowFor.contains( snippetId ) ) + return true ; + + // FUDGE! To handle the case where an OB has only vehicles or ordnance, we recognize + // this special pseudo-snippet ID. + if ( Utils.startsWith( snippetId, "ob_vehicles_" ) || Utils.startsWith( snippetId, "ob_ordnance_" ) ) { + String playerId = snippetId.substring( snippetId.length() - 1 ) ; + if ( forceNewRowFor.contains( "ob_vehicles|ordnance_" + playerId ) ) { + // remove the pseudo-snippet ID, so that it doesn't match the other V/O snippet + forceNewRowFor.remove( "ob_vehicles|ordnance_" + playerId ) ; + return true ; + } + } + + return false ; + } + + private String makeVassalCoordString( Point pos, Snippet snippet ) + { + // FUDGE! VASSAL positions labels by the X/Y co-ords of the label's centre (!) + return Integer.toString( pos.x + snippet.width/2 ) + ";" + Integer.toString( pos.y + snippet.height/2 ) ; + } + + private void saveScenario( String saveFilename ) throws IOException + { + // disable the dialog asking for log file comments + Prefs prefs = GameModule.getGameModule().getPrefs() ; + String PROMPT_LOG_COMMENT = "promptLogComment"; + prefs.setValue( PROMPT_LOG_COMMENT, false ) ; + + // FUDGE! We would like to just call GameState.saveGame(), but it calls getRestoreCommand(), + // which does nothing unless the "save game" menu action has been enabled!?! Due to Java protections, + // there doesn't seem to be any way to get at this object and enable it, so we have to re-implement + // the whole saveGame() code without this check :-/ + + // get the save string + Command cmd = getRestoreCommand() ; + String saveString = GameModule.getGameModule().encode( cmd ) ; + + // save the scenario + logger.info( "Saving scenario: {}", saveFilename ) ; + final FastByteArrayOutputStream ba = new FastByteArrayOutputStream() ; + OutputStream out = null ; + try { + out = new ObfuscatingOutputStream( ba ) ; + out.write( saveString.getBytes( "UTF-8" ) ) ; + out.close() ; + } + finally { + IOUtils.closeQuietly( out ) ; + } + FileArchive archive = null ; + try { + archive = new ZipArchive( new File( saveFilename ) ) ; + String SAVEFILE_ZIP_ENTRY = "savedGame" ; //$NON-NLS-1$ + archive.add( SAVEFILE_ZIP_ENTRY, ba.toInputStream() ) ; + (new SaveMetaData()).save( archive ) ; + archive.close() ; + } + finally { + IOUtils.closeQuietly( archive ) ; + } + } + + private static Command getRestoreCommand() // nb: taken from GameState.getRestoreCommand() + { + // NOTE: This is the check that's causing the problem :-/ + // if (!saveGame.isEnabled()) { + // return null; + // } + + GameState gameState = GameModule.getGameModule().getGameState() ; + Command c = new GameState.SetupCommand(false); + c.append(checkVersionCommand()); + c.append( gameState.getRestorePiecesCommand() ); + for (GameComponent gc : gameState.getGameComponents()) { + c.append(gc.getRestoreCommand()); + } + c.append(new GameState.SetupCommand(true)); + return c; + } + + private static Command checkVersionCommand() { + // NOTE: This is the same as GameState.checkVersionCommand(), but we can't call that since it's private :-/ + String runningVersion = GameModule.getGameModule().getAttributeValueString(GameModule.VASSAL_VERSION_RUNNING); + ConditionalCommand.Condition cond = new ConditionalCommand.Lt(GameModule.VASSAL_VERSION_RUNNING, runningVersion); + Command c = new ConditionalCommand(new ConditionalCommand.Condition[]{cond}, new AlertCommand(Resources.getString("GameState.version_mismatch", runningVersion))); //$NON-NLS-1$ + String moduleName = GameModule.getGameModule().getAttributeValueString(GameModule.MODULE_NAME); + String moduleVersion = GameModule.getGameModule().getAttributeValueString(GameModule.MODULE_VERSION); + cond = new ConditionalCommand.Lt(GameModule.MODULE_VERSION, moduleVersion); + c.append(new ConditionalCommand(new ConditionalCommand.Condition[]{cond}, new AlertCommand(Resources.getString("GameState.version_mismatch2", moduleName, moduleVersion )))); //$NON-NLS-1$ + return c; + } + + private Command loadScenario( String scenarioFilename ) throws IOException + { + // load the scenario + disableBoardWarnings() ; + logger.info( "Loading scenario: {}", scenarioFilename ) ; + return GameModule.getGameModule().getGameState().decodeSavedGame( + new File( scenarioFilename ) + ) ; + } + + private static void dumpCommand( Command cmd, String prefix ) + { + // dump the command + StringBuilder buf = new StringBuilder() ; + buf.append( prefix + cmd.getClass().getSimpleName() ) ; + String details = cmd.getDetails() ; + if ( details != null ) + buf.append( " [" + details + "]" ) ; + if ( cmd instanceof AddPiece ) + dumpCommandExtras( (AddPiece)cmd, buf, prefix ) ; + else if ( cmd instanceof GameState.SetupCommand ) + dumpCommandExtras( (GameState.SetupCommand)cmd, buf, prefix ) ; + else if ( cmd instanceof ModuleExtension.RegCmd ) + dumpCommandExtras( (ModuleExtension.RegCmd)cmd, buf, prefix ) ; + else if ( cmd instanceof ObscurableOptions.SetAllowed ) + dumpCommandExtras( (ObscurableOptions.SetAllowed)cmd, buf, prefix ) ; + System.out.println( buf.toString() ) ; + + // dump any sub-commands + prefix += " " ; + for ( Command c: cmd.getSubCommands() ) + dumpCommand( c, prefix ) ; + } + + private static void dumpCommandExtras( AddPiece cmd, StringBuilder buf, String prefix ) + { + // dump extra command info + GamePiece target = cmd.getTarget() ; + buf.append( ": " + target.getClass().getSimpleName() ) ; + if ( target.getName().length() > 0 ) + buf.append( "/" + target.getName() ) ; + + // check if this is a command we're interested in + // NOTE: We used to support VASL 6.3.3, but when we create labels, they're of type Hideable. It would be easy enough + // to add that here, but 6.3.3 is pretty old (2.5 years), so it's safer to just drop it from the list of supported versions. + if ( !( target instanceof DynamicProperty ) ) + return ; + if ( ! target.getName().equals( "User-Labeled" ) ) + return ; + + // dump extra command info + ArrayList separators = new ArrayList() ; + ArrayList fields = new ArrayList() ; + parseGamePieceState( cmd.getState(), separators, fields ) ; + for ( String field: fields ) { + buf.append( "\n" + prefix + "- " ) ; + if ( field.length() > 0 ) + buf.append( Utils.printableString( field ) ) ; + else + buf.append( "" ) ; + } + } + + private static void dumpCommandExtras( GameState.SetupCommand cmd, StringBuilder buf, String prefix ) + { + // dump extra command info + buf.append( ": starting=" + cmd.isGameStarting() ) ; + } + + private static void dumpCommandExtras( ModuleExtension.RegCmd cmd, StringBuilder buf, String prefix ) + { + // dump extra command info + buf.append( ": " + cmd.getName() + " (" + cmd.getVersion() + ")" ) ; + } + + private static void dumpCommandExtras( ObscurableOptions.SetAllowed cmd, StringBuilder buf, String prefix ) + { + // dump extra command info + buf.append( ": " + cmd.getAllowedIds() ) ; + } + + private static void parseGamePieceState( String state, ArrayList separators, ArrayList fields ) + { + // parse the GamePiece state + Matcher matcher = Pattern.compile( "\\\\+\t" ).matcher( state ) ; + int pos = 0 ; + while( matcher.find() ) { + separators.add( matcher.group() ) ; + fields.add( state.substring( pos, matcher.start() ) ) ; + pos = matcher.end() ; + } + fields.add( state.substring( pos ) ) ; + } + + private void disableBoardWarnings() + { + // FUDGE! VASSAL shows a GUI error dialog warning about boards not being found, and while these can be disabled, + // the key used to enable/disable them is derived from the board filename :-( ASLBoardPicker catches + // the FileNotFoundException thrown by ZipArchive when it can't find a file, and then calls ReadErrorDialog.error(), + // which calls WarningDialog.showDisableable(), using the following as the key: + // (Object) ( e.getClass().getName() + "@" + filename ) + // This means we have to set the "warning disabled" flag for every possible board :-/ + + // disable warnings for boards 00-99 + logger.info( "Disabling board warnings for bd00-99." ) ; + for ( int i=0 ; i < 100 ; ++i ) + disableBoardWarning( String.format( "bd%02d", i ) ) ; + + // disable warnings for additional standard boards + logger.info( "Disabling board warnings for other standard boards:" ) ; + InputStream inputStream = this.getClass().getResourceAsStream( "/data/boardNames.txt" ) ; + disableBoardWarnings( inputStream, "" ) ; + + // disable warnings for user-defined boards + if ( baseDir != null ) { + String fname = baseDir + File.separator + "boardNames.txt" ; + inputStream = null ; + try { + inputStream = new FileInputStream( fname ) ; + } catch( FileNotFoundException ex ) { } + if ( inputStream != null ) { + logger.info( "Disabling board warnings for user-defined boards: " + fname ) ; + disableBoardWarnings( inputStream, fname ) ; + } + } + } + + private void disableBoardWarnings( InputStream inputStream, String boardFilename ) + { + // disable warnings for boards listed in a file + BufferedReader reader = new BufferedReader( new InputStreamReader( inputStream ) ) ; + String lineBuf ; + try { + while ( (lineBuf = reader.readLine() ) != null ) { + lineBuf = lineBuf.trim() ; + if ( lineBuf.length() == 0 || lineBuf.charAt(0) == '#' || lineBuf.charAt(0) == ';' || lineBuf.substring(0,2).equals("//") ) + continue ; + logger.debug( "- {}", lineBuf ) ; + disableBoardWarning( lineBuf ) ; + } + } catch( IOException ex ) { + logger.error( "Error reading board file: {}", boardFilename, ex ) ; + } + } + + private void disableBoardWarning( String boardName ) + { + // disable warnings for the specified board + String boardsPath = (new File(vmodFilename)).getParent() + File.separator + "boards" ; + String key = "java.io.FileNotFoundException@" + boardsPath + File.separator + boardName ; + DialogUtils.setDisabled( key, true ) ; + } +}