# A parser for tumblr themes # # # TODO # ==== # * Add a logger so errors with the parse can be displayed # * Likes # * More blocks # * Auto summary? Description tag stripping? require 'yaml' require 'cgi' require 'time' module Thimblr class Parser BackCompatibility = {"Type" => { "Regular" => "Text", "Conversation" => "Chat" }} Defaults = { 'PostsPerPage' => 10 } def initialize(data_file,theme_file = nil,settings = {}) template = YAML::load(open(data_file)) @settings = Defaults.merge settings @apid = 0 @posts = ArrayIO.new(template['Posts']) @groupmembers = template['GroupMembers'] @pages = template['Pages'] @following = template['Following'] @followed = template['Followed'] # Add all suitable @template options to @constants @constants = template.delete_if { |key,val| ["Pages","Following","Posts","SubmissionsEnabled","Followed"].include? key } @constants['RSS'] = '/thimblr/rss' @constants['Favicon'] = '/favicon.ico' @blocks = { # These are the defaults 'Twitter' => !@constants['TwitterUsername'].empty?, 'Description' => !@constants['Description'].empty?, 'Pagination' => (@posts.length > @settings['PostsPerPage'].to_i), 'SubmissionsEnabled' => template['SubmissionsEnabled'], 'AskEnabled' => !@constants['AskLabel'].empty?, 'HasPages' => @pages.length > 0, 'Following' => @following.length > 0, 'Followed' => @followed.length > 0, 'More' => true } if theme_file and File.exists?(theme_file) set_theme(open(theme_file).read) end end def set_theme(theme_html) @theme = theme_html # Changes for Thimblr @theme.gsub!(/href="\//,"href=\"/thimblr/") # Get the meta constants @theme.scan(/()/).each do |meta| value = (meta[0].scan(/content="(.+?)"/)[0] || [])[0] if meta[1] == "if" @blocks[meta[2].gsub(/(?:\ |^)\w/) {|s| s.strip.upcase}] = (value == 1) else @constants[meta[1..-1].join(":")] = value @blocks[meta[2]+"Image"] = true if meta[1] == "image" end end @constants['MetaDescription'] = CGI.escapeHTML(@constants['Description']) end # Renders a tumblr page from the stored template def render_posts(page = 1) blocks = @blocks constants = @constants constants['TotalPages'] = (@posts.length / @settings['PostsPerPage'].to_i).ceil blocks['PreviousPage'] = page > 1 blocks['NextPage'] = page < constants['TotalPages'] blocks['Posts'] = true blocks['IndexPage'] = true constants['NextPage'] = page + 1 constants['CurrentPage'] = page constants['PreviousPage'] = page - 1 # ffw thru posts array if required @posts.seek((page - 1) * @settings['PostsPerPage'].to_i) parse(@theme,blocks,constants) end # Renders an individual post def render_permalink(postid) postid = postid.to_i blocks = @blocks constants = @constants @posts.delete_if do |post| post['PostId'] != postid end raise "Post Not Found" if @posts.length != 1 blocks['Posts'] = true blocks['PostTitle'] = true blocks['PostSummary'] = true blocks['PermalinkPage'] = true blocks['PermalinkPagination'] = (@posts.length > 1) blocks['PreviousPost'] = (postid < @posts.length) blocks['NextPost'] = (postid > 0) constants['PreviousPost'] = "/thimblr/post/#{postid - 1}" constants['NextPost'] = "/thimblr/post/#{postid + 1}" # Generate a post summary if a title isn't present parse(@theme,blocks,constants) end # Renders the search page from the query def render_search(query) @searchresults = [] blocks = @blocks constants = @constants blocks['NoSearchResults'] = (@searchresults.length == 0) blocks['SearchResults'] = !blocks['NoSearchResults'] # Is this a supported tag? blocks['SearchPage'] = true constants['SearchQuery'] = query constants['URLSafeSearchQuery'] = CGI.escape(query) constants['SearchResultCount'] = @searchresults.length parse(@theme,blocks,constants) end # Renders a special page def render_page(pageid) blocks = @blocks constants = @constants blocks['Pages'] = true parse(@theme,blocks,constants) end private def parse(string,blocks = {},constants = {}) blocks = blocks.dup constants = constants.dup blocks.merge! constants['}blocks'] if !constants['}blocks'].nil? string.gsub(/\{block:([\w:]+)\}(.*?)\{\/block:\1\}|\{([\w\-:]+)\}/m) do |match| # TODO:add not block to the second term if $2 # block blockname = $1 content = $2 # Back Compatibility blockname = BackCompatibility['Type'][blockname] if !BackCompatibility['Type'][blockname].nil? inv = false case blockname when /^IfNot(.*)$/ inv = true blockname = $1 when /^If(.*)$/ blockname = $1 when 'Posts' if @blocks['Posts'] lastday = nil repeat = @settings['PostsPerPage'].times.collect do |n| if not (post = @posts.advance).nil? post['}blocks'] = {} post['}blocks']['Date'] = true # Always render Date on Post pages thisday = Time.at(post['Timestamp']) post['}blocks']['NewDayDate'] = thisday.strftime("%Y-%m-%d") != lastday post['}blocks']['SameDayDate'] = !post['}blocks']['NewDayDate'] lastday = thisday.strftime("%Y-%m-%d") post['DayOfMonth'] = thisday.day post['DayOfMonthWithZero'] = thisday.strftime("%d") post['DayOfWeek'] = thisday.strftime("%A") post['ShortDayOfWeek'] = thisday.strftime("%a") post['DayOfWeekNumber'] = thisday.strftime("%w").to_i + 1 ordinals = ['st','nd','rd'] post['DayOfMonthSuffix'] = ([11,12].include? thisday.day) ? "th" : ordinals[thisday.day % 10 - 1] post['DayOfYear'] = thisday.strftime("%j") post['WeekOfYear'] = thisday.strftime("%W") post['Month'] = thisday.strftime("%B") post['ShortMonth'] = thisday.strftime("%b") post['MonthNumber'] = thisday.month post['MonthNumberWithZero'] = thisday.strftime("%w") post['Year'] = thisday.strftime("%Y") post['ShortYear'] = thisday.strftime("%y") post['CapitalAmPm'] = thisday.strftime("%p") post['AmPm'] = post['CapitalAmPm'].downcase post['12Hour'] = thisday.strftime("%I").sub(/^0/,"") post['24Hour'] = thisday.hour post['12HourWithZero'] = thisday.strftime("%I") post['24HourWithZero'] = thisday.strftime("%H") post['Minutes'] = thisday.strftime("%M") post['Seconds'] = thisday.strftime("%S") post['Beats'] = (thisday.usec / 1000).round post['TimeAgo'] = thisday.ago post['Permalink'] = "http://127.0.0.1:4567/thimblr/post/#{post['PostId']}/" # TODO: Port number post['ShortURL'] = post['Permalink'] # No need for a real short URL post['TagsAsClasses'] = (constants['Tags'] || []).collect{ |tag| tag.gsub(/[^a-z]/i,"_").downcase }.join(" ") post['}numberonpage'] = n + 1 # use a } at the begining so the theme can't access it # Group Posts if !post['GroupPostMember'].nil? poster = nil @groupmembers.each do |groupmember| p groupmember if groupmember['Name'] == post['GroupPostMemberName'] poster = Hash[*groupmember.to_a.collect {|key,value| ["PostAuthor#{key}",value] }.flatten] break end end p poster if poster.nil? # Add to log, GroupMemberPost not found in datafile else post.merge! poster end end post['Title'] ||= "" # This prevents the site's title being used when it shouldn't be case post['Type'] when 'Photo' post['PhotoAlt'] = CGI.escapeHTML(post['Caption']) if !post['LinkURL'].nil? post['LinkOpenTag'] = "" post['LinkCloseTag'] = "" end when 'Audio' post['AudioPlayerBlack'] = audio_player(post['AudioFile'],"black") post['AudioPlayerGrey'] = audio_player(post['AudioFile'],"grey") post['AudioPlayerWhite'] = audio_player(post['AudioFile'],"white") post['AudioPlayer'] = audio_player(post['AudioFile']) post['}blocks']['ExternalAudio'] = !(post['AudioFile'] =~/^http:\/\/(?:www\.)?tumblr\.com/) post['AudioFile'] = nil # We don't want this tag to be parsed if it happens to be in there post['}blocks']['Artist'] = !post['Artist'].empty? post['}blocks']['Album'] = !post['Album'].empty? post['}blocks']['TrackName'] = !post['TrackName'].empty? end post end end.compact end # Post details when 'Title' blocks['Title'] = !constants['Title'].empty? when /^Post(?:[1-9]|1[0-5])$/ blocks["Post#{$1}"] = true if constants['}numberonpage'] == $1 when 'Odd' blocks["Post#{$1}"] = constants['}numberonpage'] % 2 when 'Even' blocks["Post#{$1}"] = !(constants['}numberonpage'] % 2) # Reblogs when 'RebloggedFrom' if !constants['Reblog'].nil? blocks['RebloggedFrom'] = true constants.merge! constants['Reblog'] constants.merge! constants['Root'] if !constants['Root'].nil? end # Photo Posts when 'HighRes' blocks['HighRes'] = !constants['HiRes'].empty? when 'Caption' blocks['Caption'] = !constants['Caption'].empty? when 'SearchPage' repeat = @searchresults if blocks['SearchPage'] # Quote Posts when 'Source' blocks['Source'] = !constants['Source'].empty? when 'Description' if !constants['Type'].nil? blocks['Description'] = !constants['Description'].empty? end # Chat Posts when 'Lines' alt = {true => 'odd',false => 'even'} iseven = false repeat = constants['Lines'].collect do |line| parts = line.to_a[0] {"Line" => parts[1],"Label" => parts[0],"Alt" => alt[iseven = !iseven]} end constants['Lines'] = nil blocks['Lines'] = true when 'Label' blocks['Label'] = !constants['Label'].empty? # TODO: Notes # Tags when 'HasTags' if constants['Tags'].length > 0 blocks['HasTags'] = true end when 'Tags' repeat = constants['Tags'].collect do |tag| {"Tag" => tag,"URLSafeTag" => tag.gsub(/[^a-zA-Z]/,"_").downcase,"TagURL" => "/thimblr/tagged/#{CGI.escape(tag)}","ChronoTagURL" => "/thimblr/tagged/#{CGI.escape(tag)}"} # TODO: ChronoTagURL end blocks['Tags'] = repeat.length > 0 constants['Tags'] = nil # Groups when 'GroupMembers' if !constants['GroupMembers'].nil? blocks['GroupMembers'] = true end when 'GroupMember' repeat = constants['GroupMembers'].collect do |groupmember| Hash[*groupmember.collect{ |key,value| ["GroupMember#{key}",value] }.flatten] end blocks['GroupMember'] = repeat.length > 0 constants['GroupMembers'] = nil # TODO: Day Pages # TODO: Tag Pages end # Process away! (repeat || [constants]).collect do |consts| if (blocks[blockname] ^ inv) or consts['Type'] == blockname parse(content,blocks,(constants.merge consts)) end end.join else constants[$3] end end end def audio_player(audiofile,colour = "") # Colour is one of 'black', 'white' or 'grey' case colour when "black" colour = "_black" when "grey" colour = "" audiofile += "&color=E4E4E4" when "white" colour = "" audiofile += "&color=FFFFFF" else colour = "" end @apid += 1 return <<-END [Flash 9 is required to listen to audio.] END end end class ArrayIO < Array # Returns the currently selected item and advances the pointer def advance @position = @position + 1 rescue 1 self[@position - 1] end # Returns the currently selected item and moves the pointer back one def retreat @position = @position - 1 rescue -1 self[@position + 1] end def seek(n) self[@position = n] end def tell @position end end class Time < Time def ago "some time ago" end end end class NilClass def empty? true end end =begin t = Thimblr::Parser.new("demo") t.set_theme(open("themes/101.html").read) puts t.render_posts =end