require 'rubygems'
require 'sinatra/base'
require 'json'
require 'nokogiri'
require 'fileutils'
require 'logger'
require 'htmlentities'
require 'sinatra-websocket'
here = File.expand_path(File.dirname(__FILE__))
require "#{here}/showoff_utils"
require "#{here}/commandline_parser"
begin
require 'RMagick'
rescue LoadError
$stderr.puts 'WARN: image sizing disabled - install rmagick'
end
begin
require 'pdfkit'
rescue LoadError
$stderr.puts 'WARN: pdf generation disabled - install pdfkit'
end
require 'tilt'
class ShowOff < Sinatra::Application
attr_reader :cached_image_size
# Set up application variables
set :views, File.dirname(__FILE__) + '/../views'
set :public_folder, File.dirname(__FILE__) + '/../public'
set :statsdir, "stats"
set :viewstats, "viewstats.json"
set :feedback, "feedback.json"
set :server, 'thin'
set :sockets, []
set :presenters, []
set :verbose, false
set :pres_dir, '.'
set :pres_file, 'showoff.json'
set :page_size, "Letter"
set :pres_template, nil
set :showoff_config, {}
set :encoding, nil
FileUtils.mkdir settings.statsdir unless File.directory? settings.statsdir
# Page view time accumulator. Tracks how often slides are viewed by the audience
begin
@@counter = JSON.parse(File.read("#{settings.statsdir}/#{settings.viewstats}"))
rescue
@@counter = Hash.new
end
@@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
def initialize(app=nil)
super(app)
@logger = Logger.new(STDOUT)
@logger.formatter = proc { |severity,datetime,progname,msg| "#{progname} #{msg}\n" }
@logger.level = settings.verbose ? Logger::DEBUG : Logger::WARN
dir = File.expand_path(File.join(File.dirname(__FILE__), '..'))
@logger.debug(dir)
showoff_dir = File.expand_path(File.join(File.dirname(__FILE__), '..'))
settings.pres_dir ||= Dir.pwd
@root_path = "."
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)
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"]
settings.page_size = showoff_json["page-size"] || "Letter"
settings.pres_template = showoff_json["templates"]
end
@logger.debug settings.pres_template
@cached_image_size = {}
@logger.debug settings.pres_dir
@pres_name = settings.pres_dir.split('/').pop
require_ruby_files
# Default asset path
@asset_path = "./"
# Initialize Markdown Configuration
MarkdownConfig::setup(settings.pres_dir)
end
def self.pres_dir_current
opt = {:pres_dir => Dir.pwd}
ShowOff.set opt
end
def require_ruby_files
Dir.glob("#{settings.pres_dir}/*.rb").map { |path| require path }
end
helpers do
def load_section_files(section)
section = File.join(settings.pres_dir, section)
files = if File.directory? section
Dir.glob("#{section}/**/*").sort
else
[section]
end
@logger.debug files
files
end
def css_files
Dir.glob("#{settings.pres_dir}/*.css").map { |path| File.basename(path) }
end
def js_files
Dir.glob("#{settings.pres_dir}/*.js").map { |path| File.basename(path) }
end
def preshow_files
Dir.glob("#{settings.pres_dir}/_preshow/*").map { |path| File.basename(path) }.to_json
end
# todo: move more behavior into this class
class Slide
attr_reader :classes, :text, :tpl, :bg
def initialize( context = "")
@tpl = "default"
@classes = []
# Parse the context string for options and content classes
if context and context.match(/(\[(.*?)\])?(.*)/)
options = ShowOffUtils.parse_options($2)
@tpl = options["tpl"] if options["tpl"]
@bg = options["bg"] if options["bg"]
@classes += $3.strip.chomp('>').split if $3
end
@text = ""
end
def <<(s)
@text << s
@text << "\n"
end
def empty?
@text.strip == ""
end
end
def process_markdown(name, content, opts={:static=>false, :pdf=>false, :print=>false, :toc=>false, :supplemental=>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}"
@logger.debug "render options: #{engine_options.inspect}"
# if there are no !SLIDE markers, then make every H1 define a new slide
unless content =~ /^\!SLIDE/m
content = content.gsub(/^# /m, "\n# ")
end
# todo: unit test
lines = content.split("\n")
@logger.debug "#{name}: #{lines.length} lines"
slides = []
slides << (slide = Slide.new)
until lines.empty?
line = lines.shift
if line =~ /^!SLIDE(.*)>?/
ctx = $1 ? $1.strip : $1
slides << (slide = Slide.new(ctx))
else
slide << line
end
end
slides.delete_if {|slide| slide.empty? }
final = ''
if slides.size > 1
seq = 1
end
slides.each do |slide|
# update section counters before we reject slides so the numbering is consistent
if slide.classes.include? 'subsection'
@section_major += 1
@section_minor = 0
end
if opts[:supplemental]
# if we're looking for supplemental material, only include the content we want
next unless slide.classes.include? 'supplemental'
next unless slide.classes.include? opts[:supplemental]
else
# otherwise just skip all supplemental material completely
next if slide.classes.include? 'supplemental'
end
unless opts[:toc]
# just drop the slide if we're not generating a table of contents
next if slide.classes.include? 'toc'
end
if opts[:print]
# drop all slides not intended for the print version
next if slide.classes.include? 'noprint'
else
# drop slides that are intended for the print version only
next if slide.classes.include? 'printonly'
end
@slide_count += 1
content_classes = slide.classes
# extract transition, defaulting to none
transition = 'none'
content_classes.delete_if { |x| x =~ /^transition=(.+)/ && transition = $1 }
# extract id, defaulting to none
id = nil
content_classes.delete_if { |x| x =~ /^#([\w-]+)/ && id = $1 }
id = name unless id
@logger.debug "id: #{id}" if id
@logger.debug "classes: #{content_classes.inspect}"
@logger.debug "transition: #{transition}"
@logger.debug "tpl: #{slide.tpl} " if slide.tpl
@logger.debug "bg: #{slide.bg}" if slide.bg
template = "~~~CONTENT~~~"
# 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])
template = File.open(settings.pres_template[slide.tpl], "r").read()
end
end
# create html for the slide
classes = content_classes.join(' ')
content = "
"
# 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 += "
\n"
else
content += "
\n"
end
# 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 = update_p_classes(sl)
sl = process_content_for_section_tags(sl)
sl = update_special_content(sl, @slide_count, name) # TODO: deprecated
sl = update_image_paths(name, sl, opts)
content += sl
content += "
\n"
content += "
\n"
final += update_commandline_code(content)
if seq
seq += 1
end
end
final
end
# This method processes the content of the slide and replaces
# content markers with their actual value information
def process_content_for_replacements(content)
# update counters, incrementing section:minor if needed
result = content.gsub("~~~CURRENT_SLIDE~~~", @slide_count.to_s)
result.gsub!("~~~SECTION:MAJOR~~~", @section_major.to_s)
if result.include? "~~~SECTION:MINOR~~~"
@section_minor += 1
result.gsub!("~~~SECTION:MINOR~~~", @section_minor.to_s)
end
# scan for pagebreak tags. Should really only be used for handout notes or supplemental materials
result.gsub!("~~~PAGEBREAK~~~", '
continued...
')
# Now check for any kind of options
content.scan(/(~~~CONFIG:(.*?)~~~)/).each do |match|
result.gsub!(match[0], settings.showoff_config[match[1]]) if settings.showoff_config.key?(match[1])
end
# Load and replace any file tags
content.scan(/(~~~FILE:([^:]*):?(.*)?~~~)/).each do |match|
# get the file content and parse out html entities
file = HTMLEntities.new.encode(File.read(File.join(settings.pres_dir, '_files', match[1])))
# make a list of sh_highlight classes to include
css = match[2].split.collect {|i| "sh_#{i.downcase}" }.join(' ')
result.gsub!(match[0], "
#{file}
")
end
result
end
# replace section tags with classed div tags
def process_content_for_section_tags(content)
return unless content
# because this is post markdown rendering, we may need to shift a
tag around
# remove the tags if they're by themselves
result = content.gsub(/
~~~SECTION:([^~]*)~~~<\/p>/, '
')
result.gsub!(/
~~~ENDSECTION~~~<\/p>/, '
')
# shove it around the div if it belongs to the contained element
result.gsub!(/(
)?~~~SECTION:([^~]*)~~~/, '
\1')
result.gsub!(/~~~ENDSECTION~~~(<\/p>)?/, '\1
')
result
end
def process_content_for_all_slides(content, num_slides, opts={})
content.gsub!("~~~NUM_SLIDES~~~", num_slides.to_s)
# 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)
Nokogiri::HTML(content).css('div.subsection > h1').each do |section|
entry = Nokogiri::XML::Node.new('div', frag)
entry['class'] = 'tocentry'
toc.add_child(entry)
link = Nokogiri::XML::Node.new('a', frag)
link['href'] = "##{section.parent.parent['id']}"
link.content = section.content
entry.add_child(link)
end
# swap out the tag, if found, with the table of contents
content.gsub!("~~~TOC~~~", frag.to_html)
end
content
end
# find any lines that start with a
.(something) and turn them into
def update_p_classes(markdown)
markdown.gsub(/
\.(.*?) /, '
')
end
# TODO: deprecated
def update_special_content(content, seq, name)
doc = Nokogiri::HTML::DocumentFragment.parse(content)
%w[notes handouts instructor solguide].each { |mark| update_special_content_mark(doc, mark) }
update_download_links(doc, seq, name)
# TODO: what the bloody hell. Figure out how to either make Nokogiri output closed
# tags or figure out how to get its XML output to quit adding gratuitious spaces.
doc.to_html.gsub(/(]*)>/, '\1 />')
end
# TODO: deprecated
def update_special_content_mark(doc, mark)
container = doc.css("p.#{mark}").first
return unless container
@logger.warn "Special mark (#{mark}) is deprecated. Please replace with section tags. See the README for details."
# only allow localhost to print the instructor guide
if mark == 'instructor' and request.env['REMOTE_HOST'] != 'localhost'
container.remove
else
raw = container.inner_html
fixed = raw.gsub(/^\.#{mark} ?/, '')
markdown = Tilt[:markdown].new { fixed }.render
container.name = 'div'
container.inner_html = markdown
end
end
private :update_special_content_mark
def update_download_links(doc, seq, name)
container = doc.css("p.download").first
return unless container
raw = container.text
fixed = raw.gsub(/^\.download ?/, '')
# first create the data structure
# [ enabled, slide name, [array, of, files] ]
@@downloads[seq] = [ false, name, [] ]
fixed.split("\n").each { |file|
# then push each file onto the list
@@downloads[seq][2].push(file.strip)
}
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}")
if w && h
src << %( width="#{w}" height="#{h}")
end
src
end
end
if defined?(Magick)
def get_image_size(path)
if !cached_image_size.key?(path)
img = Magick::Image.ping(path).first
# don't set a size for svgs so they can expand to fit their container
if img.mime_type == 'image/svg+xml'
cached_image_size[path] = [nil, nil]
else
cached_image_size[path] = [img.columns, img.rows]
end
end
cached_image_size[path]
end
else
def get_image_size(path)
end
end
def update_commandline_code(slide)
html = Nokogiri::XML.parse(slide)
parser = CommandlineParser.new
html.css('pre').each do |pre|
pre.css('code').each do |code|
out = code.text
lines = out.split("\n")
if lines.first.strip[0, 3] == '@@@'
lang = lines.shift.gsub('@@@', '').strip
pre.set_attribute('class', 'sh_' + lang.downcase) if !lang.empty?
code.content = lines.join("\n")
end
end
end
html.css('.commandline > pre > code').each do |code|
out = code.text
code.content = ''
tree = parser.parse(out)
transform = Parslet::Transform.new do
rule(:prompt => simple(:prompt), :input => simple(:input), :output => simple(:output)) do
command = Nokogiri::XML::Node.new('code', html)
command.set_attribute('class', 'command')
command.content = "#{prompt} #{input}"
code << command
# Add newline after the input so that users can
# advance faster than the typewriter effect
# and still keep inputs on separate lines.
code << "\n"
unless output.to_s.empty?
result = Nokogiri::XML::Node.new('code', html)
result.set_attribute('class', 'result')
result.content = output
code << result
end
end
end
transform.apply(tree)
end
html.root.to_s
end
def get_slides_html(opts={:static=>false, :pdf=>false, :toc=>false, :supplemental=>nil})
@slide_count = 0
@section_major = 0
@section_minor = 0
sections = ShowOffUtils.showoff_sections(settings.pres_dir, @logger)
files = []
if sections
data = ''
sections.each do |section|
if section =~ /^#/
name = section.each_line.first.gsub(/^#*/,'').strip
data << process_markdown(name, "\n" + section, opts)
else
files = []
files << load_section_files(section)
files = files.flatten
files = files.select { |f| f =~ /.md$/ }
files.each do |f|
fname = f.gsub(settings.pres_dir + '/', '').gsub('.md', '')
data << process_markdown(fname, File.read(f), opts)
end
end
end
end
process_content_for_all_slides(data, @slide_count, opts)
end
def inline_css(csses, pre = nil)
css_content = ''
css_content
end
def inline_js(jses, pre = nil)
js_content = ''
js_content
end
def inline_all_js(jses_directory)
inline_js(Dir.entries(File.join(File.dirname(__FILE__), '..', jses_directory)).find_all{|filename| filename.length > 2 }, jses_directory)
end
def index(static=false)
if static
@title = ShowOffUtils.showoff_title(settings.pres_dir)
@slides = get_slides_html(:static=>static)
@pause_msg = ShowOffUtils.pause_msg
# Identify which languages to bundle for highlighting
@languages = @slides.scan(/
" + slides + "")
html.css('img').each do |link|
href = clean_link(link['src'])
assets << href if href
end
css = Dir.glob("#{settings.public_folder}/**/*.css").map { |path| path.gsub(settings.public_folder + '/', '') }
assets << css
js = Dir.glob("#{settings.public_folder}/**/*.js").map { |path| path.gsub(settings.public_folder + '/', '') }
assets << js
assets.uniq.join("\n")
end
def slides(static=false)
get_slides_html(:static=>static)
end
def onepage(static=false)
@slides = get_slides_html(:static=>static, :toc=>true)
#@languages = @slides.scan(/static, :toc=>true, :print=>true)
erb :onepage
end
def supplemental(content, static=false)
@slides = get_slides_html(:static=>static, :supplemental=>content)
@wrapper_classes = ['supplemental']
erb :onepage
end
def download()
begin
shared = Dir.glob("#{settings.pres_dir}/_files/share/*").map { |path| File.basename(path) }
# We use the icky -999 magic index because it has to be comparable for the view sort
@downloads = { -999 => [ true, 'Shared Files', shared ] }
rescue Errno::ENOENT => e
# don't fail if the directory doesn't exist
@downloads = {}
end
@downloads.merge! @@downloads
erb :download
end
def stats()
if request.env['REMOTE_HOST'] == 'localhost'
# the presenter should have full stats
@counter = @@counter
end
@all = Hash.new
@@counter.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(static=true)
@slides = get_slides_html(:static=>static, :pdf=>true)
@inline = true
# Identify which languages to bundle for highlighting
@languages = @slides.scan(/ e
e.message
end
# Basic auth boilerplate
def protected!
unless authorized?
response['WWW-Authenticate'] = %(Basic realm="#{@title}: Protected Area")
throw(:halt, [401, "Not authorized\n"])
end
end
def authorized?
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'
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]
end
end
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
(request.cookies['presenter'] == @@cookie)
end
get '/eval_ruby' do
return eval_ruby(params[:code]) if ENV['SHOWOFF_EVAL_RUBY']
return "Ruby Evaluation is off. To turn it on set ENV['SHOWOFF_EVAL_RUBY']"
end
get %r{(?:image|file)/(.*)} do
path = params[:captures].first
full_path = File.join(settings.pres_dir, path)
if File.exist?(full_path)
send_file full_path
else
raise Sinatra::NotFound
end
end
get '/control' do
if !request.websocket?
raise Sinatra::NotFound
else
request.websocket do |ws|
ws.onopen do
ws.send( { 'current' => @@current[:number] }.to_json )
settings.sockets << ws
@logger.warn "Open sockets: #{settings.sockets.size}"
end
ws.onmessage do |data|
begin
control = JSON.parse(data)
@logger.warn "#{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()
name = control['name']
slide = control['slide'].to_i
# check to see if we need to enable a download link
if @@downloads.has_key?(slide)
@logger.debug "Enabling file download for slide #{name}"
@@downloads[slide][0] = true
end
# 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) } }
end
when 'register'
# save a list of presenters
if valid_cookie()
remote = request.env['REMOTE_HOST'] || request.env['REMOTE_ADDR']
settings.presenters << ws
@logger.warn "Registered new presenter: #{remote}"
end
when 'track'
remote = request.env['REMOTE_HOST'] || request.env['REMOTE_ADDR']
slide = control['slide']
time = control['time'].to_f
@logger.debug "Logged #{time} on slide #{slide} for #{remote}"
# a bucket for this slide
@@counter[slide] ||= Hash.new
# a bucket of slideviews for this address
@@counter[slide][remote] ||= Array.new
# and add this slide viewing to the bucket
@@counter[slide][remote] << { 'elapsed' => time, 'timestamp' => Time.now.to_i, 'presenter' => @@current[:name] }
when 'position'
ws.send( { 'current' => @@current[:number] }.to_json ) unless @@cookie.nil?
when 'pace', 'question'
# just forward to the presenter(s)
EM.next_tick { settings.presenters.each{|s| s.send(data) } }
when 'feedback'
filename = "#{settings.statsdir}/#{settings.feedback}"
slide = control['slide']
rating = control['rating']
feedback = control['feedback']
begin
log = JSON.parse(File.read(filename))
rescue
# do nothing
end
log ||= Hash.new
log[slide] ||= Array.new
log[slide] << { :rating => rating, :feedback => feedback }
if settings.verbose then
File.write(filename, JSON.pretty_generate(log))
else
File.write(filename, log.to_json)
end
else
@logger.warn "Unknown message <#{control['message']}> received."
@logger.warn control.inspect
end
rescue Exception => e
@logger.warn "Messaging error: #{e}"
end
end
ws.onclose do
@logger.warn("websocket closed")
settings.sockets.delete(ws)
end
end
end
end
# gawd, this whole routing scheme is bollocks
get %r{/([^/]*)/?([^/]*)} do
@title = ShowOffUtils.showoff_title(settings.pres_dir)
@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
end
# this hasn't been set to anything remotely interesting for a long time now
@asset_path = nil
begin
if (what != "favicon.ico")
if what == 'supplemental'
data = send(what, opt)
else
data = send(what)
end
if data.is_a?(File)
send_file data.path
else
data
end
end
rescue NoMethodError => e
@logger.warn "Invalid object #{what} requested."
raise Sinatra::NotFound
end
end
not_found do
# Why does the asset path start from cwd??
@asset_path.slice!(/^./)
@env = request.env
erb :'404'
end
at_exit do
if defined?(@@counter)
filename = "#{settings.statsdir}/#{settings.viewstats}"
if settings.verbose then
File.write(filename, JSON.pretty_generate(@@counter))
else
File.write(filename, @@counter.to_json)
end
end
end
end