Added integration with the ASL Scenario Archive.

master
Pacman Ghost 4 years ago
parent 48c69927f6
commit 3805e131bb
  1. 3
      .pylintrc
  2. 12
      vasl_templates/main_window.py
  3. 62
      vasl_templates/tools/check_connect_roar.py
  4. 26
      vasl_templates/webapp/__init__.py
  5. 2
      vasl_templates/webapp/config/logging.yaml.example
  6. 46
      vasl_templates/webapp/data/asl-scenario-archive.json
  7. 55
      vasl_templates/webapp/data/default-template-pack/players.j2
  8. 155
      vasl_templates/webapp/downloads.py
  9. 18
      vasl_templates/webapp/main.py
  10. 134
      vasl_templates/webapp/roar.py
  11. 405
      vasl_templates/webapp/scenarios.py
  12. 87
      vasl_templates/webapp/static/css/scenario-card.css
  13. 71
      vasl_templates/webapp/static/css/scenario-search-dialog.css
  14. 5
      vasl_templates/webapp/static/css/select-roar-scenario-dialog.css
  15. 41
      vasl_templates/webapp/static/css/tabs-scenario.css
  16. 2
      vasl_templates/webapp/static/css/tabs.css
  17. 48
      vasl_templates/webapp/static/imageZoom/jquery.imageZoom.css
  18. 1
      vasl_templates/webapp/static/imageZoom/jquery.imageZoom.min.js
  19. BIN
      vasl_templates/webapp/static/imageZoom/jquery.imageZoom.png
  20. BIN
      vasl_templates/webapp/static/images/asl-scenario-archive.png
  21. BIN
      vasl_templates/webapp/static/images/connect-roar.png
  22. 0
      vasl_templates/webapp/static/images/gripper-horz.png
  23. BIN
      vasl_templates/webapp/static/images/gripper-vert.png
  24. BIN
      vasl_templates/webapp/static/images/icons/aslsk.png
  25. BIN
      vasl_templates/webapp/static/images/icons/deluxe.png
  26. BIN
      vasl_templates/webapp/static/images/icons/night.png
  27. BIN
      vasl_templates/webapp/static/images/icons/oba.png
  28. BIN
      vasl_templates/webapp/static/images/map-preview.png
  29. 4
      vasl_templates/webapp/static/lfa.js
  30. 92
      vasl_templates/webapp/static/main.js
  31. 390
      vasl_templates/webapp/static/roar.js
  32. 1201
      vasl_templates/webapp/static/scenarios.js
  33. 27
      vasl_templates/webapp/static/snippets.js
  34. 27
      vasl_templates/webapp/static/timer.js
  35. 56
      vasl_templates/webapp/static/utils.js
  36. 120
      vasl_templates/webapp/templates/check-roar.html
  37. 11
      vasl_templates/webapp/templates/index.html
  38. 153
      vasl_templates/webapp/templates/scenario-card.html
  39. 3
      vasl_templates/webapp/templates/scenario-info-dialog.html
  40. 132
      vasl_templates/webapp/templates/scenario-nat-report.html
  41. 34
      vasl_templates/webapp/templates/scenario-search-dialog.html
  42. 39
      vasl_templates/webapp/templates/tabs-scenario.html
  43. 137
      vasl_templates/webapp/tests/fixtures/asl-scenario-archive.json
  44. 4
      vasl_templates/webapp/tests/fixtures/data/default-template-pack/national-capabilities.json
  45. 9
      vasl_templates/webapp/tests/fixtures/data/default-template-pack/nationalities.json
  46. 4
      vasl_templates/webapp/tests/fixtures/data/default-template-pack/players.j2
  47. 51
      vasl_templates/webapp/tests/fixtures/roar-scenario-index.json
  48. 17
      vasl_templates/webapp/tests/remote.py
  49. 1
      vasl_templates/webapp/tests/test_default_scenario.py
  50. 7
      vasl_templates/webapp/tests/test_ob.py
  51. 11
      vasl_templates/webapp/tests/test_players.py
  52. 148
      vasl_templates/webapp/tests/test_roar.py
  53. 12
      vasl_templates/webapp/tests/test_scenario_persistence.py
  54. 756
      vasl_templates/webapp/tests/test_scenario_search.py
  55. 8
      vasl_templates/webapp/tests/test_snippets.py
  56. 6
      vasl_templates/webapp/tests/test_template_packs.py
  57. 37
      vasl_templates/webapp/tests/test_utils.py
  58. 5
      vasl_templates/webapp/tests/test_vassal.py
  59. 24
      vasl_templates/webapp/tests/utils.py
  60. 59
      vasl_templates/webapp/utils.py

@ -143,7 +143,8 @@ disable=print-statement,
too-many-lines,
duplicate-code, # can't get it to shut up about @pytest.mark.skipif's :-/
no-else-return,
len-as-condition
len-as-condition,
consider-using-enumerate
# 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

@ -13,7 +13,7 @@ from PyQt5.QtWebChannel import QWebChannel
from PyQt5.QtGui import QDesktopServices, QIcon
from PyQt5.QtCore import Qt, QUrl, QMargins, pyqtSlot, QVariant
from vasl_templates.webapp.config.constants import APP_NAME, IS_FROZEN
from vasl_templates.webapp.config.constants import APP_NAME, APP_VERSION, IS_FROZEN
from vasl_templates.main import app_settings
from vasl_templates.web_channel import WebChannelHandler
from vasl_templates.utils import catch_exceptions
@ -29,6 +29,8 @@ class AppWebPage( QWebEnginePage ):
"""Called when a link is clicked."""
if url.host() in ("localhost","127.0.0.1"):
return True
if not is_mainframe:
return True # nb: we get here if we're in a child frame (e.g. Google Maps)
QDesktopServices.openUrl( url )
return False
@ -85,8 +87,8 @@ class MainWindow( QWidget ):
if val :
self.restoreGeometry( val )
else :
self.resize( 1050, 650 )
self.setMinimumSize( 1050, 620 )
self.resize( 1000, 650 )
self.setMinimumSize( 1000, 620 )
# initialize the layout
layout = QVBoxLayout( self )
@ -105,6 +107,10 @@ class MainWindow( QWidget ):
# initialize the web page
# nb: we create an off-the-record profile to stop the view from using cached JS files :-/
profile = QWebEngineProfile( None, self._view )
version = APP_NAME.lower().replace( " ", "-" ) + "/" + APP_VERSION[1:]
profile.setHttpUserAgent(
re.sub( r"QtWebEngine/\S+", version, profile.httpUserAgent() )
)
page = AppWebPage( profile, self._view )
self._view.setPage( page )

