import
[web.mtrack] / inc / wiki.php
1 <?php # vim:ts=2:sw=2:et:
2 /* For licensing and copyright terms, see the file named LICENSE */
3
4 class MTrackWikiParser {
5
6 const EMAIL_LOOKALIKE_PATTERN = 
7 "[a-zA-Z0-9.'=+_-]+@(?:[a-zA-Z0-9_-]+\.)+[a-zA-Z](?:[-a-zA-Z\d]*[a-zA-Z\d])?";
8 const BOLDITALIC_TOKEN = "'''''";
9 const BOLD_TOKEN = "'''";
10 const ITALIC_TOKEN = "''";
11 const UNDERLINE_TOKEN = "__";
12 const STRIKE_TOKEN = "~~";
13 const SUBSCRIPT_TOKEN = ",,";
14 const SUPERSCRIPT_TOKEN = "\^";
15 const INLINE_TOKEN = "`";
16 const STARTBLOCK_TOKEN = "\{\{\{";
17 const STARTBLOCK = "{{{";
18 const ENDBLOCK_TOKEN = "\}\}\}";
19 const ENDBLOCK = "}}}";
20 const LINK_SCHEME = "[\w.+-]+"; # as per RFC 2396
21 const INTERTRAC_SCHEME = "[a-zA-Z.+-]*?"; # no digits (support for shorthand links)
22
23 const QUOTED_STRING = "'[^']+'|\"[^\"]+\"";
24
25 const SHREF_TARGET_FIRST = "[\w/?!#@](?<!_)"; # we don't want "_"
26 const SHREF_TARGET_MIDDLE = "(?:\|(?=[^|\s])|[^|<>\s])";
27 const SHREF_TARGET_LAST = "[\w/=](?<!_)"; # we don't want "_"
28
29 const LHREF_RELATIVE_TARGET = "[/#][^\s\]]*|\.\.?(?:[/#][^\s\]]*)?";
30
31 # See http://www.w3.org/TR/REC-xml/#id 
32 const XML_NAME = "[\w:](?<!\d)[\w:.-]*";
33
34 const LOWER = '(?<![A-Z0-9_])';
35 const UPPER = '(?<![a-z0-9_])';
36
37   static $pre_rules = array(
38     array("(?P<bolditalic>!?%s)", self::BOLDITALIC_TOKEN),
39     array("(?P<bold>!?%s)" , self::BOLD_TOKEN),
40     array("(?P<italic>!?%s)" , self::ITALIC_TOKEN),
41     array("(?P<underline>!?%s)" , self::UNDERLINE_TOKEN),
42     array("(?P<strike>!?%s)" , self::STRIKE_TOKEN),
43     array("(?P<subscript>!?%s)" , self::SUBSCRIPT_TOKEN),
44     array("(?P<superscript>!?%s)" , self::SUPERSCRIPT_TOKEN),
45     array("(?P<inlinecode>!?%s(?P<inline>.*?)%s)" ,
46         self::STARTBLOCK_TOKEN, self::ENDBLOCK_TOKEN),
47     array("(?P<inlinecode2>!?%s(?P<inline2>.*?)%s)",
48         self::INLINE_TOKEN, self::INLINE_TOKEN),
49   );
50   static $post_rules = array(
51     # WikiPageName
52     array("(?P<wikipagename>!?(?<!/)\\b\w%s(?:\w%s)+(?:\w%s(?:\w%s)*[\w/]%s)+(?:@\d+)?(?:#%s)?(?=:(?:\Z|\s)|[^:a-zA-Z]|\s|\Z))",
53       self::UPPER, self::LOWER, self::UPPER, self::LOWER, self::LOWER, self::XML_NAME),
54     # [WikiPageName with label]
55     array("(?P<wikipagenamewithlabel>!?\[\w%s(?:\w%s)+(?:\w%s(?:\w%s)*[\w/]%s)+(?:@\d+)?(?:#%s)?(?=:(?:\Z|\s)|[^:a-zA-Z]|\s|\Z)\s+(?:%s|[^\]]+)\])",
56       self::UPPER, self::LOWER, self::UPPER, self::LOWER, self::LOWER, self::XML_NAME, self::QUOTED_STRING),
57
58     # [21450] changeset
59     "(?P<svnchangeset>!?\[(?:(?:[a-zA-Z]+)?\d+|[a-fA-F0-9]+)\])",
60     # #ticket
61     "(?P<ticket>!?#(?:(?:[a-zA-Z]+)?\d+|[a-fA-F0-9]+))",
62     # {report}
63     "(?P<report>!?\{([^}]*)\})",
64
65     # e-mails
66     array("(?P<email>!?%s)" , self::EMAIL_LOOKALIKE_PATTERN),
67     # > ...
68     "(?P<citation>^(?P<cdepth>>(?: *>)*))",
69     # &, < and > to &amp;, &lt; and &gt;
70     "(?P<htmlspecialcharsape>[&<>])",
71     # wiki:TracLinks
72     array(
73       "(?P<shref>!?((?P<sns>%s):(?P<stgt>%s|%s(?:%s*%s)?)))",
74       self::LINK_SCHEME, self::QUOTED_STRING,
75       self::SHREF_TARGET_FIRST, self::SHREF_TARGET_MIDDLE,
76       self::SHREF_TARGET_LAST),
77
78     # [wiki:TracLinks with optional label] or [/relative label]
79     array(
80       "(?P<lhref>!?\[(?:(?P<rel>%s)|(?P<lns>%s):(?P<ltgt>%s|[^\]\s]*))(?:\s+(?P<label>%s|[^\]]+))?\])",
81       self::LHREF_RELATIVE_TARGET, self::LINK_SCHEME,
82       self::QUOTED_STRING, self::QUOTED_STRING),
83
84     # [[macro]] call
85     "(?P<macro>!?\[\[(?P<macroname>[\w/+-]+)(\]\]|\((?P<macroargs>.*?)\)\]\]))",
86     # == heading == #hanchor
87     array(
88     "(?P<heading>^\s*(?P<hdepth>=+)\s.*\s(?P=hdepth)\s*(?P<hanchor>#%s)?(?:\s|$))", self::XML_NAME),
89     #  * list
90     "(?P<list>^(?P<ldepth>\s+)(?:[-*]|\d+\.|[a-zA-Z]\.|[ivxIVX]{1,5}\.) )",
91     # definition:: 
92     array(
93       "(?P<definition>^\s+((?:%s[^%s]*%s|%s(?:%s{,2}[^%s])*?%s|[^%s%s:]+|:[^:]+)+::)(?:\s+|$))",
94       self::INLINE_TOKEN, self::INLINE_TOKEN, self::INLINE_TOKEN,
95       self::STARTBLOCK_TOKEN, '}', '}',
96       self::ENDBLOCK_TOKEN, self::INLINE_TOKEN, '{'),
97     # (leading space)
98     "(?P<indent>^(?P<idepth>\s+)(?=\S))",
99     # || table ||
100     "(?P<last_table_cell>\|\|\s*$)",
101     "(?P<table_cell>\|\|)",
102   );
103
104     function get_rules() {
105       $this->prepare_rules();
106       return $this->compiled_rules;
107     }
108
109     private function _build_rule(&$rules, $rule_def) {
110       foreach ($rule_def as $rule) {
111         if (is_array($rule)) {
112           $fmt = array_shift($rule);
113           $rule = vsprintf($fmt, $rule);
114         }
115         $rules[] = $rule;
116       }
117     }
118
119     var $compiled_rules = null;
120
121     function prepare_rules() {
122       if ($this->compiled_rules) {
123         return $this->compiled_rules;
124       }
125       $helpers = array();
126       $syntax = array();
127
128       $this->_build_rule($syntax, self::$pre_rules);
129       $this->_build_rule($syntax, self::$post_rules);
130
131       foreach ($syntax as $rule) {
132         if (preg_match_all("/\?P<([a-z\d_]+)>/", $rule, $matches)) {
133           $helpers[] = $matches[1][0];
134         }
135       }
136       $this->helper_patterns = $helpers;
137
138       /* now compose it into a big regex */
139       $this->compiled_rules = "/" .
140         str_replace("/", "\\/", join('|', $syntax)) .
141         "/u";
142     }
143 }
144
145 class MTrackWiki {
146   static $macros = array();
147   static $processors = array();
148
149   static function format_to_html($text) {
150     $f = new MTrackWikiHTMLFormatter;
151     $f->format($text);
152     $html = $f->out;
153     if (false) { /* saveHTML messes with the encoding */
154       /* tidy it up */
155       @$d = DOMDocument::loadHTML($html);
156       if ($d) {
157         $d->formatOutput = true;
158         $d->substituteEntities = false;
159         $html = $d->saveHTML();
160         $html = preg_replace("/^.*<body>/sm", '', $html);
161         $html = preg_replace(",</body>.*,sm", '', $html);
162       }
163     }
164     return $html;
165   }
166
167   static function format_to_oneliner($text) {
168     $f = new MTrackWikiOneLinerFormatter;
169     $f->format($text);
170     return $f->out;
171   }
172   static function format_wiki_page($name) {
173     $d = MTrackWikiItem::loadByPageName($name);
174     if ($d) {
175       return self::format_to_html($d->content);
176     }
177     return null;
178   }
179
180   static function register_macro($name, $callback) {
181     self::$macros[$name] = $callback;
182   }
183
184   static function register_processor($name, $callback) {
185     self::$processors[$name] = $callback;
186   }
187
188   static function macro_IncludeWiki($pagename) {
189     return self::format_wiki_page($pagename);
190   }
191   static function macro_IncludeHelp($pagename) {
192     return self::format_to_html(file_get_contents(
193       dirname(__FILE__) . '/../defaults/help/' . basename($pagename)));
194   }
195   static function macro_comment() {
196     return '';
197   }
198   static function processor_comment($name, $content) {
199     return '';
200   }
201   static function processor_html($name, $content) {
202     return join("\n", $content);
203   }
204   static function processor_dataset($name, $content) {
205     $res = '<table class="report wiki dataset">';
206     while (count($content)) {
207       $row = array_shift($content);
208       $next_row = array_shift($content);
209       $cols = preg_split("/\s*\|\s*/", $row);
210       if ($next_row[0] == '-') {
211         // it's a header
212         $res .= '<thead><tr>';
213         foreach ($cols as $c) {
214           $res .= "<th>" . htmlentities($c, ENT_QUOTES, 'utf-8') . "</th>\n";
215         }
216         $res .= "</tr></thead><tbody>";
217       } else {
218         if (is_string($next_row)) {
219           array_unshift($content, $next_row);
220         }
221         // regular row
222         $res .= "<tr>";
223         foreach ($cols as $c) {
224           $res .= "<td>" . htmlentities($c, ENT_QUOTES, 'utf-8') . "</td>\n";
225         }
226         $res .= "</tr>\n";
227       }
228     }
229     $res .= "</tbody></table>\n";
230     return $res;
231   }
232 }
233 MTrackWiki::register_macro('IncludeWikiPage',
234   array('MTrackWiki', 'macro_IncludeWiki'));
235 MTrackWiki::register_macro('IncludeHelpPage',
236   array('MTrackWiki', 'macro_IncludeHelp'));
237 MTrackWiki::register_macro('Comment',
238   array('MTrackWiki', 'macro_comment'));
239 MTrackWiki::register_processor('comment',
240   array('MTrackWiki', 'processor_comment'));
241 MTrackWiki::register_processor('html',
242   array('MTrackWiki', 'processor_html'));
243 MTrackWiki::register_processor('dataset',
244   array('MTrackWiki', 'processor_dataset'));
245
246 class MTrackWikiHTMLFormatter {
247   var $parser;
248   var $out;
249   var $in_table_row;
250   var $table_row_count = 0;
251   var $open_tags;
252   var $list_stack;
253   var $quote_stack;
254   var $tabstops;
255   var $in_code_block;
256   var $in_table;
257   var $in_def_list;
258   var $in_table_cell;
259   var $paragraph_open;
260
261   function __construct() {
262     $this->parser = new MTrackWikiParser;
263   }
264
265   function reset() {
266     $this->open_tags = array();
267     $this->list_stack = array();
268     $this->quote_stack = array();
269     $this->tabstops = array();
270     $this->in_code_block = 0;
271     $this->in_table = false;
272     $this->in_def_list = false;
273     $this->in_table_cell = false;
274     $this->paragraph_open = false;
275   }
276
277   function _apply_rules($line) {
278     $rules = $this->parser->get_rules();
279     /* slightly tricky bit of code here, because preg_replace_callback
280      * doesn't seem to support named groups */
281     $matches = array();
282     if (preg_match_all($rules, $line, $matches, PREG_OFFSET_CAPTURE)) {
283       $repl = array();
284       foreach ($matches as $key => $info) {
285         if (is_string($key)) {
286           foreach ($info as $nmatch => $item) {
287             if (!is_array($item)) {
288               continue;
289             }
290             $match = $item[0];
291             $offset = $item[1];
292
293             if (strlen($match) && $offset >= 0) {
294               if ($match[0] == '!') {
295                 $repl[$offset] = array(null, $match, null);
296               } else {
297                 $func = '_' . $key . '_formatter';
298                 if (method_exists($this, $func)) {
299                   $repl[$offset] = array($func, $match, $nmatch);
300                 } else {
301                   @$this->missing[$func]++;
302                 }
303               }
304             }
305           }
306         }
307       }
308       if (count($repl)) {
309         /* order matches by match offset */
310         ksort($repl);
311         /* and now we can generate for each fragment */
312         $sol = 0;
313         foreach ($repl as $offset => $bits) {
314           list($func, $match, $nmatch) = $bits;
315
316           if ($offset > $sol) {
317             /* emit verbatim */
318             //              $this->out .= "Copying from $sol to $offset\n";
319             $this->out .= substr($line, $sol, $offset - $sol);
320           }
321
322           if ($func === null) {
323             $this->out .= htmlspecialchars(substr($match, 1),
324                             ENT_COMPAT, 'utf-8');
325           } else {
326             //              $this->out .= "invoking $func on $match of len " . strlen($match) . "\n";
327             //              $this->out .= var_export($matches, true) . "\n";
328             $this->$func($match, $matches, $nmatch);
329           }
330
331           $sol = $offset + strlen($match);
332         }
333         $this->out .= substr($line, $sol);
334         $result = '';
335       } else {
336         $result = $line;
337       }
338     } else {
339       $result = $line;
340     }
341     return $result;
342   }
343
344   function format($text, $escape_newlines = false) {
345     $this->out = '';
346     $this->reset();
347     foreach (preg_split("!\r?\n!", $text) as $line) {
348       if ($this->in_code_block || trim($line) == MTrackWikiParser::STARTBLOCK) {
349         $this->handle_code_block($line);
350         continue;
351       }
352       if (!strncmp($line, "----", 4)) {
353         $this->close_table();
354         $this->close_paragraph();
355         $this->close_indentation();
356         $this->close_list();
357         $this->close_def_list();
358         $this->out .= "<hr />\n";
359         continue;
360       }
361       if (strlen($line) == 0) {
362         $this->close_paragraph();
363         $this->close_indentation();
364         $this->close_list();
365         $this->close_def_list();
366         $this->close_table();
367         continue;
368       }
369       if (strncmp($line, "||", 2)) {
370         // Doesn't look like a valid table row line, so break any || in the line
371         $line = str_replace("||", "|", $line);
372       }
373       // Tag expansion and clear tabstops if no indent
374       $line = str_replace("\t", "        ", $line);
375       if ($line[0] != ' ') {
376         $this->tabstops = array();
377       }
378
379       $this->in_list_item = false;
380       $this->in_quote = false;
381
382       $save = $this->out;
383       $this->out = '';
384       $result = $this->_apply_rules($line);
385       $newbit = $this->out;
386       $this->out = $save;
387       if (!($this->in_list_item || $this->in_def_list || $this->in_table)) {
388         $this->open_paragraph();
389       }
390
391       if (!$this->in_list_item) {
392         $this->close_list();
393       }
394       if (!$this->in_quote) {
395         $this->close_indentation();
396       }
397       if ($this->in_def_list && $line[0] != ' ') {
398         $this->close_def_list();
399       }
400       if ($this->in_table && strncmp(ltrim($line), '||', 2)) {
401         $this->close_table();
402       }
403       $this->out .= $newbit;
404       $sep = "\n";
405       if (!($this->in_list_item || $this->in_def_list || $this->in_table)) {
406         if (strlen($result)) {
407           $this->open_paragraph();
408         }
409         if ($escape_newlines && !preg_match(",<br />\s*$,", $line)) {
410           $sep = "<br />\n";
411         }
412       }
413       $this->out .= $result . $sep;
414       $this->close_table_row();
415     }
416     $this->close_table();
417     $this->close_paragraph();
418     $this->close_indentation();
419     $this->close_list();
420     $this->close_def_list();
421     $this->close_code_blocks();
422   }
423
424   function _parse_heading($match, $info, $nmatch, $shorten) {
425     $match = trim($match);
426     $depth = min(strlen($info['hdepth'][$nmatch][0]), 5);
427     if (isset($info['hanchor']) && is_array($info['hanchor'])
428         && is_array($info['hanchor'][$nmatch])
429         && strlen($info['hanchor'][$nmatch][0])) {
430       $anchor = $info['hanchor'][$nmatch][0];
431     } else {
432       $anchor = '';
433     }
434     $heading_text = substr($match, $depth+1, - $depth - 1 - strlen($anchor));
435     $heading = MTrackWiki::format_to_oneliner($heading_text);
436     if ($anchor) {
437       $anchor = substr($anchor, 1);
438     } else {
439       $anchor = preg_replace("/[^\w:.-]+/", "", $heading_text);
440       if (ctype_digit($anchor[0])) {
441         $anchor = 'a' . $anchor;
442       }
443     }
444     return array($depth, $heading, $anchor);
445   }
446
447   function _heading_formatter($match, $info, $nmatch) {
448     $this->close_table();
449     $this->close_paragraph();
450     $this->close_indentation();
451     $this->close_list();
452     $this->close_def_list();
453     list($depth, $heading, $anchor) = 
454       $this->_parse_heading($match, $info, $nmatch, false);
455     
456     $this->out .= sprintf('<h%d id="%s"><a class="wiki" name="%s">%s</a></h%d>',
457       $depth, $anchor, $anchor, $heading, $depth);
458   }
459
460   function tag_open_p($tag) {
461     /* do we currently have any open tag with $tag as end-tag? */
462     return in_array($tag, $this->open_tags);
463   }
464
465   function open_tag($open_tag, $close_tag) {
466     $this->open_tags[] = array($open_tag, $close_tag);
467   }
468
469   function simple_tag_handler($match, $open_tag, $close_tag) {
470     if ($this->tag_open_p(array($open_tag, $close_tag))) {
471       $this->out .= $this->close_tag($close_tag);
472       return;
473     }
474     $this->open_tag($open_tag, $close_tag);
475     $this->out .= $open_tag;
476   }
477
478   function close_tag($tag) {
479     $tmp = '';
480     /* walk backwards until we find the tag, closing out
481      * as we go */
482     $keys = array_reverse(array_keys($this->open_tags));
483     foreach ($keys as $k) {
484       $pair = $this->open_tags[$k];
485       $tmp .= $pair[1];
486       if ($pair[1] == $tag) {
487         unset($this->open_tags[$k]);
488         foreach ($this->open_tags as $k2 => $pair) {
489           if ($k2 == $k) {
490             break;
491           }
492           $tmp .= $pair[0];
493         }
494         break;
495       }
496     }
497     return $tmp;
498   }
499
500   function _bolditalic_formatter($match, $info) {
501     $italic = array('<i>', '</i>');
502     $open = $this->tag_open_p($italic);
503     $tmp = '';
504     if ($open) {
505       $this->out .= $italic[1];
506       $this->close_tag($italic[1]);
507     }
508     $this->_bold_formatter($match, $info);
509     if (!$open) {
510       $this->out .= $italic[0];
511       $this->open_tag($italic[0], $italic[1]);
512     }
513   }
514
515   function _bold_formatter($match, $info) {
516     $this->simple_tag_handler($match, '<strong>', '</strong>');
517   }
518   function _italic_formatter($match, $info) {
519     $this->simple_tag_handler($match, '<i>', '</i>');
520   }
521   function _underline_formatter($match, $info) {
522     $this->simple_tag_handler($match,
523       '<span class="underline">', '</span>');
524   }
525   function _strike_formatter($match, $info) {
526     $this->simple_tag_handler($match, '<del>', '</del>');
527   }
528   function _subscript_formatter($match, $info) {
529     $this->simple_tag_handler($match, '<sub>', '</sub>');
530   }
531   function _superscript_formatter($match, $info) {
532     $this->simple_tag_handler($match, '<sup>', '</sup>');
533   }
534
535   function _email_formatter($match, $info) {
536     $this->out .= "<a href=\"mailto:" . 
537       htmlspecialchars($match, ENT_QUOTES, 'utf-8') .
538       "\">" . htmlspecialchars($match, ENT_COMPAT, 'utf-8') . "</a>";
539   }
540
541   function _htmlspecialcharsape_formatter($match, $info) {
542     $this->out .= htmlspecialchars($match, ENT_QUOTES, 'utf-8');
543   }
544
545   function _make_link($ns, $target, $match, $label) {
546     global $ABSWEB;
547     $is_closed = false;
548
549     if ($label[0] == '"' || $label[0] == "'") {
550       $label = substr($label, 1, -1);
551     }
552     if (preg_match('/^(.*)#(.*)$/', $target, $M)) {
553       $target = $M[1];
554       $anchor = $M[2];
555     } else {
556       $anchor = null;
557     }
558
559     if (strlen($ns)) {
560
561       /* special cases */
562       if ($ns == 'ticket' && 
563           (strpos($target, '-') !== false || strpos($target, ',') !== false)) {
564         /* ranged ticket query */
565         $ns = 'query';
566         $target = 'id=' . $target;
567       }
568
569       switch ($ns) {
570         case 'ticket':
571           $this->out .= mtrack_ticket($target, array(
572             'display' => $label,
573             '#' => $anchor,
574             ));
575           return;
576
577         case 'changeset':
578           if (strpos($target, ',') !== false) {
579             list($repo, $cs) = explode(',', $target, 2);
580             $this->out .= mtrack_changeset($cs, $repo);
581           } else {
582             $this->out .= mtrack_changeset($target);
583           }
584           return;
585
586         case 'milestone':
587           $label = htmlspecialchars(urldecode($target), ENT_QUOTES, 'utf-8');
588           $target = $ABSWEB . "$ns.php/" . $target;
589
590           $this->out .= "<span class='milestone";
591           $ms = MTrackMilestone::loadByName($target);
592           if ($ms->deleted || $ms->completed) {
593             $this->out .= " completed";
594           }
595           $this->out .= "'><a href=\"$target\">$label</a></span>";
596           return;
597
598         case 'wiki':
599           $this->out .= mtrack_wiki($target, array(
600             '#' => $anchor,
601             'display' => $label
602             ));
603           return;
604
605         case 'help':
606           if (!empty($anchor)) {
607             $target .= "#$anchor";
608           }
609           $this->out .= 
610             "<a class='wikilink' href='{$ABSWEB}help.php/$target'>$label</a>";
611           return;
612
613         case 'user':
614           $this->out .= mtrack_username($target);
615           return;
616
617         case 'repo':
618           $target = $ABSWEB . "browse.php/$target";
619           break;
620          
621         case 'log':
622           if ($target == '/') {
623             $target = mtrack_defrepo();
624           }
625           $target = $ABSWEB . "$ns.php/$target";
626           break;
627
628         case 'query':
629         case 'report':
630           $target = $ABSWEB . "$ns.php/$target";
631           break;
632         case 'source':
633           @list($file, $rev) = explode('#', $target, 2);
634           $file = ltrim($file, '/');
635           /* some legacy handling here; there are three cases:
636            * owner/repo/path -> repo = owner/repo
637            * repo/path       -> repo = default/repo
638            * path            -> repo = config.ini default repo
639            */
640           $bits = explode('/', $file);
641           $repo = null;
642           if (count($bits) > 2) {
643             /* maybe owner/repo */
644             $repo = MTrackRepo::loadByName($bits[0] . '/' . $bits[1]);
645             if ($repo) {
646               $repo = $repo->getBrowseRootName();
647             }
648           }
649           if ($repo === null && count($bits) > 1) {
650             $repo = MTrackRepo::loadByName('default/' . $bits[0]);
651             if ($repo) {
652               $repo = $repo->getBrowseRootName();
653               array_unshift($bits, 'default');
654             }
655           }
656           if ($repo === null) {
657             $defrep = mtrack_defrepo();
658             if ($defrep) {
659               if (strpos($defrep, '/') === false) {
660                 $defrep = "default/$defrep";
661               }
662               $repo = MTrackRepo::loadByName($defrep);
663               if ($repo) {
664                 $repo = $repo->getBrowseRootName();
665                 array_unshift($bits, $repo);
666               }
667             }
668           }
669           $file = join($bits, '/');
670
671           if ($rev) {
672             $target = $ABSWEB . "file.php/$file@$rev";
673           } else {
674             $target = $ABSWEB . "file.php/$file";
675           }
676           break;
677         case 'comment':
678           if (preg_match('/^(\d+):ticket:(.*)$/', $target, $M)) {
679             $this->out .= mtrack_ticket($M[2], 
680               array(
681                 '#' => 'comment:' . $M[1],
682                 'display' => $label
683               )
684             );
685             return;
686           } else {
687             $target = "#comment:$target";
688           }
689           break;
690
691         default:
692           $target = "$ns:$target";
693           if (strlen($anchor)) {
694             $target .= "#$anchor";
695           }
696           break;
697       }
698     }
699     $label = htmlspecialchars($label, ENT_QUOTES, 'utf-8');
700     if ($is_closed) {
701       $label = "<del>$label</del>";
702     }
703     $link = "<a href=\"$target\">$label</a>";
704     $this->out .= $link;
705   }
706
707   function _ticket_formatter($match, $info, $nmatch) {
708     $ticket = substr($match, 1);
709     $this->_make_link('ticket', $ticket, $ticket, $match);
710   }
711
712   function _report_formatter($match, $info, $nmatch) {
713     $ticket = substr($match, 1, -1);
714     $this->_make_link('report', $ticket, $ticket, $match);
715   }
716
717   function _svnchangeset_formatter($match, $info, $nmatch) {
718     $rev = substr($match, 1, -1);
719     $this->_make_link('changeset', $rev, $rev, $match);
720   }
721
722   function _wikipagename_formatter($match, $info, $nmatch) {
723     $this->_make_link('wiki', $match, $match, $match);
724   }
725   function _wikipagenamewithlabel_formatter($match, $info, $nmatch) {
726     $match = substr($match, 1, -1);
727     list($page, $label) = explode(" ", $match, 2);
728     $label = $this->_unquote(trim($label));
729     $this->_make_link('wiki', $page, $match, $label);
730   }
731
732
733   function _shref_formatter($match, $info, $nmatch) {
734     $ns = $info['sns'][$nmatch][0];
735     $target = $this->_unquote($info['stgt'][$nmatch][0]);
736     $shref = $info['shref'][$nmatch][0];
737     $this->_make_link($ns, $target, $match, $match);
738   }
739
740   function _lhref_formatter($match, $info, $nmatch) {
741     $rel = $info['rel'][$nmatch][0];
742     $ns = $info['lns'][$nmatch][0];
743     $target = $info['ltgt'][$nmatch][0];
744     $label = isset($info['label'][$nmatch][0]) ? $info['label'][$nmatch][0] : '';
745
746 //    var_dump($rel, $ns, $target, $label);
747
748     if (!strlen($label)) {
749       /* [http://target] or [wiki:target] */
750       if (strlen($target)) {
751         if (!strncmp($target, "//", 2)) {
752           /* for [http://target], label is http://target */
753           $label = "$ns:$target";
754         } else {
755           /* for [wiki:target], label is target */
756           $label = $target;
757         }
758       } else {
759         /* [search:] */
760         $label = $ns;
761       }
762     } else {
763       $label = $this->_unquote($label);
764     }
765     if (strlen($rel)) {
766       list($path, $query, $frag) = $this->split_link($rel);
767       if (!strncmp($path, '//', 2)) {
768         $path = '/' . ltrim($path, '/');
769       } elseif ($path[0] == '/') {
770         $path = $GLOBALS['ABSWEB'] . substr($path, 1);
771       }
772       $target = $path;
773       if (strlen($query)) {
774         $target .= "?$query";
775       }
776       if (strlen($frag)) {
777         $target .= "#$frag";
778       }
779       $this->out .= "<a href=\"$target\">$label</a>";
780     } else {
781       $this->_make_link($ns, $target, $match, $label);
782     }
783   }
784
785   function _inlinecode_formatter($match, $info, $nmatch) {
786     $this->out .= "<tt>" . 
787       nl2br(htmlspecialchars($info['inline'][$nmatch][0],
788         ENT_COMPAT, 'utf-8')) .
789         "</tt>";
790   }
791   function _inlinecode2_formatter($match, $info, $nmatch) {
792     $this->out .= "<tt>" . 
793       nl2br(htmlspecialchars($info['inline2'][$nmatch][0],
794         ENT_COMPAT, 'utf-8')) .
795         "</tt>";
796   }
797
798   function _macro_formatter($match, $info, $nmatch) {
799     $name = $info['macroname'][$nmatch][0];
800     if (!strcasecmp($name, 'br')) {
801       $this->out .= "<br />";
802       return;
803     }
804     if (isset(MTrackWiki::$macros[$name])) {
805       $args = explode(',', $info['macroargs'][$nmatch][0]);
806       $this->out .= call_user_func_array(MTrackWiki::$macros[$name], $args);
807     } else {
808       $this->out .= "<tt>" . 
809         htmlspecialchars($match, ENT_QUOTES, 'utf-8') . "</tt>";
810     }
811   }
812
813
814   function split_link($target) {
815     @list($query, $frag) = explode('#', $target, 2);
816     @list($target, $query) = explode('?', $query, 2);
817     return array($target, $query, $frag);
818   }
819
820   function _unquote($text) {
821     return preg_replace("/^(['\"])(.*)(\\1)$/", "\\2", $text);
822   }
823
824   function close_list() {
825     $this->_set_list_depth(0, null, null, null);
826   }
827
828   private function _get_list_depth() {
829     // Return the space offset associated to the deepest opened list
830     if (count($this->list_stack)) {
831       $e = end($this->list_stack);
832       return $e[1];
833     }
834     return 0;
835   }
836
837   private function _open_list($depth, $new_type, $list_class, $start) {
838     $this->close_table();
839     $this->close_paragraph();
840     $this->close_indentation();
841     $this->list_stack[] = array($new_type, $depth);
842     $this->_set_tab($depth);
843     if ($list_class) {
844       $list_class = "wikilist $list_class";
845     } else {
846       $list_class = "wikilist";
847     }
848     $class_attr = $list_class ? sprintf(' class="%s"', $list_class) : '';
849     $start_attr = $start ? sprintf(' start="%s"', $start) : '';
850     $this->out .= "<$new_type$class_attr$start_attr><li>";
851   }
852   private function _close_list($type) {
853     array_pop($this->list_stack);
854     $this->out .= "</li></$type>";
855   }
856
857   private function _set_list_depth($depth, $new_type, $list_class, $start) {
858     if ($depth > $this->_get_list_depth()) {
859       $this->_open_list($depth, $new_type, $list_class, $start);
860       return;
861     }
862     while (count($this->list_stack)) {
863       list($deepest_type, $deepest_offset) = end($this->list_stack);
864       if ($depth >= $deepest_offset) {
865         break;
866       }
867       $this->_close_list($deepest_type);
868     }
869     if ($depth > 0) {
870       if (count($this->list_stack)) {
871         list($old_type, $old_offset) = end($this->list_stack);
872         if ($new_type && $new_type != $old_type) {
873           $this->_close_list($old_type);
874           $this->_open_list($depth, $new_type, $list_class, $start);
875         } else {
876           if ($old_offset != $depth) {
877             array_pop($this->list_stack);
878             $this->list_stack[] = array($old_type, $depth);
879           }
880           $this->out .= "</li><li>";
881         }
882       } else {
883         $this->_open_list($depth, $new_type, $list_class, $start);
884       }
885     }
886   }
887
888   function close_indentation() {
889     $this->_set_quote_depth(0);
890   }
891
892   private function _get_quote_depth() {
893     // Return the space offset associated to the deepest opened quote
894     if (count($this->quote_stack)) {
895       $e = end($this->quote_stack);
896       return $e;
897     }
898     return 0;
899   }
900
901   private function _open_one_quote($d, $citation) {
902     $this->quote_stack[] = $d;
903     $this->_set_tab($d);
904     $class_attr = $citation ? ' class="citation"' : '';
905     $this->out .= "<blockquote$class_attr>\n";
906   }
907
908   private function _open_quote($quote_depth, $depth, $citation) {
909     $this->close_table();
910     $this->close_paragraph();
911     $this->close_list();
912
913     if ($citation) {
914       for ($d = $quote_depth + 1; $d < $depth+1; $d++) {
915         $this->_open_one_quote($d, $citation);
916       }
917     } else {
918       $this->_open_one_quote($depth, $citation);
919     }
920   }
921
922   private function _close_quote() {
923     $this->close_table();
924     $this->close_paragraph();
925     array_pop($this->quote_stack);
926     $this->out .= "</blockquote>\n";
927   }
928
929   private function _set_quote_depth($depth, $citation = false) {
930     $quote_depth = $this->_get_quote_depth();
931     if ($depth > $quote_depth) {
932       $this->_set_tab($depth);
933       $tabstops = $this->tabstops;
934
935       while (count($tabstops)) {
936         $tab = array_pop($tabstops);
937         if ($tab > $quote_depth) {
938           $this->_open_quote($quote_depth, $tab, $citation);
939         }
940       }
941     } else {
942       while ($this->quote_stack) {
943         $deepest_offset = end($this->quote_stack);
944         if ($depth >= $deepest_offset) {
945           break;
946         }
947         $this->_close_quote();
948       }
949       if (!$citation && $depth > 0) {
950         if (count($this->quote_stack)) {
951           $old_offset = end($this->quote_stack);
952           if ($old_offset != $depth) {
953             array_pop($this->quote_stack);
954             $this->quote_stack[] = $depth;
955           }
956         } else {
957           $this->_open_quote($quote_depth, $depth, $citation);
958         }
959       }
960     }
961     if ($depth > 0) {
962       $this->in_quote = true;
963     }
964   }
965
966   function open_paragraph() {
967     if (!$this->paragraph_open) {
968       $this->out .= "<p>\n";
969       $this->paragraph_open = true;
970     }
971   }
972
973   function close_paragraph() {
974     if ($this->paragraph_open) {
975       while (count($this->open_tags)) {
976         $t = array_pop($this->open_tags);
977         $this->out .= $t[1];
978       }
979       $this->out .= "</p>\n";
980       $this->paragraph_open = false;
981     }
982   }
983
984   function _last_table_cell_formatter($match, $info, $nmatch) {
985     return;
986   }
987
988   function _table_cell_formatter($match, $info, $nmatch) {
989     $this->open_table();
990     $this->open_table_row();
991     $tag = $this->table_row_count == 1 ? 'th' : 'td';
992     if ($this->in_table_cell) {
993       $this->out .= "</$tag><$tag>";
994       return;
995     }
996     $this->in_table_cell = 1;
997     $this->out .= "<$tag>";
998   }
999
1000
1001   function open_table() {
1002     if (!$this->in_table) {
1003       $this->close_paragraph();
1004       $this->close_list();
1005       $this->close_def_list();
1006       $this->in_table = 1;
1007       $this->table_row_count = 0;
1008       $this->out .= "<table class='report wiki'>\n";
1009     }
1010   }
1011
1012   function open_table_row() {
1013     if (!$this->in_table_row) {
1014       $this->open_table();
1015       if ($this->table_row_count == 0) {
1016         $this->out .= "<thead><tr>";
1017       } else if ($this->table_row_count == 1) {
1018         $this->out .= "<tbody><tr>";
1019       } else {
1020         $this->out .= "<tr>";
1021       }
1022       $this->in_table_row = 1;
1023       $this->table_row_count++;
1024     }
1025   }
1026
1027   function close_table_row() {
1028     if ($this->in_table_row) {
1029       $tag = $this->table_row_count == 1 ? 'th' : 'td';
1030       $this->in_table_row = 0;
1031       if ($this->in_table_cell) {
1032         $this->in_table_cell = 0;
1033         $this->out .= "</$tag>";
1034       }
1035       if ($this->table_row_count == 1) {
1036         $this->out .= "</tr></thead>";
1037       } else {
1038         $this->out .= "</tr>";
1039       }
1040     }
1041   }
1042
1043   function close_table() {
1044     if ($this->in_table) {
1045       $this->close_table_row();
1046       if ($this->table_row_count == 1) {
1047         $this->out .= "</thead></table>\n";
1048       } else {
1049         $this->out .= "</tbody></table>\n";
1050       }
1051       $this->in_table = 0;
1052     }
1053   }
1054
1055   function close_def_list() {
1056     if ($this->in_def_list) {
1057       $this->out .= "</dd></dl>\n";
1058     }
1059     $this->in_def_list = false;
1060   }
1061
1062   function handle_code_block($line) {
1063     if (trim($line) == MTrackWikiParser::STARTBLOCK) {
1064       $this->in_code_block++;
1065       if ($this->in_code_block == 1) {
1066         $this->code_buf = array();
1067       } else {
1068         $this->code_buf[] = $line;
1069       }
1070     } elseif (trim($line) == MTrackWikiParser::ENDBLOCK) {
1071       $this->in_code_block--;
1072       if ($this->in_code_block == 0) {
1073         // FIXME process the code here
1074         if (preg_match("/^#!(\S+)$/", $this->code_buf[0], $M)
1075             && isset(MTrackWiki::$processors[$M[1]])) {
1076           $func = MTrackWiki::$processors[$M[1]];
1077           array_shift($this->code_buf);
1078           $this->out .= call_user_func($func, $M[1], $this->code_buf);
1079         } else {
1080           $this->out .= "<pre>" .
1081             htmlspecialchars(join("\n", $this->code_buf), ENT_COMPAT, 'utf-8') .
1082             "</pre>";
1083         }
1084       } else {
1085         $this->code_buf[] = $line;
1086       }
1087     } else {
1088       $this->code_buf[] = $line;
1089     }
1090   }
1091
1092   function close_code_blocks() {
1093     while ($this->in_code_block) {
1094       $this->handle_code_block(MTrackWikiParser::ENDBLOCK);
1095     }
1096   }
1097
1098   function _set_tab($depth) {
1099     /* Append a new tab if needed and truncate tabs deeper than `depth`
1100       given:       -*-----*--*---*--
1101       setting:              *
1102       results in:  -*-----*-*-------
1103     */
1104     $tabstops = array();
1105     foreach ($this->tabstops as $ts) {
1106       if ($ts >= $depth) {
1107         break;
1108       }
1109       $tabstops[] = $ts;
1110     }
1111     $tabstops[] = $depth;
1112     $this->tabstops = $tabstops;
1113   }
1114
1115   function _list_formatter($match, $info, $nmatch) {
1116     $ldepth = strlen($info['ldepth'][$nmatch][0]);
1117     $listid = $match[$ldepth];
1118     $this->in_list_item = true;
1119     $class = '';
1120     $start = '';
1121     if ($listid == '-' || $listid == '*') {
1122       $type = 'ul';
1123     } else {
1124       $type = 'ol';
1125       switch ($listid) {
1126         case '1': break;
1127         case '0': $class = 'arabiczero'; break;
1128         case 'i': $class = 'lowerroman'; break;
1129         case 'I': $class = 'upperroman'; break;
1130         default:
1131           if (preg_match("/(\d+)\./", substr($match, $ldepth), $d)) {
1132             $start = (int)$d[1];
1133           } elseif (ctype_lower($listid)) {
1134             $class = 'loweralpha';
1135           } elseif (ctype_upper($listid)) {
1136             $class = 'upperalpha';
1137           }
1138       }
1139     }
1140     $this->_set_list_depth($ldepth, $type, $class, $start);
1141   }
1142
1143   function _definition_formatter($match, $info, $nmatch) {
1144     $tmp = $this->in_def_list ? '</dd>' : '<dl class="wikidl">';
1145     list($def) = explode('::', $match, 2);
1146     $tmp .= sprintf("<dt>%s</dt><dd>",
1147       MTrackWiki::format_to_oneliner(trim($def)));
1148     $this->in_def_list = true;
1149     $this->out .= $tmp;
1150   }
1151
1152   function _indent_formatter($match, $info, $nmatch) {
1153     $idepth = strlen($info['idepth'][$nmatch][0]);
1154     if (count($this->list_stack)) {
1155       list($ltype, $ldepth) = end($this->list_stack);
1156       if ($idepth < $ldepth) {
1157         foreach ($this->list_stack as $pair) {
1158           $ldepth = $pair[1];
1159           if ($idepth > $ldepth) {
1160             $this->in_list_item = true;
1161             $this->_set_list_depth($idepth, null, null, null);
1162             return;
1163           }
1164         }
1165       } elseif ($idepth <= $ldepth + ($ltype == 'ol' ? 3 : 2)) {
1166         $this->in_list_item = true;
1167         return;
1168       }
1169     }
1170     if (!$this->in_def_list) {
1171       $this->_set_quote_depth($idepth);
1172     }
1173   }
1174
1175   function _citation_formatter($match, $info, $nmatch) {
1176     $cdepth = strlen(str_replace(' ', '', $info['cdepth'][$nmatch][0]));
1177     $this->_set_quote_depth($cdepth, true);
1178   }
1179
1180
1181 }
1182
1183 class MTrackWikiOneLinerFormatter extends MTrackWikiHTMLFormatter {
1184   function format($text, $escape_newlines = false) {
1185     if (!strlen($text)) return;
1186     $this->reset();
1187     $in_code_block = 0;
1188     $num = 0;
1189     foreach (preg_split("!\r?\n!", $text) as $line) {
1190       if ($num++) $this->out .= ' ';
1191       $result = '';
1192       if ($this->in_code_block || trim($line) == MTrackWikiParser::STARTBLOCK) {
1193         $in_code_block++;
1194       } elseif (trim($line) == MTrackWikiParser::ENDBLOCK) {
1195         if ($in_code_block) {
1196           $in_code_block--;
1197           if ($in_code_block == 0) {
1198             $result .= " [...]\n";
1199           }
1200         }
1201       } elseif (!$in_code_block) {
1202         $result .= "$line\n";
1203       }
1204
1205       $result = $this->_apply_rules(rtrim($result, "\r\n"));
1206       $this->out .= $result;
1207       $this->close_tag(null);
1208     }
1209   }
1210 }
1211
1212 /*
1213 #error_reporting(E_NOTICE);
1214 $f = new MTrackWikiHTMLFormatter;
1215 $f->format(file_get_contents("WikiFormatting"));
1216 #$f->format("* '''wooot'''\noh '''yeah'''\n\n");
1217 #$f->format(" < wez@php.net http://foo.com/bar [https://baz.com/flib Flib] [/foo Shoe]\n");
1218 /*
1219 $f->format(<<<WIKI
1220 >> foo
1221 > bar
1222
1223 all done
1224 WIKI
1225 );
1226 */
1227 /*
1228 echo $f->out, "\n";
1229 print_r($f->missing);
1230 echo "\ndone\n";
1231 */