43

The task

You can register add additional Themes directories using register_theme_directory() for your WP installation. Sadly core doesn't deliver with the same functionality for plugins. We already have MU-Plugin, Drop-Ins, Plugins and Themes. But we need more for a better file organization.

Here's the list of tasks to achieve:

  • Add an additional plugin directory
  • For each plugin directory, a new "tab" is needed as shown here [1]
  • The additional directory would've the same functionality as the default plugin directory has

What's in there for you?

The best and most complete answer will be awarded a bounty.


[1] Additional tab for a new plugin folder/directory

6
  • 3
    Since directory structure is pretty tied to directory constants I have doubts that doing this on file system level is practical (without core adoption that is). Virtual layer of organization in admin might be easier to achieve on extension level.
    – Rarst
    Commented Feb 23, 2012 at 13:45
  • @Rarst Which shouldn't hold you back from adding your thoughts :)
    – kaiser
    Commented Feb 23, 2012 at 14:01
  • This would be a great feature.
    – ltfishie
    Commented Mar 1, 2012 at 5:11
  • Feature sounds good. Just need to reverse engineer core, figure out how it should be done (the WP way) and then submit a patch to the Devs...you'd want to look at register_theme_directory() - search_theme_directories() - get_raw_theme_root() - get_theme_roots() - get_theme() - get_themes() Commented Mar 1, 2012 at 23:08
  • 2
    Guys: Submit what? This is a question, not an answer with full blown code :) FYI: A new ticket on trac to rewrite get_themes() to a class.
    – kaiser
    Commented Mar 2, 2012 at 1:48

3 Answers 3

28
+100

Okay, I'll take a stab at this. Some limitations I encountered along the way:

  1. There are not a lot of filters in subclasses of WP_List_Table, at least no where we need them to be.

  2. Due to that lack of filters, we can't really maintain an accurate list of plugin types at the top.

  3. We also have to use some awesome (read: dirty) JavaScript hacks to display plugins as active.

I wrapped my admin area code inside a class, so my function names are not prefixed. You can see all of this code here. Please contribute!

Central API

Just a simple function that sets up a global variable which will contain our plugin directories in an associative array. The $key is going to be something used internally to fetch plugins, etc. $dir is either a full path or something relative to the wp-content directory. $label is going to be for our display in the admin area (eg. a translatable string).

<?php
function register_plugin_directory( $key, $dir, $label )
{
    global $wp_plugin_directories;
    if( empty( $wp_plugin_directories ) ) $wp_plugin_directories = array();

    if( ! file_exists( $dir ) && file_exists( trailingslashit( WP_CONTENT_DIR ) . $dir ) )
    {
        $dir = trailingslashit( WP_CONTENT_DIR ) . $dir;
    }

    $wp_plugin_directories[$key] = array(
        'label' => $label,
        'dir'   => $dir
    );
}

Then, of course, we need to load the plugins. Hook into plugins_loaded way late and go through the active plugins, loading each.

Admin Area

Let's set up our functionality inside a class.

<?php
class CD_APD_Admin
{

    /**
     * The container for all of our custom plugins
     */
    protected $plugins = array();

    /**
     * What custom actions are we allowed to handle here?
     */
    protected $actions = array();

    /**
     * The original count of the plugins
     */
    protected $all_count = 0;

    /**
     * constructor
     * 
     * @since 0.1
     */
    function __construct()
    {
        add_action( 'load-plugins.php', array( &$this, 'init' ) );
        add_action( 'plugins_loaded', array( &$this, 'setup_actions' ), 1 );

    }

} // end class

We're going to hook into plugins_loaded really early and set up the allowed "actions" we'll be using. These will handle plugin activation and deactivation as the built in functions can't do it with custom directories.

function setup_actions()
{
    $tmp = array(
        'custom_activate',
        'custom_deactivate'
    );
    $this->actions = apply_filters( 'custom_plugin_actions', $tmp );
}

Then there's the function hooked into load-plugins.php. This does all sorts of fun stuff.

function init()
{
    global $wp_plugin_directories;

    $screen = get_current_screen();

    $this->get_plugins();

    $this->handle_actions();

    add_filter( 'views_' . $screen->id, array( &$this, 'views' ) );

    // check to see if we're using one of our custom directories
    if( $this->get_plugin_status() )
    {
        add_filter( 'views_' . $screen->id, array( &$this, 'views_again' ) );
        add_filter( 'all_plugins', array( &$this, 'filter_plugins' ) );
        // TODO: support bulk actions
        add_filter( 'bulk_actions-' . $screen->id, '__return_empty_array' );
        add_filter( 'plugin_action_links', array( &$this, 'action_links' ), 10, 2 );
        add_action( 'admin_enqueue_scripts', array( &$this, 'scripts' ) );
    }
}