@ -0,0 +1,62 @@
#!/usr/bin/env python3
""" Check how scenarios at the ASL Scenario Archive are connected to those at ROAR. """
import sys
import json
from vasl_templates.webapp.scenarios import _match_roar_scenario, \
_asa_scenarios, _build_asa_scenario_index, _roar_scenarios, _build_roar_scenario_index
# ---------------------------------------------------------------------
def asa_string( s ):
"""Return an ASL Scenario Archive scenario as a string."""
return "[{}] {} ({})".format(
s["scenario_id"], s.get("title"), s.get("sc_id")
)
def roar_string( s ):
"""Return ROAR scenario as a string."""
return "[{}] {} ({})".format(
s["roar_id"], s.get("name"), s.get("scenario_id")
)
# ---------------------------------------------------------------------
# load the ASL Scenario Archive scenarios
fname = sys.argv[1]
asa_data = json.load( open( fname, "r" ) )
_build_asa_scenario_index( _asa_scenarios, asa_data, None )
# load the ROAR scenarios
fname = sys.argv[2]
roar_data = json.load( open( fname, "r" ) )
_build_roar_scenario_index( _roar_scenarios, roar_data, None )
# try to connect each ASA scenario to ROAR
exact_matches, multiple_matches, unmatched = [], [], []
for scenario in asa_data["scenarios"]:
matches = _match_roar_scenario( scenario )
if not matches:
unmatched.append( scenario )
elif len(matches) == 1:
exact_matches.append( scenario )
else:
multiple_matches.append( [ scenario, matches ] )
# output the results
print( "ASL Scenario Archive scenarios: {}".format( len(asa_data["scenarios"]) ) )
print()
print( "Exact matches: {}".format( len(exact_matches) ) )
print()
print( "Multiple matches: {}".format( len(multiple_matches) ) )
if multiple_matches:
for scenario,matches in multiple_matches:
print( " {}:".format( asa_string(scenario) ) )
for match in matches:
print( " - {}".format( roar_string( match ) ) )
print()
print( "Unmatched: {}".format( len(unmatched) ) )
if unmatched:
for scenario in unmatched:
print( " {}".format( asa_string(scenario) ) )

@ -3,6 +3,7 @@
import sys
import os
import signal
import threading
import configparser
import logging
import logging.config
@ -17,6 +18,16 @@ from vasl_templates.webapp.config.constants import BASE_DIR
def _on_startup():
"""Do startup initialization."""
# start downloading files
# NOTE: We used to do this in the mainline code of __init__, so that we didn't have to wait
# for the first request before starting the download (useful if we are running as a standalone server).
# However, this means that the downloads start whenever we import this module e.g. for a stand-alone
# command-line tool :-/
from vasl_templates.webapp.downloads import DownloadedFile
threading.Thread( daemon=True,
target = DownloadedFile.download_files
).start()
# load the default template_pack
from vasl_templates.webapp.snippets import load_default_template_pack
load_default_template_pack()
@ -67,6 +78,12 @@ def _on_sigint( signum, stack ): #pylint: disable=unused-argument
# initialize Flask
app = Flask( __name__ )
# set config defaults
# NOTE: These are defined here since they are used by both the back- and front-ends.
app.config[ "ASA_SCENARIO_URL" ] = "https://aslscenarioarchive.com/scenario.php?id={ID}"
app.config[ "ASA_PUBLICATION_URL" ] = "https://aslscenarioarchive.com/viewPub.php?id={ID}"
app.config[ "ASA_PUBLISHER_URL" ] = "https://aslscenarioarchive.com/viewPublisher.php?id={ID}"
# load the application configuration
config_dir = os.path.join( BASE_DIR, "config" )
_fname = os.path.join( config_dir, "app.cfg" )
@ -98,7 +115,8 @@ import vasl_templates.webapp.files #pylint: disable=cyclic-import
import vasl_templates.webapp.vassal #pylint: disable=cyclic-import
import vasl_templates.webapp.vo_notes #pylint: disable=cyclic-import
import vasl_templates.webapp.nat_caps #pylint: disable=cyclic-import
import vasl_templates.webapp.roar #pylint: disable=cyclic-import
import vasl_templates.webapp.scenarios #pylint: disable=cyclic-import
import vasl_templates.webapp.downloads #pylint: disable=cyclic-import
import vasl_templates.webapp.lfa #pylint: disable=cyclic-import
if app.config.get( "ENABLE_REMOTE_TEST_CONTROL" ):
print( "*** WARNING: Remote test control enabled! ***" )
@ -107,11 +125,5 @@ if app.config.get( "ENABLE_REMOTE_TEST_CONTROL" ):
# install our signal handler (must be done in the main thread)
signal.signal( signal.SIGINT, _on_sigint )
# initialize ROAR integration
# NOTE: We do this here, rather in _on_startup(), so that we can start downloading the latest scenario index file
# without having to wait for the first request (e.g. if we are running as a standalone server, not the desktop app).
from vasl_templates.webapp.roar import init_roar
init_roar()
# register startup initialization
app.before_first_request( _on_startup )

@ -42,6 +42,6 @@ loggers:
webdriver:
level: "WARNING"
handlers: [ "file" ]
roar:
downloads:
level: "WARNING"
handlers: [ "file" ]

@ -0,0 +1,46 @@
{
"_comment_": "This section maps theaters from those at the ASL Scenario Archive to ours.",
"theater-mappings": {
"MTO": "ETO",
"Normandy": "ETO",
"KW": "Korea"
},
"_comment_": "This section maps player nationalities from those at the ASL Scenario Archive (must be lower-case) to our nationality ID's.",
"nat-mappings": {
"australian": "anzac",
"belgians": "belgian",
"canada": "british~canadian",
"canadian": "british~canadian",
"china cmd": "chinese~gmd",
"commonwealth": "anzac",
"filipinos": "filipino",
"finland": "finnish",
"free french": "free-french",
"germany": "german",
"gurkha": "british",
"gurkhas": "british",
"ina": "indonesian",
"japan": "japanese",
"kpa": "kfw-kpa",
"nkpa": "kfw-kpa",
"north korea": "kfw-kpa",
"philippine": "filipino",
"poland": "polish",
"republic of korea": "kfw-rok",
"rok": "kfw-rok",
"rumanian": "romanian",
"russia": "russian",
"russians": "russian",
"slovak": "slovakian",
"siamese": "thai",
"soviet": "russian",
"ss": "german",
"u.s.": "american",
"usmc": "american",
"yugoslav": "yugoslavian",
"vichy": "french"
}
}

