1<?php
2/*
3 * By Raphael Reitzig, 2012
4 * Edited by Hans-Nikolai Viessmann, 2016
5 * version 2.0
6 * code@verrech.net
7 * http://lmazy.verrech.net
8 */
9?>
10<?php
11/*
12    This program is free software: you can redistribute it and/or modify
13    it under the terms of the GNU General Public License as published by
14    the Free Software Foundation, either version 3 of the License, or
15    (at your option) any later version.
16
17    This program is distributed in the hope that it will be useful,
18    but WITHOUT ANY WARRANTY; without even the implied warranty of
19    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
20    GNU General Public License for more details.
21
22    You should have received a copy of the GNU General Public License
23    along with this program.  If not, see <http://www.gnu.org/licenses/>.
24*/
25?>
26<?php
27
28// Use the slightly modified BibTex parser from PEAR.
29require_once('lib/PEAR5.php');
30require_once('lib/PEAR.php');
31require_once('lib/BibTex.php');
32
33// Some stupid functions
34require_once('helper.inc.php');
35
36/**
37 * This class provides a method that parses bibtex files to
38 * other text formats based on a template language. See
39 *   http://lmazy.verrech.net/bib2tpl/
40 * for documentation.
41 *
42 * @author Raphael Reitzig
43 * @author Hans-Nikolai Viessmann <hv15@hw.ac.uk>
44 * @version 2.0
45 */
46class BibtexConverter {
47  /**
48   * BibTex parser
49   *
50   * @access private
51   * @var Structures_BibTex
52   */
53  private static $parser;
54
55  /**
56   * Options array. May contain the following pairs:
57   *   only  => array([$field => $regexp], ...)
58   *   group => (none|firstauthor|entrytype|$field)
59   *   order_groups => (asc|desc)
60   *   sort_by => (DATE|$field)
61   *   order => (asc|desc)
62   *   lang => xy (where lang/xy.php exists)
63   *   key => string (exposed in template at global-scope)
64   * @access private
65   * @var array
66   */
67  private $options;
68
69  /**
70   * Callback to a function that takes a string (taken from a
71   * BibTeX field) and clears it up for output.
72   * @access private
73   * @var callback
74   */
75  private $sanitise;
76
77  /**
78   * Helper object with support functions.
79   * @access private
80   * @var Helper
81   */
82  private $helper;
83
84  /**
85   * Array with author names and replacement.
86   * @access private
87   * @var assoc. array
88   */
89  private $authorlist;
90
91  /**
92   * Constructor.
93   *
94   * @access public
95   * @param array $options Options array. May contain the following pairs:
96   *                       - only  => array([$field => $regexp], ...)
97   *                       - group => (none|year|firstauthor|entrytype|$field)
98   *                       - order_groups => (asc|desc)
99   *                       - sort_by => (DATE|$field)
100   *                       - order => (asc|desc)
101   *                       - lang  => any string as long as proper lang/$s.php exists
102   *                       For details see documentation.
103   * @param callback $sanitise Callback to a function that takes a string (taken from a
104   *                           BibTeX field) and clears it up for output. Default is the
105   *                           identity function.
106   */
107  function __construct($options=array(), $sanitise=null, $authors=null) {
108    // Default options
109    $this->options = array(
110      'only'  => array(),
111      'group' => 'year',
112      'order_groups' => 'desc',
113      'sort_by' => 'DATE',
114      'order' => 'desc',
115      'lang' => 'en',
116      'key' => ''
117    );
118
119    // lame replacement for non-constant default parameter
120    if ( !empty($sanitise) ) {
121      $this->sanitise = $sanitise;
122    }
123    else {
124      $this->sanitise = create_function('$i', 'return $i;');
125    }
126
127    // Overwrite default options
128    foreach ( $this->options as $key => $value ) {
129      if ( !empty($options[$key]) ) {
130        $this->options[$key] = $options[$key];
131      }
132    }
133
134    /* Load translations.
135     * We assume that the english language file is always there.
136     */
137    if ( is_readable(dirname(__FILE__).'/lang/'.$this->options['lang'].'.php') ) {
138      require('lang/'.$this->options['lang'].'.php');
139    }
140    else {
141      require('lang/en.php');
142    }
143    $this->options['lang'] = $translations;
144
145    $this->helper = new Helper($this->options);
146
147
148    $this->authorlist = array();
149    foreach(preg_split("/((\r?\n)|(\r\n?))/", $authors) as $line){
150      $tmp = explode(" ",$line,2);
151      $this->authorlist[$tmp[1]] = "[[".$tmp[0]."|".$tmp[1]."]]";
152    }
153
154  }
155
156  /**
157   * Parses the specified BibTeX string into an array with entries of the form
158   * $entrykey => $entry. The result can be used with BibtexConverter::convert.
159   *
160   * @access public
161   * @param string $bibtex BibTeX code
162   * @return array Array with data from passed BibTeX
163   */
164  static function parse(&$bibtex) {
165    if ( !isset(self::$parser) ) {
166      self::$parser = new Structures_BibTex(array('removeCurlyBraces' => false));
167    }
168
169    self::$parser->loadString($bibtex);
170    $stat = self::$parser->parse();
171
172    if ( PEAR::isError($stat) ) {
173      return $stat;
174    }
175
176    $parsed = self::$parser->data;
177    $result = array();
178    foreach ( $parsed as &$entry ) {
179      $result[$entry['entrykey']] = $entry;
180    }
181
182    return $result;
183  }
184
185  /**
186   * Parses the given BibTeX string and applies its data to the passed template string.
187   * If $bibtex is an array (which has to be parsed by BibtexConverter::parse)
188   * parsing is skipped.
189   *
190   * @access public
191   * @param string|array $bibtex BibTeX code or parsed array
192   * @param string       $template template code
193   * @param array  $replacementKeys An array with entries of the form $entrykey => $newKey.
194   *                                If an entrykey occurrs here, it will be replaced by
195   *                                its correspoding newKey in the output.
196   * @return string|PEAR_Error Result string or PEAR_Error on failure
197   */
198  function convert($bibtex, &$template, &$replacementKeys=array()) {
199    // If there are no grouping tags, disable grouping.
200    if ( preg_match('/@\{group@/s', $template) + preg_match('/@\}group@/s', $template) < 2 ) {
201      $groupingDisabled = $this->options['group'];
202      $this->options['group'] = 'none';
203    }
204
205    // If grouping is off, remove grouping tags.
206    if ( $this->options['group'] === 'none' ) {
207      $template = preg_replace(array('/@\{group@/s', '/@\}group@/s'), '', $template);
208    }
209
210    // Parse if necessary
211    if ( is_array($bibtex) ) {
212      $data = $bibtex;
213    }
214    else {
215      $data = self::parse($bibtex);
216    }
217
218    $data   = $this->filter($data, $replacementKeys);
219    $data   = $this->group($data);
220    $data   = $this->sort($data);
221    $result = $this->translate($data, $template);
222
223    /* If grouping was disabled because of the template, restore the former
224     * setting for future calls. */
225    if ( !empty($groupingDisabled) ) {
226      $this->options['group'] = $groupingDisabled;
227    }
228
229    return $result;
230  }
231
232  /**
233   * This function filters data from the specified array that should
234   * not be shown. Filter criteria are specified at object creation.
235   *
236   * Furthermore, entries whose entrytype is not translated in the specified
237   * language file are put into a distinct group.
238   *
239   * @access private
240   * @param array data Unfiltered data, that is array of entries
241   * @param replacementKeys An array with entries of the form $entrykey => $newKey.
242   *                        If an entrykey occurrs here, it will be replaced by
243   *                        its correspoding newKey in the output.
244   * @return array Filtered data as array of entries
245   */
246  private function filter(&$data, &$replacementKeys=array()) {
247    $result = array();
248
249    $id = 0;
250    foreach ( $data as $entry ) {
251      // Some additions/corrections
252      if ( empty($this->options['lang']['entrytypes'][$entry['entrytype']]) ) {
253        $entry['entrytype'] = $this->options['lang']['entrytypes']['unknown'];
254      }
255
256      // Check wether this entry should be included
257      $keep = true;
258      foreach ( $this->options['only'] as $field => $regexp ) {
259        if ( !empty($entry[$field]) ) {
260          $val =   $field === 'author'
261                 ? $entry['niceauthor']
262                 : $entry[$field];
263
264          $keep = $keep && preg_match('/'.$regexp.'/i', $val);
265        }
266        else {
267          /* If the considered field does not even exist, consider this a fail.
268           * That enables to use $field => '.*' as existence check. */
269          $keep = false;
270        }
271      }
272
273      if ( $keep === true ) {
274        if ( !empty($replacementKeys[$entry['entrykey']]) ) {
275          $entry['entrykey'] = $replacementKeys[$entry['entrykey']];
276        }
277
278        $result[] = $entry;
279      }
280    }
281
282    return $result;
283  }
284
285  /**
286   * This function groups the passed entries according to the criteria
287   * passed at object creation.
288   *
289   * @access private
290   * @param array data An array of entries
291   * @return array An array of arrays of entries
292   */
293  private function group(&$data) {
294    $result = array();
295
296    if ( $this->options['group'] !== 'none' ) {
297      foreach ( $data as $entry ) {
298        if ( !empty($entry[$this->options['group']]) || $this->options['group'] === 'firstauthor' ) {
299          if ( $this->options['group'] === 'firstauthor' ) {
300            $target = $entry['author'][0]['nice'];
301          }
302          elseif ( $this->options['group'] === 'author' ) {
303            $target = $entry['niceauthor'];
304          }
305          else {
306            $target =  $entry[$this->options['group']];
307          }
308        }
309        else {
310          $target = $this->options['lang']['rest'];
311        }
312
313        if ( empty($result[$target]) ) {
314          $result[$target] = array();
315        }
316
317        $result[$target][] = $entry;
318      }
319    }
320    else {
321      $result[$this->options['lang']['all']] = $data;
322    }
323
324    return $result;
325  }
326
327  /**
328   * This function sorts the passed group of entries and the individual
329   * groups if there are any.
330   *
331   * @access private
332   * @param array data An array of arrays of entries
333   * @return array A sorted array of sorted arrays of entries
334   */
335  private function sort($data) {
336    // Sort groups if there are any
337    if ( $this->options['group'] !== 'none' ) {
338      uksort($data, array($this->helper, 'group_cmp'));
339    }
340
341    // Sort individual groups
342    foreach ( $data as &$group ) {
343      uasort($group, array($this->helper, 'entry_cmp'));
344    }
345
346    return $data;
347  }
348
349  /**
350   * This function inserts the specified data into the specified template.
351   * For template syntax see class documentation or examples.
352   *
353   * @access private
354   * @param array data An array of arrays of entries
355   * @param string template The used template
356   * @return string The data represented in terms of the template
357   */
358  private function translate(&$data, &$template) {
359    $result = $template;
360
361    // Replace global values
362    $result = preg_replace(array('/@globalcount@/', '/@globalgroupcount@/', '/@globalkey@/'),
363                           array(Helper::lcount($data, 2), count($data), $this->options['key']),
364                           $result);
365
366    if ( $this->options['group'] !== 'none' ) {
367      $pattern = '/@\{group@(.*?)@\}group@/s';
368
369      // Extract group templates
370      $group_tpl = array();
371      preg_match($pattern, $result, $group_tpl);
372
373      // For all occurrences of an group template
374      while ( !empty($group_tpl) ) {
375        // Translate all groups
376        $groups = '';
377        $id = 0;
378        foreach ( $data as $groupkey => $group ) {
379          $groups .= $this->translate_group($groupkey, $id++, $group, $group_tpl[1]);
380        }
381
382        $result = preg_replace($pattern, $groups, $result, 1);
383        preg_match($pattern, $result, $group_tpl);
384      }
385
386      return $result;
387    }
388    else {
389      $groups = '';
390      foreach ( $data as $groupkey => $group ) { // loop will only be run once
391        $groups .= $this->translate_group($groupkey, 0, $group, $template);
392      }
393      return $groups;
394    }
395  }
396
397  /**
398   * This function translates one entry group
399   *
400   * @access private
401   * @param string key The rendered group's key
402   * @param int id A unique ID for this group
403   * @param array data Array of entries in this group
404   * @param string template The group part of the template
405   * @return string String representing the passed group wrt template
406   */
407  private function translate_group($key, $id, &$data, $template) {
408    $result = $template;
409
410    // Replace group values
411    if ( $this->options['group'] === 'entrytype' ) {
412      $key = $this->options['lang']['entrytypes'][$key];
413    }
414    $result = preg_replace(array('/@groupkey@/', '/@groupid@/', '/@groupcount@/'),
415                           array($key, $id, count($data)),
416                           $result);
417
418    $pattern = '/@\{entry@(.*?)@\}entry@/s';
419
420    // Extract entry templates
421    $entry_tpl = array();
422    preg_match($pattern, $result, $entry_tpl);
423
424    // For all occurrences of an entry template
425    while ( !empty($entry_tpl) ) {
426      // Translate all entries
427      $entries = '';
428      foreach ( $data as $entry ) {
429        $entries .= $this->translate_entry($entry, $entry_tpl[1]);
430      }
431
432      $result = preg_replace($pattern, $entries, $result, 1);
433      preg_match($pattern, $result, $entry_tpl);
434    }
435
436    return $result;
437  }
438
439  /**
440   * This function translates one entry
441   *
442   * @access private
443   * @param array entry Array of fields
444   * @param string template The entry part of the template
445   * @return string String representing the passed entry wrt template
446   */
447  private function translate_entry(&$entry, $template) {
448    $result = $template;
449
450    // Resolve all conditions
451    $result = $this->resolve_conditions($entry, $result);
452
453    // Replace all possible unconditional fields
454    $patterns = array();
455    $replacements = array();
456
457    foreach ( $entry as $key => $value ) {
458      if ( $key === 'author' ) {
459        $value = $entry['niceauthor'];
460        $value = $this->authorlink($value);
461      }
462      // Don't sanitize values associated with bibtex or url keys
463      if ( $key == 'bibtex' || $key == 'url') {
464        $patterns []= '/@'.$key.'@/';
465        $replacements []= $value;
466      }
467      else {
468        $patterns []= '/@'.$key.'@/';
469        $replacements []= call_user_func($this->sanitise, $value);
470      }
471    }
472
473    return preg_replace($patterns, $replacements, $result);
474  }
475
476  /**
477   * This function eliminates conditions in template parts.
478   *
479   * @access private
480   * @param array entry Entry with respect to which conditions are to be
481   *                    solved.
482   * @param string template The entry part of the template.
483   * @return string Template string without conditions.
484   */
485  private function resolve_conditions(&$entry, &$string) {
486    $pattern = '/@\?(\w+)(?:(<=|>=|==|!=|~)(.*?))?@(.*?)(?:@:\1@(.*?))?@;\1@/s';
487    /* There are two possibilities for mode: existential or value check
488     * Then, there can be an else part or not.
489     *          Existential       Value Check      RegExp
490     * Group 1  field             field            \w+
491     * Group 2  then              operator         .*?  /  <=|>=|==|!=|~
492     * Group 3  [else]            value            .*?
493     * Group 4   ---              then             .*?
494     * Group 5   ---              [else]           .*?
495     */
496
497    $match = array();
498
499    /* Would like to do
500     *    preg_match_all($pattern, $string, $matches);
501     * to get all matches at once but that results in Segmentation
502     * fault. Therefore iteratively:
503     */
504    while ( preg_match($pattern, $string, $match) )
505    {
506      $resolved = '';
507
508      $evalcond = !empty($entry[$match[1]]);
509      $then = count($match) > 3 ? 4 : 2;
510      $else = count($match) > 3 ? 5 : 3;
511
512      if ( $evalcond && count($match) > 3 ) {
513        if ( $match[2] === '==' ) {
514          $evalcond = $entry[$match[1]] === $match[3];
515        }
516        elseif ( $match[2] === '!=' ) {
517          $evalcond = $entry[$match[1]] !== $match[3];
518        }
519        elseif ( $match[2] === '<=' ) {
520          $evalcond =    is_numeric($entry[$match[1]])
521                      && is_numeric($match[3])
522                      && (int)$entry[$match[1]] <= (int)$match[3];
523        }
524        elseif ( $match[2] === '>=' ) {
525          $evalcond =    is_numeric($entry[$match[1]])
526                      && is_numeric($match[3])
527                      && (int)$entry[$match[1]] >= (int)$match[3];
528        }
529        elseif ( $match[2] === '~' ) {
530          $evalcond = preg_match('/'.$match[3].'/', $entry[$match[1]]) > 0;
531        }
532      }
533
534      if ( $evalcond )
535      {
536        $resolved = $match[$then];
537      }
538      elseif ( !empty($match[$else]) )
539      {
540        $resolved = $match[$else];
541      }
542
543      // Recurse to cope with nested conditions
544      $resolved = $this->resolve_conditions($entry, $resolved);
545
546      $string = str_replace($match[0], $resolved, $string);
547    }
548
549    return $string;
550  }
551
552  /**
553   * This function adds links to co-author websites where available.
554   *
555   * @access private
556   * @param string data Formatted author line without links.
557   * @return string data Formatted author line with links.
558   */
559  private function authorlink($data) {
560    $data = str_replace(array_keys($this->authorlist),$this->authorlist,$data);
561    return $data;
562  }
563}
564
565?>
566