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 0000000..dfcd39e Binary files /dev/null and b/vasl_templates/webapp/tests/fixtures/dump-vsav/labels.vsav differ 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 0000000..1db5a47 Binary files /dev/null and b/vasl_templates/webapp/tests/fixtures/update-vsav/empty.vsav differ 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 0000000..b3df730 Binary files /dev/null and b/vasl_templates/webapp/tests/fixtures/update-vsav/full.vsav differ 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 0000000..4da72de Binary files /dev/null and b/vasl_templates/webapp/tests/fixtures/update-vsav/hill621-legacy.vsav differ diff --git a/vasl_templates/webapp/tests/fixtures/update-vsav/latw-legacy.vsav b/vasl_templates/webapp/tests/fixtures/update-vsav/latw-legacy.vsav new file mode 100644 index 0000000..6bf14c8 Binary files /dev/null and b/vasl_templates/webapp/tests/fixtures/update-vsav/latw-legacy.vsav differ diff --git a/vasl_templates/webapp/tests/fixtures/update-vsav/latw.vsav b/vasl_templates/webapp/tests/fixtures/update-vsav/latw.vsav new file mode 100644 index 0000000..d41feb4 Binary files /dev/null and b/vasl_templates/webapp/tests/fixtures/update-vsav/latw.vsav differ 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 0000000..e1d4324 Binary files /dev/null and b/vassal-shim/release/vassal-shim.jar differ 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 ) ; + } +}