This is what I wanted all along

Last Tuesday night, I was sitting in a chair upstairs in my house*, with my 15-inch MacBook Pro, my iPad, and my iPhone 3GS all on my lap. And I had a revelation…

I’m a huge nerd!

No, not that revelation. I had that long ago. The revelation was…

This is ridiculous!

Earlier in the evening, I had spent a considerable amount of time hunting, as I had several times before, for a workable iPad app for writing code. I decided to spring for the $6.99 for Gusto, which seems promising. But since it currently doesn’t support SFTP (due to government regulations on encryption software, which the company says it’s working on), it’s completely useless to me in its present state. The bottom line: while there’s plenty I can do with an iPad, I still can’t do my work on one, which limits its usability.

The conclusion I had last Tuesday night was simple and obvious: I need a Mac that’s as small as an iPad.

And then came Wednesday. Steve Jobs must have been reading my mind on Tuesday night, and then he hopped in his Delorean to go back in time* a few months and develop a solution to the problem I had only just realized I had. Because at the Back to the Mac event Apple held that day, Steve Jobs introduced my dream computer: the 11-inch MacBook Air.

The moment I saw it, I knew I needed it. I worried a bit that it might be underpowered, or its storage capacity might be too small. But that’s not important. It would fit in the CaseCrown iPad messenger bag I had recently purchased, and that was all that mattered.

OK, performance mattered too. So before buying one, I wanted to try it out and see if it could handle what I was going to throw at it. I’d call myself a “power user” (if it didn’t sound so stupid), although I don’t usually push my Mac’s limits in terms of processing power: I rarely edit video (unfortunately), and my work in Photoshop is usually limited to small, web-scale graphics. But I do often have a lot of programs open at once: I’ll be coding in Coda; uploading files with Transmit; checking email; previewing sites in Safari, Firefox and Chrome (and occasionally bothering to check them in Internet Explorer too, which means running Parallels Desktop); writing project proposals in Pages; and editing images in Photoshop… all while keeping the music running constantly in iTunes.

My somewhat idiosyncratic suite of applications wasn’t on the demo unit at the Apple Store, of course, but I did the best I could to push the little dynamo to full capacity: I opened all of the iWork and Microsoft Office applications at once, and then simultaneously ran a video preview in iMovie, played back a multitrack audio project in GarageBand, and watched the Close Encounters of the Third Kind trailer in iTunes. All of the video and audio ran perfectly even under these conditions, and at that moment I knew I wouldn’t be walking out of the store without a MacBook Air in my hand. I also picked up the external SuperDrive (for CD/DVD access), and I supplemented the feeble 128 GB of Flash storage with a portable external 1 TB USB drive from Seagate.

I spent most of Saturday afternoon installing applications and transferring files from my MacBook Pro to the MacBook Air. Make no mistake, my goal from the moment I laid eyes on it has been clear: this machine was going to replace both the MacBook Pro and the iPad in my “digital lifestyle.” Which means that I am also doing that thing that so many of the tech bloggers are asking if it’s even possible: I’m using the 11-inch MacBook Air as my only computer. I’m on day three of this experiment, and days one and two were heavy work days. Here’s a summary of my experiences so far.

Screen Size

The MacBook Air’s screen is indeed small, but its high resolution makes up for the lack of physical space. It’s basically like a widescreen iPad: the vertical pixel count is the same (768 pixels), with the horizontal increased substantially (from 1024 to 1366). Its dimensions are comparable to the iPad’s, which means its pixel density is about the same.

The image is very clear and sharp. But it’s also making me realize my eyesight isn’t what it used to be. It’s also not directly comparable to the iPad, because I would typically have the iPad’s screen about a foot from my face, but the MacBook Air is usually at least twice as far away. When I’m working on it directly it’s acceptable. But at my desk I attach the MacBook to a 19-inch LCD and use the MacBook’s own display as a secondary monitor. In this layout the screen is even farther from my face, and I do have a bit of trouble reading it clearly at that distance.

In short, although the screen is small, it has a high pixel count and dense resolution, so it’s a very usable size, albeit a bit challenging for aging eyes.

