mirror of
https://github.com/samsonjs/samhuri.net.git
synced 2026-03-25 09:05:47 +00:00
467 lines
11 KiB
Ruby
467 lines
11 KiB
Ruby
require 'fileutils'
|
|
require 'json'
|
|
require './harp_blog/post'
|
|
require './web_title_finder'
|
|
require './web_version_finder'
|
|
|
|
class HarpBlog
|
|
|
|
class HarpBlogError < RuntimeError ; end
|
|
class InvalidDataError < HarpBlogError ; end
|
|
class PostExistsError < HarpBlogError ; end
|
|
class PostAlreadyPublishedError < HarpBlogError ; end
|
|
class PostNotPublishedError < HarpBlogError ; end
|
|
|
|
class PostSaveError < HarpBlogError
|
|
attr_reader :original_error
|
|
def initialize(message, original_error)
|
|
super(message)
|
|
@original_error = original_error
|
|
end
|
|
end
|
|
|
|
def initialize(path, dry_run = true, title_finder = nil, version_finder = nil)
|
|
@path = path
|
|
@dry_run = dry_run
|
|
@title_finder = title_finder || WebTitleFinder.new
|
|
@version_finder = version_finder || WebVersionFinder.new
|
|
@mutated = false
|
|
end
|
|
|
|
def local_version
|
|
git_sha
|
|
end
|
|
|
|
def remote_version
|
|
@version_finder.find_version
|
|
end
|
|
|
|
def dirty?
|
|
local_version != remote_version
|
|
end
|
|
|
|
def status
|
|
local = local_version
|
|
remote = remote_version
|
|
{
|
|
'local-version' => local,
|
|
'remote-version' => remote,
|
|
'dirty' => local != remote,
|
|
}
|
|
end
|
|
|
|
def years
|
|
update_if_needed
|
|
Dir[post_path('20*')].map { |x| File.basename(x) }.sort
|
|
end
|
|
|
|
def months
|
|
update_if_needed
|
|
years.map do |year|
|
|
# hack: month dirs (and only month dirs) are always 2 characters in length
|
|
Dir[post_path("#{year}/??")].map { |x| [year, File.basename(x)] }
|
|
end.flatten(1).sort
|
|
end
|
|
|
|
def posts_for_year(year)
|
|
posts = []
|
|
1.upto(12) do |n|
|
|
month = n < 10 ? "0#{n}" : "#{n}"
|
|
posts += posts_for_month(year, month)
|
|
end
|
|
posts
|
|
end
|
|
|
|
def posts_for_month(year, month)
|
|
read_posts(File.join(year, month))
|
|
end
|
|
|
|
def drafts
|
|
read_posts('drafts', draft: true)
|
|
end
|
|
|
|
def get_post(year, month, id)
|
|
read_post(File.join(year, month), id)
|
|
end
|
|
|
|
def get_draft(id)
|
|
read_post('drafts', id, draft: true)
|
|
end
|
|
|
|
def create_post(title, body, url, extra_fields = nil, options = nil)
|
|
if !title || title.strip.length == 0
|
|
title = url && find_title(url) or 'Untitled'
|
|
end
|
|
extra_fields ||= {}
|
|
options ||= {}
|
|
options[:commit] = true unless options.has_key?(:commit)
|
|
fields = extra_fields.merge({
|
|
title: title,
|
|
link: url,
|
|
body: body,
|
|
})
|
|
post = Post.new(fields)
|
|
|
|
begin
|
|
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.id)
|
|
existing_post = nil
|
|
end
|
|
|
|
if existing_post
|
|
raise PostExistsError.new("post exists: #{post.dir}/#{post.id}")
|
|
else
|
|
save_post(post)
|
|
if options[:commit]
|
|
git_commit("create post '#{post.title}'", [year_index_path("#{post.time.year}"), post_path(post.dir)])
|
|
end
|
|
post
|
|
end
|
|
end
|
|
|
|
def update_post(post, title, body, link, timestamp = nil)
|
|
post.title = title
|
|
post.body = body
|
|
post.link = link
|
|
post.timestamp = timestamp if timestamp
|
|
save_post(post)
|
|
git_commit("update post '#{post.title}'", [post_path(post.dir)])
|
|
post
|
|
end
|
|
|
|
def delete_post(year, month, id)
|
|
dir = File.join(year, month)
|
|
delete_post_from_dir(dir, id)
|
|
git_commit("delete post #{year}/#{month}/#{id}", [post_path(dir)])
|
|
end
|
|
|
|
def delete_draft(id, options = nil)
|
|
options ||= {}
|
|
options[:commit] = true unless options.has_key?(:commit)
|
|
delete_post_from_dir('drafts', id)
|
|
if options[:commit]
|
|
git_commit("delete draft #{id}", [post_path('drafts')])
|
|
end
|
|
end
|
|
|
|
def publish_post(post)
|
|
if post.draft?
|
|
new_post = create_post(post.title, post.body, post.link, {}, {commit: false})
|
|
delete_post_from_dir('drafts', post.id)
|
|
build_rss
|
|
git_commit("publish '#{quote(post.title)}'", [post_path('drafts'), post_path(new_post.dir), root_data_path])
|
|
new_post
|
|
else
|
|
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.id}")
|
|
else
|
|
new_post = create_post(post.title, post.body, post.link, {draft: true}, {commit: false})
|
|
delete_post_from_dir(post.dir, post.id)
|
|
build_rss
|
|
git_commit("unpublish '#{quote(post.title)}'", [post_path(post.dir), post_path('drafts'), root_data_path])
|
|
new_post
|
|
end
|
|
end
|
|
|
|
def publish(env)
|
|
target = env.to_s == 'production' ? 'publish' : 'publish_beta'
|
|
success, output = run("make #{target}")
|
|
commit_root_data
|
|
[success, output]
|
|
end
|
|
|
|
def sync
|
|
update_if_needed
|
|
git_push
|
|
end
|
|
|
|
def compile
|
|
success, output = run('make compile')
|
|
if success
|
|
@mutated = false
|
|
else
|
|
puts output
|
|
end
|
|
success
|
|
end
|
|
|
|
def compile_if_mutated
|
|
compile if @mutated
|
|
end
|
|
|
|
|
|
############################################################################################
|
|
private ##################################################################################
|
|
############################################################################################
|
|
|
|
def find_title(url)
|
|
@title_finder.find_title(url)
|
|
end
|
|
|
|
def path_for(*components)
|
|
File.join(@path, *components)
|
|
end
|
|
|
|
def root_data_path
|
|
path_for('public/_data.json')
|
|
end
|
|
|
|
def year_index_path(year)
|
|
path_for('public/posts', year, 'index.ejs')
|
|
end
|
|
|
|
def post_path(dir, id = nil)
|
|
args = ['public/posts', dir]
|
|
args << "#{id}.md" if id
|
|
path_for(*args)
|
|
end
|
|
|
|
def read_posts(post_dir, extra_fields = nil)
|
|
update_if_needed
|
|
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 |id, fields|
|
|
fields[:id] = id
|
|
unless extra_fields[:draft]
|
|
fields[:slug] = id
|
|
end
|
|
post_filename = post_path(post_dir, id)
|
|
fields[:body] = File.read(post_filename)
|
|
Post.new(fields.merge(extra_fields))
|
|
end
|
|
end
|
|
|
|
def read_post(post_dir, id, extra_fields = nil)
|
|
update_if_needed
|
|
post_filename = post_path(post_dir, id)
|
|
post_data = read_post_data(post_path(post_dir))
|
|
if File.exist?(post_filename) && fields = post_data[id]
|
|
fields[:body] = File.read(post_filename)
|
|
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}/#{id}: #{post_filename}"
|
|
$stderr.puts "[HarpBlog#read_post] #{message}"
|
|
raise InvalidDataError.new(message)
|
|
elsif File.exist?(post_filename)
|
|
message = "missing metadata for #{post_dir}/#{id}: #{post_dir}/_data.json"
|
|
$stderr.puts "[HarpBlog#read_post] #{message}"
|
|
raise InvalidDataError.new(message)
|
|
end
|
|
end
|
|
|
|
def save_post(post)
|
|
update_if_needed
|
|
begin
|
|
write_post(post)
|
|
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 delete_post_from_dir(post_dir, id)
|
|
update_if_needed
|
|
post_dir = post_path(post_dir)
|
|
delete_post_body(post_dir, id)
|
|
delete_post_index(post_dir, id)
|
|
end
|
|
|
|
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.id, post.body)
|
|
begin
|
|
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.id)
|
|
raise e
|
|
end
|
|
end
|
|
|
|
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, id)
|
|
post_filename = File.join(dir, "#{id}.md")
|
|
delete_file(post_filename)
|
|
end
|
|
|
|
def write_post_index(dir, id, fields)
|
|
post_data = read_post_data(dir)
|
|
post_data[id] = fields
|
|
write_post_data(dir, post_data)
|
|
end
|
|
|
|
def delete_post_index(dir, id)
|
|
post_data = read_post_data(dir)
|
|
if post_data[id]
|
|
post_data.delete(id)
|
|
write_post_data(dir, post_data)
|
|
end
|
|
end
|
|
|
|
def ensure_post_dir_exists(dir)
|
|
monthly_index_filename = File.join(dir, 'index.ejs')
|
|
unless File.exist?(monthly_index_filename)
|
|
source = File.join(dir, '../../2006/02/index.ejs')
|
|
cp(source, monthly_index_filename)
|
|
end
|
|
|
|
yearly_index_filename = File.join(dir, '../index.ejs')
|
|
unless File.exist?(yearly_index_filename)
|
|
source = File.join(dir, '../../2006/index.ejs')
|
|
cp(source, yearly_index_filename)
|
|
end
|
|
end
|
|
|
|
def commit_root_data
|
|
git_commit('commit root _data.json', [root_data_path])
|
|
end
|
|
|
|
def read_post_data(dir)
|
|
post_data_filename = File.join(dir, '_data.json')
|
|
if File.exist?(post_data_filename)
|
|
JSON.parse(File.read(post_data_filename))
|
|
else
|
|
{}
|
|
end
|
|
end
|
|
|
|
def write_post_data(dir, data)
|
|
post_data_filename = File.join(dir, '_data.json')
|
|
json = JSON.pretty_generate(data)
|
|
write_file(post_data_filename, json)
|
|
end
|
|
|
|
def ensure_dir_exists(dir)
|
|
unless File.directory?(dir)
|
|
if @dry_run
|
|
puts ">>> mkdir -p '#{dir}'"
|
|
else
|
|
FileUtils.mkdir_p(dir)
|
|
end
|
|
end
|
|
end
|
|
|
|
def cp(source, destination, clobber = false)
|
|
ensure_dir_exists(File.dirname(destination))
|
|
if !File.exist?(destination) || clobber
|
|
if @dry_run
|
|
puts ">>> cp '#{source}' '#{destination}'"
|
|
else
|
|
FileUtils.cp(source, destination)
|
|
end
|
|
end
|
|
end
|
|
|
|
def write_file(filename, data)
|
|
ensure_dir_exists(File.dirname(filename))
|
|
if @dry_run
|
|
puts ">>> write file '#{filename}', contents:"
|
|
puts data
|
|
else
|
|
File.write(filename, data)
|
|
end
|
|
end
|
|
|
|
def delete_file(filename)
|
|
if File.exist?(filename)
|
|
if @dry_run
|
|
puts ">>> unlink '#{filename}'"
|
|
else
|
|
File.unlink(filename)
|
|
end
|
|
end
|
|
end
|
|
|
|
def quote(s)
|
|
s.gsub('"', '\\"')
|
|
end
|
|
|
|
def run(cmd, safety = :destructive)
|
|
if safety == :destructive && @dry_run
|
|
puts ">>> cd '#{@path}' && #{cmd}"
|
|
[true, '']
|
|
else
|
|
output = `cd '#{@path}' && #{cmd} 2>&1`
|
|
[$?.success?, output]
|
|
end
|
|
end
|
|
|
|
def git_sha
|
|
update_if_needed
|
|
_, output = run 'git log -n1 | head -n1 | cut -d" " -f2', :nondestructive
|
|
output.strip
|
|
end
|
|
|
|
def git_commit(message, files)
|
|
quoted_files = files.map { |f| "\"#{quote(f)}\"" }
|
|
puts ">>> git add -A #{quoted_files.join(' ')} && git commit -m \"#{message}\""
|
|
success, output = run("git add -A #{quoted_files.join(' ')} && git commit -m \"#{message}\"")
|
|
if success
|
|
@mutated = true
|
|
else
|
|
puts output
|
|
end
|
|
[success, output]
|
|
end
|
|
|
|
def git_reset_hard(ref = nil)
|
|
args = ref ? "'#{ref}'" : ''
|
|
run("git reset --hard #{args}")
|
|
end
|
|
|
|
def git_push force = false
|
|
args = force ? '-f' : nil
|
|
run "git push #{args}"
|
|
end
|
|
|
|
def origin_updated_path
|
|
File.join @path, 'origin-updated'
|
|
end
|
|
|
|
def git_update(remote = 'origin')
|
|
if run "git update #{remote}", :nondestructive
|
|
File.unlink origin_updated_path if origin_updated?
|
|
end
|
|
end
|
|
|
|
def origin_updated?
|
|
File.exist? origin_updated_path
|
|
end
|
|
|
|
def update_if_needed
|
|
git_update if origin_updated?
|
|
end
|
|
|
|
def build_rss
|
|
run 'make rss'
|
|
end
|
|
|
|
end
|