Let's go through that one thing at a time. the get_plugins method, is a wrapper around another function. It fills the attribute plugins with data.

function get_plugins()
{
    global $wp_plugin_directories;
    foreach( array_keys( $wp_plugin_directories ) as $key )
    {
       $this->plugins[$key] = cd_apd_get_plugins( $key );
    }
}

cd_apd_get_plugins is a rip of of the built in get_plugins function without the hardcoded WP_CONTENT_DIR and plugins business. Basically: get the directory from the $wp_plugin_directories global, open it, find all the plugin files. Store them in the cache for later.

<?php
function cd_apd_get_plugins( $dir_key ) 
{
    global $wp_plugin_directories;

    // invalid dir key? bail
    if( ! isset( $wp_plugin_directories[$dir_key] ) )
    {
        return array();
    }
    else
    {
        $plugin_root = $wp_plugin_directories[$dir_key]['dir'];
    }

    if ( ! $cache_plugins = wp_cache_get( 'plugins', 'plugins') )
        $cache_plugins = array();

    if ( isset( $cache_plugins[$dir_key] ) )
        return $cache_plugins[$dir_key];

    $wp_plugins = array();

    $plugins_dir = @ opendir( $plugin_root );
    $plugin_files = array();
    if ( $plugins_dir ) {
        while ( ( $file = readdir( $plugins_dir ) ) !== false ) {
            if ( substr($file, 0, 1) == '.' )
                continue;
            if ( is_dir( $plugin_root.'/'.$file ) ) {
                $plugins_subdir = @ opendir( $plugin_root.'/'.$file );
                if ( $plugins_subdir ) {
                    while (($subfile = readdir( $plugins_subdir ) ) !== false ) {
                        if ( substr($subfile, 0, 1) == '.' )
                            continue;
                        if ( substr($subfile, -4) == '.php' )
                            $plugin_files[] = "$file/$subfile";
                    }
                    closedir( $plugins_subdir );
                }
            } else {
                if ( substr($file, -4) == '.php' )
                    $plugin_files[] = $file;
            }
        }
        closedir( $plugins_dir );
    }

    if ( empty($plugin_files) )
        return $wp_plugins;

    foreach ( $plugin_files as $plugin_file ) {
        if ( !is_readable( "$plugin_root/$plugin_file" ) )
            continue;

        $plugin_data = get_plugin_data( "$plugin_root/$plugin_file", false, false ); //Do not apply markup/translate as it'll be cached.

        if ( empty ( $plugin_data['Name'] ) )
            continue;

        $wp_plugins[trim( $plugin_file )] = $plugin_data;
    }

    uasort( $wp_plugins, '_sort_uname_callback' );

    $cache_plugins[$dir_key] = $wp_plugins;
    wp_cache_set('plugins', $cache_plugins, 'plugins');

    return $wp_plugins;
}

Next up is the pesky business of actually activating and deactivating plugins. To do this, we use the handle_actions method. This is, again, blatantly ripped off from the top of the core wp-admin/plugins.php file.

function handle_actions()
{
    $action = isset( $_REQUEST['action'] ) ? $_REQUEST['action'] : '';

    // not allowed to handle this action? bail.
    if( ! in_array( $action, $this->actions ) ) return;

    // Get the plugin we're going to activate
    $plugin = isset( $_REQUEST['plugin'] ) ? $_REQUEST['plugin'] : false;
    if( ! $plugin ) return;

    $context = $this->get_plugin_status();

    switch( $action )
    {
        case 'custom_activate':
            if( ! current_user_can('activate_plugins') )
                    wp_die( __('You do not have sufficient permissions to manage plugins for this site.') );

            check_admin_referer( 'custom_activate-' . $plugin );

            $result = cd_apd_activate_plugin( $plugin, $context );
            if ( is_wp_error( $result ) ) 
            {
                if ( 'unexpected_output' == $result->get_error_code() ) 
                {
                    $redirect = add_query_arg( 'plugin_status', $context, self_admin_url( 'plugins.php' ) );
                    wp_redirect( add_query_arg( '_error_nonce', wp_create_nonce( 'plugin-activation-error_' . $plugin ), $redirect ) ) ;
                    exit();
                } 
                else 
                {
                    wp_die( $result );
                }
            }

            wp_redirect( add_query_arg( array( 'plugin_status' => $context, 'activate' => 'true' ), self_admin_url( 'plugins.php' ) ) );
            exit();
            break;
        case 'custom_deactivate':
            if ( ! current_user_can( 'activate_plugins' ) )
                wp_die( __('You do not have sufficient permissions to deactivate plugins for this site.') );

            check_admin_referer('custom_deactivate-' . $plugin);
            cd_apd_deactivate_plugins( $plugin, $context );
            if ( headers_sent() )
                echo "<meta http-equiv='refresh' content='" . esc_attr( "0;url=plugins.php?deactivate=true&plugin_status=$status&paged=$page&s=$s" ) . "' />";
            else
                wp_redirect( self_admin_url("plugins.php?deactivate=true&plugin_status=$context") );
            exit();
            break;
        default:
            do_action( 'custom_plugin_dir_' . $action );
            break;
    }

}