Tip: I’ve always kept the Dock on the bottom of my screen, but the demo unit had it on the left, which seems to make sense given the widescreen aspect ratio on this screen. I’m trying it out and so far I really like it, even though I do sometimes accidentally go to the Dock when I mean to go to the tool palette in Photoshop.

Storage Capacity

128 GB is not a lot of storage space anymore. My first Mac, back in 1994, had an 80 MB hard drive. Times change. I knew I’d never be able to fit my 250 GB iTunes library on the MacBook Air, but I was worried that I wouldn’t even be able to fit Mac OS X plus all of my applications on it. I’m pleased to say though that I was able to install all of the applications I regularly use, plus all of the iTunes content I keep on my 32 GB iPhone (allowing me to sync the phone), and I still have over 62 GB free. I’m planning to allocate about 20 GB for a Boot Camp Windows 7 set-up, but that will still leave about 40 GB for data files for future projects. The bottom line is that 128 GB is an acceptable bare minimum for my needs, but I would not have been able to get by with the 64 GB base model.

As noted above, I’m supplementing the on-board storage with a 1 TB external drive. It’s an investment I definitely recommend if you’re considering a MacBook Air. Not only is it great for Time Machine backup, but I’ve been able to load all of my iTunes and iPhoto data on it, plus archives of all of my digital crap dating back to 1994. It’s small enough and light enough to go with me in the messenger bag, too, so if I do need to access anything that’s on it, I’ll have it with me.

Tip: In order to make this work, I needed to manage two separate iTunes libraries. This is a lot easier than it might seem. When starting iTunes, hold down the Option key. iTunes will give you the opportunity to select a different library or create a new one. Same goes for iPhoto.

Performance

My old MacBook Pro had a 2.66 GHz Core 2 Duo CPU, almost double the 1.4 GHz unit in the MacBook Air. Processor speed is important for heavy-duty tasks like video editing, but in practice, I find the computer’s speed is far more a factor of its hard drive performance. Read-write operations are so much faster with Flash storage than with a traditional hard drive that with the kinds of day-to-day tasks I do, the MacBook Air seems at least as fast, if not faster, than the old MacBook Pro.

Memory

I knew going into this experiment that the biggest sacrifice I would be making was in giving up half of my RAM. My old MacBook Pro has 4 GB of RAM, and the stock MacBook Air comes with 2 GB. You can get a MacBook Air with 4 GB of RAM, but it has to be installed at the factory (since it’s soldered right onto the logic board), which means you have to special order it. I was too impatient for that, as well as reluctant to drop an extra $100, so I went with the stock 2 GB.

The biggest impact of this limitation for me is that I can’t keep as many applications open at once as before. I had gotten to the point where I never even paid attention to how many applications I had running, and rarely bothered to quit an application when I was done using it.

Tip: In the Mac OS X era, we Mac users no longer have to worry about manually setting memory allocations for our applications, but Parallels Desktop does still need to have its virtual machines’ memory limits set. I copied over my Parallels VMs from the old MacBook Pro, where I had given them each 2 GB of RAM. Doing this on the MacBook Air was not good… the thing ground to a halt when I fired up Parallels. Reducing the VMs’ memory allocation to 768 MB solved the problem.

Battery Life

Apple has touted the battery life of the new MacBook Air line with claims that the 13-inch can last up to 7 hours on a single charge, and the 11-inch 5 hours. I haven’t taken the time to log my actual usage time, but so far I’ve run down the fully charged battery twice. On Monday and Tuesday I had it plugged in all day as I worked, and then went on battery power in the evening while I spent some time organizing my data files, doing a bit more work, and of course always listening to music on iTunes. Anecdotally, I’d estimate I’ve been getting at least 4 hours of battery time under these conditions.

Today’s usage is probably the most relevant yet. I ran on battery at a coffeehouse this morning for about three hours. Now I’ve been running for about another half hour on battery power at MIA, and the battery indicator is saying I have 1:47 remaining. Not bad. This definitely beats the battery life in my old MacBook Pro. Then again, I almost never ran the MBP on battery power. It’s so big it doesn’t really feel like a portable device in the same way as the Air, and whenever I would go anywhere with it, my first instinct was to locate an electrical outlet and plug in. The MacBook Air feels portable and “unplugged” in the way that up to now only the iPad did.

