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