├── .gitignore ├── LICENSE.txt ├── README.md ├── gilded-wordpress.php ├── index.js ├── lib ├── posts.js ├── resources.js └── taxonomies.js ├── package-lock.json ├── package.json └── scripts └── version.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright Scott González http://scottgonzalez.com 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gilded WordPress 2 | 3 | Easily synchronize content between the file system and WordPress. 4 | 5 | Support this project by [donating on Gratipay](https://gratipay.com/scottgonzalez/). 6 | 7 | 8 | 9 | ## TOC 10 | 11 | * [Getting Started](#getting-started) 12 | * [Installation](#installation) 13 | * [Usage](#usage) 14 | * [Node.js API](#nodejs-api) 15 | * [Exports](#exports) 16 | * [Client Methods - Validation](#client-methods---validation) 17 | * [Client Methods - Synchronization](#client-methods---synchronization) 18 | * [Client Methods - Logging](#client-methods---logging) 19 | * [Client Methods - Utilities](#client-methods---utilies) 20 | * [Directory Structure](#directory-structure) 21 | * [taxonomies.json](#taxonomiesjson) 22 | * [Post Files](#post-files) 23 | * [PHP API](#php-api) 24 | * [Permissive Uploads](#permissive-uploads) 25 | * [License](#license) 26 | 27 | 28 | 29 | ## Getting Started 30 | 31 | Resources are uploaded to `/gw-resources/{HOME_URL}/`. If you'd like a friendlier name, you can set up a redirect in your web server. 32 | 33 | If you have problems uploading resources, check the [Permissive Uploads](#permissive-uploads) section. 34 | 35 | 36 | 37 | ## Installation 38 | 39 | ``` 40 | npm install gilded-wordpress 41 | ``` 42 | 43 | 44 | 45 | ## Usage 46 | 47 | ```javascript 48 | var wordpress = require( "gilded-wordpress" ); 49 | var client = wordpress.createClient({ 50 | url: "wordpress.dev", 51 | username: "admin", 52 | password: "admin", 53 | dir: "my-content" 54 | }); 55 | 56 | client.sync(function( error ) { 57 | if ( error ) { 58 | console.error( error ); 59 | return; 60 | } 61 | 62 | console.log( "Successfully synchronized WordPress." ); 63 | }); 64 | ``` 65 | 66 | 67 | 68 | ## Node.js API 69 | 70 | ### Exports 71 | 72 | #### wordpress.createClient( options ) 73 | 74 | Creates a new client instance. 75 | 76 | * `options`: A hash of options that apply to all requests for the new client. 77 | * `username`: The username for the WordPress account. 78 | * `password`: The password for the WordPress account. 79 | * `url`: The URL for the WordPress install. 80 | * `dir`: The path to the directory containing all taxonomies, posts, and resources (see [Directory Struture](#directory-structure)). 81 | * `host` (optional): The actual host to connect to if different from the URL, e.g., when deploying to a local server behind a firewall. 82 | * `blogId` (optional; default: `0`): The blog ID for the WordPress install. 83 | * `verbose` (optional; default: `false`): Whether logging should be verbose. 84 | 85 | #### wordpress.Client 86 | 87 | The constructor used for client connections. Useful for creating extensions. 88 | 89 | 90 | 91 | ### Client Methods - Validation 92 | 93 | #### client.validate( callback ) 94 | 95 | Validates all data. 96 | 97 | * `callback` (`function( error )`): A callback to invoke when the validation is complete. 98 | 99 | #### client.validateXmlrpcVersion( callback ) 100 | 101 | Verifies whether the WordPress plugin is installed and has the same version as the Node.js module. 102 | 103 | * `callback` (`function( error )`): A callback to invoke when the validation is complete. 104 | 105 | #### client.validateTerms( callback ) 106 | 107 | Validates all terms. 108 | 109 | * `callback` (`function( error )`): A callback to invoke when the taxonomies have been validated. 110 | 111 | ### client.validatePosts( callback ) 112 | 113 | Validates all posts. 114 | 115 | * `callback` (`function( error )`): A callback to invoke when the posts have been validated. 116 | 117 | 118 | 119 | ### Client Methods - Synchronization 120 | 121 | #### client.sync( callback ) 122 | 123 | Synchonizes all data. 124 | 125 | * `callback` (`function( error )`): A callback to invoke when the synchronization is complete. 126 | 127 | #### client.syncTerms( callback ) 128 | 129 | Synchronizes all terms. 130 | 131 | * `callback` (`function( error )`): A callback to invoke when the taxonomies have been synchronized. 132 | 133 | #### client.syncPosts( callback ) 134 | 135 | Synchronizes all posts. 136 | 137 | * `callback` (`function( error )`): A callback to invoke when the posts have been synchronized. 138 | 139 | #### client.syncResources( callback ) 140 | 141 | Synchronizes all resources. 142 | 143 | * `callback` (`function( error )`): A callback to invoke when the resources have been synchronized. 144 | 145 | 146 | 147 | ### Client Methods - Logging 148 | 149 | The client methods log various information as they perform their tasks. These methods are designed to be overridden for custom logging or to hook into an existing logging system. 150 | 151 | #### client.log( message ) 152 | 153 | Logs a message. Defaults to `console.log()`. 154 | 155 | * `message`: A message to log. 156 | 157 | #### client.logError( message ) 158 | 159 | Logs an error message. Defaults to `console.error()`. 160 | 161 | * `message`: An error message to log. 162 | 163 | 164 | 165 | ### Client Methods - Utilities 166 | 167 | The utility methods exist to help build custom extensions to the client. All callbacks from the utility methods are invoked within the context of the client instance. 168 | 169 | #### client.waterfall( steps, callback ) 170 | 171 | Asynchronously executes a set of functions. 172 | 173 | Equivalent to [`async.waterfall()`](https://github.com/caolan/async#waterfall), but with context preserved. 174 | 175 | * `steps`: An array of functions to perform. Each function is passed a callback (`function( error, result1, result2, ... )`) which must be called when the function is complete. The first argument is an error and any further arguments will be passed as arguments in order to the next step. 176 | * `callback` (`function( error )`): A callback to invoke when all steps have been completed or a step has resulted in an error. 177 | 178 | #### client.forEach( items, iterator, complete ) 179 | 180 | Asynchronous version of `Array#forEach()`. 181 | 182 | Equivalent to [`async.forEachSeries()`](https://github.com/caolan/async#forEachSeries), but with context preserved. 183 | 184 | * `items`: An array to iterate over. 185 | * `iterator` (`function( item, callback )`): A callback to invoke for each item of the array. 186 | * `item`: The current item of the array. 187 | * `callback` (`function( error )`): A callback to invoke after processing the item. 188 | * `complete` (`function( error )`): A callback to invoke when all items have been iterated over or an item resulted in an error. 189 | 190 | #### client.recurse( dir, iterator, complete ) 191 | 192 | Asyncrhonously walk all files in a directory, recursively. All files within a directory are walked before recursing. 193 | 194 | * `dir`: The path to a directory to walk. 195 | * `iterator` (`function( path, callback )`): A callback to invoke for each file within the directory. 196 | * `path`: The path to the current file. 197 | * `callback` (`function( error )`): A callback to invoke after processing the file. 198 | * `complete` (`function( error )`): A callback to invoke when all files have been iterated over or a file resulted in an error. 199 | 200 | 201 | 202 | ### Directory Structure 203 | 204 | The directory passed to the client instance has the following structure: 205 | 206 | ``` 207 | dir 208 | ├── posts 209 | │   └── 210 | │   └── .html 211 | ├── resources 212 | │   └── . 213 | └── taxonomies.json 214 | ``` 215 | 216 | The `posts` directory must only contain `` directories. 217 | The `` directories must be named to exactly match a post type, e.g., `post` or `page`. 218 | All custom post types are supported. 219 | 220 | The `resources` directory is completely freeform. 221 | Resources of any type will be uploaded based on the current directory structure. 222 | 223 | ### taxonomies.json 224 | 225 | The `taxonomies.json` file defines all used taxonomy terms. 226 | You can only manage terms, all taxonomies must already exist in WordPress. 227 | 228 | ```json 229 | { 230 | "": [ 231 | { 232 | "name": "My Term", 233 | "description": "My term is awesome", 234 | "slug": "my-term" 235 | }, 236 | { 237 | "name": "My Other Term", 238 | "slug": "my-other-term", 239 | "children": [ 240 | { 241 | "name": "I'm a child term!", 242 | "slug": "hooray-for-children" 243 | } 244 | ] 245 | } 246 | ] 247 | } 248 | ``` 249 | 250 | Slugs and names are required. 251 | 252 | ### Post Files 253 | 254 | Post files must be HTML, containing the content of the post. 255 | Post data can be specified as JSON in a ` 266 |