Portability

It’s perfect. Jason Snell has it right: “It’s quite possibly the most desirable laptop Apple has ever made.” Indeed.

Sendmail not working? Maybe your server’s IP is on a block list

This is pretty arcane, even for me, but since I spent several hours troubleshooting this problem this week — and the solution was nowhere to be found on Google — I figured it was worth sharing.

My CMS, cms34, as I’ve mentioned a few times before, is built on CakePHP. Some features of cms34 include automatically generated email messages. CakePHP has a nice email component that facilitates a lot of this work. It can be configured to use an SMTP server, but by default it sends mail directly from the web server using whatever you have installed on the server, either the ubiquitous sendmail or the more powerful (and capitalized) Postfix. Don’t unleash a deluge of flame comments on me, but I’m using sendmail. So be it.

All was working well until a few weeks ago, when suddenly none of the mails were being sent. There were no errors on the website; the messages just wouldn’t go through. What was more confusing was that messages being sent to my own domain did go through, but for those being sent to my clients’ domains, nothing.

Nothing except log entries, that is. Specifically, the mail log was filling up with lines like this:

Sep 13 13:45:56 redacted sm-mta[28158]: o8DIjsx0028156:
to=<redacted@redacted.com>, ctladdr=<redacted@redacted.com>
(33/33), delay=00:00:02, xdelay=00:00:01,
mailer=esmtp, pri=120799, relay=redacted.com.
[123.456.789.000], dsn=5.7.1, stat=Service unavailable

(Note that I’ve removed the real email and IP addresses to protect the innocent, namely myself.)

“Service unavailable,” huh? I researched that error extensively, without finding much. Eventually I was led to believe it may be an issue with my hostname, hosts, hosts.allow and/or hosts.deny files.

A few relevant points: 1) my hosts.allow file only contains one (uncommented) line: sendmail: LOCAL and 2) likewise, the hosts.deny file only contains: ALL: PARANOID. I’ll save you some time right here: the problem I had ended up having nothing whatsoever to do with any of these host-related files. Leave ’em alone.

After following a number of these dead ends, I was inspired to check the mail file on the server for the user Apache runs as, in my case www-data. On Ubuntu Linux (and probably other flavors), these mail files can be found in /var/mail. Indeed, there were some interesting things to be found there, namely, a number of references to this URL:

http://www.spamhaus.org/query/bl?ip=123.456.789.000

(Again, the IP address has been changed… and yes, I know that’s not a valid IP address. That’s the point.)

I was not previously aware of The Spamhaus Project, but perhaps I should have been. The reason my messages weren’t getting through was because my server’s IP address was on the PBL: Policy Block List. Essentially, that is a list of all of the IP addresses (or IP ranges) in the world that, according to a well-defined set of rules, have no business acting like SMTP (Simple Mail Transfer Protocol*) servers — the servers that send mail out.

It stands to reason that my server was on this list; technically it’s not an SMTP server. But it’s perfectly legitimate for a web server to be running sendmail or Postfix or something of that nature, and sending messages out from the web applications it runs. Fortunately, it’s easy to get legitimate servers removed from the PBL. Simply fill out a form, verify your identity (via a code sent to you in an email message), and within about an hour, the changes will propagate worldwide.

Success! So if you’re in the same kind of situation I was in, where everything seems to be configured properly but your messages just aren’t going out for some reason, try checking Spamhaus to see if your IP is on the PBL.

* If you made it this far in the post, I shouldn’t have to explain the acronym. But I will anyway, as is my wont.

Making CakePHP’s TreeBehavior work with scope

We’re not talking mouthwash here. We’re talking code.

CakePHP’s TreeBehavior is cool, but tree traversal is a pretty arcane concept, even for developers, and MPTT is not something that is easy to digest mentally, or to develop around. Unfortunately, it’s also not incredibly efficient on large data sets. Even not-so-large data sets, on the order of a few hundred items, make certain actions — reordering, in particular — really processor intensive.

