Dodaj wiele katalogów wtyczek


39

Zadanie

Możesz się zarejestrować, dodając dodatkowe katalogi motywów register_theme_directory()do instalacji WP. Niestety rdzeń nie zapewnia takiej samej funkcjonalności wtyczek. Mamy już wtyczki MU, wtyczki, wtyczki i motywy. Ale potrzebujemy więcej dla lepszej organizacji plików.

Oto lista zadań do osiągnięcia:

  • Dodaj dodatkowy katalog wtyczek
  • Dla każdego katalogu wtyczek potrzebna jest nowa „karta”, jak pokazano tutaj [1]
  • Dodatkowy katalog miałby taką samą funkcjonalność jak domyślny katalog wtyczek

Co tam jest dla ciebie?

Najlepsza i najbardziej kompletna odpowiedź zostanie nagrodzona.


[1] Dodatkowa zakładka dla nowego folderu / katalogu wtyczek


3
Ponieważ struktura katalogów jest dość związana ze stałymi katalogami, mam wątpliwości, czy robienie tego na poziomie systemu plików jest praktyczne (bez rdzenia). Wirtualna warstwa organizacji w adminie może być łatwiejsza do osiągnięcia na poziomie rozszerzenia.
Rarst

@Rarst Co nie powinno Cię powstrzymywać od dodawania swoich myśli :)
kaiser

To byłaby świetna funkcja.
ltfishie

Funkcja brzmi dobrze. Wystarczy odwrócić rdzeń inżyniera, dowiedzieć się, jak to zrobić (sposób WP), a następnie przesłać łatę do deweloperów ... chciałbyś spojrzeć na register_theme_directory () - search_theme_directories () - get_raw_theme_root () - get_theme_roots () - get_theme () - get_themes ()
Sterling Hamilton

2
Faceci: co przesłać ? To pytanie, a nie odpowiedź z pełnym kodem :) FYI: Nowy bilet na trac do przepisaniaget_themes() do klasy.
Kaiser

Odpowiedzi:


28

Dobra, zrobię to dźgnięciem. Kilka ograniczeń, które napotkałem po drodze:

  1. W podklasach WP_List_Table nie ma wielu filtrów, a przynajmniej nie tam, gdzie ich potrzebujemy.

  2. Z powodu tego braku filtrów nie możemy tak naprawdę utrzymywać dokładnej listy typów wtyczek na górze.

  3. Musimy także użyć niesamowitych (czytaj: brudnych) hacków JavaScript, aby wyświetlić wtyczki jako aktywne.

Zawijam kod administratora w klasie, więc nazwy funkcji nie są poprzedzane. Możesz zobaczyć cały ten kod tutaj . Proszę przyczynić się!

Centralny interfejs API

Po prostu prosta funkcja, która ustawia zmienną globalną, która będzie zawierać nasze katalogi wtyczek w tablicy asocjacyjnej. $keyBędzie coś używanego wewnętrznie do pobierania wtyczek itd $dirjest albo pełna ścieżka lub coś w stosunku do wp-contentkatalogu. $labelbędzie przeznaczone do wyświetlania w obszarze administracyjnym (np. ciąg do przetłumaczenia).

<?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
    );
}

Następnie oczywiście musimy załadować wtyczki. Spóźnij się plugins_loadedi przejrzyj aktywne wtyczki, ładując każdą z nich.

Obszar administracyjny

Ustawmy naszą funkcjonalność w klasie.

<?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

Wkroczymy plugins_loadednaprawdę wcześnie i skonfigurujemy dozwolone „akcje”, których będziemy używać. Będą one obsługiwać aktywację i dezaktywację wtyczek, ponieważ wbudowane funkcje nie mogą tego zrobić z niestandardowymi katalogami.

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

Następnie jest podłączona funkcja load-plugins.php. Robi to wiele fajnych rzeczy.

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' ) );
    }
}

Przejrzyjmy to po kolei. get_pluginsmetodą jest owinięcie wokół innej funkcji. Wypełnia atrybut pluginsdanymi.

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_pluginsjest zgrywaniem wbudowanej get_pluginsfunkcji bez sztywnego WP_CONTENT_DIRi pluginsbiznesowego. Zasadniczo: pobierz katalog z $wp_plugin_directoriesglobalnego, otwórz go, znajdź wszystkie pliki wtyczek. Przechowuj je w pamięci podręcznej na później.

<?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;
}

Następną kwestią jest nieznośny proces aktywacji i dezaktywacji wtyczek. Aby to zrobić, używamy handle_actionsmetody. Jest to znowu rażąco zdzierane z góry głównego wp-admin/plugins.phppliku.

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;
    }

}

Ponownie kilka niestandardowych funkcji. cd_apd_activate_plugin(oderwany od activate_plugin) i cd_apd_deactivate_plugins(oderwany od deactivate_plugins). Oba są takie same, jak odpowiadające im funkcje „macierzyste” bez katalogów zakodowanych na stałe.

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;
}

I funkcja dezaktywacji

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 );
}

Istnieje również cd_apd_validate_pluginfunkcja, która oczywiście jest zrywaniem validate_pluginbez zakodowanych śmieci.

<?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;
}

W porządku, z tym na uboczu. Możemy zacząć mówić o wyświetlaniu tabeli list