I'm a post!

267 | ``` 268 | 269 | The post type and parent are determined based on the [directory structure](#directory-structure). 270 | `termSlugs` must match a hierarchical slug defined in [taxonomies.json](#taxonomiesjson). 271 | 272 | 273 | 274 | ## PHP API 275 | 276 | ### Constants 277 | 278 | #### GW_VERSION 279 | 280 | The installed version of Gilded WordPress. 281 | 282 | #### GW_RESOURCE_DIR 283 | 284 | The path to the resources directory for the current site. 285 | 286 | ### Methods 287 | 288 | #### gw_resources_dir( url ) 289 | 290 | Gets the resources directory for a specific site. 291 | 292 | * `url`: The URL for the site. 293 | 294 | 295 | 296 | ## Permissive Uploads 297 | 298 | Depending on what resources you're uploading, you may need to change some WordPress settings. 299 | Here are a few settings that might help: 300 | 301 | ```php 302 | // Disable more restrictive multisite upload settings. 303 | remove_filter( 'upload_mimes', 'check_upload_mimes' ); 304 | 305 | // Give unfiltered upload ability to super admins. 306 | define( 'ALLOW_UNFILTERED_UPLOADS', true ); 307 | 308 | // Allow additional file types. 309 | add_filter( 'upload_mimes', function( $mimes ) { 310 | $mimes[ 'eot' ] = 'application/vnd.ms-fontobject'; 311 | $mimes[ 'svg' ] = 'image/svg+xml'; 312 | $mimes[ 'ttf' ] = 'application/x-font-ttf'; 313 | $mimes[ 'woff' ] = 'application/font-woff'; 314 | $mimes[ 'xml' ] = 'text/xml'; 315 | $mimes[ 'php' ] = 'application/x-php'; 316 | $mimes[ 'json' ] = 'application/json'; 317 | return $mimes; 318 | }); 319 | 320 | // Increase file size limit to 1GB. 321 | add_filter( 'pre_site_option_fileupload_maxk', function() { 322 | return 1024 * 1024; 323 | }); 324 | ``` 325 | 326 | 327 | 328 | ## License 329 | 330 | Copyright Scott González. Released under the terms of the MIT license. 331 | 332 | --- 333 | 334 | Support this project by [donating on Gratipay](https://gratipay.com/scottgonzalez/). 335 | -------------------------------------------------------------------------------- /gilded-wordpress.php: -------------------------------------------------------------------------------- 1 | escape( $args ); 19 | 20 | // Authenticate 21 | $blog_id = $args[0]; 22 | $username = $args[1]; 23 | $password = $args[2]; 24 | 25 | // We require authentication so that we can ensure that the username 26 | // and password provided will work for other methods. 27 | if ( ! $user = $wp_xmlrpc_server->login( $username, $password ) ) { 28 | return $wp_xmlrpc_server->error; 29 | } 30 | 31 | return GW_VERSION; 32 | } 33 | 34 | function gw_get_post_paths( $post_type = "" ) { 35 | $results = array(); 36 | $query = new WP_Query( array( 37 | 'post_type' => $post_type, 38 | 'post_status' => 'publish', 39 | 'posts_per_page' => -1, 40 | 'update_post_term_cache' => false, 41 | ) ); 42 | foreach ( $query->posts as $post ) { 43 | $results[ $post->post_type . '/' . get_page_uri( $post->ID ) ] = array( 44 | 'id' => $post->ID, 45 | 'checksum' => get_post_meta( $post->ID, 'gwcs', true ), 46 | ); 47 | } 48 | 49 | return $results; 50 | } 51 | 52 | 53 | function gw_get_resources() { 54 | $filename = GW_RESOURCE_DIR . "/__gw.json"; 55 | if ( !file_exists( $filename ) ) { 56 | return array(); 57 | } 58 | return json_decode( file_get_contents( $filename ), true ); 59 | } 60 | 61 | function gw_set_resources( $resources ) { 62 | file_put_contents( GW_RESOURCE_DIR . "/__gw.json", json_encode( $resources ) ); 63 | } 64 | 65 | function gw_add_resource( $args ) { 66 | global $wp_xmlrpc_server; 67 | $wp_xmlrpc_server->escape( $args ); 68 | 69 | // Authenticate 70 | $blog_id = $args[0]; 71 | $username = $args[1]; 72 | $password = $args[2]; 73 | 74 | if ( ! $user = $wp_xmlrpc_server->login( $username, $password ) ) { 75 | return $wp_xmlrpc_server->error; 76 | } 77 | 78 | // Verify path and decode file contents 79 | $path = $args[3]; 80 | if ( false !== strpos( $path, ".." ) ) { 81 | return new IXR_ERROR( 500, "Invalid path." ); 82 | } 83 | $bits = $args[4]; 84 | $checksum = md5( $bits ); 85 | $bits = base64_decode( $bits ); 86 | 87 | // Create a temp file using built-in upload functionality 88 | $info = pathinfo( $path ); 89 | $ext = !empty($info['extension']) ? '.' . $info['extension'] : ''; 90 | $upload = wp_upload_bits( "gw-resource" . $ext, null, $bits ); 91 | if ( !empty( $upload['error'] ) ) { 92 | return new IXR_Error( 500, $upload['error'] ); 93 | } 94 | 95 | // Move the file to the gw-resources directory 96 | $new_file = GW_RESOURCE_DIR . "/$path"; 97 | if ( ! wp_mkdir_p( dirname( $new_file ) ) ) { 98 | $message = sprintf( __( 'Unable to create directory %s. Is its parent directory writable by the server?' ), dirname( $new_file ) ); 99 | return new IXR_Error( 500, $message ); 100 | } 101 | rename( $upload['file'], $new_file ); 102 | clearstatcache(); 103 | 104 | // Update resource list 105 | $resources = gw_get_resources(); 106 | $resources[ $path ] = $checksum; 107 | gw_set_resources( $resources ); 108 | 109 | return $checksum; 110 | } 111 | 112 | function gw_delete_resource( $args ) { 113 | global $wp_xmlrpc_server; 114 | $wp_xmlrpc_server->escape( $args ); 115 | 116 | // Authenticate 117 | $blog_id = $args[0]; 118 | $username = $args[1]; 119 | $password = $args[2]; 120 | 121 | if ( ! $user = $wp_xmlrpc_server->login( $username, $password ) ) { 122 | return $wp_xmlrpc_server->error; 123 | } 124 | 125 | // Verify path 126 | $path = $args[3]; 127 | if ( false !== strpos( $path, ".." ) ) { 128 | return new IXR_ERROR( 500, "Invalid path." ); 129 | } 130 | 131 | // Delete resource 132 | $old_file = GW_RESOURCE_DIR . "/$path"; 133 | if ( file_exists( $old_file ) ) { 134 | unlink( $old_file ); 135 | } 136 | 137 | // Update resource list 138 | $resources = gw_get_resources(); 139 | $checksum = empty( $resources[ $path ] ) ? null : $resources[ $path ]; 140 | unset( $resources[ $path ] ); 141 | gw_set_resources( $resources ); 142 | 143 | return $checksum; 144 | } 145 | 146 | function gw_register_xmlrpc_methods( $methods ) { 147 | $methods['gw.getVersion'] = 'gw_get_version'; 148 | $methods['gw.getPostPaths'] = 'gw_get_post_paths'; 149 | $methods['gw.getResources'] = 'gw_get_resources'; 150 | $methods['gw.addResource'] = 'gw_add_resource'; 151 | $methods['gw.deleteResource'] = 'gw_delete_resource'; 152 | return $methods; 153 | } 154 | 155 | add_filter( 'xmlrpc_methods', 'gw_register_xmlrpc_methods' ); 156 | 157 | 158 | 159 | function gw_sanitize_title_with_dashes( $title, $raw_title = '', $context = 'display' ) { 160 | // Special case during the install process. 161 | if ( 'Uncategorized' == $raw_title ) 162 | return 'uncategorized'; 163 | 164 | $title = strip_tags($title); 165 | // Preserve escaped octets. 166 | $title = preg_replace( '|%([a-fA-F0-9][a-fA-F0-9])|', '---$1---', $title ); 167 | // Remove percent signs that are not part of an octet. 168 | $title = str_replace( '%', '', $title ); 169 | // Restore octets. 170 | $title = preg_replace( '|---([a-fA-F0-9][a-fA-F0-9])---|', '%$1', $title ); 171 | 172 | // CHANGE: Don't lowercase 173 | if ( seems_utf8( $title ) ) { 174 | $title = utf8_uri_encode( $title, 200 ); 175 | } 176 | 177 | // CHANGE: Don't lowercase and don't remove dots 178 | $title = preg_replace( '/&.+?;/', '', $title ); // kill entities 179 | 180 | if ( 'save' == $context ) { 181 | // Convert nbsp, ndash and mdash to hyphens 182 | $title = str_replace( array( '%c2%a0', '%e2%80%93', '%e2%80%94' ), '-', $title ); 183 | 184 | // Strip these characters entirely 185 | $title = str_replace( array( 186 | // iexcl and iquest 187 | '%c2%a1', '%c2%bf', 188 | // angle quotes 189 | '%c2%ab', '%c2%bb', '%e2%80%b9', '%e2%80%ba', 190 | // curly quotes 191 | '%e2%80%98', '%e2%80%99', '%e2%80%9c', '%e2%80%9d', 192 | '%e2%80%9a', '%e2%80%9b', '%e2%80%9e', '%e2%80%9f', 193 | // copy, reg, deg, hellip and trade 194 | '%c2%a9', '%c2%ae', '%c2%b0', '%e2%80%a6', '%e2%84%a2', 195 | ), '', $title ); 196 | } 197 | 198 | // CHANGE: Allow dots and case-insensitive 199 | $title = preg_replace( '/[^%a-z0-9 _.-]/i', '', $title ); 200 | $title = preg_replace( '/\s+/', '-', $title ); 201 | $title = preg_replace( '|-+|', '-', $title ); 202 | $title = trim( $title, '-' ); 203 | 204 | return $title; 205 | } 206 | 207 | remove_filter( 'sanitize_title', 'sanitize_title_with_dashes', 10, 3 ); 208 | add_filter( 'sanitize_title', 'gw_sanitize_title_with_dashes', 10, 3 ); 209 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var fs = require( "fs" ); 2 | var path = require( "path" ); 3 | var util = require( "util" ); 4 | var crypto = require( "crypto" ); 5 | var wordpress = require( "wordpress" ); 6 | var async = require( "async" ); 7 | var version = require( "./package" ).version; 8 | 9 | exports.createClient = createClient; 10 | exports.Client = Client; 11 | 12 | function createClient( options ) { 13 | return new Client( options ); 14 | } 15 | 16 | function Client( options ) { 17 | this.options = options; 18 | this.verbose = options.verbose || false; 19 | this.client = wordpress.createClient( options ); 20 | this.bindClientMethods(); 21 | } 22 | 23 | Client.prototype.log = console.log; 24 | Client.prototype.logError = console.error; 25 | 26 | Client.prototype.bindClientMethods = function() { 27 | var context = this; 28 | var client = this.client; 29 | 30 | function bindContext( property ) { 31 | if ( typeof client[ property ] !== "function" ) { 32 | return; 33 | } 34 | 35 | var original = client[ property ]; 36 | client[ property ] = function() { 37 | if ( !arguments.length ) { 38 | return; 39 | } 40 | 41 | var args = [].slice.apply( arguments ); 42 | var last = args.pop(); 43 | if ( typeof last === "function" ) { 44 | last = last.bind( context ); 45 | } 46 | args.push( last ); 47 | 48 | original.apply( client, args ); 49 | }; 50 | } 51 | 52 | for ( var property in client ) { 53 | bindContext( property ); 54 | } 55 | }; 56 | 57 | Client.prototype.waterfall = function( steps, callback ) { 58 | var context = this; 59 | 60 | async.waterfall( 61 | steps.map(function( step ) { 62 | return step.bind( context ); 63 | }), 64 | callback.bind( context ) 65 | ); 66 | }; 67 | 68 | Client.prototype.forEach = function( items, eachFn, complete ) { 69 | async.forEachSeries( items, eachFn.bind( this ), complete.bind( this ) ); 70 | }; 71 | 72 | Client.prototype.path = function( partial ) { 73 | return path.join( this.options.dir, partial ); 74 | }; 75 | 76 | // Async directory recursion, always walks all files before recursing 77 | Client.prototype.recurse = function( rootdir, walkFn, complete ) { 78 | complete = complete.bind( this ); 79 | 80 | try { 81 | fs.statSync( rootdir ); 82 | } catch ( e ) { 83 | // Directories are considered optional, especially if inherited 84 | // from default setttings. Treat non-existant dir as empty dir. 85 | complete(); 86 | return; 87 | } 88 | 89 | fs.readdir( rootdir, { withFileTypes: true }, function( error, entries ) { 90 | if ( error ) { 91 | return complete( error ); 92 | } 93 | 94 | var directories = []; 95 | var files = []; 96 | entries.forEach(function( entry ) { 97 | var fullPath = path.join( rootdir, entry.name ); 98 | if ( entry.isDirectory() ) { 99 | directories.push( fullPath ); 100 | } else { 101 | files.push( fullPath ); 102 | } 103 | }); 104 | 105 | this.forEach( files, walkFn, function( error ) { 106 | if ( error ) { 107 | return complete( error ); 108 | } 109 | 110 | this.forEach( directories, function( directory, directoryComplete ) { 111 | this.recurse( directory, walkFn, directoryComplete ); 112 | }, complete ); 113 | }); 114 | }.bind( this )); 115 | }; 116 | 117 | Client.prototype.createChecksum = (function() { 118 | function flatten( obj ) { 119 | if ( obj == null ) { 120 | return ""; 121 | } 122 | 123 | if ( typeof obj === "string" ) { 124 | return obj; 125 | } 126 | 127 | if ( typeof obj === "number" ) { 128 | return String( obj ); 129 | } 130 | 131 | if ( util.isDate( obj ) ) { 132 | return obj.toGMTString(); 133 | } 134 | 135 | if ( util.isArray( obj ) ) { 136 | return obj.map(function( item ) { 137 | return flatten( item ); 138 | }).join( "," ); 139 | } 140 | 141 | return Object.keys( obj ).sort().map(function( prop ) { 142 | return prop + ":" + flatten( obj[ prop ] ); 143 | }).join( ";" ); 144 | } 145 | 146 | return function( obj ) { 147 | var md5 = crypto.createHash( "md5" ); 148 | md5.update( flatten( obj ), "utf8" ); 149 | return md5.digest( "hex" ); 150 | }; 151 | })(); 152 | 153 | Client.prototype.validateXmlrpcVersion = function( callback ) { 154 | callback = callback.bind( this ); 155 | 156 | if ( this.verbose ) { 157 | this.log( "Verifying XML-RPC version..." ); 158 | } 159 | 160 | this.client.authenticatedCall( "gw.getVersion", function( error, xmlrpcVersion ) { 161 | if ( error ) { 162 | if ( error.code === "ECONNREFUSED" ) { 163 | return callback( new Error( "Could not connect to WordPress." ) ); 164 | } 165 | if ( error.code === -32601 ) { 166 | return callback( new Error( 167 | "XML-RPC extensions for Gilded WordPress are not installed." ) ); 168 | } 169 | if ( !error.code ) { 170 | error.message += "\nPlease ensure that your database server is running " + 171 | "and WordPress is functioning properly."; 172 | } 173 | 174 | // XML-RPC is disabled or bad credentials 175 | // WordPress provides good error messages, so we don't do any special handling 176 | return callback( error ); 177 | } 178 | 179 | // The server should have all capabilities expected by this client. 180 | // The server must therefore be in the "^x.y.z" semver-range, whereby it 181 | // implements the same major version, and the same (or newer) minor version. 182 | var xmlrpcVersionParts = xmlrpcVersion.split( ".", 3 ).map( parseFloat ); 183 | var clientVersionParts = version.split( ".", 3 ).map( parseFloat ); 184 | 185 | if ( !( xmlrpcVersionParts[0] === clientVersionParts[0] && xmlrpcVersionParts[1] >= clientVersionParts[1] ) ) { 186 | return callback( new Error( "Incompatible versions for Gilded WordPress. " + 187 | "Version " + version + " is installed as a Node.js module, " + 188 | "but the WordPress server is running version " + xmlrpcVersion + "." ) ); 189 | } 190 | 191 | if ( this.verbose ) { 192 | this.log( "XML-RPC version matches Node.js version." ); 193 | } 194 | 195 | callback( null ); 196 | }); 197 | }; 198 | 199 | Client.prototype.validate = function( callback ) { 200 | this.waterfall([ 201 | this.validateXmlrpcVersion, 202 | this.validateTerms, 203 | this.validatePosts 204 | ], function( error ) { 205 | if ( error ) { 206 | return callback( error ); 207 | } 208 | 209 | callback( null ); 210 | }); 211 | }; 212 | 213 | Client.prototype.sync = function( callback ) { 214 | this.waterfall([ 215 | this.syncTerms, 216 | this.syncPosts, 217 | this.syncResources 218 | ], function( error ) { 219 | if ( error ) { 220 | if ( error.code === "ECONNREFUSED" ) { 221 | this.logError( "Could not connect to WordPress XML-RPC server." ); 222 | } 223 | 224 | return callback( error ); 225 | } 226 | 227 | callback( null ); 228 | }); 229 | }; 230 | 231 | [ "posts", "taxonomies", "resources" ].forEach(function( module ) { 232 | require( "./lib/" + module )( Client ); 233 | }); 234 | -------------------------------------------------------------------------------- /lib/posts.js: -------------------------------------------------------------------------------- 1 | module.exports = function( Client ) { 2 | 3 | var fs = require( "fs" ); 4 | var async = require( "async" ); 5 | 6 | // Converts a postPath to a more readable name, e.g., "page/foo/bar" to "page foo/bar" 7 | function prettyName( postPath ) { 8 | return postPath.replace( "/", " " ); 9 | } 10 | 11 | Client.prototype.getPostPaths = function( callback ) { 12 | callback = callback.bind( this ); 13 | 14 | if ( this.verbose ) { 15 | this.log( "Getting post paths from WordPress..." ); 16 | } 17 | 18 | this.client.call( "gw.getPostPaths", "any", function( error, postPaths ) { 19 | if ( error ) { 20 | return callback( error ); 21 | } 22 | 23 | if ( this.verbose ) { 24 | this.log( "Got post paths from WordPress." ); 25 | } 26 | 27 | callback( null, postPaths ); 28 | }); 29 | }; 30 | 31 | Client.prototype.walkPosts = function( dir, walkFn, complete ) { 32 | walkFn = walkFn.bind( this ); 33 | complete = complete.bind( this ); 34 | 35 | this.recurse( dir, function( file, callback ) { 36 | this.parsePost( file, function( error, post ) { 37 | if ( error ) { 38 | return callback( error ); 39 | } 40 | 41 | var postPath = file.substr( dir.length, file.length - dir.length - 5 ); 42 | var parts = postPath.split( "/" ); 43 | var name = parts.pop(); 44 | var parent = parts.length > 1 ? parts.join( "/" ) : null; 45 | var type = parts.shift(); 46 | 47 | post.type = type; 48 | post.name = name; 49 | post.__parent = parent; 50 | post.__postPath = postPath; 51 | post.__file = file; 52 | 53 | walkFn( post, callback ); 54 | }); 55 | }, complete ); 56 | }; 57 | 58 | // Parse an html file into a post object. The metadata for the post is read 59 | // out of a " ); 74 | post = JSON.parse( content.substr( 8, index - 8 ) ); 75 | 76 | if ( "date" in post ) { 77 | post.date = new Date( post.date ); 78 | } 79 | if ( "modified" in post ) { 80 | post.modified = new Date( post.modified ); 81 | } 82 | 83 | content = content.substr( index + 9 ); 84 | } catch( error ) { 85 | return callback( new Error( "Invalid JSON metadata for " + postPath ) ); 86 | } 87 | } 88 | 89 | post.content = content; 90 | callback( null, post ); 91 | }); 92 | }; 93 | 94 | Client.prototype.validatePosts = function( callback ) { 95 | callback = callback.bind( this ); 96 | 97 | var count = 0; 98 | var postPaths = {}; 99 | 100 | this.walkPosts( this.path( "posts/" ), function( post, callback ) { 101 | // If there's a problem parsing the content of the file, then walkPosts() 102 | // will return an error and we'll automatically stop walking. So we know that the 103 | // content and structure of the metadata is already valid. 104 | var file = post.__file; 105 | 106 | postPaths[ post.__postPath ] = true; 107 | 108 | // Verify file extension 109 | if ( file.substr( file.length - 5 ) !== ".html" ) { 110 | return callback( new Error( "Invalid file extension for " + file + "; must be .html." ) ); 111 | } 112 | 113 | // Verify parent 114 | if ( post.__parent && !postPaths[ post.__parent ] ) { 115 | return callback( new Error( file + " does not have a parent." ) ); 116 | } 117 | 118 | // Verify required data 119 | if ( !post.title ) { 120 | return callback( new Error( file + " is missing required data: title" ) ); 121 | } 122 | 123 | count++; 124 | callback( null ); 125 | }, function( error ) { 126 | if ( error ) { 127 | return callback( error ); 128 | } 129 | 130 | var msg = "Validated " + ( 131 | count === 1 ? 132 | "one post." : 133 | (count + " posts.") 134 | ); 135 | this.log( msg ); 136 | 137 | callback( null ); 138 | }.bind( this )); 139 | }; 140 | 141 | // Publish (create or update) a post to WordPress. 142 | Client.prototype.publishPost = function( post, callback ) { 143 | callback = callback.bind( this ); 144 | 145 | var name = prettyName( post.__postPath ); 146 | 147 | if ( post.id ) { 148 | // Get existing custom fields 149 | 150 | if ( this.verbose ) { 151 | this.log( "Getting custom fields for " + name + "..." ); 152 | } 153 | 154 | this.client.getPost( post.id, [ "customFields" ], function( error, postData ) { 155 | if ( error ) { 156 | return callback( error ); 157 | } 158 | 159 | if ( this.verbose ) { 160 | this.log( "Got custom fields for " + name + "." ); 161 | } 162 | 163 | // If there are any existing custom fields, then we need to determine 164 | // what to add, edit, and delete. 165 | if ( postData.customFields.length ) { 166 | post.customFields = post.customFields || []; 167 | post.customFields.forEach(function( customField ) { 168 | // Look for exact matches 169 | var index; 170 | if ( postData.customFields.some(function( existingCustomField, i ) { 171 | index = i; 172 | return customField.key === existingCustomField.key && 173 | customField.value === existingCustomField.value; 174 | })) { 175 | // Copy the id to do an update and remove from the list 176 | // of existing custom fields 177 | customField.id = postData.customFields[ index ].id; 178 | postData.customFields.splice( index, 1 ); 179 | } 180 | }); 181 | 182 | // Delete any existing custom fields that are left over 183 | post.customFields = post.customFields.concat( 184 | postData.customFields.map(function( customField ) { 185 | return { id: customField.id }; 186 | }) 187 | ); 188 | } 189 | 190 | // Update the post 191 | if ( this.verbose ) { 192 | this.log( "Editing " + name + "..." ); 193 | } 194 | 195 | this.client.editPost( post.id, post, function( error ) { 196 | if ( error ) { 197 | return callback( error ); 198 | } 199 | 200 | this.log( "Edited " + name + "." ); 201 | callback( null, post.id ); 202 | }); 203 | }); 204 | } else { 205 | if ( this.verbose ) { 206 | this.log( "Creating " + name + "..." ); 207 | } 208 | 209 | this.client.newPost( post, function( error, id ) { 210 | if ( error ) { 211 | return callback( error ); 212 | } 213 | 214 | this.log( "Created " + name + "." ); 215 | callback( null, id ); 216 | }); 217 | } 218 | }; 219 | 220 | Client.prototype.deletePost = function( postId, postPath, callback ) { 221 | callback = callback.bind( this ); 222 | 223 | var name = prettyName( postPath ); 224 | 225 | if ( this.verbose ) { 226 | this.log( "Trashing " + name + "..." ); 227 | } 228 | 229 | this.client.deletePost( postId, function( error ) { 230 | if ( error ) { 231 | return callback( error ); 232 | } 233 | 234 | if ( this.verbose ) { 235 | this.log( "Trashed " + name + "." ); 236 | } 237 | 238 | // The first delete moves to trash; this one deletes :-) 239 | 240 | if ( this.verbose ) { 241 | this.log( "Deleting " + name + "..." ); 242 | } 243 | 244 | this.client.deletePost( postId, function( error ) { 245 | if ( error ) { 246 | return callback( error ); 247 | } 248 | 249 | this.log( "Deleted " + name + "." ); 250 | callback( null ); 251 | }); 252 | }); 253 | }; 254 | 255 | Client.prototype.syncPosts = function( termMap, callback ) { 256 | callback = callback.bind( this ); 257 | 258 | this.waterfall([ 259 | function getPostPaths( callback ) { 260 | this.getPostPaths( callback ); 261 | }, 262 | 263 | function publishPosts( postPaths, callback ) { 264 | var posts = {}; 265 | 266 | if ( this.verbose ) { 267 | this.log( "Publishing posts..." ); 268 | } 269 | 270 | this.walkPosts( this.path( "posts/" ), function( post, callback ) { 271 | var existingPost = postPaths[ post.__postPath ]; 272 | var name = prettyName( post.__postPath ); 273 | 274 | function complete( error, id ) { 275 | if ( error ) { 276 | return callback( error ); 277 | } 278 | 279 | posts[ post.__postPath ] = id; 280 | delete postPaths[ post.__postPath ]; 281 | callback( null ); 282 | } 283 | 284 | if ( !post.status ) { 285 | post.status = "publish"; 286 | } 287 | if ( post.__parent ) { 288 | post.parent = posts[ post.__parent ]; 289 | } 290 | 291 | // Convert term slugs to term ids 292 | if ( post.termSlugs ) { 293 | post.terms = {}; 294 | Object.keys( post.termSlugs ).forEach(function( taxonomy ) { 295 | // Check if the taxonomy exists 296 | if ( !termMap || !termMap[ taxonomy ] ) { 297 | return callback( new Error( 298 | name + " has '" + taxonomy + "' term slugs, " + 299 | "but no such taxonomy exists." ) ); 300 | } 301 | 302 | post.terms[ taxonomy ] = []; 303 | post.termSlugs[ taxonomy ].forEach(function( slug ) { 304 | var termId = termMap[ taxonomy ][ slug ]; 305 | 306 | // Check if the slug exists 307 | if ( !termId ) { 308 | return callback( new Error( 309 | name + " has a " + taxonomy + " term slug of " + 310 | "'" + slug + "', but no such term exists." ) ); 311 | } 312 | 313 | post.terms[ taxonomy ].push( termId ); 314 | }); 315 | }); 316 | } 317 | 318 | // If the post exists and hasn't changed, then there's nothing to do. 319 | var checksum = this.createChecksum( post ); 320 | if ( existingPost ) { 321 | // Don't add the id until after creating the checksum. This allows us 322 | // to create the same checksum when creating and editing. 323 | post.id = existingPost.id; 324 | 325 | if ( existingPost.checksum === checksum ) { 326 | if ( this.verbose ) { 327 | this.log( "Skipping " + name + "; already up-to-date." ); 328 | } 329 | 330 | return complete( null, post.id ); 331 | } 332 | } 333 | 334 | // Add a checksum so we can determine when a post has been edited 335 | post.customFields = post.customFields || []; 336 | post.customFields.push({ 337 | key: "gwcs", 338 | value: checksum 339 | }); 340 | 341 | this.publishPost( post, complete ); 342 | }.bind( this ), function( error ) { 343 | if ( error ) { 344 | return callback( error ); 345 | } 346 | 347 | if ( this.verbose ) { 348 | this.log( "Published all posts." ); 349 | } 350 | 351 | callback( null, postPaths ); 352 | }); 353 | }, 354 | 355 | function deletePosts( postPaths, callback ) { 356 | if ( this.verbose ) { 357 | this.log( "Deleting old posts..." ); 358 | } 359 | 360 | async.map( Object.keys( postPaths ), function( postPath, callback ) { 361 | this.deletePost( postPaths[ postPath ].id, postPath, callback ); 362 | }.bind( this ), function( error ) { 363 | if ( error ) { 364 | return callback( error ); 365 | } 366 | 367 | if ( this.verbose ) { 368 | this.log( "Deleted all old posts." ); 369 | } 370 | 371 | callback( null ); 372 | }.bind( this )); 373 | } 374 | ], callback ); 375 | }; 376 | 377 | }; 378 | -------------------------------------------------------------------------------- /lib/resources.js: -------------------------------------------------------------------------------- 1 | module.exports = function( Client ) { 2 | 3 | var fs = require( "fs" ); 4 | 5 | Client.prototype.getResources = function( callback ) { 6 | callback = callback.bind( this ); 7 | 8 | if ( this.verbose ) { 9 | this.log( "Getting resources from WordPress..." ); 10 | } 11 | 12 | this.client.call( "gw.getResources", function( error, resources ) { 13 | if ( error ) { 14 | return callback( error ); 15 | } 16 | 17 | if ( this.verbose ) { 18 | this.log( "Got resources from WordPress." ); 19 | } 20 | 21 | callback( null, resources ); 22 | }); 23 | }; 24 | 25 | Client.prototype.publishResource = function( filepath, content, callback ) { 26 | callback = callback.bind( this ); 27 | 28 | if ( this.verbose ) { 29 | this.log( "Publishing " + filepath + "..." ); 30 | } 31 | 32 | this.client.authenticatedCall( "gw.addResource", filepath, content, function( error, checksum ) { 33 | if ( error ) { 34 | return callback( error ); 35 | } 36 | 37 | this.log( "Published " + filepath + "." ); 38 | 39 | callback( null, checksum ); 40 | }); 41 | }; 42 | 43 | Client.prototype.deleteResource = function( filepath, callback ) { 44 | callback = callback.bind( this ); 45 | 46 | if ( this.verbose ) { 47 | this.log( "Deleting " + filepath + "..." ); 48 | } 49 | 50 | this.client.authenticatedCall( "gw.deleteResource", filepath, function( error, checksum ) { 51 | if ( error ) { 52 | return callback( error ); 53 | } 54 | 55 | if ( this.verbose ) { 56 | this.log( "Deleted " + filepath + "." ); 57 | } 58 | 59 | callback( null, checksum ); 60 | }); 61 | }; 62 | 63 | Client.prototype.syncResources = function( callback ) { 64 | callback = callback.bind( this ); 65 | 66 | if ( this.verbose ) { 67 | this.log( "Synchronizing resources..." ); 68 | } 69 | 70 | this.waterfall([ 71 | function getResources( callback ) { 72 | this.getResources( callback ); 73 | }, 74 | 75 | function publishResources( resources, callback ) { 76 | if ( this.verbose ) { 77 | this.log( "Publishing resources..." ); 78 | } 79 | 80 | var dir = this.path( "resources/" ); 81 | this.recurse( dir, function( file, callback ) { 82 | var resource = file.substr( dir.length, file.length - dir.length ); 83 | var content = fs.readFileSync( file, { encoding: "base64" } ); 84 | var checksum = this.createChecksum( content ); 85 | 86 | // Already exists, no need to update 87 | if ( resource in resources && checksum === resources[ resource ] ) { 88 | if ( this.verbose ) { 89 | this.log( "Skipping " + resource + "; already up-to-date." ); 90 | } 91 | 92 | delete resources[ resource ]; 93 | return callback( null ); 94 | } 95 | 96 | this.publishResource( resource, content, function( error ) { 97 | if ( error ) { 98 | return callback( error ); 99 | } 100 | 101 | delete resources[ resource ]; 102 | callback( null ); 103 | }); 104 | }, function( error ) { 105 | if ( error ) { 106 | return callback( error ); 107 | } 108 | 109 | if ( this.verbose ) { 110 | this.log( "Published all resources." ); 111 | } 112 | 113 | callback( null, resources ); 114 | }); 115 | }, 116 | 117 | function deleteResources( resources, callback ) { 118 | if ( this.verbose ) { 119 | this.log( "Deleting old resources..." ); 120 | } 121 | 122 | this.forEach( Object.keys( resources ), function( resourcePath, callback ) { 123 | this.deleteResource( resourcePath, callback ); 124 | }, function( error ) { 125 | if ( error ) { 126 | return callback( error ); 127 | } 128 | 129 | if ( this.verbose ) { 130 | this.log( "Deleted all old resources." ); 131 | } 132 | 133 | callback( null ); 134 | }); 135 | } 136 | ], callback ); 137 | }; 138 | 139 | }; 140 | -------------------------------------------------------------------------------- /lib/taxonomies.js: -------------------------------------------------------------------------------- 1 | module.exports = function( Client ) { 2 | 3 | var fs = require( "fs" ); 4 | var async = require( "async" ); 5 | 6 | // Converts a term to a readable name, e.g., { taxonomy: "foo", slug: "bar" } to "foo bar" 7 | function prettyTermName( term ) { 8 | return term.taxonomy + " " + term.slug; 9 | } 10 | 11 | Client.prototype.readTerms = function( filepath, callback ) { 12 | callback = callback.bind( this ); 13 | 14 | fs.readFile( filepath, function( error, taxonomies ) { 15 | if ( error ) { 16 | // Taxonomies are optional, so a missing file is ok 17 | if ( error.code === "ENOENT" ) { 18 | return callback( null, {} ); 19 | } 20 | 21 | return callback( error ); 22 | } 23 | 24 | try { 25 | taxonomies = JSON.parse( taxonomies ); 26 | } catch( error ) { 27 | this.logError( "Invalid taxonomy definitions file." ); 28 | return callback( error ); 29 | } 30 | 31 | callback( null, taxonomies ); 32 | }.bind( this )); 33 | }; 34 | 35 | Client.prototype.validateTerms = function( callback ) { 36 | callback = callback.bind( this ); 37 | 38 | var count = 0; 39 | 40 | this.readTerms( this.path( "taxonomies.json" ), function( error, taxonomies ) { 41 | if ( error ) { 42 | return callback( error ); 43 | } 44 | 45 | this.forEach( Object.keys( taxonomies ), function( taxonomy, callback ) { 46 | var process = function( terms, callback ) { 47 | callback = callback.bind( this ); 48 | 49 | var termNames = []; 50 | 51 | this.forEach( terms, function( term, callback ) { 52 | if ( !term.name ) { 53 | return callback( new Error( "A " + taxonomy + " term has no name." ) ); 54 | } 55 | if ( termNames.indexOf( term.name ) !== -1 ) { 56 | return callback( new Error( 57 | "There are multiple " + taxonomy + " " + term.name + " terms." ) ); 58 | } 59 | if ( !term.slug ) { 60 | return callback( new Error( 61 | "The " + taxonomy + " term " + term.name + " has no slug." ) ); 62 | } 63 | if ( !(/^([a-zA-Z0-9]+[.\-]?)+$/).test( term.slug ) ) { 64 | return callback( new Error( "Invalid slug: " + term.slug + "." ) ); 65 | } 66 | 67 | termNames.push( term.name ); 68 | count++; 69 | 70 | if ( term.children ) { 71 | return process( term.children, callback ); 72 | } 73 | 74 | callback( null ); 75 | }, callback ); 76 | }.bind( this ); 77 | 78 | process( taxonomies[ taxonomy ], callback ); 79 | }, function( error ) { 80 | if ( error ) { 81 | return callback( error ); 82 | } 83 | 84 | var msg = "Validated " + (count === 1 ? 85 | "one term." : 86 | (count + " terms.")); 87 | this.log( msg ); 88 | 89 | callback( null ); 90 | }); 91 | }); 92 | }; 93 | 94 | Client.prototype.getTerms = function( callback ) { 95 | callback = callback.bind( this ); 96 | 97 | this.waterfall([ 98 | function getTaxonomies( callback ) { 99 | if ( this.verbose ) { 100 | this.log( "Getting taxonomies from WordPress..." ); 101 | } 102 | 103 | this.client.getTaxonomies( callback ); 104 | }, 105 | 106 | function getTerms( taxonomies, callback ) { 107 | if ( this.verbose ) { 108 | this.log( "Got taxonomies from WordPress." ); 109 | } 110 | 111 | var existingTerms = {}; 112 | 113 | this.forEach( taxonomies, function( taxonomy, callback ) { 114 | existingTerms[ taxonomy.name ] = {}; 115 | 116 | if ( this.verbose ) { 117 | this.log( "Getting " + taxonomy.name + " terms..." ); 118 | } 119 | 120 | this.client.getTerms( taxonomy.name, function( error, terms ) { 121 | if ( error ) { 122 | return callback( error ); 123 | } 124 | 125 | if ( this.verbose ) { 126 | this.log( "Got " + taxonomy.name + " terms." ); 127 | } 128 | 129 | var idMap = {}; 130 | 131 | function expandSlug( term ) { 132 | var slug = term.slug; 133 | while ( term.parent !== "0" ) { 134 | term = idMap[ term.parent ]; 135 | slug = term.slug + "/" + slug; 136 | } 137 | return slug; 138 | } 139 | 140 | terms.forEach(function( term ) { 141 | idMap[ term.termId ] = term; 142 | }); 143 | 144 | terms.forEach(function( term ) { 145 | existingTerms[ taxonomy.name ][ expandSlug( term ) ] = term; 146 | }); 147 | 148 | callback( null ); 149 | }); 150 | }, function( error ) { 151 | if ( error ) { 152 | return callback( error ); 153 | } 154 | 155 | callback( null, existingTerms ); 156 | }); 157 | } 158 | ], callback ); 159 | }; 160 | 161 | Client.prototype.publishTerm = function( term, callback ) { 162 | callback = callback.bind( this ); 163 | 164 | var name = prettyTermName( term ); 165 | 166 | if ( term.termId ) { 167 | if ( this.verbose ) { 168 | this.log( "Editing " + name + "..." ); 169 | } 170 | 171 | this.client.editTerm( term.termId, term, function( error ) { 172 | if ( error ) { 173 | return callback( error ); 174 | } 175 | 176 | this.log( "Edited " + name + "." ); 177 | callback( null, term.termId ); 178 | }); 179 | } else { 180 | if ( this.verbose ) { 181 | this.log( "Creating " + name + "..." ); 182 | } 183 | 184 | this.client.newTerm( term, function( error, termId ) { 185 | if ( error ) { 186 | return callback( error ); 187 | } 188 | 189 | this.log( "Created " + name + "." ); 190 | callback( null, termId ); 191 | }); 192 | } 193 | }; 194 | 195 | Client.prototype.deleteTerm = function( term, callback ) { 196 | callback = callback.bind( this ); 197 | 198 | var name = prettyTermName( term ); 199 | 200 | if ( term.taxonomy === "category" && term.termId === "1" ) { 201 | // Do not try to delete the default category, 202 | // as it can't be deleted. 203 | callback( null ); 204 | return; 205 | } 206 | 207 | if ( this.verbose ) { 208 | this.log( "Deleting " + name + "..." ); 209 | } 210 | 211 | this.client.deleteTerm( term.taxonomy, term.termId, function( error ) { 212 | if ( error ) { 213 | return callback( error ); 214 | } 215 | 216 | this.log( "Deleted " + name + "." ); 217 | callback( null ); 218 | }); 219 | }; 220 | 221 | Client.prototype.syncTerms = function( callback ) { 222 | callback = callback.bind( this ); 223 | 224 | if ( this.verbose ) { 225 | this.log( "Synchronizing terms..." ); 226 | } 227 | 228 | this.waterfall([ 229 | function getTerms( callback ) { 230 | this.getTerms( callback ); 231 | }, 232 | 233 | function publishTerms( existingTerms, callback ) { 234 | var termMap = {}; 235 | 236 | if ( this.verbose ) { 237 | this.log( "Publishing terms..." ); 238 | } 239 | 240 | this.readTerms( this.path( "taxonomies.json" ), function( error, taxonomies ) { 241 | if ( error ) { 242 | return callback( error ); 243 | } 244 | 245 | this.forEach( Object.keys( taxonomies ), function( taxonomy, callback ) { 246 | // Taxonomies must already exist in WordPress 247 | if ( !existingTerms[ taxonomy ] ) { 248 | this.logError( "Taxonomies must exist in WordPress prior to use in taxonomies.json." ); 249 | return callback( new Error( "Invalid taxonomy: " + taxonomy ) ); 250 | } 251 | 252 | if ( this.verbose ) { 253 | this.log( "Publishing " + taxonomy + " terms..." ); 254 | } 255 | 256 | termMap[ taxonomy ] = {}; 257 | 258 | var process = function( terms, parent, callback ) { 259 | callback = callback.bind( this ); 260 | 261 | this.forEach( terms, function( term, callback ) { 262 | term.__slug = (parent ? parent.__slug + "/" : "") + term.slug; 263 | if ( existingTerms[ taxonomy ][ term.__slug ] ) { 264 | term.termId = existingTerms[ taxonomy ][ term.__slug ].termId; 265 | } 266 | // TODO: check if a term with the same name already exists 267 | term.taxonomy = taxonomy; 268 | if ( parent ) { 269 | term.parent = parent.termId; 270 | } 271 | 272 | this.publishTerm( term, function( error, termId ) { 273 | if ( error ) { 274 | this.logError( "Error publishing " + prettyTermName( term ) + "." ); 275 | return callback( error ); 276 | } 277 | 278 | term.termId = termId; 279 | termMap[ taxonomy ][ term.__slug ] = termId; 280 | function done( error ) { 281 | if ( error ) { 282 | return callback( error ); 283 | } 284 | 285 | delete existingTerms[ taxonomy ][ term.__slug ]; 286 | callback( null, termId ); 287 | } 288 | 289 | if ( !term.children ) { 290 | return done(); 291 | } 292 | 293 | // Process child terms 294 | process( term.children, term, done ); 295 | }); 296 | }, callback ); 297 | }.bind( this ); 298 | 299 | // Process top level terms 300 | process( taxonomies[ taxonomy ], null, function( error ) { 301 | if ( error ) { 302 | return callback( error ); 303 | } 304 | 305 | if ( this.verbose ) { 306 | this.log( "Published " + taxonomy + " terms." ); 307 | } 308 | 309 | callback( null ); 310 | }); 311 | }, function( error ) { 312 | if ( error ) { 313 | return callback( error ); 314 | } 315 | 316 | if ( this.verbose ) { 317 | this.log( "Published all terms." ); 318 | } 319 | 320 | callback( null, termMap, existingTerms ); 321 | }); 322 | }); 323 | }, 324 | 325 | // TODO: Don't delete terms until after processing posts. 326 | // This will allow us to use keywords without defining all of them upfront. 327 | function deleteTerms( termMap, existingTerms, callback ) { 328 | if ( this.verbose ) { 329 | this.log( "Deleting old terms..." ); 330 | } 331 | 332 | async.map( Object.keys( existingTerms ), function( taxonomy, callback ) { 333 | var terms = existingTerms[ taxonomy ]; 334 | this.forEach( Object.keys( terms ), function( term, callback ) { 335 | this.deleteTerm( terms[ term ], callback ); 336 | }, callback ); 337 | }.bind( this ), function( error ) { 338 | if ( error ) { 339 | return callback( error ); 340 | } 341 | 342 | if ( this.verbose ) { 343 | this.log( "Deleted all old terms." ); 344 | } 345 | 346 | callback( null, termMap ); 347 | }.bind( this )); 348 | } 349 | ], callback ); 350 | }; 351 | 352 | }; 353 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gilded-wordpress", 3 | "version": "1.0.8", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "gilded-wordpress", 9 | "version": "1.0.8", 10 | "license": "MIT", 11 | "dependencies": { 12 | "async": "^0.9.0", 13 | "wordpress": "^1.4.2" 14 | } 15 | }, 16 | "node_modules/async": { 17 | "version": "0.9.2", 18 | "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz", 19 | "integrity": "sha512-l6ToIJIotphWahxxHyzK9bnLR6kM4jJIIgLShZeqLY7iboHoGkdgFl7W2/Ivi4SkMJYGKqW8vSuk0uKUj6qsSw==" 20 | }, 21 | "node_modules/sax": { 22 | "version": "1.2.4", 23 | "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", 24 | "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" 25 | }, 26 | "node_modules/wordpress": { 27 | "version": "1.4.2", 28 | "resolved": "https://registry.npmjs.org/wordpress/-/wordpress-1.4.2.tgz", 29 | "integrity": "sha512-T+o+Af6pK7mhTz/rJEZk4PpSIyRMVhx6vZm6UsmrnlL8pVudhu1gWzn1n3wZXlcEZQz7I0AOkEvPQ5hu++L+qg==", 30 | "dependencies": { 31 | "xmlrpc": "1.3.2" 32 | } 33 | }, 34 | "node_modules/xmlbuilder": { 35 | "version": "8.2.2", 36 | "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-8.2.2.tgz", 37 | "integrity": "sha512-eKRAFz04jghooy8muekqzo8uCSVNeyRedbuJrp0fovbLIi7wlsYtdUn3vBAAPq2Y3/0xMz2WMEUQ8yhVVO9Stw==", 38 | "engines": { 39 | "node": ">=4.0" 40 | } 41 | }, 42 | "node_modules/xmlrpc": { 43 | "version": "1.3.2", 44 | "resolved": "https://registry.npmjs.org/xmlrpc/-/xmlrpc-1.3.2.tgz", 45 | "integrity": "sha512-jQf5gbrP6wvzN71fgkcPPkF4bF/Wyovd7Xdff8d6/ihxYmgETQYSuTc+Hl+tsh/jmgPLro/Aro48LMFlIyEKKQ==", 46 | "dependencies": { 47 | "sax": "1.2.x", 48 | "xmlbuilder": "8.2.x" 49 | }, 50 | "engines": { 51 | "node": ">=0.8", 52 | "npm": ">=1.0.0" 53 | } 54 | } 55 | }, 56 | "dependencies": { 57 | "async": { 58 | "version": "0.9.2", 59 | "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz", 60 | "integrity": "sha512-l6ToIJIotphWahxxHyzK9bnLR6kM4jJIIgLShZeqLY7iboHoGkdgFl7W2/Ivi4SkMJYGKqW8vSuk0uKUj6qsSw==" 61 | }, 62 | "sax": { 63 | "version": "1.2.4", 64 | "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", 65 | "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" 66 | }, 67 | "wordpress": { 68 | "version": "1.4.2", 69 | "resolved": "https://registry.npmjs.org/wordpress/-/wordpress-1.4.2.tgz", 70 | "integrity": "sha512-T+o+Af6pK7mhTz/rJEZk4PpSIyRMVhx6vZm6UsmrnlL8pVudhu1gWzn1n3wZXlcEZQz7I0AOkEvPQ5hu++L+qg==", 71 | "requires": { 72 | "xmlrpc": "1.3.2" 73 | } 74 | }, 75 | "xmlbuilder": { 76 | "version": "8.2.2", 77 | "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-8.2.2.tgz", 78 | "integrity": "sha512-eKRAFz04jghooy8muekqzo8uCSVNeyRedbuJrp0fovbLIi7wlsYtdUn3vBAAPq2Y3/0xMz2WMEUQ8yhVVO9Stw==" 79 | }, 80 | "xmlrpc": { 81 | "version": "1.3.2", 82 | "resolved": "https://registry.npmjs.org/xmlrpc/-/xmlrpc-1.3.2.tgz", 83 | "integrity": "sha512-jQf5gbrP6wvzN71fgkcPPkF4bF/Wyovd7Xdff8d6/ihxYmgETQYSuTc+Hl+tsh/jmgPLro/Aro48LMFlIyEKKQ==", 84 | "requires": { 85 | "sax": "1.2.x", 86 | "xmlbuilder": "8.2.x" 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gilded-wordpress", 3 | "description": "Easily synchronize content between the file system and WordPress", 4 | "version": "1.0.8", 5 | "homepage": "https://github.com/scottgonzalez/gilded-wordpress", 6 | "author": { 7 | "name": "Scott González", 8 | "email": "scott.gonzalez@gmail.com", 9 | "url": "http://scottgonzalez.com" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git://github.com/scottgonzalez/gilded-wordpress.git" 14 | }, 15 | "bugs": { 16 | "url": "https://github.com/scottgonzalez/gilded-wordpress/issues" 17 | }, 18 | "license": "MIT", 19 | "scripts": { 20 | "version": "./scripts/version.js" 21 | }, 22 | "dependencies": { 23 | "async": "^0.9.0", 24 | "wordpress": "^1.4.2" 25 | }, 26 | "keywords": [ 27 | "wordpress" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /scripts/version.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const exec = require('child_process').execFile 4 | const fs = require('fs') 5 | const path = require('path') 6 | 7 | const phpPath = path.join(__dirname, '../gilded-wordpress.php') 8 | let phpSource = fs.readFileSync(phpPath, 'utf8') 9 | 10 | phpSource = phpSource.replace( 11 | /GW_VERSION', '([^']+)'/, 12 | `GW_VERSION', '${process.env.npm_package_version}'` 13 | ) 14 | 15 | fs.writeFileSync(phpPath, phpSource) 16 | 17 | exec('git', ['add', phpPath], { env: process.env }, (error) => { 18 | if (error) { 19 | console.error(error.stack) 20 | process.exitCode = 1 21 | } 22 | }) 23 | --------------------------------------------------------------------------------