Converting to the Core WordPress Footnotes Block

I had previously used Mark Cheret’s Footnotes plugin to, well, create footnotes in WordPress. WP 6.3 introduced this functionality natively into the block editor, and since then I’ve wanted to convert all my instances to use the new core block but hadn’t got around to it yet.

That is, until I ran composer update today and got an error that wpackagist-plugin/footnotes no longer exists. It was removed from the plugin directory at some point, and must have fairly recently been removed from wpackagist too.

So I wrote a script to convert my instance. Here’s the gist of what it does:

It first checks that the core footnote block exists (eg: requires WP6.3)

Then gets the short code settings from the old footnotes plugin

Iterates through all posts and pages, checking for the old plugin’s short code (WP 6.3 and 6.4 only support footnotes on posts and pages, so on those versions it only looks at those post types. 6.5 will indiscriminately look through all post types)

When found, replace with core footnotes, adding the markup and metadata required

Add the footnotes block to the end of the post, if not already there.

Prompt before updating the database

Here’s the script, also available in a GitHub gist: https://gist.github.com/jessedyck/c5a318f69680b3a3cacf253f4c6ff040

Proceed with caution! This was for my use-case and will likely not handle all edge cases. There are a few guardrails in place, and it’ll hopefully help someone else get started doing the same thing, but it’s just as likely to cause weird content issues. Test in a dev environment, and backup your production environment first.

<?php
/**
* A script to convert from the (abandoned) footnotes plugin to WP core footnotes.
* This script is intended to be ran from wp-cli.
* Requires WP 6.3. Does not require that the original footnotes plugin still be installed,
* but does require that the options in `wp_options` are still in place.
*
* BACKUP YOUR DATABASE!
* This script is not well-tested. It works for my (small) use-case. It should support
* the original plugin's various short codes, but has only been tested with the
* default `[ref]` and similar default settings.
*
* Proceed with caution!
*
* Usage:
* wp eval-file convert-footnotes.php
*
* @see https://wordpress.org/plugins/footnotes/
*/

// phpcs:set WordPress.NamingConventions.PrefixAllGlobals prefixes[] jdf

/**
* Encapsulate in function to namespace variables.
*/
function jdf_run(): void {

/**
* Ensure we have the footnotes block
*/
if ( ! WP_Block_Type_Registry::get_instance()->is_registered( 'core/footnotes' ) ) {
echo 'Core footnotes block is not registered';
die();
}

/**
* Get the plugin's settings.
*/
$footnotes_options = get_option( 'footnotes_storage' );

if ( ! is_array( $footnotes_options ) ) {
echo "Could not find 'footnotes' plugin options";
die();
}

// Get the short code used by footnotes plugin.
$start_tag = $footnotes_options['footnote_inputfield_placeholder_start'];
$end_tag = $footnotes_options['footnote_inputfield_placeholder_end'];
$escaped_start_tag = preg_quote( $start_tag );
$escaped_end_tag = preg_quote( $end_tag, '/' );

// Get all posts to check.
// Prior to 6.5, footnotes were only supported on core post types.
$post_types = 'any';
if ( ! is_wp_version_compatible( '6.5' ) ) {
echo "Note: Prior to WP 6.5, footnotes were not supported on CPTs. Re-run the script after upgrading to convert footnotes on more post types.\n";
$post_types = array( 'post', 'page' );
}

$args = array(
'post_type' => $post_types,
'post_status' => 'publish',
'posts_per_page' => '-1',
);

$all_posts = new WP_Query( $args );
echo "Found {$all_posts->found_posts} posts\n";

if ( $all_posts->have_posts() ) {
while ( $all_posts->have_posts() ) {
$all_posts->the_post();

$content = get_the_content();

$matches = array();
$regex = "/$escaped_start_tag(.*?)$escaped_end_tag/i";

if ( preg_match_all( $regex, $content, $matches ) ) {
$pid = get_the_ID();
$permalink = get_the_permalink();

echo "Found plugin footnote on post ID: {$pid}, permalink: {$permalink}\n";

/**
* In my case, there are duplicate matches because of the `source` block
* attribute from Jetpack's Markdown block.
* Deduping ensures no extra metadata is created.
* */
$matches[0] = array_unique( $matches[0] );
$matches[1] = array_unique( $matches[1] );

// Get existing core footnotes.
$footnotes = get_post_meta( $pid, 'footnotes', true );

if ( empty( $footnotes ) ) {
$footnotes = array();
} else {
$footnotes = json_decode( $footnotes );
}

foreach ( $matches[0] as $key => $match ) {
$fn = new stdClass();
$uid = strtolower( jdf_GUID() );

$fn->content = $matches[1][ $key ];
$fn->id = $uid;

$footnotes[] = $fn;

$fn_num = count( $footnotes );

// Markup format is as of WP6.4.1.
$content = str_replace(
$matches[0][ $key ],
sprintf( '%2$d', $uid, $fn_num ),
$content
);
}

// Add the footnotes block, if it doesn't exist.
if ( ! has_block( 'core/footnotes' ) ) {
$content .= ' ';
}

// Confirm before updating.
$prompt = readline( "Update post ID: {$pid}? (y) " );
if ( $prompt === 'y' ) {
echo "Updating $pid...\n";

wp_update_post(
array(
'ID' => $pid,
'post_content' => $content,
'meta_input' => array(
'footnotes' => wp_json_encode( $footnotes, JSON_UNESCAPED_UNICODE ),
),
)
);
} else {
echo "Skipping $pid.\n";
}
}
}
}
}

jdf_run();

/**
* Generates a GUID for use in linking footnotes.
*
* @link https://stackoverflow.com/a/26163679
* @return string
*/
/* phpcs:disable */
function jdf_GUID()
{
if (function_exists('com_create_guid') === true)
{
return trim(com_create_guid(), '{}');
}

return sprintf('%04X%04X-%04X-%04X-%04X-%04X%04X%04X', mt_rand(0, 65535), mt_rand(0, 65535), mt_rand(0, 65535), mt_rand(16384, 20479), mt_rand(32768, 49151), mt_rand(0, 65535), mt_rand(0, 65535), mt_rand(0, 65535));
}
/* phpcs:enable */