<?php

/**
 * @file
 * The Footnotes module is a filter that can be used to insert
 * automatically numbered footnotes into Drupal texts.
 */

/**
 * Implementation of hook_help().
 *
 * Throughout Drupal, hook_help() is used to display help text at the top of
 * pages. Some other parts of drupal pages get explanatory text from these hooks
 * as well. We use it here to provide a description of the module on the
 * module administration page.
 */
function footnotes_help($section) {
  switch ($section) {
    case 'admin/modules#description':
      // This description is shown in the listing at admin/modules.
      return t('Insert automatically numbered footnotes using &lt;fn&gt; or [fn] tags.');
  }
}

/**
 * Implementation of hook_filter_info().
 */
function footnotes_filter_info() {
  $filters['filter_footnotes'] = array(
    'title' => t('Footnotes'),
    'description' => t('Insert automatically numbered footnotes using &lt;fn&gt; or [fn] tags.'),
    'process callback' => '_footnotes_filter',
    'settings callback' => '_footnotes_settings',
    'default settings' => array(
      'footnotes_collapse' => 0,
    ), 
    'tips callback' => '_footnotes_filter_tips',
    'weight' => -20,
  );
  return $filters;
}

/**
 * Short tips are provided on the content editing screen, while
 * long tips are provided on a separate linked page. Short tips are optional,
 * but long tips are highly recommended.
 */
function _footnotes_filter_tips($filter, $format, $long = FALSE) {
    if ($long) {
      return t('You can insert footnotes directly into texts with <code>[fn]This text becomes a footnote.[/fn]</code>. This will be replaced with a running number (the footnote reference) and the text within the [fn] tags will be moved to the bottom of the page (the footnote). See %link for additional usage options.', array('%link'=>'<a href="http://drupal.org/project/footnotes">' . t('Footnotes Readme page') . '</a>'));
    }
    else {
      return t('Use [fn]...[/fn] (or &lt;fn&gt;...&lt;/fn&gt;) to insert automatically numbered footnotes.');
    }
}


/**
 * Options for the Footnotes filter.
 *
 * This has currently 1 setting, the feature to collapse together footnotes
 * with identical content is an option.
 */
function _footnotes_settings($form, &$form_state, $filter, $format, $defaults, $filters) {
  
  $settings['footnotes_collapse'] = array(
      '#type' => 'checkbox',
      '#title' => t('Collapse footnotes with identical content'),
      '#default_value' => isset($filter->settings['footnotes_collapse']) ? $filter->settings['footnotes_collapse'] : $defaults['footnotes_collapse'],
      '#description' => t('If two footnotes have the exact same content, they will be collapsed into one as if using the same value="" attribute.')
    );
    return $settings;

}

/**
 * The bulk of filtering work is done here.
 */
function _footnotes_filter($text = '', $filter, $format) {
  
  // Supporting both [fn] and <fn> now. Thanks to fletchgqc http://drupal.org/node/268026
  // Convert all square brackets to angle brackets. This way all further code just
  // manipulates angle brackets. (Angle brackets are preferred here for the simple reason
  // that square brackets are more tedious to use in regexps.)
  $text = preg_replace( '|\[fn([^\]]*)\]|', '<fn$1>', $text);
  $text = preg_replace( '|\[/fn\]|', '</fn>', $text);
  $text = preg_replace( '|\[footnotes([^\]]*)\]|', '<footnotes$1>', $text);


  // Check that there are an even number of open and closing tags.
  // If there is one closing tag missing, append this to the end.
  // If there is more disparity, throw a warning and continue.
  // A closing tag may sometimes be missing when we are processing a teaser
  // and it has been cut in the middle of the footnote. 
  // See http://drupal.org/node/253326
  $foo = array();
  $open_tags = preg_match_all("|<fn([^>]*)>|", $text, $foo);
  $close_tags = preg_match_all("|</fn>|", $text, $foo);

  if ($open_tags == $close_tags + 1) {
    $text = $text . '</fn>';
  }
  elseif ($open_tags > $close_tags + 1) {
    trigger_error(t("You have unclosed fn tags. This is invalid and will produce unpredictable results."));
  }

  // Before doing the replacement, the callback function needs to know which options to use.
  _footnotes_replace_callback($filter->settings['footnotes_collapse'], 'prepare');

  $pattern = '|<fn([^>]*)>(.*?)</fn>|s';
  $text = preg_replace_callback($pattern , '_footnotes_replace_callback', $text);

  // Replace tag <footnotes> with the list of footnotes.
  // If tag is not present, by default add the footnotes at the end.
  // Thanks to acp on drupal.org for this idea. see http://drupal.org/node/87226 
  $footer = '';
  $footer = _footnotes_replace_callback(NULL, 'output footer');
  $pattern = '|(<footnotes([^\]]*)>)|';
  if (preg_match($pattern, $text) > 0) {
    $text = preg_replace($pattern, $footer, $text, 1);
    return $text;
  }
  else {
    return $text . "\n\n" . $footer;
   }
}