@ -2,26 +2,51 @@
<head>
<meta charset="utf-8">
<style> {{CSS:common}} </style>
<style>
{{CSS:common}}
table {
border: 1px solid #c0c0c0 ; background: #f0f0f0 ;
{%if PLAYER_1_DESCRIPTION or PLAYER_2_DESCRIPTION%}
{# NOTE: We had to do some heavy-duty hacking to get the layout to work in VASSAL :-/ The idea is that there is a padding column on the far right of the table that expands to fill up the available width, thus pushing the data columns (flags, player, ELR/SAN) to the left. We can then have a cell that spans all these columns for the player descriptions. All this is only necessary when there are player descriptions, which is why the PLAYER_WIDTH variable is only used if there are player descriptions. #}
width: {{PLAYERS_WIDTH or "200px"}} ;
{%endif%}
}
td.player { width: 1px ; white-space: nowrap ; font-weight: bold ; }
td.val { width: 1px ; white-space: nowrap ; }
td.description { font-size: 90% ; font-style: italic ; color: #808080 ; }
{%if PLAYER_1_DESCRIPTION or PLAYER_2_DESCRIPTION%}
td.padding { width: {{PLAYERS_WIDTH or "200px"}} ; }
{%endif%}
</style>
</head>
<table style="
border: 1px solid #c0c0c0 ;
background: #f0f0f0 ;
">
<table>
<td style="font-weight:bold;">
<tr>
{# Some versions of Java require <img> tags to have the width and height specified!?! #}
{%if PLAYER_FLAG_1%}<img src="{{PLAYER_FLAG_1}}" {{PLAYER_FLAG_SIZE}}>&nbsp;{%endif%}{{PLAYER_1_NAME}}: <br>
{%if PLAYER_FLAG_2%}<img src="{{PLAYER_FLAG_2}}" {{PLAYER_FLAG_SIZE}}>&nbsp;{%endif%}{{PLAYER_2_NAME}}:
<td class="flag"> {%if PLAYER_FLAG_1%} <img src="{{PLAYER_FLAG_1}}" {{PLAYER_FLAG_SIZE}}> {%endif%}
<td class="player"> {{PLAYER_1_NAME}}:
<td class="val"> &nbsp; ELR: {{PLAYER_1_ELR}}
<td class="val"> &nbsp; SAN: {{PLAYER_1_SAN}}
{%if PLAYER_1_DESCRIPTION%} <td class="padding"> {%endif%}
{%if PLAYER_1_DESCRIPTION%}
<tr>
<td></td>
<td class="description" colspan=4 valign=top> {{PLAYER_1_DESCRIPTION}}
{%endif%}
<td style="padding-left:2px;">
ELR: {{PLAYER_1_ELR}} <br>
ELR: {{PLAYER_2_ELR}}
<td style="padding-left:5px;">
SAN: {{PLAYER_1_SAN}} <br>
SAN: {{PLAYER_2_SAN}}
<tr>
{# Some versions of Java require <img> tags to have the width and height specified!?! #}
<td class="flag"> {%if PLAYER_FLAG_2%} <img src="{{PLAYER_FLAG_2}}" {{PLAYER_FLAG_SIZE}}> {%endif%}
<td class="player"> {{PLAYER_2_NAME}}:
<td class="val"> &nbsp; ELR: {{PLAYER_2_ELR}}
<td class="val"> &nbsp; SAN: {{PLAYER_2_SAN}}
{%if PLAYER_2_DESCRIPTION%} <td class="padding"> {%endif%}
{%if PLAYER_2_DESCRIPTION%}
<tr>
<td></td>
<td class="description" colspan=4 valign=top> {{PLAYER_2_DESCRIPTION}}
{%endif%}
</table>

@ -0,0 +1,155 @@
""" Manage downloading files.
This module manages downloading files on a schedule e.g. the ASL Scenario Archive and ROAR scenario indexes.
"""
import os
import threading
import json
import urllib.request
import time
import datetime
import tempfile
import logging
from vasl_templates.webapp import app
from vasl_templates.webapp.utils import parse_int
_registry = set()
_logger = logging.getLogger( "downloads" )
# ---------------------------------------------------------------------
class DownloadedFile:
"""Manage a downloaded file."""
def __init__( self, key, ttl, fname, url, on_data, extra_args=None ):
# initialize
self.key = key
self.ttl = ttl
self.fname = fname
self.url = url
self.on_data = on_data
self.error_msg = None
# initialize
self._lock = threading.Lock()
self._data = None
# install any extra member variables
if extra_args:
for k,v in extra_args.items():
setattr( self, k, v )
# register this instance
_registry.add( self )
# check if we have a cached copy of the file
self._cache_fname = os.path.join( tempfile.gettempdir(), "vasl-templates."+fname )
if os.path.isfile( self._cache_fname ):
# yup - load it
_logger.info( "Using cached %s file: %s", key, self._cache_fname )
self._set_data( self._cache_fname )
else:
# nope - start with an empty data set
_logger.debug( "No cached %s file: %s", key, self._cache_fname )
def _set_data( self, data ):
"""Install a new data set."""
with self:
try:
# install the new data
if len(data) < 1024 and os.path.isfile( data ):
with open( data, "r", encoding="utf-8" ) as fp:
data = fp.read()
self._data = json.loads( data )
# notify the owner
if self.on_data:
self.on_data( self, self._data, _logger )
except Exception as ex: #pylint: disable=broad-except
# NOTE: It would be nice to report this to the user in the UI, but because downloading
# happens in a background thread, the web page will probably have already finished rendering,
# and without the ability to push notifications, it's too late to tell the user.
_logger.error( "Can't install %s data: %s", self.key, ex )
def __enter__( self ):
"""Gain access to the underlying data.
Since the file is downloaded in a background thread, access to the underlying data
must be protected by a lock.
"""
self._lock.acquire()
return self._data
def __exit__( self, exc_type, exc_val, exc_tb ):
"""Relinquish access to the underlying data."""
self._lock.release()
@staticmethod
def download_files():
"""Download fresh copies of each file."""
#pylint: disable=protected-access
# process each DownloadedFile
for df in _registry:
# check if we should simulate slow downloads
delay = parse_int( app.config.get( "DOWNLOADED_FILES_DELAY" ) )
if delay:
_logger.debug( "Simulating a slow download for the %s file: delay=%s", df.key, delay )
time.sleep( delay )
# get the download URL
url = app.config.get( "{}_DOWNLOAD_URL".format( df.key.upper() ), df.url )
if os.path.isfile( url ):
# read the data directly from a file (for debugging porpoises)
_logger.info( "Loading the %s data directly from a file: %s", df.key, url )
df._set_data( url )
continue
# check if we have a cached copy of the file
ttl = parse_int( app.config.get( "{}_DOWNLOAD_CACHE_TTL".format( df.key ), df.ttl ), 24 )
if ttl <= 0:
_logger.info( "Download of the %s file has been disabled.", df.key )
continue
ttl *= 60*60
if os.path.isfile( df._cache_fname ):
# yup - check how long ago it was downloaded
mtime = os.path.getmtime( df._cache_fname )
age = int( time.time() - mtime )
_logger.debug( "Checking the cached %s file: age=%s, ttl=%s (mtime=%s)",
df.key,
datetime.timedelta( seconds=age ),
datetime.timedelta( seconds=ttl ),
time.strftime( "%Y-%m-%d %H:%M:%S", time.localtime(mtime) )
)
if age < ttl:
continue
# download the file
_logger.info( "Downloading the %s file: %s", df.key, url )
try:
fp = urllib.request.urlopen( url )
data = fp.read().decode( "utf-8" )
except Exception as ex: #pylint: disable=broad-except
msg = str( getattr(ex,"reason",None) or ex )
_logger.error( "Can't download the %s file: %s", df.key, msg )
df.error_msg = msg
continue
_logger.info( "Downloaded the %s file OK: %d bytes", df.key, len(data) )
# install the new data
df._set_data( data )
# NOTE: We only need to worry about thread-safety because a fresh copy of the file is downloaded
# while the old one is in use, but because downloads are only done once at startup, once we get here,
# we could delete the lock and allow unfettered access to the underlying data (since it's all
# going to be read-only).
# For simplicty, we leave the lock in place. It will slow things down a bit, since we will be
# serializing access to the data (unnecessarily, because it's all read-only) but none of the code
# is performance-critical and we can probably live it.
# save a cached copy of the data
_logger.debug( "Saving a cached copy of the %s file: %s", df.key, df._cache_fname )
with open( df._cache_fname, "w", encoding="utf-8" ) as fp:
fp.write( data )

@ -60,6 +60,7 @@ def get_startup_msgs():
# ---------------------------------------------------------------------
_APP_CONFIG_DEFAULTS = { # Bodhgaya, India (APR/19)
"THEATERS": [ "ETO", "DTO", "PTO", "Korea", "Burma", "other" ],
# NOTE: We use HTTP for static images, since VASSAL is already insanely slow loading images (done in serial?),
# so I don't even want to think about what it might be doing during a TLS handshake... :-/
"ONLINE_IMAGES_URL_BASE": "http://vasl-templates.org/services/static-images",
@ -89,6 +90,8 @@ def get_app_config():
key: app.config.get( key, default )
for key,default in _APP_CONFIG_DEFAULTS.items()
}
if isinstance( vals["THEATERS"], str ):
vals["THEATERS"] = vals["THEATERS"].split()
for key in ["APP_NAME","APP_VERSION","APP_DESCRIPTION","APP_HOME_URL"]:
vals[ key ] = getattr( vasl_templates.webapp.config.constants, key )
@ -128,6 +131,21 @@ def get_app_config():
extn_info[ key ] = extn[1][ key ]
vals["VASL_EXTENSIONS"][ extn[1]["extensionId"] ] = extn_info
# include the ASL Scenario Archive config data
for key in ["ASA_SCENARIO_URL","ASA_PUBLICATION_URL","ASA_PUBLISHER_URL"]:
vals[ key ] = app.config[ key ]
for key in ["BALANCE_GRAPH_THRESHOLD"]:
vals[ key ] = app.config.get( key )
fname = os.path.join( DATA_DIR, "asl-scenario-archive.json" )
if os.path.isfile( fname ):
with open( fname, "r" ) as fp:
try:
vals[ "SCENARIOS_CONFIG" ] = json.load( fp )
except json.decoder.JSONDecodeError as ex:
msg = "Couldn't load the ASL Scenario Archive config."
logging.error( "%s", msg )
startup_msg_store.error( msg )
return jsonify( vals )
# ---------------------------------------------------------------------

@ -1,134 +0,0 @@
"""Provide integration with ROAR."""
# Bodhgaya, India (APR/19)
import os.path
import threading
import json
import time
import datetime
import tempfile
import logging
import urllib.request
from flask import render_template, jsonify
from vasl_templates.webapp import app
_roar_scenario_index = {}
_roar_scenario_index_lock = threading.Lock()
_logger = logging.getLogger( "roar" )
ROAR_SCENARIO_INDEX_URL = "https://vasl-templates.org/services/roar/scenario-index.json"
CACHE_TTL = 6 * 60*60
# ---------------------------------------------------------------------
def init_roar():
"""Initialize ROAR integration."""
# initialize
from vasl_templates.webapp.main import startup_msg_store
msg_store = startup_msg_store
# initialize
download = True
cache_fname = os.path.join( tempfile.gettempdir(), "vasl-templates.roar-scenario-index.json" )
enable_cache = not app.config.get( "DISABLE_ROAR_SCENARIO_INDEX_CACHE" )
if not enable_cache:
cache_fname = None
# check if we have a cached copy of the scenario index
if enable_cache and os.path.isfile( cache_fname ):
# yup - load it, so that we have something until we finish downloading a fresh copy
_logger.info( "Loading cached ROAR scenario index: %s", cache_fname )
with open( cache_fname, "r" ) as fp:
_load_roar_scenario_index( fp.read(), "cached", msg_store )
# check if we should download a fresh copy
mtime = os.path.getmtime( cache_fname )
age = int( time.time() - mtime )
_logger.debug( "Cached scenario index age: %s (ttl=%s) (mtime=%s)",
datetime.timedelta(seconds=age), datetime.timedelta(seconds=CACHE_TTL),
time.strftime( "%Y-%m-%d %H:%M:%S", time.localtime(mtime) )
)
if age < CACHE_TTL:
download = False
# check if we should download the ROAR scenario index
if download:
if app.config.get("DISABLE_ROAR_SCENARIO_INDEX_DOWNLOAD"):
_logger.critical( "Downloading the ROAR scenario index has been disabled." )
else:
# yup - make it so (nb: we do it in a background thread to avoid blocking the startup process)
# NOTE: This is the only place we do this, so if it fails, the program needs to be restarted to try again.
# This is not great, but we can live it (e.g. we will generally be using the cached copy).
threading.Thread( target = _download_roar_scenario_index,
args = ( cache_fname, msg_store )
).start()
def _download_roar_scenario_index( save_fname, msg_store ):
"""Download the ROAR scenario index."""
# download the ROAR scenario index
url = app.config.get( "ROAR_SCENARIO_INDEX_URL", ROAR_SCENARIO_INDEX_URL )
_logger.info( "Downloading ROAR scenario index: %s", url )
try:
fp = urllib.request.urlopen( url )
data = fp.read().decode( "utf-8" )
except Exception as ex: #pylint: disable=broad-except
# NOTE: We catch all exceptions, since we don't want an error here to stop us from running :-/
msg = "Can't download the ROAR scenario index: {}".format( getattr(ex,"reason",str(ex)) )
_logger.warning( "%s", msg )
if msg_store:
msg_store.warning( msg )
return
if not _load_roar_scenario_index( data, "downloaded", msg_store ):
# NOTE: If we fail to load the scenario index (e.g. because of invalid JSON), we exit here
# and won't overwrite the cached copy of the file with the bad data.
return
# save a copy of the data
if save_fname:
_logger.debug( "Saving a copy of the ROAR scenario index: %s", save_fname )
with open( save_fname, "w" ) as fp:
fp.write( data )
def _load_roar_scenario_index( data, data_type, msg_store ):
"""Load the ROAR scenario index."""
# load the ROAR scenario index
try:
scenario_index = json.loads( data )
except Exception as ex: #pylint: disable=broad-except
# NOTE: We catch all exceptions, since we don't want an error here to stop us from running :-/
msg = "Can't load the {} ROAR scenario index: {}".format( data_type, ex )
_logger.error( "%s", msg )
if msg_store:
msg_store.error( msg )
return False
_logger.debug( "Loaded %s ROAR scenario index OK: #scenarios=%d", data_type, len(scenario_index) )
_logger.debug( "- Last updated: %s", scenario_index.get( "_lastUpdated_", "n/a" ) )
_logger.debug( "- # playings: %s", str( scenario_index.get( "_nPlayings_", "n/a" ) ) )
_logger.debug( "- Generated at: %s", scenario_index.get( "_generatedAt_", "n/a" ) )
# install the new ROAR scenario index
with _roar_scenario_index_lock:
global _roar_scenario_index
_roar_scenario_index = scenario_index
return True
# ---------------------------------------------------------------------
@app.route( "/roar/scenario-index" )
def get_roar_scenario_index():
"""Return the ROAR scenario index."""
with _roar_scenario_index_lock:
return jsonify( _roar_scenario_index )
# ---------------------------------------------------------------------
@app.route( "/roar/check" )
def check_roar():
"""Check the ROAR data (for testing porpoises only)."""
return render_template( "check-roar.html" )

@ -0,0 +1,405 @@
"""Provide access to the scenarios."""
# NOTE: Disable "DownloadedFile has no 'index' member" warnings.
#pylint: disable=no-member
import re
from flask import request, render_template, jsonify, abort
from vasl_templates.webapp import app
from vasl_templates.webapp.downloads import DownloadedFile
from vasl_templates.webapp.utils import get_month_name, make_formatted_day_of_month, friendly_fractions, parse_int
# ---------------------------------------------------------------------
def _build_asa_scenario_index( df, new_data, logger ):
"""Build the ASL Scenario Archive index."""
df.index = {
scenario["scenario_id"]: scenario
for scenario in new_data["scenarios"]
}
if logger:
logger.debug( "Loaded the ASL Secenario Archive index: #scenarios=%d", len(df.index) )
logger.debug( "- Generated at: %s", new_data.get( "_generatedAt_", "n/a" ) )
_asa_scenarios = DownloadedFile( "ASA", 1*24,
"asl-scenario-archive.json",
"https://vasl-templates.org/services/asl-scenario-archive/scenario-index.json",
_build_asa_scenario_index,
extra_args = { "index": None }
)
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def _build_roar_scenario_index( df, new_data, logger ):
"""Build the ROAR scenario index."""
df.index, df.title_matching, df.id_matching = {}, {}, {}
for roar_id,scenario in new_data.items():
if roar_id.startswith( "_" ):
continue
scenario[ "roar_id" ] = roar_id
df.index[ roar_id ] = scenario
_update_roar_matching_index( df.title_matching, scenario.get("name"), roar_id )
_update_roar_matching_index( df.id_matching, scenario.get("scenario_id"), roar_id )
if logger:
logger.debug( "Loaded the ROAR scenario index: #scenarios=%d", len(df.index) )
logger.debug( "- Generated at: %s", new_data.get( "_generatedAt_", "n/a" ) )
logger.debug( "- Last updated: %s", new_data.get( "_lastUpdated_", "n/a" ) )
logger.debug( "- # playings: %s", str( new_data.get( "_nPlayings_", "n/a" ) ) )
def _update_roar_matching_index( index, val, roar_id ):
"""Update the index that will be used for matching ROAR scenarios."""
if not val:
return
key = _make_roar_matching_key( val )
if key not in index:
index[ key ] = set()
index[ key ].add( roar_id )
def _make_roar_matching_key( val ):
"""Generate a key value that will be used to match ROAR scenarios."""
if not val:
return val
return re.sub( "[^a-z0-9]", "", val.lower() )
_roar_scenarios = DownloadedFile( "ROAR", 1*24,
"roar-scenario-index.json",
"https://vasl-templates.org/services/roar/scenario-index.json",
_build_roar_scenario_index,
extra_args = { "index": None }
)
# ---------------------------------------------------------------------
@app.route( "/scenario-index" )
def get_scenario_index():
"""Return the scenario index."""
def add_field( entry, key, val ): #pylint: disable=missing-docstring
if val:
entry[ key ] = val
def make_entry( scenario ):
"""Make an entry for the scenario index."""
entry = { "scenario_id": scenario["scenario_id"] }
add_field( entry, "scenario_name", _make_scenario_name( scenario ) )
add_field( entry, "scenario_display_id", scenario.get( "sc_id" ) )
add_field( entry, "scenario_location", scenario.get( "scen_location" ) )
add_field( entry, "scenario_date", _parse_date( scenario.get( "scen_date" ) ) )
add_field( entry, "publication_name", scenario.get( "pub_name" ) )
add_field( entry, "publication_id", scenario.get( "pub_id" ) )
add_field( entry, "publication_date", _parse_date( scenario.get( "published_date" ) ) )
add_field( entry, "publisher_name", scenario.get( "publisher_name" ) )
add_field( entry, "publisher_id", scenario.get( "publisher_id" ) )
return entry
# generate the scenario index
with _asa_scenarios:
if _asa_scenarios.index is None:
return _make_not_available_response(
"The scenario index is not available.", _asa_scenarios.error_msg
)
return jsonify( [
make_entry( scenario )
for scenario in _asa_scenarios.index.values()
] )
@app.route( "/roar/scenario-index" )
def get_roar_scenario_index():
"""Return the ROAR scenario index."""
with _roar_scenarios:
if _roar_scenarios.index is None:
return _make_not_available_response(
"The ROAR scenarios are not available.", _roar_scenarios.error_msg
)
return jsonify( _roar_scenarios.index )
def _make_not_available_response( msg, msg2 ):
"""Generate a "not available" response."""
resp = { "error": msg }
if msg2:
resp[ "message" ] = msg2
return jsonify( resp )
# ---------------------------------------------------------------------
@app.route( "/scenario/<scenario_id>" )
def get_scenario( scenario_id ):
"""Return a scenario."""
# get the parameters
roar_override = request.args.get( "roar" )
# get the basic scenario information
scenario, args = _do_get_scenario( scenario_id )
args[ "scenario_date_iso" ] = _parse_date_iso( scenario.get( "scen_date" ) )
args[ "defender_name" ] = scenario.get( "defender" )
args[ "attacker_name" ] = scenario.get( "attacker" )
args = { k.lower(): v for k,v in args.items() }
def get_win_score( key ):
"""Get a player's win percentage."""
nWins = parse_int( playings.get( key+"_wins" ), -1 )
if nWins < 0:
return None
score = 100 * nWins / nGames
return int( score + 0.5 )
# get the ASL Scenario Archive playings
playings = scenario.get( "playings", [ {} ] )[ 0 ]
nGames = parse_int( playings.get( "totalGames" ), 0 )
if playings and nGames > 0:
# NOTE: The player names are display names, only shown in the balance graphs,
# so it doesn't matter if we know about them or not.
args[ "balance" ] = [ {
"name": scenario.get( "defender" ),
"wins": playings.get( "defender_wins" ),
"percentage": get_win_score( "defender" )
}, {
"name": scenario.get( "attacker" ),
"wins": playings.get( "attacker_wins" ),
"percentage": get_win_score( "attacker" )
} ]
# try to match the scenario with one in ROAR
roar_id = None
if roar_override == "auto-match":
matches = _match_roar_scenario( scenario )
if matches:
roar_id = matches[0][ "roar_id" ]
else:
roar_id = roar_override
if roar_id:
args[ "roar" ] = _get_roar_info( roar_id )
return jsonify( args )
def _do_get_scenario( scenario_id ):
"""Return the basic details for the specified scenario."""
scenario = _get_scenario( scenario_id )
url_template = app.config[ "ASA_SCENARIO_URL" ]
scenario_url = url_template.replace( "{ID}", scenario_id )
return scenario, {
"SCENARIO_ID": scenario_id,
"SCENARIO_URL": scenario_url,
"SCENARIO_NAME": _make_scenario_name( scenario ),
"SCENARIO_DISPLAY_ID": scenario.get( "sc_id" ),
"SCENARIO_LOCATION": scenario.get( "scen_location" ),
"SCENARIO_DATE": _parse_date( scenario.get( "scen_date" ) ),
"THEATER": scenario.get( "theatre" ),
"DEFENDER_DESC": scenario.get( "def_desc" ),
"ATTACKER_DESC": scenario.get( "att_desc" ),
}
def _match_roar_scenario( scenario ):
"""Try to match the scenario with a ROAR scenario."""
def get_result_count( scenario ):
"""Get the number of playings for a ROAR scenario."""
results = scenario.get( "results", [] )
return sum( r[1] for r in results )
with _roar_scenarios:
# try to match by scenario title
title = scenario.get( "title" )
if not title:
return None
matches = _roar_scenarios.title_matching.get( _make_roar_matching_key( title ) )
if not matches:
return []
elif len( matches ) == 1:
# there was exactly one match - return it
roar_id = next( iter( matches ) )
return [ _roar_scenarios.index[ roar_id ] ]
else:
# we found multiple scenarios with the same title, filter by ID
matches2 = _roar_scenarios.id_matching.get( _make_roar_matching_key( scenario.get("sc_id") ), set() )
if matches2:
matches = matches.intersection( matches2 )
matches = [ _roar_scenarios.index[m] for m in matches ]
matches.sort( key=get_result_count, reverse=True )
return matches
def _get_roar_info( roar_id ):
"""Get the information for the specified ROAR scenario."""
def get_balance( player_no ):
"""Get a player's balance stats."""
# NOTE: The player names are display names, only shown in the balance graphs,
# so it doesn't matter if we know about them or not.
balance = {
"name": playings[ player_no ][0],
"wins": playings[ player_no ][1]
}
if nGames > 0:
balance[ "percentage" ] = int( 100 * playings[player_no][1] / nGames + 0.5 )
return balance
with _roar_scenarios:
# find the ROAR scenario
index = _roar_scenarios.index or {}
scenario = index.get( roar_id )
if not scenario:
abort( 404 )
# return the scenario details
results = {
"scenario_id": roar_id,
"scenario_display_id": scenario.get( "scenario_id" ),
"name": scenario.get( "name" ),
"url": scenario.get( "url" )
}
playings = scenario.get( "results" )
if playings:
nGames = playings[0][1] + playings[1][1]
results[ "balance" ] = [ get_balance(0), get_balance(1) ]
return results
# ---------------------------------------------------------------------
@app.route( "/scenario-card/<scenario_id>" )
def get_scenario_card( scenario_id ): #pylint: disable=too-many-branches
"""Return a scenario card (HTML)."""
# get the arguments
brief_mode = request.args.get( "brief" )
# find the specified scenario
scenario, args = _do_get_scenario( scenario_id )
# prepare the template parameters
args[ "DESIGNER" ] = scenario.get( "author" )
args[ "PUBLICATION" ] = scenario.get( "pub_name" )
args[ "PUBLISHER" ] = scenario.get( "publisher_name" )
args[ "PUBLICATION_DATE" ] = _parse_date( scenario.get( "published_date" ) )
args[ "PREV_PUBLICATION" ] = scenario.get( "prior_publication" )
args[ "REVISED_PUBLICATION" ] = scenario.get( "revision" )
args[ "OVERVIEW" ] = scenario.get( "overview" )
if brief_mode:
args[ "OVERVIEW_BRIEF" ] = _make_brief_overview( scenario.get( "overview" ) )
args[ "DEFENDER_NAME" ] = scenario.get( "defender" )
args[ "ATTACKER_NAME" ] = scenario.get( "attacker" )
args[ "BOARDS" ] = ", ".join( str(m) for m in scenario.get("maps",[]) )
args[ "MAP_IMAGES" ] = scenario.get( "mapImages" )
overlays = ", ".join( str(o) for o in scenario.get("overlays",[]) )
if overlays.upper() == "NONE":
overlays = None
if overlays:
args[ "OVERLAYS" ] = overlays
args[ "EXTRA_RULES" ] = scenario.get( "misc" )
args[ "ERRATA" ] = scenario.get( "errata" )
# prepare the template parameters
if scenario.get( "pub_id" ):
url_template = app.config[ "ASA_PUBLICATION_URL" ]
args[ "PUBLICATION_URL" ] = url_template.replace( "{ID}", scenario["pub_id"] )
if scenario.get( "publisher_id" ):
url_template = app.config[ "ASA_PUBLISHER_URL" ]
args[ "PUBLISHER_URL" ] = url_template.replace( "{ID}", scenario["publisher_id"] )
playing_time = scenario.get( "time_to_play", "0" )
if not str( playing_time ).startswith( "0" ):
args[ "PLAYING_TIME" ] = friendly_fractions( playing_time, "hour", "hours" )
# prepare the turn count
min_turns = scenario.get( "min_turns", "0" )
max_turns = scenario.get( "max_turns", "0" )
if min_turns != "0":
if min_turns == max_turns or max_turns == "0":
args[ "TURN_COUNT" ] = friendly_fractions( min_turns, "turn", "turns" )
elif max_turns != "0":
args[ "TURN_COUNT" ] = "{}-{} turns".format( friendly_fractions(min_turns), friendly_fractions(max_turns) )
# prepare any info icons
icons = {}
if scenario.get( "oba" ) in ("D","B"):
icons[ "DEFENDER_OBA" ] = True
if scenario.get( "oba" ) in ("A","B"):
icons[ "ATTACKER_OBA" ] = True
if scenario.get( "night" ) == "1":
icons[ "IS_NIGHT" ] = True
if scenario.get( "aslsk" ) == "1":
icons[ "IS_ASLSK" ] = True
if scenario.get( "deluxe" ) == "1":
icons[ "IS_DELUXE" ] = True
if icons:
args[ "ICONS" ] = icons
# prepare the lat/long co-ordinates
is_valid_coord = lambda val: val and val != "-99.99"
if is_valid_coord( scenario.get("gps_lat") ) and is_valid_coord( scenario.get("gps_long") ):
url_template = app.config.get( "MAP_URL", "https://maps.google.com/maps?q={LAT},{LONG}&z=4&output=embed" )
args["MAP_URL"] = url_template.replace( "{LAT}", scenario["gps_lat"] ).replace( "{LONG}", scenario["gps_long"] )
# process the template and return the generated HTML
return render_template( "scenario-card.html", **args )
def _make_brief_overview( content ):
"""Truncate the scenario overview."""
if not content:
return None
threshold = parse_int( app.config.get( "BRIEF_CONTENT_THRESHOLD" ), 200 )
if threshold <= 0 or len(content) < threshold:
return None
regex = re.compile( "[.?!]+" )
mo = regex.search( content, threshold )
if not mo:
return content[:threshold] + "..."
val = content[ : mo.start() + len(mo.group()) ]
if val == content:
return None
return val
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def _get_scenario( scenario_id ):
"""Get the specified scenario."""
with _asa_scenarios:
index = _asa_scenarios.index or {}
scenario = index.get( scenario_id )
if not scenario:
abort( 404 )
return scenario
def _make_scenario_name( scenario ):
"""Get the scenario's name."""
return scenario.get( "title" ) or "Untitled scenario (#{})".format( scenario["scenario_id"] )
# ---------------------------------------------------------------------
@app.route( "/scenario/nat-report" )
def scenario_nat_report():
"""Generate the scenario nationalities report (for testing porpoises)."""
return render_template( "scenario-nat-report.html" )
# ---------------------------------------------------------------------
def _parse_date( val ):
"""Parse a date string."""
parts = _split_date_parts( val )
if not parts:
return None
return "{} {}, {}".format(
make_formatted_day_of_month( parts[0] ),
get_month_name( parts[1] ),
parts[2]
)
def _parse_date_iso( val ):
"""Parse a date string."""
parts = _split_date_parts( val )
if not parts:
return None
return "{:04}-{:02}-{:02}".format( parts[2], parts[1], parts[0] )
def _split_date_parts( val ):
"""Split a date into its component parts."""
if val is None:
return None
mo = re.search( r"^(\d{4})-(\d{2})-(\d{2})", val )
if not mo:
return None
if mo.group(1) == "1901":
return None # nb: 1901-01-01 seems to be used as a "invalid date" marker
return [ int(mo.group(3)), int(mo.group(2)), int(mo.group(1)) ]

@ -0,0 +1,87 @@
/* scenario card */
.scenario-card { display: flex ; flex-direction: column ; position: relative ; }
.scenario-card a { text-decoration: none ; }
/* scenario card - header */
.scenario-card .header {
position: relative ;
border: 1px solid #b0b080 ; border-radius: 5px ;
background: #f0f0c0 ; padding: 0.25em 0.5em ;
margin-bottom: 0.35em ;
}
.scenario-card .scenario-name { font-size: 150% ; font-weight: bold ; }
.scenario-card .scenario-name a:focus { outline: 0 ; }
.scenario-card .scenario-id { font-style: italic ; }
.scenario-card .scenario-date { font-size: 80% ; font-style: italic ; }
.scenario-card .info {
position: absolute ;
border: 1px solid #c0c0a0 ; border-radius: 4px ;
padding: 0.25em 0.5em ;
background: #fff ;
font-size: 80% ;
}
.scenario-card .icons { margin-top: 0.25em ; opacity: 0.75 ; }
.scenario-card .icons img { height: 0.8em ; }
.scenario-card .icons img.oba { height: 0.7em ; margin-left: -3px ; }
/* scenario card - sub-header */
.scenario-card .header2 { margin: 0 0.5em ; font-size: 90% ; color: #666 ; }
.scenario-card .publisher { font-size: 90% ; font-style: italic ; }
.scenario-card .publication-date { font-size: 90% ; font-style: italic ; }
/* scenario card - content */
.scenario-card .content { margin: 0.5em 0.5em 0 0.5em ; padding-right: 0.5em ; overflow-y: auto ; }
.scenario-card .overview { margin-bottom: 1em ; text-align: justify ; }
.scenario-card .overview .more { display: inline-block ; font-size: 80% ; font-style: italic ; color: #666 ; cursor: pointer ; }
.scenario-card .map { float: right ; width: 30% ; min-width: 200px ; margin: 0 0 0.25em 1.5em ; }
.scenario-card .map iframe { width: 100% ; border: 0 ; }
/* scenario card - player info */
.scenario-card .player-info { margin-bottom: 1em ; }
.scenario-card .player-info .players td { vertical-align: top ; }
.scenario-card .player-info .players .label { font-weight: bold ; padding-right: 0.25em ; }
.scenario-card .player-info .players .flag { width: 1px ; text-align: center ; }
.scenario-card .player-info .players .name { padding-left: 0.1em ; }
.scenario-card .player-info .players .desc { font-size: 90% ; font-style: italic ; }
.scenario-card .player-info .oba { clear: both ; float: right ; font-size: 90% ; border: 1px solid #c0c0a0 ; border-radius: 5px ; }
.scenario-card .player-info .oba .oba-header { border-bottom: 1px dotted #c0c0a0 ; padding: 2px 5px 5px 5px ; font-weight: bold ; text-align: center ; white-space: nowrap ; }
.scenario-card .player-info .oba td { line-height: 1em ; }
.scenario-card .player-info .oba .name { width: 1px ; padding-left: 5px ; font-size: 90% ; font-weight: bold ; white-space: nowrap ; }
.scenario-card .player-info .oba .count { width: 1px ; padding-left: 0.5em ; text-align: right ; }
.scenario-card .player-info .oba .comments { padding-left: 10px ; font-size: 80% ; font-style: italic ; color: #444 ; }
.scenario-card .player-info .oba .date-warning { padding-top: 0.5em ; font-size: 80% ; font-style: italic ; }
.scenario-card .player-info .oba .date-warning img { float: left ; height: 1.25em ; margin: -2px 0.5em 0 0 ; }
.scenario-card .balance-graphs { display: inline-block ; border: 1px solid #c0c0a0 ; border-radius: 5px ; padding: 4px 8px ; }
.scenario-card .balance-graph { display: table-row ; font-size: 90% ; }
.scenario-card .balance-graph .player { display: table-cell ; }
.scenario-card .balance-graph .player.player1 { text-align: right ; }
.scenario-card .balance-graph .wins { display: table-cell ; text-align: right ; font-size: 70% ; font-style: italic ; color: #666 ; }
.scenario-card .balance-graph .wrapper { display: table-cell ; }
.scenario-card .balance-graph .progressbar { display: inline-block ; position: relative ; width: 4em ; height: 0.8em ; margin-bottom: -2px ; border: 1px solid #c0c0a0 ; background: #f0f0c0 ; }
.scenario-card .balance-graph a:focus { outline: 0 ; }
.scenario-card .balance-graph .progressbar.player1 { background: #f0f0c0 ; border-top-right-radius: 0 ; border-bottom-right-radius: 0 ; border-right: 0 ; }
.scenario-card .balance-graph .progressbar.player1 .ui-progressbar-value { background: #fff ; border-right: 1px solid #eee ; }
.scenario-card .balance-graph .progressbar.player2 { background: #fff ; border-top-left-radius: 0 ; border-bottom-left-radius: 0 ; }
.scenario-card .balance-graph .progressbar.player2 .ui-progressbar-value { background: #f0f0c0 ; border-right: 1px solid #eee ; }
.scenario-card .balance-graph .progressbar .score { position: absolute ; font-size: 70% ; font-style: italic ; color: #666 ; left: 35% ; }
/* scenario card - misc */
.scenario-card a.map-preview img { height: 0.75em ; margin-left: 0.25em ; }
.scenario-card a.map-preview:focus { outline: 0 ; }
.scenario-card .errata ul { margin-top: 0 ; }
.scenario-card .errata .source { font-size: 90% ; font-style: italic ; color: #666 ; }
/* scenario info dialog */
.ui-dialog.scenario-info { border-radius: 10px ; }
.ui-dialog.scenario-info .ui-dialog-titlebar { display: none ; }
.ui-dialog.scenario-info .credit { float: left ; margin-right: 0.5em ; display: flex ; align-items: center ; }
.ui-dialog.scenario-info .credit img { height: 1.4em ; margin-right: 0.5em ; opacity: 0.7 ; }
.ui-dialog.scenario-info .credit .caption { font-size: 70% ; line-height: 1em ; margin-top: -4px ; }
.ui-dialog.scenario-info .credit a { text-decoration: none ; font-style: italic ; color: #666 ; }
.ui-dialog.scenario-info .credit a:focus { outline: 0 ; }
#scenario-info-dialog .scenario-card { height: 100% ; overflow-y: hidden ; }
#scenario-info-dialog .connect-roar { display: inline-block ; margin-top: 0.25em ; font-size: 80% ; color: #444 ; cursor: pointer ; }
#scenario-info-dialog .connect-roar img { height: 0.75em ; Xmargin-right: 0.25em ; opacity: 0.7 ; }
#scenario-info-dialog .disconnect-roar img { height: 0.5em ; margin-left: 0.5em ; cursor: pointer ; }

@ -0,0 +1,71 @@
/* jQuery dialog */
.ui-dialog.scenario-search .ui-dialog-titlebar { background: #e0e0a0 ; }
#scenario-search { display: flex ; }
#scenario-search .left { min-width: 300px ; display: flex ; flex-direction: column ; }
#scenario-search .left .scenarios { flex: 1 ; }
#scenario-search .right { min-width: 400px ; flex: 1 ; display: flex ; flex-direction: column ; }
#scenario-search .scenario-card { flex: 1 ; overflow-y: hidden ; }
/* splitter */
#scenario-search .gutter { position: relative ; margin: 0 0.5em ; background-color: #ccc ; cursor: col-resize ; }
#scenario-search .gutter img { position: absolute ; top: 45% ; left: -2px ; }
/* search control */
#scenario-search .select2-container { top: 10px !important ; }
#scenario-search .select2-selection { display: none ; }
#scenario-search .select2-dropdown { border: none ; }
#scenario-search .select2-search--dropdown { padding: 0 0 0.5em 0 ; }
#scenario-search .select2-results__option { padding-left: 4px ; }
#scenario-search .select2-results__option--highlighted[aria-selected] { margin: 2px 2px -4px 0 ; padding-top:4px ; color: #f0f0f0 ; }
#scenario-search .select2-results__option--highlighted[aria-selected] .scenario-date { color: #f0f0f0 ; }
#scenario-search .select2-results__option--highlighted[aria-selected] .publication-name { color: #f0f0f0 ; }
#scenario-search .select2-results__option--highlighted[aria-selected] .publisher-name { color: #f0f0f0 ; }
#scenario-search .select2-results__option--highlighted[aria-selected] .publication-date { color: #f0f0f0 ; }
#scenario-search .select2-results__option[aria-selected] { background: #fff ; }
#scenario-search .select2-results__option--highlighted[aria-selected] { background: #5897fb ; }
#scenario-search .select2-results__option { padding-bottom: 0 ; }
#scenario-search .select2-results__option--highlighted { background: #5897fb ; margin-bottom: -4px ; }
#scenario-search .select2-results__option--highlighted .search-result { border: none ; background: transparent ; }
#scenario-search .search-result { border: 1px dotted #888 ; padding: 0 0.5em 0.25em 0.5em ; background: #f0f0f0 ; font-size: 95% ; }
#scenario-search .search-result .scenario-name { font-weight: bold ; }
#scenario-search .search-result .scenario-id { font-size: 80% ; font-style: italic ; }
#scenario-search .search-result .scenario-location { font-size: 90% ; }
#scenario-search .search-result .scenario-date { font-size: 80% ; font-style: italic ; color: #444 ; }
#scenario-search .search-result .publication-name { font-size: 80% ; font-style: italic ; color: #666 ; }
#scenario-search .search-result .publisher-name { font-size: 80% ; font-style: italic ; color: #666 ; }
#scenario-search .search-result .publication-date { font-size: 80% ; font-style: italic ; color: #666 ; }
/* footer */
#scenario-search .footer {
margin-bottom: -0.5em ;
padding-top: 0.5em ;
font-size: 70% ; font-style: italic ;
opacity: 0.8 ;
}
#scenario-search .footer img.logo { height: 1.5em ; }
#scenario-search .footer .caption { display: inline-block ; line-height: 1em ; }
#scenario-search .footer a { color: #666 ; }
#scenario-search .footer a:link { text-decoration: none ; }
#scenario-search .footer a:focus { outline: 0 ; }
/* import control */
#scenario-search .import-control { margin-top: 0.5em ; padding-top: 0.25em ; border-top: 1px dotted #666 ; }
#scenario-search .import-control .buttons button { float: right ; margin-left: 0.5em ; padding: 0.1em 0.5em ; }
#scenario-search .import-control .buttons button.import { height: 2em ; display: flex ; align-items: center ; }
#scenario-search .import-control .buttons button.import img { height: 1em ; margin-right: 0.5em ; }
#scenario-search .import-control .buttons button.ok { background: #ddd ; }
#scenario-search .import-control .buttons button.ok:hover { background: #ccc ; }
#scenario-search .import-control .warnings { margin-bottom: 0.5em ; padding: 0.25em 0 0 10px ; font-size: 90% ; }
#scenario-search .import-control .warnings .header { display: flex ; align-items: center ; height: 1.5em ; margin-left: -5px ; font-size: 120% ; }
#scenario-search .import-control .warnings .header img { height: 1.5em ; margin: 0 0.5em 0.5em 0 ; }
#scenario-search .import-control .warnings .header .caption { margin-top: -5px ; }
#scenario-search .import-control .warnings input[type="checkbox"] { margin-right: 0.25em ; }
#scenario-search .import-control .warnings .hint {
font-size: 90% ; font-style: italic ; color: #444 ;
white-space: nowrap ; overflow: hidden ; text-overflow: ellipsis ;
}
#scenario-search .import-control .warnings div.hint { margin-left: 20px ; }
#scenario-search .import-control .warnings div.hint img { height: 1em ; margin: 0 0.25em -2px 0 ; }
#scenario-search .import-control .warnings .warning2 img { height: 1.25em ; margin: 0 0.25em -2px -2px ; }

@ -1,9 +1,8 @@
#select-roar-scenario { overflow: hidden ; }
.ui-dialog.select-roar-scenario .ui-dialog-titlebar { background: #ffffcc ; border: 1px solid #e0e0cc ; }
.ui-dialog.select-roar-scenario .ui-dialog-titlebar { background: #f0f0c0 ; border: 1px solid #c0c0a0 ; }
.ui-dialog.select-roar-scenario .ui-dialog-content { padding-top: 0 !important ; }
.ui-dialog.select-roar-scenario .ui-dialog-buttonpane { border: none ; margin-top: 0 !important ; padding-top: 0 !important ; }
#select-roar-scenario { overflow: hidden ; }
#select-roar-scenario .header { height: 1.75em ; margin-top: 0.25em ; font-size: 80% ; }
#select-roar-scenario .select2-selection { display: none ; }

@ -4,52 +4,35 @@
#panel-scenario .row { display: flex ; align-items: center ; }
#panel-scenario input { flex-grow: 1 ; }
#panel-scenario input[name='SCENARIO_ID'] { margin-left: 0.25em ;