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