lib/showoff.rb in showoff-0.16.1 vs lib/showoff.rb in showoff-0.16.2
- old
+ new
@@ -138,10 +138,11 @@
# variables used for building section numbering and title
@slide_count = 0
@section_major = 0
@section_minor = 0
@section_title = settings.showoff_config['name'] rescue 'Showoff Presentation'
+ @@slide_titles = [] # a list of generated slide names, used for cross references later.
@logger.debug settings.pres_template
@cached_image_size = {}
@logger.debug settings.pres_dir
@@ -159,16 +160,20 @@
# Page view time accumulator. Tracks how often slides are viewed by the audience
begin
@@counter = JSON.parse(File.read("#{settings.statsdir}/#{settings.viewstats}"))
- # port old format stats
+ # TODO: remove this logic 4/15/2017: port old format stats
unless @@counter.has_key? 'user_agents'
- @@counter = { 'user_agents' => {}, 'pageviews' => @@counter }
+ @@counter['pageviews'] = @@counter
end
+
+ @@counter['current'] ||= {}
+ @@counter['pageviews'] ||= {}
+ @@counter['user_agents'] ||= {}
rescue
- @@counter = { 'user_agents' => {}, 'pageviews' => {} }
+ @@counter = { 'user_agents' => {}, 'pageviews' => {}, 'current' => {} }
end
# keeps track of form responses. In memory to avoid concurrence issues.
begin
@@forms = JSON.parse(File.read("#{settings.statsdir}/#{settings.forms}"))
@@ -393,15 +398,13 @@
content += " style=\"background-image: url('file/#{slide.bg}');\"" if slide.bg
content += " class=\"slide #{classes}\" data-transition=\"#{transition}\">"
# name the slide. If we've got multiple slides in this file, we'll have a sequence number
# include that sequence number to index directly into that content
- if seq
- content += "<div class=\"content #{classes}\" ref=\"#{name}:#{seq.to_s}\">\n"
- else
- content += "<div class=\"content #{classes}\" ref=\"#{name}\">\n"
- end
+ ref = seq ? "#{name}:#{seq.to_s}" : name
+ content += "<div class=\"content #{classes}\" ref=\"#{ref}\">\n"
+ @@slide_titles << ref
# renderers like wkhtmltopdf needs an <h1> tag to use for a section title, but only when printing.
if opts[:print]
# reset subsection each time we encounter a new subsection slide. Do this in a regex, because it's much
# easier to just get the first of any header than it is after rendering to html.
@@ -490,16 +493,10 @@
result.gsub!(/~~~ENDSECTION~~~(<\/p>)?/, '\1</div>')
# Turn this into a document for munging
doc = Nokogiri::HTML::DocumentFragment.parse(result)
- if opts[:section]
- doc.css('div.notes-section').each do |section|
- section.remove unless section.attr('class').split.include? opts[:section]
- end
- end
-
filename = File.join(settings.pres_dir, '_notes', "#{name}.md")
@logger.debug "personal notes filename: #{filename}"
if [nil, 'notes'].include? opts[:section] and File.file? filename
# TODO: shouldn't have to reparse config all the time
engine_options = ShowOffUtils.showoff_renderer_options(settings.pres_dir)
@@ -517,15 +514,74 @@
section.add_child(note)
end
end
end
- # Now add a target so we open all external links from notes in a new window
+ doc.css('.callout.glossary').each do |item|
+ next unless item.content =~ /^([^|]+)\|([^:]+):(.*)$/
+ item['data-term'] = $1
+ item['data-target'] = $2
+ item['data-text'] = $3
+ item.content = $3
+
+ glossary = (item.attr('class').split - ['callout', 'glossary']).first
+ address = glossary ? "#{glossary}/#{$2}" : $2
+ frag = "<a class=\"processed label\" href=\"glossary://#{address}\">#{$1}</a>"
+
+ item.children.before(Nokogiri::HTML::DocumentFragment.parse(frag))
+ end
+
+ # Process links
doc.css('a').each do |link|
- link.set_attribute('target', '_blank') unless link['href'].start_with? '#'
+ next if link['href'].start_with? '#'
+ next if link['class'].split.include? 'processed' rescue nil
+
+ # If these are glossary links, populate the notes/handouts sections
+ if link['href'].start_with? 'glossary://'
+ doc.add_child '<div class="notes-section notes"></div>' if doc.css('div.notes-section.notes').empty?
+ doc.add_child '<div class="notes-section handouts"></div>' if doc.css('div.notes-section.handouts').empty?
+
+ term = link.content
+ text = link['title']
+ href = link['href']
+ href.slice!('glossary://')
+
+ parts = href.split('/')
+ target = parts.pop
+ name = parts.pop # either the glossary name or nil
+
+ link['class'] = 'term'
+
+ label = link.clone
+ label['class'] = 'label processed'
+
+ frag = Nokogiri::HTML::DocumentFragment.parse('<p></p>')
+ definition = frag.children.first
+ definition['class'] = "callout glossary #{name}"
+ definition['data-term'] = term
+ definition['data-target'] = target
+ definition['data-text'] = text
+ definition.content = text
+ definition.children.before(label)
+
+ [doc.css('div.notes-section.notes'), doc.css('div.notes-section.handouts')].each do |section|
+ section.first.add_child(definition.clone)
+ end
+
+ else
+ # Add a target so we open all external links from notes in a new window
+ link.set_attribute('target', '_blank')
+ end
end
+ # finally, remove any sections we don't want to print
+ if opts[:section]
+ doc.css('div.notes-section').each do |section|
+ section.remove unless section.attr('class').split.include? opts[:section]
+ end
+ end
+
doc.to_html
end
# TODO: damn, this one is bad. It's named generically so we can add to it if needed.
#
@@ -550,35 +606,91 @@
doc.to_html
end
def process_content_for_all_slides(content, num_slides, opts={})
+ # this has to be text replacement for now, since the string can appear in any context
content.gsub!("~~~NUM_SLIDES~~~", num_slides.to_s)
+ doc = Nokogiri::HTML::DocumentFragment.parse(content)
# Should we build a table of contents?
if opts[:toc]
- frag = Nokogiri::HTML::DocumentFragment.parse ""
- toc = Nokogiri::XML::Node.new('div', frag)
- toc['id'] = 'toc'
- frag.add_child(toc)
+ toc = Nokogiri::HTML::DocumentFragment.parse("<p id=\"toc\"></p>")
- Nokogiri::HTML(content).css('div.subsection > h1:not(.section_title)').each do |section|
- entry = Nokogiri::XML::Node.new('div', frag)
- entry['class'] = 'tocentry'
- toc.add_child(entry)
+ doc.css('div.subsection > h1:not(.section_title)').each do |section|
+ href = section.parent.parent['id']
+ frag = "<div class=\"tocentry\"><a href=\"##{href}\">#{section.content}</a></div>"
+ link = Nokogiri::HTML::DocumentFragment.parse(frag)
- link = Nokogiri::XML::Node.new('a', frag)
- link['href'] = "##{section.parent.parent['id']}"
- link.content = section.content
- entry.add_child(link)
+ toc.children.first.add_child(link)
end
# swap out the tag, if found, with the table of contents
- content.gsub!("~~~TOC~~~", frag.to_html)
+ doc.at('p:contains("~~~TOC~~~")').replace(toc)
end
- content
+ doc.css('.slide.glossary .content').each do |glossary|
+ name = (glossary.attr('class').split - ['content', 'glossary']).first
+ list = Nokogiri::HTML::DocumentFragment.parse('<ul class="glossary terms"></ul>')
+ seen = []
+
+ doc.css('.callout.glossary').each do |item|
+ target = (item.attr('class').split - ['callout', 'glossary']).first
+
+ # if the name matches or if we didn't name it to begin with.
+ next unless target == name
+
+ # the definition can exist in multiple places, so de-dup it here
+ term = item.attr('data-term')
+ next if seen.include? term
+ seen << term
+
+ # excrutiatingly find the parent slide content and grab the ref
+ # in a library less shitty, this would be something like
+ # $(this).parent().siblings('.content').attr('ref')
+ href = nil
+ item.ancestors('.slide').first.traverse do |element|
+ next if element['class'].nil?
+ next unless element['class'].split.include? 'content'
+
+ href = element.attr('ref').gsub('/', '_')
+ end
+
+ text = item.attr('data-text')
+ link = item.attr('data-target')
+ page = glossary.attr('ref')
+ anchor = "#{page}+#{link}"
+ next if href.nil? or text.nil? or link.nil?
+
+ frag = "<li><a id=\"#{anchor}\" class=\"label\">#{term}</a>#{text}<a href=\"##{href}\" class=\"return\">↩</a></li>"
+ item = Nokogiri::HTML::DocumentFragment.parse(frag)
+
+ list.children.first.add_child(item)
+ end
+
+ glossary.add_child(list)
+ end
+
+ # now fix all the links to point to the glossary page
+ doc.css('a').each do |link|
+ next if link['href'].nil?
+ next unless link['href'].start_with? 'glossary://'
+
+ href = link['href']
+ href.slice!('glossary://')
+
+ parts = href.split('/')
+ target = parts.pop
+ name = parts.pop # either the glossary name or nil
+
+ classes = name.nil? ? ".slide.glossary" : ".slide.glossary.#{name}"
+ href = doc.at("#{classes} .content").attr('ref') rescue nil
+
+ link['href'] = "##{href}+#{target}"
+ end
+
+ doc.to_html
end
# Find any lines that start with a <p>.(something), remove the ones tagged with
# .break and .comment, then turn the remainder into <p class="something">
# The perlism line noise is splitting multiple classes (.class1.class2) on the period.
@@ -989,10 +1101,18 @@
@static = static
# Provide a button in the sidebar for interactive editing if configured
@edit = settings.showoff_config['edit'] if @review
+ # store a cookie to tell clients apart. More reliable than using IP due to proxies, etc.
+ unless request.cookies['client_id']
+ @client_id = guid()
+ response.set_cookie('client_id', @client_id)
+ else
+ @client_id = request.cookies['client_id']
+ end
+
erb :index
end
def presenter
@favicon = settings.showoff_config['favicon']
@@ -1044,10 +1164,11 @@
# If we're displaying from a repository, let's update it
ShowOffUtils.update(settings.verbose) if settings.url
# if we have a cache and we're not asking to invalidate it
return @@cache if (@@cache and params['cache'] != 'clear')
+ @@slide_titles = []
content = get_slides_html(:static=>static)
# allow command line cache disabling
@@cache = content unless settings.nocache
content
@@ -1079,28 +1200,91 @@
end
@downloads.merge! @@downloads
erb :download
end
+ def stats_data()
+ data = {}
+ begin
+
+ # what are viewers looking at right now?
+ now = Time.now.to_i # let's throw away viewers who haven't done anything in 5m
+ active = @@counter['current'].select {|client, view| (now - view[1]).abs < 300 }
+
+ # percentage of stray viewers
+ stray = active.select {|client, view| view[0] != @@current[:name] }
+ stray_p = ((stray.size.to_f / active.size.to_f) * 100).to_i rescue 0
+ data['stray_p'] = stray_p
+
+ # percentage of idle viewers
+ idle = @@counter['current'].size - active.size
+ idle_p = ((idle.to_f / @@counter['current'].size.to_f) * 100).to_i rescue 0
+ data['idle_p'] = idle_p
+
+ viewers = @@slide_titles.map do |slide|
+ count = active.select {|client, view| view[0] == slide }.size
+ flags = (slide == @@current[:name]) ? 'current' : nil
+ [count, slide, nil, flags]
+ end
+
+ # trim the ends, if nobody's looking we don't much care.
+ viewers.pop while viewers.last[0] == 0
+ viewers.shift while viewers.first[0] == 0
+ viewmax = viewers.max_by {|view| view[0] }.first
+
+ data['viewers'] = viewers
+ data['viewmax'] = viewmax
+ rescue => e
+ @logger.warn "Not enough data to generate pageviews."
+ @logger.debug e.message
+ @logger.debug e.backtrace.first
+ end
+
+ begin
+ # current elapsed time for the zoomline view
+ elapsed = @@slide_titles.map do |slide|
+ if @@counter['pageviews'][slide].nil?
+ time = 0
+ else
+ time = @@counter['pageviews'][slide].inject(0) do |outer, (viewer, views)|
+ outer += views.inject(0) { |inner, view| inner += view['elapsed'] }
+ end
+ end
+ string = Time.at(time).gmtime.strftime('%M:%S')
+ flags = (slide == @@current[:name]) ? 'current' : nil
+
+ [ time, slide, string, flags ]
+ end
+ maxtime = elapsed.max_by {|view| view[0] }.first
+
+ data['elapsed'] = elapsed
+ data['maxtime'] = maxtime
+ rescue => e
+ # expected if this is loaded before a presentation has been compiled
+ @logger.warn "Not enough data to generate elapsed time."
+ @logger.debug e.message
+ @logger.debug e.backtrace.first
+ end
+
+ data.to_json
+ end
+
def stats()
- if request.env['REMOTE_HOST'] == 'localhost'
+ if localhost?
# the presenter should have full stats in the erb
@counter = @@counter['pageviews']
end
+ # for the full page view. Maybe to be disappeared
@all = Hash.new
@@counter['pageviews'].each do |slide, stats|
@all[slide] = 0
stats.map do |host, visits|
visits.each { |entry| @all[slide] += entry['elapsed'].to_f }
end
end
- # most and least five viewed slides
- @least = @all.sort_by {|slide, time| time}[0..4]
- @most = @all.sort_by {|slide, time| -time}[0..4]
-
erb :stats
end
def pdf(name)
@slides = get_slides_html(:static=>true, :toc=>true, :print=>true)
@@ -1129,11 +1313,11 @@
end
end
- def self.do_static(args, opts = {})
+ def self.do_static(args, opts = {})
args ||= [] # handle nil arguments
what = args[0] || "index"
opt = args[1]
ShowOffUtils.presentation_config_file = opts[:f]
@@ -1317,22 +1501,24 @@
def guid
# this is a terrifyingly simple GUID generator
(0..15).to_a.map{|a| rand(16).to_s(16)}.join
end
- def valid_cookie?
+ def valid_presenter_cookie?
+ return false if @@cookie.nil?
(request.cookies['presenter'] == @@cookie)
end
post '/form/:id' do |id|
- @logger.warn("Saving form answers from ip:#{request.ip} for id:##{id}")
+ client_id = request.cookies['client_id']
+ @logger.warn("Saving form answers from ip:#{request.ip} with ID of #{client_id} for id:##{id}")
form = params.reject { |k,v| ['splat', 'captures', 'id'].include? k }
# make sure we've got a bucket for this form, then save our answers
@@forms[id] ||= {}
- @@forms[id][request.ip] = form
+ @@forms[id][client_id] = form
form.to_json
end
# Return a list of the totals for each alternative for each question of a form
@@ -1443,17 +1629,17 @@
end
ws.onmessage do |data|
begin
control = JSON.parse(data)
- @logger.warn "#{control.inspect}"
+ @logger.debug "#{control.inspect}"
case control['message']
when 'update'
# websockets don't use the same auth standards
# we use a session cookie to identify the presenter
- if valid_cookie?
+ if valid_presenter_cookie?
name = control['name']
slide = control['slide'].to_i
increment = control['increment'].to_i rescue 0
# check to see if we need to enable a download link
@@ -1470,32 +1656,40 @@
EM.next_tick { settings.sockets.each{|s| s.send({ 'message' => 'current', 'current' => @@current[:number], 'increment' => @@current[:increment] }.to_json) } }
end
when 'register'
# save a list of presenters
- if valid_cookie?
+ if valid_presenter_cookie?
remote = request.env['REMOTE_HOST'] || request.env['REMOTE_ADDR']
settings.presenters << ws
@logger.warn "Registered new presenter: #{remote}"
end
when 'track'
- remote = valid_cookie? ? 'presenter' : (request.env['REMOTE_HOST'] || request.env['REMOTE_ADDR'])
+ remote = valid_presenter_cookie? ? 'presenter' : request.cookies['client_id']
slide = control['slide']
- time = control['time'].to_f
- # record the UA of the client if we haven't seen it before
- @@counter['user_agents'][remote] ||= request.user_agent
+ if control.has_key? 'time'
+ time = control['time'].to_f
- views = @@counter['pageviews']
- # a bucket for this slide
- views[slide] ||= Hash.new
- # a bucket of slideviews for this address
- views[slide][remote] ||= Array.new
- # and add this slide viewing to the bucket
- views[slide][remote] << { 'elapsed' => time, 'timestamp' => Time.now.to_i, 'presenter' => @@current[:name] }
+ # record the UA of the client if we haven't seen it before
+ @@counter['user_agents'][remote] ||= request.user_agent
- @logger.debug "Logged #{time} on slide #{slide} for #{remote}"
+ views = @@counter['pageviews']
+ # a bucket for this slide
+ views[slide] ||= Hash.new
+ # a bucket of slideviews for this address
+ views[slide][remote] ||= Array.new
+ # and add this slide viewing to the bucket
+ views[slide][remote] << { 'elapsed' => time, 'timestamp' => Time.now.to_i, 'presenter' => @@current[:name] }
+
+ @logger.debug "Logged #{time} on slide #{slide} for #{remote}"
+
+ else
+ @@counter['current'][remote] = [slide, Time.now.to_i]
+ @logger.debug "Recorded current slide #{slide} for #{remote}"
+ end
+
when 'position'
ws.send( { 'current' => @@current[:number] }.to_json ) unless @@cookie.nil?
when 'pace', 'question', 'cancel'