lib/showoff.rb in showoff-0.12.0 vs lib/showoff.rb in showoff-0.12.1

- old
+ new

@@ -1,10 +1,11 @@ require 'rubygems' require 'sinatra/base' require 'json' require 'nokogiri' require 'fileutils' +require 'pathname' require 'logger' require 'htmlentities' require 'sinatra-websocket' here = File.expand_path(File.dirname(__FILE__)) @@ -12,10 +13,19 @@ require "#{here}/commandline_parser" require "#{here}/keymap" begin require 'rmagick' + puts "********************************************************************************" + puts " RMagick support has been deprecated." + puts + puts "CSS auto-scaling has improved greatly, and the image manipulation should no" + puts "longer be required. If you have images that don't scale properly, then you" + puts "should write custom styles to size them appropriately." + puts + puts " RMagic support will be removed completely in the next release." + puts "********************************************************************************" rescue LoadError # nop end begin @@ -74,18 +84,22 @@ # Load up the default keymap, then merge in any customizations keymapfile = File.expand_path(File.join('~', '.showoff', 'keymap.json')) rescue nil @keymap = Keymap.default @keymap.merge! JSON.parse(File.read(keymapfile)) rescue {} + # map keys to the labels we're using + @keycode_dictionary = Keymap.keycodeDictionary + @keycode_shifted_keys = Keymap.shiftedKeyDictionary + settings.pres_dir = File.expand_path(settings.pres_dir) if (settings.pres_file) ShowOffUtils.presentation_config_file = settings.pres_file end # Load configuration for page size and template from the # configuration JSON file - if File.exists?(ShowOffUtils.presentation_config_file) + if File.exist?(ShowOffUtils.presentation_config_file) showoff_json = JSON.parse(File.read(ShowOffUtils.presentation_config_file)) settings.showoff_config = showoff_json # Set options for encoding, template and page size settings.encoding = showoff_json["encoding"] @@ -94,10 +108,28 @@ end # code execution timeout settings.showoff_config['timeout'] ||= 15 + # If favicon in presentation root, use it by default + if File.exist? 'favicon.ico' + settings.showoff_config['favicon'] ||= 'file/favicon.ico' + end + + # default protection levels + if settings.showoff_config.has_key? 'password' + settings.showoff_config['protected'] ||= ["presenter", "onepage", "print"] + else + settings.showoff_config['protected'] ||= Array.new + end + + if settings.showoff_config.has_key? 'key' + settings.showoff_config['locked'] ||= ["slides"] + else + settings.showoff_config['locked'] ||= Array.new + end + # highlightjs syntax style @highlightStyle = settings.showoff_config['highlight'] || 'default' # variables used for building section numbering and title @slide_count = 0 @@ -113,12 +145,15 @@ require_ruby_files # Default asset path @asset_path = "./" + # invert the logic to maintain backwards compatibility of interactivity on by default + @interactive = ! settings.standalone rescue false + # Create stats directory - FileUtils.mkdir settings.statsdir unless File.directory? settings.statsdir + FileUtils.mkdir settings.statsdir unless File.directory? settings.statsdir if @interactive # Page view time accumulator. Tracks how often slides are viewed by the audience begin @@counter = JSON.parse(File.read("#{settings.statsdir}/#{settings.viewstats}")) @@ -140,42 +175,47 @@ @@downloads = Hash.new # Track downloadable files @@cookie = nil # presenter cookie. Identifies the presenter for control messages @@current = Hash.new # The current slide that the presenter is viewing @@cache = nil # Cache slide content for subsequent hits - # flush stats to disk periodically - Thread.new do - loop do - sleep 30 - ShowOff.flush + if @interactive + # flush stats to disk periodically + Thread.new do + loop do + sleep 30 + ShowOff.flush + end end end # Initialize Markdown Configuration MarkdownConfig::setup(settings.pres_dir) end # save stats to disk def self.flush - if defined?(@@counter) and not @@counter.empty? - File.open("#{settings.statsdir}/#{settings.viewstats}", 'w') do |f| - if settings.verbose then - f.write(JSON.pretty_generate(@@counter)) - else - f.write(@@counter.to_json) + begin + if defined?(@@counter) and not @@counter.empty? + File.open("#{settings.statsdir}/#{settings.viewstats}", 'w') do |f| + if settings.verbose then + f.write(JSON.pretty_generate(@@counter)) + else + f.write(@@counter.to_json) + end end end - end - if defined?(@@forms) and not @@forms.empty? - File.open("#{settings.statsdir}/#{settings.forms}", 'w') do |f| - if settings.verbose then - f.write(JSON.pretty_generate(@@forms)) - else - f.write(@@forms.to_json) + if defined?(@@forms) and not @@forms.empty? + File.open("#{settings.statsdir}/#{settings.forms}", 'w') do |f| + if settings.verbose then + f.write(JSON.pretty_generate(@@forms)) + else + f.write(@@forms.to_json) + end end end + rescue Errno::ENOENT => e end end def self.pres_dir_current opt = {:pres_dir => Dir.pwd} @@ -246,11 +286,11 @@ def empty? @text.strip == "" end end - def process_markdown(name, content, opts={:static=>false, :pdf=>false, :print=>false, :toc=>false, :supplemental=>nil}) + def process_markdown(name, content, opts={:static=>false, :pdf=>false, :print=>false, :toc=>false, :supplemental=>nil, :section=>nil}) if settings.encoding and content.respond_to?(:force_encoding) content.force_encoding(settings.encoding) end engine_options = ShowOffUtils.showoff_renderer_options(settings.pres_dir) @logger.debug "renderer: #{Tilt[:markdown].name}" @@ -335,11 +375,11 @@ # Template handling if settings.pres_template # We allow specifying a new template even when default is # not given. if settings.pres_template.include?(slide.tpl) and - File.exists?(settings.pres_template[slide.tpl]) + File.exist?(settings.pres_template[slide.tpl]) template = File.open(settings.pres_template[slide.tpl], "r").read() end end # create html for the slide @@ -350,11 +390,11 @@ 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" + content += "<div class=\"content #{classes}\" ref=\"#{name}:#{seq.to_s}\">\n" else content += "<div class=\"content #{classes}\" ref=\"#{name}\">\n" end # renderers like wkhtmltopdf needs an <h1> tag to use for a section title, but only when printing. @@ -371,11 +411,11 @@ # Apply the template to the slide and replace the key to generate the content of the slide sl = process_content_for_replacements(template.gsub(/~~~CONTENT~~~/, slide.text)) sl = Tilt[:markdown].new(nil, nil, engine_options) { sl }.render sl = build_forms(sl, content_classes) sl = update_p_classes(sl) - sl = process_content_for_section_tags(sl, name) + sl = process_content_for_section_tags(sl, name, opts) sl = update_special_content(sl, @slide_count, name) # TODO: deprecated sl = update_image_paths(name, sl, opts) content += sl content += "</div>\n" @@ -428,34 +468,40 @@ result end # replace section tags with classed div tags - def process_content_for_section_tags(content, name = nil) + def process_content_for_section_tags(content, name = nil, opts = {}) return unless content # because this is post markdown rendering, we may need to shift a <p> tag around # remove the tags if they're by themselves - result = content.gsub(/<p>~~~SECTION:([^~]*)~~~<\/p>/, '<div class="\1">') + result = content.gsub(/<p>~~~SECTION:([^~]*)~~~<\/p>/, '<div class="notes-section \1">') result.gsub!(/<p>~~~ENDSECTION~~~<\/p>/, '</div>') # shove it around the div if it belongs to the contained element - result.gsub!(/(<p>)?~~~SECTION:([^~]*)~~~/, '<div class="\2">\1') + result.gsub!(/(<p>)?~~~SECTION:([^~]*)~~~/, '<div class="notes-section \2">\1') 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 File.file? 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) # Make sure we've got a notes div to hang personal notes from - doc.add_child '<div class="notes"></div>' if doc.css('div.notes').empty? - doc.css('div.notes').each do |section| + doc.add_child '<div class="notes-section notes"></div>' if doc.css('div.notes-section.notes').empty? + doc.css('div.notes-section.notes').each do |section| text = Tilt[:markdown].new(nil, nil, engine_options) { File.read(filename) }.render frag = "<div class=\"personal\"><h1>Personal Notes</h1>#{text}</div>" note = Nokogiri::HTML::DocumentFragment.parse(frag) if section.children.size > 0 @@ -620,11 +666,11 @@ when /^ +(\w+) -> (.+),?$/ # NYC -> New, York City str << "<option value='#{$1}'>#{$2}</option>" when /^ +\((.+)\)$/ # (Boston) str << "<option value='#{$1}' selected>#{$1}</option>" when /^ +\[(.+)\]$/ # [Boston] - str << "<option value='#{$1}' selected>#{$1}</option>" + str << "<option value='#{$1}' class='correct'>#{$1}</option>" when /^ +([^\(].+[^\),]),?$/ # Boston str << "<option value='#{$1}'>#{$1}</option>" end end str << '</select>' @@ -744,25 +790,36 @@ container.remove end private :update_download_links def update_image_paths(path, slide, opts={:static=>false, :pdf=>false}) - paths = path.split('/') - paths.pop - path = paths.join('/') - replacement_prefix = opts[:static] ? - ( opts[:pdf] ? %(img src="file://#{settings.pres_dir}/#{path}) : %(img src="./file/#{path}) ) : - %(img src="#{@asset_path}image/#{path}) - slide.gsub(/img src=[\"\'](?!https?:\/\/)([^\/].*?)[\"\']/) do |s| - img_path = File.join(path, $1) - w, h = get_image_size(img_path) - src = %(#{replacement_prefix}/#{$1}") + doc = Nokogiri::HTML::DocumentFragment.parse(slide) + slide_dir = File.dirname(path) + + case + when opts[:static] && opts[:pdf] + replacement_prefix = "file://#{settings.pres_dir}/" + when opts[:static] + replacement_prefix = "./file/" + else + replacement_prefix = "#{@asset_path}image/" + end + + doc.css('img').each do |img| + # clean up the path and remove some of the relative nonsense + img_path = Pathname.new(File.join(slide_dir, img[:src])).cleanpath.to_path + src = "#{replacement_prefix}/#{img_path}" + img[:src] = src + + # TDOD: deprecated and to be removed + w, h = get_image_size(img_path) if w && h - src << %( width="#{w}" height="#{h}") + img[:width] = w + img[:height] = h end - src end + doc.to_html end if defined?(Magick) def get_image_size(path) if !cached_image_size.key?(path) @@ -830,11 +887,11 @@ transform.apply(tree) end html.to_html end - def get_slides_html(opts={:static=>false, :pdf=>false, :toc=>false, :supplemental=>nil}) + def get_slides_html(opts={:static=>false, :pdf=>false, :toc=>false, :supplemental=>nil, :section=>nil}) sections = ShowOffUtils.showoff_sections(settings.pres_dir, @logger) files = [] if sections data = '' @@ -850,11 +907,11 @@ files.each do |f| fname = f.gsub(settings.pres_dir + '/', '').gsub('.md', '') begin data << process_markdown(fname, File.read(f), opts) rescue Errno::ENOENT => e - logger.error e.message + @logger.error e.message data << process_markdown(fname, "!SLIDE\n# Missing File!\n## #{fname}", opts) end end end end @@ -913,17 +970,21 @@ @favicon = settings.showoff_config['favicon'] # Check to see if the presentation has enabled feedback @feedback = settings.showoff_config['feedback'] unless (params && params[:feedback] == 'false') + # If we're static, we need to not show the downloads page + @static = static + # Provide a button in the sidebar for interactive editing if configured @edit = settings.showoff_config['edit'] if @review erb :index end def presenter + @favicon = settings.showoff_config['favicon'] @issues = settings.showoff_config['issues'] @edit = settings.showoff_config['edit'] if @review @@cookie ||= guid() response.set_cookie('presenter', @@cookie) erb :presenter @@ -974,18 +1035,19 @@ # allow command line cache disabling @@cache = content unless settings.nocache content end - def print(static=false) - @slides = get_slides_html(:static=>static, :toc=>true, :print=>true) + def print(static=false, section=nil) + @slides = get_slides_html(:static=>static, :toc=>true, :print=>true, :section=>section) @favicon = settings.showoff_config['favicon'] erb :onepage end def supplemental(content, static=false) - @slides = get_slides_html(:static=>static, :supplemental=>content) + # supplemental material is by definition separate from the presentation, so it doesn't make sense to attach notes + @slides = get_slides_html(:static=>static, :supplemental=>content, :section=>false) @favicon = settings.showoff_config['favicon'] @wrapper_classes = ['supplemental'] erb :onepage end @@ -1035,12 +1097,13 @@ html.gsub!(/url\([\"\']?(?!https?:\/\/)(.*?)[\"\']?\)/) do |s| "url(file://#{settings.pres_dir}/#{$1})" end # remove the weird /files component, since that doesn't exist on the filesystem + # replace it for file://<PATH> for correct use with wkhtmltopdf (exactly with qt-webkit) html.gsub!(/<img src=".\/file\/([^"]*)/) do |s| - "<img src=\".\/#{$1}" + "<img src=\"file:\/\/#{settings.pres_dir}\/#{$1}" end # PDFKit.new takes the HTML and any options for wkhtmltopdf # run `wkhtmltopdf --extended-help` for a full list of options kit = PDFKit.new(html, ShowOffUtils.showoff_pdf_options(settings.pres_dir)) @@ -1069,10 +1132,13 @@ when 'supplemental' data = showoff.send(what, opt, true) when 'pdf' opt ||= "#{name}.pdf" data = showoff.send(what, opt) + when 'print' + opt ||= 'handouts' + data = showoff.send(what, true, opt) else data = showoff.send(what, true) end if data.is_a?(File) @@ -1107,11 +1173,11 @@ Dir.glob("#{pres_dir}/*.{css,js}").each { |path| FileUtils.copy(path, File.join(file_dir, File.basename(path))) } # ... and copy all needed image files - [/img src=[\"\'].\/file\/(.*?)[\"\']/, /style=[\"\']background: url\(\'file\/(.*?)'/].each do |regex| + [/img src=[\"\'].\/file\/(.*?)[\"\']/, /style=[\"\']background(?:-image): url\(\'file\/(.*?)'/].each do |regex| data.scan(regex).flatten.each do |path| dir = File.dirname(path) FileUtils.makedirs(File.join(file_dir, dir)) begin FileUtils.copy(File.join(pres_dir, path), File.join(file_dir, path)) @@ -1141,39 +1207,86 @@ end end # Load a slide file from disk, parse it and return the text of a code block by index def get_code_from_slide(path, index) + if path =~ /^(.*)(?::)(\d+)$/ + path = $1 + num = $2.to_i + else + num = 1 + end + slide = "#{path}.md" - return unless File.exists? slide + return unless File.exist? slide - html = process_markdown(slide, File.read(slide), {}) + content = File.read(slide) + if defined? num + content = content.split(/^\<?!SLIDE/m).reject { |sl| sl.empty? }[num-1] + end + + html = process_markdown(slide, content, {}) doc = Nokogiri::HTML::DocumentFragment.parse(html) return doc.css('code.execute')[index.to_i].text rescue 'Invalid code block index' end # Basic auth boilerplate def protected! unless authorized? - response['WWW-Authenticate'] = %(Basic realm="#{@title}: Protected Area") - throw(:halt, [401, "Not authorized\n"]) + response['WWW-Authenticate'] = %(Basic realm="#{@title}: Protected Area. Please log in.") + throw(:halt, [401, "Not authorized."]) end end + def locked! + # check auth first, because if the presenter has logged in with a password, we don't want to prompt again + unless authorized? or unlocked? + response['WWW-Authenticate'] = %(Basic realm="#{@title}: Locked Area. A presentation key is required to view.") + throw(:halt, [401, "Not authorized."]) + end + end + def authorized? + # allow localhost if we have no password if not settings.showoff_config.has_key? 'password' - # if no password is set, then default to allowing access to localhost - request.env['REMOTE_HOST'] == 'localhost' or request.ip == '127.0.0.1' + localhost? else - auth ||= Rack::Auth::Basic::Request.new(request.env) user = settings.showoff_config['user'] || '' password = settings.showoff_config['password'] - auth.provided? && auth.basic? && auth.credentials && auth.credentials == [user, password] + authenticate([user, password]) end end + def unlocked? + # allow localhost if we have no key + if not settings.showoff_config.has_key? 'key' + localhost? + else + authenticate(settings.showoff_config['key']) + end + end + + def localhost? + request.env['REMOTE_HOST'] == 'localhost' or request.ip == '127.0.0.1' + end + + def authenticate(credentials) + auth = Rack::Auth::Basic::Request.new(request.env) + + return false unless auth.provided? && auth.basic? && auth.credentials + + case credentials + when Array + auth.credentials == credentials + when String + auth.credentials.last == credentials + else + false + end + end + def guid # this is a terrifyingly simple GUID generator (0..15).to_a.map{|a| rand(16).to_s(16)}.join end @@ -1287,16 +1400,19 @@ raise Sinatra::NotFound end end get '/control' do + # leave the route so we don't have 404's for the parts we've missed + return nil unless @interactive + if !request.websocket? raise Sinatra::NotFound else request.websocket do |ws| ws.onopen do - ws.send( { 'current' => @@current[:number] }.to_json ) + ws.send( { 'message' => 'current', 'current' => @@current[:number] }.to_json ) settings.sockets << ws @logger.warn "Open sockets: #{settings.sockets.size}" end ws.onmessage do |data| @@ -1322,11 +1438,11 @@ # update the current slide pointer @logger.debug "Updated current slide to #{name}" @@current = { :name => name, :number => slide } # schedule a notification for all clients - EM.next_tick { settings.sockets.each{|s| s.send({ 'current' => @@current[:number] }.to_json) } } + EM.next_tick { settings.sockets.each{|s| s.send({ 'message' => 'current', 'current' => @@current[:number] }.to_json) } } end when 'register' # save a list of presenters if valid_cookie() @@ -1354,15 +1470,18 @@ @logger.debug "Logged #{time} on slide #{slide} for #{remote}" when 'position' ws.send( { 'current' => @@current[:number] }.to_json ) unless @@cookie.nil? - when 'pace', 'question' + when 'pace', 'question', 'cancel' # just forward to the presenter(s) along with a debounce in case a presenter is registered twice control['id'] = guid() EM.next_tick { settings.presenters.each{|s| s.send(control.to_json) } } + when 'complete' + EM.next_tick { settings.sockets.each{|s| s.send(control.to_json) } } + when 'feedback' filename = "#{settings.statsdir}/#{settings.feedback}" slide = control['slide'] rating = control['rating'] feedback = control['feedback'] @@ -1406,11 +1525,13 @@ @pause_msg = ShowOffUtils.pause_msg what = params[:captures].first opt = params[:captures][1] what = 'index' if "" == what - if settings.showoff_config.has_key? 'protected' - protected! if settings.showoff_config['protected'].include? what + if settings.showoff_config['protected'].include? what + protected! + elsif settings.showoff_config['locked'].include? what + locked! end @asset_path = env['SCRIPT_NAME'] == '' ? nil : env['SCRIPT_NAME'].gsub(/^\/?/, '/').gsub(/\/?$/, '/') begin