├── .gitignore ├── LICENSE ├── bin └── ghost ├── config-sample.js ├── images └── .gitkeep ├── lib └── spooky.js ├── package.json └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | .env 4 | config.js 5 | pages/* 6 | GhostData.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Jeff Douglas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /bin/ghost: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var program = require('commander'), 4 | cloudinary = require('cloudinary'), 5 | config = require('../config') 6 | spooky = require('../lib/spooky'); 7 | 8 | cloudinary.config({ 9 | cloud_name: config.cloudinary.cloud_name, 10 | api_key: config.cloudinary.api_key, 11 | api_secret: config.cloudinary.api_secret 12 | }); 13 | 14 | program 15 | .version('0.0.3') 16 | 17 | program 18 | .command('blogstats') 19 | .description('Displays blog stats.') 20 | .action(function(){ 21 | spooky.blogstats(); 22 | }); 23 | 24 | program 25 | .command('poststats ') 26 | .description('Displays stats for a specific post (e.g., 10)') 27 | .action(function(post){ 28 | spooky.poststats(post); 29 | }); 30 | 31 | program 32 | .command('find ') 33 | .description('Finds the target t in all posts and diplays the matching file names.') 34 | .action(function(target){ 35 | spooky.find(target); 36 | }); 37 | 38 | program 39 | .command('findtag ') 40 | .description('Finds all posts with tag t.') 41 | .action(function(tag){ 42 | spooky.findtag(tag); 43 | }); 44 | 45 | program 46 | .command('replace ') 47 | .description('Replaces all instance of t with r in the specified post (e.g., 10).') 48 | .action(function(target, replacement, post){ 49 | spooky.replace(target, replacement, post); 50 | }); 51 | 52 | program 53 | .command('404check') 54 | .description('Checks each post to make sure it exists actually exists at your blog domain.') 55 | .action(function(){ 56 | spooky.check404s(); 57 | }); 58 | 59 | program 60 | .command('toc') 61 | .description('Build a simple markdown page with links to all posts.') 62 | .action(function(){ 63 | spooky.toc(); 64 | }); 65 | 66 | program 67 | .command('dir2cloudinary') 68 | .description('Uploads all files in the /images directory to Cloudinary.') 69 | .action(function(){ 70 | spooky.dir2cloudinary(); 71 | }); 72 | 73 | program 74 | .command('url2cloudinary ') 75 | .description('Upload an image to Cloudinary by URL.') 76 | .action(function(url){ 77 | spooky.url2cloudinary(url); 78 | }); 79 | 80 | program 81 | .command('findinwayback ') 82 | .description('Finds a URL (i.e., image) in the Internet Archive Wayback Machine.') 83 | .action(function(url){ 84 | spooky.findinwayback(url); 85 | }); 86 | 87 | program 88 | .command('waybackit') 89 | .description('Looks for an image in a file and tries to locate it in the wayback machine.') 90 | .action(function(file){ 91 | spooky.waybackit(file); 92 | }); 93 | 94 | program 95 | .command('dump') 96 | .description('Writes the markdown for each blog post to a separate file.') 97 | .action(function(){ 98 | spooky.dump(); 99 | }); 100 | 101 | program.parse(process.argv); -------------------------------------------------------------------------------- /config-sample.js: -------------------------------------------------------------------------------- 1 | 2 | var config = { 3 | blog_domain: "YOUR-BLOG-URL", 4 | pagesDir: [__dirname,'pages'].join('/'), 5 | imagesDir: [__dirname,'images'].join('/'), 6 | cloudinary : { 7 | cloud_name: 'YOUR-CLOUDINARY-CLOUD-NAME', 8 | api_key: 'YOUR-CLOUDINARY-API-KEY', 9 | api_secret: 'YOUR-CLOUDINARY-API-SECRET' 10 | } 11 | } 12 | 13 | module.exports = config; -------------------------------------------------------------------------------- /images/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jeffdonthemic/ghost-cli/aa47d6a07b4e064ce70c261e839d9ec7d1301932/images/.gitkeep -------------------------------------------------------------------------------- /lib/spooky.js: -------------------------------------------------------------------------------- 1 | var program = require('commander'), 2 | _ = require("lodash"), 3 | Q = require("q"), 4 | moment = require('moment'), 5 | request = require('request'), 6 | cloudinary = require('cloudinary'), 7 | fs = require('graceful-fs'), 8 | replace = require('replace'), 9 | config = require('../config'); 10 | 11 | exports.blogstats = function() { 12 | 13 | fs.readFile('GhostData.json', 'utf8', function (err,data) { 14 | if (err) { return console.log(err); } 15 | if (!err) { 16 | var results = JSON.parse(data); 17 | var permalinks = _.find(results.data.settings, function(s) { return s.key === 'permalinks'; }); 18 | var theme = _.find(results.data.settings, function(s) { return s.key === 'activeTheme'; }); 19 | var tags = _.map(results.data.tags, function(item) { return item.name; }); 20 | var users = _.map(results.data.users, function(user) { return user.name; }); 21 | var posts = _.filter(results.data.posts, function(item) { return item.page == 0; }); 22 | var pages = _.filter(results.data.posts, function(item) { return item.page == 1; }); 23 | // output the stats 24 | console.log('Blog stats:'); 25 | console.log("Posts: " + posts.length); 26 | console.log("Static Pages: " + pages.length); 27 | console.log("Users: (" + users.length + ") " + users.join(", ")); 28 | console.log("Tags: (" + tags.length + ") " + tags.join(", ")); 29 | console.log("Theme: " + theme.value) 30 | } 31 | }); 32 | 33 | } 34 | 35 | exports.poststats = function(id) { 36 | 37 | id = parseInt(id); 38 | fs.readFile('GhostData.json', 'utf8', function (err,data) { 39 | if (err) { return console.log(err); } 40 | if (!err) { 41 | var results = JSON.parse(data); 42 | var post = _.find(results.data.posts, function(p) { return p.id === id; }); 43 | 44 | post.author = _.find(results.data.users, function(u) { return u.id === post.author_id; }).name; 45 | post.created_by = _.find(results.data.users, function(u) { return u.id === post.created_by; }).name; 46 | post.updated_by = _.find(results.data.users, function(u) { return u.id === post.updated_by; }).name; 47 | post.published_by = _.find(results.data.users, function(u) { return u.id === post.published_by; }).name; 48 | 49 | // make some asthetic mods 50 | post.static_page = 'no'; 51 | post.featured = 'no'; 52 | if (post.page === 1) post.static_page = 'yes'; 53 | if (post.featured === 1) post.featured = 'yes'; 54 | 55 | // find the tags for the post 56 | var tags = _.filter(results.data.posts_tags, function(s) { 57 | return s.post_id === id; 58 | }); 59 | // get all of matching tag ids 60 | var tag_ids = _.pluck(tags, 'tag_id'); 61 | var matching_tags = _.filter(results.data.tags, function(s) { 62 | return _.contains(tag_ids, s.id); 63 | }); 64 | post['tags'] = _.pluck(matching_tags, 'name').join(', '); 65 | 66 | // remove some properties 67 | delete post['markdown']; 68 | delete post['html']; 69 | delete post['page']; 70 | delete post['author_id']; 71 | 72 | _(_.keys(post)).forEach(function(key) { 73 | console.log(key + ": " + post[key]); 74 | }); 75 | } 76 | }); 77 | 78 | } 79 | 80 | exports.replace = function(target, replacement, file) { 81 | 82 | replace({ 83 | regex: target, 84 | replacement: replacement, 85 | paths: [[config.pagesDir,file+'.md'].join("/")], 86 | recursive: true, 87 | silent: false, 88 | }); 89 | 90 | } 91 | 92 | exports.find = function(target) { 93 | 94 | fs.readdir(config.pagesDir, function(err, files) { 95 | if (err) { console.log(err); } 96 | if (!err) { 97 | console.log("Found '" + target + "' in the following pages:") 98 | _(files).forEach(function(file) { 99 | fs.readFile([config.pagesDir, file].join("/"), function (err,data) { 100 | if (err) { console.log('Error reading ' + file); } 101 | if (!err) { 102 | if (data.toString().search(target) >= 0) { 103 | console.log(file); 104 | } 105 | } 106 | }); 107 | }); 108 | } 109 | }); 110 | 111 | } 112 | 113 | exports.findtag = function(tag) { 114 | 115 | fs.readFile('GhostData.json', 'utf8', function (err,data) { 116 | if (err) { return console.log(err); } 117 | if (!err) { 118 | var results = JSON.parse(data); 119 | // find the id of the tag 120 | var t = _.find(results.data.tags, function(s) { 121 | return s.name.toLowerCase() === tag.toLowerCase(); 122 | }); 123 | 124 | if (t) { 125 | // find all of the posts containing this tag 126 | var posts = _.filter(results.data.posts_tags, function(s) { 127 | return s.tag_id === t.id; 128 | }); 129 | // pluck the post_ids 130 | var post_ids = _.pluck(posts, 'post_id'); 131 | // find all of the posts by id 132 | var containing = _.filter(results.data.posts, function(s) { 133 | return _.contains(post_ids, s.id); 134 | }); 135 | console.log("Found the following post(s) with the tag '"+tag +"'"); 136 | _(containing).forEach(function(post) { 137 | console.log(post.slug + " (" + post.id + ")"); 138 | }); 139 | } else { 140 | console.log("Tag '"+tag +"' not found."); 141 | } 142 | } 143 | }); 144 | 145 | } 146 | 147 | exports.dir2cloudinary = function() { 148 | 149 | fs.readdir(config.imagesDir, function(err, files) { 150 | _(files).forEach(function(file) { 151 | cloudinary.uploader.upload([config.imagesDir,file].join('/'), function(result) { 152 | console.log(result) 153 | }); 154 | }); 155 | }); 156 | 157 | } 158 | 159 | exports.url2cloudinary = function(url) { 160 | 161 | cloudinary.uploader.upload(url, function(result) { 162 | console.log(result) 163 | }); 164 | 165 | } 166 | 167 | exports.findinwayback = function(url) { 168 | 169 | console.log('Searching Wayback Machine.....'); 170 | var archiveUrl = "http://archive.org/wayback/available?url="; 171 | request(archiveUrl + url, function (error, response, body) { 172 | if (!error && response.statusCode == 200) { 173 | console.log(JSON.parse(body)) ; 174 | } else { 175 | console.log("Error calling Wayback Machine!"); 176 | } 177 | }); 178 | 179 | } 180 | 181 | exports.dump = function() { 182 | 183 | fs.mkdir("pages", function() { 184 | fs.readFile('GhostData.json', 'utf8', function (err,data) { 185 | if (err) { return console.log(err); } 186 | if (!err) { 187 | var results = JSON.parse(data); 188 | var contents = ''; 189 | console.log("Saving the following pages to " + config.pagesDir + ":") 190 | _(results.data.posts).reverse().forEach(function(item) { 191 | fs.writeFile('pages/' + item.id + '.md', item.markdown, function (err) { 192 | if (err) throw err; 193 | console.log(item.slug + " (" + item.id + ".md)"); 194 | }); 195 | }); 196 | } 197 | }); 198 | }); 199 | 200 | } 201 | 202 | exports.toc = function() { 203 | 204 | fs.readFile('GhostData.json', 'utf8', function (err,data) { 205 | if (err) { return console.log(err); } 206 | if (!err) { 207 | 208 | var results = JSON.parse(data); 209 | // determine if they are using dates in permelinks 210 | var permalinks = _.find(results.data.settings, function(s) { return s.key === 'permalinks'; }); 211 | var contents = ''; 212 | _(results.data.posts).reverse().forEach(function(item) { 213 | // only posts 214 | if (item.page === 0) { 215 | var day = moment(item.published_at).format('DD'); 216 | var month = moment(item.published_at).format('MM'); 217 | var year = moment(item.published_at).format('YYYY'); 218 | var post = "[" + item.title + "](" + config.blog_domain + "/"; 219 | if (permalinks.value === '/:slug/') 220 | post += item.slug + ")\n\n"; 221 | else 222 | post += year + "/" + month + "/" + day + "/" + item.slug + ")\n\n"; 223 | contents += post; 224 | } 225 | }); 226 | 227 | fs.writeFile([config.pagesDir, 'toc.md'].join('/'), contents, function (err) { 228 | if (err) throw err; 229 | console.log('TOC page saved to ' + config.pagesDir + '/toc.md.'); 230 | }); 231 | 232 | } 233 | }); 234 | 235 | } 236 | 237 | exports.check404s = function() { 238 | 239 | var urls = []; 240 | fs.readFile('GhostData.json', 'utf8', function (err,data) { 241 | if (err) { return console.log(err); } 242 | if (!err) { 243 | var results = JSON.parse(data); 244 | var permalinks = _.find(results.data.settings, function(s) { return s.key === 'permalinks'; }); 245 | _(results.data.posts).reverse().forEach(function(item) { 246 | var day = moment(item.published_at).format('DD'); 247 | var month = moment(item.published_at).format('MM'); 248 | var year = moment(item.published_at).format('YYYY'); 249 | var url = config.blog_domain + "/"; 250 | if (permalinks.value === '/:slug/') 251 | url += item.slug; 252 | else 253 | url += year + "/" + month + "/" + day + "/" + item.slug; 254 | if (item.status === 'published' && item.page === 0) 255 | urls.push({'id': item.id, 'url': url}); 256 | }); 257 | checkUrls(urls); 258 | } 259 | }); 260 | 261 | var checkUrls = function (posts) { 262 | for (var i = 0; i < posts.length ; i++) { 263 | checkUrlStatus(posts[i]).then(function(resp) { 264 | if (resp.indexOf("404") > -1) 265 | console.log("404!! ****** " + resp + " ******"); 266 | else 267 | console.log(resp); 268 | }); 269 | } 270 | }; 271 | 272 | var checkUrlStatus = function (post) { 273 | var deferred = Q.defer(); 274 | request(post.url, function (error, response, body) { 275 | deferred.resolve(post.url + " (" + post.id + ") - " + response.statusCode); 276 | }); 277 | return deferred.promise 278 | }; 279 | 280 | } 281 | 282 | exports.waybackit = function(file) { 283 | 284 | findImageInFile([config.pagesDir, file].join("/")) 285 | .then(function(originalUrl) { 286 | findImageInWayback(originalUrl) 287 | .then(uploadToCloudinary) 288 | .then(function(cloudinaryUrl) { 289 | replaceTextInFiles(originalUrl, cloudinaryUrl) 290 | }) 291 | .fail(function (error) { 292 | console.log(error); 293 | }); 294 | }) 295 | .fail(function (error) { 296 | console.log(error); 297 | }); 298 | 299 | function findImageInFile(file) { 300 | var deferred = Q.defer(); 301 | fs.readFile(file, function (err,data) { 302 | var targetUrl = 'http://blog.jeffdouglas.com/wp-content/uploads/'; 303 | if (err) { if (err) deferred.reject(err); } 304 | if (!err) { 305 | var md = data.toString(); 306 | if (md.search(targetUrl) != -1) { 307 | var start = md.indexOf(targetUrl); 308 | // find the closing " 309 | var end = md.indexOf('"', start); 310 | deferred.resolve(md.substring(start, end)); 311 | } else { 312 | deferred.reject('No images found in ' + file); 313 | } 314 | } 315 | }); 316 | return deferred.promise 317 | } 318 | 319 | function findImageInWayback(url) { 320 | console.log('Looking in wayback for: ' + url); 321 | var deferred = Q.defer(); 322 | var archiveUrl = "http://archive.org/wayback/available?url="; 323 | request(archiveUrl + url, function (error, response, body) { 324 | if (!error && response.statusCode == 200) { 325 | var result = JSON.parse(body); 326 | if (_.isEmpty(result.archived_snapshots)) { 327 | deferred.reject("Not found in archive"); 328 | } else { 329 | console.log("Wayback URL: " + result.archived_snapshots.closest.url); 330 | deferred.resolve(result.archived_snapshots.closest.url) ; 331 | } 332 | } else { 333 | deferred.resolve(response.statusCode); 334 | } 335 | }) 336 | return deferred.promise 337 | } 338 | 339 | function replaceTextInFiles(target, replacement) { 340 | console.log("Replacing..."); 341 | replace({ 342 | regex: target, 343 | replacement: replacement, 344 | paths: [config.pagesDir], 345 | recursive: true, 346 | silent: false, 347 | }); 348 | } 349 | 350 | function uploadToCloudinary(url) { 351 | console.log('Uploading to Cloudinary: ' + url); 352 | var deferred = Q.defer(); 353 | cloudinary.uploader.upload(url, function(result) { 354 | console.log("Cloudinary URL: " + result.url); 355 | deferred.resolve(result.url) ; 356 | }); 357 | return deferred.promise 358 | } 359 | 360 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Jeff Douglas (http://blog.jeffdouglas.com)", 3 | "name": "ghost-cli", 4 | "description": "A cli for ghost in node", 5 | "version": "0.0.2", 6 | "private": false, 7 | "dependencies": { 8 | "commander": "~2.1.0", 9 | "q": "~0.9.7", 10 | "lodash": "~2.4.1", 11 | "moment": "~2.6.0", 12 | "graceful-fs": "~2.0.3", 13 | "cloudinary": "~1.0.9", 14 | "request": "~2.36.0", 15 | "replace": "~0.2.9" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Ghost CLI 2 | 3 | Since the [Ghost](http://www.ghost.org/) blogging platform does not include a CLI (at this time), I decided to build one containing some simple admin functions. I needed a number of these functions when I ported my blog from WordPress with roughly 500 post to Ghost. I had a number things that needed to be corrected and cleaned up. 4 | 5 | The CLI operates from the export of your blog's settings and data. Any changes made using the CLI are not reflected in your blog. You must **manually** update your blog. **Again, no changes are automatically made on your Ghost blog.** 6 | 7 | **[This video](https://www.youtube.com/watch?v=LzG_EJ14g3Q) covers installation and an overview of the CLI's functionality.** 8 | 9 | # Installation 10 | 11 | The CLI uses [Node.js](http://nodejs.org/) so make sure you have it installed and perform the following to get the CLI up and running: 12 | 13 | ```bash 14 | git clone git@github.com:jeffdonthemic/ghost-cli.git 15 | cd ghost-cli 16 | npm install 17 | ``` 18 | 19 | Rename `config-sample.js` to `config.js` and add your blog's domain (e.g., http://blog.jeffdouglas.com) and Cloudinary Account Details from the [Cloudinary Dashboard](https://cloudinary.com/console) if you want to use the Cloudinary functions. 20 | 21 | You may also have to run `chmod 777 ./bin/ghost` to make the file executable on OS X. 22 | 23 | # Running 24 | 25 | Before you can run the CLI you'll need to export and download the data and setting for your blog. The CLI runs against this data. Log into your blog and go to https://YOUR-BLOG.ghost.io/ghost/debug. 26 | 27 | Click the blue Export button and save the `GhostData.json` file to the `ghost-cli` root directory. 28 | 29 | Now run the following in terminal to write the markdown for each blog post to a separate file in the `/pages` directory. Feel free to perform this any time you wish to update your data and settings locally after you make changes on your actual blog. 30 | 31 | ```bash 32 | ./bin/ghost dump 33 | ``` 34 | 35 | Now you are ready to rock 'n roll! To see what commands are available for the CLI type `./bin/ghost --help` 36 | 37 | ``` 38 | ./bin/ghost --help 39 | 40 | Usage: ghost [options] [command] 41 | 42 | Commands: 43 | 44 | blogstats Displays blog stats. 45 | poststats Displays stats for a specific post (e.g., 10) 46 | find Finds the target t in all posts and diplays the matching file names. 47 | findtag Finds all posts with tag t. 48 | replace Replaces all instance of t with r in the specified post (e.g., 10). 49 | 404check Checks each post to make sure it exists actually exists at your blog domain. 50 | toc Build a simple markdown page with links to all posts. 51 | dir2cloudinary Uploads all files in the /images directory to Cloudinary. 52 | url2cloudinary Upload an image to Cloudinary by URL. 53 | findinwayback Finds a URL (i.e., image) in the Internet Archive Wayback Machine. 54 | waybackit Looks for an image in a file and tries to locate it in the wayback machine. 55 | dump Writes the markdown for each blog post to a separate file. 56 | 57 | Options: 58 | 59 | -h, --help output usage information 60 | -V, --version output the version number 61 | ``` --------------------------------------------------------------------------------