Added footnotes.

master
Pacman Ghost 3 years ago
parent c5c0249c70
commit 4d9c9d5ac5
  1. 40
      asl_rulebook2/webapp/content.py
  2. 89
      asl_rulebook2/webapp/static/ContentPane.js
  3. 23
      asl_rulebook2/webapp/static/MainApp.js
  4. 9
      asl_rulebook2/webapp/static/NavPane.js
  5. 3
      asl_rulebook2/webapp/static/SearchPane.js
  6. 3
      asl_rulebook2/webapp/static/SearchResult.js
  7. 11
      asl_rulebook2/webapp/static/css/ContentPane.css
  8. 1
      asl_rulebook2/webapp/static/css/global.css
  9. 18
      asl_rulebook2/webapp/static/utils.js
  10. 1
      asl_rulebook2/webapp/templates/index.html
  11. 1
      asl_rulebook2/webapp/templates/testing.html
  12. 29
      asl_rulebook2/webapp/tests/fixtures/footnotes/content set 1.footnotes
  13. 16
      asl_rulebook2/webapp/tests/fixtures/footnotes/content set 1.index
  14. 9
      asl_rulebook2/webapp/tests/fixtures/footnotes/content set 1.targets
  15. 16
      asl_rulebook2/webapp/tests/fixtures/footnotes/content set 2.footnotes
  16. 12
      asl_rulebook2/webapp/tests/fixtures/footnotes/content set 2.index
  17. 6
      asl_rulebook2/webapp/tests/fixtures/footnotes/content set 2.targets
  18. 83
      asl_rulebook2/webapp/tests/test_footnotes.py
  19. 1
      asl_rulebook2/webapp/tests/utils.py