/**
 * Search the $store_matches array for footnote text that matches and return the value.
 *
 * Note: This does a linear search on the $store_matches array. For a large list of 
 * footnotes it would be more efficient to maintain a separate array with the footnote
 * content as key, in order to do a hash lookup at this stage. Since you typically
 * only have a handful of footnotes, this simple search is assumed to be more efficient.
 * (but was not tested).
 *
 * @author djdevin (see http://drupal.org/node/808214)
 * 
 * @param string The footnote text
 * @param array The matches array
 *
 * @return mixed The value of the existing footnote, FALSE otherwise
 */
function _footnotes_helper_find_footnote($text, &$store_matches) {
  if (!empty($store_matches)) {
    foreach ($store_matches as &$fn) {
      if ($fn['text'] == $text) {
        return $fn['value'];
      }
    }
  }  
  return FALSE;
}
 

/**
 * Helper function called from preg_replace_callback() above
 *
 * Uses static vars to temporarily store footnotes found.
 * This is not threadsafe, but PHP isn't.
 */
function _footnotes_replace_callback( $matches, $op = '' ) {
  static $opt_collapse = 0;
  static $n = 0;
  static $store_matches = array();
  static $used_values = array();
  $str = '';

  if ($op == 'prepare') {
    // In the 'prepare' case, the first argument contains the options to use.
    // The name 'matches' is incorrect, we just use the variable anyway.
    $opt_collapse = $matches;    
    return 0;
  }

  if( $op == 'output footer' ) {
    if (count($store_matches) > 0) {
      // Only if there are stored fn matches, pass the array of fns to be themed
      // as a list
      // Drupal 7 requires we use "render element" which just introduces a wrapper
      // around the old array.
      $str = theme('footnote_list',array('footnotes' => $store_matches));
    }
    // Reset the static variables so they can be used again next time
    $n = 0;
    $store_matches = array();
    $used_values = array();

    return $str;
  }


  // Default op: act as called by preg_replace_callback()
  // Random string used to ensure footnote id's are unique, even
  // when contents of multiple nodes reside on same page. (fixes http://drupal.org/node/194558)
  $randstr = _footnotes_helper_randstr();

  $value = '';
  // Did the pattern match anything in the <fn> tag?
  if ($matches[1]) {
    // See if value attribute can parsed, either well-formed in quotes eg <fn value="3">
    if (preg_match('|value=["\'](.*?)["\']|',$matches[1],$value_match)) {
      $value = $value_match[1];
    // Or without quotes eg <fn value=8>
    } elseif (preg_match('|value=(\S*)|',$matches[1],$value_match)) {
      $value = $value_match[1];
    }
  }

  if ($value) {
    // A value label was found. If it is numeric, record it in $n so further notes
    // can increment from there.
    // After adding support for multiple references to same footnote in the body (http://drupal.org/node/636808)
    // also must check that $n is monotonously increasing
    if ( is_numeric($value) && $n < $value ) {
      $n = $value;
    }
  } elseif ($opt_collapse and $value_existing = _footnotes_helper_find_footnote($matches[2],$store_matches)) {
    // An identical footnote already exists. Set value to the previously existing value.
    $value = $value_existing;
  } else {
    // No value label, either a plain <fn> or unparsable attributes. Increment the
    // footnote counter, set label equal to it.
    $n++;
    $value = $n;
  }

  // Remove illegal characters from $value so it can be used as an HTML id attribute.
  $value_id = preg_replace('|[^\w\-]|', '', $value);

  // Create a sanitized version of $text that is suitable for using as HTML attribute
  // value. (In particular, as the title attribute to the footnote link.)
  $allowed_tags = array();
  $text_clean = filter_xss($matches['2'], $allowed_tags);
  // HTML attribute cannot contain quotes
  $text_clean = str_replace('"', "&quot;", $text_clean);
  // Remove newlines. Browsers don't support them anyway and they'll confuse line break converter in filter.module
  $text_clean = str_replace("\n", " ", $text_clean);
  $text_clean = str_replace("\r", "", $text_clean);


  // Create a footnote item as an array.
  $fn = array(
    'value' => $value,
    'text' => $matches[2],
    'text_clean' => $text_clean,
    'fn_id' => 'footnote' . $value_id . '_' . $randstr,
    'ref_id' => 'footnoteref' . $value_id . '_' . $randstr
  );

  // We now allow to repeat the footnote value label, in which case the link to the previously
  // existing footnote is returned. Content of the current footnote is ignored.
  // See http://drupal.org/node/636808
  if( ! in_array( $value, $used_values ) )
  {
    // This is the normal case, add the footnote to $store_matches

    // Store the footnote item.
    array_push( $store_matches, $fn );
    array_push( $used_values, $value );
  }
  else
  {
    // A footnote with the same label already exists

    // Use the text and id from the first footnote with this value. 
    // Any text in this footnote is discarded.
    $i = array_search( $value, $used_values );
    $fn['text'] = $store_matches[$i]['text'];
    $fn['text_clean'] = $store_matches[$i]['text_clean'];
    $fn['fn_id'] = $store_matches[$i]['fn_id'];
    // Push the new ref_id into the first occurence of this footnote label
    // The stored footnote thus holds a list of ref_id's rather than just one id
    $ref_array = is_array($store_matches[$i]['ref_id']) ? $store_matches[$i]['ref_id'] : array( $store_matches[$i]['ref_id'] );
    array_push( $ref_array, $fn['ref_id'] );
    $store_matches[$i]['ref_id'] = $ref_array;
  }

  // Return the item themed into a footnote link.
  // Drupal 7 requires we use "render element" which just introduces a wrapper
  // around the old array.
  $fn = array('fn' => $fn );
  return theme('footnote_link',$fn);
}

