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'