diff --git a/server/harp_blog.rb b/server/harp_blog.rb index 4af4515..43c3f94 100644 --- a/server/harp_blog.rb +++ b/server/harp_blog.rb @@ -19,7 +19,7 @@ class HarpBlog class Post PERSISTENT_FIELDS = %w[author title date timestamp link url tags].map(&:to_sym) - TRANSIENT_FIELDS = %w[time slug body].map(&:to_sym) + TRANSIENT_FIELDS = %w[time slug body draft].map(&:to_sym) FIELDS = PERSISTENT_FIELDS + TRANSIENT_FIELDS attr_accessor *FIELDS @@ -51,9 +51,8 @@ class HarpBlog !!link end - def title=(title) - @slug = nil - @title = title + def draft? + @draft end def author @@ -69,7 +68,12 @@ class HarpBlog end def url - @url ||= "/posts/#{time.year}/#{padded_month}/#{slug}" + @url ||= + if draft? + "/posts/drafts/#{slug}" + else + "/posts/#{time.year}/#{padded_month}/#{slug}" + end end def slug @@ -95,6 +99,14 @@ class HarpBlog pad(time.month) end + def dir + if draft? + 'drafts' + else + File.join(time.year.to_s, padded_month) + end + end + def pad(n) n.to_i < 10 ? "0#{n}" : "#{n}" end @@ -149,71 +161,64 @@ class HarpBlog end def posts_for_month(year, month) - post_dir = post_path(year, month) - post_data = read_post_data(post_dir) - post_data.values.sort_by { |p| p['timestamp'] }.map { |p| Post.new(p) } + read_posts(File.join(year, month)) + end + + def drafts + read_posts('drafts', draft: true) end def get_post(year, month, slug) - post_dir = post_path(year, month) - post_filename = File.join(post_dir, "#{slug}.md") - post_data = read_post_data(post_dir) - if File.exist?(post_filename) && fields = post_data[slug] - fields[:body] = File.read(post_filename) - Post.new(fields) - elsif fields - message = "missing post body for #{year}/#{month}/#{slug}: #{post_filename}" - $stderr.puts "[HarpBlog#get_post] #{message}" - raise InvalidDataError.new(message) - elsif File.exist?(post_filename) - message = "missing metadata for #{year}/#{month}/#{slug}: #{post_dir}/_data.json" - $stderr.puts "[HarpBlog#get_post] #{message}" - raise InvalidDataError.new(message) - end + read_post(File.join(year, month), slug) end - def create_post(title, body, link) + def get_draft(slug) + read_post('drafts', slug, draft: true) + end + + def create_post(title, body, link, extra_fields = nil) if !title || title.strip.length == 0 title = find_title(link) end unless title raise "cannot find title for #{link}" end - fields = { + extra_fields ||= {} + fields = extra_fields.merge({ title: title, link: link, body: body, - } + }) post = Post.new(fields) - year, month, slug = post.time.year, post.padded_month, post.slug begin - existing_post = get_post(year.to_s, month, slug) + existing_post = read_post(post.dir, post.slug, extra_fields) rescue InvalidDataError => e $stderr.puts "[HarpBlog#create_post] deleting post with invalid data: #{e.message}" - delete_post(year.to_s, month, slug) + delete_post_from_dir(post.dir, post.slug) existing_post = nil end if existing_post - raise PostExistsError.new("post exists: #{year}/#{month}/#{slug}") + raise PostExistsError.new("post exists: #{post.dir}/#{post.slug}") else - save_post(post) + save_post('create post', post) end end def update_post(post, title, body, link) - old_slug = post.slug post.title = title post.body = body post.link = link - save_post(post, old_slug) + save_post('update post', post) end def delete_post(year, month, slug) - post_dir = post_path(year, month) - delete_post_body(post_dir, slug) - delete_post_index(post_dir, slug) + delete_post_from_dir(File.join(year, month), slug) + end + + def delete_draft(slug) + delete_post_from_dir('drafts', slug) end def publish(production = false) @@ -236,37 +241,74 @@ class HarpBlog path_for('public/posts', *components) end - def save_post(post, old_slug = nil) + def drafts_path(*components) + post_path('drafts', *components) + end + + def read_posts(post_dir, extra_fields = nil) + post_data = read_post_data(post_path(post_dir)) + post_data.sort_by do |k, v| + (v['timestamp'] || Time.now).to_i + end.map do |slug, fields| + Post.new(fields.merge(extra_fields || {}).merge(slug: slug)) + end + end + + def read_post(post_dir, slug, extra_fields = nil) + post_filename = post_path(post_dir, "#{slug}.md") + post_data = read_post_data(post_path(post_dir)) + if File.exist?(post_filename) && fields = post_data[slug] + fields[:body] = File.read(post_filename) + Post.new(fields.merge(extra_fields || {}).merge(slug: slug)) + elsif fields + message = "missing post body for #{post_dir}/#{slug}: #{post_filename}" + $stderr.puts "[HarpBlog#read_post] #{message}" + raise InvalidDataError.new(message) + elsif File.exist?(post_filename) + message = "missing metadata for #{post_dir}/#{slug}: #{post_dir}/_data.json" + $stderr.puts "[HarpBlog#read_post] #{message}" + raise InvalidDataError.new(message) + end + end + + def save_post(action, post) git_fetch git_reset_hard('origin/master') begin - post_dir = write_post(post, old_slug) - git_commit(post.title, post_dir) + write_post(post) + git_commit(action, post.title, post_path(post.dir)) git_push post rescue => e + $stderr.puts "#{e.class}: #{e.message}" + $stderr.puts e.backtrace git_reset_hard raise PostSaveError.new('failed to save post', e) end end - def write_post(post, old_slug = nil) - post_dir = post_path(post.time.year.to_s, post.padded_month) - ensure_post_dir_exists(post_dir) - if old_slug - delete_post_body(post_dir, old_slug) - delete_post_index(post_dir, old_slug) + def write_post(post) + post_dir = post_path(post.dir) + unless post.draft? + ensure_post_dir_exists(post_dir) end write_post_body(post_dir, post.slug, post.body) begin write_post_index(post_dir, post.slug, post.persistent_fields) rescue => e + $stderr.puts "#{e.class}: #{e.message}" + $stderr.puts e.backtrace delete_post_body(post_dir, post.slug) raise e end - post_dir + end + + def delete_post_from_dir(post_dir, slug) + post_dir = post_path(post_dir) + delete_post_body(post_dir, slug) + delete_post_index(post_dir, slug) end def write_post_body(dir, slug, body) @@ -287,8 +329,10 @@ class HarpBlog def delete_post_index(dir, slug) post_data = read_post_data(dir) - post_data.delete(slug) - write_post_data(dir, post_data) + if post_data[slug] + post_data.delete(slug) + write_post_data(dir, post_data) + end end def ensure_post_dir_exists(dir) @@ -383,9 +427,9 @@ class HarpBlog end end - def git_commit(title, *files) + def git_commit(action, title, *files) quoted_files = files.map { |f| "\"#{quote(f)}\"" } - message = "linked '#{quote(title)}'" + message = "#{action} '#{quote(title)}'" run("git add -A #{quoted_files.join(' ')} && git commit -m \"#{message}\"") end diff --git a/server/server.rb b/server/server.rb index ccb4918..7bf9ec8 100755 --- a/server/server.rb +++ b/server/server.rb @@ -88,7 +88,7 @@ get '/months' do JSON.generate(months: blog.months) end -# list posts +# list published posts get '/posts/:year/?:month?' do |year, month| posts = if month @@ -102,6 +102,15 @@ get '/posts/:year/?:month?' do |year, month| JSON.generate(posts: posts.map(&:fields)) end +# list drafts +get '/drafts' do + posts = blog.drafts + + status 200 + headers 'Content-Type' => 'application/json' + JSON.generate(posts: posts.map(&:fields)) +end + # get a post get '/posts/:year/:month/:slug' do |year, month, slug| begin @@ -130,15 +139,43 @@ get '/posts/:year/:month/:slug' do |year, month, slug| end end -# make a post -post '/posts' do +# get a draft +get '/drafts/:slug' do |slug| + begin + post = blog.get_draft(slug) + rescue HarpBlog::InvalidDataError => e + status 500 + return "Failed to get draft, invalid data on disk: #{e.message}" + end + + if post + if request.accept?('application/json') + status 200 + headers 'Content-Type' => 'application/json' + JSON.generate(post: post.fields) + elsif request.accept?('text/html') + status 200 + headers 'Content-Type' => 'text/html' + blog.render_post(post.fields) + else + status 400 + "content not available in an acceptable format: #{request.accept.join(', ')}" + end + else + status 404 + 'not found' + end +end + +# make a draft +post '/drafts' do unless authenticated?(request['Auth']) status 403 return 'forbidden' end begin - post = blog.create_post(params[:title], params[:body], params[:link]) + post = blog.create_post(params[:title], params[:body], params[:link], draft: true) rescue HarpBlog::PostExistsError => e post = HarpBlog::Post.new({ title: params[:title], @@ -146,13 +183,13 @@ post '/posts' do link: params[:link], }) status 409 - return "refusing to clobber existing post, update it instead: #{post.url}" + return "refusing to clobber existing draft, update it instead: #{post.url}" rescue HarpBlog::PostSaveError => e status 500 if orig_err = e.original_error "#{e.message} -- #{orig_err.class}: #{orig_err.message}" else - "Failed to create post: #{e.message}" + "Failed to create draft: #{e.message}" end end @@ -190,7 +227,35 @@ put '/posts/:year/:month/:slug' do |year, month, slug| if orig_err = e.original_error "#{e.message} -- #{orig_err.class}: #{orig_err.message}" else - "Failed to create post: #{e.message}" + "Failed to update post: #{e.message}" + end + end +end + +# update a draft +put '/drafts/:slug' do |slug| + unless authenticated?(request['Auth']) + status 403 + return 'forbidden' + end + + begin + if post = blog.get_draft(slug) + blog.update_post(post, params[:title], params[:body], params[:link]) + status 204 + else + status 404 + 'not found' + end + rescue HarpBlog::InvalidDataError => e + status 500 + "Failed to update draft, invalid data on disk: #{e.message}" + rescue HarpBlog::PostSaveError => e + status 500 + if orig_err = e.original_error + "#{e.message} -- #{orig_err.class}: #{orig_err.message}" + else + "Failed to update draft: #{e.message}" end end end @@ -206,6 +271,17 @@ delete '/posts/:year/:month/:slug' do |year, month, slug| status 204 end +# delete a draft +delete '/drafts/:slug' do |slug| + unless authenticated?(request['Auth']) + status 403 + return 'forbidden' + end + + blog.delete_draft(slug) + status 204 +end + # publish post '/publish' do unless authenticated?(request['Auth']) diff --git a/server/spec/harp_blog_spec.rb b/server/spec/harp_blog_spec.rb index b4d309c..02deb22 100644 --- a/server/spec/harp_blog_spec.rb +++ b/server/spec/harp_blog_spec.rb @@ -62,12 +62,24 @@ RSpec.describe HarpBlog::Post do describe '#link?' do it "returns true for link posts" do post = HarpBlog::Post.new(link: @default_fields[:link]) - expect(post.link?).to eq(true) + expect(post.link?).to be_truthy end it "returns false for article posts" do post = HarpBlog::Post.new - expect(post.link?).to eq(false) + expect(post.link?).to be_falsy + end + end + + describe '#draft?' do + it "returns true for draft posts" do + post = HarpBlog::Post.new(draft: true) + expect(post.draft?).to be_truthy + end + + it "returns false for published posts" do + post = HarpBlog::Post.new + expect(post.draft?).to be_falsy end end @@ -124,6 +136,18 @@ RSpec.describe HarpBlog::Post do end end + describe '#dir' do + it "returns the drafts dir for draft posts" do + post = HarpBlog::Post.new(draft: true) + expect(post.dir).to eq('drafts') + end + + it "returns the dated dir for published posts" do + post = HarpBlog::Post.new + expect(post.dir).to eq("#{post.time.year}/#{post.padded_month}") + end + end + describe '#pad' do it "should have a leading zero for integers 0 < n < 10" do post = HarpBlog::Post.new @@ -183,7 +207,6 @@ RSpec.describe HarpBlog do @blog.create_post('title', 'body', nil) expect(@blog.dirty?).to be_truthy - @blog.publish @mock_version_finder.version = @blog.local_version expect(@blog.dirty?).to be_falsy end @@ -244,6 +267,59 @@ RSpec.describe HarpBlog do end end + describe '#drafts' do + it "returns the correct number of posts" do + expect(@blog.drafts.length).to eq(2) + end + + it "should sort the posts by publish time" do + timestamps = @blog.drafts.map(&:timestamp) + expect(increasing?(timestamps)).to be_truthy + end + end + + describe '#get_post' do + it "should return complete posts" do + first_post_path = File.join(TEST_BLOG_PATH, 'public/posts/2006/02/first-post.md') + post = @blog.get_post('2006', '02', 'first-post') + expect(post).to be_truthy + expect(post.author).to eq('Sami Samhuri') + expect(post.title).to eq('First Post!') + expect(post.slug).to eq('first-post') + expect(post.timestamp).to eq(1139368860) + expect(post.date).to eq('8th February, 2006') + expect(post.url).to eq('/posts/2006/02/first-post') + expect(post.link).to eq(nil) + expect(post.link?).to be_falsy + expect(post.tags).to eq(['life']) + expect(post.body).to eq(File.read(first_post_path)) + end + + it "should return nil if the post does not exist" do + post = @blog.get_post('2005', '01', 'anything') + expect(post).to be(nil) + end + end + + describe '#get_draft' do + it "should return complete posts" do + title = 'new draft' + body = "blah blah blah\n" + @blog.create_post(title, body, nil, draft: true) + post = @blog.get_draft('new-draft') + expect(post).to be_truthy + expect(post.title).to eq(title) + expect(post.url).to eq('/posts/drafts/new-draft') + expect(post.draft?).to be_truthy + expect(post.body).to eq(body) + end + + it "should return nil if the post does not exist" do + post = @blog.get_draft('does-not-exist') + expect(post).to be(nil) + end + end + describe '#create_post' do it "should create a link post when a link is given" do title = 'test post' @@ -270,6 +346,15 @@ RSpec.describe HarpBlog do expect(post.time.to_date).to eq(Date.today) end + it "should create a draft post" do + title = 'test draft' + body = 'check this out' + post = @blog.create_post(title, body, nil, draft: true) + expect(post).to be_truthy + expect(post.draft?).to be_truthy + expect(post.dir).to eq('drafts') + end + it "should create a post that can be fetched immediately" do title = 'fetch now' body = 'blah blah blah' @@ -280,6 +365,16 @@ RSpec.describe HarpBlog do year = today.year.to_s month = post.pad(today.month) fetched_post = @blog.get_post(year, month, post.slug) + expect(fetched_post.url).to eq(post.url) + end + + it "should create a draft that can be fetched immediately" do + title = 'fetch now' + body = 'blah blah blah' + post = @blog.create_post(title, body, nil, draft: true) + expect(post).to be_truthy + + fetched_post = @blog.get_draft(post.slug) expect(post.url).to eq(fetched_post.url) end @@ -301,29 +396,6 @@ RSpec.describe HarpBlog do end end - describe '#get_post' do - it "should return complete posts" do - first_post_path = File.join(TEST_BLOG_PATH, 'public/posts/2006/02/first-post.md') - post = @blog.get_post('2006', '02', 'first-post') - expect(post).to be_truthy - expect(post.author).to eq('Sami Samhuri') - expect(post.title).to eq('First Post!') - expect(post.slug).to eq('first-post') - expect(post.timestamp).to eq(1139368860) - expect(post.date).to eq('8th February, 2006') - expect(post.url).to eq('/posts/2006/02/first-post') - expect(post.link).to eq(nil) - expect(post.link?).to eq(false) - expect(post.tags).to eq(['life']) - expect(post.body).to eq(File.read(first_post_path)) - end - - it "should return nil if the post does not exist" do - post = @blog.get_post('2005', '01', 'anything') - expect(post).to be(nil) - end - end - describe '#update_post' do it "should immediately reflect changes when fetched" do post = @blog.get_post('2006', '02', 'first-post') @@ -333,14 +405,10 @@ RSpec.describe HarpBlog do @blog.update_post(post, title, body, link) # new slug, new data - post = @blog.get_post('2006', '02', 'new-title') + post = @blog.get_post('2006', '02', 'first-post') expect(post.title).to eq(title) expect(post.body).to eq(body) expect(post.link).to eq(link) - - # old post is long gone - post = @blog.get_post('2006', '02', 'first-post') - expect(post).to eq(nil) end end @@ -364,4 +432,31 @@ RSpec.describe HarpBlog do end end + describe '#delete_draft' do + it "should delete existing drafts" do + title = 'new draft' + body = 'blah blah blah' + existing_post = @blog.create_post(title, body, nil, draft: true) + post = @blog.get_draft(existing_post.slug) + expect(post).to be_truthy + + @blog.delete_draft(post.slug) + + post = @blog.get_draft(post.slug) + expect(post).to eq(nil) + end + + it "should do nothing for non-existent posts" do + title = 'new draft' + body = 'blah blah blah' + existing_post = @blog.create_post(title, body, nil, draft: true) + + post = @blog.get_draft(existing_post.slug) + expect(post).to be_truthy + + @blog.delete_draft(post.slug) + @blog.delete_draft(post.slug) + end + end + end