/**
 * Helper function to return a random text string
 *
 * @return random (lowercase) alphanumeric string
 */
function _footnotes_helper_randstr() {
  $chars = "abcdefghijklmnopqrstuwxyz1234567890";
  $str = "";

  //seeding with srand() not neccessary in modern PHP versions
  for( $i = 0; $i < 7; $i++ ) {
    $n = rand( 0, strlen( $chars ) - 1 );
    $str .= substr( $chars, $n, 1 );
  }
  return $str;
}

/**
 * Implementation of hook_theme()
 *
 * Thanks to emfabric for this implementation. http://drupal.org/node/221156
 */
function footnotes_theme() {
  return array(
    'footnote_link' => array(
      'render element' => 'fn' 
    ),
    'footnote_list' => array(
      'render element' => 'footnotes'
    )
  );
}

/**
 * Themed output of a footnote link appearing in the text body
 *
 * Accepts a single associative array, containing values on the following keys:
 *   text   - the raw unprocessed text extracted from within the [fn] tag
 *   text_clean   - a sanitized version of the previous, may be used as HTML attribute value
 *   value  - the raw unprocessed footnote number or other identifying label
 *   fn_id  - the globally unique identifier for the in-body footnote link
 *            anchor, used to allow links from the list to the body
 *   ref_id - the globally unique identifier for the footnote's anchor in the
 *            footnote listing, used to allow links to the list from the body
 */
