diff --git a/api/v1/entries/product.php b/api/v1/entries/product.php index 34089f3221..ae8f4e374b 100644 --- a/api/v1/entries/product.php +++ b/api/v1/entries/product.php @@ -58,7 +58,9 @@ class productEntry extends Entry $product->bugStatistic = $this->loadModel('bug')->getStatistic($productID); break; case 'moduleoptionmenu': - $product->moduleOptionMenu = $this->loadModel('tree')->getOptionMenu($productID, 'story', 0, '0'); + $modules = $this->loadModel('tree')->getOptionMenu($productID, $this->param('moduleType', 'story')); + $product->moduleOptionMenu = array(); + foreach($modules as $id => $name) $product->moduleOptionMenu[] = array('id' => $id, 'name' => $name); break; case 'parentstories': $product->parentstories= $this->loadModel('story')->getParentStoryPairs($productID); diff --git a/config/config.php b/config/config.php index cf57c4897c..82d7c689bc 100644 --- a/config/config.php +++ b/config/config.php @@ -24,6 +24,7 @@ $config->timezone = 'Asia/Shanghai'; // 时区设置。 The tim $config->webRoot = ''; // URL根目录。 The root path of the url. $config->customSession = false; // 是否开启自定义session的存储路径。Whether custom the session save path. $config->edition = 'open'; // 设置系统的edition,可选值:open|biz|max。Set edition, optional: open|biz|max. +$config->tabSession = true; // 是否开启浏览器新标签独立session. /* 框架路由相关设置。Routing settings. */ $config->requestType = 'PATH_INFO'; // 请求类型:PATH_INFO|PATHINFO2|GET。 The request type: PATH_INFO|PATH_INFO2|GET. diff --git a/config/filter.php b/config/filter.php index 308d9416bd..eeb4c9df87 100644 --- a/config/filter.php +++ b/config/filter.php @@ -20,6 +20,7 @@ $filter->default->paramName = 'reg::paramName'; $filter->default->paramValue = 'reg::paramValue'; $filter->default->get['onlybody'] = 'equal::yes'; +$filter->default->get['tid'] = 'reg::word'; $filter->default->get['HTTP_X_REQUESTED_WITH'] = 'equal::XMLHttpRequest'; $filter->default->cookie['lang'] = 'reg::lang'; diff --git a/db/update17.0.beta1.sql b/db/update17.0.beta1.sql new file mode 100644 index 0000000000..6ad66f1b12 --- /dev/null +++ b/db/update17.0.beta1.sql @@ -0,0 +1,7 @@ +ALTER TABLE `zt_task` ADD `fromIssue` mediumint(8) unsigned NOT NULL DEFAULT '0' AFTER `fromBug`; + +ALTER TABLE `zt_kanbanspace` ADD COLUMN `activatedBy` char(30) NOT NULL AFTER `closedDate`; +ALTER TABLE `zt_kanbanspace` ADD COLUMN `activatedDate` datetime NOT NULL AFTER `activatedBy`; + +ALTER TABLE `zt_kanban` ADD COLUMN `activatedBy` char(30) NOT NULL AFTER `closedDate`; +ALTER TABLE `zt_kanban` ADD COLUMN `activatedDate` datetime NOT NULL AFTER `activatedBy`; diff --git a/db/zentao.sql b/db/zentao.sql old mode 100644 new mode 100755 index 05fdae8af7..f0f9e8605d --- a/db/zentao.sql +++ b/db/zentao.sql @@ -760,6 +760,8 @@ CREATE TABLE `zt_kanbanspace` ( `lastEditedDate` datetime NOT NULL, `closedBy` char(30) NOT NULL, `closedDate` datetime NOT NULL, + `activatedBy` char(30) NOT NULL, + `activatedDate` datetime NOT NULL, `deleted` enum('0', '1') NOT NULL DEFAULT '0', PRIMARY KEY (`id`) ) ENGINE=MyISAM DEFAULT CHARSET=utf8; @@ -786,6 +788,8 @@ CREATE TABLE `zt_kanban` ( `lastEditedDate` datetime NOT NULL, `closedBy` char(30) NOT NULL, `closedDate` datetime NOT NULL, + `activatedBy` char(30) NOT NULL, + `activatedDate` datetime NOT NULL, `deleted` enum('0', '1') NOT NULL DEFAULT '0', PRIMARY KEY (`id`) ) ENGINE=MyISAM DEFAULT CHARSET=utf8; @@ -1457,6 +1461,7 @@ CREATE TABLE IF NOT EXISTS `zt_task` ( `storyVersion` smallint(6) NOT NULL default '1', `designVersion` smallint(6) unsigned NOT NULL, `fromBug` mediumint(8) unsigned NOT NULL default '0', + `fromIssue` mediumint(8) unsigned NOT NULL default '0', `name` varchar(255) NOT NULL, `type` varchar(20) NOT NULL, `pri` tinyint(3) unsigned NOT NULL default '0', diff --git a/framework/api/entry.class.php b/framework/api/entry.class.php index 2d93b300d9..beadc3089a 100644 --- a/framework/api/entry.class.php +++ b/framework/api/entry.class.php @@ -515,10 +515,15 @@ class baseEntry if(!isset($object->$key)) continue; $pos = strpos($type, ']'); - if($pos !== FALSE) + if($pos !== false) { - $is_array = true; - $type = substr($type, $pos + 1); + $isArray = true; + $type = substr($type, $pos + 1); + } + else if(strpos($type, 'array') !== false) + { + $isArray = true; + $type = 'object'; } /* Format value. */ diff --git a/framework/base/helper.class.php b/framework/base/helper.class.php index 3a645afe62..a9490316bf 100644 --- a/framework/base/helper.class.php +++ b/framework/base/helper.class.php @@ -160,9 +160,12 @@ class baseHelper public static function processOnlyBodyParam($link, $onlyBody = false) { global $config; - if(!$onlyBody and !self::inOnlyBodyMode()) return $link; - $onlybodyString = strpos($link, '?') === false ? "?onlybody=yes" : "&onlybody=yes"; - return $link . $onlybodyString; + + $sign = strpos($link, '?') === false ? "?" : "&"; + $appendString = ''; + if($onlyBody or self::inOnlyBodyMode()) $appendString = $sign . "onlybody=yes"; + if(self::isWithTID()) $appendString .= empty($appendString) ? "{$sign}tid={$_GET['tid']}" : "&tid={$_GET['tid']}"; + return $link . $appendString; } /** @@ -177,6 +180,19 @@ class baseHelper return (isset($_GET['onlybody']) and $_GET['onlybody'] == 'yes'); } + /** + * Is with tid. + * + * @static + * @access public + * @return bool + */ + public static function isWithTID() + { + global $config; + return (!empty($config->tabSession) and isset($_GET['tid'])); + } + /** * 使用helper::import()来引入文件,不要直接使用include或者require. * Using helper::import() to import a file, instead of include or require. diff --git a/framework/base/router.class.php b/framework/base/router.class.php index 931645b528..3b63bf756a 100644 --- a/framework/base/router.class.php +++ b/framework/base/router.class.php @@ -964,6 +964,32 @@ class baseRouter { if(defined('SESSION_STARTED')) return; + if(ini_get('session.save_handler') == 'files' and isset($_GET['tid'])) + { + $savePath = ini_get('session.save_path'); + $writable = is_writable($savePath); + if(!$writable) + { + $savePath = $this->getTmpRoot() . 'session'; + if(!is_dir($savePath)) mkdir($savePath, 0777, true); + $writable = is_writable($savePath); + if($writable) session_save_path($this->getTmpRoot() . 'session'); + } + + if($writable) + { + $ztSessionHandler = new ztSessionHandler($_GET['tid']); + session_set_save_handler( + array($ztSessionHandler, "open"), + array($ztSessionHandler, "close"), + array($ztSessionHandler, "read"), + array($ztSessionHandler, "write"), + array($ztSessionHandler, "destroy"), + array($ztSessionHandler, "gc") + ); + } + } + /* If request header has token, use it as session for authentication. */ if(isset($_SERVER['HTTP_TOKEN'])) session_id($_SERVER['HTTP_TOKEN']); @@ -973,9 +999,13 @@ class baseRouter if($this->config->customSession) session_save_path($this->getTmpRoot() . 'session'); session_start(); - $this->sessionID = session_id(); + $this->sessionID = isset($ztSessionHandler) ? $ztSessionHandler->getSessionID() : session_id(); - if(isset($_GET[$this->config->sessionVar])) helper::restartSession($_GET[$this->config->sessionVar]); + if(isset($_GET[$this->config->sessionVar])) + { + helper::restartSession($_GET[$this->config->sessionVar]); + $this->sessionID = isset($ztSessionHandler) ? $ztSessionHandler->getSessionID() : session_id(); + } define('SESSION_STARTED', true); } @@ -2168,6 +2198,7 @@ class baseRouter /* Remove these three params. */ unset($passedParams['onlybody']); + unset($passedParams['tid']); unset($passedParams['HTTP_X_REQUESTED_WITH']); /* Check params from URL. */ @@ -3038,3 +3069,171 @@ class EndResponseException extends \Exception return $this->content; } } + +/** + * ZenTao session handler. + * + * @package framework + */ +class ztSessionHandler +{ + public $sessSavePath; + public $tagID; + public $sessionFile; + public $sessionID; + public $rawID; + public $rawFile; + + /** + * Construct. + * + * @param string $tagID + * @access public + * @return void + */ + public function __construct($tagID = '') + { + $this->tagID = $tagID; + ini_set('session.save_handler', 'files'); + } + + /** + * Get sessionID + * + * @access public + * @return string + */ + public function getSessionID() + { + return $this->sessionID; + } + + /** + * Get session file. + * + * @param string $id + * @access public + * @return string + */ + public function getSessionFile($id) + { + if(!empty($this->sessionFile)) return $this->sessionFile; + + $sessionID = $id; + if($this->tagID) $sessionID = md5($id . $this->tagID); + + $fileName = "sess_$sessionID"; + + $this->sessionFile = $this->sessSavePath . '/' . $fileName; + $this->sessionID = $sessionID; + $this->rawID = $id; + $this->rawFile = $this->sessSavePath . '/' . "sess_$id"; + return $this->sessionFile; + } + + /** + * Open + * + * @param string $savePath + * @param string $sessionName + * @access public + * @return bool + */ + public function open($savePath, $sessionName) + { + $this->sessSavePath = $savePath; + return true; + } + + /** + * Close + * + * @access public + * @return bool + */ + public function close() + { + return true; + } + + /** + * Read + * + * @param string $id + * @access public + * @return bool + */ + public function read($id) + { + $sessFile = $this->getSessionFile($id); + if(!file_exists($sessFile)) + { + ($this->tagID and file_exists($this->rawFile)) ? copy($this->rawFile, $sessFile) : touch($sessFile); + } + + return (string) file_get_contents($sessFile); + } + + /** + * Write + * + * @param string $id + * @param string $sessData + * @access public + * @return bool + */ + public function write($id, $sessData) + { + $sessFile = $this->getSessionFile($id); + if(file_put_contents($sessFile, $sessData)) + { + if(strpos($sessData, 'user|') !== false) + { + if(file_exists($this->rawFile)) + { + $rawSessContent = (string) file_get_contents($this->rawFile); + if(strpos($rawSessContent, 'user|') === false) copy($sessFile, $this->rawFile); + } + else + { + copy($sessFile, $this->rawFile); + } + } + + return true; + } + return false; + } + + /** + * Destroy + * + * @param string $id + * @access public + * @return bool + */ + public function destroy($id) + { + $sessFile = $this->getSessionFile($id); + @unlink($sessFile); + touch($sessFile); + return true; + } + + /** + * GC + * + * @param int $maxlifeTime + * @access public + * @return bool + */ + public function gc($maxlifeTime) + { + $time = time(); + foreach(glob("$this->sessSavePath/sess_*") as $fileName) + { + if(filemtime($fileName) + $maxlifeTime < $time) @unlink($fileName); + } + return true; + } +} diff --git a/framework/router.class.php b/framework/router.class.php index 3fb5136cfa..d84eea29fb 100755 --- a/framework/router.class.php +++ b/framework/router.class.php @@ -584,13 +584,15 @@ class router extends baseRouter */ public function getURI($full = false) { - $URI = !empty($this->rawURI) ? $this->rawURI : $this->URI; + $URI = !empty($this->rawURI) ? $this->rawURI : $this->URI; + $tidParam = ($this->config->requestType == 'PATH_INFO' and helper::isWithTID()) ? "?tid={$_GET['tid']}" : ''; + if($full and $this->config->requestType == 'PATH_INFO') { - if($URI) return $this->config->webRoot . $URI . '.' . $this->viewType; - return $this->config->webRoot; + if($URI) return $this->config->webRoot . $URI . '.' . $this->viewType . $tidParam; + return $this->config->webRoot . $tidParam; } - return $URI; + return $URI . $tidParam; } /** diff --git a/lib/base/filter/filter.class.php b/lib/base/filter/filter.class.php index 8db2523369..fda784c2a2 100644 --- a/lib/base/filter/filter.class.php +++ b/lib/base/filter/filter.class.php @@ -661,11 +661,11 @@ class baseValidater global $config; if(empty($config->framework->filterXSS)) return $var; - if(stripos($var, 'cookieLife, $config->webRoot, '', $config->cookieSecure, false); } - $dataApp = array_search($gobackLink, $gobackList) ? array_search($gobackLink, $gobackList) : ''; - $dataApp = empty($dataApp) ? '' : "data-app='$dataApp'"; - - return "{$label}"; + return "{$label}"; } /** @@ -1168,6 +1165,9 @@ EOT; $jsConfig->runMode = $runMode; $jsConfig->timeout = isset($config->timeout) ? $config->timeout : ''; $jsConfig->pingInterval = isset($config->pingInterval) ? $config->pingInterval : ''; + $jsConfig->onlybody = zget($_GET, 'onlybody', 'no'); + $jsConfig->tabSession = $config->tabSession; + if($config->tabSession and helper::isWithTID()) $jsConfig->tid = zget($_GET, 'tid', ''); $jsLang = new stdclass(); $jsLang->submitting = isset($lang->loading) ? $lang->loading : ''; diff --git a/lib/dao/dao.class.php b/lib/dao/dao.class.php index 38145ee250..537484e1a5 100644 --- a/lib/dao/dao.class.php +++ b/lib/dao/dao.class.php @@ -113,6 +113,7 @@ class dao extends baseDAO $app->loadLang('workflowfield'); $app->loadConfig('flow'); $app->loadConfig('workflowfield'); + foreach($fields as $field) { if(isset($data->{$field->field})) diff --git a/lib/michelf/Michelf/Markdown.inc.php b/lib/michelf/Michelf/Markdown.inc.php new file mode 100644 index 0000000000..e2bd3808e8 --- /dev/null +++ b/lib/michelf/Michelf/Markdown.inc.php @@ -0,0 +1,10 @@ + + * @copyright 2004-2019 Michel Fortin + * @copyright (Original Markdown) 2004-2006 John Gruber + */ + +namespace Michelf; + +/** + * Markdown Parser Class + */ +class Markdown implements MarkdownInterface { + /** + * Define the package version + * @var string + */ + const MARKDOWNLIB_VERSION = "2.0"; + + /** + * Simple function interface - Initialize the parser and return the result + * of its transform method. This will work fine for derived classes too. + * + * @api + * + * @param string $text + * @return string + */ + public static function defaultTransform($text) { + // Take parser class on which this function was called. + $parser_class = static::class; + + // Try to take parser from the static parser list + static $parser_list; + $parser =& $parser_list[$parser_class]; + + // Create the parser it not already set + if (!$parser) { + $parser = new $parser_class; + } + + // Transform text using parser. + return $parser->transform($text); + } + + /** + * Configuration variables + */ + /** + * Change to ">" for HTML output. + */ + public $empty_element_suffix = " />"; + + /** + * The width of indentation of the output markup + */ + public $tab_width = 4; + + /** + * Change to `true` to disallow markup or entities. + */ + public $no_markup = false; + public $no_entities = false; + + + /** + * Change to `true` to enable line breaks on \n without two trailling spaces + * @var boolean + */ + public $hard_wrap = false; + + /** + * Predefined URLs and titles for reference links and images. + */ + public $predef_urls = array(); + public $predef_titles = array(); + + /** + * Optional filter function for URLs + * @var callable|null + */ + public $url_filter_func = null; + + /** + * Optional header id="" generation callback function. + * @var callable|null + */ + public $header_id_func = null; + + /** + * Optional function for converting code block content to HTML + * @var callable|null + */ + public $code_block_content_func = null; + + /** + * Optional function for converting code span content to HTML. + * @var callable|null + */ + public $code_span_content_func = null; + + /** + * Class attribute to toggle "enhanced ordered list" behaviour + * setting this to true will allow ordered lists to start from the index + * number that is defined first. + * + * For example: + * 2. List item two + * 3. List item three + * + * Becomes: + *
    + *
  1. List item two
  2. + *
  3. List item three
  4. + *