I’m using a JavaScript drag-and-drop tool in cms34 to allow admins to manage the page tree on their sites, and the data gets stored using CakePHP’s TreeBehavior, which is MPTT-based. The problem is, it really wasn’t working. So I completely rebuilt the code that assigns the über-critical “left” and “right” values to each node in the tree. My new way is much faster, even though it may break a few rules, and requires bypassing TreeBehavior’s callbacks.

Once I got that working, I was delighted, but then I discovered some other problems, namely pertaining to… scope. My CMS supports multiple sites in one installation, which means multiple trees, which means scope. The problem is, I was having a hell of a time figuring out just how to make scope work with TreeBehavior. Finally I found a link to a succinct and effective solution.

The upshot here is that you can’t define your $actsAs in the model, because… well… to be honest, I only have a vague understanding of why not, but essentially it’s due to the split roles of the model-view-controller framework. You’re building a rule that requires access to specific data values, which is something that needs to happen in the controller; the model is strictly for the abstract structure of the data. I understand it just enough to agree that it makes sense not to do it in the model. Which means you have to do it in the controller. The sample code from the link above goes a little something like this:

function add() {
    $this->Task->Behaviors->attach('Tree', array(
         'scope' => "Task.schedule_id = {$this->data['Task']['schedule_id']}"
    ));
    $this->Task->create();
    $this->Task->save($this->data);
}

That didn’t quite work in my situation, but it got me far enough along that I could figure out what to do from there.

I still need to do a little more testing to make sure my solution to the more efficient tree reordering is rock solid, and then I’ll post a tutorial. But for now, I hope this helps spread the word that scope does work on TreeBehavior… if you do it right.

Update (September 22, 2010): Although this didn’t really seem to be breaking anything, just throwing up a warning (when debugging was turned on), I discovered a minor issue with this code yesterday. Turns out CakePHP expects the value of scope to be an array. Just taking the string it was defined as and wrapping it in array() did the trick.

You can’t hear my latest song

But that’s just because I’ve submitted it for consideration for inclusion in Ramen Music, a new subscription music service — a “zine” I suppose, in late-’90s Internet parlance — that brings independent musicians and artists together in what looks to be a very cool web interface. I’m excited to get the first issue, and even more excited about possibly being a part of it.

Ramen Music is the brainchild of my fellow indie musician and web guy, Sudara Williams, who also created alonetone. It’s a great idea and it looks like it’s got the right kind of support behind it to make it a success artistically, and hopefully as a business venture as well.

As for my song, well, it’s 5 1/2 minutes of upbeat electronica, probably the best track I’ve recorded to date in that style (at least, I think so). It’s called “Sembei,” the Japanese word for a quintessentially Japanese snack food: rice crackers. I’ve gotten addicted to the things — there are some excellent options imported straight from Japan at United Noodles here in Minneapolis — and it just seemed like a good name for a track I’d submit to a project called Ramen Music.

Here’s where I’d say, “Enjoy!” and link to the MP3. But in this case I’ll say… Subscribe! There’s no guarantee at this point that “Sembei” will be included on the first (or any) issue of Ramen Music, but it’s still worth it to support great independent music.

How I migrated a client’s blog from Drupal to WordPress 3.0

I just finished migrating a client’s blog from Drupal to WordPress 3.0. When I took on the project, I assumed there was a straightforward way to migrate content from Drupal to WordPress, but I was mistaken. Fortunately, there is a way, but it involves directly mucking about with the Drupal and WordPress databases. Never fear! There are some tools to help.

After a bunch of research, I found the most comprehensive and up-to-date solution on Mike Smullin’s blog. It wasn’t that up-to-date, though, having been written over a year ago and targeted at WordPress 2.7. Some helpful comments on that post offered a few refinements, and I added a few of my own to accommodate the particular quirks of this client’s site.

Since Mike Smullin (and D’Arcy Norman and Dave Dash before him) was so kind to share his work, I thought I should do the same. And so, I present my further refined SQL script for migrating Drupal data to a WordPress 3.0 site.

