PageRenderTime 60ms CodeModel.GetById 22ms RepoModel.GetById 1ms app.codeStats 0ms

/lib/thimblr/parser.rb

http://github.com/jphastings/thimblr
Ruby | 389 lines | 342 code | 16 blank | 31 comment | 10 complexity | 6e9b0d6d407279b35d088a37ecc4917e MD5 | raw file
  1. # A parser for tumblr themes
  2. #
  3. #
  4. # TODO
  5. # ====
  6. # * Add a logger so errors with the parse can be displayed
  7. # * Likes
  8. # * More blocks
  9. # * Auto summary? Description tag stripping?
  10. require 'yaml'
  11. require 'cgi'
  12. require 'time'
  13. module Thimblr
  14. class Parser
  15. BackCompatibility = {"Type" => {
  16. "Regular" => "Text",
  17. "Conversation" => "Chat"
  18. }}
  19. Defaults = {
  20. 'PostsPerPage' => 10
  21. }
  22. def initialize(data_file,theme_file = nil,settings = {})
  23. template = YAML::load(open(data_file))
  24. @settings = Defaults.merge settings
  25. @apid = 0
  26. @posts = ArrayIO.new(template['Posts'])
  27. @groupmembers = template['GroupMembers']
  28. @pages = template['Pages']
  29. @following = template['Following']
  30. @followed = template['Followed']
  31. # Add all suitable @template options to @constants
  32. @constants = template.delete_if { |key,val| ["Pages","Following","Posts","SubmissionsEnabled","Followed"].include? key }
  33. @constants['RSS'] = '/thimblr/rss'
  34. @constants['Favicon'] = '/favicon.ico'
  35. @blocks = { # These are the defaults
  36. 'Twitter' => !@constants['TwitterUsername'].empty?,
  37. 'Description' => !@constants['Description'].empty?,
  38. 'Pagination' => (@posts.length > @settings['PostsPerPage'].to_i),
  39. 'SubmissionsEnabled' => template['SubmissionsEnabled'],
  40. 'AskEnabled' => !@constants['AskLabel'].empty?,
  41. 'HasPages' => (@pages.length > 0 rescue false),
  42. 'Following' => (@following.length > 0 rescue false),
  43. 'Followed' => (@followed.length > 0 rescue false),
  44. 'More' => true
  45. }
  46. if theme_file and File.exists?(theme_file)
  47. set_theme(open(theme_file).read)
  48. end
  49. end
  50. def set_theme(theme_html)
  51. @theme = theme_html
  52. # Changes for Thimblr
  53. @theme.gsub!(/href="\//,"href=\"/thimblr/")
  54. # Get the meta constants
  55. @theme.scan(/(<meta.*?name="(\w+):(.+?)".*?\/>)/).each do |meta|
  56. value = (meta[0].scan(/content="(.+?)"/)[0] || [])[0]
  57. if meta[1] == "if"
  58. @blocks[meta[2].gsub(/(?:\ |^)\w/) {|s| s.strip.upcase}] = (value == 1)
  59. else
  60. @constants[meta[1..-1].join(":")] = value
  61. @blocks[meta[2]+"Image"] = true if meta[1] == "image"
  62. end
  63. end
  64. @constants['MetaDescription'] = CGI.escapeHTML(@constants['Description'])
  65. end
  66. # Renders a tumblr page from the stored template
  67. def render_posts(page = 1)
  68. blocks = @blocks
  69. constants = @constants
  70. constants['TotalPages'] = (@posts.length / @settings['PostsPerPage'].to_i).ceil
  71. blocks['PreviousPage'] = page > 1
  72. blocks['NextPage'] = page < constants['TotalPages']
  73. blocks['Posts'] = true
  74. blocks['IndexPage'] = true
  75. constants['NextPage'] = page + 1
  76. constants['CurrentPage'] = page
  77. constants['PreviousPage'] = page - 1
  78. # ffw thru posts array if required
  79. @posts.seek((page - 1) * @settings['PostsPerPage'].to_i)
  80. parse(@theme,blocks,constants)
  81. end
  82. # Renders an individual post
  83. def render_permalink(postid)
  84. postid = postid.to_i
  85. blocks = @blocks
  86. constants = @constants
  87. @posts.delete_if do |post|
  88. post['PostId'] != postid
  89. end
  90. raise "Post Not Found" if @posts.length != 1
  91. blocks['Posts'] = true
  92. blocks['PostTitle'] = true
  93. blocks['PostSummary'] = true
  94. blocks['PermalinkPage'] = true
  95. blocks['PermalinkPagination'] = (@posts.length > 1)
  96. blocks['PreviousPost'] = (postid < @posts.length)
  97. blocks['NextPost'] = (postid > 0)
  98. constants['PreviousPost'] = "/thimblr/post/#{postid - 1}"
  99. constants['NextPost'] = "/thimblr/post/#{postid + 1}"
  100. # Generate a post summary if a title isn't present
  101. parse(@theme,blocks,constants)
  102. end
  103. # Renders the search page from the query
  104. def render_search(query)
  105. @searchresults = []
  106. blocks = @blocks
  107. constants = @constants
  108. blocks['NoSearchResults'] = (@searchresults.length == 0)
  109. blocks['SearchResults'] = !blocks['NoSearchResults'] # Is this a supported tag?
  110. blocks['SearchPage'] = true
  111. constants['SearchQuery'] = query
  112. constants['URLSafeSearchQuery'] = CGI.escape(query)
  113. constants['SearchResultCount'] = @searchresults.length
  114. parse(@theme,blocks,constants)
  115. end
  116. # Renders a special page
  117. def render_page(pageid)
  118. blocks = @blocks
  119. constants = @constants
  120. blocks['Pages'] = true
  121. parse(@theme,blocks,constants)
  122. end
  123. private
  124. def parse(string,blocks = {},constants = {})
  125. blocks = blocks.dup
  126. constants = constants.dup
  127. blocks.merge! constants['}blocks'] if !constants['}blocks'].nil?
  128. string.gsub(/\{block:([\w:]+)\}(.*?)\{\/block:\1\}|\{([\w\-:]+)\}/m) do |match| # TODO:add not block to the second term
  129. if $2 # block
  130. blockname = $1
  131. content = $2
  132. # Back Compatibility
  133. blockname = BackCompatibility['Type'][blockname] if !BackCompatibility['Type'][blockname].nil?
  134. inv = false
  135. case blockname
  136. when /^IfNot(.*)$/
  137. inv = true
  138. blockname = $1
  139. when /^If(.*)$/
  140. blockname = $1
  141. when 'Posts'
  142. if @blocks['Posts']
  143. lastday = nil
  144. repeat = @settings['PostsPerPage'].times.collect do |n|
  145. if not (post = @posts.advance).nil?
  146. post['}blocks'] = {}
  147. post['}blocks']['Date'] = true # Always render Date on Post pages
  148. thisday = Time.at(post['Timestamp'])
  149. post['}blocks']['NewDayDate'] = thisday.strftime("%Y-%m-%d") != lastday
  150. post['}blocks']['SameDayDate'] = !post['}blocks']['NewDayDate']
  151. lastday = thisday.strftime("%Y-%m-%d")
  152. post['DayOfMonth'] = thisday.day
  153. post['DayOfMonthWithZero'] = thisday.strftime("%d")
  154. post['DayOfWeek'] = thisday.strftime("%A")
  155. post['ShortDayOfWeek'] = thisday.strftime("%a")
  156. post['DayOfWeekNumber'] = thisday.strftime("%w").to_i + 1
  157. ordinals = ['st','nd','rd']
  158. post['DayOfMonthSuffix'] = ([11,12].include? thisday.day) ? "th" : ordinals[thisday.day % 10 - 1]
  159. post['DayOfYear'] = thisday.strftime("%j")
  160. post['WeekOfYear'] = thisday.strftime("%W")
  161. post['Month'] = thisday.strftime("%B")
  162. post['ShortMonth'] = thisday.strftime("%b")
  163. post['MonthNumber'] = thisday.month
  164. post['MonthNumberWithZero'] = thisday.strftime("%w")
  165. post['Year'] = thisday.strftime("%Y")
  166. post['ShortYear'] = thisday.strftime("%y")
  167. post['CapitalAmPm'] = thisday.strftime("%p")
  168. post['AmPm'] = post['CapitalAmPm'].downcase
  169. post['12Hour'] = thisday.strftime("%I").sub(/^0/,"")
  170. post['24Hour'] = thisday.hour
  171. post['12HourWithZero'] = thisday.strftime("%I")
  172. post['24HourWithZero'] = thisday.strftime("%H")
  173. post['Minutes'] = thisday.strftime("%M")
  174. post['Seconds'] = thisday.strftime("%S")
  175. post['Beats'] = (thisday.usec / 1000).round
  176. post['TimeAgo'] = thisday.ago
  177. post['Permalink'] = "http://127.0.0.1:4567/thimblr/post/#{post['PostId']}/" # TODO: Port number
  178. post['ShortURL'] = post['Permalink'] # No need for a real short URL
  179. post['TagsAsClasses'] = (post['Tags'] || []).collect{ |tag| tag.gsub(/[^a-z]/i,"_").downcase }.join(" ")
  180. post['}numberonpage'] = n + 1 # use a } at the begining so the theme can't access it
  181. # Group Posts
  182. if !post['GroupPostMember'].nil?
  183. poster = nil
  184. @groupmembers.each do |groupmember|
  185. p groupmember
  186. if groupmember['Name'] == post['GroupPostMemberName']
  187. poster = Hash[*groupmember.to_a.collect {|key,value| ["PostAuthor#{key}",value] }.flatten]
  188. break
  189. end
  190. end
  191. p poster
  192. if poster.nil?
  193. # Add to log, GroupMemberPost not found in datafile
  194. else
  195. post.merge! poster
  196. end
  197. end
  198. post['Title'] ||= "" # This prevents the site's title being used when it shouldn't be
  199. case post['Type']
  200. when 'Photo'
  201. post['PhotoAlt'] = CGI.escapeHTML(post['Caption'])
  202. if !post['LinkURL'].nil?
  203. post['LinkOpenTag'] = "<a href=\"#{post['LinkURL']}\">"
  204. post['LinkCloseTag'] = "</a>"
  205. end
  206. when 'Audio'
  207. post['AudioPlayerBlack'] = audio_player(post['AudioFile'],"black")
  208. post['AudioPlayerGrey'] = audio_player(post['AudioFile'],"grey")
  209. post['AudioPlayerWhite'] = audio_player(post['AudioFile'],"white")
  210. post['AudioPlayer'] = audio_player(post['AudioFile'])
  211. post['}blocks']['ExternalAudio'] = !(post['AudioFile'] =~/^http:\/\/(?:www\.)?tumblr\.com/)
  212. post['AudioFile'] = nil # We don't want this tag to be parsed if it happens to be in there
  213. post['}blocks']['Artist'] = !post['Artist'].empty?
  214. post['}blocks']['Album'] = !post['Album'].empty?
  215. post['}blocks']['TrackName'] = !post['TrackName'].empty?
  216. end
  217. post
  218. end
  219. end.compact
  220. end
  221. # Post details
  222. when 'Title'
  223. blocks['Title'] = !constants['Title'].empty?
  224. when /^Post(?:[1-9]|1[0-5])$/
  225. blocks["Post#{$1}"] = true if constants['}numberonpage'] == $1
  226. when 'Odd'
  227. blocks["Post#{$1}"] = constants['}numberonpage'] % 2
  228. when 'Even'
  229. blocks["Post#{$1}"] = !(constants['}numberonpage'] % 2)
  230. # Reblogs
  231. when 'RebloggedFrom'
  232. if !constants['Reblog'].nil?
  233. blocks['RebloggedFrom'] = true
  234. constants.merge! constants['Reblog']
  235. constants.merge! constants['Root'] if !constants['Root'].nil?
  236. end
  237. # Photo Posts
  238. when 'HighRes'
  239. blocks['HighRes'] = !constants['HiRes'].empty?
  240. when 'Caption'
  241. blocks['Caption'] = !constants['Caption'].empty?
  242. when 'SearchPage'
  243. repeat = @searchresults if blocks['SearchPage']
  244. # Quote Posts
  245. when 'Source'
  246. blocks['Source'] = !constants['Source'].empty?
  247. when 'Description'
  248. if !constants['Type'].nil?
  249. blocks['Description'] = !constants['Description'].empty?
  250. end
  251. # Chat Posts
  252. when 'Lines'
  253. alt = {true => 'odd',false => 'even'}
  254. iseven = false
  255. repeat = constants['Lines'].collect do |line|
  256. parts = line.to_a[0]
  257. {"Line" => parts[1],"Label" => parts[0],"Alt" => alt[iseven = !iseven]}
  258. end
  259. constants['Lines'] = nil
  260. blocks['Lines'] = true
  261. when 'Label'
  262. blocks['Label'] = !constants['Label'].empty?
  263. # TODO: Notes
  264. # Tags
  265. when 'HasTags'
  266. if constants['Tags'].length > 0
  267. blocks['HasTags'] = true
  268. end
  269. when 'Tags'
  270. repeat = constants['Tags'].collect do |tag|
  271. {"Tag" => tag,"URLSafeTag" => tag.gsub(/[^a-zA-Z]/,"_").downcase,"TagURL" => "/thimblr/tagged/#{CGI.escape(tag)}","ChronoTagURL" => "/thimblr/tagged/#{CGI.escape(tag)}"} # TODO: ChronoTagURL
  272. end
  273. blocks['Tags'] = repeat.length > 0
  274. constants['Tags'] = nil
  275. # Groups
  276. when 'GroupMembers'
  277. if !constants['GroupMembers'].nil?
  278. blocks['GroupMembers'] = true
  279. end
  280. when 'GroupMember'
  281. repeat = constants['GroupMembers'].collect do |groupmember|
  282. Hash[*groupmember.collect{ |key,value| ["GroupMember#{key}",value] }.flatten]
  283. end
  284. blocks['GroupMember'] = repeat.length > 0
  285. constants['GroupMembers'] = nil
  286. # TODO: Day Pages
  287. # TODO: Tag Pages
  288. end
  289. # Process away!
  290. (repeat || [constants]).collect do |consts|
  291. if (blocks[blockname] ^ inv) or consts['Type'] == blockname
  292. parse(content,blocks,(constants.merge consts))
  293. end
  294. end.join
  295. else
  296. constants[$3]
  297. end
  298. end
  299. end
  300. def audio_player(audiofile,colour = "") # Colour is one of 'black', 'white' or 'grey'
  301. case colour
  302. when "black"
  303. colour = "_black"
  304. when "grey"
  305. colour = ""
  306. audiofile += "&color=E4E4E4"
  307. when "white"
  308. colour = ""
  309. audiofile += "&color=FFFFFF"
  310. else
  311. colour = ""
  312. end
  313. @apid += 1
  314. return <<-END
  315. <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>
  316. END
  317. end
  318. end
  319. class ArrayIO < Array
  320. # Returns the currently selected item and advances the pointer
  321. def advance
  322. @position = @position + 1 rescue 1
  323. self[@position - 1]
  324. end
  325. # Returns the currently selected item and moves the pointer back one
  326. def retreat
  327. @position = @position - 1 rescue -1
  328. self[@position + 1]
  329. end
  330. def seek(n)
  331. self[@position = n]
  332. end
  333. def tell
  334. @position
  335. end
  336. end
  337. class Time < Time
  338. def ago
  339. "some time ago"
  340. end
  341. end
  342. end
  343. class NilClass
  344. def empty?
  345. true
  346. end
  347. end
  348. =begin
  349. t = Thimblr::Parser.new("demo")
  350. t.set_theme(open("themes/101.html").read)
  351. puts t.render_posts
  352. =end