diff --git a/vasl_templates/main_window.py b/vasl_templates/main_window.py index 6343e58..f1dfe01 100644 --- a/vasl_templates/main_window.py +++ b/vasl_templates/main_window.py @@ -2,6 +2,8 @@ import os import re +import json +import io import logging from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QMessageBox @@ -161,7 +163,35 @@ class MainWindow( QWidget ): @pyqtSlot() @log_exceptions( caption="SLOT EXCEPTION" ) - def on_new_scenario( self): + def on_app_loaded( self ): + """Called when the application has finished loading. + + NOTE: This handler might be called multiple times. + """ + # load and install the user settings + buf = io.StringIO() + buf.write( "{" ) + for key in self.settings.allKeys(): + if key.startswith( "UserSettings/" ): + buf.write( '"{}": {},'.format( key[13:], self.settings.value(key) ) ) + buf.write( '"_dummy_": null }' ) + buf = buf.getvalue() + user_settings = {} + try: + user_settings = json.loads( buf ) + except Exception as ex: #pylint: disable=broad-except + self.showErrorMsg( "Couldn't load the user settings:\n\n{}".format( ex ) ) + logging.error( "Couldn't load the user settings: %s", ex ) + logging.error( buf ) + return + del user_settings["_dummy_"] + self._view.page().runJavaScript( + "install_user_settings('{}')".format( json.dumps( user_settings ) ) + ) + + @pyqtSlot() + @log_exceptions( caption="SLOT EXCEPTION" ) + def on_new_scenario( self ): """Called when the user wants to load a scenario.""" self._web_channel_handler.on_new_scenario() @@ -177,6 +207,19 @@ class MainWindow( QWidget ): """Called when the user wants to save a scenario.""" return self._web_channel_handler.save_scenario( data ) + @pyqtSlot( str ) + @log_exceptions( caption="SLOT EXCEPTION" ) + def on_user_settings_change( self, user_settings ): + """Called when the user changes the user settings.""" + # delete all existing keys + for key in self.settings.allKeys(): + if key.startswith( "UserSettings/" ): + self.settings.remove( key ) + # save the new user settings + user_settings = json.loads( user_settings ) + for key,val in user_settings.items(): + self.settings.setValue( "UserSettings/{}".format(key) , val ) + @pyqtSlot( str ) @log_exceptions( caption="SLOT EXCEPTION" ) def on_scenario_name_change( self, val ): 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 2cd094d..6fd5fde 100644 --- a/vasl_templates/webapp/data/default-template-pack/ob_ordnance.j2 +++ b/vasl_templates/webapp/data/default-template-pack/ob_ordnance.j2 @@ -3,7 +3,7 @@ @@ -23,8 +23,9 @@ sup { font-size: 75% ; } {%for ord in OB_ORDNANCE%} - + {{ord.name}} + {%if ord.image%}
{%endif%}
{%if ord.notes%} {{ord.note_number}}, {{ord.notes | join(", ")}} 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 3252726..2f9922c 100644 --- a/vasl_templates/webapp/data/default-template-pack/ob_vehicles.j2 +++ b/vasl_templates/webapp/data/default-template-pack/ob_vehicles.j2 @@ -3,7 +3,7 @@ @@ -23,8 +23,9 @@ sup { font-size: 75% ; } {%for veh in OB_VEHICLES%} - + {{veh.name}} + {%if veh.image%}
{%endif%}
{%if veh.notes%} {{veh.note_number}}, {{veh.notes | join(", ")}} diff --git a/vasl_templates/webapp/static/css/main.css b/vasl_templates/webapp/static/css/main.css index f8c4342..8b87c19 100644 --- a/vasl_templates/webapp/static/css/main.css +++ b/vasl_templates/webapp/static/css/main.css @@ -137,6 +137,10 @@ button.edit-template img { height: 18px ; vertical-align: middle ; margin-right: .ui-dialog.select-vo-image .ui-dialog-content { padding: 0 ; height: 100% !important ; overflow: hidden ; } .ui-dialog.select-vo-image .vo-images img { margin: 5px ; padding: 10px ; border: 1px dotted #ddd ; } +.ui-dialog.user-settings .ui-dialog-titlebar { background: #80d0ff ; } +.ui-dialog.user-settings .ui-dialog-buttonpane { border: none ; padding: 0 ; font-size: 75% ; } +.ui-dialog.user-settings .note { font-size: 80% ; font-style: italic ; color: #666 ; } + .growl-title { display: none ; } .growl .pre { font-family: monospace ; } .growl div.pre { margin: 0 0 1em 1em ; font-size: 80% ; } diff --git a/vasl_templates/webapp/static/js-cookie/js.cookie.js b/vasl_templates/webapp/static/js-cookie/js.cookie.js new file mode 100644 index 0000000..6d0965a --- /dev/null +++ b/vasl_templates/webapp/static/js-cookie/js.cookie.js @@ -0,0 +1,163 @@ +/*! + * JavaScript Cookie v2.2.0 + * https://github.com/js-cookie/js-cookie + * + * Copyright 2006, 2015 Klaus Hartl & Fagner Brack + * Released under the MIT license + */ +;(function (factory) { + var registeredInModuleLoader; + if (typeof define === 'function' && define.amd) { + define(factory); + registeredInModuleLoader = true; + } + if (typeof exports === 'object') { + module.exports = factory(); + registeredInModuleLoader = true; + } + if (!registeredInModuleLoader) { + var OldCookies = window.Cookies; + var api = window.Cookies = factory(); + api.noConflict = function () { + window.Cookies = OldCookies; + return api; + }; + } +}(function () { + function extend () { + var i = 0; + var result = {}; + for (; i < arguments.length; i++) { + var attributes = arguments[ i ]; + for (var key in attributes) { + result[key] = attributes[key]; + } + } + return result; + } + + function decode (s) { + return s.replace(/(%[0-9A-Z]{2})+/g, decodeURIComponent); + } + + function init (converter) { + function api() {} + + function set (key, value, attributes) { + if (typeof document === 'undefined') { + return; + } + + attributes = extend({ + path: '/' + }, api.defaults, attributes); + + if (typeof attributes.expires === 'number') { + attributes.expires = new Date(new Date() * 1 + attributes.expires * 864e+5); + } + + // We're using "expires" because "max-age" is not supported by IE + attributes.expires = attributes.expires ? attributes.expires.toUTCString() : ''; + + try { + var result = JSON.stringify(value); + if (/^[\{\[]/.test(result)) { + value = result; + } + } catch (e) {} + + value = converter.write ? + converter.write(value, key) : + encodeURIComponent(String(value)) + .replace(/%(23|24|26|2B|3A|3C|3E|3D|2F|3F|40|5B|5D|5E|60|7B|7D|7C)/g, decodeURIComponent); + + key = encodeURIComponent(String(key)) + .replace(/%(23|24|26|2B|5E|60|7C)/g, decodeURIComponent) + .replace(/[\(\)]/g, escape); + + var stringifiedAttributes = ''; + for (var attributeName in attributes) { + if (!attributes[attributeName]) { + continue; + } + stringifiedAttributes += '; ' + attributeName; + if (attributes[attributeName] === true) { + continue; + } + + // Considers RFC 6265 section 5.2: + // ... + // 3. If the remaining unparsed-attributes contains a %x3B (";") + // character: + // Consume the characters of the unparsed-attributes up to, + // not including, the first %x3B (";") character. + // ... + stringifiedAttributes += '=' + attributes[attributeName].split(';')[0]; + } + + return (document.cookie = key + '=' + value + stringifiedAttributes); + } + + function get (key, json) { + if (typeof document === 'undefined') { + return; + } + + var jar = {}; + // To prevent the for loop in the first place assign an empty array + // in case there are no cookies at all. + var cookies = document.cookie ? document.cookie.split('; ') : []; + var i = 0; + + for (; i < cookies.length; i++) { + var parts = cookies[i].split('='); + var cookie = parts.slice(1).join('='); + + if (!json && cookie.charAt(0) === '"') { + cookie = cookie.slice(1, -1); + } + + try { + var name = decode(parts[0]); + cookie = (converter.read || converter)(cookie, name) || + decode(cookie); + + if (json) { + try { + cookie = JSON.parse(cookie); + } catch (e) {} + } + + jar[name] = cookie; + + if (key === name) { + break; + } + } catch (e) {} + } + + return key ? jar[key] : jar; + } + + api.set = set; + api.get = function (key) { + return get(key, false /* read as raw */); + }; + api.getJSON = function (key) { + return get(key, true /* read as json */); + }; + api.remove = function (key, attributes) { + set(key, '', extend(attributes, { + expires: -1 + })); + }; + + api.defaults = {}; + + api.withConverter = init; + + return api; + } + + return init(function () {}); +})); diff --git a/vasl_templates/webapp/static/main.js b/vasl_templates/webapp/static/main.js index ec7c5ed..698d945 100644 --- a/vasl_templates/webapp/static/main.js +++ b/vasl_templates/webapp/static/main.js @@ -1,3 +1,5 @@ +APP_URL_BASE = window.location.origin ; + gTemplatePack = {} ; gDefaultNationalities = {} ; gValidTemplateIds = [] ; @@ -24,6 +26,9 @@ $(document).ready( function () { // connect to the web channel new QWebChannel( qt.webChannelTransport, function(channel) { gWebChannelHandler = channel.objects.handler ; + // FUDGE! If the page finishes loading before the web channel is ready, + // the desktop won't get this notification. To be sure, we issue it again... + gWebChannelHandler.on_app_loaded() ; } ) ; } ) ; } @@ -37,6 +42,7 @@ $(document).ready( function () { separator: { type: "separator" }, template_pack: { label: "Load template pack", action: on_template_pack }, separator2: { type: "separator" }, + user_settings: { label: "Settings", action: user_settings }, show_help: { label: "Help", action: show_help }, } ) ; // nb: we only show the popmenu on left click (not the normal right-click) @@ -402,6 +408,9 @@ function update_page_load_status( id ) $("#watermark").fadeIn( 5*1000 ) ; // notify the test suite $("body").append( $("
") ) ; + // notify the PyQT desktop application + if ( gWebChannelHandler ) + gWebChannelHandler.on_app_loaded() ; } } diff --git a/vasl_templates/webapp/static/snippets.js b/vasl_templates/webapp/static/snippets.js index d0949af..432aed4 100644 --- a/vasl_templates/webapp/static/snippets.js +++ b/vasl_templates/webapp/static/snippets.js @@ -220,6 +220,11 @@ function unload_snippet_params( params, check_date_capabilities ) note_number: vo_entry.note_number, notes: vo_entry.notes } ; + if ( gUserSettings["include-vasl-images-in-snippets"] ) { + var url = get_vo_image_url( vo_entry, vo_image_id ) ; + if ( url ) + obj.image = APP_URL_BASE + url ; + } if ( vo_entry.no_radio ) obj.no_radio = vo_entry.no_radio ; if ( vo_entry.no_if ) { diff --git a/vasl_templates/webapp/static/user_settings.js b/vasl_templates/webapp/static/user_settings.js new file mode 100644 index 0000000..9e68763 --- /dev/null +++ b/vasl_templates/webapp/static/user_settings.js @@ -0,0 +1,82 @@ +gUserSettings = Cookies.getJSON( "user-settings" ) || {} ; + +USER_SETTINGS = { + "include-vasl-images-in-snippets": "checkbox", +} ; + +// -------------------------------------------------------------------- + +function user_settings() +{ + function load_settings() { + // load each user setting + for ( var name in USER_SETTINGS ) { + var $elem = $( ".ui-dialog.user-settings [name='" + name + "']" ) ; + var func = handlers[ "load_" + USER_SETTINGS[name] ] ; + func( $elem, gUserSettings[name] ) ; + } + } + + function unload_settings() { + // unload each user setting + var settings = {} ; + for ( var name in USER_SETTINGS ) { + var $elem = $( ".ui-dialog.user-settings [name='" + name + "']" ) ; + func = handlers[ "unload_" + USER_SETTINGS[name] ] ; + settings[name] = func( $elem ) ; + } + return settings ; + } + + var handlers = { + load_checkbox: function( $elem, val ) { $elem.prop( "checked", val?true:false ) ; }, + unload_checkbox: function( $elem ) { return $elem.prop( "checked" ) ; }, + } ; + + function update_ui() { + // update the UI + var $dlg = $( ".ui-dialog.user-settings" ) ; + var is_checked = $dlg.find( "input[name='include-vasl-images-in-snippets']" ).prop( "checked" ) ; + $dlg.find( ".include-vasl-images-in-snippets-hint" ).css( + "color", is_checked ? "#444" : "#aaa" + ) ; + } + + // show the "user settings" dialog + $( "#user-settings" ).dialog( { + title: "User settings", + dialogClass: "user-settings", + modal: true, + width: 450, + height: 150, + resizable: false, + create: function() { + init_dialog( $(this), "OK", false ) ; + $(this).find( "input[name='include-vasl-images-in-snippets']" ).change( update_ui ) ; + }, + open: function() { + // load the current user settings + load_settings( $(this) ) ; + update_ui() ; + }, + buttons: { + OK: function() { + // unload and install the new user settings + var settings = unload_settings() ; + gUserSettings = settings ; + Cookies.set( "user-settings", settings, { expires: 999 } ) ; + if ( gWebChannelHandler ) + gWebChannelHandler.on_user_settings_change( JSON.stringify( settings ) ) ; + $(this).dialog( "close" ) ; + }, + Cancel: function() { $(this).dialog( "close" ) ; }, + }, + } ) ; +} + +// -------------------------------------------------------------------- + +function install_user_settings( user_settings ) // nb: this is called by the PyQT desktop application +{ + gUserSettings = JSON.parse( user_settings ) ; +} diff --git a/vasl_templates/webapp/static/vo.js b/vasl_templates/webapp/static/vo.js index 845823b..e4d5866 100644 --- a/vasl_templates/webapp/static/vo.js +++ b/vasl_templates/webapp/static/vo.js @@ -32,7 +32,7 @@ function add_vo( vo_type, player_no ) if ( is_small_vasl_piece( vo_entry ) ) div_class += " small-piece" ; var buf2 = [ "
", - "", + "", "
", vo_entry.name, vo_entry.type ? " ("+vo_entry.type+")" : "", @@ -131,7 +131,7 @@ function do_add_vo( vo_type, player_no, vo_entry, vo_image_id ) fixed_height = "2.5em" ; } div_tag += ">" ; - var url = _get_vo_image_url( vo_entry, vo_image_id ) ; + var url = get_vo_image_url( vo_entry, vo_image_id, true ) ; $sortable2.sortable2( "add", { content: $( div_tag + "" + vo_entry.name + "
" ), data: { caption: vo_entry.name, vo_entry: vo_entry, vo_image_id: vo_image_id, fixed_height: fixed_height }, @@ -214,7 +214,7 @@ function on_select_vo_image( $btn ) { for ( var i=0 ; i < vo_images.length ; ++i ) { var $elem = $( "" ) .bind( "load", on_image_loaded ) - .attr( "src", _get_vo_image_url( null, vo_images[i] ) ) ; + .attr( "src", get_vo_image_url( null, vo_images[i], true ) ) ; $images.append( $elem ) ; } @@ -240,7 +240,7 @@ function on_select_vo_image( $btn ) { // handle image selection $images.children( "img" ).click( function() { vo_image_id = vo_images[ $(this).data("index") ] ; - $img.attr( "src", _get_vo_image_url(vo_image_id) ) ; + $img.attr( "src", get_vo_image_url(vo_image_id,true) ) ; $img.data( "vo-image-id", vo_image_id ) ; $dlg.dialog( "close" ) ; // nb: if the user selected an image, we take that to mean they also want to add that vehicle/ordnance @@ -262,7 +262,7 @@ function on_select_vo_image( $btn ) { } ) ; } -function _get_vo_image_url( vo_entry, vo_image_id ) +function get_vo_image_url( vo_entry, vo_image_id, allow_missing_image ) { if ( vo_image_id ) return "/counter/" + vo_image_id[0] + "/front/" + vo_image_id[1] ; @@ -273,7 +273,7 @@ function _get_vo_image_url( vo_entry, vo_image_id ) if ( vo_entry.gpid ) return "/counter/" + vo_entry.gpid + "/front" ; } - return gImagesBaseUrl + "/missing-image.png" ; + return allow_missing_image ? gImagesBaseUrl + "/missing-image.png" : null ; } function is_small_vasl_piece( vo_entry ) diff --git a/vasl_templates/webapp/templates/index.html b/vasl_templates/webapp/templates/index.html index 3b467d7..868bc57 100644 --- a/vasl_templates/webapp/templates/index.html +++ b/vasl_templates/webapp/templates/index.html @@ -52,6 +52,8 @@ {%include "select-vo-dialog.html"%} {%include "select-vo-image-dialog.html"%} +{%include "user-settings-dialog.html"%} + {%include "ask-dialog.html"%} @@ -67,6 +69,7 @@ + + {%include "testing.html"%} diff --git a/vasl_templates/webapp/templates/user-settings-dialog.html b/vasl_templates/webapp/templates/user-settings-dialog.html new file mode 100644 index 0000000..f3e382f --- /dev/null +++ b/vasl_templates/webapp/templates/user-settings-dialog.html @@ -0,0 +1,4 @@ + diff --git a/vasl_templates/webapp/tests/test_user_settings.py b/vasl_templates/webapp/tests/test_user_settings.py new file mode 100644 index 0000000..7b9352f --- /dev/null +++ b/vasl_templates/webapp/tests/test_user_settings.py @@ -0,0 +1,56 @@ +""" Test the user settings. """ + +import json + +from vasl_templates.webapp.tests.utils import \ + init_webapp, find_child, _get_clipboard, \ + wait_for, select_menu_option, click_dialog_button +from vasl_templates.webapp.tests.test_vehicles_ordnance import add_vo +from vasl_templates.webapp.config.constants import DATA_DIR as REAL_DATA_DIR + +# --------------------------------------------------------------------- + +def test_include_vasl_images_in_snippets( webapp, webdriver, monkeypatch ): + """Test the user settings.""" + + # initialize + monkeypatch.setitem( webapp.config, "DATA_DIR", REAL_DATA_DIR ) + init_webapp( webapp, webdriver ) + + # add a vehicle + add_vo( webdriver, "vehicles", 1, "PzKpfw IB (Tt)" ) + + # enable "show VASL images in snippets" + select_menu_option( "user_settings" ) + elem = find_child( ".ui-dialog.user-settings input[name='include-vasl-images-in-snippets']" ) + assert not elem.is_selected() + elem.click() + click_dialog_button( "OK" ) + _check_cookies( webdriver, "include-vasl-images-in-snippets", True ) + + # make sure that it took effect + snippet_btn = find_child( "button[data-id='ob_vehicles_1']" ) + snippet_btn.click() + wait_for( 2, lambda: "/counter/2524/front" in _get_clipboard() ) + + # disable "show VASL images in snippets" + select_menu_option( "user_settings" ) + elem = find_child( ".ui-dialog.user-settings input[name='include-vasl-images-in-snippets']" ) + assert elem.is_selected() + elem.click() + click_dialog_button( "OK" ) + _check_cookies( webdriver, "include-vasl-images-in-snippets", False ) + + # make sure that it took effect + snippet_btn.click() + wait_for( 2, lambda: "/counter/2524/front" not in _get_clipboard() ) + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +def _check_cookies( webdriver, name, expected ): + """Check that a user setting was stored in the cookies correctly.""" + cookies = [ c for c in webdriver.get_cookies() if c["name"] == "user-settings" ] + assert len(cookies) == 1 + val = cookies[0]["value"].replace( "%22", '"' ) + user_settings = json.loads( val ) + assert user_settings[name] == expected