/lib/thimblr/parser.rb
Ruby | 389 lines | 342 code | 16 blank | 31 comment | 10 complexity | 6e9b0d6d407279b35d088a37ecc4917e MD5 | raw file
- # 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 rescue false),
- 'Following' => (@following.length > 0 rescue false),
- 'Followed' => (@followed.length > 0 rescue false),
- '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(/(<meta.*?name="(\w+):(.+?)".*?\/>)/).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'] = (post['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'] = "<a href=\"#{post['LinkURL']}\">"
- post['LinkCloseTag'] = "</a>"
- 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
- <script type="text/javascript" language="javascript" src="http://assets.tumblr.com/javascript/tumblelog.js?16"></script><span id="audio_player_#{@apid}">[<a href="http://www.adobe.com/shockwave/download/download.cgi?P1_Prod_Version=ShockwaveFlash" target="_blank">Flash 9</a> is required to listen to audio.]</span><script type="text/javascript">replaceIfFlash(9,"audio_player_#{@apid}",'<div class="audio_player"><embed type="application/x-shockwave-flash" src="/audio_player#{colour}.swf?audio_file=#{audiofile}" height="27" width="207" quality="best"></embed></div>')</script>
- 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