function theme_footnote_link($fn) {
  // Drupal 7 requires we use "render element" which just introduces a wrapper
  // around the old array.
  $fn = $fn['fn'];
  return '<a class="see-footnote" id="' . $fn['ref_id'] . 
         '" title="' . $fn['text_clean'] . '" href="#' . $fn['fn_id'] . '">' .
         $fn['value'] . '</a>';
}

/**
 * Themed output of the footnotes list appearing at at [footnotes]
 *
 * Accepts an array containing an ordered listing of associative arrays, each 
 * containing values on the following keys:
 *   text   - the raw unprocessed text extracted from within the [fn] tag
 *   text_clean   - a sanitized version of the previous, may be used as HTML attribute value
 *   value  - the raw unprocessed footnote number or other identifying label
 *   fn_id  - the globally unique identifier for the in-body footnote link
 *            anchor, used to allow links from the list to the body
 *   ref_id - the globally unique identifier for the footnote's anchor in the
 *            footnote listing, used to allow links to the list from the body
 */
function theme_footnote_list($footnotes) {
  $str = '<ul class="footnotes">';
  // Drupal 7 requires we use "render element" which just introduces a wrapper
  // around the old array.
  $footnotes = $footnotes['footnotes'];
  // loop through the footnotes
  foreach ($footnotes as $fn) {
    if(!is_array( $fn['ref_id'])) {
      // Output normal footnote
      $str .= '<li class="footnote" id="' . $fn['fn_id'] .'"><a class="footnote-label" href="#' . $fn['ref_id'] . '">' . $fn['value'] . '.</a> ';
      $str .= $fn['text'] . "</li>\n";
    }
    else {
      // Output footnote that has more than one reference to it in the body.
      // The only difference is to insert backlinks to all references.
      // Helper: we need to enumerate a, b, c...
      $abc = str_split("abcdefghijklmnopqrstuvwxyz"); $i = 0;

      $str .= '<li class="footnote" id="' . $fn['fn_id'] .'"><a href="#' . $fn['ref_id'][0] . '" class="footnote-label">' . $fn['value'] . '.</a> ';
      foreach ($fn['ref_id'] as $ref) {
        $str .= '<a class="footnote-multi" href="#' . $ref . '">' . $abc[$i] . '.</a> ';
        $i++;
      }
      $str .= $fn['text'] . "</li>\n";   
    }
  }
  $str .= "</ul>\n";
  return $str;
}

/**
* Implementation of hook_init()
*
* Add special css for Footnotes module.
*
* Thanks to binford2k@lug.wsu.edu for this tip and drinkypoo
* for the question leading up to it. http://drupal.org/node/80538
*/
function footnotes_init() {
  drupal_add_css(drupal_get_path('module', 'footnotes') . '/footnotes.css', array('group' => CSS_DEFAULT, 'every_page' => TRUE));
}

/**
* Helper for other filters, check if Footnotes is present in your filter chain.
*
* Note: Due to changes in Filter API, the arguments to this function have changed
* in Drupal 7.
* 
* Other filters may leverage the Footnotes functionality in a simple way:
* by outputting markup with <fn>...</fn> tags within. 
*
* This creates a dependency, the Footnotes filter must be present later in 
* "Input format". By calling this helper function the other filters that 
* depend on Footnotes may check whether Footnotes is present later in the chain
* of filters in the current Input format.
*
* If this function returns true, the caller may depend on Footnotes. Function returns
* false if caller may not depend on Footnotes.
* 
* You should also put "dependencies = footnotes" in your module.info file.
*
* Example usage:
* <code>
* _filter_example_process( $text, $filter, $format ) {
*   ...
*   if(footnotes_is_footnotes_later($format, $filter)) {
*     //output markup which may include [fn] tags
*   }
*   else {
*     // must make do without footnotes features
*     // can also emit warning/error that user should install and configure footnotes module
*   }
*   ...
* }
* </code>
*
* @param $format
*    The text format object caller is part of.
* @param $caller_filter
*    The filter object representing the caller (in this text format).
*
* @return True if Footnotes is present after $caller in $format.
*/
function footnotes_is_footnotes_later( $format, $caller_filter) {
    return $format['filter_footnotes']['weight'] > $caller_filter['weight'];
}