A couple custom functions here again. cd_apd_activate_plugin (ripped off from activate_plugin) and cd_apd_deactivate_plugins (ripped off from deactivate_plugins). Both are the same as their respective "parent" functions without the hard coded directories.

function cd_apd_activate_plugin( $plugin, $context, $silent = false ) 
{
    $plugin = trim( $plugin );

    $redirect = add_query_arg( 'plugin_status', $context, admin_url( 'plugins.php' ) );
    $redirect = apply_filters( 'custom_plugin_redirect', $redirect );

    $current = get_option( 'active_plugins_' . $context, array() );

    $valid = cd_apd_validate_plugin( $plugin, $context );
    if ( is_wp_error( $valid ) )
        return $valid;

    if ( !in_array($plugin, $current) ) {
        if ( !empty($redirect) )
            wp_redirect(add_query_arg('_error_nonce', wp_create_nonce('plugin-activation-error_' . $plugin), $redirect)); // we'll override this later if the plugin can be included without fatal error
        ob_start();
        include_once( $valid );

        if ( ! $silent ) {
            do_action( 'custom_activate_plugin', $plugin, $context );
            do_action( 'custom_activate_' . $plugin, $context );
        }

        $current[] = $plugin;
        sort( $current );
        update_option( 'active_plugins_' . $context, $current );

        if ( ! $silent ) {
            do_action( 'custom_activated_plugin', $plugin, $context );
        }

        if ( ob_get_length() > 0 ) {
            $output = ob_get_clean();
            return new WP_Error('unexpected_output', __('The plugin generated unexpected output.'), $output);
        }
        ob_end_clean();
    }

    return true;
}

And the deactivation function

function cd_apd_deactivate_plugins( $plugins, $context, $silent = false ) {
    $current = get_option( 'active_plugins_' . $context, array() );

    foreach ( (array) $plugins as $plugin ) 
    {
        $plugin = trim( $plugin );
        if ( ! in_array( $plugin, $current ) ) continue;

        if ( ! $silent )
            do_action( 'custom_deactivate_plugin', $plugin, $context );

        $key = array_search( $plugin, $current );
        if ( false !== $key ) {
            array_splice( $current, $key, 1 );
        }

        if ( ! $silent ) {
            do_action( 'custom_deactivate_' . $plugin, $context );
            do_action( 'custom_deactivated_plugin', $plugin, $context );
        }
    }

    update_option( 'active_plugins_' . $context, $current );
}

There's also cd_apd_validate_plugin function, which off course, is a rip off of validate_plugin without the hard coded junk.

<?php
function cd_apd_validate_plugin( $plugin, $context ) 
{
    $rv = true;
    if ( validate_file( $plugin ) )
    {
        $rv = new WP_Error('plugin_invalid', __('Invalid plugin path.'));
    }

    global $wp_plugin_directories;
    if( ! isset( $wp_plugin_directories[$context] ) )
    {
        $rv = new WP_Error( 'invalid_context', __( 'The context for this plugin does not exist' ) );
    }

    $dir = $wp_plugin_directories[$context]['dir'];
    if( ! file_exists( $dir . '/' . $plugin) )
    {
        $rv = new WP_Error( 'plugin_not_found', __( 'Plugin file does not exist.' ) );
    }

    $installed_plugins = cd_apd_get_plugins( $context );
    if ( ! isset($installed_plugins[$plugin]) )
    {
        $rv = new WP_Error( 'no_plugin_header', __('The plugin does not have a valid header.') );
    }

    $rv = $dir . '/' . $plugin;
    return $rv;
}

