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