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