Alright, with that out of the way. We can actually start talking about the list table display

Step 1: add our views to the list at the top of the table. This is done by filtering views_{$screen->id} inside our init function.

add_filter( 'views_' . $screen->id, array( &$this, 'views' ) );

Then the actual hooked function just loops through the $wp_plugin_directories. If one of the newly registered directories has plugins, we'll include it in the display.

function views( $views )
{
    global $wp_plugin_directories;

    // bail if we don't have any extra dirs
    if( empty( $wp_plugin_directories ) ) return $views;

    // Add our directories to the action links
    foreach( $wp_plugin_directories as $key => $info )
    {
        if( ! count( $this->plugins[$key] ) ) continue;
        $class = $this->get_plugin_status() == $key ? ' class="current" ' : '';
        $views[$key] = sprintf( 
            '<a href="%s"' . $class . '>%s <span class="count">(%d)</span></a>',
            add_query_arg( 'plugin_status', $key, 'plugins.php' ),
            esc_html( $info['label'] ),
            count( $this->plugins[$key] )
        );
    }
    return $views;
}

The first thing we need to do if we happen to be viewing a custom plugin directory page is filter the views again. We need to get rid of the inactive count because it's not going to be accurate. A consequence of there being no filters where we need them to be. Hook in again...

if( $this->get_plugin_status() )
{
    add_filter( 'views_' . $screen->id, array( &$this, 'views_again' ) );
}

And a quick unset...

function views_again( $views )
{
    if( isset( $views['inactive'] ) ) unset( $views['inactive'] );
    return $views;
}

Next up, let's get rid of the plugins you would have otherwise seen in the list table, and replace them with our custom plugins. Hook into all_plugins.

if( $this->get_plugin_status() )
{
    add_filter( 'views_' . $screen->id, array( &$this, 'views_again' ) );
    add_filter( 'all_plugins', array( &$this, 'filter_plugins' ) );
}

Since we already set up our plugins and data (see setup_plugins above) , the filter_plugins method just (1) saves the count on all the plugins for later, and (2) replaces the plugins in the list table.

function filter_plugins( $plugins )
{
    if( $key = $this->get_plugin_status() )
    {
        $this->all_count = count( $plugins );
        $plugins = $this->plugins[$key];
    }
    return $plugins;
}

And now we'll kill the bulk actions. These could easily be supported, I suppose?

if( $this->get_plugin_status() )
{
    add_filter( 'views_' . $screen->id, array( &$this, 'views_again' ) );
    add_filter( 'all_plugins', array( &$this, 'filter_plugins' ) );
    // TODO: support bulk actions
    add_filter( 'bulk_actions-' . $screen->id, '__return_empty_array' );
}

The default plugin actions links are not going to work for us. So instead, we need to set up our own (with the custom actions, etc). In the init function.

if( $this->get_plugin_status() )
{
    add_filter( 'views_' . $screen->id, array( &$this, 'views_again' ) );
    add_filter( 'all_plugins', array( &$this, 'filter_plugins' ) );
    // TODO: support bulk actions
    add_filter( 'bulk_actions-' . $screen->id, '__return_empty_array' );
    add_filter( 'plugin_action_links', array( &$this, 'action_links' ), 10, 2 );
}

The only things that get changed here are (1) we're changing the actions, (2) keeping the plugin status in, and (3) changing the nonce names a bit.

function action_links( $links, $plugin_file )
{
    $context = $this->get_plugin_status();

    // let's just start over
    $links = array();
    $links['activate'] = sprintf(
        '<a href="%s" title="Activate this plugin">%s</a>',
        wp_nonce_url( 'plugins.php?action=custom_activate&amp;plugin=' . $plugin_file . '&amp;plugin_status=' . esc_attr( $context ), 'custom_activate-' . $plugin_file ),
        __( 'Activate' )
    );

    $active = get_option( 'active_plugins_' . $context, array() );
    if( in_array( $plugin_file, $active ) )
    {
        $links['deactivate'] = sprintf(
            '<a href="%s" title="Deactivate this plugin" class="cd-apd-deactivate">%s</a>',
            wp_nonce_url( 'plugins.php?action=custom_deactivate&amp;plugin=' . $plugin_file . '&amp;plugin_status=' . esc_attr( $context ), 'custom_deactivate-' . $plugin_file ),
            __( 'Deactivate' )
        );
    }
    return $links;
}

And finally, we just need to enqueue some JavaScript to top it off. In the init function again (all together this time).