@ -10,6 +10,7 @@ from asl_rulebook2.webapp import app
from asl_rulebook2.webapp.utils import load_data_file, slugify
_content_sets = None
_footnote_index = None
_chapter_resources = None
# ---------------------------------------------------------------------
@ -27,8 +28,8 @@ def load_content_sets( startup_msgs, logger ):
# in the MMP eASLRB index, and have their own index.
# initialize
global _content_sets, _chapter_resources
_content_sets = {}
global _content_sets, _footnote_index, _chapter_resources
_content_sets, _footnote_index = {}, {}
_chapter_resources = { "background": {}, "icon": {} }
# get the data directory
@ -47,12 +48,25 @@ def load_content_sets( startup_msgs, logger ):
return fname2
return None
def load_content_doc( fname_stem, title ):
def load_content_doc( fname_stem, title, cdoc_id ):
# load the content doc files
content_doc = { "title": title }
content_doc = { "cdoc_id": cdoc_id, "title": title }
load_file( fname_stem+".targets", content_doc, "targets", startup_msgs.warning )
load_file( fname_stem+".chapters", content_doc, "chapters", startup_msgs.warning )
load_file( fname_stem+".footnotes", content_doc, "footnotes", startup_msgs.warning )
if load_file( fname_stem+".footnotes", content_doc, "footnotes", startup_msgs.warning ):
# update the footnote index
# NOTE: The front-end doesn't care about what chapter a footnote belongs to,
# and we rework things a bit to make it easier to map ruleid's to footnotes.
if cdoc_id not in _footnote_index:
_footnote_index[ cdoc_id ] = {}
for chapter_id, footnotes in content_doc.get( "footnotes", {} ).items():
for footnote_id, footnote in footnotes.items():
for caption in footnote.get( "captions", [] ):
footnote[ "display_name" ] = "{}{}".format( chapter_id, footnote_id )
ruleid = caption[ "ruleid" ]
if ruleid not in _footnote_index[ cdoc_id ]:
_footnote_index[ cdoc_id ][ ruleid ] = []
_footnote_index[ cdoc_id ][ ruleid ].append( footnote )
load_file( fname_stem+".pdf", content_doc, "content", startup_msgs.warning, binary=True )
# locate any chapter backgrounds and icons
resource_dirs = [
@ -112,19 +126,18 @@ def load_content_sets( startup_msgs, logger ):
continue # nb: we can't do anything without an index file
# load the main content doc
fname_stem = os.path.splitext( fname2 )[0]
content_doc = load_content_doc( fname_stem, fname_stem )
cdoc_id = cset_id # nb: because this the main content document
content_doc[ "cdoc_id" ] = cdoc_id
content_doc = load_content_doc( fname_stem, fname_stem, cdoc_id )
content_set[ "content_docs" ][ cdoc_id ] = content_doc
# load any associated content docs
for fname_stem2 in find_assoc_cdocs( fname_stem ):
# nb: we assume there's only one space between the two filename stems :-/
cdoc_id2 = "{}!{}".format( cdoc_id, slugify(fname_stem2) )
content_doc = load_content_doc(
"{} ({})".format( fname_stem, fname_stem2 ),
fname_stem2
fname_stem2,
cdoc_id2
)
cdoc_id2 = "{}!{}".format( cdoc_id, slugify(fname_stem2) )
content_doc[ "cdoc_id" ] = cdoc_id2
content_set[ "content_docs" ][ cdoc_id2 ] = content_doc
# save the new content set
_content_sets[ content_set["cset_id"] ] = content_set
@ -179,6 +192,13 @@ def get_content( cdoc_id ):
# ---------------------------------------------------------------------
@app.route( "/footnotes" )
def get_footnotes():
"""Return the footnote index."""
return jsonify( _footnote_index )
# ---------------------------------------------------------------------
@app.route( "/chapter/<chapter_id>/<rtype>" )
def get_chapter_resource( chapter_id, rtype ):
"""Return a chapter resource."""

@ -1,5 +1,5 @@
import { gMainApp, gEventBus, gUrlParams } from "./MainApp.js" ;
import { findTargets, showErrorMsg } from "./utils.js" ;
import { gMainApp, gFootnoteIndex, gEventBus, gUrlParams } from "./MainApp.js" ;
import { findTargets, showErrorMsg, showNotificationMsg } from "./utils.js" ;
// --------------------------------------------------------------------
@ -14,6 +14,21 @@ gMainApp.component( "content-pane", {
</tabbed-page>
</tabbed-pages>`,
created() {
gEventBus.on( "show-target", (cdocId, ruleid) => {
// check if the target has footnote(s) associated with it
if ( gFootnoteIndex[ cdocId ] ) {
let footnotes = gFootnoteIndex[ cdocId ][ ruleid ] ;
if ( footnotes ) {
// yup - show them to the user
this.showFootnotes( footnotes ) ;
}
}
} ) ;
},
mounted() {
const showContentDoc = (cdocId) => {
this.$refs.tabbedPages.activateTab( cdocId ) ; // nb: tabId == cdocId
@ -26,6 +41,76 @@ gMainApp.component( "content-pane", {
} ) ;
},
methods: {
showFootnotes( footnotes ) {
// show the footnote in a notification balloon
let msg = this.makeFootnoteContent( footnotes ) ;
let $growl = showNotificationMsg( "footnote", msg ) ;
// adjust the width of the balloon (based on the available width)
// NOTE: The longest footnote is ~7K (A25.8), so we try to hit the max width at ~3K.
let $contentPane = $( this.$el ) ;
let width = Math.min( 50 + 30 * (msg.length / 3000), 80 ) ;
width *= $contentPane.width() / 100 ;
$growl.css( "width", Math.floor(width)+"px" ) ;
// FUDGE! We want to limit how tall the notification balloon can get, and show a v-scrollbar
// for the content if there's too much. However, max-height only works if one of the parent elements
// has a specific height set for it, so we set a timer to configure the height of the balloon
// to whatever it is after it has appeared on-screen. Sigh...
setTimeout( () => {
let height = $growl.height() ;
let maxHeight = $contentPane.height() * 0.4 ; // nb: CSS max-height of #growls-br is 40%
if ( height > maxHeight )
height = maxHeight - 40 ;
// FIXME! This is really jerky, but I can't get it to animate :-/ But it's only a problem
// when the v-scrollbar comes into play, and there's stuff moving around as that happens,
// so it's a bit less visually annoying. The footnote balloons are on a light background,
// which also helps.
$growl.css( "height", Math.floor(height)+"px" ) ;
}, 500 ) ; // nb: yes, this needs to be this large :-/
},
makeFootnoteContent( footnotes ) {
let buf = [] ;
function addCaption( footnote, caption, style ) {
buf.push( "<div class='header' ", style ? "style='"+style+"'" : "", ">",
"<span class='caption'>", caption.caption, " ("+caption.ruleid+")", "</span>", " ",
"<span class='footnote-id'>", "["+footnote.display_name+"]", "</span>",
"</div>" ) ;
}
if ( footnotes.length == 1 ) {
// there is only 1 footnote - we make only its content v-scrollable
let footnote = footnotes[0] ;
buf.push( "<div class='footnote'>" ) ;
footnote.captions.forEach( (caption) => {
addCaption( footnote, caption, "padding: 0 5px;" ) ;
} ) ;
buf.push( "<div class='content'>", footnote.content, "</div>" ) ;
buf.push( "</div>" ) ;
} else {
// there are multiple footnotes - we make the entire content scrollable
buf.push( "<div class='content'>" ) ;
footnotes.forEach( (footnote) => {
buf.push( "<div class='footnote'>" ) ;
footnote.captions.forEach( (caption) => {
addCaption( footnote, caption ) ;
} ) ;
buf.push( footnote.content ) ;
buf.push( "</div>" ) ;
} ) ;
buf.push( "</div>" ) ;
}
return buf.join( "" ) ;
},
},
} ) ;
// --------------------------------------------------------------------

@ -17,6 +17,7 @@ $(document).ready( () => {
export let gAppConfig = null ;
export let gContentDocs = null ;
export let gTargetIndex = null ;
export let gFootnoteIndex = null ;
export let gChapterResources = null ;
// --------------------------------------------------------------------
@ -45,8 +46,9 @@ gMainApp.component( "main-app", {
// initialze the webapp
Promise.all( [
this.getAppConfig( this ),
this.getAppConfig(),
this.getContentDocs( this ),
this.getFootnoteIndex(),
] ).then( () => {
this.isLoaded = true ;
gEventBus.emit( "app-loaded" ) ;
@ -62,6 +64,11 @@ gMainApp.component( "main-app", {
if ( evt.keyCode == 27 ) {
if ( $(".jquery-image-zoom").length >= 1 )
return ; // an image is zoomed - ignore the Escape
// close any notification balloons
$( ".growl-close" ).each( function() {
$(this).trigger( "click" ) ;
} ) ;
// notify the rest of the application
gEventBus.emit( "escape-pressed" ) ;
}
} ) ;
@ -107,6 +114,20 @@ gMainApp.component( "main-app", {
} ) ;
},
getFootnoteIndex() {
return new Promise( (resolve, reject) => {
// get the footnotes
$.getJSON( gGetFootnotesUrl, (resp) => { //eslint-disable-line no-undef
gFootnoteIndex = resp ;
resolve() ;
} ).fail( (xhr, status, errorMsg) => {
let msg = "Couldn't get the footnote index." ;
showErrorMsg( msg + " <div class='pre'>" + errorMsg + "</div>" ) ;
reject( msg )
} ) ;
} ) ;
},
installContentDocs( contentDocs ) {
// install the content docs
gContentDocs = contentDocs ;

@ -24,11 +24,10 @@ gMainApp.component( "nav-pane", {
created() {
// show any Q+A and annotations when a target is opened
gEventBus.on( "show-target", (cdocId, ruleid) => {
if ( gAppConfig.DISABLE_AUTO_SHOW_RULE_INFO )
return ;
// get the rule info for the target being opened
// get the Q+A and annotations for the target being opened
// NOTE: Targets are associated with a content set, but the Q+A is global, which is not quite
// the right thing to do - what if there is a ruleid that exists in multiple content set,
// but is referenced in the Q+A? Hopefully, this will never happen... :-/
@ -44,7 +43,11 @@ gMainApp.component( "nav-pane", {
} ) ;
// close the rule info popup if Escape is pressed
gEventBus.on( "escape-pressed", this.closeRuleInfo ) ;
gEventBus.on( "escape-pressed", () => {
if ( $( ".growl-footnote" ).length > 0 )
return ; // nb: unless a footnote on-screen (let the Escape close that instead)
this.closeRuleInfo() ;
} ) ;
},

@ -1,5 +1,5 @@
import { gMainApp, gAppConfig, gEventBus } from "./MainApp.js" ;
import { findTargets, getPrimaryTarget, fixupSearchHilites } from "./utils.js" ;
import { findTargets, getPrimaryTarget, fixupSearchHilites, hideFootnotes } from "./utils.js" ;
// --------------------------------------------------------------------
@ -82,6 +82,7 @@ gMainApp.component( "search-results", {
// initialize
this.errorMsg = null ;
hideFootnotes() ;
function onSearchDone() {
Vue.nextTick( () => { gEventBus.emit( "search-done" ) ; } ) ;
}

@ -1,5 +1,5 @@
import { gMainApp, gEventBus, gUrlParams } from "./MainApp.js" ;
import { findTargets, getPrimaryTarget, isRuleid, getChapterResource, fixupSearchHilites, hasHilite } from "./utils.js" ;
import { findTargets, getPrimaryTarget, isRuleid, getChapterResource, fixupSearchHilites, hasHilite, hideFootnotes } from "./utils.js" ;
// --------------------------------------------------------------------
@ -164,6 +164,7 @@ gMainApp.component( "ruleid", {
methods: {
onClick() {
// show the target
hideFootnotes() ;
gEventBus.emit( "show-target", this.cdocId, this.ruleid ) ;
},
},

@ -8,3 +8,14 @@
#content .content-doc iframe { position: absolute ; width: 100% ; height: calc(100% - 40px) ; border: none ; }
#content .content-doc .disabled { margin-top: 1em ; text-align: center ; font-style: italic ; color: #888 ; }
#growls-br { max-height: 40% ;}
.growl-footnote { background: #f0f0d0 ; border: 1px solid #ccc ; color: black ; width: 120px ; }
/* FUDGE! We would like to set the opacity to 1 on :hover, but it breaks the click handler!?!? :wtf: */
.growl-footnote { opacity: 1 ; }
.growl-footnote .footnote { margin-top: 10px ; height: 100% ; display: flex ; flex-direction: column ; }
.growl-footnote .footnote:first-of-type { margin-top: 0 ; }
.growl-footnote .header { font-weight: bold ; }
.growl-footnote .header .footnote-id { font-size: 90% ; font-style: italic ; color: #666 ; }
.growl-footnote .growl-message { height: 100% ; display: flex ; flex-direction: column ; }
.growl-footnote .growl-message .content { flex-grow: 1 ; overflow-y: auto ; padding: 0 5px 5px 5px ; text-align: justify ; }

@ -9,6 +9,7 @@
button { height: 24px ; padding: 0 5px !important ; }
/* notification balloons */
.growl { cursor: pointer ; }
.growl .growl-close { position: absolute ; top: 0 ; right: 6px ; }
.growl .growl-title { display: none ; }
.growl .pre { font-family: monospace ; }

@ -96,14 +96,26 @@ export function showNotificationMsg( msgType, msg )
}
// show the notification message
$.growl( {
let $growl = $.growl( {
style: (msgType == "info") ? "notice" : msgType,
title: null,
message: msg,
location: "br",
duration: (msgType == "warning") ? 15*1000 : 5*1000,
duration: (msgType == "warning" || msgType == "footnote") ? 15*1000 : 5*1000,
fixed: (msgType == "error"),
} ) ;
} ).$growl() ;
function onClick() {
$growl.off( "click", onClick ) ;
$(this).find( ".growl-close" ).click() ;
}
$growl.on( "click", onClick ) ;
return $growl ;
}
export function hideFootnotes()
{
// hide the footnotes balloon
$( ".growl-footnote" ).find( ".growl-close" ).click() ;
}
// --------------------------------------------------------------------

@ -55,6 +55,7 @@ gGetStartupMsgsUrl = "{{ url_for( 'get_startup_msgs' ) }}" ;
gSearchUrl = "{{ url_for( 'search' ) }}" ;
gGetRuleInfoUrl = "{{ url_for( 'get_rule_info', ruleid='RULEID' ) }}" ;
gGetQAImageUrl = "{{ url_for( 'get_qa_image', fname='FNAME' ) }}" ;
gGetFootnotesUrl = "{{ url_for( 'get_footnotes' ) }}" ;
</script>
<script type="module" src="{{ url_for( 'static', filename='MainApp.js' ) }}"></script>

@ -2,3 +2,4 @@
<textarea id="_last-info-msg_" style="display:none;"></textarea>
<textarea id="_last-warning-msg_" style="display:none;"></textarea>
<textarea id="_last-error-msg_" style="display:none;"></textarea>
<textarea id="_last-footnote-msg_" style="display:none;"></textarea>

@ -0,0 +1,29 @@
{
"X": {
"1": {
"captions": [ { "caption": "Alpha", "ruleid": "1a.1" } ],
"content": "This footnote is for ruleid 1a.1."
},
"2": {
"captions": [ { "caption": "Bravo", "ruleid": "1a.2" } ],
"content": "This footnote is for ruleid 1a.2."
},
"10": {
"captions": [ { "caption": "Charlie", "ruleid": "1b.1" } ],
"content": "This footnote is for ruleid 1b1.1."
},
"11": {
"captions": [ { "caption": "Delta", "ruleid": "1b.1" } ],
"content": "This footnote is also for ruleid 1b1.1."
},
"20": {
"captions": [ { "ruleid": "1b.2" } ]
}
}
}

@ -0,0 +1,16 @@
[
{ "title": "Content Set 1",
"rulerefs": [
{ "caption": "Main document", "ruleids": [
"1a.1", "1a.2"
]
},
{ "caption": "Linked document", "ruleids": [
"1b.1", "1b.2"
]
}
]
}
]

@ -0,0 +1,9 @@
{
"1a.1": { "caption": "Item 1a.1" },
"1a.2": { "caption": "Item 1a.2" },
"1b.1": { "caption": "Item 1b.1" },
"1b.2": { "caption": "Item 1b.2" }
}

@ -0,0 +1,16 @@
{
"Y": {
"1": {
"captions": [ { "caption": "Mike", "ruleid": "2.1" } ],
"content": "This footnote is for ruleid 2.1."
},
"2": {
"captions": [ { "caption": "November", "ruleid": "2.2" } ],
"content": "This footnote is for ruleid 2.2."
}
}
}

@ -0,0 +1,12 @@
[
{ "title": "Content Set 2",
"rulerefs": [
{ "caption": "The only document", "ruleids": [
"2.1", "2.2"
]
}
]
}
]

@ -0,0 +1,6 @@
{
"2.1": { "caption": "Item 2.1" },
"2.2": { "caption": "Item 2.2" }
}

@ -0,0 +1,83 @@
""" Test footnotes. """
import lxml.html
from asl_rulebook2.webapp.tests.utils import init_webapp, \
find_children, wait_for, set_stored_msg_marker, get_last_footnote_msg
from asl_rulebook2.webapp.tests.test_search import do_search
# ---------------------------------------------------------------------
def test_footnotes( webdriver, webapp ):
"""Test footnotes."""
# initialize
webapp.control_tests.set_data_dir( "footnotes" )
init_webapp( webapp, webdriver )
# bring up the ruleid's and locate the ruleid links
do_search( "document" )
def remove_brackets( val ):
assert val.startswith( "[" ) and val.endswith( "]" )
return val[1:-1]
ruleid_elems = {
remove_brackets( c.text ): c
for c in find_children( "#search-results .ruleid" )
}
def do_test( ruleid, expected ):
# click on the specified ruleid and wait for the footnote to appear
marker = set_stored_msg_marker( "footnote" )
ruleid_elems[ ruleid ].click()
wait_for( 2, lambda: get_last_footnote_msg() != marker )
# locate the footnote(s)
footnotes = []
root = lxml.html.fragment_fromstring( get_last_footnote_msg() )
if root.attrib["class"] == "footnote":
# this is a single footnote
footnote_elems = [ root ]
get_content = lambda fnote: fnote.find( "div[@class='content']" ).text
else:
# there are multiple footnotes
footnote_elems = root.findall( "div[@class='footnote']" )
get_content = lambda fnote: "".join( fnote.xpath( "text()" ) )
# extract content from each footnote
for footnote in footnote_elems:
header = footnote.find( "div[@class='header']" )
footnotes.append( {
"caption": header.find( "span[@class='caption']" ).text,
"footnote_id": remove_brackets( header.find( "span[@class='footnote-id']" ).text ),
"content": get_content( footnote )
} )
assert footnotes == expected
# do the tests (content set 1)
do_test( "1a.1", [ {
"caption": "Alpha (1a.1)", "footnote_id": "X1",
"content": "This footnote is for ruleid 1a.1."
} ] )
do_test( "1a.2", [ {
"caption": "Bravo (1a.2)", "footnote_id": "X2",
"content": "This footnote is for ruleid 1a.2."
} ] )
do_test( "1b.1", [ {
"caption": "Charlie (1b.1)", "footnote_id": "X10",
"content": "This footnote is for ruleid 1b1.1."
}, {
"caption": "Delta (1b.1)", "footnote_id": "X11",
"content": "This footnote is also for ruleid 1b1.1."
} ] )
do_test( "1b.2", [ {
"caption": " (1b.2)", "footnote_id": "X20",
"content": None
} ] )
# do the tests (content set 2)
do_test( "2.1", [ {
"caption": "Mike (2.1)", "footnote_id": "Y1",
"content": "This footnote is for ruleid 2.1."
} ] )
do_test( "2.2", [ {
"caption": "November (2.2)", "footnote_id": "Y2",
"content": "This footnote is for ruleid 2.2."
} ] )

@ -92,6 +92,7 @@ def get_curr_target():
def get_last_info(): return get_stored_msg( "info" )
def get_last_warning_msg(): return get_stored_msg( "warning" )
def get_last_error_msg(): return get_stored_msg( "error" )
def get_last_footnote_msg(): return get_stored_msg( "footnote" )
#pylint: enable=multiple-statements,missing-function-docstring
def get_stored_msg( msg_type ):

Loading…
Cancel
Save