User:WillowW/Footnote.php

From Wikipedia, the free encyclopedia

<?php
if ( ! defined( 'MEDIAWIKI' ) )
        die();
/**#@+
 * A parser extension that adds two tags, <note> and <notes/> for adding
 * explanatory footnotes (NB! *not* citations to references) to pages
 *
 * @addtogroup Extensions
 *
 * @link http://meta.wikimedia.org/wiki/Footnote/Footnote.php Documentation, based on http://meta.wikimedia.org/wiki/Cite/Cite.php Documentation
 * @link http://www.w3.org/TR/html4/struct/text.html#edef-CITE <cite> definition in HTML
 * @link http://www.w3.org/TR/2005/WD-xhtml2-20050527/mod-text.html#edef_text_cite <cite> definition in XHTML 2.0
 *
 *
 *
 * @author WillowW <theknittingcat@yahoo.com>, based on Cite.php by Ævar Arnfjörð Bjarmason <avarab@gmail.com>
 * @copyright Copyright © 2005, 2008 Ævar Arnfjörð Bjarmason, WillowW
 * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later
 */

$wgExtensionFunctions[] = 'wfFootnote';
$wgExtensionCredits['parserhook'][] = array(
        'name' => 'Footnote',
        'version' => preg_replace('/^.* (\d\d\d\d-\d\d-\d\d) .*$/', '\1', '$LastChangedDate$'), #just the date of the last change
        'author' => 'WillowW, based on Ævar Arnfjörð Bjarmason's work; thanks, big Æ!',
        'description' => 'Adds <note[ name=id]> and <notes/> tags, for explanatory footnotes', // kept for b/c
        'descriptionmsg' => 'footnote_desc',
        'url' => 'http://www.mediawiki.org/wiki/Extension:Footnote/Footnote.php'
);
$wgParserTestFiles[] = dirname( __FILE__ ) . "/footnoteParserTests.txt";
$wgExtensionMessagesFiles['Footnote'] = dirname( __FILE__ ) . "/Footnote.i18n.php";

