require 'rubygems' require 'sinatra/base' require 'json' require 'nokogiri' require 'fileutils' require 'pathname' require 'logger' require 'htmlentities' require 'sinatra-websocket' require 'tempfile' require 'i18n' require 'i18n/backend/fallbacks' require 'rack' require 'rack/contrib' require 'iso-639' here = File.expand_path(File.dirname(__FILE__)) require "#{here}/showoff_utils" require "#{here}/commandline_parser" require "#{here}/keymap" begin require 'pdfkit' rescue LoadError # nop 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 :forms, "forms.json" set :server, 'thin' set :sockets, [] set :presenters, [] set :verbose, false set :review, false set :execute, false set :nocache, false set :pres_dir, '.' set :pres_file, 'showoff.json' set :page_size, "Letter" set :pres_template, nil set :showoff_config, {} set :encoding, nil set :url, nil # automatically select the translation based on the user's configured browser language use Rack::Locale configure do I18n::Backend::Simple.send(:include, I18n::Backend::Fallbacks) I18n.load_path += Dir[File.join(settings.root, '..', 'locales', '*.yml')] I18n.backend.load_translations I18n.enforce_available_locales = false end 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 @review = settings.review @execute = settings.execute settings.pres_dir ||= Dir.pwd @root_path = "." # 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 and settings.pres_file != 'showoff.json') ShowOffUtils.presentation_config_file = settings.pres_file end # Load configuration for page size and template from the # configuration JSON 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"] || 'UTF-8' settings.page_size = showoff_json["page-size"] || "Letter" settings.pres_template = showoff_json["templates"] 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 # default code parsers (for executable code blocks) settings.showoff_config['parsers'] ||= {} settings.showoff_config['parsers']['perl'] ||= 'perl' settings.showoff_config['parsers']['puppet'] ||= 'puppet apply --color=false' settings.showoff_config['parsers']['python'] ||= 'python' settings.showoff_config['parsers']['ruby'] ||= 'ruby' settings.showoff_config['parsers']['shell'] ||= 'sh' # default code validators settings.showoff_config['validators'] ||= {} settings.showoff_config['validators']['perl'] ||= 'perl -cw' settings.showoff_config['validators']['puppet'] ||= 'puppet parser validate' settings.showoff_config['validators']['python'] ||= 'python -m py_compile' settings.showoff_config['validators']['ruby'] ||= 'ruby -c' settings.showoff_config['validators']['shell'] ||= 'sh -n' # highlightjs syntax style @highlightStyle = settings.showoff_config['highlight'] || 'default' # variables used for building section numbering and title @slide_count = 0 @section_major = 0 @section_minor = 0 @section_title = settings.showoff_config['name'] rescue I18n.t('name') @@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 @pres_name = settings.pres_dir.split('/').pop 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 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}")) # TODO: remove this logic 4/15/2017: port old format stats unless @@counter.has_key? 'user_agents' @@counter['pageviews'] = @@counter end @@counter['current'] ||= {} @@counter['pageviews'] ||= {} @@counter['user_agents'] ||= {} rescue @@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}")) rescue @@forms = Hash.new end @@downloads = Hash.new # Track downloadable files @@cookie = nil # presenter cookie. Identifies the presenter for control messages @@master = nil # this holds the @client_id of the master presenter, for the cases in which multiple presenters are loaded @@current = Hash.new # The current slide that the presenter is viewing @@cache = Hash.new # Cache slide content for subsequent hits @@activity = [] # keep track of completion for activity slides 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) # Process renderer config options @engine_options = ShowOffUtils.showoff_renderer_options(settings.pres_dir) end # save stats to disk def self.flush 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 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} ShowOff.set opt end def require_ruby_files Dir.glob("#{settings.pres_dir}/*.rb").map { |path| require path } end helpers do 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 # return a list of keys associated with a given action in the keymap def mapped_keys(action, klass='key') list = @keymap.select { |key,value| value == action }.keys if klass list.map { |val| "#{val}" }.join else list.join ', ' end end # This is just a unified lookup method that takes a full locale name # and then resolves it to an available version of the name def with_locale(locale) locale = locale.to_s until (locale.empty?) do result = yield(locale) return result unless result.nil? # if not found, chop off a section and try again locale = locale.rpartition(/[-_]/).first end end # turns a locale code into a string name def get_language_name(locale) with_locale(locale) do |str| result = ISO_639.find(str) result[3] unless result.nil? end end # This function returns the directory containing translated *content*, defaulting # to cwd. This works similarly to I18n fallback, but we cannot reuse that as it's # a different translation mechanism. def get_locale_dir(prefix, locale) return '.' if locale == 'disable' with_locale(locale) do |str| path = "#{prefix}/#{str}" return path if File.directory?(path) end || '.' end # return a hash of all language codes available and the long name description of each def language_names Dir.glob('locales/*').inject({}) do |memo, entry| next memo unless File.directory? entry locale = File.basename(entry) memo.update(locale => get_language_name(locale)) end end # returns the minimized canonical version of the current selected content locale # it assumes that if the user has specified a locale, that it's already minimized # note: if the locale doesn't exist on disk, it will just default to no translation def locale(user_locale) if [nil, '', 'auto'].include? user_locale languages = I18n.available_locales I18n.fallbacks[I18n.locale].select { |f| languages.include? f }.first else user_locale end end # returns a hash of all translations for the current language. This is used # for the javascript half of the translations def get_translations languages = I18n.backend.send(:translations) fallback = I18n.fallbacks[I18n.locale].select { |f| languages.keys.include? f }.first languages[fallback] end # Finds the language key from strings.json and returns the strings hash. This is # used for user translations in the presentation, e.g. SVG translations. def user_translations return {} unless File.file? 'locales/strings.json' strings = JSON.parse(File.read('locales/strings.json')) rescue {} with_locale(@locale) do |key| return strings[key] if strings.include? key end {} 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, section, 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 @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 =~ /^\\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 =~ /^?/ ctx = $1 ? $1.strip : $1 slides << (slide = Slide.new(ctx)) else slide << line end end slides.delete_if {|slide| slide.empty? and not slide.bg } 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.dup unless id id.gsub!(/[^-A-Za-z0-9_]/, '_') # valid HTML id characters id << seq.to_s if seq @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.exist?(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 ref = seq ? "#{name}:#{seq.to_s}" : name content += "
\n" @@slide_titles << ref # renderers like wkhtmltopdf needs an

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. if content_classes.include? 'subsection' @section_title = slide.text.match(/#+ *(.*?)#*$/)[1] rescue settings.showoff_config['name'] end # include a header that's hidden by CSS the renderer can use it, but not be visible content += "

#{@section_title}

\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 = build_forms(sl, content_classes) sl = update_p_classes(sl) 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 += "
\n" if content_classes.include? 'activity' content += '' content += " " content += " " content += '' end content += "\n" content += "
\n" content = final_slide_fixup(content) 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...
') # replace with form rendering placeholder result.gsub!(/~~~FORM:([^~]*)~~~/, '
') # 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| # make a list of code highlighting classes to include css = match[2].split.collect {|i| "language-#{i.downcase}" }.join(' ') # get the file content and parse out html entities name = match[1] file = File.read(File.join(settings.pres_dir, '_files', name)) rescue "Nonexistent file: #{name}" file = "Empty file: #{name}" if file.empty? file = HTMLEntities.new.encode(file) rescue "HTML parsing of #{name} failed" result.gsub!(match[0], "
#{file}
") end result.gsub!(/\[(fa-.*)\]/, '') result end # replace section tags with classed div tags 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

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
') # Turn this into a document for munging doc = Nokogiri::HTML::DocumentFragment.parse(result) 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 # Make sure we've got a notes div to hang personal notes from doc.add_child '
' 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 = "

#{I18n.t('presenter.notes.personal')}

#{text}
" note = Nokogiri::HTML::DocumentFragment.parse(frag) if section.children.size > 0 section.children.before(note) else section.add_child(note) end end end 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 = "#{$1}" item.children.before(Nokogiri::HTML::DocumentFragment.parse(frag)) end # Process links doc.css('a').each do |link| next unless link.include? 'href' next unless link.include? 'class' 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 '
' if doc.css('div.notes-section.notes').empty? doc.add_child '
' 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('

') 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. # # This method is intended to be the dumping ground for the slide fixups that we can't do in # other places until we get #615 implemented. Then this method should be refactored away. # def final_slide_fixup(text) # Turn this into a document for munging doc = Nokogiri::HTML::DocumentFragment.parse(text) slide = doc.at_css 'div.slide' content = doc.at_css 'div.content' # move each notes section outside of the content div doc.css('div.notes-section').each do |note| content.add_next_sibling(note) end # this is a list of classes that we want applied *only* to content, and not to the slide, # typically so that overly aggressive selectors don't match more than they should. blacklist = ['bigtext'] slide['class'] = slide['class'].split.reject { |klass| blacklist.include? klass }.join(' ') 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] toc = Nokogiri::HTML::DocumentFragment.parse("

") doc.css('div.subsection > h1:not(.section_title)').each do |section| href = section.parent.parent['id'] frag = "
#{section.content}
" link = Nokogiri::HTML::DocumentFragment.parse(frag) toc.children.first.add_child(link) end # swap out the tag, if found, with the table of contents doc.at('p:contains("~~~TOC~~~")').replace(toc) rescue nil end doc.css('.slide.glossary .content').each do |glossary| name = (glossary.attr('class').split - ['content', 'glossary']).first list = Nokogiri::HTML::DocumentFragment.parse('') 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 = "
  • #{term}#{text}
  • " 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

    .(something), remove the ones tagged with # .break and .comment, then turn the remainder into

    # The perlism line noise is splitting multiple classes (.class1.class2) on the period. # # TODO: We really need to update this to use the DOM instead of text parsing :/ # def update_p_classes(content) # comment & break content.gsub!(/

    \.(?:break|comment)( .*)?<\/p>/, '') # paragraph classes content.gsub!(/

    \.(.*?) /) { "

    " } # image classes content.gsub(/(\.\S*)\s*(.*)/) { "\"#{$3}\"" } end # replace custom markup with html forms def build_forms(content, classes=[]) title = classes.collect { |cl| $1 if cl =~ /^form=(\w+)$/ }.compact.first # only process slides marked as forms return content if title.nil? begin tools = '

    ' tools << "" tools << "" tools << '
    ' form = "
    #{content}#{tools}
    " doc = Nokogiri::HTML::DocumentFragment.parse(form) doc.css('p').each do |p| if p.text =~ /^(\w*) ?(?:->)? ?(.*)? (\*?)= ?(.*)?$/ code = $1 id = "#{title}_#{code}" name = $2.empty? ? code : $2 required = ! $3.empty? rhs = $4 p.replace form_element(id, code, name, required, rhs, p.text) end end doc.to_html rescue Exception => e @logger.warn "Form parsing failed: #{e.message}" @logger.debug "Backtrace:\n\t#{e.backtrace.join("\n\t")}" content end end def form_element(id, code, name, required, rhs, text) required = required ? 'required' : '' str = "
    " str << "" case rhs when /^\[\s+(\d*)\]$$/ # value = [ 5] (textarea) str << form_element_textarea(id, code, $1) when /^___+(?:\[(\d+)\])?$/ # value = ___[50] (text) str << form_element_text(id, code, $1) when /^\(.?\)/ # value = (x) option one (=) opt2 () opt3 -> option 3 (radio) str << form_element_radio(id, code, rhs.scan(/\((.?)\)\s*([^()]+)\s*/)) when /^\[.?\]/ # value = [x] option one [=] opt2 [] opt3 -> option 3 (checkboxes) str << form_element_checkboxes(id, code, rhs.scan(/\[(.?)\] ?([^\[\]]+)/)) when /^\{(.*)\}$/ # value = {BOS, [SFO], (NYC)} (select shorthand) str << form_element_select(id, code, rhs.scan(/[(\[]?\w+[)\]]?/)) when /^\{$/ # value = { (select) str << form_element_select_multiline(id, code, text) when '' # value = (radio/checkbox list) str << form_element_multiline(id, code, text) else @logger.warn "Unmatched form element: #{rhs}" end str << '
    ' end def form_element_text(id, code, length) "" end def form_element_textarea(id, code, rows) rows = 3 if rows.empty? "" end def form_element_radio(id, code, items) form_element_check_or_radio_set('radio', id, code, items) end def form_element_checkboxes(id, code, items) form_element_check_or_radio_set('checkbox', id, code, items) end def form_element_select(id, code, items) str = "' end def form_element_select_multiline(id, code, text) str = "' end def form_element_multiline(id, code, text) str = '' end def form_element_check_or_radio_set(type, id, code, items) str = '' items.each do |item| modifier = item[0] if item[1] =~ /^(\w*) -> (.*)$/ value = $1 label = $2 else value = label = item[1] end str << form_element_check_or_radio(type, id, code, value, label, modifier) end str end def form_element_check_or_radio(type, id, code, value, label, modifier) # yes, value and id are conflated, because this is the id of the parent widget checked = form_checked?(modifier) classes = form_classes(modifier) name = (type == 'checkbox') ? "#{code}[]" : code str = "" str << "" end def form_classes(modifier) modifier.downcase! classes = ['response'] classes << 'correct' if modifier.include?('=') classes.join(' ') end def form_checked?(modifier) modifier.downcase.include?('x') ? "checked='checked'" : '' 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}) 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 end doc.to_html end def update_commandline_code(slide) html = Nokogiri::HTML::DocumentFragment.parse(slide) parser = CommandlineParser.new html.css('pre').each do |pre| pre.css('code').each do |code| out = code.text lang = code.get_attribute('class') # Skip this if we've got an empty code block next if out.empty? # catch fenced code blocks from commonmarker if (lang and lang.start_with? 'language-' ) pre.set_attribute('class', 'highlight') # or weve started a code block with a Showoff language tag elsif out.strip[0, 3] == '@@@' lines = out.split("\n") lang = lines.shift.gsub('@@@', '').strip pre.set_attribute('class', 'highlight') code.set_attribute('class', 'language-' + 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.to_html end def get_slides_html(opts={:static=>false, :pdf=>false, :toc=>false, :supplemental=>nil, :section=>nil}) sections = nil Dir.chdir(get_locale_dir('locales', @locale)) do sections = ShowOffUtils.showoff_sections(settings.pres_dir, settings.showoff_config, @logger) end if sections data = '' sections.each do |section, slides| slides.each do |filename| next unless filename.end_with? '.md' path = filename.chomp('.md') # TODO: I don't know why we do this silly thing begin data << process_markdown(path, section, File.read(filename), opts) rescue Errno::ENOENT => e @logger.error e.message data << process_markdown(path, section, "!SLIDE\n# Missing File!\n## #{filename}", opts) end end # I don't know what this part was supposed to do # if section =~ /^#/ # name = section.each_line.first.gsub(/^#*/,'').strip # data << process_markdown(name, "\n" + section, opts) # else 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 @asset_path = "./" end # Display favicon in the window if configured @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 # translated UI strings, according to the current locale @language = get_translations() # store a cookie to tell clients apart. More reliable than using IP due to proxies, etc. manage_client_cookies() erb :index end def presenter @favicon = settings.showoff_config['favicon'] @issues = settings.showoff_config['issues'] @edit = settings.showoff_config['edit'] if @review @feedback = settings.showoff_config['feedback'] @language = get_translations() manage_client_cookies(true) erb :presenter end def clean_link(href) if href && href[0, 1] == '/' href = href[1, href.size] end href end def assets_needed assets = ["index", "slides"] index = erb :index html = Nokogiri::XML.parse(index) html.css('head link').each do |link| href = clean_link(link['href']) assets << href if href end html.css('head script').each do |link| href = clean_link(link['src']) assets << href if href end slides = get_slides_html html = Nokogiri::XML.parse("" + 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) @logger.warn "Cached presentations: #{@@cache.keys}" # if we have a cache and we're not asking to invalidate it return @@cache[@locale] if (@@cache[@locale] and params['cache'] != 'clear') @logger.warn "Generating locale: #{@locale}" # If we're displaying from a repository, let's update it ShowOffUtils.update(settings.verbose) if settings.url @@slide_titles = [] content = get_slides_html(:static=>static) # allow command line cache disabling @@cache[@locale] = content unless settings.nocache content end def print(section=nil) @slides = get_slides_html(:static=>true, :toc=>true, :print=>true, :section=>section) @favicon = settings.showoff_config['favicon'] erb :onepage end def supplemental(content, static=false) # 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 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 ] } @favicon = settings.showoff_config['favicon'] rescue Errno::ENOENT => e # don't fail if the directory doesn't exist @downloads = {} 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 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 erb :stats end def pdf(name) @slides = get_slides_html(:static=>true, :toc=>true, :print=>true) @inline = true html = erb :onepage # Process inline css and js for included images # The css uses relative paths for images and we prepend the file url 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:// for correct use with wkhtmltopdf (exactly with qt-webkit) html.gsub!(/ e puts "Missing source file: #{path}" end end end # copy images from css too Dir.glob("#{pres_dir}/*.css").each do |css_path| File.open(css_path) do |file| data = file.read data.scan(/url\([\"\']?(?!https?:\/\/)(.*?)[\"\']?\)/).flatten.each do |path| path.gsub!(/(\#.*)$/, '') # get rid of the anchor path.gsub!(/(\?.*)$/, '') # get rid of the query logger.debug 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)) rescue Errno::ENOENT => e puts "Missing source file: #{path}" end end end end 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, executable=true) if path =~ /^(.*)(?::)(\d+)$/ path = $1 num = $2.to_i else num = 1 end classes = executable ? 'code.execute' : 'code' slide = "#{path}.md" return unless File.exist? slide content = File.read(slide) if defined? num content = content.split(/^\ 0, 'responses' => {} } # increment the number of unique responses we've seen sum[key]['count'] += 1 responses = sum[key]['responses'] if val.class == Array val.each do |item| responses[item] ||= 0 responses[item] += 1 end else responses[val] ||= 0 responses[val] += 1 end end end.to_json end # Evaluate known good code from a slide file on disk. get '/execute/:lang' do |lang| return 'Run showoff with -x or --executecode to enable code execution' unless @execute code = get_code_from_slide(params[:path], params[:index]) parser = settings.showoff_config['parsers'][lang] return "No parser for #{lang}" unless parser require 'timeout' require 'open3' # for 1.8 compatibility :/ begin Timeout::timeout(settings.showoff_config['timeout']) do # write out a tempfile to make it simpler for end users to add custom language parser Tempfile.open('showoff-execution') do |f| File.write(f.path, code) @logger.debug "Evaluating: #{parser} #{f.path}" output, status = Open3.capture2e("#{parser} #{f.path}") unless status.success? @logger.warn "Command execution failed for #{params[:path]}[#{params[:index]}]" @logger.warn output end output end end rescue => e e.message end.gsub(/\n/, '
    ') end # provide a callback to trigger a local file editor, but only when called when viewing from localhost. get '/edit/*' do |path| # Docs suggest that old versions of Sinatra might provide an array here, so just make sure. filename = path.class == Array ? path.first : path @logger.debug "Editing #{filename}" # When a relative path is used, it's sometimes fully expanded. But then when # it's passed via URL, the initial slash is lost. Here we try to get it back. filename = "/#{filename}" unless File.exist? filename return unless File.exist? filename if request.host != 'localhost' @logger.warn "Disallowing edit because #{request.host} isn't localhost." return end case RUBY_PLATFORM when /darwin/ `open #{filename}` when /linux/ `xdg-open #{filename}` when /cygwin|mswin|mingw|bccwin|wince|emx/ `start #{filename}` else @logger.warn "Cannot open #{filename}, unknown platform #{RUBY_PLATFORM}." end 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 # 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( { 'message' => 'current', '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.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_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 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, :increment => increment } # schedule a notification for all clients 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_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_presenter_cookie? ? 'presenter' : request.cookies['client_id'] slide = control['slide'] if control.has_key? 'time' 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 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( { 'message' => 'current', 'current' => @@current[:number] }.to_json ) unless @@cookie.nil? when 'activity' next if valid_presenter_cookie? remote = request.cookies['client_id'] slide = control['slide'] status = control['status'] @@activity[slide] ||= {} @@activity[slide][remote] = status current = @@current[:number] activity = @@activity[current] rescue nil @logger.debug "Current activity status: #{activity.inspect}" if activity # select all activity on this slide where completion status is false count = activity.select {|viewer, status| status == false }.size EM.next_tick { settings.presenters.each{|s| s.send({ 'message' => 'activity', 'count' => count }.to_json) } } end 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', 'answerkey' EM.next_tick { settings.sockets.each{|s| s.send(control.to_json) } } when 'annotation', 'annotationConfig' EM.next_tick { (settings.sockets - settings.presenters).each{|s| s.send(control.to_json) } } 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}" @logger.debug e.backtrace.join("\n") 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 @locale = locale(request.cookies['locale']) @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['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 if (what != "favicon.ico") if ['supplemental', 'print'].include? what 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." @logger.warn e.message @logger.debug e.backtrace.join("\n") raise Sinatra::NotFound end end not_found do # Why does the asset path start from cwd?? @asset_path.slice!(/^./) rescue nil @env = request.env erb :'404' end at_exit do ShowOff.flush end end