# DRUPAL-TO-WORDPRESS CONVERSION SCRIPT

# Changelog

# 07.29.2010 – Updated by Scott Anderson / Room 34 Creative Services http://blog.room34.com/archives/4530
# 02.06.2009 – Updated by Mike Smullin http://www.mikesmullin.com/development/migrate-convert-import-drupal-5-to-wordpress-27/
# 05.15.2007 – Updated by D’Arcy Norman http://www.darcynorman.net/2007/05/15/how-to-migrate-from-drupal-5-to-wordpress-2/
# 05.19.2006 – Created by Dave Dash http://spindrop.us/2006/05/19/migrating-from-drupal-47-to-wordpress/

# This assumes that WordPress and Drupal are in separate databases, named 'wordpress' and 'drupal'.
# If your database names differ, adjust these accordingly.

# Empty previous content from WordPress database.
TRUNCATE TABLE wordpress.wp_comments;
TRUNCATE TABLE wordpress.wp_links;
TRUNCATE TABLE wordpress.wp_postmeta;
TRUNCATE TABLE wordpress.wp_posts;
TRUNCATE TABLE wordpress.wp_term_relationships;
TRUNCATE TABLE wordpress.wp_term_taxonomy;
TRUNCATE TABLE wordpress.wp_terms;

# If you're not bringing over multiple Drupal authors, comment out these lines and the other
# author-related queries near the bottom of the script.
# This assumes you're keeping the default admin user (user_id = 1) created during installation.
DELETE FROM wordpress.wp_users WHERE ID > 1;
DELETE FROM wordpress.wp_usermeta WHERE user_id > 1;

# TAGS
# Using REPLACE prevents script from breaking if Drupal contains duplicate terms.
REPLACE INTO wordpress.wp_terms
    (term_id, `name`, slug, term_group)
    SELECT DISTINCT
        d.tid, d.name, REPLACE(LOWER(d.name), ' ', '_'), 0
    FROM drupal.term_data d
    INNER JOIN drupal.term_hierarchy h
        USING(tid)
    INNER JOIN drupal.term_node n
        USING(tid)
    WHERE (1
         # This helps eliminate spam tags from import; uncomment if necessary.
         # AND LENGTH(d.name) < 50
    )
;

INSERT INTO wordpress.wp_term_taxonomy
    (term_id, taxonomy, description, parent)
    SELECT DISTINCT
        d.tid `term_id`,
        'post_tag' `taxonomy`,
        d.description `description`,
        h.parent `parent`
    FROM drupal.term_data d
    INNER JOIN drupal.term_hierarchy h
        USING(tid)
    INNER JOIN drupal.term_node n
        USING(tid)
    WHERE (1
         # This helps eliminate spam tags from import; uncomment if necessary.
         # AND LENGTH(d.name) < 50
    )
;

# POSTS
# Keeps private posts hidden.
INSERT INTO wordpress.wp_posts
    (id, post_author, post_date, post_content, post_title, post_excerpt,
    post_name, post_modified, post_type, `post_status`)
    SELECT DISTINCT
        n.nid `id`,
        n.uid `post_author`,
        FROM_UNIXTIME(n.created) `post_date`,
        r.body `post_content`,
        n.title `post_title`,
        r.teaser `post_excerpt`,
        IF(SUBSTR(a.dst, 11, 1) = '/', SUBSTR(a.dst, 12), a.dst) `post_name`,
        FROM_UNIXTIME(n.changed) `post_modified`,
        n.type `post_type`,
        IF(n.status = 1, 'publish', 'private') `post_status`
    FROM drupal.node n
    INNER JOIN drupal.node_revisions r
        USING(vid)
    LEFT OUTER JOIN drupal.url_alias a
        ON a.src = CONCAT('node/', n.nid)
    # Add more Drupal content types below if applicable.
    WHERE n.type IN ('post', 'page', 'blog')
;