Krok 1: dodaj nasze widoki do listy u góry tabeli. Odbywa się to poprzez filtrowanie views_{$screen->id}wewnątrz naszej initfunkcji.

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

Wtedy rzeczywista funkcja zaczepiona po prostu zapętla się przez $wp_plugin_directories. Jeśli jeden z nowo zarejestrowanych katalogów ma wtyczki, uwzględnimy go na wyświetlaczu.

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;
}

Pierwszą rzeczą, którą musimy zrobić, jeśli oglądamy niestandardową stronę katalogu wtyczek, jest ponowne filtrowanie widoków. Musimy pozbyć się inactiveliczby, ponieważ nie będzie dokładna. Konsekwencją braku filtrów tam, gdzie są potrzebne. Zaczep ponownie ...

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

I szybkie rozbrojenie ...

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

Następnie pozbądźmy się wtyczek, które w innym przypadku byłyby widoczne w tabeli list, i zastąpmy je naszymi niestandardowymi wtyczkami. Zaczep się all_plugins.

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

Ponieważ już skonfigurowaliśmy nasze wtyczki i dane (patrz setup_pluginswyżej), filter_pluginsmetoda just (1) zapisuje liczbę wszystkich wtyczek na później i (2) zastępuje wtyczki w tabeli list.

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

A teraz zabijemy akcje masowe. Sądzę, że można je z łatwością wesprzeć?

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' );
}

Domyślne linki akcji wtyczek nie będą dla nas działać. Zamiast tego musimy skonfigurować własne (z niestandardowymi akcjami itp.). W initfunkcji.

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 );
}

Jedyne, co się tutaj zmienia, to (1) zmieniamy akcje, (2) utrzymujemy status wtyczki i (3) zmieniamy nieco nazwy nonce.

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;
}

I na koniec, musimy tylko dodać trochę kodu JavaScript, aby go uzupełnić. W initfunkcji ponownie (tym razem wszystko razem).

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' ) );
}

Podczas kolejkowania naszego JS użyjemy również wp_localize_scriptdo uzyskania wartości całkowitej liczby „wszystkich wtyczek”.

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 )
        )
    );
}

I oczywiście JS to tylko kilka fajnych hacków, aby poprawnie włączyć / wyłączyć nieaktywne wtyczki tabeli listy. Wkleimy również prawidłową liczbę wszystkich wtyczek z powrotem do Alllinku.

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');
        }
    });
});

Zakończyć

Rzeczywiste ładowanie dodatkowych katalogów wtyczek jest dość nieciekawe. Utrudnione jest uzyskanie prawidłowego wyświetlania tabeli list. Nadal nie jestem w pełni usatysfakcjonowany tym, jak się okazało, ale może ktoś może poprawić kod


1
Imponujący! Naprawdę dobra robota. Poświęcę weekend na zapoznanie się z twoim kodem. Uwaga: Istnieje funkcja __return_empty_array().
fuxia

Dzięki! Informacje zwrotne są zawsze mile widziane. Zawiera __return_empty_arrayfunkcję!
chrisguitarguy

1
Powinieneś zebrać listę wszystkich miejsc, w których prosty filtr rdzeniowy zapisałby ci osobną funkcję. A potem… prześlij bilet Trac.
fuxia

To jest naprawdę świetne. Byłoby jeszcze fajniej, gdybyśmy mogli to zrobić jako bibliotekę wewnątrz motywu (patrz mój komentarz na Github: github.com/chrisguitarguy/WP-Plugin-Directories/issues/4 )
julien_c

1
+1 Nie mogę uwierzyć, że przegapiłem tę odpowiedź - świetna robota! Przyjrzę się Twojemu kodowi bardziej szczegółowo w weekend :). @Julien_c - dlaczego miałbyś używać tego w motywie?
Stephen Harris

2

Osobiście nie jestem zainteresowany modyfikowaniem interfejsu użytkownika, ale z kilku powodów chciałbym bardziej zorganizowany układ systemu plików.

W tym celu innym podejściem byłoby użycie dowiązań symbolicznych.

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

Możesz skonfigurować niestandardowe wtyczki plugins-custom, które mogą być częścią repozytorium kontroli wersji projektu.

Następnie możesz zainstalować zależności innych firm w plugins-external(za pomocą podmodułów Composer, Git lub cokolwiek innego).

Następnie możesz mieć prosty skrypt Bash lub komendę WP-CLI, która skanuje dodatkowe katalogi i tworzy dowiązanie symboliczne pluginsdla każdego znalezionego podfolderu.

pluginsnadal byłby zagracony, ale nie miałoby to znaczenia, ponieważ wystarczyłaby interakcja z plugins-customi plugins-external.

Skalowanie do ndodatkowych katalogów odbywałoby się w taki sam sposób jak dwa pierwsze.


-3

Możesz także użyć COMPOSER z niestandardową ścieżką do katalogu ustawioną tak, aby wskazywała folder wp-content. Jeśli nie jest to bezpośrednia odpowiedź na twoje pytanie, to nowy sposób myślenia w wordpress, przejdź do kompozytora, zanim cię pochłonie.


Dawno temu zakończyłeś przeprowadzkę do kompozytora. Sprawdź datę tego pytania. Poza tym: To nie jest tak naprawdę odpowiedź. Może pokaż, jak to właściwie skonfigurować?
kaiser
Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.