+ */ + public $enhanced_ordered_list = false; + + /** + * Parser implementation + */ + /** + * Regex to match balanced [brackets]. + * Needed to insert a maximum bracked depth while converting to PHP. + */ + protected $nested_brackets_depth = 6; + protected $nested_brackets_re; + + protected $nested_url_parenthesis_depth = 4; + protected $nested_url_parenthesis_re; + + /** + * Table of hash values for escaped characters: + */ + protected $escape_chars = '\`*_{}[]()>#+-.!'; + protected $escape_chars_re; + + /** + * Constructor function. Initialize appropriate member variables. + * @return void + */ + public function __construct() { + $this->_initDetab(); + $this->prepareItalicsAndBold(); + + $this->nested_brackets_re = + str_repeat('(?>[^\[\]]+|\[', $this->nested_brackets_depth). + str_repeat('\])*', $this->nested_brackets_depth); + + $this->nested_url_parenthesis_re = + str_repeat('(?>[^()\s]+|\(', $this->nested_url_parenthesis_depth). + str_repeat('(?>\)))*', $this->nested_url_parenthesis_depth); + + $this->escape_chars_re = '['.preg_quote($this->escape_chars).']'; + + // Sort document, block, and span gamut in ascendent priority order. + asort($this->document_gamut); + asort($this->block_gamut); + asort($this->span_gamut); + } + + + /** + * Internal hashes used during transformation. + */ + protected $urls = array(); + protected $titles = array(); + protected $html_hashes = array(); + + /** + * Status flag to avoid invalid nesting. + */ + protected $in_anchor = false; + + /** + * Status flag to avoid invalid nesting. + */ + protected $in_emphasis_processing = false; + + /** + * Called before the transformation process starts to setup parser states. + * @return void + */ + protected function setup() { + // Clear global hashes. + $this->urls = $this->predef_urls; + $this->titles = $this->predef_titles; + $this->html_hashes = array(); + $this->in_anchor = false; + $this->in_emphasis_processing = false; + } + + /** + * Called after the transformation process to clear any variable which may + * be taking up memory unnecessarly. + * @return void + */ + protected function teardown() { + $this->urls = array(); + $this->titles = array(); + $this->html_hashes = array(); + } + + /** + * Main function. Performs some preprocessing on the input text and pass + * it through the document gamut. + * + * @api + * + * @param string $text + * @return string + */ + public function transform($text) { + $this->setup(); + + # Remove UTF-8 BOM and marker character in input, if present. + $text = preg_replace('{^\xEF\xBB\xBF|\x1A}', '', $text); + + # Standardize line endings: + # DOS to Unix and Mac to Unix + $text = preg_replace('{\r\n?}', "\n", $text); + + # Make sure $text ends with a couple of newlines: + $text .= "\n\n"; + + # Convert all tabs to spaces. + $text = $this->detab($text); + + # Turn block-level HTML blocks into hash entries + $text = $this->hashHTMLBlocks($text); + + # Strip any lines consisting only of spaces and tabs. + # This makes subsequent regexen easier to write, because we can + # match consecutive blank lines with /\n+/ instead of something + # contorted like /[ ]*\n+/ . + $text = preg_replace('/^[ ]+$/m', '', $text); + + # Run document gamut methods. + foreach ($this->document_gamut as $method => $priority) { + $text = $this->$method($text); + } + + $this->teardown(); + + return $text . "\n"; + } + + /** + * Define the document gamut + */ + protected $document_gamut = array( + // Strip link definitions, store in hashes. + "stripLinkDefinitions" => 20, + "runBasicBlockGamut" => 30, + ); + + /** + * Strips link definitions from text, stores the URLs and titles in + * hash references + * @param string $text + * @return string + */ + protected function stripLinkDefinitions($text) { + + $less_than_tab = $this->tab_width - 1; + + // Link defs are in the form: ^[id]: url "optional title" + $text = preg_replace_callback('{ + ^[ ]{0,'.$less_than_tab.'}\[(.+)\][ ]?: # id = $1 + [ ]* + \n? # maybe *one* newline + [ ]* + (?: + <(.+?)> # url = $2 + | + (\S+?) # url = $3 + ) + [ ]* + \n? # maybe one newline + [ ]* + (?: + (?<=\s) # lookbehind for whitespace + ["(] + (.*?) # title = $4 + [")] + [ ]* + )? # title is optional + (?:\n+|\Z) + }xm', + array($this, '_stripLinkDefinitions_callback'), + $text + ); + return $text; + } + + /** + * The callback to strip link definitions + * @param array $matches + * @return string + */ + protected function _stripLinkDefinitions_callback($matches) { + $link_id = strtolower($matches[1]); + $url = $matches[2] == '' ? $matches[3] : $matches[2]; + $this->urls[$link_id] = $url; + $this->titles[$link_id] =& $matches[4]; + return ''; // String that will replace the block + } + + /** + * Hashify HTML blocks + * @param string $text + * @return string + */ + protected function hashHTMLBlocks($text) { + if ($this->no_markup) { + return $text; + } + + $less_than_tab = $this->tab_width - 1; + + /** + * Hashify HTML blocks: + * + * We only want to do this for block-level HTML tags, such as headers, + * lists, and tables. That's because we still want to wrap

s around + * "paragraphs" that are wrapped in non-block-level tags, such as + * anchors, phrase emphasis, and spans. The list of tags we're looking + * for is hard-coded: + * + * * List "a" is made of tags which can be both inline or block-level. + * These will be treated block-level when the start tag is alone on + * its line, otherwise they're not matched here and will be taken as + * inline later. + * * List "b" is made of tags which are always block-level; + */ + $block_tags_a_re = 'ins|del'; + $block_tags_b_re = 'p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|address|'. + 'script|noscript|style|form|fieldset|iframe|math|svg|'. + 'article|section|nav|aside|hgroup|header|footer|'. + 'figure|details|summary'; + + // Regular expression for the content of a block tag. + $nested_tags_level = 4; + $attr = ' + (?> # optional tag attributes + \s # starts with whitespace + (?> + [^>"/]+ # text outside quotes + | + /+(?!>) # slash not followed by ">" + | + "[^"]*" # text inside double quotes (tolerate ">") + | + \'[^\']*\' # text inside single quotes (tolerate ">") + )* + )? + '; + $content = + str_repeat(' + (?> + [^<]+ # content without tag + | + <\2 # nested opening tag + '.$attr.' # attributes + (?> + /> + | + >', $nested_tags_level). // end of opening tag + '.*?'. // last level nested tag content + str_repeat(' + # closing nested tag + ) + | + <(?!/\2\s*> # other tags with a different name + ) + )*', + $nested_tags_level); + $content2 = str_replace('\2', '\3', $content); + + /** + * First, look for nested blocks, e.g.: + *

+ *
+ * tags for inner block must be indented. + *
+ *
+ * + * The outermost tags must start at the left margin for this to match, + * and the inner nested divs must be indented. + * We need to do this before the next, more liberal match, because the + * next match will start at the first `
` and stop at the + * first `
`. + */ + $text = preg_replace_callback('{(?> + (?> + (?<=\n) # Starting on its own line + | # or + \A\n? # the at beginning of the doc + ) + ( # save in $1 + + # Match from `\n` to `\n`, handling nested tags + # in between. + + [ ]{0,'.$less_than_tab.'} + <('.$block_tags_b_re.')# start tag = $2 + '.$attr.'> # attributes followed by > and \n + '.$content.' # content, support nesting + # the matching end tag + [ ]* # trailing spaces/tabs + (?=\n+|\Z) # followed by a newline or end of document + + | # Special version for tags of group a. + + [ ]{0,'.$less_than_tab.'} + <('.$block_tags_a_re.')# start tag = $3 + '.$attr.'>[ ]*\n # attributes followed by > + '.$content2.' # content, support nesting + # the matching end tag + [ ]* # trailing spaces/tabs + (?=\n+|\Z) # followed by a newline or end of document + + | # Special case just for
. It was easier to make a special + # case than to make the other regex more complicated. + + [ ]{0,'.$less_than_tab.'} + <(hr) # start tag = $2 + '.$attr.' # attributes + /?> # the matching end tag + [ ]* + (?=\n{2,}|\Z) # followed by a blank line or end of document + + | # Special case for standalone HTML comments: + + [ ]{0,'.$less_than_tab.'} + (?s: + + ) + [ ]* + (?=\n{2,}|\Z) # followed by a blank line or end of document + + | # PHP and ASP-style processor instructions ( + ) + [ ]* + (?=\n{2,}|\Z) # followed by a blank line or end of document + + ) + )}Sxmi', + array($this, '_hashHTMLBlocks_callback'), + $text + ); + + return $text; + } + + /** + * The callback for hashing HTML blocks + * @param string $matches + * @return string + */ + protected function _hashHTMLBlocks_callback($matches) { + $text = $matches[1]; + $key = $this->hashBlock($text); + return "\n\n$key\n\n"; + } + + /** + * Called whenever a tag must be hashed when a function insert an atomic + * element in the text stream. Passing $text to through this function gives + * a unique text-token which will be reverted back when calling unhash. + * + * The $boundary argument specify what character should be used to surround + * the token. By convension, "B" is used for block elements that needs not + * to be wrapped into paragraph tags at the end, ":" is used for elements + * that are word separators and "X" is used in the general case. + * + * @param string $text + * @param string $boundary + * @return string + */ + protected function hashPart($text, $boundary = 'X') { + // Swap back any tag hash found in $text so we do not have to `unhash` + // multiple times at the end. + $text = $this->unhash($text); + + // Then hash the block. + static $i = 0; + $key = "$boundary\x1A" . ++$i . $boundary; + $this->html_hashes[$key] = $text; + return $key; // String that will replace the tag. + } + + /** + * Shortcut function for hashPart with block-level boundaries. + * @param string $text + * @return string + */ + protected function hashBlock($text) { + return $this->hashPart($text, 'B'); + } + + /** + * Define the block gamut - these are all the transformations that form + * block-level tags like paragraphs, headers, and list items. + */ + protected $block_gamut = array( + "doHeaders" => 10, + "doHorizontalRules" => 20, + "doLists" => 40, + "doCodeBlocks" => 50, + "doBlockQuotes" => 60, + ); + + /** + * Run block gamut tranformations. + * + * We need to escape raw HTML in Markdown source before doing anything + * else. This need to be done for each block, and not only at the + * begining in the Markdown function since hashed blocks can be part of + * list items and could have been indented. Indented blocks would have + * been seen as a code block in a previous pass of hashHTMLBlocks. + * + * @param string $text + * @return string + */ + protected function runBlockGamut($text) { + $text = $this->hashHTMLBlocks($text); + return $this->runBasicBlockGamut($text); + } + + /** + * Run block gamut tranformations, without hashing HTML blocks. This is + * useful when HTML blocks are known to be already hashed, like in the first + * whole-document pass. + * + * @param string $text + * @return string + */ + protected function runBasicBlockGamut($text) { + + foreach ($this->block_gamut as $method => $priority) { + $text = $this->$method($text); + } + + // Finally form paragraph and restore hashed blocks. + $text = $this->formParagraphs($text); + + return $text; + } + + /** + * Convert horizontal rules + * @param string $text + * @return string + */ + protected function doHorizontalRules($text) { + return preg_replace( + '{ + ^[ ]{0,3} # Leading space + ([-*_]) # $1: First marker + (?> # Repeated marker group + [ ]{0,2} # Zero, one, or two spaces. + \1 # Marker character + ){2,} # Group repeated at least twice + [ ]* # Tailing spaces + $ # End of line. + }mx', + "\n".$this->hashBlock("empty_element_suffix")."\n", + $text + ); + } + + /** + * These are all the transformations that occur *within* block-level + * tags like paragraphs, headers, and list items. + */ + protected $span_gamut = array( + // Process character escapes, code spans, and inline HTML + // in one shot. + "parseSpan" => -30, + // Process anchor and image tags. Images must come first, + // because ![foo][f] looks like an anchor. + "doImages" => 10, + "doAnchors" => 20, + // Make links out of things like `` + // Must come after doAnchors, because you can use < and > + // delimiters in inline links like [this](). + "doAutoLinks" => 30, + "encodeAmpsAndAngles" => 40, + "doItalicsAndBold" => 50, + "doHardBreaks" => 60, + ); + + /** + * Run span gamut transformations + * @param string $text + * @return string + */ + protected function runSpanGamut($text) { + foreach ($this->span_gamut as $method => $priority) { + $text = $this->$method($text); + } + + return $text; + } + + /** + * Do hard breaks + * @param string $text + * @return string + */ + protected function doHardBreaks($text) { + if ($this->hard_wrap) { + return preg_replace_callback('/ *\n/', + array($this, '_doHardBreaks_callback'), $text); + } else { + return preg_replace_callback('/ {2,}\n/', + array($this, '_doHardBreaks_callback'), $text); + } + } + + /** + * Trigger part hashing for the hard break (callback method) + * @param array $matches + * @return string + */ + protected function _doHardBreaks_callback($matches) { + return $this->hashPart("empty_element_suffix\n"); + } + + /** + * Turn Markdown link shortcuts into XHTML tags. + * @param string $text + * @return string + */ + protected function doAnchors($text) { + if ($this->in_anchor) { + return $text; + } + $this->in_anchor = true; + + // First, handle reference-style links: [link text] [id] + $text = preg_replace_callback('{ + ( # wrap whole match in $1 + \[ + ('.$this->nested_brackets_re.') # link text = $2 + \] + + [ ]? # one optional space + (?:\n[ ]*)? # one optional newline followed by spaces + + \[ + (.*?) # id = $3 + \] + ) + }xs', + array($this, '_doAnchors_reference_callback'), $text); + + // Next, inline-style links: [link text](url "optional title") + $text = preg_replace_callback('{ + ( # wrap whole match in $1 + \[ + ('.$this->nested_brackets_re.') # link text = $2 + \] + \( # literal paren + [ \n]* + (?: + <(.+?)> # href = $3 + | + ('.$this->nested_url_parenthesis_re.') # href = $4 + ) + [ \n]* + ( # $5 + ([\'"]) # quote char = $6 + (.*?) # Title = $7 + \6 # matching quote + [ \n]* # ignore any spaces/tabs between closing quote and ) + )? # title is optional + \) + ) + }xs', + array($this, '_doAnchors_inline_callback'), $text); + + // Last, handle reference-style shortcuts: [link text] + // These must come last in case you've also got [link text][1] + // or [link text](/foo) + $text = preg_replace_callback('{ + ( # wrap whole match in $1 + \[ + ([^\[\]]+) # link text = $2; can\'t contain [ or ] + \] + ) + }xs', + array($this, '_doAnchors_reference_callback'), $text); + + $this->in_anchor = false; + return $text; + } + + /** + * Callback method to parse referenced anchors + * @param array $matches + * @return string + */ + protected function _doAnchors_reference_callback($matches) { + $whole_match = $matches[1]; + $link_text = $matches[2]; + $link_id =& $matches[3]; + + if ($link_id == "") { + // for shortcut links like [this][] or [this]. + $link_id = $link_text; + } + + // lower-case and turn embedded newlines into spaces + $link_id = strtolower($link_id); + $link_id = preg_replace('{[ ]?\n}', ' ', $link_id); + + if (isset($this->urls[$link_id])) { + $url = $this->urls[$link_id]; + $url = $this->encodeURLAttribute($url); + + $result = "titles[$link_id] ) ) { + $title = $this->titles[$link_id]; + $title = $this->encodeAttribute($title); + $result .= " title=\"$title\""; + } + + $link_text = $this->runSpanGamut($link_text); + $result .= ">$link_text"; + $result = $this->hashPart($result); + } else { + $result = $whole_match; + } + return $result; + } + + /** + * Callback method to parse inline anchors + * @param array $matches + * @return string + */ + protected function _doAnchors_inline_callback($matches) { + $link_text = $this->runSpanGamut($matches[2]); + $url = $matches[3] === '' ? $matches[4] : $matches[3]; + $title =& $matches[7]; + + // If the URL was of the form it got caught by the HTML + // tag parser and hashed. Need to reverse the process before using + // the URL. + $unhashed = $this->unhash($url); + if ($unhashed !== $url) + $url = preg_replace('/^<(.*)>$/', '\1', $unhashed); + + $url = $this->encodeURLAttribute($url); + + $result = "encodeAttribute($title); + $result .= " title=\"$title\""; + } + + $link_text = $this->runSpanGamut($link_text); + $result .= ">$link_text"; + + return $this->hashPart($result); + } + + /** + * Turn Markdown image shortcuts into tags. + * @param string $text + * @return string + */ + protected function doImages($text) { + // First, handle reference-style labeled images: ![alt text][id] + $text = preg_replace_callback('{ + ( # wrap whole match in $1 + !\[ + ('.$this->nested_brackets_re.') # alt text = $2 + \] + + [ ]? # one optional space + (?:\n[ ]*)? # one optional newline followed by spaces + + \[ + (.*?) # id = $3 + \] + + ) + }xs', + array($this, '_doImages_reference_callback'), $text); + + // Next, handle inline images: ![alt text](url "optional title") + // Don't forget: encode * and _ + $text = preg_replace_callback('{ + ( # wrap whole match in $1 + !\[ + ('.$this->nested_brackets_re.') # alt text = $2 + \] + \s? # One optional whitespace character + \( # literal paren + [ \n]* + (?: + <(\S*)> # src url = $3 + | + ('.$this->nested_url_parenthesis_re.') # src url = $4 + ) + [ \n]* + ( # $5 + ([\'"]) # quote char = $6 + (.*?) # title = $7 + \6 # matching quote + [ \n]* + )? # title is optional + \) + ) + }xs', + array($this, '_doImages_inline_callback'), $text); + + return $text; + } + + /** + * Callback to parse references image tags + * @param array $matches + * @return string + */ + protected function _doImages_reference_callback($matches) { + $whole_match = $matches[1]; + $alt_text = $matches[2]; + $link_id = strtolower($matches[3]); + + if ($link_id == "") { + $link_id = strtolower($alt_text); // for shortcut links like ![this][]. + } + + $alt_text = $this->encodeAttribute($alt_text); + if (isset($this->urls[$link_id])) { + $url = $this->encodeURLAttribute($this->urls[$link_id]); + $result = "\"$alt_text\"";titles[$link_id])) { + $title = $this->titles[$link_id]; + $title = $this->encodeAttribute($title); + $result .= " title=\"$title\""; + } + $result .= $this->empty_element_suffix; + $result = $this->hashPart($result); + } else { + // If there's no such link ID, leave intact: + $result = $whole_match; + } + + return $result; + } + + /** + * Callback to parse inline image tags + * @param array $matches + * @return string + */ + protected function _doImages_inline_callback($matches) { + $whole_match = $matches[1]; + $alt_text = $matches[2]; + $url = $matches[3] == '' ? $matches[4] : $matches[3]; + $title =& $matches[7]; + + $alt_text = $this->encodeAttribute($alt_text); + $url = $this->encodeURLAttribute($url); + $result = "\"$alt_text\"";encodeAttribute($title); + $result .= " title=\"$title\""; // $title already quoted + } + $result .= $this->empty_element_suffix; + + return $this->hashPart($result); + } + + /** + * Parse Markdown heading elements to HTML + * @param string $text + * @return string + */ + protected function doHeaders($text) { + /** + * Setext-style headers: + * Header 1 + * ======== + * + * Header 2 + * -------- + */ + $text = preg_replace_callback('{ ^(.+?)[ ]*\n(=+|-+)[ ]*\n+ }mx', + array($this, '_doHeaders_callback_setext'), $text); + + /** + * atx-style headers: + * # Header 1 + * ## Header 2 + * ## Header 2 with closing hashes ## + * ... + * ###### Header 6 + */ + $text = preg_replace_callback('{ + ^(\#{1,6}) # $1 = string of #\'s + [ ]* + (.+?) # $2 = Header text + [ ]* + \#* # optional closing #\'s (not counted) + \n+ + }xm', + array($this, '_doHeaders_callback_atx'), $text); + + return $text; + } + + /** + * Setext header parsing callback + * @param array $matches + * @return string + */ + protected function _doHeaders_callback_setext($matches) { + // Terrible hack to check we haven't found an empty list item. + if ($matches[2] == '-' && preg_match('{^-(?: |$)}', $matches[1])) { + return $matches[0]; + } + + $level = $matches[2][0] == '=' ? 1 : 2; + + // ID attribute generation + $idAtt = $this->_generateIdFromHeaderValue($matches[1]); + + $block = "".$this->runSpanGamut($matches[1]).""; + return "\n" . $this->hashBlock($block) . "\n\n"; + } + + /** + * ATX header parsing callback + * @param array $matches + * @return string + */ + protected function _doHeaders_callback_atx($matches) { + // ID attribute generation + $idAtt = $this->_generateIdFromHeaderValue($matches[2]); + + $level = strlen($matches[1]); + $block = "".$this->runSpanGamut($matches[2]).""; + return "\n" . $this->hashBlock($block) . "\n\n"; + } + + /** + * If a header_id_func property is set, we can use it to automatically + * generate an id attribute. + * + * This method returns a string in the form id="foo", or an empty string + * otherwise. + * @param string $headerValue + * @return string + */ + protected function _generateIdFromHeaderValue($headerValue) { + if (!is_callable($this->header_id_func)) { + return ""; + } + + $idValue = call_user_func($this->header_id_func, $headerValue); + if (!$idValue) { + return ""; + } + + return ' id="' . $this->encodeAttribute($idValue) . '"'; + } + + /** + * Form HTML ordered (numbered) and unordered (bulleted) lists. + * @param string $text + * @return string + */ + protected function doLists($text) { + $less_than_tab = $this->tab_width - 1; + + // Re-usable patterns to match list item bullets and number markers: + $marker_ul_re = '[*+-]'; + $marker_ol_re = '\d+[\.]'; + + $markers_relist = array( + $marker_ul_re => $marker_ol_re, + $marker_ol_re => $marker_ul_re, + ); + + foreach ($markers_relist as $marker_re => $other_marker_re) { + // Re-usable pattern to match any entirel ul or ol list: + $whole_list_re = ' + ( # $1 = whole list + ( # $2 + ([ ]{0,'.$less_than_tab.'}) # $3 = number of spaces + ('.$marker_re.') # $4 = first list item marker + [ ]+ + ) + (?s:.+?) + ( # $5 + \z + | + \n{2,} + (?=\S) + (?! # Negative lookahead for another list item marker + [ ]* + '.$marker_re.'[ ]+ + ) + | + (?= # Lookahead for another kind of list + \n + \3 # Must have the same indentation + '.$other_marker_re.'[ ]+ + ) + ) + ) + '; // mx + + // We use a different prefix before nested lists than top-level lists. + //See extended comment in _ProcessListItems(). + + if ($this->list_level) { + $text = preg_replace_callback('{ + ^ + '.$whole_list_re.' + }mx', + array($this, '_doLists_callback'), $text); + } else { + $text = preg_replace_callback('{ + (?:(?<=\n)\n|\A\n?) # Must eat the newline + '.$whole_list_re.' + }mx', + array($this, '_doLists_callback'), $text); + } + } + + return $text; + } + + /** + * List parsing callback + * @param array $matches + * @return string + */ + protected function _doLists_callback($matches) { + // Re-usable patterns to match list item bullets and number markers: + $marker_ul_re = '[*+-]'; + $marker_ol_re = '\d+[\.]'; + $marker_any_re = "(?:$marker_ul_re|$marker_ol_re)"; + $marker_ol_start_re = '[0-9]+'; + + $list = $matches[1]; + $list_type = preg_match("/$marker_ul_re/", $matches[4]) ? "ul" : "ol"; + + $marker_any_re = ( $list_type == "ul" ? $marker_ul_re : $marker_ol_re ); + + $list .= "\n"; + $result = $this->processListItems($list, $marker_any_re); + + $ol_start = 1; + if ($this->enhanced_ordered_list) { + // Get the start number for ordered list. + if ($list_type == 'ol') { + $ol_start_array = array(); + $ol_start_check = preg_match("/$marker_ol_start_re/", $matches[4], $ol_start_array); + if ($ol_start_check){ + $ol_start = $ol_start_array[0]; + } + } + } + + if ($ol_start > 1 && $list_type == 'ol'){ + $result = $this->hashBlock("<$list_type start=\"$ol_start\">\n" . $result . ""); + } else { + $result = $this->hashBlock("<$list_type>\n" . $result . ""); + } + return "\n". $result ."\n\n"; + } + + /** + * Nesting tracker for list levels + */ + protected $list_level = 0; + + /** + * Process the contents of a single ordered or unordered list, splitting it + * into individual list items. + * @param string $list_str + * @param string $marker_any_re + * @return string + */ + protected function processListItems($list_str, $marker_any_re) { + /** + * The $this->list_level global keeps track of when we're inside a list. + * Each time we enter a list, we increment it; when we leave a list, + * we decrement. If it's zero, we're not in a list anymore. + * + * We do this because when we're not inside a list, we want to treat + * something like this: + * + * I recommend upgrading to version + * 8. Oops, now this line is treated + * as a sub-list. + * + * As a single paragraph, despite the fact that the second line starts + * with a digit-period-space sequence. + * + * Whereas when we're inside a list (or sub-list), that line will be + * treated as the start of a sub-list. What a kludge, huh? This is + * an aspect of Markdown's syntax that's hard to parse perfectly + * without resorting to mind-reading. Perhaps the solution is to + * change the syntax rules such that sub-lists must start with a + * starting cardinal number; e.g. "1." or "a.". + */ + $this->list_level++; + + // Trim trailing blank lines: + $list_str = preg_replace("/\n{2,}\\z/", "\n", $list_str); + + $list_str = preg_replace_callback('{ + (\n)? # leading line = $1 + (^[ ]*) # leading whitespace = $2 + ('.$marker_any_re.' # list marker and space = $3 + (?:[ ]+|(?=\n)) # space only required if item is not empty + ) + ((?s:.*?)) # list item text = $4 + (?:(\n+(?=\n))|\n) # tailing blank line = $5 + (?= \n* (\z | \2 ('.$marker_any_re.') (?:[ ]+|(?=\n)))) + }xm', + array($this, '_processListItems_callback'), $list_str); + + $this->list_level--; + return $list_str; + } + + /** + * List item parsing callback + * @param array $matches + * @return string + */ + protected function _processListItems_callback($matches) { + $item = $matches[4]; + $leading_line =& $matches[1]; + $leading_space =& $matches[2]; + $marker_space = $matches[3]; + $tailing_blank_line =& $matches[5]; + + if ($leading_line || $tailing_blank_line || + preg_match('/\n{2,}/', $item)) + { + // Replace marker with the appropriate whitespace indentation + $item = $leading_space . str_repeat(' ', strlen($marker_space)) . $item; + $item = $this->runBlockGamut($this->outdent($item)."\n"); + } else { + // Recursion for sub-lists: + $item = $this->doLists($this->outdent($item)); + $item = $this->formParagraphs($item, false); + } + + return "
  • " . $item . "
  • \n"; + } + + /** + * Process Markdown `
    ` blocks.
    +	 * @param  string $text
    +	 * @return string
    +	 */
    +	protected function doCodeBlocks($text) {
    +		$text = preg_replace_callback('{
    +				(?:\n\n|\A\n?)
    +				(	            # $1 = the code block -- one or more lines, starting with a space/tab
    +				  (?>
    +					[ ]{'.$this->tab_width.'}  # Lines must start with a tab or a tab-width of spaces
    +					.*\n+
    +				  )+
    +				)
    +				((?=^[ ]{0,'.$this->tab_width.'}\S)|\Z)	# Lookahead for non-space at line-start, or end of doc
    +			}xm',
    +			array($this, '_doCodeBlocks_callback'), $text);
    +
    +		return $text;
    +	}
    +
    +	/**
    +	 * Code block parsing callback
    +	 * @param  array $matches
    +	 * @return string
    +	 */
    +	protected function _doCodeBlocks_callback($matches) {
    +		$codeblock = $matches[1];
    +
    +		$codeblock = $this->outdent($codeblock);
    +		if (is_callable($this->code_block_content_func)) {
    +			$codeblock = call_user_func($this->code_block_content_func, $codeblock, "");
    +		} else {
    +			$codeblock = htmlspecialchars($codeblock, ENT_NOQUOTES);
    +		}
    +
    +		# trim leading newlines and trailing newlines
    +		$codeblock = preg_replace('/\A\n+|\n+\z/', '', $codeblock);
    +
    +		$codeblock = "
    $codeblock\n
    "; + return "\n\n" . $this->hashBlock($codeblock) . "\n\n"; + } + + /** + * Create a code span markup for $code. Called from handleSpanToken. + * @param string $code + * @return string + */ + protected function makeCodeSpan($code) { + if (is_callable($this->code_span_content_func)) { + $code = call_user_func($this->code_span_content_func, $code); + } else { + $code = htmlspecialchars(trim($code), ENT_NOQUOTES); + } + return $this->hashPart("$code"); + } + + /** + * Define the emphasis operators with their regex matches + * @var array + */ + protected $em_relist = array( + '' => '(?:(? '(? '(? '(?:(? '(? '(? '(?:(? '(? '(?em_relist as $em => $em_re) { + foreach ($this->strong_relist as $strong => $strong_re) { + // Construct list of allowed token expressions. + $token_relist = array(); + if (isset($this->em_strong_relist["$em$strong"])) { + $token_relist[] = $this->em_strong_relist["$em$strong"]; + } + $token_relist[] = $em_re; + $token_relist[] = $strong_re; + + // Construct master expression from list. + $token_re = '{(' . implode('|', $token_relist) . ')}'; + $this->em_strong_prepared_relist["$em$strong"] = $token_re; + } + } + } + + /** + * Convert Markdown italics (emphasis) and bold (strong) to HTML + * @param string $text + * @return string + */ + protected function doItalicsAndBold($text) { + if ($this->in_emphasis_processing) { + return $text; // avoid reentrency + } + $this->in_emphasis_processing = true; + + $token_stack = array(''); + $text_stack = array(''); + $em = ''; + $strong = ''; + $tree_char_em = false; + + while (1) { + // Get prepared regular expression for seraching emphasis tokens + // in current context. + $token_re = $this->em_strong_prepared_relist["$em$strong"]; + + // Each loop iteration search for the next emphasis token. + // Each token is then passed to handleSpanToken. + $parts = preg_split($token_re, $text, 2, PREG_SPLIT_DELIM_CAPTURE); + $text_stack[0] .= $parts[0]; + $token =& $parts[1]; + $text =& $parts[2]; + + if (empty($token)) { + // Reached end of text span: empty stack without emitting. + // any more emphasis. + while ($token_stack[0]) { + $text_stack[1] .= array_shift($token_stack); + $text_stack[0] .= array_shift($text_stack); + } + break; + } + + $token_len = strlen($token); + if ($tree_char_em) { + // Reached closing marker while inside a three-char emphasis. + if ($token_len == 3) { + // Three-char closing marker, close em and strong. + array_shift($token_stack); + $span = array_shift($text_stack); + $span = $this->runSpanGamut($span); + $span = "$span"; + $text_stack[0] .= $this->hashPart($span); + $em = ''; + $strong = ''; + } else { + // Other closing marker: close one em or strong and + // change current token state to match the other + $token_stack[0] = str_repeat($token[0], 3-$token_len); + $tag = $token_len == 2 ? "strong" : "em"; + $span = $text_stack[0]; + $span = $this->runSpanGamut($span); + $span = "<$tag>$span"; + $text_stack[0] = $this->hashPart($span); + $$tag = ''; // $$tag stands for $em or $strong + } + $tree_char_em = false; + } else if ($token_len == 3) { + if ($em) { + // Reached closing marker for both em and strong. + // Closing strong marker: + for ($i = 0; $i < 2; ++$i) { + $shifted_token = array_shift($token_stack); + $tag = strlen($shifted_token) == 2 ? "strong" : "em"; + $span = array_shift($text_stack); + $span = $this->runSpanGamut($span); + $span = "<$tag>$span"; + $text_stack[0] .= $this->hashPart($span); + $$tag = ''; // $$tag stands for $em or $strong + } + } else { + // Reached opening three-char emphasis marker. Push on token + // stack; will be handled by the special condition above. + $em = $token[0]; + $strong = "$em$em"; + array_unshift($token_stack, $token); + array_unshift($text_stack, ''); + $tree_char_em = true; + } + } else if ($token_len == 2) { + if ($strong) { + // Unwind any dangling emphasis marker: + if (strlen($token_stack[0]) == 1) { + $text_stack[1] .= array_shift($token_stack); + $text_stack[0] .= array_shift($text_stack); + $em = ''; + } + // Closing strong marker: + array_shift($token_stack); + $span = array_shift($text_stack); + $span = $this->runSpanGamut($span); + $span = "$span"; + $text_stack[0] .= $this->hashPart($span); + $strong = ''; + } else { + array_unshift($token_stack, $token); + array_unshift($text_stack, ''); + $strong = $token; + } + } else { + // Here $token_len == 1 + if ($em) { + if (strlen($token_stack[0]) == 1) { + // Closing emphasis marker: + array_shift($token_stack); + $span = array_shift($text_stack); + $span = $this->runSpanGamut($span); + $span = "$span"; + $text_stack[0] .= $this->hashPart($span); + $em = ''; + } else { + $text_stack[0] .= $token; + } + } else { + array_unshift($token_stack, $token); + array_unshift($text_stack, ''); + $em = $token; + } + } + } + $this->in_emphasis_processing = false; + return $text_stack[0]; + } + + /** + * Parse Markdown blockquotes to HTML + * @param string $text + * @return string + */ + protected function doBlockQuotes($text) { + $text = preg_replace_callback('/ + ( # Wrap whole match in $1 + (?> + ^[ ]*>[ ]? # ">" at the start of a line + .+\n # rest of the first line + (.+\n)* # subsequent consecutive lines + \n* # blanks + )+ + ) + /xm', + array($this, '_doBlockQuotes_callback'), $text); + + return $text; + } + + /** + * Blockquote parsing callback + * @param array $matches + * @return string + */ + protected function _doBlockQuotes_callback($matches) { + $bq = $matches[1]; + // trim one level of quoting - trim whitespace-only lines + $bq = preg_replace('/^[ ]*>[ ]?|^[ ]+$/m', '', $bq); + $bq = $this->runBlockGamut($bq); // recurse + + $bq = preg_replace('/^/m', " ", $bq); + // These leading spaces cause problem with
     content,
    +		// so we need to fix that:
    +		$bq = preg_replace_callback('{(\s*
    .+?
    )}sx', + array($this, '_doBlockQuotes_callback2'), $bq); + + return "\n" . $this->hashBlock("
    \n$bq\n
    ") . "\n\n"; + } + + /** + * Blockquote parsing callback + * @param array $matches + * @return string + */ + protected function _doBlockQuotes_callback2($matches) { + $pre = $matches[1]; + $pre = preg_replace('/^ /m', '', $pre); + return $pre; + } + + /** + * Parse paragraphs + * + * @param string $text String to process in paragraphs + * @param boolean $wrap_in_p Whether paragraphs should be wrapped in

    tags + * @return string + */ + protected function formParagraphs($text, $wrap_in_p = true) { + // Strip leading and trailing lines: + $text = preg_replace('/\A\n+|\n+\z/', '', $text); + + $grafs = preg_split('/\n{2,}/', $text, -1, PREG_SPLIT_NO_EMPTY); + + // Wrap

    tags and unhashify HTML blocks + foreach ($grafs as $key => $value) { + if (!preg_match('/^B\x1A[0-9]+B$/', $value)) { + // Is a paragraph. + $value = $this->runSpanGamut($value); + if ($wrap_in_p) { + $value = preg_replace('/^([ ]*)/', "

    ", $value); + $value .= "

    "; + } + $grafs[$key] = $this->unhash($value); + } else { + // Is a block. + // Modify elements of @grafs in-place... + $graf = $value; + $block = $this->html_hashes[$graf]; + $graf = $block; +// if (preg_match('{ +// \A +// ( # $1 =
    tag +//
    ]* +// \b +// markdown\s*=\s* ([\'"]) # $2 = attr quote char +// 1 +// \2 +// [^>]* +// > +// ) +// ( # $3 = contents +// .* +// ) +// (
    ) # $4 = closing tag +// \z +// }xs', $block, $matches)) +// { +// list(, $div_open, , $div_content, $div_close) = $matches; +// +// // We can't call Markdown(), because that resets the hash; +// // that initialization code should be pulled into its own sub, though. +// $div_content = $this->hashHTMLBlocks($div_content); +// +// // Run document gamut methods on the content. +// foreach ($this->document_gamut as $method => $priority) { +// $div_content = $this->$method($div_content); +// } +// +// $div_open = preg_replace( +// '{\smarkdown\s*=\s*([\'"]).+?\1}', '', $div_open); +// +// $graf = $div_open . "\n" . $div_content . "\n" . $div_close; +// } + $grafs[$key] = $graf; + } + } + + return implode("\n\n", $grafs); + } + + /** + * Encode text for a double-quoted HTML attribute. This function + * is *not* suitable for attributes enclosed in single quotes. + * @param string $text + * @return string + */ + protected function encodeAttribute($text) { + $text = $this->encodeAmpsAndAngles($text); + $text = str_replace('"', '"', $text); + return $text; + } + + /** + * Encode text for a double-quoted HTML attribute containing a URL, + * applying the URL filter if set. Also generates the textual + * representation for the URL (removing mailto: or tel:) storing it in $text. + * This function is *not* suitable for attributes enclosed in single quotes. + * + * @param string $url + * @param string $text Passed by reference + * @return string URL + */ + protected function encodeURLAttribute($url, &$text = null) { + if (is_callable($this->url_filter_func)) { + $url = call_user_func($this->url_filter_func, $url); + } + + if (preg_match('{^mailto:}i', $url)) { + $url = $this->encodeEntityObfuscatedAttribute($url, $text, 7); + } else if (preg_match('{^tel:}i', $url)) { + $url = $this->encodeAttribute($url); + $text = substr($url, 4); + } else { + $url = $this->encodeAttribute($url); + $text = $url; + } + + return $url; + } + + /** + * Smart processing for ampersands and angle brackets that need to + * be encoded. Valid character entities are left alone unless the + * no-entities mode is set. + * @param string $text + * @return string + */ + protected function encodeAmpsAndAngles($text) { + if ($this->no_entities) { + $text = str_replace('&', '&', $text); + } else { + // Ampersand-encoding based entirely on Nat Irons's Amputator + // MT plugin: + $text = preg_replace('/&(?!#?[xX]?(?:[0-9a-fA-F]+|\w+);)/', + '&', $text); + } + // Encode remaining <'s + $text = str_replace('<', '<', $text); + + return $text; + } + + /** + * Parse Markdown automatic links to anchor HTML tags + * @param string $text + * @return string + */ + protected function doAutoLinks($text) { + $text = preg_replace_callback('{<((https?|ftp|dict|tel):[^\'">\s]+)>}i', + array($this, '_doAutoLinks_url_callback'), $text); + + // Email addresses: + $text = preg_replace_callback('{ + < + (?:mailto:)? + ( + (?: + [-!#$%&\'*+/=?^_`.{|}~\w\x80-\xFF]+ + | + ".*?" + ) + \@ + (?: + [-a-z0-9\x80-\xFF]+(\.[-a-z0-9\x80-\xFF]+)*\.[a-z]+ + | + \[[\d.a-fA-F:]+\] # IPv4 & IPv6 + ) + ) + > + }xi', + array($this, '_doAutoLinks_email_callback'), $text); + + return $text; + } + + /** + * Parse URL callback + * @param array $matches + * @return string + */ + protected function _doAutoLinks_url_callback($matches) { + $url = $this->encodeURLAttribute($matches[1], $text); + $link = "$text"; + return $this->hashPart($link); + } + + /** + * Parse email address callback + * @param array $matches + * @return string + */ + protected function _doAutoLinks_email_callback($matches) { + $addr = $matches[1]; + $url = $this->encodeURLAttribute("mailto:$addr", $text); + $link = "$text"; + return $this->hashPart($link); + } + + /** + * Input: some text to obfuscate, e.g. "mailto:foo@example.com" + * + * Output: the same text but with most characters encoded as either a + * decimal or hex entity, in the hopes of foiling most address + * harvesting spam bots. E.g.: + * + * mailto:foo + * @example.co + * m + * + * Note: the additional output $tail is assigned the same value as the + * ouput, minus the number of characters specified by $head_length. + * + * Based by a filter by Matthew Wickline, posted to BBEdit-Talk. + * With some optimizations by Milian Wolff. Forced encoding of HTML + * attribute special characters by Allan Odgaard. + * + * @param string $text + * @param string $tail Passed by reference + * @param integer $head_length + * @return string + */ + protected function encodeEntityObfuscatedAttribute($text, &$tail = null, $head_length = 0) { + if ($text == "") { + return $tail = ""; + } + + $chars = preg_split('/(? $char) { + $ord = ord($char); + // Ignore non-ascii chars. + if ($ord < 128) { + $r = ($seed * (1 + $key)) % 100; // Pseudo-random function. + // roughly 10% raw, 45% hex, 45% dec + // '@' *must* be encoded. I insist. + // '"' and '>' have to be encoded inside the attribute + if ($r > 90 && strpos('@"&>', $char) === false) { + /* do nothing */ + } else if ($r < 45) { + $chars[$key] = '&#x'.dechex($ord).';'; + } else { + $chars[$key] = '&#'.$ord.';'; + } + } + } + + $text = implode('', $chars); + $tail = $head_length ? implode('', array_slice($chars, $head_length)) : $text; + + return $text; + } + + /** + * Take the string $str and parse it into tokens, hashing embeded HTML, + * escaped characters and handling code spans. + * @param string $str + * @return string + */ + protected function parseSpan($str) { + $output = ''; + + $span_re = '{ + ( + \\\\'.$this->escape_chars_re.' + | + (?no_markup ? '' : ' + | + # comment + | + <\?.*?\?> | <%.*?%> # processing instruction + | + <[!$]?[-a-zA-Z0-9:_]+ # regular tags + (?> + \s + (?>[^"\'>]+|"[^"]*"|\'[^\']*\')* + )? + > + | + <[-a-zA-Z0-9:_]+\s*/> # xml-style empty tag + | + # closing tag + ').' + ) + }xs'; + + while (1) { + // Each loop iteration seach for either the next tag, the next + // openning code span marker, or the next escaped character. + // Each token is then passed to handleSpanToken. + $parts = preg_split($span_re, $str, 2, PREG_SPLIT_DELIM_CAPTURE); + + // Create token from text preceding tag. + if ($parts[0] != "") { + $output .= $parts[0]; + } + + // Check if we reach the end. + if (isset($parts[1])) { + $output .= $this->handleSpanToken($parts[1], $parts[2]); + $str = $parts[2]; + } else { + break; + } + } + + return $output; + } + + /** + * Handle $token provided by parseSpan by determining its nature and + * returning the corresponding value that should replace it. + * @param string $token + * @param string $str Passed by reference + * @return string + */ + protected function handleSpanToken($token, &$str) { + switch ($token[0]) { + case "\\": + return $this->hashPart("&#". ord($token[1]). ";"); + case "`": + // Search for end marker in remaining text. + if (preg_match('/^(.*?[^`])'.preg_quote($token).'(?!`)(.*)$/sm', + $str, $matches)) + { + $str = $matches[2]; + $codespan = $this->makeCodeSpan($matches[1]); + return $this->hashPart($codespan); + } + return $token; // Return as text since no ending marker found. + default: + return $this->hashPart($token); + } + } + + /** + * Remove one level of line-leading tabs or spaces + * @param string $text + * @return string + */ + protected function outdent($text) { + return preg_replace('/^(\t|[ ]{1,' . $this->tab_width . '})/m', '', $text); + } + + + /** + * String length function for detab. `_initDetab` will create a function to + * handle UTF-8 if the default function does not exist. + * can be a string or function + */ + protected $utf8_strlen = 'mb_strlen'; + + /** + * Replace tabs with the appropriate amount of spaces. + * + * For each line we separate the line in blocks delemited by tab characters. + * Then we reconstruct every line by adding the appropriate number of space + * between each blocks. + * + * @param string $text + * @return string + */ + protected function detab($text) { + $text = preg_replace_callback('/^.*\t.*$/m', + array($this, '_detab_callback'), $text); + + return $text; + } + + /** + * Replace tabs callback + * @param string $matches + * @return string + */ + protected function _detab_callback($matches) { + $line = $matches[0]; + $strlen = $this->utf8_strlen; // strlen function for UTF-8. + + // Split in blocks. + $blocks = explode("\t", $line); + // Add each blocks to the line. + $line = $blocks[0]; + unset($blocks[0]); // Do not add first block twice. + foreach ($blocks as $block) { + // Calculate amount of space, insert spaces, insert block. + $amount = $this->tab_width - + $strlen($line, 'UTF-8') % $this->tab_width; + $line .= str_repeat(" ", $amount) . $block; + } + return $line; + } + + /** + * Check for the availability of the function in the `utf8_strlen` property + * (initially `mb_strlen`). If the function is not available, create a + * function that will loosely count the number of UTF-8 characters with a + * regular expression. + * @return void + */ + protected function _initDetab() { + + if (function_exists($this->utf8_strlen)) { + return; + } + + //$this->utf8_strlen = fn($text) => preg_match_all('/[\x00-\xBF]|[\xC0-\xFF][\x80-\xBF]*/', $text, $m); + } + + /** + * Swap back in all the tags hashed by _HashHTMLBlocks. + * @param string $text + * @return string + */ + protected function unhash($text) { + return preg_replace_callback('/(.)\x1A[0-9]+\1/', + array($this, '_unhash_callback'), $text); + } + + /** + * Unhashing callback + * @param array $matches + * @return string + */ + protected function _unhash_callback($matches) { + return $this->html_hashes[$matches[0]]; + } +} diff --git a/lib/michelf/Michelf/MarkdownExtra.inc.php b/lib/michelf/Michelf/MarkdownExtra.inc.php new file mode 100644 index 0000000000..d09bd7a480 --- /dev/null +++ b/lib/michelf/Michelf/MarkdownExtra.inc.php @@ -0,0 +1,11 @@ + + * @copyright 2004-2019 Michel Fortin + * @copyright (Original Markdown) 2004-2006 John Gruber + */ + +namespace Michelf; + +/** + * Markdown Extra Parser Class + */ +class MarkdownExtra extends \Michelf\Markdown { + /** + * Configuration variables + */ + /** + * Prefix for footnote ids. + */ + public $fn_id_prefix = ""; + + /** + * Optional title attribute for footnote links. + */ + public $fn_link_title = ""; + + /** + * Optional class attribute for footnote links and backlinks. + */ + public $fn_link_class = "footnote-ref"; + public $fn_backlink_class = "footnote-backref"; + + /** + * Content to be displayed within footnote backlinks. The default is '↩'; + * the U+FE0E on the end is a Unicode variant selector used to prevent iOS + * from displaying the arrow character as an emoji. + * Optionally use '^^' and '%%' to refer to the footnote number and + * reference number respectively. {@see parseFootnotePlaceholders()} + */ + public $fn_backlink_html = '↩︎'; + + /** + * Optional title and aria-label attributes for footnote backlinks for + * added accessibility (to ensure backlink uniqueness). + * Use '^^' and '%%' to refer to the footnote number and reference number + * respectively. {@see parseFootnotePlaceholders()} + */ + public $fn_backlink_title = ""; + public $fn_backlink_label = ""; + + /** + * Class name for table cell alignment (%% replaced left/center/right) + * For instance: 'go-%%' becomes 'go-left' or 'go-right' or 'go-center' + * If empty, the align attribute is used instead of a class name. + */ + public $table_align_class_tmpl = ''; + + /** + * Optional class prefix for fenced code block. + */ + public $code_class_prefix = ""; + + /** + * Class attribute for code blocks goes on the `code` tag; + * setting this to true will put attributes on the `pre` tag instead. + */ + public $code_attr_on_pre = false; + + /** + * Predefined abbreviations. + */ + public $predef_abbr = array(); + + /** + * Only convert atx-style headers if there's a space between the header and # + */ + public $hashtag_protection = false; + + /** + * Determines whether footnotes should be appended to the end of the document. + * If true, footnote html can be retrieved from $this->footnotes_assembled. + */ + public $omit_footnotes = false; + + + /** + * After parsing, the HTML for the list of footnotes appears here. + * This is available only if $omit_footnotes == true. + * + * Note: when placing the content of `footnotes_assembled` on the page, + * consider adding the attribute `role="doc-endnotes"` to the `div` or + * `section` that will enclose the list of footnotes so they are + * reachable to accessibility tools the same way they would be with the + * default HTML output. + */ + public $footnotes_assembled = null; + + /** + * Parser implementation + */ + + /** + * Constructor function. Initialize the parser object. + * @return void + */ + public function __construct() { + // Add extra escapable characters before parent constructor + // initialize the table. + $this->escape_chars .= ':|'; + + // Insert extra document, block, and span transformations. + // Parent constructor will do the sorting. + $this->document_gamut += array( + "doFencedCodeBlocks" => 5, + "stripFootnotes" => 15, + "stripAbbreviations" => 25, + "appendFootnotes" => 50, + ); + $this->block_gamut += array( + "doFencedCodeBlocks" => 5, + "doTables" => 15, + "doDefLists" => 45, + ); + $this->span_gamut += array( + "doFootnotes" => 5, + "doAbbreviations" => 70, + ); + + $this->enhanced_ordered_list = true; + parent::__construct(); + } + + + /** + * Extra variables used during extra transformations. + */ + protected $footnotes = array(); + protected $footnotes_ordered = array(); + protected $footnotes_ref_count = array(); + protected $footnotes_numbers = array(); + protected $abbr_desciptions = array(); + protected $abbr_word_re = ''; + + /** + * Give the current footnote number. + */ + protected $footnote_counter = 1; + + /** + * Ref attribute for links + */ + protected $ref_attr = array(); + + /** + * Setting up Extra-specific variables. + */ + protected function setup() { + parent::setup(); + + $this->footnotes = array(); + $this->footnotes_ordered = array(); + $this->footnotes_ref_count = array(); + $this->footnotes_numbers = array(); + $this->abbr_desciptions = array(); + $this->abbr_word_re = ''; + $this->footnote_counter = 1; + $this->footnotes_assembled = null; + + foreach ($this->predef_abbr as $abbr_word => $abbr_desc) { + if ($this->abbr_word_re) + $this->abbr_word_re .= '|'; + $this->abbr_word_re .= preg_quote($abbr_word); + $this->abbr_desciptions[$abbr_word] = trim($abbr_desc); + } + } + + /** + * Clearing Extra-specific variables. + */ + protected function teardown() { + $this->footnotes = array(); + $this->footnotes_ordered = array(); + $this->footnotes_ref_count = array(); + $this->footnotes_numbers = array(); + $this->abbr_desciptions = array(); + $this->abbr_word_re = ''; + + if ( ! $this->omit_footnotes ) + $this->footnotes_assembled = null; + + parent::teardown(); + } + + + /** + * Extra attribute parser + */ + /** + * Expression to use to catch attributes (includes the braces) + */ + protected $id_class_attr_catch_re = '\{((?>[ ]*[#.a-z][-_:a-zA-Z0-9=]+){1,})[ ]*\}'; + + /** + * Expression to use when parsing in a context when no capture is desired + */ + protected $id_class_attr_nocatch_re = '\{(?>[ ]*[#.a-z][-_:a-zA-Z0-9=]+){1,}[ ]*\}'; + + /** + * Parse attributes caught by the $this->id_class_attr_catch_re expression + * and return the HTML-formatted list of attributes. + * + * Currently supported attributes are .class and #id. + * + * In addition, this method also supports supplying a default Id value, + * which will be used to populate the id attribute in case it was not + * overridden. + * @param string $tag_name + * @param string $attr + * @param mixed $defaultIdValue + * @param array $classes + * @return string + */ + protected function doExtraAttributes($tag_name, $attr, $defaultIdValue = null, $classes = array()) { + if (empty($attr) && !$defaultIdValue && empty($classes)) { + return ""; + } + + // Split on components + preg_match_all('/[#.a-z][-_:a-zA-Z0-9=]+/', $attr, $matches); + $elements = $matches[0]; + + // Handle classes and IDs (only first ID taken into account) + $attributes = array(); + $id = false; + foreach ($elements as $element) { + if ($element[0] === '.') { + $classes[] = substr($element, 1); + } else if ($element[0] === '#') { + if ($id === false) $id = substr($element, 1); + } else if (strpos($element, '=') > 0) { + $parts = explode('=', $element, 2); + $attributes[] = $parts[0] . '="' . $parts[1] . '"'; + } + } + + if ($id === false || $id === '') { + $id = $defaultIdValue; + } + + // Compose attributes as string + $attr_str = ""; + if (!empty($id)) { + $attr_str .= ' id="'.$this->encodeAttribute($id) .'"'; + } + if (!empty($classes)) { + $attr_str .= ' class="'. implode(" ", $classes) . '"'; + } + if (!$this->no_markup && !empty($attributes)) { + $attr_str .= ' '.implode(" ", $attributes); + } + return $attr_str; + } + + /** + * Strips link definitions from text, stores the URLs and titles in + * hash references. + * @param string $text + * @return string + */ + protected function stripLinkDefinitions($text) { + $less_than_tab = $this->tab_width - 1; + + // Link defs are in the form: ^[id]: url "optional title" + $text = preg_replace_callback('{ + ^[ ]{0,'.$less_than_tab.'}\[(.+)\][ ]?: # id = $1 + [ ]* + \n? # maybe *one* newline + [ ]* + (?: + <(.+?)> # url = $2 + | + (\S+?) # url = $3 + ) + [ ]* + \n? # maybe one newline + [ ]* + (?: + (?<=\s) # lookbehind for whitespace + ["(] + (.*?) # title = $4 + [")] + [ ]* + )? # title is optional + (?:[ ]* '.$this->id_class_attr_catch_re.' )? # $5 = extra id & class attr + (?:\n+|\Z) + }xm', + array($this, '_stripLinkDefinitions_callback'), + $text); + return $text; + } + + /** + * Strip link definition callback + * @param array $matches + * @return string + */ + protected function _stripLinkDefinitions_callback($matches) { + $link_id = strtolower($matches[1]); + $url = $matches[2] == '' ? $matches[3] : $matches[2]; + $this->urls[$link_id] = $url; + $this->titles[$link_id] =& $matches[4]; + $this->ref_attr[$link_id] = $this->doExtraAttributes("", $dummy =& $matches[5]); + return ''; // String that will replace the block + } + + + /** + * HTML block parser + */ + /** + * Tags that are always treated as block tags + */ + protected $block_tags_re = 'p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|address|form|fieldset|iframe|hr|legend|article|section|nav|aside|hgroup|header|footer|figcaption|figure|details|summary'; + + /** + * Tags treated as block tags only if the opening tag is alone on its line + */ + protected $context_block_tags_re = 'script|noscript|style|ins|del|iframe|object|source|track|param|math|svg|canvas|audio|video'; + + /** + * Tags where markdown="1" default to span mode: + */ + protected $contain_span_tags_re = 'p|h[1-6]|li|dd|dt|td|th|legend|address'; + + /** + * Tags which must not have their contents modified, no matter where + * they appear + */ + protected $clean_tags_re = 'script|style|math|svg'; + + /** + * Tags that do not need to be closed. + */ + protected $auto_close_tags_re = 'hr|img|param|source|track'; + + /** + * Hashify HTML Blocks and "clean tags". + * + * We only want to do this for block-level HTML tags, such as headers, + * lists, and tables. That's because we still want to wrap

    s around + * "paragraphs" that are wrapped in non-block-level tags, such as anchors, + * phrase emphasis, and spans. The list of tags we're looking for is + * hard-coded. + * + * This works by calling _HashHTMLBlocks_InMarkdown, which then calls + * _HashHTMLBlocks_InHTML when it encounter block tags. When the markdown="1" + * attribute is found within a tag, _HashHTMLBlocks_InHTML calls back + * _HashHTMLBlocks_InMarkdown to handle the Markdown syntax within the tag. + * These two functions are calling each other. It's recursive! + * @param string $text + * @return string + */ + protected function hashHTMLBlocks($text) { + if ($this->no_markup) { + return $text; + } + + // Call the HTML-in-Markdown hasher. + list($text, ) = $this->_hashHTMLBlocks_inMarkdown($text); + + return $text; + } + + /** + * Parse markdown text, calling _HashHTMLBlocks_InHTML for block tags. + * + * * $indent is the number of space to be ignored when checking for code + * blocks. This is important because if we don't take the indent into + * account, something like this (which looks right) won't work as expected: + * + *

    + *
    + * Hello World. <-- Is this a Markdown code block or text? + *
    <-- Is this a Markdown code block or a real tag? + *
    + * + * If you don't like this, just don't indent the tag on which + * you apply the markdown="1" attribute. + * + * * If $enclosing_tag_re is not empty, stops at the first unmatched closing + * tag with that name. Nested tags supported. + * + * * If $span is true, text inside must treated as span. So any double + * newline will be replaced by a single newline so that it does not create + * paragraphs. + * + * Returns an array of that form: ( processed text , remaining text ) + * + * @param string $text + * @param integer $indent + * @param string $enclosing_tag_re + * @param boolean $span + * @return array + */ + protected function _hashHTMLBlocks_inMarkdown($text, $indent = 0, + $enclosing_tag_re = '', $span = false) + { + + if ($text === '') return array('', ''); + + // Regex to check for the presense of newlines around a block tag. + $newline_before_re = '/(?:^\n?|\n\n)*$/'; + $newline_after_re = + '{ + ^ # Start of text following the tag. + (?>[ ]*)? # Optional comment. + [ ]*\n # Must be followed by newline. + }xs'; + + // Regex to match any tag. + $block_tag_re = + '{ + ( # $2: Capture whole tag. + # Tag name. + ' . $this->block_tags_re . ' | + ' . $this->context_block_tags_re . ' | + ' . $this->clean_tags_re . ' | + (?!\s)'.$enclosing_tag_re . ' + ) + (?: + (?=[\s"\'/a-zA-Z0-9]) # Allowed characters after tag name. + (?> + ".*?" | # Double quotes (can contain `>`) + \'.*?\' | # Single quotes (can contain `>`) + .+? # Anything but quotes and `>`. + )*? + )? + > # End of tag. + | + # HTML Comment + | + <\?.*?\?> | <%.*?%> # Processing instruction + | + # CData Block + ' . ( !$span ? ' # If not in span. + | + # Indented code block + (?: ^[ ]*\n | ^ | \n[ ]*\n ) + [ ]{' . ($indent + 4) . '}[^\n]* \n + (?> + (?: [ ]{' . ($indent + 4) . '}[^\n]* | [ ]* ) \n + )* + | + # Fenced code block marker + (?<= ^ | \n ) + [ ]{0,' . ($indent + 3) . '}(?:~{3,}|`{3,}) + [ ]* + (?: \.?[-_:a-zA-Z0-9]+ )? # standalone class name + [ ]* + (?: ' . $this->id_class_attr_nocatch_re . ' )? # extra attributes + [ ]* + (?= \n ) + ' : '' ) . ' # End (if not is span). + | + # Code span marker + # Note, this regex needs to go after backtick fenced + # code blocks but it should also be kept outside of the + # "if not in span" condition adding backticks to the parser + `+ + ) + }xs'; + + + $depth = 0; // Current depth inside the tag tree. + $parsed = ""; // Parsed text that will be returned. + + // Loop through every tag until we find the closing tag of the parent + // or loop until reaching the end of text if no parent tag specified. + do { + // Split the text using the first $tag_match pattern found. + // Text before pattern will be first in the array, text after + // pattern will be at the end, and between will be any catches made + // by the pattern. + $parts = preg_split($block_tag_re, $text, 2, + PREG_SPLIT_DELIM_CAPTURE); + + // If in Markdown span mode, add a empty-string span-level hash + // after each newline to prevent triggering any block element. + if ($span) { + $void = $this->hashPart("", ':'); + $newline = "\n$void"; + $parts[0] = $void . str_replace("\n", $newline, $parts[0]) . $void; + } + + $parsed .= $parts[0]; // Text before current tag. + + // If end of $text has been reached. Stop loop. + if (count($parts) < 3) { + $text = ""; + break; + } + + $tag = $parts[1]; // Tag to handle. + $text = $parts[2]; // Remaining text after current tag. + + // Check for: Fenced code block marker. + // Note: need to recheck the whole tag to disambiguate backtick + // fences from code spans + if (preg_match('{^\n?([ ]{0,' . ($indent + 3) . '})(~{3,}|`{3,})[ ]*(?:\.?[-_:a-zA-Z0-9]+)?[ ]*(?:' . $this->id_class_attr_nocatch_re . ')?[ ]*\n?$}', $tag, $capture)) { + // Fenced code block marker: find matching end marker. + $fence_indent = strlen($capture[1]); // use captured indent in re + $fence_re = $capture[2]; // use captured fence in re + if (preg_match('{^(?>.*\n)*?[ ]{' . ($fence_indent) . '}' . $fence_re . '[ ]*(?:\n|$)}', $text, + $matches)) + { + // End marker found: pass text unchanged until marker. + $parsed .= $tag . $matches[0]; + $text = substr($text, strlen($matches[0])); + } + else { + // No end marker: just skip it. + $parsed .= $tag; + } + } + // Check for: Indented code block. + else if ($tag[0] === "\n" || $tag[0] === " ") { + // Indented code block: pass it unchanged, will be handled + // later. + $parsed .= $tag; + } + // Check for: Code span marker + // Note: need to check this after backtick fenced code blocks + else if ($tag[0] === "`") { + // Find corresponding end marker. + $tag_re = preg_quote($tag); + if (preg_match('{^(?>.+?|\n(?!\n))*?(?block_tags_re . ')\b}', $tag) || + ( preg_match('{^<(?:' . $this->context_block_tags_re . ')\b}', $tag) && + preg_match($newline_before_re, $parsed) && + preg_match($newline_after_re, $text) ) + ) + { + // Need to parse tag and following text using the HTML parser. + list($block_text, $text) = + $this->_hashHTMLBlocks_inHTML($tag . $text, "hashBlock", true); + + // Make sure it stays outside of any paragraph by adding newlines. + $parsed .= "\n\n$block_text\n\n"; + } + // Check for: Clean tag (like script, math) + // HTML Comments, processing instructions. + else if (preg_match('{^<(?:' . $this->clean_tags_re . ')\b}', $tag) || + $tag[1] === '!' || $tag[1] === '?') + { + // Need to parse tag and following text using the HTML parser. + // (don't check for markdown attribute) + list($block_text, $text) = + $this->_hashHTMLBlocks_inHTML($tag . $text, "hashClean", false); + + $parsed .= $block_text; + } + // Check for: Tag with same name as enclosing tag. + else if ($enclosing_tag_re !== '' && + // Same name as enclosing tag. + preg_match('{^= 0); + + return array($parsed, $text); + } + + /** + * Parse HTML, calling _HashHTMLBlocks_InMarkdown for block tags. + * + * * Calls $hash_method to convert any blocks. + * * Stops when the first opening tag closes. + * * $md_attr indicate if the use of the `markdown="1"` attribute is allowed. + * (it is not inside clean tags) + * + * Returns an array of that form: ( processed text , remaining text ) + * @param string $text + * @param string $hash_method + * @param bool $md_attr Handle `markdown="1"` attribute + * @return array + */ + protected function _hashHTMLBlocks_inHTML($text, $hash_method, $md_attr) { + if ($text === '') return array('', ''); + + // Regex to match `markdown` attribute inside of a tag. + $markdown_attr_re = ' + { + \s* # Eat whitespace before the `markdown` attribute + markdown + \s*=\s* + (?> + (["\']) # $1: quote delimiter + (.*?) # $2: attribute value + \1 # matching delimiter + | + ([^\s>]*) # $3: unquoted attribute value + ) + () # $4: make $3 always defined (avoid warnings) + }xs'; + + // Regex to match any tag. + $tag_re = '{ + ( # $2: Capture whole tag. + + ".*?" | # Double quotes (can contain `>`) + \'.*?\' | # Single quotes (can contain `>`) + .+? # Anything but quotes and `>`. + )*? + )? + > # End of tag. + | + # HTML Comment + | + <\?.*?\?> | <%.*?%> # Processing instruction + | + # CData Block + ) + }xs'; + + $original_text = $text; // Save original text in case of faliure. + + $depth = 0; // Current depth inside the tag tree. + $block_text = ""; // Temporary text holder for current text. + $parsed = ""; // Parsed text that will be returned. + $base_tag_name_re = ''; + + // Get the name of the starting tag. + // (This pattern makes $base_tag_name_re safe without quoting.) + if (preg_match('/^<([\w:$]*)\b/', $text, $matches)) + $base_tag_name_re = $matches[1]; + + // Loop through every tag until we find the corresponding closing tag. + do { + // Split the text using the first $tag_match pattern found. + // Text before pattern will be first in the array, text after + // pattern will be at the end, and between will be any catches made + // by the pattern. + $parts = preg_split($tag_re, $text, 2, PREG_SPLIT_DELIM_CAPTURE); + + if (count($parts) < 3) { + // End of $text reached with unbalenced tag(s). + // In that case, we return original text unchanged and pass the + // first character as filtered to prevent an infinite loop in the + // parent function. + return array($original_text[0], substr($original_text, 1)); + } + + $block_text .= $parts[0]; // Text before current tag. + $tag = $parts[1]; // Tag to handle. + $text = $parts[2]; // Remaining text after current tag. + + // Check for: Auto-close tag (like
    ) + // Comments and Processing Instructions. + if (preg_match('{^auto_close_tags_re . ')\b}', $tag) || + $tag[1] === '!' || $tag[1] === '?') + { + // Just add the tag to the block as if it was text. + $block_text .= $tag; + } + else { + // Increase/decrease nested tag count. Only do so if + // the tag's name match base tag's. + if (preg_match('{^contain_span_tags_re . ')\b}', $tag)); + + // Calculate indent before tag. + if (preg_match('/(?:^|\n)( *?)(?! ).*?$/', $block_text, $matches)) { + $strlen = $this->utf8_strlen; + $indent = $strlen($matches[1], 'UTF-8'); + } else { + $indent = 0; + } + + // End preceding block with this tag. + $block_text .= $tag; + $parsed .= $this->$hash_method($block_text); + + // Get enclosing tag name for the ParseMarkdown function. + // (This pattern makes $tag_name_re safe without quoting.) + preg_match('/^<([\w:$]*)\b/', $tag, $matches); + $tag_name_re = $matches[1]; + + // Parse the content using the HTML-in-Markdown parser. + list ($block_text, $text) + = $this->_hashHTMLBlocks_inMarkdown($text, $indent, + $tag_name_re, $span_mode); + + // Outdent markdown text. + if ($indent > 0) { + $block_text = preg_replace("/^[ ]{1,$indent}/m", "", + $block_text); + } + + // Append tag content to parsed text. + if (!$span_mode) { + $parsed .= "\n\n$block_text\n\n"; + } else { + $parsed .= (string) $block_text; + } + + // Start over with a new block. + $block_text = ""; + } + else $block_text .= $tag; + } + + } while ($depth > 0); + + // Hash last block text that wasn't processed inside the loop. + $parsed .= $this->$hash_method($block_text); + + return array($parsed, $text); + } + + /** + * Called whenever a tag must be hashed when a function inserts a "clean" tag + * in $text, it passes through this function and is automaticaly escaped, + * blocking invalid nested overlap. + * @param string $text + * @return string + */ + protected function hashClean($text) { + return $this->hashPart($text, 'C'); + } + + /** + * Turn Markdown link shortcuts into XHTML tags. + * @param string $text + * @return string + */ + protected function doAnchors($text) { + if ($this->in_anchor) { + return $text; + } + $this->in_anchor = true; + + // First, handle reference-style links: [link text] [id] + $text = preg_replace_callback('{ + ( # wrap whole match in $1 + \[ + (' . $this->nested_brackets_re . ') # link text = $2 + \] + + [ ]? # one optional space + (?:\n[ ]*)? # one optional newline followed by spaces + + \[ + (.*?) # id = $3 + \] + ) + }xs', + array($this, '_doAnchors_reference_callback'), $text); + + // Next, inline-style links: [link text](url "optional title") + $text = preg_replace_callback('{ + ( # wrap whole match in $1 + \[ + (' . $this->nested_brackets_re . ') # link text = $2 + \] + \( # literal paren + [ \n]* + (?: + <(.+?)> # href = $3 + | + (' . $this->nested_url_parenthesis_re . ') # href = $4 + ) + [ \n]* + ( # $5 + ([\'"]) # quote char = $6 + (.*?) # Title = $7 + \6 # matching quote + [ \n]* # ignore any spaces/tabs between closing quote and ) + )? # title is optional + \) + (?:[ ]? ' . $this->id_class_attr_catch_re . ' )? # $8 = id/class attributes + ) + }xs', + array($this, '_doAnchors_inline_callback'), $text); + + // Last, handle reference-style shortcuts: [link text] + // These must come last in case you've also got [link text][1] + // or [link text](/foo) + $text = preg_replace_callback('{ + ( # wrap whole match in $1 + \[ + ([^\[\]]+) # link text = $2; can\'t contain [ or ] + \] + ) + }xs', + array($this, '_doAnchors_reference_callback'), $text); + + $this->in_anchor = false; + return $text; + } + + /** + * Callback for reference anchors + * @param array $matches + * @return string + */ + protected function _doAnchors_reference_callback($matches) { + $whole_match = $matches[1]; + $link_text = $matches[2]; + $link_id =& $matches[3]; + + if ($link_id == "") { + // for shortcut links like [this][] or [this]. + $link_id = $link_text; + } + + // lower-case and turn embedded newlines into spaces + $link_id = strtolower($link_id); + $link_id = preg_replace('{[ ]?\n}', ' ', $link_id); + + if (isset($this->urls[$link_id])) { + $url = $this->urls[$link_id]; + $url = $this->encodeURLAttribute($url); + + $result = "titles[$link_id] ) ) { + $title = $this->titles[$link_id]; + $title = $this->encodeAttribute($title); + $result .= " title=\"$title\""; + } + if (isset($this->ref_attr[$link_id])) + $result .= $this->ref_attr[$link_id]; + + $link_text = $this->runSpanGamut($link_text); + $result .= ">$link_text"; + $result = $this->hashPart($result); + } + else { + $result = $whole_match; + } + return $result; + } + + /** + * Callback for inline anchors + * @param array $matches + * @return string + */ + protected function _doAnchors_inline_callback($matches) { + $link_text = $this->runSpanGamut($matches[2]); + $url = $matches[3] === '' ? $matches[4] : $matches[3]; + $title_quote =& $matches[6]; + $title =& $matches[7]; + $attr = $this->doExtraAttributes("a", $dummy =& $matches[8]); + + // if the URL was of the form it got caught by the HTML + // tag parser and hashed. Need to reverse the process before using the URL. + $unhashed = $this->unhash($url); + if ($unhashed !== $url) + $url = preg_replace('/^<(.*)>$/', '\1', $unhashed); + + $url = $this->encodeURLAttribute($url); + + $result = "encodeAttribute($title); + $result .= " title=\"$title\""; + } + $result .= $attr; + + $link_text = $this->runSpanGamut($link_text); + $result .= ">$link_text"; + + return $this->hashPart($result); + } + + /** + * Turn Markdown image shortcuts into tags. + * @param string $text + * @return string + */ + protected function doImages($text) { + // First, handle reference-style labeled images: ![alt text][id] + $text = preg_replace_callback('{ + ( # wrap whole match in $1 + !\[ + (' . $this->nested_brackets_re . ') # alt text = $2 + \] + + [ ]? # one optional space + (?:\n[ ]*)? # one optional newline followed by spaces + + \[ + (.*?) # id = $3 + \] + + ) + }xs', + array($this, '_doImages_reference_callback'), $text); + + // Next, handle inline images: ![alt text](url "optional title") + // Don't forget: encode * and _ + $text = preg_replace_callback('{ + ( # wrap whole match in $1 + !\[ + (' . $this->nested_brackets_re . ') # alt text = $2 + \] + \s? # One optional whitespace character + \( # literal paren + [ \n]* + (?: + <(\S*)> # src url = $3 + | + (' . $this->nested_url_parenthesis_re . ') # src url = $4 + ) + [ \n]* + ( # $5 + ([\'"]) # quote char = $6 + (.*?) # title = $7 + \6 # matching quote + [ \n]* + )? # title is optional + \) + (?:[ ]? ' . $this->id_class_attr_catch_re . ' )? # $8 = id/class attributes + ) + }xs', + array($this, '_doImages_inline_callback'), $text); + + return $text; + } + + /** + * Callback for referenced images + * @param array $matches + * @return string + */ + protected function _doImages_reference_callback($matches) { + $whole_match = $matches[1]; + $alt_text = $matches[2]; + $link_id = strtolower($matches[3]); + + if ($link_id === "") { + $link_id = strtolower($alt_text); // for shortcut links like ![this][]. + } + + $alt_text = $this->encodeAttribute($alt_text); + if (isset($this->urls[$link_id])) { + $url = $this->encodeURLAttribute($this->urls[$link_id]); + $result = "\"$alt_text\"";titles[$link_id])) { + $title = $this->titles[$link_id]; + $title = $this->encodeAttribute($title); + $result .= " title=\"$title\""; + } + if (isset($this->ref_attr[$link_id])) { + $result .= $this->ref_attr[$link_id]; + } + $result .= $this->empty_element_suffix; + $result = $this->hashPart($result); + } + else { + // If there's no such link ID, leave intact: + $result = $whole_match; + } + + return $result; + } + + /** + * Callback for inline images + * @param array $matches + * @return string + */ + protected function _doImages_inline_callback($matches) { + $alt_text = $matches[2]; + $url = $matches[3] === '' ? $matches[4] : $matches[3]; + $title_quote =& $matches[6]; + $title =& $matches[7]; + $attr = $this->doExtraAttributes("img", $dummy =& $matches[8]); + + $alt_text = $this->encodeAttribute($alt_text); + $url = $this->encodeURLAttribute($url); + $result = "\"$alt_text\"";encodeAttribute($title); + $result .= " title=\"$title\""; // $title already quoted + } + $result .= $attr; + $result .= $this->empty_element_suffix; + + return $this->hashPart($result); + } + + /** + * Process markdown headers. Redefined to add ID and class attribute support. + * @param string $text + * @return string + */ + protected function doHeaders($text) { + // Setext-style headers: + // Header 1 {#header1} + // ======== + // + // Header 2 {#header2 .class1 .class2} + // -------- + // + $text = preg_replace_callback( + '{ + (^.+?) # $1: Header text + (?:[ ]+ ' . $this->id_class_attr_catch_re . ' )? # $3 = id/class attributes + [ ]*\n(=+|-+)[ ]*\n+ # $3: Header footer + }mx', + array($this, '_doHeaders_callback_setext'), $text); + + // atx-style headers: + // # Header 1 {#header1} + // ## Header 2 {#header2} + // ## Header 2 with closing hashes ## {#header3.class1.class2} + // ... + // ###### Header 6 {.class2} + // + $text = preg_replace_callback('{ + ^(\#{1,6}) # $1 = string of #\'s + [ ]'.($this->hashtag_protection ? '+' : '*').' + (.+?) # $2 = Header text + [ ]* + \#* # optional closing #\'s (not counted) + (?:[ ]+ ' . $this->id_class_attr_catch_re . ' )? # $3 = id/class attributes + [ ]* + \n+ + }xm', + array($this, '_doHeaders_callback_atx'), $text); + + return $text; + } + + /** + * Callback for setext headers + * @param array $matches + * @return string + */ + protected function _doHeaders_callback_setext($matches) { + if ($matches[3] === '-' && preg_match('{^- }', $matches[1])) { + return $matches[0]; + } + + $level = $matches[3][0] === '=' ? 1 : 2; + + $defaultId = is_callable($this->header_id_func) ? call_user_func($this->header_id_func, $matches[1]) : null; + + $attr = $this->doExtraAttributes("h$level", $dummy =& $matches[2], $defaultId); + $block = "" . $this->runSpanGamut($matches[1]) . ""; + return "\n" . $this->hashBlock($block) . "\n\n"; + } + + /** + * Callback for atx headers + * @param array $matches + * @return string + */ + protected function _doHeaders_callback_atx($matches) { + $level = strlen($matches[1]); + + $defaultId = is_callable($this->header_id_func) ? call_user_func($this->header_id_func, $matches[2]) : null; + $attr = $this->doExtraAttributes("h$level", $dummy =& $matches[3], $defaultId); + $block = "" . $this->runSpanGamut($matches[2]) . ""; + return "\n" . $this->hashBlock($block) . "\n\n"; + } + + /** + * Form HTML tables. + * @param string $text + * @return string + */ + protected function doTables($text) { + $less_than_tab = $this->tab_width - 1; + // Find tables with leading pipe. + // + // | Header 1 | Header 2 + // | -------- | -------- + // | Cell 1 | Cell 2 + // | Cell 3 | Cell 4 + $text = preg_replace_callback(' + { + ^ # Start of a line + [ ]{0,' . $less_than_tab . '} # Allowed whitespace. + [|] # Optional leading pipe (present) + (.+) \n # $1: Header row (at least one pipe) + + [ ]{0,' . $less_than_tab . '} # Allowed whitespace. + [|] ([ ]*[-:]+[-| :]*) \n # $2: Header underline + + ( # $3: Cells + (?> + [ ]* # Allowed whitespace. + [|] .* \n # Row content. + )* + ) + (?=\n|\Z) # Stop at final double newline. + }xm', + array($this, '_doTable_leadingPipe_callback'), $text); + + // Find tables without leading pipe. + // + // Header 1 | Header 2 + // -------- | -------- + // Cell 1 | Cell 2 + // Cell 3 | Cell 4 + $text = preg_replace_callback(' + { + ^ # Start of a line + [ ]{0,' . $less_than_tab . '} # Allowed whitespace. + (\S.*[|].*) \n # $1: Header row (at least one pipe) + + [ ]{0,' . $less_than_tab . '} # Allowed whitespace. + ([-:]+[ ]*[|][-| :]*) \n # $2: Header underline + + ( # $3: Cells + (?> + .* [|] .* \n # Row content + )* + ) + (?=\n|\Z) # Stop at final double newline. + }xm', + array($this, '_DoTable_callback'), $text); + + return $text; + } + + /** + * Callback for removing the leading pipe for each row + * @param array $matches + * @return string + */ + protected function _doTable_leadingPipe_callback($matches) { + $head = $matches[1]; + $underline = $matches[2]; + $content = $matches[3]; + + $content = preg_replace('/^ *[|]/m', '', $content); + + return $this->_doTable_callback(array($matches[0], $head, $underline, $content)); + } + + /** + * Make the align attribute in a table + * @param string $alignname + * @return string + */ + protected function _doTable_makeAlignAttr($alignname) { + if (empty($this->table_align_class_tmpl)) { + return " align=\"$alignname\""; + } + + $classname = str_replace('%%', $alignname, $this->table_align_class_tmpl); + return " class=\"$classname\""; + } + + /** + * Calback for processing tables + * @param array $matches + * @return string + */ + protected function _doTable_callback($matches) { + $head = $matches[1]; + $underline = $matches[2]; + $content = $matches[3]; + $attr = []; + + // Remove any tailing pipes for each line. + $head = preg_replace('/[|] *$/m', '', $head); + $underline = preg_replace('/[|] *$/m', '', $underline); + $content = preg_replace('/[|] *$/m', '', $content); + + // Reading alignement from header underline. + $separators = preg_split('/ *[|] */', $underline); + foreach ($separators as $n => $s) { + if (preg_match('/^ *-+: *$/', $s)) + $attr[$n] = $this->_doTable_makeAlignAttr('right'); + else if (preg_match('/^ *:-+: *$/', $s)) + $attr[$n] = $this->_doTable_makeAlignAttr('center'); + else if (preg_match('/^ *:-+ *$/', $s)) + $attr[$n] = $this->_doTable_makeAlignAttr('left'); + else + $attr[$n] = ''; + } + + // Parsing span elements, including code spans, character escapes, + // and inline HTML tags, so that pipes inside those gets ignored. + $head = $this->parseSpan($head); + $headers = preg_split('/ *[|] */', $head); + $col_count = count($headers); + $attr = array_pad($attr, $col_count, ''); + + // Write column headers. + $text = "\n"; + $text .= "\n"; + $text .= "\n"; + foreach ($headers as $n => $header) { + $text .= " " . $this->runSpanGamut(trim($header)) . "\n"; + } + $text .= "\n"; + $text .= "\n"; + + // Split content by row. + $rows = explode("\n", trim($content, "\n")); + + $text .= "\n"; + foreach ($rows as $row) { + // Parsing span elements, including code spans, character escapes, + // and inline HTML tags, so that pipes inside those gets ignored. + $row = $this->parseSpan($row); + + // Split row by cell. + $row_cells = preg_split('/ *[|] */', $row, $col_count); + $row_cells = array_pad($row_cells, $col_count, ''); + + $text .= "\n"; + foreach ($row_cells as $n => $cell) { + $text .= " " . $this->runSpanGamut(trim($cell)) . "\n"; + } + $text .= "\n"; + } + $text .= "\n"; + $text .= "
    "; + + return $this->hashBlock($text) . "\n"; + } + + /** + * Form HTML definition lists. + * @param string $text + * @return string + */ + protected function doDefLists($text) { + $less_than_tab = $this->tab_width - 1; + + // Re-usable pattern to match any entire dl list: + $whole_list_re = '(?> + ( # $1 = whole list + ( # $2 + [ ]{0,' . $less_than_tab . '} + ((?>.*\S.*\n)+) # $3 = defined term + \n? + [ ]{0,' . $less_than_tab . '}:[ ]+ # colon starting definition + ) + (?s:.+?) + ( # $4 + \z + | + \n{2,} + (?=\S) + (?! # Negative lookahead for another term + [ ]{0,' . $less_than_tab . '} + (?: \S.*\n )+? # defined term + \n? + [ ]{0,' . $less_than_tab . '}:[ ]+ # colon starting definition + ) + (?! # Negative lookahead for another definition + [ ]{0,' . $less_than_tab . '}:[ ]+ # colon starting definition + ) + ) + ) + )'; // mx + + $text = preg_replace_callback('{ + (?>\A\n?|(?<=\n\n)) + ' . $whole_list_re . ' + }mx', + array($this, '_doDefLists_callback'), $text); + + return $text; + } + + /** + * Callback for processing definition lists + * @param array $matches + * @return string + */ + protected function _doDefLists_callback($matches) { + // Re-usable patterns to match list item bullets and number markers: + $list = $matches[1]; + + // Turn double returns into triple returns, so that we can make a + // paragraph for the last item in a list, if necessary: + $result = trim($this->processDefListItems($list)); + $result = "
    \n" . $result . "\n
    "; + return $this->hashBlock($result) . "\n\n"; + } + + /** + * Process the contents of a single definition list, splitting it + * into individual term and definition list items. + * @param string $list_str + * @return string + */ + protected function processDefListItems($list_str) { + + $less_than_tab = $this->tab_width - 1; + + // Trim trailing blank lines: + $list_str = preg_replace("/\n{2,}\\z/", "\n", $list_str); + + // Process definition terms. + $list_str = preg_replace_callback('{ + (?>\A\n?|\n\n+) # leading line + ( # definition terms = $1 + [ ]{0,' . $less_than_tab . '} # leading whitespace + (?!\:[ ]|[ ]) # negative lookahead for a definition + # mark (colon) or more whitespace. + (?> \S.* \n)+? # actual term (not whitespace). + ) + (?=\n?[ ]{0,3}:[ ]) # lookahead for following line feed + # with a definition mark. + }xm', + array($this, '_processDefListItems_callback_dt'), $list_str); + + // Process actual definitions. + $list_str = preg_replace_callback('{ + \n(\n+)? # leading line = $1 + ( # marker space = $2 + [ ]{0,' . $less_than_tab . '} # whitespace before colon + \:[ ]+ # definition mark (colon) + ) + ((?s:.+?)) # definition text = $3 + (?= \n+ # stop at next definition mark, + (?: # next term or end of text + [ ]{0,' . $less_than_tab . '} \:[ ] | +
    | \z + ) + ) + }xm', + array($this, '_processDefListItems_callback_dd'), $list_str); + + return $list_str; + } + + /** + * Callback for
    elements in definition lists + * @param array $matches + * @return string + */ + protected function _processDefListItems_callback_dt($matches) { + $terms = explode("\n", trim($matches[1])); + $text = ''; + foreach ($terms as $term) { + $term = $this->runSpanGamut(trim($term)); + $text .= "\n
    " . $term . "
    "; + } + return $text . "\n"; + } + + /** + * Callback for
    elements in definition lists + * @param array $matches + * @return string + */ + protected function _processDefListItems_callback_dd($matches) { + $leading_line = $matches[1]; + $marker_space = $matches[2]; + $def = $matches[3]; + + if ($leading_line || preg_match('/\n{2,}/', $def)) { + // Replace marker with the appropriate whitespace indentation + $def = str_repeat(' ', strlen($marker_space)) . $def; + $def = $this->runBlockGamut($this->outdent($def . "\n\n")); + $def = "\n". $def ."\n"; + } + else { + $def = rtrim($def); + $def = $this->runSpanGamut($this->outdent($def)); + } + + return "\n
    " . $def . "
    \n"; + } + + /** + * Adding the fenced code block syntax to regular Markdown: + * + * ~~~ + * Code block + * ~~~ + * + * @param string $text + * @return string + */ + protected function doFencedCodeBlocks($text) { + + $text = preg_replace_callback('{ + (?:\n|\A) + # 1: Opening marker + ( + (?:~{3,}|`{3,}) # 3 or more tildes/backticks. + ) + [ ]* + (?: + \.?([-_:a-zA-Z0-9]+) # 2: standalone class name + )? + [ ]* + (?: + ' . $this->id_class_attr_catch_re . ' # 3: Extra attributes + )? + [ ]* \n # Whitespace and newline following marker. + + # 4: Content + ( + (?> + (?!\1 [ ]* \n) # Not a closing marker. + .*\n+ + )+ + ) + + # Closing marker. + \1 [ ]* (?= \n ) + }xm', + array($this, '_doFencedCodeBlocks_callback'), $text); + + return $text; + } + + /** + * Callback to process fenced code blocks + * @param array $matches + * @return string + */ + protected function _doFencedCodeBlocks_callback($matches) { + $classname =& $matches[2]; + $attrs =& $matches[3]; + $codeblock = $matches[4]; + + if ($this->code_block_content_func) { + $codeblock = call_user_func($this->code_block_content_func, $codeblock, $classname); + } else { + $codeblock = htmlspecialchars($codeblock, ENT_NOQUOTES); + } + + $codeblock = preg_replace_callback('/^\n+/', + array($this, '_doFencedCodeBlocks_newlines'), $codeblock); + + $classes = array(); + if ($classname !== "") { + if ($classname[0] === '.') { + $classname = substr($classname, 1); + } + $classes[] = $this->code_class_prefix . $classname; + } + $attr_str = $this->doExtraAttributes($this->code_attr_on_pre ? "pre" : "code", $attrs, null, $classes); + $pre_attr_str = $this->code_attr_on_pre ? $attr_str : ''; + $code_attr_str = $this->code_attr_on_pre ? '' : $attr_str; + $codeblock = "$codeblock
    "; + + return "\n\n".$this->hashBlock($codeblock)."\n\n"; + } + + /** + * Replace new lines in fenced code blocks + * @param array $matches + * @return string + */ + protected function _doFencedCodeBlocks_newlines($matches) { + return str_repeat("empty_element_suffix", + strlen($matches[0])); + } + + /** + * Redefining emphasis markers so that emphasis by underscore does not + * work in the middle of a word. + * @var array + */ + protected $em_relist = array( + '' => '(?:(? '(? '(? '(?:(? '(? '(? '(?:(? '(? '(? tags + * @return string HTML output + */ + protected function formParagraphs($text, $wrap_in_p = true) { + // Strip leading and trailing lines: + $text = preg_replace('/\A\n+|\n+\z/', '', $text); + + $grafs = preg_split('/\n{2,}/', $text, -1, PREG_SPLIT_NO_EMPTY); + + // Wrap

    tags and unhashify HTML blocks + foreach ($grafs as $key => $value) { + $value = trim($this->runSpanGamut($value)); + + // Check if this should be enclosed in a paragraph. + // Clean tag hashes & block tag hashes are left alone. + $is_p = $wrap_in_p && !preg_match('/^B\x1A[0-9]+B|^C\x1A[0-9]+C$/', $value); + + if ($is_p) { + $value = "

    $value

    "; + } + $grafs[$key] = $value; + } + + // Join grafs in one text, then unhash HTML tags. + $text = implode("\n\n", $grafs); + + // Finish by removing any tag hashes still present in $text. + $text = $this->unhash($text); + + return $text; + } + + + /** + * Footnotes - Strips link definitions from text, stores the URLs and + * titles in hash references. + * @param string $text + * @return string + */ + protected function stripFootnotes($text) { + $less_than_tab = $this->tab_width - 1; + + // Link defs are in the form: [^id]: url "optional title" + $text = preg_replace_callback('{ + ^[ ]{0,' . $less_than_tab . '}\[\^(.+?)\][ ]?: # note_id = $1 + [ ]* + \n? # maybe *one* newline + ( # text = $2 (no blank lines allowed) + (?: + .+ # actual text + | + \n # newlines but + (?!\[.+?\][ ]?:\s)# negative lookahead for footnote or link definition marker. + (?!\n+[ ]{0,3}\S)# ensure line is not blank and followed + # by non-indented content + )* + ) + }xm', + array($this, '_stripFootnotes_callback'), + $text); + return $text; + } + + /** + * Callback for stripping footnotes + * @param array $matches + * @return string + */ + protected function _stripFootnotes_callback($matches) { + $note_id = $this->fn_id_prefix . $matches[1]; + $this->footnotes[$note_id] = $this->outdent($matches[2]); + return ''; // String that will replace the block + } + + /** + * Replace footnote references in $text [^id] with a special text-token + * which will be replaced by the actual footnote marker in appendFootnotes. + * @param string $text + * @return string + */ + protected function doFootnotes($text) { + if (!$this->in_anchor) { + $text = preg_replace('{\[\^(.+?)\]}', "F\x1Afn:\\1\x1A:", $text); + } + return $text; + } + + /** + * Append footnote list to text + * @param string $text + * @return string + */ + protected function appendFootnotes($text) { + $text = preg_replace_callback('{F\x1Afn:(.*?)\x1A:}', + array($this, '_appendFootnotes_callback'), $text); + + if ( ! empty( $this->footnotes_ordered ) ) { + $this->_doFootnotes(); + if ( ! $this->omit_footnotes ) { + $text .= "\n\n"; + $text .= "
    \n"; + $text .= "empty_element_suffix . "\n"; + $text .= $this->footnotes_assembled; + $text .= "
    "; + } + } + return $text; + } + + + /** + * Generates the HTML for footnotes. Called by appendFootnotes, even if + * footnotes are not being appended. + * @return void + */ + protected function _doFootnotes() { + $attr = array(); + if ($this->fn_backlink_class !== "") { + $class = $this->fn_backlink_class; + $class = $this->encodeAttribute($class); + $attr['class'] = " class=\"$class\""; + } + $attr['role'] = " role=\"doc-backlink\""; + $num = 0; + + $text = "
      \n\n"; + while (!empty($this->footnotes_ordered)) { + $footnote = reset($this->footnotes_ordered); + $note_id = key($this->footnotes_ordered); + unset($this->footnotes_ordered[$note_id]); + $ref_count = $this->footnotes_ref_count[$note_id]; + unset($this->footnotes_ref_count[$note_id]); + unset($this->footnotes[$note_id]); + + $footnote .= "\n"; // Need to append newline before parsing. + $footnote = $this->runBlockGamut("$footnote\n"); + $footnote = preg_replace_callback('{F\x1Afn:(.*?)\x1A:}', + array($this, '_appendFootnotes_callback'), $footnote); + + $num++; + $note_id = $this->encodeAttribute($note_id); + + // Prepare backlink, multiple backlinks if multiple references + // Do not create empty backlinks if the html is blank + $backlink = ""; + if (!empty($this->fn_backlink_html)) { + for ($ref_num = 1; $ref_num <= $ref_count; ++$ref_num) { + if (!empty($this->fn_backlink_title)) { + $attr['title'] = ' title="' . $this->encodeAttribute($this->fn_backlink_title) . '"'; + } + if (!empty($this->fn_backlink_label)) { + $attr['label'] = ' aria-label="' . $this->encodeAttribute($this->fn_backlink_label) . '"'; + } + $parsed_attr = $this->parseFootnotePlaceholders( + implode('', $attr), + $num, + $ref_num + ); + $backlink_text = $this->parseFootnotePlaceholders( + $this->fn_backlink_html, + $num, + $ref_num + ); + $ref_count_mark = $ref_num > 1 ? $ref_num : ''; + $backlink .= " $backlink_text"; + } + $backlink = trim($backlink); + } + + // Add backlink to last paragraph; create new paragraph if needed. + if (!empty($backlink)) { + if (preg_match('{

      $}', $footnote)) { + $footnote = substr($footnote, 0, -4) . " $backlink

      "; + } else { + $footnote .= "\n\n

      $backlink

      "; + } + } + + $text .= "
    1. \n"; + $text .= $footnote . "\n"; + $text .= "
    2. \n\n"; + } + $text .= "
    \n"; + + $this->footnotes_assembled = $text; + } + + /** + * Callback for appending footnotes + * @param array $matches + * @return string + */ + protected function _appendFootnotes_callback($matches) { + $node_id = $this->fn_id_prefix . $matches[1]; + + // Create footnote marker only if it has a corresponding footnote *and* + // the footnote hasn't been used by another marker. + if (isset($this->footnotes[$node_id])) { + $num =& $this->footnotes_numbers[$node_id]; + if (!isset($num)) { + // Transfer footnote content to the ordered list and give it its + // number + $this->footnotes_ordered[$node_id] = $this->footnotes[$node_id]; + $this->footnotes_ref_count[$node_id] = 1; + $num = $this->footnote_counter++; + $ref_count_mark = ''; + } else { + $ref_count_mark = $this->footnotes_ref_count[$node_id] += 1; + } + + $attr = ""; + if ($this->fn_link_class !== "") { + $class = $this->fn_link_class; + $class = $this->encodeAttribute($class); + $attr .= " class=\"$class\""; + } + if ($this->fn_link_title !== "") { + $title = $this->fn_link_title; + $title = $this->encodeAttribute($title); + $attr .= " title=\"$title\""; + } + $attr .= " role=\"doc-noteref\""; + + $attr = str_replace("%%", $num, $attr); + $node_id = $this->encodeAttribute($node_id); + + return + "". + "$num". + ""; + } + + return "[^" . $matches[1] . "]"; + } + + /** + * Build footnote label by evaluating any placeholders. + * - ^^ footnote number + * - %% footnote reference number (Nth reference to footnote number) + * @param string $label + * @param int $footnote_number + * @param int $reference_number + * @return string + */ + protected function parseFootnotePlaceholders($label, $footnote_number, $reference_number) { + return str_replace( + array('^^', '%%'), + array($footnote_number, $reference_number), + $label + ); + } + + + /** + * Abbreviations - strips abbreviations from text, stores titles in hash + * references. + * @param string $text + * @return string + */ + protected function stripAbbreviations($text) { + $less_than_tab = $this->tab_width - 1; + + // Link defs are in the form: [id]*: url "optional title" + $text = preg_replace_callback('{ + ^[ ]{0,' . $less_than_tab . '}\*\[(.+?)\][ ]?: # abbr_id = $1 + (.*) # text = $2 (no blank lines allowed) + }xm', + array($this, '_stripAbbreviations_callback'), + $text); + return $text; + } + + /** + * Callback for stripping abbreviations + * @param array $matches + * @return string + */ + protected function _stripAbbreviations_callback($matches) { + $abbr_word = $matches[1]; + $abbr_desc = $matches[2]; + if ($this->abbr_word_re) { + $this->abbr_word_re .= '|'; + } + $this->abbr_word_re .= preg_quote($abbr_word); + $this->abbr_desciptions[$abbr_word] = trim($abbr_desc); + return ''; // String that will replace the block + } + + /** + * Find defined abbreviations in text and wrap them in elements. + * @param string $text + * @return string + */ + protected function doAbbreviations($text) { + if ($this->abbr_word_re) { + // cannot use the /x modifier because abbr_word_re may + // contain significant spaces: + $text = preg_replace_callback('{' . + '(?abbr_word_re . ')' . + '(?![\w\x1A])' . + '}', + array($this, '_doAbbreviations_callback'), $text); + } + return $text; + } + + /** + * Callback for processing abbreviations + * @param array $matches + * @return string + */ + protected function _doAbbreviations_callback($matches) { + $abbr = $matches[0]; + if (isset($this->abbr_desciptions[$abbr])) { + $desc = $this->abbr_desciptions[$abbr]; + if (empty($desc)) { + return $this->hashPart("$abbr"); + } + $desc = $this->encodeAttribute($desc); + return $this->hashPart("$abbr"); + } + return $matches[0]; + } +} diff --git a/lib/michelf/Michelf/MarkdownInterface.inc.php b/lib/michelf/Michelf/MarkdownInterface.inc.php new file mode 100644 index 0000000000..c4e9ac7f64 --- /dev/null +++ b/lib/michelf/Michelf/MarkdownInterface.inc.php @@ -0,0 +1,9 @@ + + * @copyright 2004-2021 Michel Fortin + * @copyright (Original Markdown) 2004-2006 John Gruber + */ + +namespace Michelf; + +/** + * Markdown Parser Interface + */ +interface MarkdownInterface { + /** + * Initialize the parser and return the result of its transform method. + * This will work fine for derived classes too. + * + * @api + * + * @param string $text + * @return string + */ + public static function defaultTransform($text); + + /** + * Main function. Performs some preprocessing on the input text + * and pass it through the document gamut. + * + * @api + * + * @param string $text + * @return string + */ + public function transform($text); +} diff --git a/lib/michelf/michelf.class.php b/lib/michelf/michelf.class.php new file mode 100644 index 0000000000..6f55684fb5 --- /dev/null +++ b/lib/michelf/michelf.class.php @@ -0,0 +1,27 @@ +fn_id_prefix = "post22-"; + $html = $parser->transform($mdCodes); + + return "
    $html
    "; + } +} diff --git a/lib/parsedownextraplugin/LICENSE.txt b/lib/parsedownextraplugin/LICENSE.txt deleted file mode 100644 index 8e7c764d16..0000000000 --- a/lib/parsedownextraplugin/LICENSE.txt +++ /dev/null @@ -1,20 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2013-2018 Emanuil Rusev, erusev.com - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/lib/parsedownextraplugin/parsedown.php b/lib/parsedownextraplugin/parsedown.php deleted file mode 100644 index 69147dc2ac..0000000000 --- a/lib/parsedownextraplugin/parsedown.php +++ /dev/null @@ -1,1992 +0,0 @@ -textElements($text); - - # convert to markup - $markup = $this->elements($Elements); - - # trim line breaks - $markup = trim($markup, "\n"); - - return $markup; - } - - protected function textElements($text) - { - # make sure no definitions are set - $this->DefinitionData = array(); - - # standardize line breaks - $text = str_replace(array("\r\n", "\r"), "\n", $text); - - # remove surrounding line breaks - $text = trim($text, "\n"); - - # split text into lines - $lines = explode("\n", $text); - - # iterate through lines to identify blocks - return $this->linesElements($lines); - } - - # - # Setters - # - - function setBreaksEnabled($breaksEnabled) - { - $this->breaksEnabled = $breaksEnabled; - - return $this; - } - - protected $breaksEnabled; - - function setMarkupEscaped($markupEscaped) - { - $this->markupEscaped = $markupEscaped; - - return $this; - } - - protected $markupEscaped; - - function setUrlsLinked($urlsLinked) - { - $this->urlsLinked = $urlsLinked; - - return $this; - } - - protected $urlsLinked = true; - - function setSafeMode($safeMode) - { - $this->safeMode = (bool) $safeMode; - - return $this; - } - - protected $safeMode; - - function setStrictMode($strictMode) - { - $this->strictMode = (bool) $strictMode; - - return $this; - } - - protected $strictMode; - - protected $safeLinksWhitelist = array( - 'http://', - 'https://', - 'ftp://', - 'ftps://', - 'mailto:', - 'tel:', - 'data:image/png;base64,', - 'data:image/gif;base64,', - 'data:image/jpeg;base64,', - 'irc:', - 'ircs:', - 'git:', - 'ssh:', - 'news:', - 'steam:', - ); - - # - # Lines - # - - protected $BlockTypes = array( - '#' => array('Header'), - '*' => array('Rule', 'List'), - '+' => array('List'), - '-' => array('SetextHeader', 'Table', 'Rule', 'List'), - '0' => array('List'), - '1' => array('List'), - '2' => array('List'), - '3' => array('List'), - '4' => array('List'), - '5' => array('List'), - '6' => array('List'), - '7' => array('List'), - '8' => array('List'), - '9' => array('List'), - ':' => array('Table'), - '<' => array('Comment', 'Markup'), - '=' => array('SetextHeader'), - '>' => array('Quote'), - '[' => array('Reference'), - '_' => array('Rule'), - '`' => array('FencedCode'), - '|' => array('Table'), - '~' => array('FencedCode'), - ); - - # ~ - - protected $unmarkedBlockTypes = array( - 'Code', - ); - - # - # Blocks - # - - protected function lines(array $lines) - { - return $this->elements($this->linesElements($lines)); - } - - protected function linesElements(array $lines) - { - $Elements = array(); - $CurrentBlock = null; - - foreach ($lines as $line) - { - if (chop($line) === '') - { - if (isset($CurrentBlock)) - { - $CurrentBlock['interrupted'] = (isset($CurrentBlock['interrupted']) - ? $CurrentBlock['interrupted'] + 1 : 1 - ); - } - - continue; - } - - while (($beforeTab = strstr($line, "\t", true)) !== false) - { - $shortage = 4 - mb_strlen($beforeTab, 'utf-8') % 4; - - $line = $beforeTab - . str_repeat(' ', $shortage) - . substr($line, strlen($beforeTab) + 1) - ; - } - - $indent = strspn($line, ' '); - - $text = $indent > 0 ? substr($line, $indent) : $line; - - # ~ - - $Line = array('body' => $line, 'indent' => $indent, 'text' => $text); - - # ~ - - if (isset($CurrentBlock['continuable'])) - { - $methodName = 'block' . $CurrentBlock['type'] . 'Continue'; - $Block = $this->$methodName($Line, $CurrentBlock); - - if (isset($Block)) - { - $CurrentBlock = $Block; - - continue; - } - else - { - if ($this->isBlockCompletable($CurrentBlock['type'])) - { - $methodName = 'block' . $CurrentBlock['type'] . 'Complete'; - $CurrentBlock = $this->$methodName($CurrentBlock); - } - } - } - - # ~ - - $marker = $text[0]; - - # ~ - - $blockTypes = $this->unmarkedBlockTypes; - - if (isset($this->BlockTypes[$marker])) - { - foreach ($this->BlockTypes[$marker] as $blockType) - { - $blockTypes []= $blockType; - } - } - - # - # ~ - - foreach ($blockTypes as $blockType) - { - $Block = $this->{"block$blockType"}($Line, $CurrentBlock); - - if (isset($Block)) - { - $Block['type'] = $blockType; - - if ( ! isset($Block['identified'])) - { - if (isset($CurrentBlock)) - { - $Elements[] = $this->extractElement($CurrentBlock); - } - - $Block['identified'] = true; - } - - if ($this->isBlockContinuable($blockType)) - { - $Block['continuable'] = true; - } - - $CurrentBlock = $Block; - - continue 2; - } - } - - # ~ - - if (isset($CurrentBlock) and $CurrentBlock['type'] === 'Paragraph') - { - $Block = $this->paragraphContinue($Line, $CurrentBlock); - } - - if (isset($Block)) - { - $CurrentBlock = $Block; - } - else - { - if (isset($CurrentBlock)) - { - $Elements[] = $this->extractElement($CurrentBlock); - } - - $CurrentBlock = $this->paragraph($Line); - - $CurrentBlock['identified'] = true; - } - } - - # ~ - - if (isset($CurrentBlock['continuable']) and $this->isBlockCompletable($CurrentBlock['type'])) - { - $methodName = 'block' . $CurrentBlock['type'] . 'Complete'; - $CurrentBlock = $this->$methodName($CurrentBlock); - } - - # ~ - - if (isset($CurrentBlock)) - { - $Elements[] = $this->extractElement($CurrentBlock); - } - - # ~ - - return $Elements; - } - - protected function extractElement(array $Component) - { - if ( ! isset($Component['element'])) - { - if (isset($Component['markup'])) - { - $Component['element'] = array('rawHtml' => $Component['markup']); - } - elseif (isset($Component['hidden'])) - { - $Component['element'] = array(); - } - } - - return $Component['element']; - } - - protected function isBlockContinuable($Type) - { - return method_exists($this, 'block' . $Type . 'Continue'); - } - - protected function isBlockCompletable($Type) - { - return method_exists($this, 'block' . $Type . 'Complete'); - } - - # - # Code - - protected function blockCode($Line, $Block = null) - { - if (isset($Block) and $Block['type'] === 'Paragraph' and ! isset($Block['interrupted'])) - { - return; - } - - if ($Line['indent'] >= 4) - { - $text = substr($Line['body'], 4); - - $Block = array( - 'element' => array( - 'name' => 'pre', - 'element' => array( - 'name' => 'code', - 'text' => $text, - ), - ), - ); - - return $Block; - } - } - - protected function blockCodeContinue($Line, $Block) - { - if ($Line['indent'] >= 4) - { - if (isset($Block['interrupted'])) - { - $Block['element']['element']['text'] .= str_repeat("\n", $Block['interrupted']); - - unset($Block['interrupted']); - } - - $Block['element']['element']['text'] .= "\n"; - - $text = substr($Line['body'], 4); - - $Block['element']['element']['text'] .= $text; - - return $Block; - } - } - - protected function blockCodeComplete($Block) - { - return $Block; - } - - # - # Comment - - protected function blockComment($Line) - { - if ($this->markupEscaped or $this->safeMode) - { - return; - } - - if (strpos($Line['text'], '') !== false) - { - $Block['closed'] = true; - } - - return $Block; - } - } - - protected function blockCommentContinue($Line, array $Block) - { - if (isset($Block['closed'])) - { - return; - } - - $Block['element']['rawHtml'] .= "\n" . $Line['body']; - - if (strpos($Line['text'], '-->') !== false) - { - $Block['closed'] = true; - } - - return $Block; - } - - # - # Fenced Code - - protected function blockFencedCode($Line) - { - $marker = $Line['text'][0]; - - $openerLength = strspn($Line['text'], $marker); - - if ($openerLength < 3) - { - return; - } - - $infostring = trim(substr($Line['text'], $openerLength), "\t "); - - if (strpos($infostring, '`') !== false) - { - return; - } - - $Element = array( - 'name' => 'code', - 'text' => '', - ); - - if ($infostring !== '') - { - /** - * https://www.w3.org/TR/2011/WD-html5-20110525/elements.html#classes - * Every HTML element may have a class attribute specified. - * The attribute, if specified, must have a value that is a set - * of space-separated tokens representing the various classes - * that the element belongs to. - * [...] - * The space characters, for the purposes of this specification, - * are U+0020 SPACE, U+0009 CHARACTER TABULATION (tab), - * U+000A LINE FEED (LF), U+000C FORM FEED (FF), and - * U+000D CARRIAGE RETURN (CR). - */ - $language = substr($infostring, 0, strcspn($infostring, " \t\n\f\r")); - - $Element['attributes'] = array('class' => "language-$language"); - } - - $Block = array( - 'char' => $marker, - 'openerLength' => $openerLength, - 'element' => array( - 'name' => 'pre', - 'element' => $Element, - ), - ); - - return $Block; - } - - protected function blockFencedCodeContinue($Line, $Block) - { - if (isset($Block['complete'])) - { - return; - } - - if (isset($Block['interrupted'])) - { - $Block['element']['element']['text'] .= str_repeat("\n", $Block['interrupted']); - - unset($Block['interrupted']); - } - - if (($len = strspn($Line['text'], $Block['char'])) >= $Block['openerLength'] - and chop(substr($Line['text'], $len), ' ') === '' - ) { - $Block['element']['element']['text'] = substr($Block['element']['element']['text'], 1); - - $Block['complete'] = true; - - return $Block; - } - - $Block['element']['element']['text'] .= "\n" . $Line['body']; - - return $Block; - } - - protected function blockFencedCodeComplete($Block) - { - return $Block; - } - - # - # Header - - protected function blockHeader($Line) - { - $level = strspn($Line['text'], '#'); - - if ($level > 6) - { - return; - } - - $text = trim($Line['text'], '#'); - - if ($this->strictMode and isset($text[0]) and $text[0] !== ' ') - { - return; - } - - $text = trim($text, ' '); - - $Block = array( - 'element' => array( - 'name' => 'h' . $level, - 'handler' => array( - 'function' => 'lineElements', - 'argument' => $text, - 'destination' => 'elements', - ) - ), - ); - - return $Block; - } - - # - # List - - protected function blockList($Line, array $CurrentBlock = null) - { - list($name, $pattern) = $Line['text'][0] <= '-' ? array('ul', '[*+-]') : array('ol', '[0-9]{1,9}+[.\)]'); - - if (preg_match('/^('.$pattern.'([ ]++|$))(.*+)/', $Line['text'], $matches)) - { - $contentIndent = strlen($matches[2]); - - if ($contentIndent >= 5) - { - $contentIndent -= 1; - $matches[1] = substr($matches[1], 0, -$contentIndent); - $matches[3] = str_repeat(' ', $contentIndent) . $matches[3]; - } - elseif ($contentIndent === 0) - { - $matches[1] .= ' '; - } - - $markerWithoutWhitespace = strstr($matches[1], ' ', true); - - $Block = array( - 'indent' => $Line['indent'], - 'pattern' => $pattern, - 'data' => array( - 'type' => $name, - 'marker' => $matches[1], - 'markerType' => ($name === 'ul' ? $markerWithoutWhitespace : substr($markerWithoutWhitespace, -1)), - ), - 'element' => array( - 'name' => $name, - 'elements' => array(), - ), - ); - $Block['data']['markerTypeRegex'] = preg_quote($Block['data']['markerType'], '/'); - - if ($name === 'ol') - { - $listStart = ltrim(strstr($matches[1], $Block['data']['markerType'], true), '0') ?: '0'; - - if ($listStart !== '1') - { - if ( - isset($CurrentBlock) - and $CurrentBlock['type'] === 'Paragraph' - and ! isset($CurrentBlock['interrupted']) - ) { - return; - } - - $Block['element']['attributes'] = array('start' => $listStart); - } - } - - $Block['li'] = array( - 'name' => 'li', - 'handler' => array( - 'function' => 'li', - 'argument' => !empty($matches[3]) ? array($matches[3]) : array(), - 'destination' => 'elements' - ) - ); - - $Block['element']['elements'] []= & $Block['li']; - - return $Block; - } - } - - protected function blockListContinue($Line, array $Block) - { - if (isset($Block['interrupted']) and empty($Block['li']['handler']['argument'])) - { - return null; - } - - $requiredIndent = ($Block['indent'] + strlen($Block['data']['marker'])); - - if ($Line['indent'] < $requiredIndent - and ( - ( - $Block['data']['type'] === 'ol' - and preg_match('/^[0-9]++'.$Block['data']['markerTypeRegex'].'(?:[ ]++(.*)|$)/', $Line['text'], $matches) - ) or ( - $Block['data']['type'] === 'ul' - and preg_match('/^'.$Block['data']['markerTypeRegex'].'(?:[ ]++(.*)|$)/', $Line['text'], $matches) - ) - ) - ) { - if (isset($Block['interrupted'])) - { - $Block['li']['handler']['argument'] []= ''; - - $Block['loose'] = true; - - unset($Block['interrupted']); - } - - unset($Block['li']); - - $text = isset($matches[1]) ? $matches[1] : ''; - - $Block['indent'] = $Line['indent']; - - $Block['li'] = array( - 'name' => 'li', - 'handler' => array( - 'function' => 'li', - 'argument' => array($text), - 'destination' => 'elements' - ) - ); - - $Block['element']['elements'] []= & $Block['li']; - - return $Block; - } - elseif ($Line['indent'] < $requiredIndent and $this->blockList($Line)) - { - return null; - } - - if ($Line['text'][0] === '[' and $this->blockReference($Line)) - { - return $Block; - } - - if ($Line['indent'] >= $requiredIndent) - { - if (isset($Block['interrupted'])) - { - $Block['li']['handler']['argument'] []= ''; - - $Block['loose'] = true; - - unset($Block['interrupted']); - } - - $text = substr($Line['body'], $requiredIndent); - - $Block['li']['handler']['argument'] []= $text; - - return $Block; - } - - if ( ! isset($Block['interrupted'])) - { - $text = preg_replace('/^[ ]{0,'.$requiredIndent.'}+/', '', $Line['body']); - - $Block['li']['handler']['argument'] []= $text; - - return $Block; - } - } - - protected function blockListComplete(array $Block) - { - if (isset($Block['loose'])) - { - foreach ($Block['element']['elements'] as &$li) - { - if (end($li['handler']['argument']) !== '') - { - $li['handler']['argument'] []= ''; - } - } - } - - return $Block; - } - - # - # Quote - - protected function blockQuote($Line) - { - if (preg_match('/^>[ ]?+(.*+)/', $Line['text'], $matches)) - { - $Block = array( - 'element' => array( - 'name' => 'blockquote', - 'handler' => array( - 'function' => 'linesElements', - 'argument' => (array) $matches[1], - 'destination' => 'elements', - ) - ), - ); - - return $Block; - } - } - - protected function blockQuoteContinue($Line, array $Block) - { - if (isset($Block['interrupted'])) - { - return; - } - - if ($Line['text'][0] === '>' and preg_match('/^>[ ]?+(.*+)/', $Line['text'], $matches)) - { - $Block['element']['handler']['argument'] []= $matches[1]; - - return $Block; - } - - if ( ! isset($Block['interrupted'])) - { - $Block['element']['handler']['argument'] []= $Line['text']; - - return $Block; - } - } - - # - # Rule - - protected function blockRule($Line) - { - $marker = $Line['text'][0]; - - if (substr_count($Line['text'], $marker) >= 3 and chop($Line['text'], " $marker") === '') - { - $Block = array( - 'element' => array( - 'name' => 'hr', - ), - ); - - return $Block; - } - } - - # - # Setext - - protected function blockSetextHeader($Line, array $Block = null) - { - if ( ! isset($Block) or $Block['type'] !== 'Paragraph' or isset($Block['interrupted'])) - { - return; - } - - if ($Line['indent'] < 4 and chop(chop($Line['text'], ' '), $Line['text'][0]) === '') - { - $Block['element']['name'] = $Line['text'][0] === '=' ? 'h1' : 'h2'; - - return $Block; - } - } - - # - # Markup - - protected function blockMarkup($Line) - { - if ($this->markupEscaped or $this->safeMode) - { - return; - } - - if (preg_match('/^<[\/]?+(\w*)(?:[ ]*+'.$this->regexHtmlAttribute.')*+[ ]*+(\/)?>/', $Line['text'], $matches)) - { - $element = strtolower($matches[1]); - - if (in_array($element, $this->textLevelElements)) - { - return; - } - - $Block = array( - 'name' => $matches[1], - 'element' => array( - 'rawHtml' => $Line['text'], - 'autobreak' => true, - ), - ); - - return $Block; - } - } - - protected function blockMarkupContinue($Line, array $Block) - { - if (isset($Block['closed']) or isset($Block['interrupted'])) - { - return; - } - - $Block['element']['rawHtml'] .= "\n" . $Line['body']; - - return $Block; - } - - # - # Reference - - protected function blockReference($Line) - { - if (strpos($Line['text'], ']') !== false - and preg_match('/^\[(.+?)\]:[ ]*+?(?:[ ]+["\'(](.+)["\')])?[ ]*+$/', $Line['text'], $matches) - ) { - $id = strtolower($matches[1]); - - $Data = array( - 'url' => $matches[2], - 'title' => isset($matches[3]) ? $matches[3] : null, - ); - - $this->DefinitionData['Reference'][$id] = $Data; - - $Block = array( - 'element' => array(), - ); - - return $Block; - } - } - - # - # Table - - protected function blockTable($Line, array $Block = null) - { - if ( ! isset($Block) or $Block['type'] !== 'Paragraph' or isset($Block['interrupted'])) - { - return; - } - - if ( - strpos($Block['element']['handler']['argument'], '|') === false - and strpos($Line['text'], '|') === false - and strpos($Line['text'], ':') === false - or strpos($Block['element']['handler']['argument'], "\n") !== false - ) { - return; - } - - if (chop($Line['text'], ' -:|') !== '') - { - return; - } - - $alignments = array(); - - $divider = $Line['text']; - - $divider = trim($divider); - $divider = trim($divider, '|'); - - $dividerCells = explode('|', $divider); - - foreach ($dividerCells as $dividerCell) - { - $dividerCell = trim($dividerCell); - - if ($dividerCell === '') - { - return; - } - - $alignment = null; - - if ($dividerCell[0] === ':') - { - $alignment = 'left'; - } - - if (substr($dividerCell, - 1) === ':') - { - $alignment = $alignment === 'left' ? 'center' : 'right'; - } - - $alignments []= $alignment; - } - - # ~ - - $HeaderElements = array(); - - $header = $Block['element']['handler']['argument']; - - $header = trim($header); - $header = trim($header, '|'); - - $headerCells = explode('|', $header); - - if (count($headerCells) !== count($alignments)) - { - return; - } - - foreach ($headerCells as $index => $headerCell) - { - $headerCell = trim($headerCell); - - $HeaderElement = array( - 'name' => 'th', - 'handler' => array( - 'function' => 'lineElements', - 'argument' => $headerCell, - 'destination' => 'elements', - ) - ); - - if (isset($alignments[$index])) - { - $alignment = $alignments[$index]; - - $HeaderElement['attributes'] = array( - 'style' => "text-align: $alignment;", - ); - } - - $HeaderElements []= $HeaderElement; - } - - # ~ - - $Block = array( - 'alignments' => $alignments, - 'identified' => true, - 'element' => array( - 'name' => 'table', - 'elements' => array(), - ), - ); - - $Block['element']['elements'] []= array( - 'name' => 'thead', - ); - - $Block['element']['elements'] []= array( - 'name' => 'tbody', - 'elements' => array(), - ); - - $Block['element']['elements'][0]['elements'] []= array( - 'name' => 'tr', - 'elements' => $HeaderElements, - ); - - return $Block; - } - - protected function blockTableContinue($Line, array $Block) - { - if (isset($Block['interrupted'])) - { - return; - } - - if (count($Block['alignments']) === 1 or $Line['text'][0] === '|' or strpos($Line['text'], '|')) - { - $Elements = array(); - - $row = $Line['text']; - - $row = trim($row); - $row = trim($row, '|'); - - preg_match_all('/(?:(\\\\[|])|[^|`]|`[^`]++`|`)++/', $row, $matches); - - $cells = array_slice($matches[0], 0, count($Block['alignments'])); - - foreach ($cells as $index => $cell) - { - $cell = trim($cell); - - $Element = array( - 'name' => 'td', - 'handler' => array( - 'function' => 'lineElements', - 'argument' => $cell, - 'destination' => 'elements', - ) - ); - - if (isset($Block['alignments'][$index])) - { - $Element['attributes'] = array( - 'style' => 'text-align: ' . $Block['alignments'][$index] . ';', - ); - } - - $Elements []= $Element; - } - - $Element = array( - 'name' => 'tr', - 'elements' => $Elements, - ); - - $Block['element']['elements'][1]['elements'] []= $Element; - - return $Block; - } - } - - # - # ~ - # - - protected function paragraph($Line) - { - return array( - 'type' => 'Paragraph', - 'element' => array( - 'name' => 'p', - 'handler' => array( - 'function' => 'lineElements', - 'argument' => $Line['text'], - 'destination' => 'elements', - ), - ), - ); - } - - protected function paragraphContinue($Line, array $Block) - { - if (isset($Block['interrupted'])) - { - return; - } - - $Block['element']['handler']['argument'] .= "\n".$Line['text']; - - return $Block; - } - - # - # Inline Elements - # - - protected $InlineTypes = array( - '!' => array('Image'), - '&' => array('SpecialCharacter'), - '*' => array('Emphasis'), - ':' => array('Url'), - '<' => array('UrlTag', 'EmailTag', 'Markup'), - '[' => array('Link'), - '_' => array('Emphasis'), - '`' => array('Code'), - '~' => array('Strikethrough'), - '\\' => array('EscapeSequence'), - ); - - # ~ - - protected $inlineMarkerList = '!*_&[:<`~\\'; - - # - # ~ - # - - public function line($text, $nonNestables = array()) - { - return $this->elements($this->lineElements($text, $nonNestables)); - } - - protected function lineElements($text, $nonNestables = array()) - { - # standardize line breaks - $text = str_replace(array("\r\n", "\r"), "\n", $text); - - $Elements = array(); - - $nonNestables = (empty($nonNestables) - ? array() - : array_combine($nonNestables, $nonNestables) - ); - - # $excerpt is based on the first occurrence of a marker - - while ($excerpt = strpbrk($text, $this->inlineMarkerList)) - { - $marker = $excerpt[0]; - - $markerPosition = strlen($text) - strlen($excerpt); - - $Excerpt = array('text' => $excerpt, 'context' => $text); - - foreach ($this->InlineTypes[$marker] as $inlineType) - { - # check to see if the current inline type is nestable in the current context - - if (isset($nonNestables[$inlineType])) - { - continue; - } - - $Inline = $this->{"inline$inlineType"}($Excerpt); - - if ( ! isset($Inline)) - { - continue; - } - - # makes sure that the inline belongs to "our" marker - - if (isset($Inline['position']) and $Inline['position'] > $markerPosition) - { - continue; - } - - # sets a default inline position - - if ( ! isset($Inline['position'])) - { - $Inline['position'] = $markerPosition; - } - - # cause the new element to 'inherit' our non nestables - - - $Inline['element']['nonNestables'] = isset($Inline['element']['nonNestables']) - ? array_merge($Inline['element']['nonNestables'], $nonNestables) - : $nonNestables - ; - - # the text that comes before the inline - $unmarkedText = substr($text, 0, $Inline['position']); - - # compile the unmarked text - $InlineText = $this->inlineText($unmarkedText); - $Elements[] = $InlineText['element']; - - # compile the inline - $Elements[] = $this->extractElement($Inline); - - # remove the examined text - $text = substr($text, $Inline['position'] + $Inline['extent']); - - continue 2; - } - - # the marker does not belong to an inline - - $unmarkedText = substr($text, 0, $markerPosition + 1); - - $InlineText = $this->inlineText($unmarkedText); - $Elements[] = $InlineText['element']; - - $text = substr($text, $markerPosition + 1); - } - - $InlineText = $this->inlineText($text); - $Elements[] = $InlineText['element']; - - foreach ($Elements as &$Element) - { - if ( ! isset($Element['autobreak'])) - { - $Element['autobreak'] = false; - } - } - - return $Elements; - } - - # - # ~ - # - - protected function inlineText($text) - { - $Inline = array( - 'extent' => strlen($text), - 'element' => array(), - ); - - $Inline['element']['elements'] = self::pregReplaceElements( - $this->breaksEnabled ? '/[ ]*+\n/' : '/(?:[ ]*+\\\\|[ ]{2,}+)\n/', - array( - array('name' => 'br'), - array('text' => "\n"), - ), - $text - ); - - return $Inline; - } - - protected function inlineCode($Excerpt) - { - $marker = $Excerpt['text'][0]; - - if (preg_match('/^(['.$marker.']++)[ ]*+(.+?)[ ]*+(? strlen($matches[0]), - 'element' => array( - 'name' => 'code', - 'text' => $text, - ), - ); - } - } - - protected function inlineEmailTag($Excerpt) - { - $hostnameLabel = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?'; - - $commonMarkEmail = '[a-zA-Z0-9.!#$%&\'*+\/=?^_`{|}~-]++@' - . $hostnameLabel . '(?:\.' . $hostnameLabel . ')*'; - - if (strpos($Excerpt['text'], '>') !== false - and preg_match("/^<((mailto:)?$commonMarkEmail)>/i", $Excerpt['text'], $matches) - ){ - $url = $matches[1]; - - if ( ! isset($matches[2])) - { - $url = "mailto:$url"; - } - - return array( - 'extent' => strlen($matches[0]), - 'element' => array( - 'name' => 'a', - 'text' => $matches[1], - 'attributes' => array( - 'href' => $url, - ), - ), - ); - } - } - - protected function inlineEmphasis($Excerpt) - { - if ( ! isset($Excerpt['text'][1])) - { - return; - } - - $marker = $Excerpt['text'][0]; - - if ($Excerpt['text'][1] === $marker and preg_match($this->StrongRegex[$marker], $Excerpt['text'], $matches)) - { - $emphasis = 'strong'; - } - elseif (preg_match($this->EmRegex[$marker], $Excerpt['text'], $matches)) - { - $emphasis = 'em'; - } - else - { - return; - } - - return array( - 'extent' => strlen($matches[0]), - 'element' => array( - 'name' => $emphasis, - 'handler' => array( - 'function' => 'lineElements', - 'argument' => $matches[1], - 'destination' => 'elements', - ) - ), - ); - } - - protected function inlineEscapeSequence($Excerpt) - { - if (isset($Excerpt['text'][1]) and in_array($Excerpt['text'][1], $this->specialCharacters)) - { - return array( - 'element' => array('rawHtml' => $Excerpt['text'][1]), - 'extent' => 2, - ); - } - } - - protected function inlineImage($Excerpt) - { - if ( ! isset($Excerpt['text'][1]) or $Excerpt['text'][1] !== '[') - { - return; - } - - $Excerpt['text']= substr($Excerpt['text'], 1); - - $Link = $this->inlineLink($Excerpt); - - if ($Link === null) - { - return; - } - - $Inline = array( - 'extent' => $Link['extent'] + 1, - 'element' => array( - 'name' => 'img', - 'attributes' => array( - 'src' => $Link['element']['attributes']['href'], - 'alt' => $Link['element']['handler']['argument'], - ), - 'autobreak' => true, - ), - ); - - $Inline['element']['attributes'] += $Link['element']['attributes']; - - unset($Inline['element']['attributes']['href']); - - return $Inline; - } - - protected function inlineLink($Excerpt) - { - $Element = array( - 'name' => 'a', - 'handler' => array( - 'function' => 'lineElements', - 'argument' => null, - 'destination' => 'elements', - ), - 'nonNestables' => array('Url', 'Link'), - 'attributes' => array( - 'href' => null, - 'title' => null, - ), - ); - - $extent = 0; - - $remainder = $Excerpt['text']; - - if (preg_match('/\[((?:[^][]++|(?R))*+)\]/', $remainder, $matches)) - { - $Element['handler']['argument'] = $matches[1]; - - $extent += strlen($matches[0]); - - $remainder = substr($remainder, $extent); - } - else - { - return; - } - - if (preg_match('/^[(]\s*+((?:[^ ()]++|[(][^ )]+[)])++)(?:[ ]+("[^"]*+"|\'[^\']*+\'))?\s*+[)]/', $remainder, $matches)) - { - $Element['attributes']['href'] = $matches[1]; - - if (isset($matches[2])) - { - $Element['attributes']['title'] = substr($matches[2], 1, - 1); - } - - $extent += strlen($matches[0]); - } - else - { - if (preg_match('/^\s*\[(.*?)\]/', $remainder, $matches)) - { - $definition = strlen($matches[1]) ? $matches[1] : $Element['handler']['argument']; - $definition = strtolower($definition); - - $extent += strlen($matches[0]); - } - else - { - $definition = strtolower($Element['handler']['argument']); - } - - if ( ! isset($this->DefinitionData['Reference'][$definition])) - { - return; - } - - $Definition = $this->DefinitionData['Reference'][$definition]; - - $Element['attributes']['href'] = $Definition['url']; - $Element['attributes']['title'] = $Definition['title']; - } - - return array( - 'extent' => $extent, - 'element' => $Element, - ); - } - - protected function inlineMarkup($Excerpt) - { - if ($this->markupEscaped or $this->safeMode or strpos($Excerpt['text'], '>') === false) - { - return; - } - - if ($Excerpt['text'][1] === '/' and preg_match('/^<\/\w[\w-]*+[ ]*+>/s', $Excerpt['text'], $matches)) - { - return array( - 'element' => array('rawHtml' => $matches[0]), - 'extent' => strlen($matches[0]), - ); - } - - if ($Excerpt['text'][1] === '!' and preg_match('/^/s', $Excerpt['text'], $matches)) - { - return array( - 'element' => array('rawHtml' => $matches[0]), - 'extent' => strlen($matches[0]), - ); - } - - if ($Excerpt['text'][1] !== ' ' and preg_match('/^<\w[\w-]*+(?:[ ]*+'.$this->regexHtmlAttribute.')*+[ ]*+\/?>/s', $Excerpt['text'], $matches)) - { - return array( - 'element' => array('rawHtml' => $matches[0]), - 'extent' => strlen($matches[0]), - ); - } - } - - protected function inlineSpecialCharacter($Excerpt) - { - if (substr($Excerpt['text'], 1, 1) !== ' ' and strpos($Excerpt['text'], ';') !== false - and preg_match('/^&(#?+[0-9a-zA-Z]++);/', $Excerpt['text'], $matches) - ) { - return array( - 'element' => array('rawHtml' => '&' . $matches[1] . ';'), - 'extent' => strlen($matches[0]), - ); - } - - return; - } - - protected function inlineStrikethrough($Excerpt) - { - if ( ! isset($Excerpt['text'][1])) - { - return; - } - - if ($Excerpt['text'][1] === '~' and preg_match('/^~~(?=\S)(.+?)(?<=\S)~~/', $Excerpt['text'], $matches)) - { - return array( - 'extent' => strlen($matches[0]), - 'element' => array( - 'name' => 'del', - 'handler' => array( - 'function' => 'lineElements', - 'argument' => $matches[1], - 'destination' => 'elements', - ) - ), - ); - } - } - - protected function inlineUrl($Excerpt) - { - if ($this->urlsLinked !== true or ! isset($Excerpt['text'][2]) or $Excerpt['text'][2] !== '/') - { - return; - } - - if (strpos($Excerpt['context'], 'http') !== false - and preg_match('/\bhttps?+:[\/]{2}[^\s<]+\b\/*+/ui', $Excerpt['context'], $matches, PREG_OFFSET_CAPTURE) - ) { - $url = $matches[0][0]; - - $Inline = array( - 'extent' => strlen($matches[0][0]), - 'position' => $matches[0][1], - 'element' => array( - 'name' => 'a', - 'text' => $url, - 'attributes' => array( - 'href' => $url, - ), - ), - ); - - return $Inline; - } - } - - protected function inlineUrlTag($Excerpt) - { - if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<(\w++:\/{2}[^ >]++)>/i', $Excerpt['text'], $matches)) - { - $url = $matches[1]; - - return array( - 'extent' => strlen($matches[0]), - 'element' => array( - 'name' => 'a', - 'text' => $url, - 'attributes' => array( - 'href' => $url, - ), - ), - ); - } - } - - # ~ - - protected function unmarkedText($text) - { - $Inline = $this->inlineText($text); - return $this->element($Inline['element']); - } - - # - # Handlers - # - - protected function handle(array $Element) - { - if (isset($Element['handler'])) - { - if (!isset($Element['nonNestables'])) - { - $Element['nonNestables'] = array(); - } - - if (is_string($Element['handler'])) - { - $function = $Element['handler']; - $argument = $Element['text']; - unset($Element['text']); - $destination = 'rawHtml'; - } - else - { - $function = $Element['handler']['function']; - $argument = $Element['handler']['argument']; - $destination = $Element['handler']['destination']; - } - - $Element[$destination] = $this->{$function}($argument, $Element['nonNestables']); - - if ($destination === 'handler') - { - $Element = $this->handle($Element); - } - - unset($Element['handler']); - } - - return $Element; - } - - protected function handleElementRecursive(array $Element) - { - return $this->elementApplyRecursive(array($this, 'handle'), $Element); - } - - protected function handleElementsRecursive(array $Elements) - { - return $this->elementsApplyRecursive(array($this, 'handle'), $Elements); - } - - protected function elementApplyRecursive($closure, array $Element) - { - $Element = call_user_func($closure, $Element); - - if (isset($Element['elements'])) - { - $Element['elements'] = $this->elementsApplyRecursive($closure, $Element['elements']); - } - elseif (isset($Element['element'])) - { - $Element['element'] = $this->elementApplyRecursive($closure, $Element['element']); - } - - return $Element; - } - - protected function elementApplyRecursiveDepthFirst($closure, array $Element) - { - if (isset($Element['elements'])) - { - $Element['elements'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['elements']); - } - elseif (isset($Element['element'])) - { - $Element['element'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['element']); - } - - $Element = call_user_func($closure, $Element); - - return $Element; - } - - protected function elementsApplyRecursive($closure, array $Elements) - { - foreach ($Elements as &$Element) - { - $Element = $this->elementApplyRecursive($closure, $Element); - } - - return $Elements; - } - - protected function elementsApplyRecursiveDepthFirst($closure, array $Elements) - { - foreach ($Elements as &$Element) - { - $Element = $this->elementApplyRecursiveDepthFirst($closure, $Element); - } - - return $Elements; - } - - protected function element(array $Element) - { - if ($this->safeMode) - { - $Element = $this->sanitiseElement($Element); - } - - # identity map if element has no handler - $Element = $this->handle($Element); - - $hasName = isset($Element['name']); - - $markup = ''; - - if ($hasName) - { - $markup .= '<' . $Element['name']; - - if (isset($Element['attributes'])) - { - foreach ($Element['attributes'] as $name => $value) - { - if ($value === null) - { - continue; - } - - $markup .= " $name=\"".self::escape($value).'"'; - } - } - } - - $permitRawHtml = false; - - if (isset($Element['text'])) - { - $text = $Element['text']; - } - // very strongly consider an alternative if you're writing an - // extension - elseif (isset($Element['rawHtml'])) - { - $text = $Element['rawHtml']; - - $allowRawHtmlInSafeMode = isset($Element['allowRawHtmlInSafeMode']) && $Element['allowRawHtmlInSafeMode']; - $permitRawHtml = !$this->safeMode || $allowRawHtmlInSafeMode; - } - - $hasContent = isset($text) || isset($Element['element']) || isset($Element['elements']); - - if ($hasContent) - { - $markup .= $hasName ? '>' : ''; - - if (isset($Element['elements'])) - { - $markup .= $this->elements($Element['elements']); - } - elseif (isset($Element['element'])) - { - $markup .= $this->element($Element['element']); - } - else - { - if (!$permitRawHtml) - { - $markup .= self::escape($text, true); - } - else - { - $markup .= $text; - } - } - - $markup .= $hasName ? '' : ''; - } - elseif ($hasName) - { - $markup .= ' />'; - } - - return $markup; - } - - protected function elements(array $Elements) - { - $markup = ''; - - $autoBreak = true; - - foreach ($Elements as $Element) - { - if (empty($Element)) - { - continue; - } - - $autoBreakNext = (isset($Element['autobreak']) - ? $Element['autobreak'] : isset($Element['name']) - ); - // (autobreak === false) covers both sides of an element - $autoBreak = !$autoBreak ? $autoBreak : $autoBreakNext; - - $markup .= ($autoBreak ? "\n" : '') . $this->element($Element); - $autoBreak = $autoBreakNext; - } - - $markup .= $autoBreak ? "\n" : ''; - - return $markup; - } - - # ~ - - protected function li($lines) - { - $Elements = $this->linesElements($lines); - - if ( ! in_array('', $lines) - and isset($Elements[0]) and isset($Elements[0]['name']) - and $Elements[0]['name'] === 'p' - ) { - unset($Elements[0]['name']); - } - - return $Elements; - } - - # - # AST Convenience - # - - /** - * Replace occurrences $regexp with $Elements in $text. Return an array of - * elements representing the replacement. - */ - protected static function pregReplaceElements($regexp, $Elements, $text) - { - $newElements = array(); - - while (preg_match($regexp, $text, $matches, PREG_OFFSET_CAPTURE)) - { - $offset = $matches[0][1]; - $before = substr($text, 0, $offset); - $after = substr($text, $offset + strlen($matches[0][0])); - - $newElements[] = array('text' => $before); - - foreach ($Elements as $Element) - { - $newElements[] = $Element; - } - - $text = $after; - } - - $newElements[] = array('text' => $text); - - return $newElements; - } - - # - # Deprecated Methods - # - - function parse($text) - { - $markup = $this->text($text); - - return $markup; - } - - protected function sanitiseElement(array $Element) - { - static $goodAttribute = '/^[a-zA-Z0-9][a-zA-Z0-9-_]*+$/'; - static $safeUrlNameToAtt = array( - 'a' => 'href', - 'img' => 'src', - ); - - if ( ! isset($Element['name'])) - { - unset($Element['attributes']); - return $Element; - } - - if (isset($safeUrlNameToAtt[$Element['name']])) - { - $Element = $this->filterUnsafeUrlInAttribute($Element, $safeUrlNameToAtt[$Element['name']]); - } - - if ( ! empty($Element['attributes'])) - { - foreach ($Element['attributes'] as $att => $val) - { - # filter out badly parsed attribute - if ( ! preg_match($goodAttribute, $att)) - { - unset($Element['attributes'][$att]); - } - # dump onevent attribute - elseif (self::striAtStart($att, 'on')) - { - unset($Element['attributes'][$att]); - } - } - } - - return $Element; - } - - protected function filterUnsafeUrlInAttribute(array $Element, $attribute) - { - foreach ($this->safeLinksWhitelist as $scheme) - { - if (self::striAtStart($Element['attributes'][$attribute], $scheme)) - { - return $Element; - } - } - - $Element['attributes'][$attribute] = str_replace(':', '%3A', $Element['attributes'][$attribute]); - - return $Element; - } - - # - # Static Methods - # - - protected static function escape($text, $allowQuotes = false) - { - return htmlspecialchars($text, $allowQuotes ? ENT_NOQUOTES : ENT_QUOTES, 'UTF-8'); - } - - protected static function striAtStart($string, $needle) - { - $len = strlen($needle); - - if ($len > strlen($string)) - { - return false; - } - else - { - return strtolower(substr($string, 0, $len)) === strtolower($needle); - } - } - - static function instance($name = 'default') - { - if (isset(self::$instances[$name])) - { - return self::$instances[$name]; - } - - $instance = new static(); - - self::$instances[$name] = $instance; - - return $instance; - } - - private static $instances = array(); - - # - # Fields - # - - protected $DefinitionData; - - # - # Read-Only - - protected $specialCharacters = array( - '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '>', '#', '+', '-', '.', '!', '|', '~' - ); - - protected $StrongRegex = array( - '*' => '/^[*]{2}((?:\\\\\*|[^*]|[*][^*]*+[*])+?)[*]{2}(?![*])/s', - '_' => '/^__((?:\\\\_|[^_]|_[^_]*+_)+?)__(?!_)/us', - ); - - protected $EmRegex = array( - '*' => '/^[*]((?:\\\\\*|[^*]|[*][*][^*]+?[*][*])+?)[*](?![*])/s', - '_' => '/^_((?:\\\\_|[^_]|__[^_]*__)+?)_(?!_)\b/us', - ); - - protected $regexHtmlAttribute = '[a-zA-Z_:][\w:.-]*+(?:\s*+=\s*+(?:[^"\'=<>`\s]+|"[^"]*+"|\'[^\']*+\'))?+'; - - protected $voidElements = array( - 'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', - ); - - protected $textLevelElements = array( - 'a', 'br', 'bdo', 'abbr', 'blink', 'nextid', 'acronym', 'basefont', - 'b', 'em', 'big', 'cite', 'small', 'spacer', 'listing', - 'i', 'rp', 'del', 'code', 'strike', 'marquee', - 'q', 'rt', 'ins', 'font', 'strong', - 's', 'tt', 'kbd', 'mark', - 'u', 'xm', 'sub', 'nobr', - 'sup', 'ruby', - 'var', 'span', - 'wbr', 'time', - ); -} diff --git a/lib/parsedownextraplugin/parsedownextra.php b/lib/parsedownextraplugin/parsedownextra.php deleted file mode 100644 index 2eeeda6e0b..0000000000 --- a/lib/parsedownextraplugin/parsedownextra.php +++ /dev/null @@ -1,686 +0,0 @@ -BlockTypes[':'] []= 'DefinitionList'; - $this->BlockTypes['*'] []= 'Abbreviation'; - - # identify footnote definitions before reference definitions - array_unshift($this->BlockTypes['['], 'Footnote'); - - # identify footnote markers before before links - array_unshift($this->InlineTypes['['], 'FootnoteMarker'); - } - - # - # ~ - - function text($text) - { - $Elements = $this->textElements($text); - - # convert to markup - $markup = $this->elements($Elements); - - # trim line breaks - $markup = trim($markup, "\n"); - - # merge consecutive dl elements - - $markup = preg_replace('/<\/dl>\s+
    \s+/', '', $markup); - - # add footnotes - - if (isset($this->DefinitionData['Footnote'])) - { - $Element = $this->buildFootnoteElement(); - - $markup .= "\n" . $this->element($Element); - } - - return $markup; - } - - # - # Blocks - # - - # - # Abbreviation - - protected function blockAbbreviation($Line) - { - if (preg_match('/^\*\[(.+?)\]:[ ]*(.+?)[ ]*$/', $Line['text'], $matches)) - { - $this->DefinitionData['Abbreviation'][$matches[1]] = $matches[2]; - - $Block = array( - 'hidden' => true, - ); - - return $Block; - } - } - - # - # Footnote - - protected function blockFootnote($Line) - { - if (preg_match('/^\[\^(.+?)\]:[ ]?(.*)$/', $Line['text'], $matches)) - { - $Block = array( - 'label' => $matches[1], - 'text' => $matches[2], - 'hidden' => true, - ); - - return $Block; - } - } - - protected function blockFootnoteContinue($Line, $Block) - { - if ($Line['text'][0] === '[' and preg_match('/^\[\^(.+?)\]:/', $Line['text'])) - { - return; - } - - if (isset($Block['interrupted'])) - { - if ($Line['indent'] >= 4) - { - $Block['text'] .= "\n\n" . $Line['text']; - - return $Block; - } - } - else - { - $Block['text'] .= "\n" . $Line['text']; - - return $Block; - } - } - - protected function blockFootnoteComplete($Block) - { - $this->DefinitionData['Footnote'][$Block['label']] = array( - 'text' => $Block['text'], - 'count' => null, - 'number' => null, - ); - - return $Block; - } - - # - # Definition List - - protected function blockDefinitionList($Line, $Block) - { - if ( ! isset($Block) or $Block['type'] !== 'Paragraph') - { - return; - } - - $Element = array( - 'name' => 'dl', - 'elements' => array(), - ); - - $terms = explode("\n", $Block['element']['handler']['argument']); - - foreach ($terms as $term) - { - $Element['elements'] []= array( - 'name' => 'dt', - 'handler' => array( - 'function' => 'lineElements', - 'argument' => $term, - 'destination' => 'elements' - ), - ); - } - - $Block['element'] = $Element; - - $Block = $this->addDdElement($Line, $Block); - - return $Block; - } - - protected function blockDefinitionListContinue($Line, array $Block) - { - if ($Line['text'][0] === ':') - { - $Block = $this->addDdElement($Line, $Block); - - return $Block; - } - else - { - if (isset($Block['interrupted']) and $Line['indent'] === 0) - { - return; - } - - if (isset($Block['interrupted'])) - { - $Block['dd']['handler']['function'] = 'textElements'; - $Block['dd']['handler']['argument'] .= "\n\n"; - - $Block['dd']['handler']['destination'] = 'elements'; - - unset($Block['interrupted']); - } - - $text = substr($Line['body'], min($Line['indent'], 4)); - - $Block['dd']['handler']['argument'] .= "\n" . $text; - - return $Block; - } - } - - # - # Header - - protected function blockHeader($Line) - { - $Block = parent::blockHeader($Line); - - if ($Block !== null && preg_match('/[ #]*{('.$this->regexAttribute.'+)}[ ]*$/', $Block['element']['handler']['argument'], $matches, PREG_OFFSET_CAPTURE)) - { - $attributeString = $matches[1][0]; - - $Block['element']['attributes'] = $this->parseAttributeData($attributeString); - - $Block['element']['handler']['argument'] = substr($Block['element']['handler']['argument'], 0, $matches[0][1]); - } - - return $Block; - } - - # - # Markup - - protected function blockMarkup($Line) - { - if ($this->markupEscaped or $this->safeMode) - { - return; - } - - if (preg_match('/^<(\w[\w-]*)(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*(\/)?>/', $Line['text'], $matches)) - { - $element = strtolower($matches[1]); - - if (in_array($element, $this->textLevelElements)) - { - return; - } - - $Block = array( - 'name' => $matches[1], - 'depth' => 0, - 'element' => array( - 'rawHtml' => $Line['text'], - 'autobreak' => true, - ), - ); - - $length = strlen($matches[0]); - $remainder = substr($Line['text'], $length); - - if (trim($remainder) === '') - { - if (isset($matches[2]) or in_array($matches[1], $this->voidElements)) - { - $Block['closed'] = true; - $Block['void'] = true; - } - } - else - { - if (isset($matches[2]) or in_array($matches[1], $this->voidElements)) - { - return; - } - if (preg_match('/<\/'.$matches[1].'>[ ]*$/i', $remainder)) - { - $Block['closed'] = true; - } - } - - return $Block; - } - } - - protected function blockMarkupContinue($Line, array $Block) - { - if (isset($Block['closed'])) - { - return; - } - - if (preg_match('/^<'.$Block['name'].'(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*>/i', $Line['text'])) # open - { - $Block['depth'] ++; - } - - if (preg_match('/(.*?)<\/'.$Block['name'].'>[ ]*$/i', $Line['text'], $matches)) # close - { - if ($Block['depth'] > 0) - { - $Block['depth'] --; - } - else - { - $Block['closed'] = true; - } - } - - if (isset($Block['interrupted'])) - { - $Block['element']['rawHtml'] .= "\n"; - unset($Block['interrupted']); - } - - $Block['element']['rawHtml'] .= "\n".$Line['body']; - - return $Block; - } - - protected function blockMarkupComplete($Block) - { - if ( ! isset($Block['void'])) - { - $Block['element']['rawHtml'] = $this->processTag($Block['element']['rawHtml']); - } - - return $Block; - } - - # - # Setext - - protected function blockSetextHeader($Line, array $Block = null) - { - $Block = parent::blockSetextHeader($Line, $Block); - - if ($Block !== null && preg_match('/[ ]*{('.$this->regexAttribute.'+)}[ ]*$/', $Block['element']['handler']['argument'], $matches, PREG_OFFSET_CAPTURE)) - { - $attributeString = $matches[1][0]; - - $Block['element']['attributes'] = $this->parseAttributeData($attributeString); - - $Block['element']['handler']['argument'] = substr($Block['element']['handler']['argument'], 0, $matches[0][1]); - } - - return $Block; - } - - # - # Inline Elements - # - - # - # Footnote Marker - - protected function inlineFootnoteMarker($Excerpt) - { - if (preg_match('/^\[\^(.+?)\]/', $Excerpt['text'], $matches)) - { - $name = $matches[1]; - - if ( ! isset($this->DefinitionData['Footnote'][$name])) - { - return; - } - - $this->DefinitionData['Footnote'][$name]['count'] ++; - - if ( ! isset($this->DefinitionData['Footnote'][$name]['number'])) - { - $this->DefinitionData['Footnote'][$name]['number'] = ++ $this->footnoteCount; # » & - } - - $Element = array( - 'name' => 'sup', - 'attributes' => array('id' => 'fnref'.$this->DefinitionData['Footnote'][$name]['count'].':'.$name), - 'element' => array( - 'name' => 'a', - 'attributes' => array('href' => '#fn:'.$name, 'class' => 'footnote-ref'), - 'text' => $this->DefinitionData['Footnote'][$name]['number'], - ), - ); - - return array( - 'extent' => strlen($matches[0]), - 'element' => $Element, - ); - } - } - - private $footnoteCount = 0; - - # - # Link - - protected function inlineLink($Excerpt) - { - $Link = parent::inlineLink($Excerpt); - - $remainder = $Link !== null ? substr($Excerpt['text'], $Link['extent']) : ''; - - if (preg_match('/^[ ]*{('.$this->regexAttribute.'+)}/', $remainder, $matches)) - { - $Link['element']['attributes'] += $this->parseAttributeData($matches[1]); - - $Link['extent'] += strlen($matches[0]); - } - - return $Link; - } - - # - # ~ - # - - private $currentAbreviation; - private $currentMeaning; - - protected function insertAbreviation(array $Element) - { - if (isset($Element['text'])) - { - $Element['elements'] = self::pregReplaceElements( - '/\b'.preg_quote($this->currentAbreviation, '/').'\b/', - array( - array( - 'name' => 'abbr', - 'attributes' => array( - 'title' => $this->currentMeaning, - ), - 'text' => $this->currentAbreviation, - ) - ), - $Element['text'] - ); - - unset($Element['text']); - } - - return $Element; - } - - protected function inlineText($text) - { - $Inline = parent::inlineText($text); - - if (isset($this->DefinitionData['Abbreviation'])) - { - foreach ($this->DefinitionData['Abbreviation'] as $abbreviation => $meaning) - { - $this->currentAbreviation = $abbreviation; - $this->currentMeaning = $meaning; - - $Inline['element'] = $this->elementApplyRecursiveDepthFirst( - array($this, 'insertAbreviation'), - $Inline['element'] - ); - } - } - - return $Inline; - } - - # - # Util Methods - # - - protected function addDdElement(array $Line, array $Block) - { - $text = substr($Line['text'], 1); - $text = trim($text); - - unset($Block['dd']); - - $Block['dd'] = array( - 'name' => 'dd', - 'handler' => array( - 'function' => 'lineElements', - 'argument' => $text, - 'destination' => 'elements' - ), - ); - - if (isset($Block['interrupted'])) - { - $Block['dd']['handler']['function'] = 'textElements'; - - unset($Block['interrupted']); - } - - $Block['element']['elements'] []= & $Block['dd']; - - return $Block; - } - - protected function buildFootnoteElement() - { - $Element = array( - 'name' => 'div', - 'attributes' => array('class' => 'footnotes'), - 'elements' => array( - array('name' => 'hr'), - array( - 'name' => 'ol', - 'elements' => array(), - ), - ), - ); - - uasort($this->DefinitionData['Footnote'], 'self::sortFootnotes'); - - foreach ($this->DefinitionData['Footnote'] as $definitionId => $DefinitionData) - { - if ( ! isset($DefinitionData['number'])) - { - continue; - } - - $text = $DefinitionData['text']; - - $textElements = parent::textElements($text); - - $numbers = range(1, $DefinitionData['count']); - - $backLinkElements = array(); - - foreach ($numbers as $number) - { - $backLinkElements[] = array('text' => ' '); - $backLinkElements[] = array( - 'name' => 'a', - 'attributes' => array( - 'href' => "#fnref$number:$definitionId", - 'rev' => 'footnote', - 'class' => 'footnote-backref', - ), - 'rawHtml' => '↩', - 'allowRawHtmlInSafeMode' => true, - 'autobreak' => false, - ); - } - - unset($backLinkElements[0]); - - $n = count($textElements) -1; - - if ($textElements[$n]['name'] === 'p') - { - $backLinkElements = array_merge( - array( - array( - 'rawHtml' => ' ', - 'allowRawHtmlInSafeMode' => true, - ), - ), - $backLinkElements - ); - - unset($textElements[$n]['name']); - - $textElements[$n] = array( - 'name' => 'p', - 'elements' => array_merge( - array($textElements[$n]), - $backLinkElements - ), - ); - } - else - { - $textElements[] = array( - 'name' => 'p', - 'elements' => $backLinkElements - ); - } - - $Element['elements'][1]['elements'] []= array( - 'name' => 'li', - 'attributes' => array('id' => 'fn:'.$definitionId), - 'elements' => array_merge( - $textElements - ), - ); - } - - return $Element; - } - - # ~ - - protected function parseAttributeData($attributeString) - { - $Data = array(); - - $attributes = preg_split('/[ ]+/', $attributeString, - 1, PREG_SPLIT_NO_EMPTY); - - foreach ($attributes as $attribute) - { - if ($attribute[0] === '#') - { - $Data['id'] = substr($attribute, 1); - } - else # "." - { - $classes []= substr($attribute, 1); - } - } - - if (isset($classes)) - { - $Data['class'] = implode(' ', $classes); - } - - return $Data; - } - - # ~ - - protected function processTag($elementMarkup) # recursive - { - # http://stackoverflow.com/q/1148928/200145 - libxml_use_internal_errors(true); - - $DOMDocument = new DOMDocument; - - # http://stackoverflow.com/q/11309194/200145 - $elementMarkup = mb_convert_encoding($elementMarkup, 'HTML-ENTITIES', 'UTF-8'); - - # http://stackoverflow.com/q/4879946/200145 - $DOMDocument->loadHTML($elementMarkup); - $DOMDocument->removeChild($DOMDocument->doctype); - $DOMDocument->replaceChild($DOMDocument->firstChild->firstChild->firstChild, $DOMDocument->firstChild); - - $elementText = ''; - - if ($DOMDocument->documentElement->getAttribute('markdown') === '1') - { - foreach ($DOMDocument->documentElement->childNodes as $Node) - { - $elementText .= $DOMDocument->saveHTML($Node); - } - - $DOMDocument->documentElement->removeAttribute('markdown'); - - $elementText = "\n".$this->text($elementText)."\n"; - } - else - { - foreach ($DOMDocument->documentElement->childNodes as $Node) - { - $nodeMarkup = $DOMDocument->saveHTML($Node); - - if ($Node instanceof DOMElement and ! in_array($Node->nodeName, $this->textLevelElements)) - { - $elementText .= $this->processTag($nodeMarkup); - } - else - { - $elementText .= $nodeMarkup; - } - } - } - - # because we don't want for markup to get encoded - $DOMDocument->documentElement->nodeValue = 'placeholder\x1A'; - - $markup = $DOMDocument->saveHTML($DOMDocument->documentElement); - $markup = str_replace('placeholder\x1A', $elementText, $markup); - - return $markup; - } - - # ~ - - protected function sortFootnotes($A, $B) # callback - { - return $A['number'] - $B['number']; - } - - # - # Fields - # - - protected $regexAttribute = '(?:[#.][-\w]+[ ]*)'; -} diff --git a/lib/parsedownextraplugin/parsedownextraplugin.class.php b/lib/parsedownextraplugin/parsedownextraplugin.class.php deleted file mode 100644 index f4787db640..0000000000 --- a/lib/parsedownextraplugin/parsedownextraplugin.class.php +++ /dev/null @@ -1,590 +0,0 @@ -'; - - protected $regexAttribute = '(?:[#.][-\w:\\\]+[ ]*|[-\w:\\\]+(?:=(?:["\'][^\n]*?["\']|[^\s]+)?)?[ ]*)'; - - # Method aliases for every configuration property - public function __call($key, array $arguments = array()) { - $property = lcfirst(substr($key, 3)); - if (strpos($key, 'set') === 0 && property_exists($this, $property)) { - $this->{$property} = $arguments[0]; - return $this; - } - throw new Exception('Method ' . $key . ' does not exists.'); - } - - public function __construct() { - if (version_compare(parent::version, '0.8.0-beta-1') < 0) { - throw new Exception('ParsedownExtraPlugin requires a later version of Parsedown'); - } - $this->BlockTypes['!'][] = 'Image'; - parent::__construct(); - } - - protected function blockAbbreviation($Line) { - // Allow empty abbreviations - if (preg_match('/^\*\[(.+?)\]:[ ]*$/', $Line['text'], $matches)) { - $this->DefinitionData['Abbreviation'][$matches[1]] = null; - return array('hidden' => true); - } - return parent::blockAbbreviation($Line); - } - - protected function blockCodeComplete($Block) { - $this->doSetAttributes($Block['element']['element'], $this->blockCodeAttributes); - $this->doSetContent($Block['element']['element'], $this->blockCodeHtml, true); - // Put code attributes on parent element - if ($this->codeAttributesOnParent) { - if ($this->codeAttributesOnParent === true) { - // $this->codeAttributesOnParent = array_keys($Block['element']['element']['attributes']); - $this->codeAttributesOnParent = array('class', 'id'); - } - foreach ((array) $this->codeAttributesOnParent as $Name) { - if (isset($Block['element']['element']['attributes'][$Name])) { - $Block['element']['attributes'][$Name] = $Block['element']['element']['attributes'][$Name]; - unset($Block['element']['element']['attributes'][$Name]); - } - } - } - $Block['element']['element']['rawHtml'] = $Block['element']['element']['text']; - $Block['element']['element']['allowRawHtmlInSafeMode'] = true; - unset($Block['element']['element']['text']); - return $Block; - } - - protected function blockFencedCode($Line) { - // Re-enable the multiple class name feature - $Line['text'] = strtr(trim($Line['text']), array( - ' ' => "\x1A", - '.' => "\x1A." - )); - // Enable custom attribute syntax on code block - $Attributes = array(); - if (strpos($Line['text'], '{') !== false && substr($Line['text'], -1) === '}') { - $Parts = explode('{', $Line['text'], 2); - $Attributes = $this->parseAttributeData(strtr(substr($Parts[1], 0, -1), "\x1A", ' ')); - $Line['text'] = trim($Parts[0]); - } - if (!$Block = parent::blockFencedCode($Line)) { - return; - } - if ($Attributes) { - $Block['element']['element']['attributes'] = $Attributes; - } else if (isset($Block['element']['element']['attributes']['class'])) { - $Classes = explode("\x1A", strtr($Block['element']['element']['attributes']['class'], ' ', "\x1A")); - // `~~~ php` → `
    `
    -            // `~~~ php html` → `
    `
    -            // `~~~ .php` → `
    `
    -            // `~~~ .php.html` → `
    `
    -            // `~~~ .php html` → `
    `
    -            // `~~~ {.php #foo}` → `
    `
    -            $Results = [];
    -            foreach ($Classes as $Class) {
    -                if ($Class === "" || $Class === strtr($this->blockCodeClassFormat, array('%s' => ""))) {
    -                    continue;
    -                }
    -                if ($Class[0] === '.') {
    -                    $Results[] = substr($Class, 1);
    -                } else if (preg_match('/^' . strtr(preg_quote($this->blockCodeClassFormat), array('%s' => '\S+')) . '$/', $Class)) {
    -                    $Results[] = $Class; // Do nothing!
    -                } else {
    -                    $Results[] = sprintf($this->blockCodeClassFormat, $Class);
    -                }
    -            }
    -            if ($Results = array_unique($Results)) {
    -                $Block['element']['element']['attributes']['class'] = implode(' ', $Results);
    -            } else {
    -                unset($Block['element']['element']['attributes']['class']);
    -            }
    -        }
    -        return $Block;
    -    }
    -
    -    protected function blockFencedCodeComplete($Block) {
    -        return $this->blockCodeComplete($Block);
    -    }
    -
    -    protected function blockHeader($Line) {
    -        if (!$Block = parent::blockHeader($Line)) {
    -            return;
    -        }
    -        $Level = strspn($Line['text'], '#');
    -        $this->doSetAttributes($Block['element'], $this->headerAttributes, array($Level));
    -        $this->doSetContent($Block['element'], $this->headerText, false, 'argument', array($Level));
    -        return $Block;
    -    }
    -
    -    protected function blockImage($Line) {
    -        if (!$this->figuresEnabled) {
    -            return;
    -        }
    -        // Match exactly an image syntax in a paragraph (with optional custom attributes, and optional hard break marker)
    -        if (preg_match('/^\!\[[^\n]*?\](\[[^\n]*?\]|\([^\n]*?\))(\s*\{' . $this->regexAttribute . '+?\})?([ ]{2})?$/', $Line['text'])) {
    -            $Block = array(
    -                'description' => "",
    -                'element' => array(
    -                    'name' => 'figure',
    -                    'attributes' => array(),
    -                    'elements' => array(
    -                        $this->inlineImage($Line)
    -                    )
    -                )
    -            );
    -            $this->doSetAttributes($Block['element'], $this->figureAttributes);
    -            return $Block;
    -        }
    -        return;
    -    }
    -
    -    protected function blockImageComplete($Block) {
    -        if (!empty($Block['description'])) {
    -            $Description = $Block['description'];
    -            $Block['element']['elements'][] = array(
    -                'name' => 'figcaption',
    -                'rawHtml' => $this->{strpos($Description, "\n\n") === false ? 'line' : 'text'}(trim($Description, "\n"))
    -            );
    -            // unset($Block['description']);
    -        }
    -        if ($this->imageAttributesOnParent) {
    -            $Inline = $Block['element']['elements'][0];
    -            if ($this->imageAttributesOnParent === true) {
    -                $this->imageAttributesOnParent = array_keys($Inline['element']['attributes']);
    -            }
    -            foreach ((array) $this->imageAttributesOnParent as $Name) {
    -                if (isset($Inline['element']['attributes'][$Name])) {
    -                    // Merge class names
    -                    if (
    -                        $Name === 'class' &&
    -                        isset($Block['element']['attributes'][$Name]) &&
    -                        isset($Inline['element']['attributes'][$Name])
    -                    ) {
    -                        $Classes = array_merge(
    -                            explode(' ', $Block['element']['attributes'][$Name]),
    -                            explode(' ', $Inline['element']['attributes'][$Name])
    -                        );
    -                        sort($Classes);
    -                        $Block['element']['attributes']['class'] = implode(' ', array_unique(array_filter($Classes)));
    -                        unset($Block['element']['elements'][0]['element']['attributes'][$Name]);
    -                        continue;
    -                    }
    -                    $Block['element']['attributes'][$Name] = $Inline['element']['attributes'][$Name];
    -                    unset($Block['element']['elements'][0]['element']['attributes'][$Name]);
    -                }
    -            }
    -        }
    -        return $Block;
    -    }
    -
    -    protected function blockImageContinue($Line, array $Block) {
    -        if (isset($Block['complete'])) {
    -            return;
    -        }
    -        if (isset($Block['interrupted'])) {
    -            $Block['description'] .= "\n";
    -            unset($Block['interrupted']);
    -        }
    -        if ($Line['indent'] === 0) {
    -            $Block['complete'] = true;
    -            return;
    -        }
    -        if ($Line['indent'] > 0 && $Line['indent'] < 4) {
    -            $Block['description'] .= "\n" . $Line['text'];
    -            return $Block;
    -        }
    -        return;
    -    }
    -
    -    protected function blockQuoteComplete($Block) {
    -        $this->doSetAttributes($Block['element'], $this->blockQuoteAttributes);
    -        $this->doSetContent($Block['element'], $this->blockQuoteText, false, 'arguments');
    -        return $Block;
    -    }
    -
    -    protected function blockSetextHeader($Line, array $Block = null) {
    -        if (!$Block = parent::blockSetextHeader($Line, $Block)) {
    -            return;
    -        }
    -        $Level = $Line['text'][0] === '=' ? 1 : 2;
    -        $this->doSetAttributes($Block['element'], $this->headerAttributes, array($Level));
    -        $this->doSetContent($Block['element'], $this->headerText, false, 'argument', array($Level));
    -        return $Block;
    -    }
    -
    -    protected function blockTableContinue($Line, array $Block) {
    -        if (!$Block = parent::blockTableContinue($Line, $Block)) {
    -            return;
    -        }
    -        $Aligns = $Block['alignments'];
    -        // `` or ``
    -        foreach ($Block['element']['elements'] as $Index0 => &$Element0) {
    -            // ``
    -            foreach ($Element0['elements'] as $Index1 => &$Element1) {
    -                // `` or ``
    -                foreach ($Element1['elements'] as $Index2 => &$Element2) {
    -                    $this->doSetAttributes($Element2, $this->tableColumnAttributes, array($Aligns[$Index2], $Index2, $Index1));
    -                }
    -            }
    -        }
    -        return $Block;
    -    }
    -
    -    protected function blockTableComplete($Block) {
    -        $this->doSetAttributes($Block['element'], $this->tableAttributes);
    -        return $Block;
    -    }
    -
    -    protected function buildFootnoteElement() {
    -        $DefinitionData = $this->DefinitionData['Footnote'];
    -        if (!$Footnotes = parent::buildFootnoteElement()) {
    -            return;
    -        }
    -        $DefinitionKey = array_keys($DefinitionData);
    -        $DefinitionData = array_values($DefinitionData);
    -        $this->doSetAttributes($Footnotes, $this->footnoteAttributes);
    -        foreach ($Footnotes['elements'][1]['elements'] as $Index0 => &$Element0) {
    -            $Name = $DefinitionKey[$Index0];
    -            $Count = $DefinitionData[$Index0]['count'];
    -            $Args = array(is_numeric($Name) ? (float) $Name : $Name, $Count);
    -            $this->doSetAttributes($Element0, $this->footnoteBackReferenceAttributes, $Args);
    -            foreach ($Element0['elements'] as $Index1 => &$Element1) {
    -                if (!isset($Element1['elements'])) {
    -                    continue;
    -                }
    -                $Count = 0;
    -                foreach ($Element1['elements'] as $Index2 => &$Element2) {
    -                    if (!isset($Element2['name']) || $Element2['name'] !== 'a') {
    -                        continue;
    -                    }
    -                    $Args[1] = ++$Count;
    -                    $this->doSetAttributes($Element2, $this->footnoteBackLinkAttributes, $Args);
    -                    $this->doSetContent($Element2, $this->footnoteBackLinkHtml, false, 'rawHtml');
    -                }
    -            }
    -        }
    -        return $Footnotes;
    -    }
    -
    -    protected function doGetAttributes($Element) {
    -        if (isset($Element['attributes'])) {
    -            return (array) $Element['attributes'];
    -        }
    -        return array();
    -    }
    -
    -    protected function doGetContent($Element) {
    -        if (isset($Element['text'])) {
    -            return $Element['text'];
    -        }
    -        if (isset($Element['rawHtml'])) {
    -            return $Element['rawHtml'];
    -        }
    -        if (isset($Element['handler']['argument'])) {
    -            return implode("\n", (array) $Element['handler']['argument']);
    -        }
    -        return null;
    -    }
    -
    -    private function doSetLink($Excerpt, $Function) {
    -        if (!$Inline = call_user_func('parent::' . $Function, $Excerpt)) {
    -            return;
    -        }
    -        $this->doSetAttributes($Inline['element'], $this->linkAttributes, array($this->isLocal($Inline['element'], 'href')));
    -        $this->doSetData($this->DefinitionData['Reference'], $this->referenceData);
    -        return $Inline;
    -    }
    -
    -    protected function doSetAttributes(&$Element, $From, $Args = array()) {
    -        $Attributes = $this->doGetAttributes($Element);
    -        $Content = $this->doGetContent($Element);
    -        if (is_callable($From)) {
    -            $Args = array_merge(array($Content, $Attributes, &$Element), $Args);
    -            $Element['attributes'] = array_replace($Attributes, (array) call_user_func_array($From, $Args));
    -        } else {
    -            $Element['attributes'] = array_replace($Attributes, (array) $From);
    -        }
    -    }
    -
    -    protected function doSetContent(&$Element, $From, $Esc = false, $Mode = 'text', $Args = array()) {
    -        $Attributes = $this->doGetAttributes($Element);
    -        $Content = $this->doGetContent($Element);
    -        if ($Esc) {
    -            $Content = parent::escape($Content, true);
    -        }
    -        if (is_callable($From)) {
    -            $Args = array_merge(array($Content, $Attributes, &$Element), $Args);
    -            $Content = call_user_func_array($From, $Args);
    -        } else if (!empty($From)) {
    -            $Content = sprintf($From, $Content);
    -        }
    -        if ($Mode === 'arguments') {
    -            $Element['handler']['argument'] = explode("\n", $Content);
    -        } else if ($Mode === 'argument') {
    -            $Element['handler']['argument'] = $Content;
    -        } else {
    -            $Element[$Mode] = $Content;
    -        }
    -    }
    -
    -    protected function doSetData(&$To, $From) {
    -        $To = array_replace((array) $To, (array) $From);
    -    }
    -
    -    protected function element(array $Element) {
    -        if (!$Any = parent::element($Element)) {
    -            return;
    -        }
    -        if (substr($Any, -3) === ' />') {
    -            if (is_callable($this->voidElementSuffix)) {
    -                $Attributes = $this->doGetAttributes($Element);
    -                $Content = $this->doGetContent($Element);
    -                $Suffix = call_user_func_array($this->voidElementSuffix, [$Content, $Attributes, &$Element]);
    -            } else {
    -                $Suffix = $this->voidElementSuffix;
    -            }
    -            $Any = substr_replace($Any, $Suffix, -3);
    -        }
    -        return $Any;
    -    }
    -
    -    protected function inlineCode($Excerpt) {
    -        if (!$Inline = parent::inlineCode($Excerpt)) {
    -            return;
    -        }
    -        $this->doSetAttributes($Inline['element'], $this->codeAttributes);
    -        $this->doSetContent($Inline['element'], $this->codeHtml, true);
    -        $Inline['element']['rawHtml'] = $Inline['element']['text'];
    -        $Inline['element']['allowRawHtmlInSafeMode'] = true;
    -        unset($Inline['element']['text']);
    -        return $Inline;
    -    }
    -
    -    protected function inlineFootnoteMarker($Excerpt) {
    -        if (!$Inline = parent::inlineFootnoteMarker($Excerpt)) {
    -            return;
    -        }
    -        $Name = null;
    -        if (preg_match('/^\[\^(.+?)\]/', $Excerpt['text'], $matches)) {
    -            $Name = $matches[1];
    -        }
    -        $Args = array(is_numeric($Name) ? (float) $Name : $Name, $this->DefinitionData['Footnote'][$Name]['count']);
    -        $this->doSetAttributes($Inline['element'], $this->footnoteReferenceAttributes, $Args);
    -        $this->doSetAttributes($Inline['element']['element'], $this->footnoteLinkAttributes, $Args);
    -        $this->doSetContent($Inline['element']['element'], $this->footnoteLinkHtml, false, 'text', $Args);
    -        $Inline['element']['element']['rawHtml'] = $Inline['element']['element']['text'];
    -        $Inline['element']['element']['allowRawHtmlInSafeMode'] = true;
    -        unset($Inline['element']['element']['text']);
    -        return $Inline;
    -    }
    -
    -    protected function inlineImage($Excerpt) {
    -        if (!$Inline = parent::inlineImage($Excerpt)) {
    -            return;
    -        }
    -        $this->doSetAttributes($Inline['element'], $this->imageAttributes, array($this->isLocal($Inline['element'], 'src')));
    -        return $Inline;
    -    }
    -
    -    protected function inlineLink($Excerpt) {
    -        return $this->doSetLink($Excerpt, __FUNCTION__);
    -    }
    -
    -    protected function inlineText($Text) {
    -        $this->doSetData($this->DefinitionData['Abbreviation'], $this->abbreviationData);
    -        return parent::inlineText($Text);
    -    }
    -
    -    protected function inlineUrl($Excerpt) {
    -        return $this->doSetLink($Excerpt, __FUNCTION__);
    -    }
    -
    -    protected function inlineUrlTag($Excerpt) {
    -        return $this->doSetLink($Excerpt, __FUNCTION__);
    -    }
    -
    -    protected function isLocal($Element, $Key) {
    -        $Link = isset($Element['attributes'][$Key]) ? (string) $Element['attributes'][$Key] : null;
    -        if (
    -            // ``
    -            $Link === "" ||
    -            // ``
    -            // ``
    -            // ``
    -            // ``
    -            // ``
    -            strpos('./?&#', $Link[0]) !== false && strpos($Link, '//') !== 0 ||
    -            // ``
    -            strpos($Link, 'data:') === 0 ||
    -            // ``
    -            strpos($Link, 'javascript:') === 0 ||
    -            // ``
    -            strpos($Link, 'mailto:') === 0
    -        ) {
    -            return true;
    -        }
    -        if (isset($_SERVER['HTTP_HOST'])) {
    -            $Host = $_SERVER['HTTP_HOST'];
    -        } else if (isset($_SERVER['SERVER_NAME'])) {
    -            $Host = $_SERVER['SERVER_NAME'];
    -        } else {
    -            $Host = "";
    -        }
    -        // ``
    -        if (strpos($Link, '//') === 0 && strpos($Link, '//' . $Host) !== 0) {
    -            return false;
    -        }
    -        if (
    -            // ``
    -            strpos($Link, 'https://' . $Host) === 0 ||
    -            // ``
    -            strpos($Link, 'http://' . $Host) === 0
    -        ) {
    -            return true;
    -        }
    -        // ``
    -        return strpos($Link, '://') === false;
    -    }
    -
    -    protected function parseAttributeData($attributeString) {
    -        // Allow compact attributes
    -        $attributeString = strtr($attributeString, array(
    -            '#' => ' #',
    -            '.' => ' .'
    -        ));
    -        if (strpos($attributeString, '="') !== false || strpos($attributeString, "='") !== false) {
    -            $attributeString = preg_replace_callback('#([-\w]+=)(["\'])([^\n]*?)\2#', function($matches) {
    -                $value = strtr($matches[3], array(
    -                    ' #' => '#',
    -                    ' .' => '.',
    -                    ' ' => "\x1A"
    -                ));
    -                return $matches[1] . $matches[2] . $value . $matches[2];
    -            }, $attributeString);
    -        }
    -        $Attributes = array();
    -        foreach (explode(' ', $attributeString) as $v) {
    -            if (!$v) {
    -                continue;
    -            }
    -            // `{#foo}`
    -            if ($v[0] === '#' && isset($v[1])) {
    -                $Attributes['id'] = substr($v, 1);
    -            // `{.foo}`
    -            } else if ($v[0] === '.' && isset($v[1])) {
    -                $Attributes['class'][] = substr($v, 1);
    -            // ~
    -            } else if (strpos($v, '=') !== false) {
    -                $vv = explode('=', $v, 2);
    -                // `{foo=}`
    -                if ($vv[1] === "") {
    -                    if ($vv[0] === 'class') {
    -                        continue;
    -                    }
    -                    $Attributes[$vv[0]] = "";
    -                // `{foo="bar baz"}`
    -                // `{foo='bar baz'}`
    -                } else if ($vv[1][0] === '"' && substr($vv[1], -1) === '"' || $vv[1][0] === "'" && substr($vv[1], -1) === "'") {
    -                    $values = stripslashes(strtr(substr(substr($vv[1], 1), 0, -1), "\x1A", ' '));
    -                    if ($vv[0] === 'class' && isset($Attributes[$vv[0]])) {
    -                        $values = explode(' ', $values);
    -                        $Attributes[$vv[0]] = array_merge($Attributes[$vv[0]], $values);
    -                    } else {
    -                        $Attributes[$vv[0]] = $values;
    -                    }
    -                // `{foo=bar}`
    -                } else {
    -                    if ($vv[0] === 'class' && isset($Attributes[$vv[0]])) {
    -                        $Attributes[$vv[0]] = array_merge($Attributes[$vv[0]], [$vv[1]]);
    -                    } else {
    -                        $Attributes[$vv[0]] = $vv[1];
    -                    }
    -                }
    -            // `{foo}`
    -            } else {
    -                if ($v === 'class' && isset($Attributes[$v])) {
    -                    continue;
    -                }
    -                $Attributes[$v] = $v;
    -            }
    -        }
    -        if (isset($Attributes['class'])) {
    -            $Attributes['class'] = implode(' ', array_unique((array) $Attributes['class']));
    -        }
    -        return $Attributes;
    -    }
    -
    -}
    diff --git a/module/action/control.php b/module/action/control.php
    index 2df3d88288..ac9224fabe 100755
    --- a/module/action/control.php
    +++ b/module/action/control.php
    @@ -175,7 +175,8 @@ class action extends control
             {
                 $story      = $this->loadModel('story')->getById($objectID);
                 $executions = explode(',', $this->app->user->view->sprints);
    -            if(!array_intersect(array_keys($story->executions), $executions)) return print(js::error($this->lang->error->accessDenied));
    +            $products   = explode(',', $this->app->user->view->products);
    +            if(!array_intersect(array_keys($story->executions), $executions) and !in_array($story->product, $products)) return print(js::error($this->lang->error->accessDenied));
             }
     
             $actionID = $this->action->create($objectType, $objectID, 'Commented', $this->post->comment);
    diff --git a/module/action/lang/en.php b/module/action/lang/en.php
    index 19cabc24e4..8ccc581887 100755
    --- a/module/action/lang/en.php
    +++ b/module/action/lang/en.php
    @@ -162,7 +162,7 @@ $lang->action->desc->hidden               = '$date, hidden by $actoraction->desc->commented            = '$date, added by $actor.' . "\n";
     $lang->action->desc->activated            = '$date, activated by $actor .' . "\n";
     $lang->action->desc->blocked              = '$date, blocked by $actor .' . "\n";
    -$lang->action->desc->moved                = '$date, moved by $actor , which was "$extra".' . "\n";
    +$lang->action->desc->moved                = '$date, moved by $actor .' . "\n";
     $lang->action->desc->confirmed            = '$date, $actor confirmed the story change. The latest build is #$extra.' . "\n";
     $lang->action->desc->caseconfirmed        = '$date, $actor confirmed the case change. The latest build is #$extra' . "\n";
     $lang->action->desc->bugconfirmed         = '$date, $actor confirmed Bug.' . "\n";
    @@ -224,6 +224,12 @@ $lang->action->desc->deletechildrenstory = '$date, $actor delet
     $lang->action->desc->linkrelatedcase   = '$date, $actor linked a case $extra.' . "\n";
     $lang->action->desc->unlinkrelatedcase = '$date, $actor unlinked a case $extra.' . "\n";
     
    +/* Used to describe the history of operations link story and bug to productplan. */
    +$lang->action->desc->linkstory   = '$date, 由 $actor 关联需求 $extra 到计划。' . "\n";
    +$lang->action->desc->linkbug     = '$date, 由 $actor 关联BUG $extra 到计划。' . "\n";
    +$lang->action->desc->unlinkstory = '$date, 由 $actor 从计划移除需求 $extra。' . "\n";
    +$lang->action->desc->unlinkbug   = '$date, 由 $actor 从计划移除BUG $extra。' . "\n";
    +
     /* Used to display dynamic information. */
     $lang->action->label                        = new stdclass();
     $lang->action->label->created               = 'created ';
    @@ -352,6 +358,10 @@ $lang->action->label->importedbuild         = 'imported';
     $lang->action->label->fromsonarqube         = 'created a bug from SonarQube Issue named:';
     $lang->action->label->bind                  = 'bound';
     $lang->action->label->unbind                = 'unbound';
    +$lang->action->label->linkstory             = 'link stories to';
    +$lang->action->label->linkbug               = 'link bugs to';
    +$lang->action->label->unlinkstory           = 'unlink stories from';
    +$lang->action->label->unlindbug             = 'unlink bugs from';
     
     /* Dynamic information is grouped by object. */
     $lang->action->dynamicAction                    = new stdclass;
    @@ -392,12 +402,16 @@ $lang->action->dynamicAction->branch['activated']        = 'Activate Branch';
     $lang->action->dynamicAction->branch['setdefaultbranch'] = 'Set Default Branch';
     $lang->action->dynamicAction->branch['mergebranch']      = 'Merge Branch';
     
    -$lang->action->dynamicAction->productplan['opened']    = 'Create Plan';
    -$lang->action->dynamicAction->productplan['edited']    = 'Edit Plan';
    -$lang->action->dynamicAction->productplan['started']   = "Start Plan";
    -$lang->action->dynamicAction->productplan['finished']  = "Finish Plan";
    -$lang->action->dynamicAction->productplan['closed']    = "Close Plan";
    -$lang->action->dynamicAction->productplan['activated'] = "Activate Plan";
    +$lang->action->dynamicAction->productplan['opened']      = 'Create Plan';
    +$lang->action->dynamicAction->productplan['edited']      = 'Edit Plan';
    +$lang->action->dynamicAction->productplan['started']     = "Start Plan";
    +$lang->action->dynamicAction->productplan['finished']    = "Finish Plan";
    +$lang->action->dynamicAction->productplan['closed']      = "Close Plan";
    +$lang->action->dynamicAction->productplan['activated']   = "Activate Plan";
    +$lang->action->dynamicAction->productplan['linkstory']   = "Link Story";
    +$lang->action->dynamicAction->productplan['unlinkstory'] = "Unlink Story";
    +$lang->action->dynamicAction->productplan['linkbug']     = "Link Bug";
    +$lang->action->dynamicAction->productplan['unlinkbug']   = "Unlink Bug";
     
     $lang->action->dynamicAction->release['opened']       = 'Create Release';
     $lang->action->dynamicAction->release['edited']       = 'Edit Release';
    diff --git a/module/action/lang/zh-cn.php b/module/action/lang/zh-cn.php
    index b70a9b69bd..d44fa78f60 100755
    --- a/module/action/lang/zh-cn.php
    +++ b/module/action/lang/zh-cn.php
    @@ -162,7 +162,7 @@ $lang->action->desc->hidden               = '$date, 由 $actor
     $lang->action->desc->commented            = '$date, 由 $actor 添加备注。' . "\n";
     $lang->action->desc->activated            = '$date, 由 $actor 激活。' . "\n";
     $lang->action->desc->blocked              = '$date, 由 $actor 阻塞。' . "\n";
    -$lang->action->desc->moved                = '$date, 由 $actor 移动,之前为 "$extra"。' . "\n";
    +$lang->action->desc->moved                = '$date, 由 $actor 移动。' . "\n";
     $lang->action->desc->confirmed            = '$date, 由 $actor 确认' . $lang->SRCommon . '变动,最新版本为#$extra。' . "\n";
     $lang->action->desc->caseconfirmed        = '$date, 由 $actor 确认用例变动,最新版本为#$extra。' . "\n";
     $lang->action->desc->bugconfirmed         = '$date, 由 $actor 确认Bug。' . "\n";
    @@ -224,6 +224,12 @@ $lang->action->desc->deletechildrenstory = '$date, 由 $actor 
     $lang->action->desc->linkrelatedcase   = '$date, 由 $actor 关联相关用例 $extra。' . "\n";
     $lang->action->desc->unlinkrelatedcase = '$date, 由 $actor 移除相关用例 $extra。' . "\n";
     
    +/* 用来描述计划关联和移除需求、bug时的历史操作记录。*/
    +$lang->action->desc->linkstory   = '$date, 由 $actor 关联需求 $extra 到计划。' . "\n";
    +$lang->action->desc->linkbug     = '$date, 由 $actor 关联BUG $extra 到计划。' . "\n";
    +$lang->action->desc->unlinkstory = '$date, 由 $actor 从计划移除需求 $extra。' . "\n";
    +$lang->action->desc->unlinkbug   = '$date, 由 $actor 从计划移除BUG $extra。' . "\n";
    +
     /* 用来显示动态信息。*/
     $lang->action->label                        = new stdclass();
     $lang->action->label->created               = '创建';
    @@ -352,6 +358,10 @@ $lang->action->label->importedbuild         = '导入了';
     $lang->action->label->fromsonarqube         = '由SonarQube问题创建';
     $lang->action->label->bind                  = '绑定了';
     $lang->action->label->unbind                = '取消绑定了';
    +$lang->action->label->linkstory             = '关联需求到';
    +$lang->action->label->linkbug               = '关联BUG到';
    +$lang->action->label->unlinkstory           = '移除需求从';
    +$lang->action->label->unlinkbug             = '移除BUG从';
     
     /* 动态信息按照对象分组 */
     $lang->action->dynamicAction                    = new stdclass();
    @@ -392,12 +402,16 @@ $lang->action->dynamicAction->branch['activated']        = '激活分支';
     $lang->action->dynamicAction->branch['setdefaultbranch'] = '设置默认分支';
     $lang->action->dynamicAction->branch['mergebranch']      = '合并分支';
     
    -$lang->action->dynamicAction->productplan['opened']    = "创建计划";
    -$lang->action->dynamicAction->productplan['edited']    = "编辑计划";
    -$lang->action->dynamicAction->productplan['started']   = "开始计划";
    -$lang->action->dynamicAction->productplan['finished']  = "完成计划";
    -$lang->action->dynamicAction->productplan['closed']    = "关闭计划";
    -$lang->action->dynamicAction->productplan['activated'] = "激活计划";
    +$lang->action->dynamicAction->productplan['opened']      = "创建计划";
    +$lang->action->dynamicAction->productplan['edited']      = "编辑计划";
    +$lang->action->dynamicAction->productplan['started']     = "开始计划";
    +$lang->action->dynamicAction->productplan['finished']    = "完成计划";
    +$lang->action->dynamicAction->productplan['closed']      = "关闭计划";
    +$lang->action->dynamicAction->productplan['activated']   = "激活计划";
    +$lang->action->dynamicAction->productplan['linkstory']   = "关联需求";
    +$lang->action->dynamicAction->productplan['unlinkstory'] = "移除需求";
    +$lang->action->dynamicAction->productplan['linkbug']     = "关联BUG";
    +$lang->action->dynamicAction->productplan['unlinkbug']   = "移除BUG";
     
     $lang->action->dynamicAction->release['opened']       = '创建发布';
     $lang->action->dynamicAction->release['edited']       = '编辑发布';
    @@ -624,6 +638,7 @@ else
     {
         $lang->action->label->execution = "$lang->executionCommon|execution|task|executionID=%s";
     }
    +
     $lang->action->label->task         = '任务|task|view|taskID=%s';
     $lang->action->label->build        = '版本|build|view|buildID=%s';
     $lang->action->label->bug          = 'Bug|bug|view|bugID=%s';
    diff --git a/module/action/model.php b/module/action/model.php
    index 56a2b37b33..767dacdf18 100755
    --- a/module/action/model.php
    +++ b/module/action/model.php
    @@ -73,7 +73,7 @@ class actionModel extends model
             if($this->post->uid) $this->file->updateObjectID($this->post->uid, $objectID, $objectType);
     
             /* Call the message notification function. */
    -        $this->loadModel('message')->send($objectType, $objectID, $actionType, $actionID, $actor);
    +        $this->loadModel('message')->send(strtolower($objectType), $objectID, $actionType, $actionID, $actor);
     
             /* Add index for global search. */
             $this->saveIndex($objectType, $objectID, $actionType);
    @@ -555,6 +555,18 @@ class actionModel extends model
                         if($history->field == 'git') $history->diff = str_replace('+', '%2B', $history->diff);
                     }
                 }
    +            elseif($actionName == 'linkstory' or $actionName == 'unlinkstory')
    +            {
    +                $extra = '';
    +                foreach(explode(',', $action->extra) as $id) $extra .= common::hasPriv('story', 'view') ? html::a(helper::createLink('story', 'view', "storyID=$id"), "#$id ") . ', ' : "#$id, ";
    +                $action->extra = trim(trim($extra), ',');
    +            }
    +            elseif($actionName == 'linkbug' or $actionName == 'unlinkbug')
    +            {
    +                $extra = '';
    +                foreach(explode(',', $action->extra) as $id) $extra .= common::hasPriv('bug', 'view') ? html::a(helper::createLink('bug', 'view', "bugID=$id"), "#$id ") . ', ' : "#$id, ";
    +                $action->extra = trim(trim($extra), ',');
    +            }
     
                 $action->comment = $this->file->setImgSize($action->comment, $this->config->action->commonImgSize);
     
    diff --git a/module/api/model.php b/module/api/model.php
    index 0cfde0ab69..e15d7264eb 100644
    --- a/module/api/model.php
    +++ b/module/api/model.php
    @@ -375,9 +375,12 @@ class apiModel extends model
         public function getApiListByRelease($release, $where = '1 = 1 ')
         {
             $strJoin = array();
    -        foreach($release->snap['apis'] as $api)
    +        if(isset($release->snap['apis']))
             {
    -            $strJoin[] = "(spec.doc = {$api['id']} and spec.version = {$api['version']} )";
    +            foreach($release->snap['apis'] as $api)
    +            {
    +                $strJoin[] = "(spec.doc = {$api['id']} and spec.version = {$api['version']} )";
    +            }
             }
     
             if($strJoin) $where .= 'and (' . implode(' or ', $strJoin) . ')';
    @@ -404,7 +407,7 @@ class apiModel extends model
                 $rel = $this->getRelease(0, 'byId', $release);
     
                 $where = "1=1 and lib = $libID ";
    -            if($moduleID > 0)
    +            if($moduleID > 0 and isset($rel->snap['modules']))
                 {
                     $sub = array();
                     foreach($rel->snap['modules'] as $module)
    @@ -497,9 +500,12 @@ class apiModel extends model
         public function getStructListByRelease($release, $where = '1 = 1 ', $orderBy = 'id')
         {
             $strJoin = array();
    -        foreach($release->snap['structs'] as $struct)
    +        if(isset($release->snap['structs']))
             {
    -            $strJoin[] = "(object.id = {$struct['id']} and spec.version = {$struct['version']} )";
    +            foreach($release->snap['structs'] as $struct)
    +            {
    +                $strJoin[] = "(object.id = {$struct['id']} and spec.version = {$struct['version']} )";
    +            }
             }
     
             if($strJoin) $where .= 'and (' . implode(' or ', $strJoin) . ')';
    @@ -619,8 +625,8 @@ class apiModel extends model
                     foreach($_POST as $key => $value) $param .= ',' . $key . '=' . $value;
                     $param = ltrim($param, ',');
                 }
    -            $url  = rtrim($host, '/') . inlink('getModel', "moduleName=$moduleName&methodName=$methodName¶ms=$param", 'json');
    -            $url .= $this->config->requestType == "PATH_INFO" ? '?' : '&';
    +            $url  = rtrim($host, '/') . inlink('getModel',  "moduleName=$moduleName&methodName=$methodName¶ms=$param", 'json');
    +            $url .= strpos($url, '?') === false ? '?' : '&';
                 $url .= $this->config->sessionVar . '=' . session_id();
             }
             else
    @@ -631,7 +637,7 @@ class apiModel extends model
                     $param = ltrim($param, '&');
                 }
                 $url  = rtrim($host, '/') . helper::createLink($moduleName, $methodName, $param, 'json');
    -            $url .= $this->config->requestType == "PATH_INFO" ? '?' : '&';
    +            $url .= strpos($url, '?') === false ? '?' : '&';
                 $url .= $this->config->sessionVar . '=' . session_id();
             }
     
    diff --git a/module/bug/config.php b/module/bug/config.php
    index a2a00b49c6..c51a97e7f8 100644
    --- a/module/bug/config.php
    +++ b/module/bug/config.php
    @@ -326,7 +326,7 @@ $config->bug->datatable->fieldList['closedDate']['required'] = 'no';
     
     $config->bug->datatable->fieldList['lastEditedBy']['title']    = 'lastEditedBy';
     $config->bug->datatable->fieldList['lastEditedBy']['fixed']    = 'no';
    -$config->bug->datatable->fieldList['lastEditedBy']['width']    = '80';
    +$config->bug->datatable->fieldList['lastEditedBy']['width']    = '90';
     $config->bug->datatable->fieldList['lastEditedBy']['required'] = 'no';
     
     $config->bug->datatable->fieldList['lastEditedDate']['title']    = 'lastEditedDateAB';
    @@ -338,3 +338,19 @@ $config->bug->datatable->fieldList['actions']['title']    = 'actions';
     $config->bug->datatable->fieldList['actions']['fixed']    = 'right';
     $config->bug->datatable->fieldList['actions']['width']    = '150';
     $config->bug->datatable->fieldList['actions']['required'] = 'yes';
    +
    +$config->bug->colorList = new stdclass();
    +$config->bug->colorList->pri[1]      = '#d50000';
    +$config->bug->colorList->pri[2]      = '#ff9800';
    +$config->bug->colorList->pri[3]      = '#2098ee';
    +$config->bug->colorList->pri[4]      = '#009688';
    +$config->bug->colorList->pri[5]      = '#919090';
    +$config->bug->colorList->pri[6]      = '#B6B4B4';
    +$config->bug->colorList->pri[7]      = '#BDBEBD';
    +$config->bug->colorList->severity[1] = '#c62828';
    +$config->bug->colorList->severity[2] = '#ff8f00';
    +$config->bug->colorList->severity[3] = '#fdd835';
    +$config->bug->colorList->severity[4] = '#cddc39';
    +$config->bug->colorList->severity[5] = '#8bc34a';
    +$config->bug->colorList->severity[6] = '#B6B4B4';
    +$config->bug->colorList->severity[7] = '#BDBEBD';
    diff --git a/module/bug/control.php b/module/bug/control.php
    index d02f8cc38d..bbaa636ad9 100755
    --- a/module/bug/control.php
    +++ b/module/bug/control.php
    @@ -126,7 +126,7 @@ class bug extends control
             $queryID  = ($browseType == 'bysearch') ? (int)$param : 0;
     
             /* Set session. */
    -        $this->session->set('bugList', $this->app->getURI(true), 'qa');
    +        $this->session->set('bugList', $this->app->getURI(true) . "#app={$this->app->tab}", 'qa');
     
             /* Set moduleTree. */
             if($browseType == '')
    @@ -267,7 +267,7 @@ class bug extends control
         public function report($productID, $browseType, $branchID, $moduleID, $chartType = 'default')
         {
             $this->loadModel('report');
    -        $this->view->charts   = array();
    +        $this->view->charts = array();
     
             if(!empty($_POST))
             {
    @@ -1447,7 +1447,7 @@ class bug extends control
                 $this->loadModel('score')->create('ajax', 'batchOther');
             }
     
    -        if($type == 'product' || $type == 'my') return print(js::locate($this->session->bugList, 'parent'));
    +        if($type == 'product' || $type == 'my') return print(js::reload('parent'));
             if($type == 'execution') return print(js::locate($this->createLink('execution', 'bug', "executionID=$objectID")));
             if($type == 'project')   return print(js::locate($this->createLink('project', 'bug', "projectID=$objectID")));
         }
    diff --git a/module/bug/js/common.js b/module/bug/js/common.js
    index c39ceac887..26026e6df3 100644
    --- a/module/bug/js/common.js
    +++ b/module/bug/js/common.js
    @@ -712,8 +712,8 @@ function notice()
                 var branch = $('#branch').val();
                 if(typeof(branch) == 'undefined') branch = 0;
                 var link = createLink('release', 'create', 'productID=' + $('#product').val() + '&branch=' + branch);
    -            if(config.onlybody != 'yes') link += config.requestType == 'GET' ? '&onlybody=yes' : '?onlybody=yes';
    -            html += '' + createRelease + ' ';
    +            if(config.onlybody != 'yes') link += link.indexOf('?') >= 0 ? '&onlybody=yes' : '?onlybody=yes';
    +            html += '' + createBuild + ' ';
                 html += '' + refresh + '';
             }
             else
    @@ -722,7 +722,7 @@ function notice()
                 productID   = $('#product').val();
                 projectID   = $('#project').val();
                 link = createLink('build', 'create','executionID=' + executionID + '&productID=' + productID + '&projectID=' + projectID);
    -            if(config.onlybody != 'yes') link += config.requestType == 'GET' ? '&onlybody=yes' : '?onlybody=yes';
    +            link += link.indexOf('?') >= 0 ? '&onlybody=yes' : '?onlybody=yes';
                 html += '' + createBuild + ' ';
                 html += '' + refresh + '';
             }
    diff --git a/module/bug/lang/en.php b/module/bug/lang/en.php
    index e12a4b45ee..7647b31a3b 100644
    --- a/module/bug/lang/en.php
    +++ b/module/bug/lang/en.php
    @@ -137,6 +137,7 @@ $lang->bug->assignToMe         = 'AssignedToMe';
     $lang->bug->openedByMe         = 'ReportedByMe';
     $lang->bug->resolvedByMe       = 'ResolvedByMe';
     $lang->bug->closedByMe         = 'ClosedByMe';
    +$lang->bug->assignedByMe       = 'AssignedByMe';
     $lang->bug->assignToNull       = 'Unassigned';
     $lang->bug->unResolved         = 'Active';
     $lang->bug->toClosed           = 'ToBeClosed';
    @@ -420,6 +421,7 @@ $lang->bug->featureBar['browse']['unclosed']     = $lang->bug->unclosed;
     $lang->bug->featureBar['browse']['openedbyme']   = $lang->bug->openedByMe;
     $lang->bug->featureBar['browse']['assigntome']   = $lang->bug->assignToMe;
     $lang->bug->featureBar['browse']['resolvedbyme'] = $lang->bug->resolvedByMe;
    +$lang->bug->featureBar['browse']['assignedbyme'] = $lang->bug->assignedByMe;
     
     $lang->bug->featureBar['browse']['unresolved']   = $lang->bug->unResolved;
     $lang->bug->featureBar['browse']['more']         = $lang->more;
    diff --git a/module/bug/lang/zh-cn.php b/module/bug/lang/zh-cn.php
    index 1549c5b6fb..279caed209 100644
    --- a/module/bug/lang/zh-cn.php
    +++ b/module/bug/lang/zh-cn.php
    @@ -137,6 +137,7 @@ $lang->bug->assignToMe         = '指派给我';
     $lang->bug->openedByMe         = '由我创建';
     $lang->bug->resolvedByMe       = '由我解决';
     $lang->bug->closedByMe         = '由我关闭';
    +$lang->bug->assignedByMe       = '由我指派';
     $lang->bug->assignToNull       = '未指派';
     $lang->bug->unResolved         = '未解决';
     $lang->bug->toClosed           = '待关闭';
    @@ -420,6 +421,7 @@ $lang->bug->featureBar['browse']['unclosed']     = $lang->bug->unclosed;
     $lang->bug->featureBar['browse']['openedbyme']   = $lang->bug->openedByMe;
     $lang->bug->featureBar['browse']['assigntome']   = $lang->bug->assignToMe;
     $lang->bug->featureBar['browse']['resolvedbyme'] = $lang->bug->resolvedByMe;
    +$lang->bug->featureBar['browse']['assignedbyme'] = $lang->bug->assignedByMe;
     $lang->bug->featureBar['browse']['toclosed']     = $lang->bug->toClosed;
     $lang->bug->featureBar['browse']['unresolved']   = $lang->bug->unResolved;
     $lang->bug->featureBar['browse']['more']         = $lang->more;
    diff --git a/module/bug/model.php b/module/bug/model.php
    index 5976da81ad..ee586c068b 100644
    --- a/module/bug/model.php
    +++ b/module/bug/model.php
    @@ -79,7 +79,7 @@ class bugModel extends model
                 ->remove('files,labels,uid,oldTaskID,contactListMenu,region,lane')
                 ->get();
     
    -        if($bug->execution != 0) $bug->project = $this->dao->select('parent')->from(TABLE_EXECUTION)->where('id')->eq($bug->execution)->fetch('parent');
    +        if($bug->execution != 0) $bug->project = $this->dao->select('project')->from(TABLE_EXECUTION)->where('id')->eq($bug->execution)->fetch('project');
     
             /* Check repeat bug. */
             $result = $this->loadModel('common')->removeDuplicate('bug', $bug, "product={$bug->product}");
    @@ -211,7 +211,7 @@ class bugModel extends model
     
                 if(isset($data->lanes[$i])) $bug->laneID = $data->lanes[$i];
     
    -            if($bug->execution != 0) $bug->project = $this->dao->select('parent')->from(TABLE_EXECUTION)->where('id')->eq($bug->execution)->fetch('parent');
    +            if($bug->execution != 0) $bug->project = $this->dao->select('project')->from(TABLE_EXECUTION)->where('id')->eq($bug->execution)->fetch('project');
     
                 /* Assign the bug to the person in charge of the module. */
                 if(!empty($moduleOwners[$bug->module]))
    @@ -349,7 +349,7 @@ class bugModel extends model
             $bug->task         = 0;
             $bug->pri          = 3;
             $bug->severity     = 3;
    -        $bug->project      = $this->dao->select('parent')->from(TABLE_EXECUTION)->where('id')->eq($executionID)->fetch('parent');
    +        $bug->project      = $this->dao->select('project')->from(TABLE_EXECUTION)->where('id')->eq($executionID)->fetch('project');
     
             $this->dao->insert(TABLE_BUG)->data($bug, $skip = 'gitlab,gitlabProject')->autoCheck()->batchCheck($this->config->bug->create->requiredFields, 'notempty')->exec();
             if(!dao::isError()) return $this->dao->lastInsertID();
    @@ -396,6 +396,7 @@ class bugModel extends model
             elseif($browseType == 'needconfirm')   $bugs = $this->getByNeedconfirm($productIDList, $branch, $modules, $executions, $sort, $pager, $projectID);
             elseif($browseType == 'bysearch')      $bugs = $this->getBySearch($productIDList, $branch, $queryID, $sort, '', $pager, $projectID);
             elseif($browseType == 'overduebugs')   $bugs = $this->getOverdueBugs($productIDList, $branch, $modules, $executions, $sort, $pager, $projectID);
    +        elseif($browseType == 'assignedbyme')  $bugs = $this->getByAssignedbyme($productIDList, $branch, $modules, $executions, $sort, $pager, $projectID);
     
             return $this->checkDelayedBugs($bugs);
         }
    @@ -2327,7 +2328,7 @@ class bugModel extends model
             $commonOption = $this->lang->bug->report->options;
     
             $chartOption->graph->caption = $this->lang->bug->report->charts[$chartType];
    -        if(!isset($chartOption->type))    $chartOption->type    = $commonOption->type;
    +        if(!isset($chartOption->type))   $chartOption->type   = $commonOption->type;
             if(!isset($chartOption->width))  $chartOption->width  = $commonOption->width;
             if(!isset($chartOption->height)) $chartOption->height = $commonOption->height;
     
    @@ -2646,6 +2647,36 @@ class bugModel extends model
                 ->fetchAll();
         }
     
    +    /**
    +     * Get by assigned by me.
    +     * @param  array      $productIDList
    +     * @param  int|string $branch
    +     * @param  array      $modules
    +     * @param  array      $executions
    +     * @param  string     $sort
    +     * @param  object     $pager
    +     * @param  int        $projectID
    +     *
    +     * @access public
    +     * @return array
    +     */
    +    public function getByAssignedbyme($productIDList, $branch, $modules, $executions, $sort, $pager, $projectID)
    +    {
    +        $actionIDList = $this->dao->select('objectID')->from(TABLE_ACTION)->where('objectType')->eq('bug')->andWhere('action')->eq('assigned')->andWhere('actor')->eq($this->app->user->account)->fetchPairs('objectID', 'objectID');
    +        return $this->dao->select('*')->from(TABLE_BUG)
    +            ->where('product')->in($productIDList)
    +            ->beginIF($branch !== 'all')->andWhere('branch')->in($branch)->fi()
    +            ->beginIF($modules)->andWhere('module')->in($modules)->fi()
    +            ->beginIF($projectID)->andWhere('project')->eq($projectID)->fi()
    +            ->andWhere('execution')->in(array_keys($executions))
    +            ->andWhere('deleted')->eq(0)
    +            ->andWhere('status')->ne('closed')
    +            ->andWhere('id')->in($actionIDList)
    +            ->orderBy($sort)
    +            ->page($pager)
    +            ->fetchAll();
    +    }
    +
         /**
          * Get by Sonarqube id.
          *
    @@ -3282,7 +3313,7 @@ class bugModel extends model
             if($type == 'view') $menu .= $this->buildMenu('bug', 'activate', $params, $bug, $type, '', '', "text-success iframe showinonlybody", true);
             if($type == 'view' && $this->app->tab != 'product')
             {
    -            $menu .= $this->buildMenu('bug', 'toStory', $toStoryParams, $bug, $type, $this->lang->icons['story'], '', '', '', "data-app='product'", $this->lang->bug->toStory);
    +            $menu .= $this->buildMenu('bug', 'toStory', $toStoryParams, $bug, $type, $this->lang->icons['story'], '', '', '', "data-app='qa'", $this->lang->bug->toStory);
                 $menu .= $this->buildMenu('bug', 'createCase', $convertParams, $bug, $type, 'sitemap');
             }
             if($type == 'view')
    diff --git a/module/bug/view/batchcreate.html.php b/module/bug/view/batchcreate.html.php
    index f0f4267291..dd09aa4bb5 100755
    --- a/module/bug/view/batchcreate.html.php
    +++ b/module/bug/view/batchcreate.html.php
    @@ -131,12 +131,7 @@
                 '>
                 loadModel('flow');
    -            foreach($extendFields as $extendField) 
    -            {
    -                $object = new stdclass();
    -                $object->{$extendField->field} = $extendField->default;
    -                echo "control == 'select' or $extendField->control == 'multi-select') ? " style='overflow:visible'" : '') . ">" . $this->flow->getFieldControl($extendField, $object, $extendField->field . "[$i]") . "";
    -            }
    +            foreach($extendFields as $extendField) echo "control == 'select' or $extendField->control == 'multi-select') ? " style='overflow:visible'" : '') . ">" . $this->flow->getFieldControl($extendField, '', $extendField->field . "[$i]") . "";
                 ?>
               
               
    @@ -186,12 +181,7 @@
                 '>
                 loadModel('flow');
    -            foreach($extendFields as $extendField) 
    -            {
    -                $object = new stdclass();
    -                $object->{$extendField->field} = $extendField->default;
    -                echo "control == 'select' or $extendField->control == 'multi-select') ? " style='overflow:visible'" : '') . ">" . $this->flow->getFieldControl($extendField, $object, $extendField->field . "[$i]") . "";
    -            }
    +            foreach($extendFields as $extendField) echo "control == 'select' or $extendField->control == 'multi-select') ? " style='overflow:visible'" : '') . ">" . $this->flow->getFieldControl($extendField, '', $extendField->field . "[$i]") . "";
                 ?>
               
               
    @@ -240,12 +230,7 @@
           '>
           loadModel('flow');
    -      foreach($extendFields as $extendField) 
    -      {
    -          $object = new stdclass();
    -          $object->{$extendField->field} = $extendField->default;
    -          echo "control == 'select' or $extendField->control == 'multi-select') ? " style='overflow:visible'" : '') . ">" . $this->flow->getFieldControl($extendField, $object, $extendField->field . "[%s]") . "";
    -      }
    +      foreach($extendFields as $extendField) echo "control == 'select' or $extendField->control == 'multi-select') ? " style='overflow:visible'" : '') . ">" . $this->flow->getFieldControl($extendField, '', $extendField->field . "[%s]") . "";
           ?>
         
       
    diff --git a/module/bug/view/report.html.php b/module/bug/view/report.html.php
    index 118c64ed1a..24111bc15f 100644
    --- a/module/bug/view/report.html.php
    +++ b/module/bug/view/report.html.php
    @@ -65,8 +65,19 @@
                       report->percent;?>
                     
                   
    +              bug->colorList->pri;
    +              }
    +              elseif(strpos(strtolower($chartType), 'severity') !== false)
    +              {
    +                  $colorList = $config->bug->colorList->severity;
    +              }
    +              ?>
                    $data):?>
    -              
    +              
                     
                     name;?>
                     value;?>
    diff --git a/module/bug/view/view.html.php b/module/bug/view/view.html.php
    index b7f0629533..1d52f7da9c 100644
    --- a/module/bug/view/view.html.php
    +++ b/module/bug/view/view.html.php
    @@ -217,7 +217,7 @@
                       bug->deadline;?>
                       
                         deadline) echo  $bug->deadline;
    +                    if($bug->deadline) echo $bug->deadline;
                         if(isset($bug->delay)) printf($lang->bug->delayWarning, $bug->delay);
                         ?>
                       
    diff --git a/module/build/model.php b/module/build/model.php
    index 0bd153b707..3283ec5786 100644
    --- a/module/build/model.php
    +++ b/module/build/model.php
    @@ -246,7 +246,10 @@ class buildModel extends model
                 foreach($releases as $buildID => $releaseName)
                 {
                     $branchName = $allBuilds[$buildID]->branchName ? $allBuilds[$buildID]->branchName : $this->lang->branch->main;
    -                $builds[$buildID] = (strpos($params, 'withbranch') !== false ? $branchName . '/' : '') . $releaseName;
    +                if($allBuilds[$buildID]->productType != 'normal')
    +                {
    +                    $builds[$buildID] = (strpos($params, 'withbranch') !== false ? $branchName . '/' : '') . $releaseName;
    +                }
                 }
             }
     
    diff --git a/module/build/view/linkstory.html.php b/module/build/view/linkstory.html.php
    index d874361a28..56e906e431 100644
    --- a/module/build/view/linkstory.html.php
    +++ b/module/build/view/linkstory.html.php
    @@ -42,7 +42,7 @@
             
             
               
    -            id => sprintf('%03d', $story->id)), ($story->stage == 'developed' or $story->status == 'closed') ? $story->id : '');?>
    +            id => sprintf('%03d', $story->id)), (in_array($story->stage, array('developed', 'closed', 'tested'))) ? $story->id : '');?>
               
               story->priList, $story->pri, $story->pri)?>
               
    diff --git a/module/caselib/control.php b/module/caselib/control.php
    index dc8d4b19cc..8d31fe904e 100644
    --- a/module/caselib/control.php
    +++ b/module/caselib/control.php
    @@ -251,7 +251,6 @@ class caselib extends control
             if(!empty($_POST))
             {
                 $this->loadModel('testcase');
    -            $this->config->testcase->create->requiredFields = $this->config->caselib->createcase->requiredFields;
                 setcookie('lastLibCaseModule', (int)$this->post->module, $this->config->cookieLife, $this->config->webRoot, '', $this->config->cookieSecure, false);
                 $caseResult = $this->testcase->create($bugID = 0);
                 if(!$caseResult or dao::isError()) return print(js::error(dao::getError()));
    diff --git a/module/caselib/model.php b/module/caselib/model.php
    index fe9fd3c30f..c087eb1974 100644
    --- a/module/caselib/model.php
    +++ b/module/caselib/model.php
    @@ -258,7 +258,7 @@ class caselibModel extends model
     
                 $cases = $this->dao->select('*')->from(TABLE_CASE)->where($caseQuery)
                     ->beginIF($queryLibID != 'all')->andWhere('lib')->eq((int)$libID)->fi()
    -                ->beginIF($this->config->systemMode == 'new' and $this->lang->navGroup->caselib != 'qa')->andWhere('project')->eq($this->session->project)->fi()
    +                ->beginIF($this->config->systemMode == 'new' and $this->app->tab != 'qa')->andWhere('project')->eq($this->session->project)->fi()
                     ->andWhere('product')->eq(0)
                     ->andWhere('deleted')->eq(0)
                     ->orderBy($sort)->page($pager)->fetchAll();
    @@ -282,7 +282,7 @@ class caselibModel extends model
             $this->config->testcase->search['params']['lib']['values']    = array('' => '', $libID => $libraries[$libID], 'all' => $this->lang->caselib->all);
             $this->config->testcase->search['params']['lib']['operator']  = '=';
             $this->config->testcase->search['params']['lib']['control']   = 'select';
    -        $this->config->testcase->search['params']['module']['values'] = $this->loadModel('tree')->getOptionMenu($libID, $viewType = 'caselib');
    +        $this->config->testcase->search['params']['module']['values'] = $this->loadModel('tree')->getOptionMenu($libID, 'caselib');
             if(!$this->config->testcase->needReview) unset($this->config->testcase->search['params']['status']['values']['wait']);
             unset($this->config->testcase->search['fields']['product']);
             unset($this->config->testcase->search['params']['product']);
    @@ -586,8 +586,7 @@ class caselibModel extends model
     
                     if(dao::isError())
                     {
    -                    echo js::error(dao::getError());
    -                    return print(js::reload('parent'));
    +                    return helper::end(js::error(dao::getError()));
                     }
     
                     $caseID   = $this->dao->lastInsertID();
    diff --git a/module/caselib/view/batchcreatecase.html.php b/module/caselib/view/batchcreatecase.html.php
    index 8324c93b9a..19bf7f080d 100644
    --- a/module/caselib/view/batchcreatecase.html.php
    +++ b/module/caselib/view/batchcreatecase.html.php
    @@ -25,7 +25,7 @@
           
             
               idAB;?>
    -          testcase->module;?>
    +          '>testcase->module;?>
               testcase->title;?>
               testcase->type;?>
               testcase->pri;?>
    diff --git a/module/caselib/view/createcase.html.php b/module/caselib/view/createcase.html.php
    index 5f1e0ef23c..f1e4629c8c 100644
    --- a/module/caselib/view/createcase.html.php
    +++ b/module/caselib/view/createcase.html.php
    @@ -33,7 +33,7 @@
                     
                   
                 
    -            
    +            testcase->create->requiredFields, 'module') ? ' class="required"' : '';?>>
                   
    testcase->module?> my->dividerMenu = ',work,dynamic,'; /* Program menu. */ $lang->program->homeMenu = new stdclass(); -$lang->program->homeMenu->browse = array('link' => "{$lang->program->list}|program|browse|", 'alias' => 'create,edit'); +$lang->program->homeMenu->browse = array('link' => "{$lang->program->list}|program|browse|", 'alias' => 'create,edit', 'subModule' => 'project'); $lang->program->homeMenu->kanban = array('link' => "{$lang->program->kanban}|program|kanban|"); $lang->program->menu = new stdclass(); @@ -476,7 +476,7 @@ $lang->company->menuOrder[30] = 'addUser'; $lang->admin->menu = new stdclass(); $lang->admin->menu->index = array('link' => "$lang->indexPage|admin|index", 'alias' => 'register,certifytemail,certifyztmobile,ztcompany'); $lang->admin->menu->company = array('link' => "{$lang->personnel->common}|company|browse|", 'subModule' => ',user,dept,group,'); -$lang->admin->menu->model = array('link' => "$lang->model|custom|browsestoryconcept|", 'class' => 'dropdown dropdown-hover', 'exclude' => 'custom-index,custom-set,custom-product,custom-execution,custom-required,custom-flow,custom-score,custom-feedback,custom-timezone,custom-mode'); +$lang->admin->menu->model = array('link' => "$lang->model|custom|browsestoryconcept|", 'class' => 'dropdown dropdown-hover', 'exclude' => 'custom-index,custom-set,custom-product,custom-execution,custom-kanban,custom-required,custom-flow,custom-score,custom-feedback,custom-timezone,custom-mode'); $lang->admin->menu->custom = array('link' => "{$lang->custom->common}|custom|index", 'exclude' => 'custom-browsestoryconcept,custom-timezone,custom-estimate'); $lang->admin->menu->extension = array('link' => "{$lang->extension->common}|extension|browse", 'subModule' => 'extension'); $lang->admin->menu->dev = array('link' => "$lang->redev|dev|api", 'alias' => 'db', 'subModule' => 'dev,editor,entry'); diff --git a/module/common/model.php b/module/common/model.php index 363835bbd6..27e2443cc6 100644 --- a/module/common/model.php +++ b/module/common/model.php @@ -450,7 +450,7 @@ class commonModel extends model echo '
  • ' . html::a(helper::createLink('tutorial', 'start'), " " . $lang->tutorialAB, '', "class='iframe' data-class-name='modal-inverse' data-width='800' data-headerless='true' data-backdrop='true' data-keyboard='true'") . '
  • '; } - echo '
  • ' . html::a(helper::createLink('my', 'preference', '', '', true), " " . $lang->preference, '', "class='iframe' data-width='700'") . '
  • '; + echo '
  • ' . html::a(helper::createLink('my', 'preference', 'showTip=false', '', true), " " . $lang->preference, '', "class='iframe' data-width='700'") . '
  • '; } if(common::hasPriv('my', 'changePassword')) echo '
  • ' . html::a(helper::createLink('my', 'changepassword', '', '', true), " " . $lang->changePassword, '', "class='iframe' data-width='600'") . '
  • '; @@ -664,7 +664,12 @@ class commonModel extends model $params = "productID=$productID&branch=&moduleID=0&from=¶m=0&storyID=0&extras=from=global"; break; case 'execution': - $params = "projectID=&executionID=0©ExecutionID=0&planID=0&confirm=no&productID=0&extra=from=global"; + $projectID = 0; + if(in_array($app->tab, array('project', 'execution')) and isset($lang->switcherMenu)) + { + $projectID = isset($_SESSION['project']) ? $_SESSION['project'] : 0; + } + $params = "projectID={$projectID}&executionID=0©ExecutionID=0&planID=0&confirm=no&productID=0&extra=from=global"; break; case 'product': $params = "programID=&extra=from=global"; @@ -913,6 +918,9 @@ class commonModel extends model public static function getMainNavList($moduleName) { global $lang; + global $app; + + $app->loadLang('my'); $menuOrder = $lang->mainNav->menuOrder; ksort($menuOrder); @@ -958,8 +966,24 @@ class commonModel extends model } } + /* Check whether other preference item under the module have permissions. If yes, point to other methods. */ + $moduleLinkList = $currentModule . 'LinkList'; + if(!$display and isset($lang->my->$moduleLinkList)) + { + foreach($lang->my->$moduleLinkList as $key => $linkList) + { + $method = explode('-', $key)[1]; + if(common::hasPriv($currentModule, $method)) + { + $display = true; + $currentMethod = $method; + break; + } + } + } + /* Check whether other methods under the module have permissions. If yes, point to other methods. */ - if($display == false and isset($lang->$currentModule->menu)) + if($display == false and isset($lang->$currentModule->menu) and !in_array($currentModule, array('program', 'product', 'project', 'execution'))) { foreach($lang->$currentModule->menu as $menu) { @@ -2216,7 +2240,7 @@ EOD; { if($this->app->getModuleName() == 'upgrade' and $this->session->upgrading) return false; - $statusFile = $this->app->getAppRoot() . 'www' . DIRECTORY_SEPARATOR . 'ok.txt'; + $statusFile = $this->app->getAppRoot() . 'www' . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'ok.txt'; return (!is_file($statusFile) or (time() - filemtime($statusFile)) > 3600) ? $statusFile : false; } @@ -3250,13 +3274,11 @@ EOD; if(empty($markdown)) return false; global $app; - $app->loadClass('parsedownextraplugin'); + $app->loadClass('michelf'); - $Parsedown = new parsedownextraplugin; + $Michelf = new michelf; - $Parsedown->voidElementSuffix = '>'; // HTML5 - - return $Parsedown->text($markdown); + return $Michelf->parse($markdown); } } diff --git a/module/common/view/footer.html.php b/module/common/view/footer.html.php index 84ebd0b205..6a5ea6bf22 100755 --- a/module/common/view/footer.html.php +++ b/module/common/view/footer.html.php @@ -78,5 +78,14 @@ $extHookRule = $extensionRoot . 'custom/common/ext/view/footer.*.hook.php'; $extHookFiles = glob($extHookRule); if($extHookFiles) foreach($extHookFiles as $extHookFile) include $extHookFile; ?> +debug > 2 and $config->tabSession): ?> +
    +tsid= + +servertid= + +sid= +
    + diff --git a/module/common/view/header.lite.html.php b/module/common/view/header.lite.html.php index 560f800007..b0e795ee68 100755 --- a/module/common/view/header.lite.html.php +++ b/module/common/view/header.lite.html.php @@ -19,7 +19,6 @@ $onlybody = zget($_GET, 'onlybody', 'no'); zentaoPMS); js::exportConfigVars(); - echo ''; if($config->debug) { $timestamp = time(); diff --git a/module/convert/model.php b/module/convert/model.php index 4eda5be515..9f5b684fa8 100644 --- a/module/convert/model.php +++ b/module/convert/model.php @@ -857,7 +857,7 @@ class convertModel extends model $bug->openedBy = $this->getJiraAccount($data->CREATOR, $method); $bug->openedDate = substr($data->CREATED, 0, 19); $bug->openedBuild = 'trunk'; - $bug->assignedTo = $this->getJiraAccount($data->ASSIGNEE, $method); + $bug->assignedTo = $bug->status == 'closed' ? 'closed' : $this->getJiraAccount($data->ASSIGNEE, $method); if($data->RESOLUTION) { diff --git a/module/custom/config.php b/module/custom/config.php index 9b42284642..c95872d315 100644 --- a/module/custom/config.php +++ b/module/custom/config.php @@ -56,8 +56,8 @@ $config->custom->fieldList['build'] = 'scmPath,filePath,desc'; $config->custom->fieldList['bug']['create'] = 'module,project,deadline,type,os,browser,severity,pri,steps,keywords'; $config->custom->fieldList['bug']['edit'] = 'plan,project,assignedTo,deadline,type,os,browser,severity,pri,steps,keywords'; $config->custom->fieldList['bug']['resolve'] = 'resolvedBuild,resolvedDate,assignedTo,comment'; -$config->custom->fieldList['testcase']['create'] = 'stage,story,pri,precondition,keywords'; -$config->custom->fieldList['testcase']['edit'] = 'stage,story,pri,precondition,keywords,status'; +$config->custom->fieldList['testcase']['create'] = 'stage,story,pri,precondition,keywords,module'; +$config->custom->fieldList['testcase']['edit'] = 'stage,story,pri,precondition,keywords,status,module'; $config->custom->fieldList['testsuite'] = 'desc'; $config->custom->fieldList['caselib'] = 'desc'; $config->custom->fieldList['testcase']['createcase'] = 'lib,stage,pri,precondition,keywords'; diff --git a/module/custom/control.php b/module/custom/control.php index 62ad339866..6ed32f35e7 100644 --- a/module/custom/control.php +++ b/module/custom/control.php @@ -71,7 +71,18 @@ class custom extends control if(($module == 'story' or $module == 'testcase') and $field == 'review') { $this->app->loadConfig($module); - $this->view->users = $this->loadModel('user')->getPairs('noclosed|nodeleted'); + $this->loadModel('user'); + + if($module == 'story') + { + $this->view->depts = $this->loadModel('dept')->getDeptPairs(); + $this->view->forceReviewAll = zget($this->config->$module, 'forceReviewAll', 'false'); + $this->view->forceReview = zget($this->config->$module, 'forceReview', ''); + $this->view->forceReviewRoles = zget($this->config->$module, 'forceReviewRoles', ''); + $this->view->forceReviewDepts = zget($this->config->$module, 'forceReviewDepts', ''); + } + + $this->view->users = $module == 'story' ? $this->user->getCanCreateStoryUsers() : $this->user->getPairs('noclosed|nodeleted'); $this->view->needReview = zget($this->config->$module, 'needReview', 1); $this->view->forceReview = zget($this->config->$module, 'forceReview', ''); $this->view->forceNotReview = zget($this->config->$module, 'forceNotReview', ''); @@ -112,7 +123,16 @@ class custom extends control } elseif($module == 'story' and $field == 'review') { - $data = fixer::input('post')->join('forceReview', ',')->get(); + $data = fixer::input('post') + ->setDefault('forceReviewAll', 0) + ->setDefault('forceReviewDepts', '') + ->join('forceReview', ',') + ->join('forceReviewRoles', ',') + ->join('forceReviewDepts', ',') + ->join('forceReviewAll', ',') + ->setIF(isset($this->post->forceReviewAll), 'forceReviewAll', 1) + ->get(); + $this->loadModel('setting')->setItems("system.$module@{$this->config->vision}", $data); } elseif($module == 'story' and $field == 'reviewRules') @@ -518,6 +538,27 @@ class custom extends control $this->display(); } + /** + * Set whether the kanban is read-only. + * + * @access public + * @return void + */ + public function kanban() + { + if($_POST) + { + $this->loadModel('setting')->setItem("system.common.CRKanban@{$this->config->vision}", $this->post->kanban); + return $this->send(array('result' => 'success', 'message' => $this->lang->saveSuccess, 'locate' => 'reload')); + } + + $this->view->title = $this->lang->custom->kanban; + $this->view->position[] = $this->lang->custom->common; + $this->view->position[] = $this->view->title; + + $this->display(); + } + /** * Set flow. * diff --git a/module/custom/lang/en.php b/module/custom/lang/en.php index 84ffb3fb62..257bbdc8b8 100644 --- a/module/custom/lang/en.php +++ b/module/custom/lang/en.php @@ -49,6 +49,11 @@ $lang->custom->switch = "Switch"; $lang->custom->oneUnit = "One {$lang->hourCommon}"; $lang->custom->convertRelationTitle = "Please firstly set the conversion factor from {$lang->hourCommon} to %s"; $lang->custom->superReviewers = "Super Reviewer"; +$lang->custom->kanban = "Kanban"; +$lang->custom->allUsers = 'All Users'; +$lang->custom->account = 'Users'; +$lang->custom->role = 'Role'; +$lang->custom->dept = 'Dept'; if($config->systemMode == 'new') $lang->custom->execution = 'Execution'; if($config->systemMode == 'classic' || !$config->systemMode) $lang->custom->execution = $lang->executionCommon; @@ -79,11 +84,13 @@ $lang->custom->saveTips = 'After clicking save, the current %s will b $lang->custom->numberError = 'The interval must be greater than zero!'; $lang->custom->closedExecution = 'Closed ' . $lang->executionCommon; +$lang->custom->closedKanban = 'Closed ' . $lang->custom->kanban; $lang->custom->closedProduct = 'Closed ' . $lang->productCommon; if($config->systemMode == 'new') $lang->custom->object['project'] = 'Project'; $lang->custom->object['product'] = $lang->productCommon; $lang->custom->object['execution'] = $lang->custom->execution; +$lang->custom->object['kanban'] = $lang->custom->kanban; $lang->custom->object['story'] = 'Story'; $lang->custom->object['task'] = 'Task'; $lang->custom->object['bug'] = 'Bug'; @@ -174,9 +181,12 @@ $lang->custom->notice->conceptResult = 'According to your preference, $lang->custom->notice->conceptPath = 'Go to Admin -> Custom -> Concept to set it.'; $lang->custom->notice->readOnlyOfProduct = 'If Change Forbidden, any change on stories, bugs, cases, efforts, releases and plans of the closed product is also forbidden.'; $lang->custom->notice->readOnlyOfExecution = "If Change Forbidden, any change on tasks, builds, efforts and stories of the closed {$lang->executionCommon} is also forbidden."; +$lang->custom->notice->readOnlyOfKanban = "If Change Forbidden, any change on kanban card and related operations of {$lang->custom->kanban} is also forbidden."; $lang->custom->notice->URSREmpty = 'Custom requirement name can not be empty!'; $lang->custom->notice->confirmDelete = 'Are you sure you want to delete it?'; $lang->custom->notice->confirmReviewCase = 'Set the case in Wait to Normal?'; +$lang->custom->notice->storyReviewTip = 'After selecting by individual, position, and department, take the union of these three filters. '; +$lang->custom->notice->selectAllTip = 'After selecting all people, the reviewers will be emptied and grayed out while hiding their positions and departments.'; $lang->custom->notice->indexPage['product'] = "ZenTao 8.2+ has Product Home. Do you want to go to Product Home?"; $lang->custom->notice->indexPage['project'] = "ZenTao 8.2+ has Project Home. Do you want to go to Project Home?"; @@ -232,6 +242,9 @@ $lang->custom->CRProduct[0] = 'Change Forbidden'; $lang->custom->CRExecution[1] = 'Change Allowed'; $lang->custom->CRExecution[0] = 'Change Forbidden'; +$lang->custom->CRKanban[1] = 'Change Allowed'; +$lang->custom->CRKanban[0] = 'Change Forbidden'; + $lang->custom->moduleName['product'] = $lang->productCommon; $lang->custom->moduleName['productplan'] = 'Plan'; $lang->custom->moduleName['execution'] = $lang->custom->execution; diff --git a/module/custom/lang/zh-cn.php b/module/custom/lang/zh-cn.php index 991319a060..c4142d8e76 100644 --- a/module/custom/lang/zh-cn.php +++ b/module/custom/lang/zh-cn.php @@ -49,6 +49,11 @@ $lang->custom->switch = "切换"; $lang->custom->oneUnit = "一个{$lang->hourCommon}"; $lang->custom->convertRelationTitle = "请先设置{$lang->hourCommon}转换为%s的换算系数"; $lang->custom->superReviewers = "超级评审人"; +$lang->custom->kanban = "看板"; +$lang->custom->allUsers = '所有人员'; +$lang->custom->account = '人员'; +$lang->custom->role = '职位'; +$lang->custom->dept = '部门'; if($config->systemMode == 'new') $lang->custom->execution = '执行'; if($config->systemMode == 'classic' || !$config->systemMode) $lang->custom->execution = $lang->executionCommon; @@ -79,11 +84,13 @@ $lang->custom->saveTips = '点击保存后,则以当前%s为默认 $lang->custom->numberError = '区间必须大于零'; $lang->custom->closedExecution = '已关闭' . $lang->custom->execution; +$lang->custom->closedKanban = '已关闭' . $lang->custom->kanban; $lang->custom->closedProduct = '已关闭' . $lang->productCommon; if($config->systemMode == 'new') $lang->custom->object['project'] = '项目'; $lang->custom->object['product'] = $lang->productCommon; $lang->custom->object['execution'] = $lang->custom->execution; +$lang->custom->object['kanban'] = $lang->custom->kanban; $lang->custom->object['story'] = $lang->SRCommon; $lang->custom->object['task'] = '任务'; $lang->custom->object['bug'] = 'Bug'; @@ -174,9 +181,12 @@ $lang->custom->notice->conceptResult = '我们已经根据您的选择为 $lang->custom->notice->conceptPath = '您可以在:后台 -> 自定义 -> 流程页面修改。'; $lang->custom->notice->readOnlyOfProduct = '禁止修改后,已关闭' . $lang->productCommon . '下的' . $lang->SRCommon . '、Bug、用例、日志、发布、计划都禁止修改。'; $lang->custom->notice->readOnlyOfExecution = "禁止修改后,已关闭{$lang->custom->execution}下的任务、版本、日志以及关联需求都禁止修改。"; +$lang->custom->notice->readOnlyOfKanban = "禁止修改后,已关闭{$lang->custom->kanban}下的卡片以及相关设置都禁止修改。"; $lang->custom->notice->URSREmpty = '自定义需求名称不能为空!'; $lang->custom->notice->confirmDelete = '您确定要删除吗?'; $lang->custom->notice->confirmReviewCase = '是否将待评审的用例修改为正常状态?'; +$lang->custom->notice->storyReviewTip = '按人员、职位、部门勾选后,取所有人员的并集。'; +$lang->custom->notice->selectAllTip = '勾选所有人员后,会清空并置灰评审人员,同时隐藏职位、部门。'; $lang->custom->notice->indexPage['product'] = "从8.2版本起增加了产品主页视图,是否默认进入产品主页?"; $lang->custom->notice->indexPage['project'] = "从8.2版本起增加了项目主页视图,是否默认进入项目主页?"; @@ -232,6 +242,9 @@ $lang->custom->CRProduct[0] = '禁止修改'; $lang->custom->CRExecution[1] = '允许修改'; $lang->custom->CRExecution[0] = '禁止修改'; +$lang->custom->CRKanban[1] = '允许修改'; +$lang->custom->CRKanban[0] = '禁止修改'; + $lang->custom->moduleName['product'] = $lang->productCommon; $lang->custom->moduleName['productplan'] = '计划'; $lang->custom->moduleName['execution'] = $lang->custom->execution; diff --git a/module/custom/view/header.html.php b/module/custom/view/header.html.php index a74dcd2e93..26b58c1e3f 100644 --- a/module/custom/view/header.html.php +++ b/module/custom/view/header.html.php @@ -5,8 +5,8 @@ foreach($lang->custom->object as $object => $name) { if(strpos('story|todo|block', $object) !== false) echo ""; - if(strpos('execution|product', $object) !== false) common::printLink('custom', $object, "", "{$lang->custom->$object}", '', "class='btn btn-link' id='{$object}Tab'"); - if(strpos('execution|product', $object) === false) common::printLink('custom', 'set', "module=$object&field=" . key($lang->custom->{$object}->fields), "{$name}", '', "class='btn btn-link' id='{$object}Tab'"); + if(strpos('execution|product|kanban', $object) !== false) common::printLink('custom', $object, "", "{$lang->custom->$object}", '', "class='btn btn-link' id='{$object}Tab'"); + if(strpos('execution|product|kanban', $object) === false) common::printLink('custom', 'set', "module=$object&field=" . key($lang->custom->{$object}->fields), "{$name}", '', "class='btn btn-link' id='{$object}Tab'"); if($object == 'user') common::printLink('custom', 'required', "", "{$lang->custom->required}", '', "class='btn btn-link' id='requiredTab'"); } diff --git a/module/custom/view/kanban.html.php b/module/custom/view/kanban.html.php new file mode 100644 index 0000000000..787c1b1eea --- /dev/null +++ b/module/custom/view/kanban.html.php @@ -0,0 +1,41 @@ + + * @package custom + * @version $Id$ + * @link http://www.zentao.net + */ +?> + +
    +
    + + + + + + + + + + +
    custom->closedKanban;?> + CRKanban) ? $config->CRKanban : 1;?> + custom->CRKanban as $key => $value):?> + + + custom->notice->readOnlyOfKanban;?>
    + +
    +
    +
    + + diff --git a/module/custom/view/set.html.php b/module/custom/view/set.html.php index ef2f2ca94e..4e1a38ed76 100644 --- a/module/custom/view/set.html.php +++ b/module/custom/view/set.html.php @@ -36,6 +36,18 @@ EOT; + + +
    + diff --git a/module/kanban/view/sendmail.html.php b/module/kanban/view/sendmail.html.php new file mode 100644 index 0000000000..939287afb9 --- /dev/null +++ b/module/kanban/view/sendmail.html.php @@ -0,0 +1,34 @@ + + * @package kanban + * @version $Id: sendmail.html.php 867 2022-05-17 13:50:58Z $ + * @link https://www.zentao.net + */ +?> +id . ' ' . $object->name;?> +app->getModuleRoot() . 'common/view/mail.header.html.php';?> + + + + + + +
    + kanban", 'html'), $mailTitle, '', "style='color: #333; text-decoration: underline;'");?> +
    + + + + +
    + lang->kanbancard->legendDesc;?> +
    desc;?>
    +
    + + +app->getModuleRoot() . 'common/view/mail.footer.html.php';?> diff --git a/module/kanban/view/space.html.php b/module/kanban/view/space.html.php index 0d21beed09..d84c12e01e 100644 --- a/module/kanban/view/space.html.php +++ b/module/kanban/view/space.html.php @@ -68,27 +68,37 @@ name;?>
    - - + status == 'active'); + $canActivate = (common::hasPriv('kanban', 'activate') and $kanban->status == 'closed'); + ?> +