# Fix post type; http://www.mikesmullin.com/development/migrate-convert-import-drupal-5-to-wordpress-27/#comment-17826
# Add more Drupal content types below if applicable.
UPDATE wordpress.wp_posts
    SET post_type = 'post'
    WHERE post_type IN ('blog')
;

# Set all pages to "pending".
# If you're keeping the same page structure from Drupal, comment out this query
# and the new page INSERT at the end of this script.
UPDATE wordpress.wp_posts SET post_status = 'pending' WHERE post_type = 'page';

# POST/TAG RELATIONSHIPS
INSERT INTO wordpress.wp_term_relationships (object_id, term_taxonomy_id)
    SELECT DISTINCT nid, tid FROM drupal.term_node
;

# Update tag counts.
UPDATE wp_term_taxonomy tt
    SET `count` = (
        SELECT COUNT(tr.object_id)
        FROM wp_term_relationships tr
        WHERE tr.term_taxonomy_id = tt.term_taxonomy_id
    )
;

# COMMENTS
# Keeps unapproved comments hidden.
# Incorporates change noted here: http://www.mikesmullin.com/development/migrate-convert-import-drupal-5-to-wordpress-27/#comment-32169
INSERT INTO wordpress.wp_comments
    (comment_post_ID, comment_date, comment_content, comment_parent, comment_author,
    comment_author_email, comment_author_url, comment_approved)
    SELECT DISTINCT
        nid, FROM_UNIXTIME(timestamp), comment, thread, name,
        mail, homepage, ((status + 1) % 2)
    FROM drupal.comments
;

# Update comments count on wp_posts table.
UPDATE wordpress.wp_posts
    SET `comment_count` = (
        SELECT COUNT(`comment_post_id`)
        FROM wordpress.wp_comments
        WHERE wordpress.wp_posts.`id` = wordpress.wp_comments.`comment_post_id`
    )
;

# Fix images in post content; uncomment if you're moving files from "files" to "wp-content/uploads".
# UPDATE wordpress.wp_posts SET post_content = REPLACE(post_content, '"/files/', '"/wp-content/uploads/');

# Fix taxonomy; http://www.mikesmullin.com/development/migrate-convert-import-drupal-5-to-wordpress-27/#comment-27140
UPDATE IGNORE wordpress.wp_term_relationships, wordpress.wp_term_taxonomy
    SET wordpress.wp_term_relationships.term_taxonomy_id = wordpress.wp_term_taxonomy.term_taxonomy_id
    WHERE wordpress.wp_term_relationships.term_taxonomy_id = wordpress.wp_term_taxonomy.term_id
;

# OPTIONAL ADDITIONS — REMOVE ALL BELOW IF NOT APPLICABLE TO YOUR CONFIGURATION

# CATEGORIES
# These are NEW categories, not in Drupal. Add as many sets as needed.
INSERT IGNORE INTO wordpress.wp_terms (name, slug)
    VALUES
    ('First Category', 'first-category'),
    ('Second Category', 'second-category'),
    ('Third Category', 'third-category')
;

# Set category names to title case (in case term already exists [as a tag] in lowercase).
UPDATE wordpress.wp_terms SET name = 'First Category' WHERE name = 'first category';
UPDATE wordpress.wp_terms SET name = 'Second Category' WHERE name = 'second category';
UPDATE wordpress.wp_terms SET name = 'Third Category' WHERE name = 'third category';

# Add categories to taxonomy.
INSERT INTO wordpress.wp_term_taxonomy (term_id, taxonomy)
    VALUES
    ((SELECT term_id FROM wp_terms WHERE slug = 'first-category'), 'category'),
    ((SELECT term_id FROM wp_terms WHERE slug = 'second-category'), 'category'),
    ((SELECT term_id FROM wp_terms WHERE slug = 'third-category'), 'category')
;