if( $this->get_plugin_status() )
{
    add_filter( 'views_' . $screen->id, array( &$this, 'views_again' ) );
    add_filter( 'all_plugins', array( &$this, 'filter_plugins' ) );
    // TODO: support bulk actions
    add_filter( 'bulk_actions-' . $screen->id, '__return_empty_array' );
    add_filter( 'plugin_action_links', array( &$this, 'action_links' ), 10, 2 );
    add_action( 'admin_enqueue_scripts', array( &$this, 'scripts' ) );
}

While enqueueing ourJS, we'll also use wp_localize_script to get the value of the total "all plugins" count.

function scripts()
{
    wp_enqueue_script(
        'cd-apd-js',
        CD_APD_URL . 'js/apd.js',
        array( 'jquery' ),
        null
    );
    wp_localize_script(
        'cd-apd-js',
        'cd_apd',
        array(
            'count' => esc_js( $this->all_count )
        )
    );
}

And of course, the JS is just some nice hacks to get the list table active/inactive plugins to display properly. We'll also stick the correct count of all plugins back into the All link.

jQuery(document).ready(function(){
    jQuery('li.all a').removeClass('current').find('span.count').html('(' + cd_apd.count + ')');
    jQuery('.wp-list-table.plugins tr').each(function(){
        var is_active = jQuery(this).find('a.cd-apd-deactivate');
        if(is_active.length) {
            jQuery(this).removeClass('inactive').addClass('active');
            jQuery(this).find('div.plugin-version-author-uri').removeClass('inactive').addClass('active');
        }
    });
});

Wrap Up

The actual loading of additional plugin directories is pretty unexciting. Getting the list table to display correctly is the more difficult part. I'm still not completely satisfied with how it turned out, but maybe someone can improve the code

7
  • 1
    Impressive! Really good work. I’ll take some time over the weekend to study your code. Note: There is a function __return_empty_array().
    – fuxia
    Commented Mar 9, 2012 at 7:05
  • Thanks! Feedback is always welcome. Incorporated the __return_empty_array function! Commented Mar 9, 2012 at 7:14
  • 1
    You should collect a list of all places where a simple core filter would have saved you a separate function. And then … submit a Trac ticket.
    – fuxia
    Commented Mar 9, 2012 at 7:18
  • This is really great. It would be even cooler if we could this as a library inside a Theme (see my comment on Github: github.com/chrisguitarguy/WP-Plugin-Directories/issues/4)
    – julien_c
    Commented Mar 28, 2012 at 10:30
  • 1
    +1 Can't believed I'd missed this answer - great work! I shall look over your code in more detail over the weekend :). @Julien_c - why would you use this inside a theme? Commented Apr 14, 2012 at 9:48
4

I don't personally have any interest in modifying the UI, but I'd love a more organized file system layout, for several reasons.

To that end, another approach would be to use symlinks.

wp-content
    |-- plugins
        |-- acme-widgets               -> ../plugins-custom/acme-widgets
        |-- acme-custom-post-types     -> ../plugins-custom/acme-custom-post-types
        |-- acme-business-logic        -> ../plugins-custom/acme-business-logic
        |-- google-authenticator       -> ../plugins-external/google-authenticator
        |-- rest-api                   -> ../plugins-external/rest-api
        |-- quick-navigation-interface -> ../plugins-external/quick-navigation-interface
    |-- plugins-custom
        |-- acme-widgets
        |-- acme-custom-post-types
        |-- acme-business-logic
    |-- plugins-external
        |-- google-authenticator
        |-- rest-api
        |-- quick-navigation-interface

You could setup your custom plugins in plugins-custom, which could be part of your project's version control repository.

Then you could install 3rd-party dependencies into plugins-external (via Composer, or Git submodules, or whatever you prefer).

Then you could have a simple Bash script or WP-CLI command that scans the additional directories, and creates a symlink in plugins for each subfolder it finds.

plugins would still be cluttered, but it wouldn't matter because you'd only need to interact with plugins-custom and plugins-external.

Scaling to n extra directories would follow the same process as the first two.

-3

Or you could also use COMPOSER with custom directory path setted to point to wp-content folder. If it is not a direct answer of your question is a new way of thinking wordpress, move on to composer before it eats you up.

1
  • Done moving to Composer a long time ago. Please look up the date of this question. Aside from that: This is not really a answer. Maybe show how to actually set this up?
    – kaiser
    Commented Nov 16, 2016 at 12:07

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.