diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..331edc0 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,22 @@ +{ + "env": { + "browser": true, + "jquery": true + }, + "extends": [ + "eslint:recommended", + "plugin:vue/essential" + ], + "parserOptions": { + "ecmaVersion": 6, + "sourceType": "module" + }, + "plugins": [ + "vue" + ], + "globals": { + "Vue": "readable" + }, + "rules": { + } +} diff --git a/.gitignore b/.gitignore index bfbefd0..82b05e6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +package.json +package-lock.json +node_modules/ + _work_/ .vscode diff --git a/asl_rulebook2/webapp/__init__.py b/asl_rulebook2/webapp/__init__.py index e843c4e..1d3c473 100644 --- a/asl_rulebook2/webapp/__init__.py +++ b/asl_rulebook2/webapp/__init__.py @@ -35,7 +35,6 @@ def _on_sigint( signum, stack ): #pylint: disable=unused-argument shutdown_event.set() # call any registered cleanup handlers - from asl_rulebook2.webapp import globvars #pylint: disable=cyclic-import for handler in globvars.cleanup_handlers: handler() @@ -78,6 +77,9 @@ else: # load the application import asl_rulebook2.webapp.main #pylint: disable=wrong-import-position,cyclic-import +import asl_rulebook2.webapp.content #pylint: disable=wrong-import-position,cyclic-import +from asl_rulebook2.webapp import globvars #pylint: disable=wrong-import-position,cyclic-import +app.before_request( globvars.on_request ) # install our signal handler signal.signal( signal.SIGINT, _on_sigint ) diff --git a/asl_rulebook2/webapp/content.py b/asl_rulebook2/webapp/content.py new file mode 100644 index 0000000..1055126 --- /dev/null +++ b/asl_rulebook2/webapp/content.py @@ -0,0 +1,79 @@ +""" Manage the content documents. """ + +import os +import io +import glob + +from flask import jsonify, send_file, url_for, abort + +from asl_rulebook2.webapp import app +from asl_rulebook2.webapp.utils import change_extn, slugify + +content_docs = None + +# --------------------------------------------------------------------- + +def load_content_docs(): + """Load the content documents from the data directory.""" + + # initialize + global content_docs + content_docs = {} + dname = app.config.get( "DATA_DIR" ) + if not dname: + return + if not os.path.dirname( dname ): + raise RuntimeError( "Invalid data directory: {}".format( dname ) ) + + def get_doc( content_doc, key, fname, binary=False ): + fname = os.path.join( dname, fname ) + if not os.path.isfile( fname ): + return + kwargs = {} + kwargs["mode"] = "rb" if binary else "r" + if not binary: + kwargs["encoding"] = "utf-8" + with open( fname, **kwargs ) as fp: + content_doc[ key ] = fp.read() + + # load each content doc + fspec = os.path.join( dname, "*.index" ) + for fname in glob.glob( fspec ): + fname = os.path.basename( fname ) + title = os.path.splitext( fname )[0] + content_doc = { + "doc_id": slugify( title ), + "title": title, + } + get_doc( content_doc, "index", fname ) + get_doc( content_doc, "targets", change_extn(fname,".targets") ) + get_doc( content_doc, "footnotes", change_extn(fname,".footnotes") ) + get_doc( content_doc, "content", change_extn(fname,".pdf"), binary=True ) + content_docs[ content_doc["doc_id"] ] = content_doc + +# --------------------------------------------------------------------- + +@app.route( "/content-docs" ) +def get_content_docs(): + """Return the available content docs.""" + resp = {} + for cdoc in content_docs.values(): + cdoc2 = { + "docId": cdoc["doc_id"], + "title": cdoc["title"], + } + if "content" in cdoc: + cdoc2["url"] = url_for( "get_content", doc_id=cdoc["doc_id"] ) + resp[ cdoc["doc_id"] ] = cdoc2 + return jsonify( resp ) + +# --------------------------------------------------------------------- + +@app.route( "/content/" ) +def get_content( doc_id ): + """Return the content for the specified document.""" + cdoc = content_docs.get( doc_id ) + if not cdoc or "content" not in cdoc: + abort( 404 ) + buf = io.BytesIO( cdoc["content"] ) + return send_file( buf, mimetype="application/pdf" ) diff --git a/asl_rulebook2/webapp/globvars.py b/asl_rulebook2/webapp/globvars.py index 2e7b5d1..ba807b5 100644 --- a/asl_rulebook2/webapp/globvars.py +++ b/asl_rulebook2/webapp/globvars.py @@ -1,4 +1,8 @@ -""" Global variables. """ +""" Global definitions. """ + +import threading + +from flask import request from asl_rulebook2.webapp import app from asl_rulebook2.webapp.config.constants import APP_NAME, APP_VERSION @@ -7,6 +11,30 @@ cleanup_handlers = [] # --------------------------------------------------------------------- +_init_lock = threading.Lock() +_init_done = False + +def on_request(): + """Called before each request.""" + if request.path == "/control-tests": + # NOTE: The test suite calls $/control-tests to find out which port the gRPC test control service + # is running on, which is nice since we don't need to configure both ends with a predefined port. + # However, we don't want this call to trigger initialization, since the tests will often want to + # configure the remote webapp before loading the main page. + return + with _init_lock: + global _init_done + if not _init_done or (request.path == "/" and request.args.get("reload")): + try: + from asl_rulebook2.webapp.main import init_webapp + init_webapp() + finally: + # NOTE: It's important to set this, even if initialization failed, so we don't + # try to initialize again. + _init_done = True + +# --------------------------------------------------------------------- + @app.context_processor def inject_template_params(): """Inject template parameters into Jinja2.""" diff --git a/asl_rulebook2/webapp/main.py b/asl_rulebook2/webapp/main.py index e4cf967..0df4676 100644 --- a/asl_rulebook2/webapp/main.py +++ b/asl_rulebook2/webapp/main.py @@ -8,10 +8,22 @@ import logging from flask import render_template, jsonify, abort from asl_rulebook2.webapp import app, globvars, shutdown_event +from asl_rulebook2.webapp.content import load_content_docs from asl_rulebook2.webapp.utils import parse_int # --------------------------------------------------------------------- +def init_webapp(): + """Initialize the webapp. + + IMPORTANT: This is called on the first Flask request, but can also be called multiple times + after that by the test suite, to reset the webapp before each test. + """ + # initialize the webapp + load_content_docs() + +# --------------------------------------------------------------------- + @app.route( "/" ) def main(): """Return the main page.""" diff --git a/asl_rulebook2/webapp/run_server.py b/asl_rulebook2/webapp/run_server.py index bb38318..0a0f433 100755 --- a/asl_rulebook2/webapp/run_server.py +++ b/asl_rulebook2/webapp/run_server.py @@ -2,6 +2,9 @@ """ Run the webapp server. """ import os +import threading +import urllib.request +import time import glob import click @@ -12,8 +15,10 @@ from asl_rulebook2.webapp import app @click.command() @click.option( "--addr","-a","bind_addr", help="Webapp server address (host:port)." ) +@click.option( "--data","-d","data_dir", help="Data directory." ) +@click.option( "--force-init-delay", default=0, help="Force the webapp to initialize (#seconds delay)." ) @click.option( "--debug","flask_debug", is_flag=True, default=False, help="Run Flask in debug mode." ) -def main( bind_addr, flask_debug ): +def main( bind_addr, data_dir, force_init_delay, flask_debug ): """Run the webapp server.""" # initialize @@ -30,6 +35,12 @@ def main( bind_addr, flask_debug ): if not flask_debug: flask_debug = app.config.get( "FLASK_DEBUG", False ) + # initialize + if data_dir: + if not os.path.isdir( data_dir ): + raise RuntimeError( "Invalid data directory: {}".format( data_dir ) ) + app.config["DATA_DIR"] = data_dir + # validate the configuration if not host: raise RuntimeError( "The server host was not set." ) @@ -51,6 +62,14 @@ def main( bind_addr, flask_debug ): files = glob.glob( fspec ) extra_files.extend( files ) + # check if we should force webapp initialization + if force_init_delay > 0: + def _start_server(): + time.sleep( force_init_delay ) + url = "http://{}:{}/ping".format( host, port ) + _ = urllib.request.urlopen( url ) + threading.Thread( target=_start_server, daemon=True ).start() + # run the server app.run( host=host, port=port, debug=flask_debug, extra_files = extra_files diff --git a/asl_rulebook2/webapp/static/ContentPane.js b/asl_rulebook2/webapp/static/ContentPane.js new file mode 100644 index 0000000..398eb60 --- /dev/null +++ b/asl_rulebook2/webapp/static/ContentPane.js @@ -0,0 +1,40 @@ +import { gMainApp, gEventBus, gUrlParams } from "./MainApp.js" ; + +// -------------------------------------------------------------------- + +gMainApp.component( "content-pane", { + + props: [ "contentDocs" ], + + template: ` + + + + +`, + + mounted() { + gEventBus.on( "show-content-doc", (docId) => { + this.$refs.tabbedPages.activateTab( docId ) ; // nb: tabId == docId + } ) ; + }, + +} ) ; + +// -------------------------------------------------------------------- + +gMainApp.component( "content-doc", { + + props: [ "doc" ], + data() { return { + noContent: gUrlParams.get( "no-content" ), + } ; }, + + template: ` +
+
Content disabled.
+