# Auto-assign posts to category.
# You'll need to work out your own logic to determine strings/terms to match.
# Repeat this block as needed for each category you're creating.
INSERT IGNORE INTO wordpress.wp_term_relationships (object_id, term_taxonomy_id)
    SELECT DISTINCT p.ID AS object_id,
        (SELECT tt.term_taxonomy_id
        FROM wordpress.wp_term_taxonomy tt
        INNER JOIN wordpress.wp_terms t USING (term_id)
        WHERE t.slug = 'enter-category-slug-here'
        AND tt.taxonomy = 'category') AS term_taxonomy_id
    FROM wordpress.wp_posts p
    WHERE p.post_content LIKE '%enter string to match here%'
    OR p.ID IN (
        SELECT tr.object_id
        FROM wordpress.wp_term_taxonomy tt
        INNER JOIN wordpress.wp_terms t USING (term_id)
        INNER JOIN wordpress.wp_term_relationships tr USING (term_taxonomy_id)
        WHERE t.slug IN ('enter','terms','to','match','here')
        AND tt.taxonomy = 'post_tag'
    )
;

# Update category counts.
UPDATE wp_term_taxonomy tt
    SET `count` = (
        SELECT COUNT(tr.object_id)
        FROM wp_term_relationships tr
        WHERE tr.term_taxonomy_id = tt.term_taxonomy_id
    )
;

# AUTHORS
INSERT IGNORE INTO wordpress.wp_users
    (ID, user_login, user_pass, user_nicename, user_email,
    user_registered, user_activation_key, user_status, display_name)
    SELECT DISTINCT
        u.uid, u.mail, NULL, u.name, u.mail,
        FROM_UNIXTIME(created), '', 0, u.name
    FROM drupal.users u
    INNER JOIN drupal.users_roles r
        USING (uid)
    WHERE (1
        # Uncomment and enter any email addresses you want to exclude below.
        # AND u.mail NOT IN ('test@example.com')
    )
;

# Assign author permissions.
# Sets all authors to "author" by default; next section can selectively promote individual authors
INSERT IGNORE INTO wordpress.wp_usermeta (user_id, meta_key, meta_value)
    SELECT DISTINCT
        u.uid, 'wp_capabilities', 'a:1:{s:6:"author";s:1:"1";}'
    FROM drupal.users u
    INNER JOIN drupal.users_roles r
        USING (uid)
    WHERE (1
        # Uncomment and enter any email addresses you want to exclude below.
        # AND u.mail NOT IN ('test@example.com')
    )
;
INSERT IGNORE INTO wordpress.wp_usermeta (user_id, meta_key, meta_value)
    SELECT DISTINCT
        u.uid, 'wp_user_level', '2'
    FROM drupal.users u
    INNER JOIN drupal.users_roles r
        USING (uid)
    WHERE (1
        # Uncomment and enter any email addresses you want to exclude below.
        # AND u.mail NOT IN ('test@example.com')
    )
;

# Change permissions for admins.
# Add any specific user IDs to IN list to make them administrators.
# User ID values are carried over from Drupal.
UPDATE wordpress.wp_usermeta
    SET meta_value = 'a:1:{s:13:"administrator";s:1:"1";}'
    WHERE user_id IN (1) AND meta_key = 'wp_capabilities'
;
UPDATE wordpress.wp_usermeta
    SET meta_value = '10'
    WHERE user_id IN (1) AND meta_key = 'wp_user_level'
;

# Reassign post authorship.
UPDATE wordpress.wp_posts
    SET post_author = NULL
    WHERE post_author NOT IN (SELECT DISTINCT ID FROM wordpress.wp_users)
;

# VIDEO – READ BELOW AND COMMENT OUT IF NOT APPLICABLE TO YOUR SITE
# If your Drupal site uses the content_field_video table to store links to YouTube videos,
# this query will insert the video URLs at the end of all relevant posts.
# WordPress will automatically convert the video URLs to YouTube embed code.
UPDATE IGNORE wordpress.wp_posts p, drupal.content_field_video v
    SET p.post_content = CONCAT_WS('\n',post_content,v.field_video_embed)
    WHERE p.ID = v.nid
;