function wfFootnote() {
        class Footnote {
                /**#@+
                 * @access private
                 */
                
                /**
                 * Datastructure representing <note> input, in the format of:
                 * <code>
                 * array(
                 *      'user supplied' => array(
                 *              'text' => 'user supplied footnote & key',
                 *              'count' => 1, // occurs twice
                 *              'number' => 1, // The first footnote, we want
                 *                             // all occurrences of it to
                 *                             // use the same number
                 *      ),
                 *      0 => 'Anonymous footnote',
                 *      1 => 'Another anonymous footnote',
                 *      'some key' => array(
                 *              'text' => 'this one occurs once'
                 *              'count' => 0,
                 *              'number' => 4
                 *      ),
                 *      3 => 'more stuff'
                 * );
                 * </code>
                 *
                 * This works because:
                 * * PHP's datastructures are guaranteed to be returned in the
                 *   order that things are inserted into them (unless you mess
                 *   with that)
                 * * User supplied keys can't be integers, therefore avoiding
                 *   conflict with anonymous keys
                 *
                 * @var array
                 **/
                var $mFootnotes = array();
                
                /**
                 * Count for user displayed output (note[1], note[2], ...)
                 *
                 * @var int
                 */
                var $mOutCnt = 0;

                /**
                 * Internal counter for anonymous footnotes, separate from
                 * $mOutCnt because anonymous footnotes won't increment it,
                 * but will incremement $mOutCnt
                 *
                 * @var int
                 */
                var $mInCnt = 0;

                /**
                 * The backlinks, in order, to pass as $3 to
                 * 'footnote_references_link_many_format', defined in
                 * 'footnote_references_link_many_format_backlink_labels
                 *
                 * @var array
                 */
                var $mBacklinkLabels;
                
                /**
                 * @var object
                 */
                var $mParser;
                
                /**
                 * True when a <note> or <notes> tag is being processed.
                 * Used to avoid infinite recursion
                 * 
                 * @var boolean
                 */
                var $mInFootnote = false;
                
                /**#@-*/

                /**
                 * Constructor
                 */
                function Footnote() {
                        $this->setHooks();
                }

                /**#@+ @access private */

                /**
                 * Callback function for <note>
                 *
                 * @param string $str Input
                 * @param array $argv Arguments
                 * @return string
                 */
                function note( $str, $argv, $parser ) {
                        wfLoadExtensionMessages( 'Footnote' );
                        if ( $this->mInFootnote ) {
                                return htmlspecialchars( "<note>$str</note>" );
                        } else {
                                $this->mInFootnote = true;
                                $ret = $this->guardedNote( $str, $argv, $parser );
                                $this->mInFootnote = false;
                                return $ret;
                        }
                }
                
                function guardedNote( $str, $argv, $parser ) {
                        $this->mParser = $parser;
                        
                        # The key here is the "name" attribute.
                        $key = $this->noteArg( $argv );
                        
                        if( $str === '' ) {
                                # <note ...></note>.  This construct is always invalid: either
                                # it's a contentful note, or it's a named duplicate and should
                                # be <note ... />.
                                return $this->error( 'footnote_error_ref_no_input' );
                        }
                                        
                        if( $key === false ) {
                                # TODO: Comment this case; what does this condition mean?
                                return $this->error( 'footnote_error_ref_too_many_keys' );
                        }

                        if( $str === null and $key === null ) {
                                # Something like <note />; this makes no sense.
                                return $this->error( 'footnote_error_ref_no_key' );
                        }
                        
                        if( preg_match( '/^[0-9]+$/', $key ) ) {
                                # Numeric names mess up the resulting id's, potentially produ-
                                # cing duplicate id's in the XHTML.  The Right Thing To Do
                                # would be to mangle them, but it's not really high-priority
                                # (and would produce weird id's anyway).
                                return $this->error( 'footnote_error_ref_numeric_key' );
                        }
                        
                        if( is_string( $key ) or is_string( $str ) ) {
                                # We don't care about the content: if the key exists, the note
                                # is presumptively valid.  Either it stores a new note, or re-
                                # fers to an existing one.  If it refers to a nonexistent note,
                                # we'll figure that out later.  Likewise it's definitely valid
                                # if there's any content, regardless of key.
                                return $this->stack( $str, $key );
                        }

                        # Not clear how we could get here, but something is probably
                        # wrong with the types.  Let's fail fast.
                        $this->croak( 'footnote_error_key_str_invalid', serialize( "$str; $key" ) );
                }

                /**
                 * Parse the arguments to the <note> tag
                 *
                 * @static
                 *
                 * @param array $argv The argument vector
                 * @return mixed false on invalid input, a string on valid
                 *               input and null on no input
                 */
                function noteArg( $argv ) {
                        $cnt = count( $argv );
                        
                        if ( $cnt > 1 )
                                // There should only be one key
                                return false;
                        else if ( $cnt == 1 )
                                if ( isset( $argv['name'] ) )
                                        // Key given.
                                        return $this->validateName( array_shift( $argv ) );
                                else
                                        // Invalid key
                                        return false;
                        else
                                // No key
                                return null;
                }
                
                /**
                 * Since the key name is used in an XHTML id attribute, it must
                 * conform to the validity rules. The restriction to begin with
                 * a letter is lifted since footnotes have their own prefix.
                 *
                 * @fixme merge this code with the various section name transformations
                 * @fixme double-check for complete validity
                 * @return string if valid, false if invalid
                 */
                function validateName( $name ) {
                        if( preg_match( '/^[A-Za-z0-9:_.-]*$/i', $name ) ) {
                                return $name;
                        } else {
                                // WARNING: CRAPPY CUT AND PASTE MAKES BABY JESUS CRY
                                $text = urlencode( str_replace( ' ', '_', $name ) );
                                $replacearray = array(
                                        '%3A' => ':',
                                        '%' => '.'
                                );
                                return str_replace(
                                        array_keys( $replacearray ),
                                        array_values( $replacearray ),
                                        $text );
                        }
                }

                /**
                 * Populate $this->mFootnotes based on input and arguments to <note>
                 *
                 * @param string $str Input from the <note> tag
                 * @param mixed $key Argument to the <note> tag as returned by $this->noteArg()
                 * @return string 
                 */
                function stack( $str, $key = null ) {
                        if ( $key === null ) {
                                // No key
                                $this->mFootnotes[] = $str;
                                return $this->linkNote( $this->mInCnt++ );
                        } else if ( is_string( $key ) )
                                // Valid key
                                if ( ! isset( $this->mFootnotes[$key] ) || ! is_array( $this->mFootnotes[$key] ) ) {
                                        // First occurrence
                                        $this->mFootnotes[$key] = array(
                                                'text' => $str,
                                                'count' => 0,
                                                'number' => ++$this->mOutCnt
                                        );
                                        return
                                                $this->linkNote(
                                                        $key,
                                                        $this->mFootnotes[$key]['count'],
                                                        $this->mFootnotes[$key]['number']
                                                );
                                } else {
                                        // We've been here before
                                        if ( $this->mFootnotes[$key]['text'] === null && $str !== '' ) {
                                                // If no text found before, use this text
                                                $this->mFootnotes[$key]['text'] = $str;
                                        };
                                        return 
                                                $this->linkNote(
                                                        $key,
                                                        ++$this->mFootnotes[$key]['count'],
                                                        $this->mFootnotes[$key]['number']
                                                ); }
                        else
                                $this->croak( 'footnote_error_stack_invalid_input', serialize( array( $key, $str ) ) );
                }
                
                /**
                 * Callback function for <notes>
                 *
                 * @param string $str Input
                 * @param array $argv Arguments
                 * @return string
                 */
                function footnotes( $str, $argv, $parser ) {
                        wfLoadExtensionMessages( 'Footnote' );
                        if ( $this->mInFootnote ) {
                                if ( is_null( $str ) ) {
                                        return htmlspecialchars( "<notes/>" );
                                } else {
                                        return htmlspecialchars( "<notes>$str</notes>" );
                                }
                        } else {
                                $this->mInFootnote = true;
                                $ret = $this->guardedFootnotes( $str, $argv, $parser );
                                $this->mInFootnote = false;
                                return $ret;
                        }
                }
                
                function guardedFootnotes( $str, $argv, $parser ) {
                        $this->mParser = $parser;
                        if ( $str !== null )
                                return $this->error( 'footnote_error_references_invalid_input' );
                        else if ( count( $argv ) )
                                return $this->error( 'footnote_error_references_invalid_parameters' );
                        else
                                return $this->footnotesFormat();
                }

                /**
                 * Make output to be returned from the footnotes() function
                 *
                 * @return string XHTML ready for output
                 */
                function footnotesFormat() {
                        if ( count( $this->mFootnotes ) == 0 )
                                return '';
                        
                        wfProfileIn( __METHOD__ );
                        wfProfileIn( __METHOD__ .'-entries' );
                        $ent = array();
                        foreach ( $this->mFootnotes as $k => $v )
                                $ent[] = $this->footnotesFormatEntry( $k, $v );
                        
                        $prefix = wfMsgForContentNoTrans( 'footnote_references_prefix' );
                        $suffix = wfMsgForContentNoTrans( 'footnote_references_suffix' );
                        $content = implode( "\n", $ent );
                        
                        wfProfileOut( __METHOD__ .'-entries' );
                        wfProfileIn( __METHOD__ .'-parse' );
                        // Live hack: parse() adds two newlines on WM, can't reproduce it locally -ævar
                        $ret = rtrim( $this->parse( $prefix . $content . $suffix ), "\n" );
                        wfProfileOut( __METHOD__ .'-parse' );
                        wfProfileOut( __METHOD__ );
                        
                        return $ret;
                }

                /**
                 * Format a single entry for the footnotesFormat() function
                 *
                 * @param string $key The key of the note
                 * @param mixed $val The value of the note, string for anonymous
                 *                   notes, array for user-supplied
                 * @return string Wikitext
                 */
                function footnotesFormatEntry( $key, $val ) {
                        // Anonymous note
                        if ( ! is_array( $val ) )
                                return
                                        wfMsgForContentNoTrans(
                                                'footnote_references_link_one',
                                                $this->footnotesKey( $key ),
                                                $this->noteKey( $key ),
                                                $val
                                        );
                        else if ($val['text']=='') return
                                        wfMsgForContentNoTrans(
                                                'footnote_references_link_one',
                                                $this->footnotesKey( $key ),
                                                $this->noteKey( $key, $val['count'] ),
                                                $this->error( 'footnote_error_references_no_text', $key )
                                        );
                        // Standalone named note, I want to format this like an
                        // anonymous note because displaying "1. 1.1 Ref text" is
                        // overkill and users frequently use named footnotes when they
                        // don't need them for convenience
                        else if ( $val['count'] === 0 )
                                return
                                        wfMsgForContentNoTrans(
                                                'footnote_references_link_one',
                                                $this->footnotesKey( $key ),
                                                $this->noteKey( $key, $val['count'] ),
                                                ( $val['text'] != '' ? $val['text'] : $this->error( 'footnote_error_references_no_text', $key ) )
                                        );
                        // Named footnotes with >1 occurrences
                        else {
                                $links = array();

                                for ( $i = 0; $i <= $val['count']; ++$i ) {
                                        $links[] = wfMsgForContentNoTrans(
                                                        'footnote_references_link_many_format',
                                                        $this->noteKey( $key, $i ),
                                                        $this->footnotesFormatEntryNumericBacklinkLabel( $val['number'], $i, $val['count'] ),
                                                        $this->footnotesFormatEntryAlternateBacklinkLabel( $i )
                                        );
                                }

                                $list = $this->listToText( $links );

                                return
                                        wfMsgForContentNoTrans( 'footnote_references_link_many',
                                                $this->footnotesKey( $key ),
                                                $list,
                                                ( $val['text'] != '' ? $val['text'] : $this->error( 'footnote_error_references_no_text', $key ) )
                                        );
                        }
                }

                /**
                 * Generate a numeric backlink given a base number and an
                 * offset, e.g. $base = 1, $offset = 2; = 1.2
                 * Since bug #5525, it correctly does 1.9 -> 1.10 as well as 1.099 -> 1.100
                 *
                 * @static
                 *
                 * @param int $base The base
                 * @param int $offset The offset
                 * @param int $max Maximum value expected.
                 * @return string
                 */
                function footnotesFormatEntryNumericBacklinkLabel( $base, $offset, $max ) {
                        global $wgContLang;
                        $scope = strlen( $max );
                        $ret = $wgContLang->formatNum( $offset );
                        return $ret;
                }

                /**
                 * Generate a custom format backlink given an offset, e.g.
                 * $offset = 2; = c if $this->mBacklinkLabels = array( 'a',
                 * 'b', 'c', ...). Return an error if the offset > the # of
                 * array items
                 *
                 * @param int $offset The offset
                 *
                 * @return string
                 */
                function footnotesFormatEntryAlternateBacklinkLabel( $offset ) {
                        if ( !isset( $this->mBacklinkLabels ) ) {
                                $this->genBacklinkLabels();
                        }
                        if ( isset( $this->mBacklinkLabels[$offset] ) ) {
                                return $this->mBacklinkLabels[$offset];
                        } else {
                                // Feed me!
                                return $this->error( 'footnote_error_references_no_backlink_label' );
                        }
                }

                /**
                 * Return an id for use in wikitext output based on a key and
                 * optionally the # of it, used in <notes>, not <note>
                 * (since otherwise it would link to itself)
                 *
                 * @static
                 *
                 * @param string $key The key
                 * @param int $num The number of the key
                 * @return string A key for use in wikitext
                 */
                function noteKey( $key, $num = null ) {
                        $prefix = wfMsgForContent( 'footnote_reference_link_prefix' );
                        $suffix = wfMsgForContent( 'footnote_reference_link_suffix' );
                        if ( isset( $num ) )
                                $key = wfMsgForContentNoTrans( 'footnote_reference_link_key_with_num', $key, $num );
                        
                        return $prefix . $key . $suffix;
                }

                /**
                 * Return an id for use in wikitext output based on a key and
                 * optionally the # of it, used in <note>, not <notes>
                 * (since otherwise it would link to itself)
                 *
                 * @static
                 *
                 * @param string $key The key
                 * @param int $num The number of the key
                 * @return string A key for use in wikitext
                 */
                function footnotesKey( $key, $num = null ) {
                        $prefix = wfMsgForContent( 'footnote_references_link_prefix' );
                        $suffix = wfMsgForContent( 'footnote_references_link_suffix' );
                        if ( isset( $num ) )
                                $key = wfMsgForContentNoTrans( 'footnote_reference_link_key_with_num', $key, $num );
                        
                        return $prefix . $key . $suffix;
                }

                /**
                 * Generate a link (<sup ...) for the <note> element from a key
                 * and return XHTML ready for output
                 *
                 * @param string $key The key for the link
                 * @param int $count The # of the key, used for distinguishing
                 *                   multiple occurrences of the same key
                 * @param int $label The label to use for the link, I want to
                 *                   use the same label for all occurrences of
                 *                   the same named reference.
                 * @return string
                 */
                function linkNote( $key, $count = null, $label = null ) {
                        global $wgContLang;

                        return
                                $this->parse(
                                        wfMsgForContentNoTrans(
                                                'footnote_reference_link',
                                                $this->noteKey( $key, $count ),
                                                $this->footnotesKey( $key ),
                                                $wgContLang->formatNum( is_null( $label ) ? $this->footnotesFormatEntryAlternateBacklinkLabel( ++$this->mOutCnt ) : $label )
                                        )
                                );
                }

                /**
                 * This does approximately the same thing as
                 * Language::listToText() but due to this being used for a
                 * slightly different purpose (people might not want , as the
                 * first separator and not 'and' as the second, and this has to
                 * use messages from the content language) I'm rolling my own.
                 *
                 * @static
                 *
                 * @param array $arr The array to format
                 * @return string
                 */
                function listToText( $arr ) {
                        $cnt = count( $arr );

                        $sep = wfMsgForContentNoTrans( 'footnote_references_link_many_sep' );
                        $and = wfMsgForContentNoTrans( 'footnote_references_link_many_and' );

                        if ( $cnt == 1 )
                                // Enforce always returning a string
                                return (string)$arr[0];
                        else {
                                $t = array_slice( $arr, 0, $cnt - 1 );
                                return implode( $sep, $t ) . $and . $arr[$cnt - 1];
                        }
                }

                /**
                 * Parse a given fragment and fix up Tidy's trail of blood on
                 * it...
                 *
                 * @param string $in The text to parse
                 * @return string The parsed text
                 */
                function parse( $in ) {
                        if ( method_exists( $this->mParser, 'recursiveTagParse' ) ) {
                                // New fast method
                                return $this->mParser->recursiveTagParse( $in );
                        } else {
                                // Old method
                                $ret = $this->mParser->parse(
                                        $in,
                                        $this->mParser->mTitle,
                                        $this->mParser->mOptions,
                                        // Avoid whitespace buildup
                                        false,
                                        // Important, otherwise $this->clearState()
                                        // would get run every time <note> or
                                        // <notes> is called, fucking the whole
                                        // thing up.
                                        false
                                );
                                $text = $ret->getText();
                                
                                return $this->fixTidy( $text );
                        }
                }

                /**
                 * Tidy treats all input as a block, it will e.g. wrap most
                 * input in <p> if it isn't already, fix that and return the fixed text
                 *
                 * @static
                 *
                 * @param string $text The text to fix
                 * @return string The fixed text
                 */
                function fixTidy( $text ) {
                        global $wgUseTidy;

                        if ( ! $wgUseTidy )
                                return $text;
                        else {
                                $text = preg_replace( '~^<p>\s*~', '', $text );
                                $text = preg_replace( '~\s*</p>\s*~', '', $text );
                                $text = preg_replace( '~\n$~', '', $text );
                                
                                return $text;
                        }
                }

                /**
                 * Generate the labels to pass to the
                 * 'footnote_references_link_many_format' message, the format is an
                 * arbitary number of tokens separated by [\t\n ]
                 */
                function genBacklinkLabels() {
                        wfProfileIn( __METHOD__ );
                        $text = wfMsgForContentNoTrans( 'footnote_references_link_many_format_backlink_labels' );
                        $this->mBacklinkLabels = preg_split( '#[\n\t ]#', $text );
                        wfProfileOut( __METHOD__ );
                }

                /**
                 * Gets run when Parser::clearState() gets run, since we don't
                 * want the counts to transcend pages and other instances
                 */
                function clearState() {
                        $this->mOutCnt = $this->mInCnt = 0;
                        $this->mFootnotes = array();

                        return true;
                }

                /**
                 * Initialize the parser hooks
                 */
                function setHooks() {
                        global $wgParser, $wgHooks;
                        
                        $wgParser->setHook( 'note' , array( &$this, 'note' ) );
                        $wgParser->setHook( 'notes' , array( &$this, 'footnotes' ) );

                        $wgHooks['ParserClearState'][] = array( &$this, 'clearState' );
                }

                /**
                 * Return an error message based on an error ID
                 *
                 * @param string $key   Message name for the error
                 * @param string $param Parameter to pass to the message
                 * @return string XHTML ready for output
                 */
                function error( $key, $param=null ) {
                        # We rely on the fact that PHP is okay with passing unused argu-
                        # ments to functions.  If $1 is not used in the message, wfMsg will
                        # just ignore the extra parameter.
                        return 
                                $this->parse(
                                        '<strong class="error">' .
                                        wfMsg( 'footnote_error', wfMsg( $key, $param ) ) .
                                        '</strong>'
                                );
                }

                /**
                 * Die with a backtrace if something happens in the code which
                 * shouldn't have
                 *
                 * @param int $error  ID for the error
                 * @param string $data Serialized error data
                 */
                function croak( $error, $data ) {
                        wfDebugDieBacktrace( wfMsgForContent( 'footnote_croak', $this->error( $error ), $data ) );
                }

                /**#@-*/
        }

        new Footnote;
}

/**#@-*/