use IDs instead of slugs for posts

Drafts don’t have reliable slugs until they’re published so give them
UUIDs, and lookup posts by ID instead of slug.
This commit is contained in:
Sami Samhuri 2015-03-29 19:42:13 -07:00
parent d9731944c2
commit c905f5c414
3 changed files with 230 additions and 146 deletions

View file

@ -1,5 +1,6 @@
require 'fileutils'
require 'json'
require 'securerandom'
require './web_title_finder'
require './web_version_finder'
@ -20,7 +21,7 @@ class HarpBlog
end
class Post
PERSISTENT_FIELDS = %w[author title date timestamp link url tags].map(&:to_sym)
PERSISTENT_FIELDS = %w[id author title date timestamp link url tags].map(&:to_sym)
TRANSIENT_FIELDS = %w[time slug body draft].map(&:to_sym)
FIELDS = PERSISTENT_FIELDS + TRANSIENT_FIELDS
FIELDS.each { |f| attr_accessor f }
@ -83,10 +84,19 @@ class HarpBlog
@timestamp = timestamp
end
def id
@id ||=
if draft?
SecureRandom.uuid
else
slug
end
end
def url
@url ||=
if draft?
"/posts/drafts/#{slug}"
"/posts/drafts/#{id}"
else
"/posts/#{time.year}/#{padded_month}/#{slug}"
end
@ -94,7 +104,7 @@ class HarpBlog
def slug
# TODO: be intelligent about unicode ... \p{Word} might help. negated char class with it?
if title
if !draft? && title
@slug ||= title.downcase.
gsub(/'/, '').
gsub(/[^[:alpha:]\d_]/, '-').
@ -184,12 +194,12 @@ class HarpBlog
read_posts('drafts', draft: true)
end
def get_post(year, month, slug)
read_post(File.join(year, month), slug)
def get_post(year, month, id)
read_post(File.join(year, month), id)
end
def get_draft(slug)
read_post('drafts', slug, draft: true)
def get_draft(id)
read_post('drafts', id, draft: true)
end
def create_post(title, body, url, extra_fields = nil)
@ -208,15 +218,15 @@ class HarpBlog
post = Post.new(fields)
begin
existing_post = read_post(post.dir, post.slug, extra_fields)
existing_post = read_post(post.dir, post.id, extra_fields)
rescue InvalidDataError => e
$stderr.puts "[HarpBlog#create_post] deleting post with invalid data: #{e.message}"
delete_post_from_dir(post.dir, post.slug)
delete_post_from_dir(post.dir, post.id)
existing_post = nil
end
if existing_post
raise PostExistsError.new("post exists: #{post.dir}/#{post.slug}")
raise PostExistsError.new("post exists: #{post.dir}/#{post.id}")
else
save_post('create post', post)
end
@ -229,36 +239,36 @@ class HarpBlog
save_post('update post', post)
end
def delete_post(year, month, slug)
delete_post_from_dir(File.join(year, month), slug)
def delete_post(year, month, id)
delete_post_from_dir(File.join(year, month), id)
end
def delete_draft(slug)
delete_post_from_dir('drafts', slug)
def delete_draft(id)
delete_post_from_dir('drafts', id)
end
def publish_post(post)
if post.draft?
new_post = create_post(post.title, post.body, post.link)
delete_post_from_dir('drafts', post.slug)
delete_post_from_dir('drafts', post.id)
new_post
else
raise PostAlreadyPublishedError.new("post is already published: #{post.dir}/#{post.slug}")
raise PostAlreadyPublishedError.new("post is already published: #{post.dir}/#{post.id}")
end
end
def unpublish_post(post)
if post.draft?
raise PostNotPublishedError.new("post is not published: #{post.dir}/#{post.slug}")
raise PostNotPublishedError.new("post is not published: #{post.dir}/#{post.id}")
else
new_post = create_post(post.title, post.body, post.link, draft: true)
delete_post_from_dir(post.dir, post.slug)
delete_post_from_dir(post.dir, post.id)
new_post
end
end
def publish(production = false)
target = production ? 'publish' : 'publish_beta'
def publish(env)
target = env.to_s == 'production' ? 'publish' : 'publish_beta'
run("make #{target}")
end
@ -282,26 +292,40 @@ class HarpBlog
end
def read_posts(post_dir, extra_fields = nil)
extra_fields ||= {}
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.map do |id, fields|
fields[:id] = id
unless extra_fields[:draft]
fields[:slug] = id
end
post_filename = post_path(post_dir, "#{id}.md")
fields[:body] = File.read(post_filename)
Post.new(fields.merge(extra_fields))
end
end
def read_post(post_dir, slug, extra_fields = nil)
post_filename = post_path(post_dir, "#{slug}.md")
def read_post(post_dir, id, extra_fields = nil)
post_filename = post_path(post_dir, "#{id}.md")
post_data = read_post_data(post_path(post_dir))
if File.exist?(post_filename) && fields = post_data[slug]
if File.exist?(post_filename) && fields = post_data[id]
fields[:body] = File.read(post_filename)
Post.new(fields.merge(extra_fields || {}).merge(slug: slug))
if extra_fields
fields.merge!(extra_fields)
end
fields[:id] = id
unless fields[:draft]
fields[:slug] = id
end
Post.new(fields)
elsif fields
message = "missing post body for #{post_dir}/#{slug}: #{post_filename}"
message = "missing post body for #{post_dir}/#{id}: #{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"
message = "missing metadata for #{post_dir}/#{id}: #{post_dir}/_data.json"
$stderr.puts "[HarpBlog#read_post] #{message}"
raise InvalidDataError.new(message)
end
@ -330,43 +354,43 @@ class HarpBlog
unless post.draft?
ensure_post_dir_exists(post_dir)
end
write_post_body(post_dir, post.slug, post.body)
write_post_body(post_dir, post.id, post.body)
begin
write_post_index(post_dir, post.slug, post.persistent_fields)
write_post_index(post_dir, post.id, post.persistent_fields)
rescue => e
$stderr.puts "#{e.class}: #{e.message}"
$stderr.puts e.backtrace
delete_post_body(post_dir, post.slug)
delete_post_body(post_dir, post.id)
raise e
end
end
def delete_post_from_dir(post_dir, slug)
def delete_post_from_dir(post_dir, id)
post_dir = post_path(post_dir)
delete_post_body(post_dir, slug)
delete_post_index(post_dir, slug)
delete_post_body(post_dir, id)
delete_post_index(post_dir, id)
end
def write_post_body(dir, slug, body)
post_filename = File.join(dir, "#{slug}.md")
def write_post_body(dir, id, body)
post_filename = File.join(dir, "#{id}.md")
write_file(post_filename, body)
end
def delete_post_body(dir, slug)
post_filename = File.join(dir, "#{slug}.md")
def delete_post_body(dir, id)
post_filename = File.join(dir, "#{id}.md")
delete_file(post_filename)
end
def write_post_index(dir, slug, fields)
def write_post_index(dir, id, fields)
post_data = read_post_data(dir)
post_data[slug] = fields
post_data[id] = fields
write_post_data(dir, post_data)
end
def delete_post_index(dir, slug)
def delete_post_index(dir, id)
post_data = read_post_data(dir)
if post_data[slug]
post_data.delete(slug)
if post_data[id]
post_data.delete(id)
write_post_data(dir, post_data)
end
end

View file

@ -67,11 +67,36 @@ set :port, $config[:port]
blog = HarpBlog.new($config[:path], $config[:dry_run])
before do
if request.body.size > 0
type = request['HTTP_CONTENT_TYPE']
@fields =
case
when type =~ /^application\/json\b/
request.body.rewind
JSON.parse(request.body.read)
else
params
end
end
end
# status
get '/status' do
status 200
headers 'Content-Type' => 'application/json'
JSON.generate(blog.status)
JSON.generate(status: blog.status)
end
# publish the site
post '/publish' do
unless authenticated?(request['Auth'])
status 403
return 'forbidden'
end
blog.publish(@fields['env'])
status 204
end
# list years
@ -102,6 +127,17 @@ get '/posts/:year/?:month?' do |year, month|
JSON.generate(posts: posts.map(&:fields))
end
# list all published posts
get '/posts' do
posts = blog.months.map do |year, month|
blog.posts_for_month(year, month)
end.flatten
status 200
headers 'Content-Type' => 'application/json'
JSON.generate(posts: posts.map(&:fields))
end
# list drafts
get '/drafts' do
posts = blog.drafts
@ -112,9 +148,9 @@ get '/drafts' do
end
# get a post
get '/posts/:year/:month/:slug' do |year, month, slug|
get '/posts/:year/:month/:id' do |year, month, id|
begin
post = blog.get_post(year, month, slug)
post = blog.get_post(year, month, id)
rescue HarpBlog::InvalidDataError => e
status 500
return "Failed to get post, invalid data on disk: #{e.message}"
@ -140,9 +176,9 @@ get '/posts/:year/:month/:slug' do |year, month, slug|
end
# get a draft
get '/drafts/:slug' do |slug|
get '/drafts/:id' do |id|
begin
post = blog.get_draft(slug)
post = blog.get_draft(id)
rescue HarpBlog::InvalidDataError => e
status 500
return "Failed to get draft, invalid data on disk: #{e.message}"
@ -174,16 +210,25 @@ post '/drafts' do
return 'forbidden'
end
id, title, body, link = @fields.values_at('id', 'title', 'body', 'link')
begin
post = blog.create_post(params[:title], params[:body], params[:link], draft: true)
if post = blog.create_post(title, body, link, id: id, draft: true)
url = url_for(post.url)
status 201
headers 'Location' => url, 'Content-Type' => 'application/json'
JSON.generate(post: post.fields)
else
status 500
'failed to create post'
end
rescue HarpBlog::PostExistsError => e
post = HarpBlog::Post.new({
title: params[:title],
body: params[:body],
link: params[:link],
title: title,
body: body,
link: link,
})
status 409
return "refusing to clobber existing draft, update it instead: #{post.url}"
"refusing to clobber existing draft, update it instead: #{post.url}"
rescue HarpBlog::PostSaveError => e
status 500
if orig_err = e.original_error
@ -192,28 +237,19 @@ post '/drafts' do
"Failed to create draft: #{e.message}"
end
end
if post
url = url_for(post.url)
status 201
headers 'Location' => url, 'Content-Type' => 'application/json'
JSON.generate(post: post.fields)
else
status 500
'failed to create post'
end
end
# update a post
put '/posts/:year/:month/:slug' do |year, month, slug|
put '/posts/:year/:month/:id' do |year, month, id|
unless authenticated?(request['Auth'])
status 403
return 'forbidden'
end
title, body, link = @field.values_at('title', 'body', 'link')
begin
if post = blog.get_post(year, month, slug)
blog.update_post(post, params[:title], params[:body], params[:link])
if post = blog.get_post(year, month, id)
blog.update_post(post, title, body, link)
status 204
else
status 404
@ -233,15 +269,16 @@ put '/posts/:year/:month/:slug' do |year, month, slug|
end
# update a draft
put '/drafts/:slug' do |slug|
put '/drafts/:id' do |id|
unless authenticated?(request['Auth'])
status 403
return 'forbidden'
end
title, body, link = @field.values_at('title', 'body', 'link')
begin
if post = blog.get_draft(slug)
blog.update_post(post, params[:title], params[:body], params[:link])
if post = blog.get_draft(id)
blog.update_post(post, title, body, link)
status 204
else
status 404
@ -261,35 +298,35 @@ put '/drafts/:slug' do |slug|
end
# delete a post
delete '/posts/:year/:month/:slug' do |year, month, slug|
delete '/posts/:year/:month/:id' do |year, month, id|
unless authenticated?(request['Auth'])
status 403
return 'forbidden'
end
blog.delete_post(year, month, slug)
blog.delete_post(year, month, id)
status 204
end
# delete a draft
delete '/drafts/:slug' do |slug|
delete '/drafts/:id' do |id|
unless authenticated?(request['Auth'])
status 403
return 'forbidden'
end
blog.delete_draft(slug)
blog.delete_draft(id)
status 204
end
# publish a post
post '/drafts/:slug/publish' do |slug|
post '/drafts/:id/publish' do |id|
unless authenticated?(request['Auth'])
status 403
return 'forbidden'
end
if post = blog.get_draft(slug)
if post = blog.get_draft(id)
new_post = blog.publish_post(post)
status 201
headers 'Location' => url_for(new_post.url), 'Content-Type' => 'application/json'
@ -301,13 +338,13 @@ post '/drafts/:slug/publish' do |slug|
end
# unpublish a post
post '/posts/:year/:month/:slug/unpublish' do |year, month, slug|
post '/posts/:year/:month/:id/unpublish' do |year, month, id|
unless authenticated?(request['Auth'])
status 403
return 'forbidden'
end
if post = blog.get_post(year, month, slug)
if post = blog.get_post(year, month, id)
new_post = blog.unpublish_post(post)
status 201
headers 'Location' => url_for(new_post.url), 'Content-Type' => 'application/json'
@ -317,15 +354,3 @@ post '/posts/:year/:month/:slug/unpublish' do |year, month, slug|
'not found'
end
end
# publish the site
post '/publish' do
unless authenticated?(request['Auth'])
status 403
return 'forbidden'
end
production = params[:env] == 'production'
blog.publish(production)
status 204
end

View file

@ -11,21 +11,28 @@ end
RSpec.describe HarpBlog::Post do
# Persistent fields: author, title, date, timestamp, link, url, tags
# Persistent fields: id, author, title, date, timestamp, link, url, tags
# Transient fields: time, slug, body
before :all do
@default_fields = {
@post_fields = {
title: 'samhuri.net',
link: 'http://samhuri.net',
body: 'this site is sick',
}
@default_slug = 'samhuri-net'
@post_slug = 'samhuri-net'
@draft_fields = {
title: 'reddit.com',
link: 'http://reddit.com',
body: 'hi reddit',
draft: true,
id: 'dummy-draft-id',
}
end
describe '#new' do
it "takes a Hash of fields" do
fields = @default_fields
fields = @post_fields
post = HarpBlog::Post.new(fields)
expect(post.title).to eq(fields[:title])
expect(post.link).to eq(fields[:link])
@ -61,7 +68,7 @@ RSpec.describe HarpBlog::Post do
describe '#link?' do
it "returns true for link posts" do
post = HarpBlog::Post.new(link: @default_fields[:link])
post = HarpBlog::Post.new(link: @post_fields[:link])
expect(post.link?).to be_truthy
end
@ -101,18 +108,38 @@ RSpec.describe HarpBlog::Post do
describe '#url' do
it "should be derived from the time and slug if necessary" do
post = HarpBlog::Post.new(@default_fields)
post = HarpBlog::Post.new(@post_fields)
year = post.time.year.to_s
month = post.time.month
padded_month = month < 10 ? " #{month}" : "#{month}"
expect(post.url).to eq("/posts/#{year}/#{padded_month}/#{@default_slug}")
padded_month = month < 10 ? "0#{month}" : "#{month}"
expect(post.url).to eq("/posts/#{year}/#{padded_month}/#{@post_slug}")
end
end
describe '#id' do
it "should be generated for drafts if necessary" do
draft = HarpBlog::Post.new(@draft_fields)
expect(draft.id).to eq(@draft_fields[:id])
draft = HarpBlog::Post.new(@draft_fields.merge(id: nil))
expect(draft.id).to_not eq(@draft_fields[:id])
end
it "should be the slug for posts" do
post = HarpBlog::Post.new(@post_fields)
expect(post.id).to eq(post.slug)
end
end
describe '#slug' do
it "should be derived from the title if necessary" do
post = HarpBlog::Post.new(@default_fields)
expect(post.slug).to eq(@default_slug)
post = HarpBlog::Post.new(@post_fields)
expect(post.slug).to eq(@post_slug)
end
it "should be nil for drafts" do
draft = HarpBlog::Post.new(@draft_fields)
expect(draft.slug).to be_nil
end
it "should strip apostrophes" do
@ -303,20 +330,21 @@ RSpec.describe HarpBlog do
describe '#get_draft' do
it "should return complete posts" do
id = 'some-draft-id'
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)
@blog.create_post(title, body, nil, id: id, draft: true)
draft = @blog.get_draft(id)
expect(draft).to be_truthy
expect(draft.title).to eq(title)
expect(draft.url).to eq("/posts/drafts/#{id}")
expect(draft.draft?).to be_truthy
expect(draft.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)
draft = @blog.get_draft('does-not-exist')
expect(draft).to be(nil)
end
end
@ -369,13 +397,14 @@ RSpec.describe HarpBlog do
end
it "should create a draft that can be fetched immediately" do
id = 'another-draft-id'
title = 'fetch now'
body = 'blah blah blah'
post = @blog.create_post(title, body, nil, draft: true)
expect(post).to be_truthy
draft = @blog.create_post(title, body, nil, id: id, draft: true)
expect(draft).to be_truthy
fetched_post = @blog.get_draft(post.slug)
expect(post.url).to eq(fetched_post.url)
fetched_draft = @blog.get_draft(draft.id)
expect(draft.url).to eq(fetched_draft.url)
end
it "should fetch titles if necessary" do
@ -434,49 +463,54 @@ RSpec.describe HarpBlog do
describe '#delete_draft' do
it "should delete existing drafts" do
id = 'bunk-draft-id'
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
existing_draft = @blog.create_post(title, body, nil, id: id, draft: true)
draft = @blog.get_draft(existing_draft.id)
expect(draft).to be_truthy
@blog.delete_draft(post.slug)
@blog.delete_draft(draft.id)
post = @blog.get_draft(post.slug)
expect(post).to eq(nil)
draft = @blog.get_draft(draft.id)
expect(draft).to eq(nil)
end
it "should do nothing for non-existent posts" do
id = 'missing-draft-id'
title = 'new draft'
body = 'blah blah blah'
existing_post = @blog.create_post(title, body, nil, draft: true)
existing_draft = @blog.create_post(title, body, nil, id: id, draft: true)
post = @blog.get_draft(existing_post.slug)
expect(post).to be_truthy
draft = @blog.get_draft(existing_draft.id)
expect(draft).to be_truthy
@blog.delete_draft(post.slug)
@blog.delete_draft(post.slug)
@blog.delete_draft(draft.id)
expect(@blog.get_draft(existing_draft.id)).to be_nil
@blog.delete_draft(draft.id)
end
end
describe '#publish_post' do
it "should publish drafts" do
id = 'this-draft-is-a-keeper'
title = 'a-shiny-new-post'
body = 'blah blah blah'
link = 'http://samhuri.net'
post = @blog.create_post(title, body, link, draft: true)
new_post = @blog.publish_post(post)
expect(new_post).to be_truthy
expect(new_post.draft?).to be_falsy
expect(new_post.title).to eq(title)
expect(new_post.body).to eq(body)
expect(new_post.link).to eq(link)
draft = @blog.get_draft(post.slug)
expect(draft).to eq(nil)
post = @blog.get_post(post.time.year.to_s, post.padded_month, post.slug)
draft = @blog.create_post(title, body, link, id: id, draft: true)
post = @blog.publish_post(draft)
expect(post).to be_truthy
expect(post.id).to eq(post.slug)
expect(post.draft?).to be_falsy
expect(post.title).to eq(title)
expect(post.body).to eq(body)
expect(post.link).to eq(link)
missing_draft = @blog.get_draft(draft.id)
expect(missing_draft).to eq(nil)
fetched_post = @blog.get_post(post.time.year.to_s, post.padded_month, post.slug)
expect(fetched_post).to be_truthy
end
it "should raise an error for published posts" do
@ -488,18 +522,19 @@ RSpec.describe HarpBlog do
describe '#unpublish_post' do
it "should unpublish posts" do
post = @blog.get_post('2006', '02', 'first-post')
new_post = @blog.unpublish_post(post)
expect(new_post).to be_truthy
expect(new_post.draft?).to be_truthy
expect(new_post.title).to eq(post.title)
expect(new_post.body).to eq(post.body)
expect(new_post.link).to eq(post.link)
post = @blog.get_post(post.time.year.to_s, post.padded_month, post.slug)
expect(post).to eq(nil)
draft = @blog.get_draft(new_post.slug)
draft = @blog.unpublish_post(post)
expect(draft).to be_truthy
expect(draft.id).to be_truthy
expect(draft.draft?).to be_truthy
expect(draft.title).to eq(post.title)
expect(draft.body).to eq(post.body)
expect(draft.link).to eq(post.link)
missing_post = @blog.get_post(post.time.year.to_s, post.padded_month, post.slug)
expect(missing_post).to eq(nil)
fetched_draft = @blog.get_draft(draft.id)
expect(fetched_draft).to be_truthy
end
it "should raise an error for drafts" do