# IMAGES – READ BELOW AND COMMENT OUT IF NOT APPLICABLE TO YOUR SITE
# If your Drupal site uses the content_field_image table to store images associated with posts,
# but not actually referenced in the content of the posts themselves, this query
# will insert the images at the top of the post.
# HTML/CSS NOTE: The code applies a "drupal_image" class to the image and places it inside a <div>
# with the "drupal_image_wrapper" class. Add CSS to your WordPress theme as appropriate to
# handle styling of these elements. The <img> tag as written assumes you'll be copying the
# Drupal "files" directory into the root level of WordPress, NOT placing it inside the
# "wp-content/uploads" directory. It also relies on a properly formatted <base href="" /> tag.
# Make changes as necessary before running this script!
UPDATE IGNORE wordpress.wp_posts p, drupal.content_field_image i, drupal.files f
    SET p.post_content =
        CONCAT(
            CONCAT(
                '<div class="drupal_image_wrapper"><img src="files/',
                f.filename,
                '" class="drupal_image" /></div>'
            ),
            p.post_content
        )
    WHERE p.ID = i.nid
    AND i.field_image_fid = f.fid
    AND (
        f.filename LIKE '%.jpg'
        OR f.filename LIKE '%.jpeg'
        OR f.filename LIKE '%.png'
        OR f.filename LIKE '%.gif'
    )
;

# Fix post_name to remove paths.
# If applicable; Drupal allows paths (i.e. slashes) in the dst field, but this breaks
# WordPress URLs. If you have mod_rewrite turned on, stripping out the portion before
# the final slash will allow old site links to work properly, even if the path before
# the slash is different!
UPDATE wordpress.wp_posts
    SET post_name =
    REVERSE(SUBSTRING(REVERSE(post_name),1,LOCATE('/',REVERSE(post_name))-1))
;

# Miscellaneous clean-up.
# There may be some extraneous blank spaces in your Drupal posts; use these queries
# or other similar ones to strip out the undesirable tags.
UPDATE wordpress.wp_posts
    SET post_content = REPLACE(post_content,'<p> </p>','')
;
UPDATE wordpress.wp_posts
    SET post_content = REPLACE(post_content,'<p class="italic"> </p>','')
;

# NEW PAGES – READ BELOW AND COMMENT OUT IF NOT APPLICABLE TO YOUR SITE
# MUST COME LAST IN THE SCRIPT AFTER ALL OTHER QUERIES!
# If your site will contain new pages, you can set up the basic structure for them here.
# Once the import is complete, go into the WordPress admin and copy content from the Drupal
# pages (which are set to "pending" in a query above) into the appropriate new pages.
INSERT INTO wordpress.wp_posts
    (`post_author`, `post_date`, `post_date_gmt`, `post_content`, `post_title`,
    `post_excerpt`, `post_status`, `comment_status`, `ping_status`, `post_password`,
    `post_name`, `to_ping`, `pinged`, `post_modified`, `post_modified_gmt`,
    `post_content_filtered`, `post_parent`, `guid`, `menu_order`, `post_type`,
    `post_mime_type`, `comment_count`)
    VALUES
    (1, NOW(), NOW(), 'Page content goes here, or leave this value empty.', 'Page Title',
    '', 'publish', 'closed', 'closed', '',
    'slug-goes-here', '', '', NOW(), NOW(),
    '', 0, 'http://full.url.to.page.goes.here', 1, 'page', '', 0)
;

A few notes and suggestions:

  • Use at your own risk. I’m just sharing the code I used. In fact, it’s not even exactly the code I used, since I had to generalize a few things for mass consumption. I haven’t even tested this modified version. I make no guarantees as to its reliability or applicability to your particular implementation.
  • Download the script below; don’t just copy-paste the code from the block above. I had to apply some HTML formatting to make it look right, and this might result in bad characters that would break the script if run as-is above.
  • Always work with a test copy of your database before altering live site content. In theory this script can be run over and over again cleanly, but it will erase whatever data was already in the WordPress database, and there will be no way to get it back unless you have a backup.
  • Read through the code carefully, especially all comments before running the script. Most likely you will need to make some changes to the code.
  • Good luck! If you find bugs or develop further improvements, please comment below!

Download the Script

Download zip file... Drupal-to-WordPress Migration Script
drupal-to-wordpress.sql.zip • 4.8 KB