ZeroBlog.coffee 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. class ZeroBlog extends ZeroFrame
  2. init: ->
  3. @data = null
  4. @site_info = null
  5. @server_info = null
  6. @page = 1
  7. @my_post_votes = {}
  8. @event_page_load = $.Deferred()
  9. @event_site_info = $.Deferred()
  10. # Editable items on own site
  11. $.when(@event_page_load, @event_site_info).done =>
  12. if @site_info.settings.own or @data.demo
  13. @addInlineEditors()
  14. @checkPublishbar()
  15. $(".publishbar").on "click", @publish
  16. $(".posts .button.new").css("display", "inline-block")
  17. $(".editbar .icon-help").on "click", =>
  18. $(".editbar .markdown-help").css("display", "block")
  19. $(".editbar .markdown-help").toggleClassLater("visible", 10)
  20. $(".editbar .icon-help").toggleClass("active")
  21. return false
  22. $.when(@event_site_info).done =>
  23. @log "event site info"
  24. # Set avatar
  25. imagedata = new Identicon(@site_info.address, 70).toString();
  26. $("body").append("<style>.avatar { background-image: url(data:image/png;base64,#{imagedata}) }</style>")
  27. @log "inited!"
  28. loadData: (query="new") ->
  29. # Get blog parameters
  30. if query == "old" # Old type query for pre 0.3.0
  31. query = "SELECT key, value FROM json LEFT JOIN keyvalue USING (json_id) WHERE path = 'data.json'"
  32. else
  33. query = "SELECT key, value FROM json LEFT JOIN keyvalue USING (json_id) WHERE directory = '' AND file_name = 'data.json'"
  34. @cmd "dbQuery", [query], (res) =>
  35. @data = {}
  36. if res
  37. for row in res
  38. @data[row.key] = row.value
  39. $(".left h1 a:not(.editable-edit)").html(@data.title).data("content", @data.title)
  40. $(".left h2").html(Text.renderMarked(@data.description)).data("content", @data.description)
  41. $(".left .links").html(Text.renderMarked(@data.links)).data("content", @data.links)
  42. loadLastcomments: (type="show", cb=false) ->
  43. query = "
  44. SELECT comment.*, json_content.json_id AS content_json_id, keyvalue.value AS cert_user_id, json.directory, post.title AS post_title
  45. FROM comment
  46. LEFT JOIN json USING (json_id)
  47. LEFT JOIN json AS json_content ON (json_content.directory = json.directory AND json_content.file_name='content.json')
  48. LEFT JOIN keyvalue ON (keyvalue.json_id = json_content.json_id AND key = 'cert_user_id')
  49. LEFT JOIN post ON (comment.post_id = post.post_id)
  50. WHERE post.title IS NOT NULL
  51. ORDER BY date_added DESC LIMIT 3"
  52. @cmd "dbQuery", [query], (res) =>
  53. if res.length
  54. $(".lastcomments").css("display", "block")
  55. res.reverse()
  56. for lastcomment in res
  57. elem = $("#lastcomment_#{lastcomment.json_id}_#{lastcomment.comment_id}")
  58. if elem.length == 0 # Not exits yet
  59. elem = $(".lastcomment.template").clone().removeClass("template").attr("id", "lastcomment_#{lastcomment.json_id}_#{lastcomment.comment_id}")
  60. if type != "noanim"
  61. elem.cssSlideDown()
  62. elem.prependTo(".lastcomments ul")
  63. @applyLastcommentdata(elem, lastcomment)
  64. if cb then cb()
  65. applyLastcommentdata: (elem, lastcomment) ->
  66. elem.find(".user_name").text(lastcomment.cert_user_id.replace(/@.*/, "")+":")
  67. body = Text.renderMarked(lastcomment.body)
  68. body = body.replace /[\r\n]/g, " " # Remove whitespace
  69. body = body.replace /\<blockquote\>.*?\<\/blockquote\>/g, " " # Remove quotes
  70. body = body.replace /\<.*?\>/g, " " # Remove html codes
  71. if body.length > 60 # Strip if too long
  72. body = body[0..60].replace(/(.*) .*?$/, "$1") + " ..." # Keep the last 60 character and strip back until last space
  73. elem.find(".body").html(body)
  74. title_hash = lastcomment.post_title.replace(/[#?& ]/g, "+").replace(/[+]+/g, "+")
  75. elem.find(".postlink").text(lastcomment.post_title).attr("href", "?Post:#{lastcomment.post_id}:#{title_hash}#Comments")
  76. applyPagerdata: (page, limit, has_next) ->
  77. pager = $(".pager")
  78. if page > 1
  79. pager.find(".prev").css("display", "inline-block").attr("href", "?page=#{page-1}")
  80. if has_next
  81. pager.find(".next").css("display", "inline-block").attr("href", "?page=#{page+1}")
  82. routeUrl: (url) ->
  83. @log "Routing url:", url
  84. if match = url.match /Post:([0-9]+)/
  85. $("body").addClass("page-post")
  86. @post_id = parseInt(match[1])
  87. @pagePost()
  88. else
  89. $("body").addClass("page-main")
  90. if match = url.match /page=([0-9]+)/
  91. @page = parseInt(match[1])
  92. @pageMain()
  93. # - Pages -
  94. pagePost: () ->
  95. s = (+ new Date)
  96. @cmd "dbQuery", ["SELECT *, (SELECT COUNT(*) FROM post_vote WHERE post_vote.post_id = post.post_id) AS votes FROM post WHERE post_id = #{@post_id} LIMIT 1"], (res) =>
  97. parse_res = (res) =>
  98. if res.length
  99. post = res[0]
  100. @applyPostdata($(".post-full"), post, true)
  101. $(".post-full .like").attr("id", "post_like_#{post.post_id}").on "click", @submitPostVote
  102. Comments.pagePost(@post_id)
  103. else
  104. $(".post-full").html("<h1>Not found</h1>")
  105. @pageLoaded()
  106. Comments.checkCert()
  107. # Temporary dbschema bug workaround
  108. if res.error
  109. @cmd "dbQuery", ["SELECT *, -1 AS votes FROM post WHERE post_id = #{@post_id} LIMIT 1"], parse_res
  110. else
  111. parse_res(res)
  112. pageMain: ->
  113. limit = 15
  114. query = """
  115. SELECT
  116. post.*, COUNT(comment_id) AS comments,
  117. (SELECT COUNT(*) FROM post_vote WHERE post_vote.post_id = post.post_id) AS votes
  118. FROM post
  119. LEFT JOIN comment USING (post_id)
  120. GROUP BY post_id
  121. ORDER BY date_published DESC
  122. LIMIT #{(@page-1)*limit}, #{limit+1}
  123. """
  124. @cmd "dbQuery", [query], (res) =>
  125. parse_res = (res) =>
  126. s = (+ new Date)
  127. if res.length > limit # Has next page
  128. res.pop()
  129. @applyPagerdata(@page, limit, true)
  130. else
  131. @applyPagerdata(@page, limit, false)
  132. res.reverse()
  133. for post in res
  134. elem = $("#post_#{post.post_id}")
  135. if elem.length == 0 # Not exits yet
  136. elem = $(".post.template").clone().removeClass("template").attr("id", "post_#{post.post_id}")
  137. elem.prependTo(".posts")
  138. # elem.find(".score").attr("id", "post_score_#{post.post_id}").on "click", @submitPostVote # Submit vote
  139. elem.find(".like").attr("id", "post_like_#{post.post_id}").on "click", @submitPostVote
  140. @applyPostdata(elem, post)
  141. @pageLoaded()
  142. @log "Posts loaded in", ((+ new Date)-s),"ms"
  143. $(".posts .new").on "click", => # Create new blog post
  144. @cmd "fileGet", ["data/data.json"], (res) =>
  145. data = JSON.parse(res)
  146. # Add to data
  147. data.post.unshift
  148. post_id: data.next_post_id
  149. title: "New blog post"
  150. date_published: (+ new Date)/1000
  151. body: "Blog post body"
  152. data.next_post_id += 1
  153. # Create html elements
  154. elem = $(".post.template").clone().removeClass("template")
  155. @applyPostdata(elem, data.post[0])
  156. elem.hide()
  157. elem.prependTo(".posts").slideDown()
  158. @addInlineEditors(elem)
  159. @writeData(data)
  160. return false
  161. # Temporary dbschema bug workaround
  162. if res.error
  163. query = """
  164. SELECT
  165. post.*, COUNT(comment_id) AS comments,
  166. -1 AS votes
  167. FROM post
  168. LEFT JOIN comment USING (post_id)
  169. GROUP BY post_id
  170. ORDER BY date_published DESC
  171. LIMIT #{(@page-1)*limit}, #{limit+1}
  172. """
  173. @cmd "dbQuery", [query], parse_res
  174. else
  175. parse_res(res)
  176. # - EOF Pages -
  177. # All page content loaded
  178. pageLoaded: =>
  179. $("body").addClass("loaded") # Back/forward button keep position support
  180. $('pre code').each (i, block) -> # Higlight code blocks
  181. hljs.highlightBlock(block)
  182. @event_page_load.resolve()
  183. @cmd "innerLoaded", true
  184. addInlineEditors: (parent) ->
  185. @logStart "Adding inline editors"
  186. elems = $("[data-editable]:visible", parent)
  187. for elem in elems
  188. elem = $(elem)
  189. if not elem.data("editor") and not elem.hasClass("editor")
  190. editor = new InlineEditor(elem, @getContent, @saveContent, @getObject)
  191. elem.data("editor", editor)
  192. @logEnd "Adding inline editors"
  193. # Check if publishing is necessary
  194. checkPublishbar: ->
  195. if not @data["modified"] or @data["modified"] > @site_info.content.modified
  196. $(".publishbar").addClass("visible")
  197. else
  198. $(".publishbar").removeClass("visible")
  199. # Sign and Publish site
  200. publish: =>
  201. if @site_info.privatekey # Privatekey stored in users.json
  202. @cmd "sitePublish", ["stored"], (res) =>
  203. @log "Publish result:", res
  204. else
  205. @cmd "wrapperPrompt", ["Enter your private key:", "password"], (privatekey) => # Prompt the private key
  206. $(".publishbar .button").addClass("loading")
  207. @cmd "sitePublish", [privatekey], (res) =>
  208. $(".publishbar .button").removeClass("loading")
  209. @log "Publish result:", res
  210. return false # Ignore link default event
  211. # Apply from data to post html element
  212. applyPostdata: (elem, post, full=false) ->
  213. title_hash = post.title.replace(/[#?& ]/g, "+").replace(/[+]+/g, "+")
  214. elem.data("object", "Post:"+post.post_id)
  215. $(".title .editable", elem).html(post.title).attr("href", "?Post:#{post.post_id}:#{title_hash}").data("content", post.title)
  216. date_published = Time.since(post.date_published)
  217. # Published date
  218. if post.body.match /^---/m # Has more over fold
  219. date_published += " &middot; #{Time.readtime(post.body)}" # If has break add readtime
  220. $(".more", elem).css("display", "inline-block").attr("href", "?Post:#{post.post_id}:#{title_hash}")
  221. $(".details .published", elem).html(date_published).data("content", post.date_published)
  222. # Comments num
  223. if post.comments > 0
  224. $(".details .comments-num", elem).css("display", "inline").attr("href", "?Post:#{post.post_id}:#{title_hash}#Comments")
  225. $(".details .comments-num .num", elem).text("#{post.comments} comments")
  226. else
  227. $(".details .comments-num", elem).css("display", "none")
  228. ###
  229. if @my_post_votes[post.post_id] # Voted on it
  230. $(".score-inactive .score-num", elem).text post.votes-1
  231. $(".score-active .score-num", elem).text post.votes
  232. $(".score", elem).addClass("active")
  233. else # Not voted on it
  234. $(".score-inactive .score-num", elem).text post.votes
  235. $(".score-active .score-num", elem).text post.votes+1
  236. if post.votes == 0
  237. $(".score", elem).addClass("noscore")
  238. else
  239. $(".score", elem).removeClass("noscore")
  240. ###
  241. if post.votes > 0
  242. $(".like .num", elem).text post.votes
  243. else if post.votes == -1 # DB bug
  244. $(".like", elem).css("display", "none")
  245. else
  246. $(".like .num", elem).text ""
  247. if @my_post_votes[post.post_id] # Voted on it
  248. $(".like", elem).addClass("active")
  249. if full
  250. body = post.body
  251. else # On main page only show post until the first --- hr separator
  252. body = post.body.replace(/^([\s\S]*?)\n---\n[\s\S]*$/, "$1")
  253. if $(".body", elem).data("content") != post.body
  254. $(".body", elem).html(Text.renderMarked(body)).data("content", post.body)
  255. # Wrapper websocket connection ready
  256. onOpenWebsocket: (e) =>
  257. @loadData()
  258. @cmd "siteInfo", {}, (site_info) =>
  259. @setSiteinfo(site_info)
  260. query_my_votes = """
  261. SELECT
  262. 'post_vote' AS type,
  263. post_id AS uri
  264. FROM json
  265. LEFT JOIN post_vote USING (json_id)
  266. WHERE directory = 'users/#{@site_info.auth_address}' AND file_name = 'data.json'
  267. """
  268. @cmd "dbQuery", [query_my_votes], (res) =>
  269. for row in res
  270. @my_post_votes[row["uri"]] = 1
  271. @routeUrl(window.location.search.substring(1))
  272. @cmd "serverInfo", {}, (ret) => # Get server info
  273. @server_info = ret
  274. if @server_info.rev < 160
  275. @loadData("old")
  276. @loadLastcomments("noanim")
  277. # Returns the elem parent object
  278. getObject: (elem) =>
  279. return elem.parents("[data-object]:first")
  280. # Get content from data.json
  281. getContent: (elem, raw=false) =>
  282. [type, id] = @getObject(elem).data("object").split(":")
  283. id = parseInt(id)
  284. content = elem.data("content")
  285. if elem.data("editable-mode") == "timestamp" # Convert to time
  286. content = Time.date(content, "full")
  287. if elem.data("editable-mode") == "simple" or raw # No markdown
  288. return content
  289. else
  290. return Text.renderMarked(content)
  291. # Save content to data.json
  292. saveContent: (elem, content, cb=false) =>
  293. if elem.data("deletable") and content == null then return @deleteObject(elem, cb) # Its a delete request
  294. elem.data("content", content)
  295. [type, id] = @getObject(elem).data("object").split(":")
  296. id = parseInt(id)
  297. if type == "Post" or type == "Site"
  298. @saveSite(elem, type, id, content, cb)
  299. else if type == "Comment"
  300. @saveComment(elem, type, id, content, cb)
  301. saveSite: (elem, type, id, content, cb) ->
  302. @cmd "fileGet", ["data/data.json"], (res) =>
  303. data = JSON.parse(res)
  304. if type == "Post"
  305. post = (post for post in data.post when post.post_id == id)[0]
  306. if elem.data("editable-mode") == "timestamp" # Time parse to timestamp
  307. content = Time.timestamp(content)
  308. post[elem.data("editable")] = content
  309. else if type == "Site"
  310. data[elem.data("editable")] = content
  311. @writeData data, (res) =>
  312. if cb
  313. if res == true # OK
  314. if elem.data("editable-mode") == "simple" # No markdown
  315. cb(content)
  316. else if elem.data("editable-mode") == "timestamp" # Format timestamp
  317. cb(Time.since(content))
  318. else
  319. cb(Text.renderMarked(content))
  320. else # Error
  321. cb(false)
  322. saveComment: (elem, type, id, content, cb) ->
  323. @log "Saving comment...", id
  324. @getObject(elem).css "height", "auto"
  325. inner_path = "data/users/#{Page.site_info.auth_address}/data.json"
  326. Page.cmd "fileGet", {"inner_path": inner_path, "required": false}, (data) =>
  327. data = JSON.parse(data)
  328. comment = (comment for comment in data.comment when comment.comment_id == id)[0]
  329. comment[elem.data("editable")] = content
  330. json_raw = unescape(encodeURIComponent(JSON.stringify(data, undefined, '\t')))
  331. @writePublish inner_path, btoa(json_raw), (res) =>
  332. if res == true
  333. Comments.checkCert("updaterules")
  334. if cb then cb(Text.renderMarked(content, {"sanitize": true}))
  335. else
  336. @cmd "wrapperNotification", ["error", "File write error: #{res}"]
  337. if cb then cb(false)
  338. deleteObject: (elem, cb=False) ->
  339. [type, id] = elem.data("object").split(":")
  340. id = parseInt(id)
  341. if type == "Post"
  342. @cmd "fileGet", ["data/data.json"], (res) =>
  343. data = JSON.parse(res)
  344. if type == "Post"
  345. post = (post for post in data.post when post.post_id == id)[0]
  346. if not post then return false # No post found for this id
  347. data.post.splice(data.post.indexOf(post), 1) # Remove from data
  348. @writeData data, (res) =>
  349. if cb then cb()
  350. if res == true then elem.slideUp()
  351. else if type == "Comment"
  352. inner_path = "data/users/#{Page.site_info.auth_address}/data.json"
  353. @cmd "fileGet", {"inner_path": inner_path, "required": false}, (data) =>
  354. data = JSON.parse(data)
  355. comment = (comment for comment in data.comment when comment.comment_id == id)[0]
  356. data.comment.splice(data.comment.indexOf(comment), 1)
  357. json_raw = unescape(encodeURIComponent(JSON.stringify(data, undefined, '\t')))
  358. @writePublish inner_path, btoa(json_raw), (res) =>
  359. if res == true
  360. elem.slideUp()
  361. if cb then cb()
  362. writeData: (data, cb=null) ->
  363. if not data
  364. return @log "Data missing"
  365. @data["modified"] = data.modified = Time.timestamp()
  366. json_raw = unescape(encodeURIComponent(JSON.stringify(data, undefined, '\t'))) # Encode to json, encode utf8
  367. @cmd "fileWrite", ["data/data.json", btoa(json_raw)], (res) => # Convert to to base64 and send
  368. if res == "ok"
  369. if cb then cb(true)
  370. else
  371. @cmd "wrapperNotification", ["error", "File write error: #{res}"]
  372. if cb then cb(false)
  373. @checkPublishbar()
  374. # Updating title in content.json
  375. @cmd "fileGet", ["content.json"], (content) =>
  376. content = content.replace /"title": ".*?"/, "\"title\": \"#{data.title}\"" # Load as raw html to prevent js bignumber problems
  377. @cmd "fileWrite", ["content.json", btoa(content)], (res) =>
  378. if res != "ok"
  379. @cmd "wrapperNotification", ["error", "Content.json write error: #{res}"]
  380. # If the privatekey is stored sign the new content
  381. if @site_info["privatekey"]
  382. @cmd "siteSign", ["stored", "content.json"], (res) =>
  383. @log "Sign result", res
  384. writePublish: (inner_path, data, cb) ->
  385. @cmd "fileWrite", [inner_path, data], (res) =>
  386. if res != "ok" # fileWrite failed
  387. @cmd "wrapperNotification", ["error", "File write error: #{res}"]
  388. cb(false)
  389. return false
  390. @cmd "sitePublish", {"inner_path": inner_path}, (res) =>
  391. if res == "ok"
  392. cb(true)
  393. else
  394. cb(res)
  395. submitPostVote: (e) =>
  396. if not Page.site_info.cert_user_id # No selected cert
  397. Page.cmd "certSelect", [["zeroid.bit"]]
  398. return false
  399. elem = $(e.currentTarget)
  400. elem.toggleClass("active").addClass("loading")
  401. inner_path = "data/users/#{@site_info.auth_address}/data.json"
  402. Page.cmd "fileGet", {"inner_path": inner_path, "required": false}, (data) =>
  403. if data
  404. data = JSON.parse(data)
  405. else # Default data
  406. data = {"next_comment_id": 1, "comment": [], "comment_vote": {}, "post_vote": {} }
  407. if not data.post_vote
  408. data.post_vote = {}
  409. post_id = elem.attr("id").match("_([0-9]+)$")[1]
  410. if elem.hasClass("active")
  411. data.post_vote[post_id] = 1
  412. else
  413. delete data.post_vote[post_id]
  414. json_raw = unescape(encodeURIComponent(JSON.stringify(data, undefined, '\t')))
  415. current_num = parseInt elem.find(".num").text()
  416. if not current_num
  417. current_num = 0
  418. if elem.hasClass("active")
  419. elem.find(".num").text(current_num+1)
  420. else
  421. elem.find(".num").text(current_num-1)
  422. Page.writePublish inner_path, btoa(json_raw), (res) =>
  423. elem.removeClass("loading")
  424. @log "Writepublish result", res
  425. return false
  426. # Parse incoming requests
  427. onRequest: (cmd, message) ->
  428. if cmd == "setSiteInfo" # Site updated
  429. @actionSetSiteInfo(message)
  430. else
  431. @log "Unknown command", message
  432. # Siteinfo changed
  433. actionSetSiteInfo: (message) =>
  434. @setSiteinfo(message.params)
  435. @checkPublishbar()
  436. setSiteinfo: (site_info) =>
  437. @site_info = site_info
  438. @event_site_info.resolve(site_info)
  439. if $("body").hasClass("page-post") then Comments.checkCert() # Update if username changed
  440. # User commented
  441. if site_info.event?[0] == "file_done" and site_info.event[1].match /.*users.*data.json$/
  442. if $("body").hasClass("page-post")
  443. @pagePost()
  444. Comments.loadComments() # Post page, reload comments
  445. @loadLastcomments()
  446. if $("body").hasClass("page-main")
  447. RateLimit 500, =>
  448. @pageMain()
  449. @loadLastcomments()
  450. else if site_info.event?[0] == "file_done" and site_info.event[1] == "data/data.json"
  451. @loadData()
  452. if $("body").hasClass("page-main") then @pageMain()
  453. if $("body").hasClass("page-post") then @pagePost()
  454. window.Page = new ZeroBlog()