├── .README ├── demo.gif └── marsedit.png ├── Gemfile ├── Gemfile.lock ├── app ├── orbit │ ├── servlet.rb │ ├── media.rb │ ├── blogger_api.rb │ ├── metaweblog_api.rb │ ├── db.rb │ └── post.rb └── orbit.rb ├── LICENSE ├── .gitignore └── README.md /.README/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elliotekj/orbit/HEAD/.README/demo.gif -------------------------------------------------------------------------------- /.README/marsedit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elliotekj/orbit/HEAD/.README/marsedit.png -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'webrick', '~> 1.6' 4 | gem 'xmlrpc', '~> 0.3.0' 5 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | webrick (1.6.1) 5 | xmlrpc (0.3.0) 6 | 7 | PLATFORMS 8 | ruby 9 | 10 | DEPENDENCIES 11 | webrick (~> 1.6) 12 | xmlrpc (~> 0.3.0) 13 | 14 | BUNDLED WITH 15 | 1.16.0 16 | -------------------------------------------------------------------------------- /app/orbit/servlet.rb: -------------------------------------------------------------------------------- 1 | class OrbitServlet < XMLRPC::WEBrickServlet 2 | attr_accessor :token 3 | 4 | def initialize(token) 5 | super() 6 | 7 | @token = token 8 | end 9 | 10 | def service(req, res) 11 | unless @token.nil? 12 | unless req.request_uri.to_s.match(/.*token=(\S+)$/)[1] == @token 13 | raise XMLRPC::FaultException.new(0, 'Token invalid') 14 | end 15 | end 16 | 17 | super 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/orbit/media.rb: -------------------------------------------------------------------------------- 1 | require 'date' 2 | require 'fileutils' 3 | 4 | class Media 5 | def self.save(path, name, date) 6 | ymd_structure = DateTime.now.strftime('%Y/%m/%d') 7 | dir_structure = File.join(path, 'content/images', ymd_structure) 8 | FileUtils.mkpath(dir_structure) unless File.exist?(dir_structure) 9 | file_path = File.join(dir_structure, name) 10 | 11 | File.open(file_path, 'w') do |file| 12 | file.write(date) 13 | end 14 | 15 | '/images/' + ymd_structure + '/' + name 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/orbit/blogger_api.rb: -------------------------------------------------------------------------------- 1 | require_relative 'post.rb' 2 | 3 | class BloggerAPI 4 | def initialize(db, user_passed_update_cmd) 5 | @db = db 6 | @user_passed_update_cmd = user_passed_update_cmd 7 | end 8 | 9 | def run_user_cmd 10 | Thread.new do 11 | system(@user_passed_update_cmd) unless @user_passed_update_cmd.nil? 12 | end 13 | end 14 | 15 | # +--------------------------------------------------------------------------+ 16 | # | Posts 17 | # +--------------------------------------------------------------------------+ 18 | 19 | def deletePost(_, post_id, _, _, _) 20 | Post.delete(post_id) 21 | @db.refresh_post_paths 22 | 23 | run_user_cmd 24 | true 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Elliot Jackson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/orbit/metaweblog_api.rb: -------------------------------------------------------------------------------- 1 | require_relative 'post.rb' 2 | require_relative 'media.rb' 3 | 4 | class MetaWeblogAPI 5 | def initialize(db, user_passed_update_cmd) 6 | @db = db 7 | @user_passed_update_cmd = user_passed_update_cmd 8 | end 9 | 10 | def run_user_cmd 11 | Thread.new do 12 | system(@user_passed_update_cmd) unless @user_passed_update_cmd.nil? 13 | end 14 | end 15 | 16 | # +--------------------------------------------------------------------------+ 17 | # | Posts 18 | # +--------------------------------------------------------------------------+ 19 | 20 | def newPost(_, _, _, metaweblog_struct, _) 21 | post = Post.create(@db.content_path, metaweblog_struct) 22 | @db.refresh_post_paths 23 | 24 | run_user_cmd 25 | post['postid'] 26 | end 27 | 28 | def getPost(post_id, _, _) 29 | Post.get(post_id) 30 | end 31 | 32 | def editPost(post_id, _, _, metaweblog_struct, _publish) 33 | post = Post.get(post_id) 34 | post = Post.merge_metaweblog_struct(post, metaweblog_struct) 35 | 36 | body = post.delete('description') 37 | Post.write(post_id, post, body) 38 | 39 | @db.refresh_post_paths 40 | 41 | run_user_cmd 42 | post_id 43 | end 44 | 45 | def getRecentPosts(_, _, _, post_count) 46 | @db.fetch_first(post_count) 47 | end 48 | 49 | # +--------------------------------------------------------------------------+ 50 | # | Categories 51 | # +--------------------------------------------------------------------------+ 52 | 53 | def getCategories(_, _, _) 54 | @db.categories 55 | end 56 | 57 | # +--------------------------------------------------------------------------+ 58 | # | Media 59 | # +--------------------------------------------------------------------------+ 60 | 61 | def newMediaObject(_, _, _, data) 62 | path = Media.save(@db.src_path, data['name'], data['bits']) 63 | 64 | run_user_cmd 65 | { 66 | 'url' => path 67 | } 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### macOS ### 2 | *.DS_Store 3 | .AppleDouble 4 | .LSOverride 5 | 6 | # Icon must end with two \r 7 | Icon 8 | 9 | # Thumbnails 10 | ._* 11 | 12 | # Files that might appear in the root of a volume 13 | .DocumentRevisions-V100 14 | .fseventsd 15 | .Spotlight-V100 16 | .TemporaryItems 17 | .Trashes 18 | .VolumeIcon.icns 19 | .com.apple.timemachine.donotpresent 20 | 21 | # Directories potentially created on remote AFP share 22 | .AppleDB 23 | .AppleDesktop 24 | Network Trash Folder 25 | Temporary Items 26 | .apdisk 27 | 28 | ### Ruby ### 29 | *.gem 30 | *.rbc 31 | /.config 32 | /coverage/ 33 | /InstalledFiles 34 | /pkg/ 35 | /spec/reports/ 36 | /spec/examples.txt 37 | /test/tmp/ 38 | /test/version_tmp/ 39 | /tmp/ 40 | 41 | # Used by dotenv library to load environment variables. 42 | # .env 43 | 44 | ## Specific to RubyMotion: 45 | .dat* 46 | .repl_history 47 | build/ 48 | *.bridgesupport 49 | build-iPhoneOS/ 50 | build-iPhoneSimulator/ 51 | 52 | ## Specific to RubyMotion (use of CocoaPods): 53 | # 54 | # We recommend against adding the Pods directory to your .gitignore. However 55 | # you should judge for yourself, the pros and cons are mentioned at: 56 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 57 | # 58 | # vendor/Pods/ 59 | 60 | ## Documentation cache and generated files: 61 | /.yardoc/ 62 | /_yardoc/ 63 | /doc/ 64 | /rdoc/ 65 | 66 | ## Environment normalization: 67 | /.bundle/ 68 | /vendor/bundle 69 | /lib/bundler/man/ 70 | 71 | # for a library or gem, you might want to ignore these files since the code is 72 | # intended to run in multiple environments; otherwise, check them in: 73 | # Gemfile.lock 74 | # .ruby-version 75 | # .ruby-gemset 76 | 77 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 78 | .rvmrc 79 | 80 | ### Vim ### 81 | # swap 82 | [._]*.s[a-v][a-z] 83 | [._]*.sw[a-p] 84 | [._]s[a-v][a-z] 85 | [._]sw[a-p] 86 | # session 87 | Session.vim 88 | # temporary 89 | .netrwhist 90 | *~ 91 | # auto-generated tag files 92 | tags 93 | 94 | # End of https://www.gitignore.io/api/vim,ruby,macos 95 | -------------------------------------------------------------------------------- /app/orbit.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | 3 | require 'rubygems' 4 | require 'bundler/setup' 5 | require 'optparse' 6 | require 'webrick' 7 | require 'xmlrpc/server' 8 | require_relative 'orbit/db.rb' 9 | require_relative 'orbit/metaweblog_api.rb' 10 | require_relative 'orbit/blogger_api.rb' 11 | require_relative 'orbit/servlet.rb' 12 | 13 | options = {} 14 | 15 | optparse = OptionParser.new do |opts| 16 | opts.banner = "Usage: #{$PROGRAM_NAME} -s '/path/to/hugo/site' [options]" 17 | 18 | options['src_path'] = nil 19 | opts.on('-s', '--src-path FOLDER', 'Path to your Hugo site (required)') do |s| 20 | options['src_path'] = s 21 | end 22 | 23 | options['content_folder'] = 'post' 24 | opts.on('-c', '--content-folder FOLDER_NAME', "Name of the folder in \ 25 | `/content` you want Orbit to serve (default: 'post')") do |c| 26 | options['content_folder'] = c 27 | end 28 | 29 | options['port'] = 4040 30 | opts.on('-p', '--port PORT', Integer, 'Port to run Orbit on (default: 4040)') do |p| 31 | options['port'] = p 32 | end 33 | 34 | options['token'] = nil 35 | opts.on('-t', '--token TOKEN', 'Token used for authenticating yourself (optional)') do |t| 36 | options['token'] = t 37 | end 38 | 39 | options['update_command'] = nil 40 | opts.on('-u', '--update-command COMMAND', 'Command run when your site is \ 41 | updated (optional)') do |u| 42 | options['update_command'] = u 43 | end 44 | end 45 | 46 | optparse.parse! 47 | 48 | if options['src_path'].nil? or not File.directory? options['src_path'] 49 | puts 'option --src-path is required must be an existing folder' 50 | exit 1 51 | end 52 | 53 | db = OrbitDB.new(options) 54 | metaweblog_api = MetaWeblogAPI.new(db, options['update_command']) 55 | blogger_api = BloggerAPI.new(db, options['update_command']) 56 | 57 | servlet = OrbitServlet.new(options['token']) 58 | servlet.add_handler('metaWeblog', metaweblog_api) 59 | servlet.add_handler('blogger', blogger_api) 60 | 61 | server = WEBrick::HTTPServer.new(:Port => options['port']) 62 | server.mount('/xmlrpc', servlet) 63 | 64 | ['INT', 'TERM', 'HUP'].each do |signal| 65 | trap(signal) { server.shutdown } 66 | end 67 | 68 | server.start 69 | -------------------------------------------------------------------------------- /app/orbit/db.rb: -------------------------------------------------------------------------------- 1 | require_relative 'post.rb' 2 | 3 | class OrbitDB 4 | attr_accessor :categories, :src_path, :content_path 5 | 6 | def initialize(options) 7 | @src_path = options['src_path'] 8 | @content_path = File.join(options['src_path'], "content/#{options['content_folder']}") 9 | @post_minimal_metadata = [] 10 | @categories = [] 11 | end 12 | 13 | # Public: Gets all of the data for the first `n` paths in @post_minimal_metadata. 14 | # 15 | # n - The number Integer of posts to return 16 | # 17 | # Returns an Array of posts in a metaWeblog compatible hash. 18 | def fetch_first(n) 19 | refresh_post_paths 20 | 21 | metaweblog_hashes = [] 22 | posts_to_read = if n > @post_minimal_metadata.length 23 | @post_minimal_metadata 24 | else 25 | @post_minimal_metadata[0..n] 26 | end 27 | 28 | posts_to_read.each do |metadata| 29 | metaweblog_hashes.push(Post.get(metadata['path'])) 30 | end 31 | 32 | metaweblog_hashes 33 | end 34 | 35 | # Public: Rebuild the `post_minimal_metadata` array. 36 | def refresh_post_paths 37 | @post_minimal_metadata = [] 38 | @categories = [] 39 | gather_post_minimal_metadata(@content_path) 40 | end 41 | 42 | private 43 | 44 | # Private: Sets `@post_minimal_metadata`. 45 | # 46 | # path - The path String to recusively search 47 | # 48 | # Returns an Array of minimal metadata Hashes ordered by the `date` in the frontmatter. 49 | def gather_post_minimal_metadata(path) 50 | walk_posts_path(path) 51 | 52 | @post_minimal_metadata.sort_by! { |hash| hash['dateCreated'].strftime('%s').to_i } 53 | @post_minimal_metadata.reverse! 54 | 55 | @categories.unshift('[Orbit - Draft]') 56 | @categories.uniq! 57 | end 58 | 59 | # Private: Recursively walk the passed path looking for markdown files. 60 | # 61 | # path - The path String to recusively search 62 | def walk_posts_path(path) 63 | Dir.foreach(path) do |path_item| 64 | next if path_item =~ /^\.+$/ # Filter out `.` and `..` 65 | next if path_item =~ /^[\.]/ # Filter out hidden files 66 | 67 | full_path = File.join(path, path_item) 68 | 69 | if File.directory? full_path 70 | walk_posts_path(full_path) 71 | elsif File.file? full_path 72 | next unless path_item =~ /^.+(md|markdown|txt)$/ 73 | single_markdown_file(full_path) 74 | end 75 | end 76 | end 77 | 78 | # Private: Handle a single markdown file found by `walk_posts_path`. 79 | # 80 | # path - The path String to the markdown file 81 | def single_markdown_file(path) 82 | frontmatter = Post.get_frontmatter(path) 83 | return if frontmatter.nil? 84 | 85 | @post_minimal_metadata.push( 86 | 'path' => path, 87 | 'dateCreated' => frontmatter['dateCreated'] 88 | ) 89 | 90 | @categories.concat(frontmatter['categories']) 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Orbit - A MetaWeblog API Server for Hugo 2 | 3 | ![](https://github.com/elliotekj/orbit/blob/master/.README/demo.gif) 4 | 5 | Orbit is a MetaWeblog API server for blogs powered by 6 | [Hugo](https://gohugo.io). It was written so that I could write and publish to 7 | my [blog](https://elliotekj.com) from 8 | [MarsEdit](https://www.red-sweater.com/marsedit/). It supports draft posts (not 9 | a MetaWeblog standard) and MarsEdit's drag & drop image insertion. It also has 10 | built-in token verification so you can safely expose it on your server. 11 | 12 | **Jump to:** [Basic Usage](https://github.com/elliotekj/orbit#basic-usage) | [Post Types](https://github.com/elliotekj/orbit#post-types) | [Update Command](https://github.com/elliotekj/orbit#update-command) | [Authentication](https://github.com/elliotekj/orbit#authentication) | [Draft Posts](https://github.com/elliotekj/orbit#draft-posts)| [All Options](https://github.com/elliotekj/orbit#all-options) 13 | 14 | 15 | ## Basic Usage 16 | 17 | 1. [Download Orbit](https://github.com/elliotekj/orbit/archive/master.zip) 18 | 19 | 2. `cd` into the folder and run: 20 | 21 | ```sh 22 | $ ruby app/orbit.rb -s /YOUR/HUGO/SITE/PATH 23 | ``` 24 | 25 | 3. Configure MarsEdit: 26 | 27 | ![MarsEdit configuration](https://github.com/elliotekj/orbit/blob/master/.README/marsedit.png) 28 | 29 | 30 | ## Post Types 31 | 32 | If your blog, like mine, has multiple post types (e.g. link posts, regular 33 | posts, and microposts), you can still use Orbit. When starting Orbit, the `-c` 34 | flag allows you to you can specify the folder within `content` the post will be 35 | saved in. 36 | 37 | Example: The following will save posts sent to port 4041 in the `link` folder. 38 | 39 | ```sh 40 | $ ruby app/orbit.rb -s /YOUR/HUGO/SITE/PATH -c link -p 4041 41 | ``` 42 | 43 | ## Update Command 44 | 45 | If you don't want to have `hugo server` constantly running to watch for changes 46 | (for example if you're running Orbit on your server), you can regenerate your 47 | site with a command passed to the `-u` flag. 48 | 49 | Example: 50 | 51 | ```sh 52 | $ ruby app/orbit.rb -s /YOUR/HUGO/SITE/PATH -u "cd /YOUR/HUGO/SITE/PATH && hugo" 53 | ``` 54 | 55 | Tip for [Micro.Blog](https://micro.blog) users: You can use the `-u` flag to ping Micro.Blog whenever you publish a new micropost 56 | 57 | ```sh 58 | $ ruby app/orbit.rb -s /YOUR/HUGO/SITE/PATH -u "curl -d 'url=https://YOURSITE.com/microposts.json' -X POST http://micro.blog/ping" 59 | ``` 60 | 61 | ## Authentication 62 | 63 | If you want to expose Orbit on your server (which is how I use Orbit), you'll 64 | want to take advantage of the built-in token verification. When starting Orbit, 65 | pass some secret token to the `-t` flag and update the `API Endpoint URL` in 66 | MarsEdit with a `token` parameter. 67 | 68 | Example: 69 | 70 | ```sh 71 | $ ruby app/orbit.rb -s /YOUR/HUGO/SITE/PATH -t MYSECRETTOKEN 72 | ``` 73 | 74 | Then in MarsEdit, if your endpoint was, for example, 75 | `https://hugo.elliotekj.com/xmlrpc` you'd update it to 76 | `https://hugo.elliotekj.com/xmlrpc?token=MYSECRETTOKEN`. 77 | 78 | ## Draft Posts 79 | 80 | Draft posts aren't a MetaWeblog standard. Orbit works around this by adding 81 | a `[Orbit - Draft]` category to the category list. If you use that category 82 | then when saving your post, `draft` will be set to true in the post's 83 | frontmatter. 84 | 85 | ## All Options 86 | 87 | - `-s PATH` `--src-path PATH`: Path to your Hugo site (required) 88 | - `-c FOLDER_NAME` `--content-folder FOLDER_NAME`: Name of the folder in `/content` you want Orbit to serve (default: 'post') 89 | - `-p PORT` `--port PORT`: Port to run Orbit on (default: 4040) 90 | - `-t TOKEN` `--token TOKEN`: Token used for authenticating yourself (optional) 91 | - `-u COMMAND` `--update-command COMMAND`: Command run when your site is updated (optional) 92 | 93 | ## Common Issues 94 | ### MarsEdit won't load any of my old posts 95 | 96 | Check that your front matter (the metadata block above each of your raw posts) is in YAML form. If you're not sure which format that is, check the separator between the metadata block, and your actual post. It could be `+++`, `---`, or enclosed in `{` and `}`. 97 | 98 | If it is not the form ending with three dashes, (`---`), you'll need to convert your old posts before continuing. Hugo provides a built-in tool for this: `hugo convert toYAML` 99 | 100 | ## License 101 | 102 | Orbit is released under the MIT [`LICENSE`](https://github.com/elliotekj/orbit/blob/master/LICENSE). 103 | 104 | ## About 105 | 106 | This crate was written by [Elliot Jackson](https://elliotekj.com). 107 | 108 | - Blog: [https://elliotekj.com](https://elliotekj.com) 109 | - Email: elliot@elliotekj.com 110 | -------------------------------------------------------------------------------- /app/orbit/post.rb: -------------------------------------------------------------------------------- 1 | require 'date' 2 | require 'fileutils' 3 | require 'time' 4 | require 'yaml' 5 | 6 | class Post 7 | # Public: Get a post's frontmatter from its path. 8 | # 9 | # path - The path String to the post 10 | # 11 | # Returns a Hash of the frontmatter. 12 | def self.get_frontmatter(path) 13 | p = read(path) 14 | frontmatter, = parse(p) 15 | return nil if frontmatter.nil? 16 | 17 | process_frontmatter(frontmatter) 18 | end 19 | 20 | # Public: Get a post from its path. 21 | # 22 | # path - The path String to the post 23 | # 24 | # Returns a MetaWeblog compatible hash. 25 | def self.get(path) 26 | p = read(path) 27 | frontmatter, body = parse(p) 28 | return nil if frontmatter.nil? || body.nil? 29 | 30 | post_hash = process_frontmatter(frontmatter) 31 | post_hash['postid'] = path 32 | post_hash['description'] = body 33 | 34 | post_hash 35 | end 36 | 37 | # Public: Create a post. 38 | # 39 | # path - The path String where the post will be written 40 | # metaweblog_struct - The post data hash 41 | # 42 | # Returns a post Hash. 43 | def self.create(path, metaweblog_struct) 44 | now = DateTime.now 45 | filename = now.strftime('%Y-%m-%d-%H-%M-%S.md') 46 | 47 | post_id = File.join(path, filename) 48 | body = metaweblog_struct['description'] || '' 49 | frontmatter = { 50 | 'title' => metaweblog_struct['title'] || '', 51 | 'link' => metaweblog_struct['link'] || '', 52 | 'dateCreated' => now.rfc3339, 53 | 'categories' => metaweblog_struct['categories'] || [] 54 | } 55 | 56 | write(post_id, frontmatter, body) 57 | get(post_id) 58 | end 59 | 60 | def self.merge_metaweblog_struct(post, metaweblog_struct) 61 | unless FileTest.exist?(post['postid']) 62 | raise XMLRPC::FaultException.new(0, 'Post doesn’t exist') 63 | end 64 | 65 | post['title'] = metaweblog_struct['title'] || '' 66 | post['description'] = metaweblog_struct['description'] || '' 67 | post['link'] = metaweblog_struct['link'] || '' 68 | if post['categories'].include?('[Orbit - Draft]') 69 | post['dateCreated'] = Time.now.to_datetime.rfc3339 70 | end 71 | post['categories'] = metaweblog_struct['categories'] || [] 72 | 73 | post 74 | end 75 | 76 | def self.write(post_id, frontmatter, body) 77 | dir_structure = File.dirname(post_id) 78 | FileUtils.mkpath(dir_structure) unless File.exist?(dir_structure) 79 | 80 | # We'll handle inserting these ourselves: 81 | date_created = frontmatter.delete('dateCreated') if frontmatter.key?('dateCreated') 82 | date_modified = frontmatter.delete('date_modified') if frontmatter.key?('date_modified') 83 | 84 | # Remove the `postid`: 85 | frontmatter.delete('postid') if frontmatter.key?('postid') 86 | 87 | # Merge `otherFrontmatter` into the `frontmatter`: 88 | if frontmatter.key?('otherFrontmatter') 89 | other_frontmatter = frontmatter.delete('otherFrontmatter') 90 | frontmatter = frontmatter.merge(other_frontmatter.reduce({}, :update)) 91 | end 92 | 93 | # Set a post's draft status: 94 | if frontmatter['categories'].include?('[Orbit - Draft]') 95 | date_created = nil 96 | date_modified = nil 97 | frontmatter['draft'] = true 98 | frontmatter['categories'] -= ['[Orbit - Draft]'] 99 | else 100 | frontmatter.delete('draft') if frontmatter.key?('draft') 101 | date_modified = Time.now.to_datetime.rfc3339 102 | end 103 | 104 | 105 | File.open(post_id, 'w') do |file| 106 | file.truncate(0) # Empty the file 107 | 108 | file.write(frontmatter.to_yaml) 109 | file.write("date: #{date_created}\n") unless date_created == nil 110 | file.write("date_modified: #{date_created}\n") unless date_modified == nil 111 | file.write("---\n\n") 112 | file.write(body) 113 | end 114 | end 115 | 116 | def self.delete(path) 117 | FileUtils.rm(path) if File.exist?(path) 118 | end 119 | 120 | # Private: Read the contents of a post path. 121 | # 122 | # path - The path to read 123 | # 124 | # Returns the post as a String. 125 | def self.read(path) 126 | File.read(path) 127 | end 128 | 129 | # Private: Parse the contents of a post file. 130 | # 131 | # post_string - The String to parse 132 | # 133 | # Returns an Array composed of the frontmatter and body. 134 | def self.parse(post_string) 135 | frontmatter = read_frontmatter(post_string) 136 | return nil if frontmatter.nil? 137 | body = read_body(post_string) 138 | return nil if body.nil? 139 | 140 | [frontmatter, body] 141 | end 142 | 143 | # Private: Find the frontmatter in a post and parse it as YAML. 144 | # 145 | # post_string - The String to search 146 | # 147 | # Returns the frontmatter as YAML or nil if no frontmatter is found. 148 | def self.read_frontmatter(post_string) 149 | frontmatter = post_string.match(/\A---\n((.|\n)+)\n---/) 150 | return nil unless frontmatter 151 | YAML.load(frontmatter[1]) 152 | end 153 | 154 | # Private: Find the body of a post. 155 | # 156 | # post_string - The String to search 157 | # 158 | # Returns the body as a String or nil. 159 | def self.read_body(post_string) 160 | frontmatter = post_string.match(/\A---(.+)\n---/m) 161 | return nil unless frontmatter 162 | frontmatter_char_count = frontmatter[1].length 163 | frontmatter_char_count += 7 # The len of "---\n---" 164 | body = post_string[frontmatter_char_count..-1] 165 | body.strip! 166 | end 167 | 168 | # Private: Separate the frontmatter that MetaWeblog doesn't handle so that we don't lose it. 169 | # 170 | # frontmatter - The YAML to process 171 | # 172 | # Returns a Hash with the processed frontmatter. 173 | def self.process_frontmatter(frontmatter) 174 | # Load draft posts with a special category: 175 | categories = frontmatter['categories'] || [] 176 | if frontmatter.key?('draft') && frontmatter['draft'] == true 177 | categories.unshift('[Orbit - Draft]') 178 | end 179 | 180 | # Preserve any other frontmatter that isn't handled by MarsEdit: 181 | other_frontmatter = [] 182 | frontmatter.each do |key, value| 183 | next if key == 'title' 184 | next if key == 'link' 185 | next if key == 'date' 186 | next if key == 'date_modified' 187 | next if key == 'categories' 188 | next if key == 'draft' 189 | 190 | other_frontmatter.push(key => value) 191 | end 192 | 193 | { 194 | 'title' => frontmatter['title'] || '', 195 | 'link' => frontmatter['link'] || '', 196 | 'dateCreated' => frontmatter['date'] || Time.parse(DateTime.now.rfc3339.to_s), 197 | 'categories' => categories, 198 | 'otherFrontmatter' => other_frontmatter || [] 199 | } 200 | end 201 | end 202 | --------------------------------------------------------------------------------