소스 검색

(HTML) Formatted Case Logs, Description and Notifications with inline images uploaded as Attachments. Beta Version !!

git-svn-id: http://svn.code.sf.net/p/itop/code/trunk@3916 a333f486-631f-4898-b8df-5754b55c2be0
dflaven 9 년 전
부모
커밋
ccf403a397
46개의 변경된 파일4215개의 추가작업 그리고 206개의 파일을 삭제
  1. 321 0
      application/Html2Text.php
  2. 28 0
      application/Html2TextException.php
  3. 16 2
      application/cmdbabstract.class.inc.php
  4. 30 4
      application/displayblock.class.inc.php
  5. 3 3
      application/itopwebpage.class.inc.php
  6. 65 0
      application/utils.inc.php
  7. 3 3
      core/action.class.inc.php
  8. 291 56
      core/attributedef.class.inc.php
  9. 64 26
      core/cmdbchangeop.class.inc.php
  10. 18 4
      core/cmdbobject.class.inc.php
  11. 10 2
      core/config.class.inc.php
  12. 11 5
      core/csvbulkexport.class.inc.php
  13. 4 4
      core/dbobject.class.php
  14. 43 3
      core/email.class.inc.php
  15. 41 7
      core/excelbulkexport.class.inc.php
  16. 338 0
      core/htmlsanitizer.class.inc.php
  17. 3 4
      core/metamodel.class.php
  18. 101 17
      core/ormcaselog.class.inc.php
  19. 51 4
      css/light-grey.css
  20. 38 5
      css/light-grey.scss
  21. 3 3
      datamodels/1.x/itop-attachments/main.attachments.php
  22. 114 2
      datamodels/2.x/itop-attachments/ajax.attachment.php
  23. 374 0
      datamodels/2.x/itop-attachments/css/magnific-popup.css
  24. 2060 0
      datamodels/2.x/itop-attachments/js/jquery.magnific-popup.js
  25. 3 0
      datamodels/2.x/itop-attachments/js/jquery.magnific-popup.min.js
  26. 115 16
      datamodels/2.x/itop-attachments/main.attachments.php
  27. 1 0
      datamodels/2.x/itop-tickets/datamodel.itop-tickets.xml
  28. 2 1
      dictionaries/cs.dictionary.itop.core.php
  29. 2 1
      dictionaries/da.dictionary.itop.core.php
  30. 3 2
      dictionaries/de.dictionary.itop.core.php
  31. 3 2
      dictionaries/dictionary.itop.core.php
  32. 3 2
      dictionaries/es_cr.dictionary.itop.core.php
  33. 3 2
      dictionaries/fr.dictionary.itop.core.php
  34. 3 2
      dictionaries/hu.dictionary.itop.core.php
  35. 3 2
      dictionaries/it.dictionary.itop.core.php
  36. 3 2
      dictionaries/ja.dictionary.itop.core.php
  37. 3 2
      dictionaries/nl.dictionary.itop.core.php
  38. 3 2
      dictionaries/pt_br.dictionary.itop.core.php
  39. 1 0
      dictionaries/ru.dictionary.itop.core.php
  40. 3 2
      dictionaries/tr.dictionary.itop.core.php
  41. 3 2
      dictionaries/zh.dictionary.itop.core.php
  42. 10 0
      js/forms-json-utils.js
  43. 16 11
      js/jquery.tablesorter.pager.js
  44. 1 1
      js/simple_graph.js
  45. BIN
      portal/images/company_logo.png
  46. 1 0
      setup/compiler.class.inc.php

+ 321 - 0
application/Html2Text.php

@@ -0,0 +1,321 @@
+<?php
+namespace Html2Text;
+
+/**
+ * Replace all occurrences of the search string with the replacement string.
+ *
+ * @author Sean Murphy <sean@iamseanmurphy.com>
+ * @copyright Copyright 2012 Sean Murphy. All rights reserved.
+ * @license http://creativecommons.org/publicdomain/zero/1.0/
+ * @link http://php.net/manual/function.str-replace.php
+ *
+ * @param mixed $search
+ * @param mixed $replace
+ * @param mixed $subject
+ * @param int $count
+ * @return mixed
+ */
+function mb_str_replace($search, $replace, $subject, &$count = 0) {
+	if (!is_array($subject)) {
+		// Normalize $search and $replace so they are both arrays of the same length
+		$searches = is_array($search) ? array_values($search) : array($search);
+		$replacements = is_array($replace) ? array_values($replace) : array($replace);
+		$replacements = array_pad($replacements, count($searches), '');
+		foreach ($searches as $key => $search) {
+			$parts = mb_split(preg_quote($search), $subject);
+			$count += count($parts) - 1;
+			$subject = implode($replacements[$key], $parts);
+		}
+	} else {
+		// Call mb_str_replace for each subject in array, recursively
+		foreach ($subject as $key => $value) {
+			$subject[$key] = mb_str_replace($search, $replace, $value, $count);
+		}
+	}
+	return $subject;
+}
+
+/******************************************************************************
+ * Copyright (c) 2010 Jevon Wright and others.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * or
+ *
+ * LGPL which is available at http://www.gnu.org/licenses/lgpl.html
+ *
+ *
+ * Contributors:
+ *    Jevon Wright - initial API and implementation
+ *    Denis Flaven - some fixes for properly handling UTF-8 characters
+ ****************************************************************************/
+
+class Html2Text {
+
+	/**
+	 * Tries to convert the given HTML into a plain text format - best suited for
+	 * e-mail display, etc.
+	 *
+	 * <p>In particular, it tries to maintain the following features:
+	 * <ul>
+	 *   <li>Links are maintained, with the 'href' copied over
+	 *   <li>Information in the &lt;head&gt; is lost
+	 * </ul>
+	 *
+	 * @param string html the input HTML
+	 * @return string the HTML converted, as best as possible, to text
+	 * @throws Html2TextException if the HTML could not be loaded as a {@link DOMDocument}
+	 */
+	static function convert($html) {
+		// replace &nbsp; with spaces
+
+		$html = str_replace("&nbsp;", " ", $html);
+		$html = mb_str_replace("\xa0", " ", $html); // DO NOT USE str_replace since it breaks the "à" character which is \xc3 \xa0 in UTF-8
+
+		$html = static::fixNewlines($html);
+
+		$doc = new \DOMDocument();
+		if (!@$doc->loadHTML('<?xml encoding="UTF-8">'.$html)) // Forces the UTF-8 character set for HTML fragments
+		{
+			throw new Html2TextException("Could not load HTML - badly formed?", $html);
+		}
+
+		$output = static::iterateOverNode($doc);
+
+		// remove leading and trailing spaces on each line
+		$output = preg_replace("/[ \t]*\n[ \t]*/im", "\n", $output);
+		$output = preg_replace("/ *\t */im", "\t", $output);
+
+		// remove unnecessary empty lines
+		$output = preg_replace("/\n\n\n*/im", "\n\n", $output);
+
+		// remove leading and trailing whitespace
+		$output = trim($output);
+
+		return $output;
+	}
+
+	/**
+	 * Unify newlines; in particular, \r\n becomes \n, and
+	 * then \r becomes \n. This means that all newlines (Unix, Windows, Mac)
+	 * all become \ns.
+	 *
+	 * @param string text text with any number of \r, \r\n and \n combinations
+	 * @return string the fixed text
+	 */
+	static function fixNewlines($text) {
+		// replace \r\n to \n
+		$text = str_replace("\r\n", "\n", $text);
+		// remove \rs
+		$text = str_replace("\r", "\n", $text);
+
+		return $text;
+	}
+
+	static function nextChildName($node) {
+		// get the next child
+		$nextNode = $node->nextSibling;
+		while ($nextNode != null) {
+			if ($nextNode instanceof \DOMElement) {
+				break;
+			}
+			$nextNode = $nextNode->nextSibling;
+		}
+		$nextName = null;
+		if ($nextNode instanceof \DOMElement && $nextNode != null) {
+			$nextName = strtolower($nextNode->nodeName);
+		}
+
+		return $nextName;
+	}
+
+	static function prevChildName($node) {
+		// get the previous child
+		$nextNode = $node->previousSibling;
+		while ($nextNode != null) {
+			if ($nextNode instanceof \DOMElement) {
+				break;
+			}
+			$nextNode = $nextNode->previousSibling;
+		}
+		$nextName = null;
+		if ($nextNode instanceof \DOMElement && $nextNode != null) {
+			$nextName = strtolower($nextNode->nodeName);
+		}
+
+		return $nextName;
+	}
+
+	static function iterateOverNode($node) {
+		if ($node instanceof \DOMText) {
+		  // Replace whitespace characters with a space (equivilant to \s)
+			return preg_replace("/[\\t\\n\\f\\r ]+/im", " ", $node->wholeText);
+		}
+		if ($node instanceof \DOMDocumentType) {
+			// ignore
+			return "";
+		}
+
+		$nextName = static::nextChildName($node);
+		$prevName = static::prevChildName($node);
+
+		$name = strtolower($node->nodeName);
+
+		// start whitespace
+		switch ($name) {
+			case "hr":
+				return "---------------------------------------------------------------\n";
+
+			case "style":
+			case "head":
+			case "title":
+			case "meta":
+			case "script":
+				// ignore these tags
+				return "";
+
+			case "h1":
+			case "h2":
+			case "h3":
+			case "h4":
+			case "h5":
+			case "h6":
+			case "ol":
+			case "ul":
+				// add two newlines, second line is added below
+				$output = "\n";
+				break;
+
+			case "td":
+			case "th":
+				// add tab char to separate table fields
+			   $output = "\t";
+			   break;
+
+			case "tr":
+			case "p":
+			case "div":
+				// add one line
+				$output = "\n";
+				break;
+
+			case "li":
+				$output = "- ";
+				break;
+
+			default:
+				// print out contents of unknown tags
+				$output = "";
+				break;
+		}
+
+		// debug
+		//$output .= "[$name,$nextName]";
+
+		if (isset($node->childNodes)) {
+			for ($i = 0; $i < $node->childNodes->length; $i++) {
+				$n = $node->childNodes->item($i);
+
+				$text = static::iterateOverNode($n);
+
+				$output .= $text;
+			}
+		}
+
+		// end whitespace
+		switch ($name) {
+			case "h1":
+			case "h2":
+			case "h3":
+			case "h4":
+			case "h5":
+			case "h6":
+				$output .= "\n";
+				break;
+
+			case "p":
+			case "br":
+				// add one line
+				if ($nextName != "div")
+					$output .= "\n";
+				break;
+
+			case "div":
+				// add one line only if the next child isn't a div
+				if ($nextName != "div" && $nextName != null)
+					$output .= "\n";
+				break;
+
+			case "a":
+				// links are returned in [text](link) format
+				$href = $node->getAttribute("href");
+
+				$output = trim($output);
+
+				// remove double [[ ]] s from linking images
+				if (substr($output, 0, 1) == "[" && substr($output, -1) == "]") {
+					$output = substr($output, 1, strlen($output) - 2);
+
+					// for linking images, the title of the <a> overrides the title of the <img>
+					if ($node->getAttribute("title")) {
+						$output = $node->getAttribute("title");
+					}
+				}
+
+				// if there is no link text, but a title attr
+				if (!$output && $node->getAttribute("title")) {
+					$output = $node->getAttribute("title");
+				}
+
+				if ($href == null) {
+					// it doesn't link anywhere
+					if ($node->getAttribute("name") != null) {
+						$output = "[$output]";
+					}
+				} else {
+					if ($href == $output || $href == "mailto:$output" || $href == "http://$output" || $href == "https://$output") {
+						// link to the same address: just use link
+						$output;
+					} else {
+						// replace it
+						if ($output) {
+							$output = "[$output]($href)";
+						} else {
+							// empty string
+							$output = $href;
+						}
+					}
+				}
+
+				// does the next node require additional whitespace?
+				switch ($nextName) {
+					case "h1": case "h2": case "h3": case "h4": case "h5": case "h6":
+						$output .= "\n";
+						break;
+				}
+				break;
+
+			case "img":
+				if ($node->getAttribute("title")) {
+					$output = "[" . $node->getAttribute("title") . "]";
+				} elseif ($node->getAttribute("alt")) {
+					$output = "[" . $node->getAttribute("alt") . "]";
+				} else {
+					$output = "";
+				}
+				break;
+
+			case "li":
+				$output .= "\n";
+				break;
+
+			default:
+				// do nothing
+		}
+
+		return $output;
+	}
+
+}

+ 28 - 0
application/Html2TextException.php

@@ -0,0 +1,28 @@
+<?php
+
+/******************************************************************************
+ * Copyright (c) 2010 Jevon Wright and others.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * or
+ *
+ * LGPL which is available at http://www.gnu.org/licenses/lgpl.html
+ *
+ *
+ * Contributors:
+ *    Jevon Wright - initial API and implementation
+ ****************************************************************************/
+
+namespace Html2Text;
+
+class Html2TextException extends \Exception {
+	var $more_info;
+
+	public function __construct($message = "", $more_info = "") {
+		parent::__construct($message);
+		$this->more_info = $more_info;
+	}
+}

+ 16 - 2
application/cmdbabstract.class.inc.php

@@ -1825,7 +1825,7 @@ EOF
 					$sPreviousLog = is_object($value) ? $value->GetAsHTML($oPage, true /* bEditMode */, array('AttributeText', 'RenderWikiHtml')) : '';
 					$iEntriesCount = is_object($value) ? count($value->GetIndex()) : 0;
 					$sHidden = "<input type=\"hidden\" id=\"{$iId}_count\" value=\"$iEntriesCount\"/>"; // To know how many entries the case log already contains
-					$sHTMLValue = "<div class=\"caselog\" $sStyle><table style=\"width:100%;\"><tr><td>$sHeader<textarea style=\"border:0;width:100%\" title=\"$sHelpText\" name=\"attr_{$sFieldPrefix}{$sAttCode}{$sNameSuffix}\" rows=\"8\" cols=\"40\" id=\"$iId\">".htmlentities($sEditValue, ENT_QUOTES, 'UTF-8')."</textarea>$sPreviousLog</td><td>{$sValidationField}</td></tr></table>$sHidden</div>";
+					$sHTMLValue = "<div class=\"caselog\" $sStyle><table style=\"width:100%;\"><tr><td>$sHeader<textarea class=\"htmlEditor\" style=\"border:0;width:100%\" title=\"$sHelpText\" name=\"attr_{$sFieldPrefix}{$sAttCode}{$sNameSuffix}\" rows=\"8\" cols=\"40\" id=\"$iId\">".htmlentities($sEditValue, ENT_QUOTES, 'UTF-8')."</textarea>$sPreviousLog</td><td>{$sValidationField}</td></tr></table>$sHidden</div>";
 					$oPage->add_ready_script("$('#$iId').bind('keyup change validate', function(evt, sFormId) { return ValidateCaseLogField('$iId', $bMandatory, sFormId) } );"); // Custom validation function
 				break;
 
@@ -3364,9 +3364,23 @@ EOF
 				{
 					$sHTMLValue = '<span>'.$sComment.'</span><br/>';
 				}
-				$sHTMLValue .= "<span id=\"field_{$sInputId}\">".self::GetFormElementForField($oPage, $sClass, $sAttCode, $oAttDef, $sValue, $sDisplayValue, $sInputId, '', $iFlags, $aArgs).'</span>';
+				$sHTMLValue .= "<span style=\"font-family:Tahoma,Verdana,Arial,Helvetica;font-size:12px;\" id=\"field_{$sInputId}\">".self::GetFormElementForField($oPage, $sClass, $sAttCode, $oAttDef, $sValue, $sDisplayValue, $sInputId, '', $iFlags, $aArgs).'</span>';
 				$aFieldsMap[$sAttCode] = $sInputId;
+
+				// Replace the text area with CKEditor
+				// To change the default settings of the editor,
+				// a) edit the file /js/ckeditor/config.js
+				// b) or override some of the configuration settings, using the second parameter of ckeditor()
+				$aConfig = array();
+				$sLanguage = strtolower(trim(UserRights::GetUserLanguage()));
+				$aConfig['font_style'] = $sLanguage;
+				$aConfig['language'] = $sLanguage;
+				$aConfig['contentsLanguage'] = $sLanguage;
+				$aConfig['extraPlugins'] = 'disabler';
+				$sConfigJS = json_encode($aConfig);
 				
+				$oPage->add_ready_script("$('#$sInputId').ckeditor(function() { /* callback code */ }, $sConfigJS);"); // Transform $iId into a CKEdit
+								
 			}
 			//$aVal = array('label' => '<span title="'.$oAttDef->GetDescription().'">'.$oAttDef->GetLabel().'</span>', 'value' => $sHTMLValue, 'comments' => $sComments, 'infos' => $sInfos);
 			$oPage->add('<fieldset><legend>'.$oAttDef->GetLabel().'</legend>');

+ 30 - 4
application/displayblock.class.inc.php

@@ -1,5 +1,5 @@
 <?php
-// Copyright (C) 2010-2015 Combodo SARL
+// Copyright (C) 2010-2016 Combodo SARL
 //
 //   This file is part of iTop.
 //
@@ -19,7 +19,7 @@
 /**
  * DisplayBlock and derived class
  *
- * @copyright   Copyright (C) 2010-2015 Combodo SARL
+ * @copyright   Copyright (C) 2010-2016 Combodo SARL
  * @license     http://opensource.org/licenses/AGPL-3.0
  */
 
@@ -1289,8 +1289,34 @@ class HistoryBlock extends DisplayBlock
 			else
 			{
 				$sHtml .= $this->GetHistoryTable($oPage, $oSet);
-			}	
+			}
+			$sMaxWidth = MetaModel::GetModuleSetting('itop-attachment', 'inline_image_max_width', '450px');
+		
 			$oPage->add_ready_script("$('.case-log-history-entry-toggle').on('click', function () { $(this).closest('.case-log-history-entry').toggleClass('expanded');});");
+			$oPage->add_ready_script(
+<<<EOF
+$('.history_entry').each(function() {
+	var jMe = $(this)
+	jMe.find('img[data-att-id]').each(function() {
+		if ('$sMaxWidth' != '')
+		{
+			$(this).css({'max-width': '$sMaxWidth', width: '', height: '', 'max-height': ''});
+		}
+		$(this).addClass('inline-image');
+		$(this).attr('href', $(this).attr('src'));
+	}).magnificPopup({type: 'image', closeOnContentClick: true });
+				
+	var oContent = $(this).find('.history_html_content');
+	if (jMe.height() < oContent.height())
+	{
+			jMe.prepend('<span class="history_truncated_toggler"></span>');
+			jMe.find('.history_truncated_toggler').on('click', function() {
+				jMe.toggleClass('history_entry_truncated');
+			});
+	}
+});
+EOF
+			);
 		}
 		return $sHtml;
 	}
@@ -1319,7 +1345,7 @@ class HistoryBlock extends DisplayBlock
 		}
 		$aAttribs = array('date' => array('label' => Dict::S('UI:History:Date'), 'description' => Dict::S('UI:History:Date+')),
 						  'userinfo' => array('label' => Dict::S('UI:History:User'), 'description' => Dict::S('UI:History:User+')),
-						  'log' => array('label' => Dict::S('UI:History:Changes'), 'description' => Dict::S('UI:History:Changes+')),
+						  'log' => array('label' => Dict::S('UI:History:Changes').'<span style="display:block;float:right">Expand All / Collapse All</span>', 'description' => Dict::S('UI:History:Changes+')),
 						 );
 		$aValues = array();
 		foreach($aChanges as $aChange)

+ 3 - 3
application/itopwebpage.class.inc.php

@@ -1,5 +1,5 @@
 <?php
-// Copyright (C) 2010-2015 Combodo SARL
+// Copyright (C) 2010-2016 Combodo SARL
 //
 //   This file is part of iTop.
 //
@@ -20,7 +20,7 @@
 /**
  * Class iTopWebPage
  *
- * @copyright   Copyright (C) 2010-2015 Combodo SARL
+ * @copyright   Copyright (C) 2010-2016 Combodo SARL
  * @license     http://opensource.org/licenses/AGPL-3.0
  */
 
@@ -416,7 +416,7 @@ EOF
 	ShowDebug();
 	$('#logOffBtn>ul').popupmenu();
 	
-	$('.caselog_header').click( function () { $(this).toggleClass('open').next('.caselog_entry').toggle(); });
+	$('.caselog_header').click( function () { $(this).toggleClass('open').next('.caselog_entry,.caselog_entry_html').toggle(); });
 	
 	$(document).ajaxSend(function(event, jqxhr, options) {
 		jqxhr.setRequestHeader('X-Combodo-Ajax', 'true');

+ 65 - 0
application/utils.inc.php

@@ -1,4 +1,5 @@
 <?php
+use Html2Text\Html2Text;
 // Copyright (C) 2010-2016 Combodo SARL
 //
 //   This file is part of iTop.
@@ -26,11 +27,14 @@
 
 require_once(APPROOT.'/core/config.class.inc.php');
 require_once(APPROOT.'/application/transaction.class.inc.php');
+require_once(APPROOT.'application/Html2Text.php');
+require_once(APPROOT.'application/Html2TextException.php');
 
 define('ITOP_CONFIG_FILE', 'config-itop.php');
 define('ITOP_DEFAULT_CONFIG_FILE', APPCONF.ITOP_DEFAULT_ENV.'/'.ITOP_CONFIG_FILE);
 
 define('SERVER_NAME_PLACEHOLDER', '$SERVER_NAME$');
+define('ATTACHMENT_DOWNLOAD_URL', 'pages/ajax.render.php?operation=download_document&class=Attachment&field=contents&id=');
 
 class FileUploadException extends Exception
 {
@@ -1134,4 +1138,65 @@ class utils
 		asort($aPossibleEncodings);
 		return $aPossibleEncodings;
 	}
+	
+	/**
+	 * Convert a string containing some (valid) HTML markup to plain text
+	 * @param string $sHtml
+	 * @return string
+	 */
+	public static function HtmlToText($sHtml)
+	{
+		try
+		{
+			//return '<?xml encoding="UTF-8">'.$sHtml;
+			return \Html2Text\Html2Text::convert('<?xml encoding="UTF-8">'.$sHtml);
+		}
+		catch(Exception $e)
+		{
+			return $e->getMessage();
+		}
+	}
+	
+	/**
+	 * Convert (?) plain text to some HTML markup by replacing newlines by </br> tags
+	 * and escaping HTML entities
+	 * @param string $sText
+	 * @return string
+	 */
+	public static function TextToHtml($sText)
+	{
+		$sText = str_replace("\r\n", "\n", $sText);
+		$sText = str_replace("\r", "\n", $sText);
+		return str_replace("\n", '</br>', htmlentities($sText, ENT_QUOTES, 'UTF-8'));
+	}
+	
+	/**
+	 * Parses the supplied HTML fragment to rebuild the attribute src="" for images
+	 * that refer to an attachment (detected via the attribute data-att-id="") so that
+	 * the URL is consistent with the current URL of the application.
+	 * @param string $sHtml The HTML fragment to process
+	 * @return string The modified HTML
+	 */
+	public static function FixInlineAttachments($sHtml)
+	{
+		$aNeedles = array();
+		$aReplacements = array();
+		// Find img tags with an attribute data-att-id
+		if (preg_match_all('/<img ([^>]*)data-att-id="([0-9]+)"([^>]*)>/i', $sHtml, $aMatches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE))
+		{
+			$sUrl = utils::GetAbsoluteUrlAppRoot().ATTACHMENT_DOWNLOAD_URL;
+			foreach($aMatches as $aImgInfo)
+			{
+				$sImgTag = $aImgInfo[0][0];
+				$sAttId = $aImgInfo[2][0];
+				
+				$sNewImgTag = preg_replace('/src="[^"]+"/', 'src="'.$sUrl.$sAttId.'"', $sImgTag); // preserve other attributes
+				$aNeedles[] = $sImgTag;
+				$aReplacements[] = $sNewImgTag;
+			}
+			$sHtml = str_replace($aNeedles, $aReplacements, $sHtml);
+		}
+		return $sHtml;
+	}
+	
 }

+ 3 - 3
core/action.class.inc.php

@@ -1,5 +1,5 @@
 <?php
-// Copyright (C) 2010-2012 Combodo SARL
+// Copyright (C) 2010-2016 Combodo SARL
 //
 //   This file is part of iTop.
 //
@@ -20,7 +20,7 @@
 /**
  * Persistent classes (internal): user defined actions
  *
- * @copyright   Copyright (C) 2010-2012 Combodo SARL
+ * @copyright   Copyright (C) 2010-2016 Combodo SARL
  * @license     http://opensource.org/licenses/AGPL-3.0
  */
 
@@ -157,7 +157,7 @@ class ActionEmail extends ActionNotification
 		MetaModel::Init_AddAttribute(new AttributeOQL("cc", array("allowed_values"=>null, "sql"=>"cc", "default_value"=>null, "is_null_allowed"=>true, "depends_on"=>array())));
 		MetaModel::Init_AddAttribute(new AttributeOQL("bcc", array("allowed_values"=>null, "sql"=>"bcc", "default_value"=>null, "is_null_allowed"=>true, "depends_on"=>array())));
 		MetaModel::Init_AddAttribute(new AttributeTemplateString("subject", array("allowed_values"=>null, "sql"=>"subject", "default_value"=>null, "is_null_allowed"=>false, "depends_on"=>array())));
-		MetaModel::Init_AddAttribute(new AttributeTemplateText("body", array("allowed_values"=>null, "sql"=>"body", "default_value"=>null, "is_null_allowed"=>false, "depends_on"=>array())));
+		MetaModel::Init_AddAttribute(new AttributeTemplateHTML("body", array("allowed_values"=>null, "sql"=>"body", "default_value"=>null, "is_null_allowed"=>false, "depends_on"=>array())));
 		MetaModel::Init_AddAttribute(new AttributeEnum("importance", array("allowed_values"=>new ValueSetEnum('low,normal,high'), "sql"=>"importance", "default_value"=>'normal', "is_null_allowed"=>false, "depends_on"=>array())));
 
 		// Display lists

+ 291 - 56
core/attributedef.class.inc.php

@@ -30,6 +30,7 @@ require_once('ormdocument.class.inc.php');
 require_once('ormstopwatch.class.inc.php');
 require_once('ormpassword.class.inc.php');
 require_once('ormcaselog.class.inc.php');
+require_once('htmlsanitizer.class.inc.php');
 
 /**
  * MissingColumnException - sent if an attribute is being created but the column is missing in the row 
@@ -436,6 +437,15 @@ abstract class AttributeDefinition
 	{
 		return (string)$sValue;
 	}
+	
+	/**
+	 * For fields containing a potential markup, return the value without this markup
+	 * @return string
+	 */
+	public function GetAsPlainText($sValue, $oHostObj = null)
+	{
+		return (string) $this->GetEditValue($sValue, $oHostObj);
+	}
 
 	/**
 	 * Helper to get a value that will be JSON encoded
@@ -476,7 +486,7 @@ abstract class AttributeDefinition
 	/**
 	 * Override to escape the value when read by DBObject::GetAsCSV()	
 	 */	
-	public function GetAsCSV($sValue, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true)
+	public function GetAsCSV($sValue, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true, $bConvertToPlainText = false)
 	{
 		return (string)$sValue;
 	}
@@ -499,6 +509,7 @@ abstract class AttributeDefinition
 			'' => 'Plain text (unlocalized) representation',
 			'html' => 'HTML representation',
 			'label' => 'Localized representation',
+			'text' => 'Plain text representation (without any markup)',
 		);
 	}
 
@@ -524,6 +535,10 @@ abstract class AttributeDefinition
 				case 'label':
 				return $this->GetEditValue($value);
 				
+				case 'text':
+				return $this->GetAsPlainText($value);
+				break;
+				
 				default:
 				throw new Exception("Unknown verb '$sVerb' for attribute ".$this->GetCode().' in class '.get_class($oHostObj));
 			}
@@ -780,7 +795,7 @@ class AttributeLinkedSet extends AttributeDefinition
 		return $sRes;
 	}
 
-	public function GetAsCSV($sValue, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true)
+	public function GetAsCSV($sValue, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true, $bConvertToPlainText = false)
 	{
 		$sSepItem = MetaModel::GetConfig()->Get('link_set_item_separator');
 		$sSepAttribute = MetaModel::GetConfig()->Get('link_set_attribute_separator');
@@ -1676,7 +1691,7 @@ class AttributeBoolean extends AttributeInteger
 	{
 		return $sValue ? '1' : '0';
 	}
-	public function GetAsCSV($sValue, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true)
+	public function GetAsCSV($sValue, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true, $bConvertToPlainText = false)
 	{
 		return $sValue ? '1' : '0';
 	}
@@ -1806,7 +1821,7 @@ class AttributeString extends AttributeDBField
 		return $value;
 	}
 
-	public function GetAsCSV($sValue, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true)
+	public function GetAsCSV($sValue, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true, $bConvertToPlainText = false)
 	{
 		$sFrom = array("\r\n", $sTextQualifier);
 		$sTo = array("\n", $sTextQualifier.$sTextQualifier);
@@ -1972,7 +1987,7 @@ class AttributeFinalClass extends AttributeString
 		return $value;
 	}
 	
- 	public function GetAsCSV($value, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true)
+ 	public function GetAsCSV($value, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true, $bConvertToPlainText = false)
 	{
 		if ($bLocalize && $value != '')
 		{
@@ -1982,7 +1997,7 @@ class AttributeFinalClass extends AttributeString
 		{
 			$sRawValue = $value;
 		}
-		return parent::GetAsCSV($sRawValue, $sSeparator, $sTextQualifier, null, false);
+		return parent::GetAsCSV($sRawValue, $sSeparator, $sTextQualifier, null, false, $bConvertToPlainText);
 	}
 
 	public function GetAsXML($value, $oHostObject = null, $bLocalize = true)
@@ -2162,9 +2177,43 @@ define('WIKI_OBJECT_REGEXP', '/\[\[(.+):(.+)\]\]/U');
  */
 class AttributeText extends AttributeString
 {
-	public function GetEditClass() {return "Text";}
+	public function GetEditClass() {return ($this->GetFormat() == 'text') ? 'Text' : "HTML";}
+	
 	protected function GetSQLCol($bFullSpec = false) {return "TEXT";}
 
+	public function GetSQLColumns($bFullSpec = false)
+	{
+		$aColumns = array();
+		$aColumns[$this->Get('sql')] = $this->GetSQLCol($bFullSpec);
+		if ($this->GetOptional('format', null) != null )
+		{
+			// Add the extra column only if the property 'format' is specified for the attribute
+			$aColumns[$this->Get('sql').'_format'] = "ENUM('text','html')";
+			if ($bFullSpec)
+			{
+				$aColumns[$this->Get('sql').'_format'].= " DEFAULT 'text'"; // default 'text' is for migrating old records
+			}
+		}
+		return $aColumns;
+	}
+
+	public function GetSQLExpressions($sPrefix = '')
+	{
+		if ($sPrefix == '')
+		{
+			$sPrefix = $this->Get('sql');
+		}
+		$aColumns = array();
+		// Note: to optimize things, the existence of the attribute is determined by the existence of one column with an empty suffix
+		$aColumns[''] = $sPrefix;
+		if ($this->GetOptional('format', null) != null )
+		{
+			// Add the extra column only if the property 'format' is specified for the attribute
+			$aColumns['_format'] = $sPrefix.'_format';
+		}
+		return $aColumns;
+	}
+	
 	public function GetMaxSize()
 	{
 		// Is there a way to know the current limitation for mysql?
@@ -2224,8 +2273,6 @@ class AttributeText extends AttributeString
 
 	public function GetAsHTML($sValue, $oHostObject = null, $bLocalize = true)
 	{
-		$sValue = parent::GetAsHTML($sValue, $oHostObject, $bLocalize);
-		$sValue = self::RenderWikiHtml($sValue);
 		$aStyles = array();
 		if ($this->GetWidth() != '')
 		{
@@ -2241,44 +2288,83 @@ class AttributeText extends AttributeString
 			$aStyles[] = 'overflow:auto';
 			$sStyle = 'style="'.implode(';', $aStyles).'"';
 		}
-		return "<div $sStyle>".str_replace("\n", "<br>\n", $sValue).'</div>';
+		
+		if ($this->GetFormat() == 'text')
+		{
+			$sValue = parent::GetAsHTML($sValue, $oHostObject, $bLocalize);
+			$sValue = self::RenderWikiHtml($sValue);
+			return "<div $sStyle>".str_replace("\n", "<br>\n", $sValue).'</div>';			
+		}
+		else
+		{
+			return "<div class=\"HTML\" $sStyle>".utils::FixInlineAttachments($sValue).'</div>';
+		}
+		
 	}
 
 	public function GetEditValue($sValue, $oHostObj = null)
 	{
-		if (preg_match_all(WIKI_OBJECT_REGEXP, $sValue, $aAllMatches, PREG_SET_ORDER))
+		if ($this->GetFormat() == 'text')
 		{
-			foreach($aAllMatches as $iPos => $aMatches)
+			if (preg_match_all(WIKI_OBJECT_REGEXP, $sValue, $aAllMatches, PREG_SET_ORDER))
 			{
-				$sClass = $aMatches[1];
-				$sName = $aMatches[2];
-				
-				if (MetaModel::IsValidClass($sClass))
+				foreach($aAllMatches as $iPos => $aMatches)
 				{
-					$sClassLabel = MetaModel::GetName($sClass);
-					$sValue = str_replace($aMatches[0], "[[$sClassLabel:$sName]]", $sValue);
+					$sClass = $aMatches[1];
+					$sName = $aMatches[2];
+					
+					if (MetaModel::IsValidClass($sClass))
+					{
+						$sClassLabel = MetaModel::GetName($sClass);
+						$sValue = str_replace($aMatches[0], "[[$sClassLabel:$sName]]", $sValue);
+					}
 				}
 			}
 		}
 		return $sValue;
 	}
 
+	/**
+	 * For fields containing a potential markup, return the value without this markup
+	 * @return string
+	 */
+	public function GetAsPlainText($sValue, $oHostObj = null)
+	{
+		if ($this->GetFormat() == 'html')
+		{
+			return (string) utils::HtmlToText($this->GetEditValue($sValue, $oHostObj));
+		}
+		else
+		{
+			return parent::GetAsPlainText($sValue, $oHostObj);
+		}
+	}
+	
 	public function MakeRealValue($proposedValue, $oHostObj)
 	{
 		$sValue = $proposedValue;
-		if (preg_match_all(WIKI_OBJECT_REGEXP, $sValue, $aAllMatches, PREG_SET_ORDER))
+		switch ($this->GetFormat())
 		{
-			foreach($aAllMatches as $iPos => $aMatches)
+			case 'html':
+			$sValue = HTMLSanitizer::Sanitize($sValue);
+			break;
+			
+			case 'text':
+			default:
+			if (preg_match_all(WIKI_OBJECT_REGEXP, $sValue, $aAllMatches, PREG_SET_ORDER))
 			{
-				$sClassLabel = $aMatches[1];
-				$sName = $aMatches[2];
-				
-				if (!MetaModel::IsValidClass($sClassLabel))
+				foreach($aAllMatches as $iPos => $aMatches)
 				{
-					$sClass = MetaModel::GetClassFromLabel($sClassLabel);
-					if ($sClass)
+					$sClassLabel = $aMatches[1];
+					$sName = $aMatches[2];
+					
+					if (!MetaModel::IsValidClass($sClassLabel))
 					{
-						$sValue = str_replace($aMatches[0], "[[$sClass:$sName]]", $sValue);
+						$sClass = MetaModel::GetClassFromLabel($sClassLabel);
+						if ($sClass)
+						{
+							$sValue = str_replace($aMatches[0], "[[$sClass:$sName]]", $sValue);
+						}
 					}
 				}
 			}
@@ -2300,6 +2386,92 @@ class AttributeText extends AttributeString
 	{
 		return $this->GetOptional('height', '');		
 	}
+	
+	/**
+	 * The actual formatting of the field: either text (=plain text) or html (= text with HTML markup)
+	 * @return string
+	 */
+	public function GetFormat()
+	{
+		return $this->GetOptional('format', 'text');
+	}
+	
+	/**
+	 * Read the value from the row returned by the SQL query and transorms it to the appropriate
+	 * internal format (either text or html)
+	 * @see AttributeDBFieldVoid::FromSQLToValue()
+	 */
+	public function FromSQLToValue($aCols, $sPrefix = '')
+	{
+		$value = $aCols[$sPrefix.''];
+		if ($this->GetOptional('format', null) != null )
+		{
+			// Read from the extra column only if the property 'format' is specified for the attribute
+			$sFormat = $aCols[$sPrefix.'_format'];
+		}
+		else
+		{
+			$sFormat = $this->GetFormat();
+		}
+		
+		switch($sFormat)
+		{
+			case 'text':
+			if ($this->GetFormat() == 'html')
+			{
+				$value = utils::TextToHtml($value);
+			}
+			break;
+			
+			case 'html':
+			if ($this->GetFormat() == 'text')
+			{
+				$value = utils::HtmlToText($value);
+			}
+			else
+			{
+				$value = utils::FixInlineAttachments((string)$value);
+			}
+			break;
+			
+			default:
+			// unknown format ??
+		}
+		return $value;
+	}
+	
+	public function GetSQLValues($value)
+	{
+		$aValues = array();
+		$aValues[$this->Get("sql")] = $this->ScalarToSQL($value);
+		if ($this->GetOptional('format', null) != null )
+		{
+			// Add the extra column only if the property 'format' is specified for the attribute
+			$aValues[$this->Get("sql").'_format'] = $this->GetFormat();
+		}
+		return $aValues;
+	}	
+	
+	public function GetAsCSV($sValue, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true, $bConvertToPlainText = false)
+	{
+		switch($this->GetFormat())
+		{
+			case 'html':
+			if ($bConvertToPlainText)
+			{
+				$sValue = utils::HtmlToText((string)$sValue);
+			}
+			$sFrom = array("\r\n", $sTextQualifier);
+			$sTo = array("\n", $sTextQualifier.$sTextQualifier);
+			$sEscaped = str_replace($sFrom, $sTo, (string)$sValue);
+			return $sTextQualifier.$sEscaped.$sTextQualifier;
+			break;
+			
+			case 'text':
+			default:
+			return parent::GetAsCSV($sValue, $sSeparator, $sTextQualifier, $oHostObject, $bLocalize, $bConvertToPlainText);
+		}
+	}
 }
 
 /**
@@ -2358,7 +2530,25 @@ class AttributeCaseLog extends AttributeLongText
 		}
 		return $sValue->GetModifiedEntry();
 	}
-	
+
+	/**
+	 * For fields containing a potential markup, return the value without this markup
+	 * @return string
+	 */
+	public function GetAsPlainText($value, $oHostObj = null)
+	{
+		$value = $oObj->Get($sAttCode);
+		if ($value instanceOf ormCaseLog)
+		{
+
+			return $value->GetAsPlainText();
+		}
+		else
+		{
+			return (string) $value;
+		}
+	}
+		
 	public function GetDefaultValue() {return new ormCaseLog();}
 	public function Equals($val1, $val2) {return ($val1->GetText() == $val2->GetText());}
 	
@@ -2402,7 +2592,7 @@ class AttributeCaseLog extends AttributeLongText
 			{
 				if (strlen($proposedValue) > 0)
 				{
-					$oCaseLog->AddLogEntry(parent::MakeRealValue($proposedValue, $oHostObj));
+					$oCaseLog->AddLogEntry($proposedValue);
 				}
 			}
 			$ret = $oCaseLog;
@@ -2414,7 +2604,7 @@ class AttributeCaseLog extends AttributeLongText
 	{
 		if ($sPrefix == '')
 		{
-			$sPrefix = $this->GetCode();
+			$sPrefix = $this->Get('sql');
 		}
 		$aColumns = array();
 		// Note: to optimize things, the existence of the attribute is determined by the existence of one column with an empty suffix
@@ -2502,11 +2692,11 @@ class AttributeCaseLog extends AttributeLongText
 		return "<div class=\"caselog\" $sStyle>".$sContent.'</div>';	}
 
 
-	public function GetAsCSV($value, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true)
+	public function GetAsCSV($value, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true, $bConvertToPlainText = false)
 	{
 		if ($value instanceOf ormCaseLog)
 		{
-			return parent::GetAsCSV($value->GetText(), $sSeparator, $sTextQualifier, $oHostObject, $bLocalize);
+			return parent::GetAsCSV($value->GetText($bConvertToPlainText), $sSeparator, $sTextQualifier, $oHostObject, $bLocalize, $bConvertToPlainText);
 		}
 		else
 		{
@@ -2615,6 +2805,15 @@ class AttributeCaseLog extends AttributeLongText
 		}
 		return $sFingerprint;
 	}
+	
+	/**
+	 * The actual formatting of the text: either text (=plain text) or html (= text with HTML markup)
+	 * @return string
+	 */
+	public function GetFormat()
+	{
+		return $this->GetOptional('format', 'html'); // default format for case logs is now HTML
+	}
 }
 
 /**
@@ -2624,11 +2823,29 @@ class AttributeCaseLog extends AttributeLongText
  */
 class AttributeHTML extends AttributeLongText
 {
-	public function GetEditClass() {return "HTML";}
+	public function GetSQLColumns($bFullSpec = false)
+	{
+		$aColumns = array();
+		$aColumns[$this->GetCode()] = $this->GetSQLCol();
+		if ($this->GetOptional('format', null) != null )
+		{
+			// Add the extra column only if the property 'format' is specified for the attribute
+			$aColumns[$this->Get('sql').'_format'] = "ENUM('text','html')";
+			if ($bFullSpec)
+			{
+				$aColumns[$this->Get('sql').'_format'].= " DEFAULT 'html'"; // default 'html' is for migrating old records
+			}
+		}
+		return $aColumns;
+	}
 
-	public function GetAsHTML($sValue, $oHostObject = null, $bLocalize = true)
+	/**
+	 * The actual formatting of the text: either text (=plain text) or html (= text with HTML markup)
+	 * @return string
+	 */
+	public function GetFormat()
 	{
-		return $sValue;
+		return $this->GetOptional('format', 'html'); // Defaults to HTML
 	}
 }
 
@@ -2706,11 +2923,29 @@ class AttributeTemplateText extends AttributeText
  */
 class AttributeTemplateHTML extends AttributeText
 {
-	public function GetEditClass() {return "HTML";}
+	public function GetSQLColumns($bFullSpec = false)
+	{
+		$aColumns = array();
+		$aColumns[$this->GetCode()] = $this->GetSQLCol();
+		if ($this->GetOptional('format', null) != null )
+		{
+			// Add the extra column only if the property 'format' is specified for the attribute
+			$aColumns[$this->Get('sql').'_format'] = "ENUM('text','html')";
+			if ($bFullSpec)
+			{
+				$aColumns[$this->Get('sql').'_format'].= " DEFAULT 'html'"; // default 'html' is for migrating old records
+			}
+		}
+		return $aColumns;
+	}
 
-	public function GetAsHTML($sValue, $oHostObject = null, $bLocalize = true)
+	/**
+	 * The actual formatting of the text: either text (=plain text) or html (= text with HTML markup)
+	 * @return string
+	 */
+	public function GetFormat()
 	{
-		return $sValue;
+		return $this->GetOptional('format', 'html'); // Defaults to HTML
 	}
 }
 
@@ -2885,7 +3120,7 @@ class AttributeEnum extends AttributeString
 		return $sRes;
 	}
 
-	public function GetAsCSV($sValue, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true)
+	public function GetAsCSV($sValue, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true, $bConvertToPlainText = false)
 	{
 		if (is_null($sValue))
 		{
@@ -3257,7 +3492,7 @@ class AttributeDateTime extends AttributeDBField
 		return Str::pure2xml($value);
 	}
 
-	public function GetAsCSV($sValue, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true)
+	public function GetAsCSV($sValue, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true, $bConvertToPlainText = false)
 	{
 		$sFrom = array("\r\n", $sTextQualifier);
 		$sTo = array("\n", $sTextQualifier.$sTextQualifier);
@@ -3818,7 +4053,7 @@ class AttributeExternalField extends AttributeDefinition
 	{
 		if ($sPrefix == '')
 		{
-			return array('' => $this->GetCode());
+			return array('' => $this->GetCode()); // Warning: Use GetCode() since AttributeExternalField does not have any 'sql' property
 		}
 		else
 		{
@@ -4013,10 +4248,10 @@ class AttributeExternalField extends AttributeDefinition
 		$oExtAttDef = $this->GetExtAttDef();
 		return $oExtAttDef->GetAsXML($value, null, $bLocalize);
 	}
-	public function GetAsCSV($value, $sSeparator = ',', $sTestQualifier = '"', $oHostObject = null, $bLocalize = true)
+	public function GetAsCSV($value, $sSeparator = ',', $sTestQualifier = '"', $oHostObject = null, $bLocalize = true, $bConvertToPlainText = false)
 	{
 		$oExtAttDef = $this->GetExtAttDef();
-		return $oExtAttDef->GetAsCSV($value, $sSeparator, $sTestQualifier, null, $bLocalize);
+		return $oExtAttDef->GetAsCSV($value, $sSeparator, $sTestQualifier, null, $bLocalize, $bConvertToPlainText);
 	}
 	
 	public function IsPartOfFingerprint() { return false; }
@@ -4257,7 +4492,7 @@ class AttributeBlob extends AttributeDefinition
 		}
 	}
 
-	public function GetAsCSV($sValue, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true)
+	public function GetAsCSV($sValue, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true, $bConvertToPlainText = false)
 	{
 		return ''; // Not exportable in CSV !
 	}
@@ -4379,7 +4614,7 @@ class AttributeStopWatch extends AttributeDefinition
 	{
 		if ($sPrefix == '')
 		{
-			$sPrefix = $this->GetCode();
+			$sPrefix = $this->GetCode(); // Warning: a stopwatch does not have any 'sql' property, so its SQL column is equal to its attribute code !!
 		}
 		$aColumns = array();
 		// Note: to optimize things, the existence of the attribute is determined by the existence of one column with an empty suffix
@@ -4551,7 +4786,7 @@ class AttributeStopWatch extends AttributeDefinition
 		}
 	}
 
-	public function GetAsCSV($value, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true)
+	public function GetAsCSV($value, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true, $bConvertToPlainText = false)
 	{
 		return $value->GetTimeSpent();
 	}
@@ -4773,7 +5008,7 @@ class AttributeStopWatch extends AttributeDefinition
 		return $sHtml;
 	}
 
-	public function GetSubItemAsCSV($sItemCode, $value, $sSeparator = ',', $sTextQualifier = '"')
+	public function GetSubItemAsCSV($sItemCode, $value, $sSeparator = ',', $sTextQualifier = '"', $bConvertToPlainText = false)
 	{
 		$sFrom = array("\r\n", $sTextQualifier);
 		$sTo = array("\n", $sTextQualifier.$sTextQualifier);
@@ -5026,10 +5261,10 @@ class AttributeSubItem extends AttributeDefinition
 		return $res;
 	}
 
-	public function GetAsCSV($value, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true)
+	public function GetAsCSV($value, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true, $bConvertToPlainText = false)
 	{
 		$oParent = $this->GetTargetAttDef();
-		$res = $oParent->GetSubItemAsCSV($this->Get('item_code'), $value, $sSeparator, $sTextQualifier);
+		$res = $oParent->GetSubItemAsCSV($this->Get('item_code'), $value, $sSeparator, $sTextQualifier, $bConvertToPlainText);
 		return $res;
 	}
 	
@@ -5087,7 +5322,7 @@ class AttributeOneWayPassword extends AttributeDefinition
 	{
 		if ($sPrefix == '')
 		{
-			$sPrefix = $this->GetCode();
+			$sPrefix = $this->GetCode(); // Warning: AttributeOneWayPassword does not have any sql property so code = sql !
 		}
 		$aColumns = array();
 		// Note: to optimize things, the existence of the attribute is determined by the existence of one column with an empty suffix
@@ -5196,7 +5431,7 @@ class AttributeOneWayPassword extends AttributeDefinition
 		}
 	}
 
-	public function GetAsCSV($sValue, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true)
+	public function GetAsCSV($sValue, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true, $bConvertToPlainText = false)
 	{
 		return ''; // Not exportable in CSV
 	}
@@ -5300,7 +5535,7 @@ class AttributeTable extends AttributeDBField
 		return $sRes;
 	}
 
-	public function GetAsCSV($sValue, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true)
+	public function GetAsCSV($sValue, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true, $bConvertToPlainText = false)
 	{
 		// Not implemented
 		return '';
@@ -5372,7 +5607,7 @@ class AttributePropertySet extends AttributeTable
 		return $sRes;
 	}
 
-	public function GetAsCSV($value, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true)
+	public function GetAsCSV($value, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true, $bConvertToPlainText = false)
 	{
 		if (count($value) == 0)
 		{
@@ -5451,7 +5686,7 @@ class AttributeComputedFieldVoid extends AttributeDefinition
 	{
 		if ($sPrefix == '')
 		{
-			$sPrefix = $this->GetCode();
+			$sPrefix = $this->GetCode(); // Warning AttributeComputedFieldVoid does not have any sql property
 		}
 		return array('' => $sPrefix); 
 	}
@@ -5602,7 +5837,7 @@ class AttributeFriendlyName extends AttributeComputedFieldVoid
 		return Str::pure2html((string)$sValue);
 	}
 
-	public function GetAsCSV($sValue, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true)
+	public function GetAsCSV($sValue, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true, $bConvertToPlainText = false)
 	{
 		$sFrom = array("\r\n", $sTextQualifier);
 		$sTo = array("\n", $sTextQualifier.$sTextQualifier);
@@ -5769,7 +6004,7 @@ class AttributeRedundancySettings extends AttributeDBField
 		return sprintf($this->GetUserOptionFormat($sCurrentOption), $this->GetMinUpValue($sValue), MetaModel::GetName($sClass));
 	}
 
-	public function GetAsCSV($sValue, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true)
+	public function GetAsCSV($sValue, $sSeparator = ',', $sTextQualifier = '"', $oHostObject = null, $bLocalize = true, $bConvertToPlainText = false)
 	{
 		$sFrom = array("\r\n", $sTextQualifier);
 		$sTo = array("\n", $sTextQualifier.$sTextQualifier);

+ 64 - 26
core/cmdbchangeop.class.inc.php

@@ -1,5 +1,5 @@
 <?php
-// Copyright (C) 2010-2013 Combodo SARL
+// Copyright (C) 2010-2016 Combodo SARL
 //
 //   This file is part of iTop.
 //
@@ -20,7 +20,7 @@
 /**
  * Persistent classes (internal) : cmdbChangeOp and derived
  *
- * @copyright   Copyright (C) 2010-2012 Combodo SARL
+ * @copyright   Copyright (C) 2010-2016 Combodo SARL
  * @license     http://opensource.org/licenses/AGPL-3.0
  */
 
@@ -529,9 +529,6 @@ class CMDBChangeOpSetAttributeLongText extends CMDBChangeOpSetAttribute
 	 */	 
 	public function GetDescription()
 	{
-		// Temporary, until we change the options of GetDescription() -needs a more global revision
-		$bIsHtml = true;
-		
 		$sResult = '';
 		$oTargetObjectClass = $this->Get('objclass');
 		$oTargetObjectKey = $this->Get('objkey');
@@ -561,6 +558,66 @@ class CMDBChangeOpSetAttributeLongText extends CMDBChangeOpSetAttribute
 }
 
 /**
+ * Record the modification of a multiline string (text) containing some HTML markup
+ *
+ * @package     iTopORM
+ */
+class CMDBChangeOpSetAttributeHTML extends CMDBChangeOpSetAttributeLongText
+{
+	public static function Init()
+	{
+		$aParams = array
+		(
+			"category" => "core/cmdb",
+			"key_type" => "",
+			"name_attcode" => "change",
+			"state_attcode" => "",
+			"reconc_keys" => array(),
+			"db_table" => "priv_changeop_setatt_html",
+			"db_key_field" => "id",
+			"db_finalclass_field" => "",
+		);
+		MetaModel::Init_Params($aParams);
+		MetaModel::Init_InheritAttributes();
+		
+		// Display lists
+		MetaModel::Init_SetZListItems('details', array('date', 'userinfo', 'attcode')); // Attributes to be displayed for the complete details
+		MetaModel::Init_SetZListItems('list', array('date', 'userinfo', 'attcode')); // Attributes to be displayed for a list
+	}
+	/**
+	 * Describe (as a text string) the modifications corresponding to this change
+	 */
+	public function GetDescription()
+	{
+		$sResult = '';
+		$oTargetObjectClass = $this->Get('objclass');
+		$oTargetObjectKey = $this->Get('objkey');
+		$oTargetSearch = new DBObjectSearch($oTargetObjectClass);
+		$oTargetSearch->AddCondition('id', $oTargetObjectKey, '=');
+	
+		$oMonoObjectSet = new DBObjectSet($oTargetSearch);
+		if (UserRights::IsActionAllowedOnAttribute($this->Get('objclass'), $this->Get('attcode'), UR_ACTION_READ, $oMonoObjectSet) == UR_ALLOWED_YES)
+		{
+			if (MetaModel::IsValidAttCode($this->Get('objclass'), $this->Get('attcode')))
+			{
+				$oAttDef = MetaModel::GetAttributeDef($this->Get('objclass'), $this->Get('attcode'));
+				$sAttName = $oAttDef->GetLabel();
+			}
+			else
+			{
+				// The attribute was renamed or removed from the object ?
+				$sAttName = $this->Get('attcode');
+			}
+			$sTextView = '<div class="history_entry history_entry_truncated"><div class="history_html_content">'.$this->Get('prevdata').'</div></div>';
+	
+			//$sDocView = $oPrevDoc->GetDisplayInline(get_class($this), $this->GetKey(), 'prevdata');
+			$sResult = Dict::Format('Change:AttName_Changed_PreviousValue_OldValue', $sAttName, $sTextView);
+		}
+		return $sResult;
+	}	
+}
+
+/**
  * Record the modification of a caselog (text)
  * since the caselog itself stores the history
  * of its entries, there is no need to duplicate
@@ -622,27 +679,8 @@ class CMDBChangeOpSetAttributeCaseLog extends CMDBChangeOpSetAttribute
 			$oObj = $oMonoObjectSet->Fetch();
 			$oCaseLog = $oObj->Get($this->Get('attcode'));
 			$iMaxVisibleLength = MetaModel::getConfig()->Get('max_history_case_log_entry_length', 0);
-			$sTextEntry = $oCaseLog->GetEntryAt($this->Get('lastentry'));
-			if (($iMaxVisibleLength > 0) && (strlen($sTextEntry) > $iMaxVisibleLength))
-			{
-				if (function_exists('mb_strcut'))
-				{
-					// Safe with multi-byte strings
-					$sBefore = $this->ToHtml(mb_strcut($sTextEntry, 0, $iMaxVisibleLength, 'UTF-8'));
-					$sAfter = $this->ToHtml(mb_strcut($sTextEntry, $iMaxVisibleLength, null, 'UTF-8'));
-				}
-				else
-				{
-					// Let's hope we have no multi-byte characters around the cuttting point...
-					$sBefore = $this->ToHtml(substr($sTextEntry, 0, $iMaxVisibleLength));
-					$sAfter = $this->ToHtml(substr($sTextEntry, $iMaxVisibleLength));
-				}
-				$sTextEntry = '<span class="case-log-history-entry">'.$sBefore.'<span class="case-log-history-entry-end">'.$sAfter.'<span class="case-log-history-entry-toggle ui-icon ui-icon-circle-minus"></span></span><span class="case-log-history-entry-more">...<span class="case-log-history-entry-toggle ui-icon ui-icon-circle-plus"></span></span></span>';
-			}
-			else
-			{
-				$sTextEntry = $this->ToHtml($sTextEntry);
-			}
+			$sTextEntry = '<div class="history_entry history_entry_truncated"><div class="history_html_content">'.$oCaseLog->GetEntryAt($this->Get('lastentry')).'</div></div>';
+
 			$sResult = Dict::Format('Change:AttName_EntryAdded', $sAttName, $sTextEntry);
 		}
 		return $sResult;

+ 18 - 4
core/cmdbobject.class.inc.php

@@ -1,5 +1,5 @@
 <?php
-// Copyright (C) 2010-2015 Combodo SARL
+// Copyright (C) 2010-2016 Combodo SARL
 //
 //   This file is part of iTop.
 //
@@ -20,7 +20,7 @@
 /**
  * Class cmdbObject
  *
- * @copyright   Copyright (C) 2010-2015 Combodo SARL
+ * @copyright   Copyright (C) 2010-2016 Combodo SARL
  * @license     http://opensource.org/licenses/AGPL-3.0
  */
 
@@ -337,7 +337,14 @@ abstract class CMDBObject extends DBObject
 			elseif ($oAttDef instanceOf AttributeLongText)
 			{
 				// Data blobs
-				$oMyChangeOp = MetaModel::NewObject("CMDBChangeOpSetAttributeLongText");
+				if ($oAttDef->GetFormat() == 'html')
+				{
+					$oMyChangeOp = MetaModel::NewObject("CMDBChangeOpSetAttributeHTML");
+				}
+				else
+				{
+					$oMyChangeOp = MetaModel::NewObject("CMDBChangeOpSetAttributeLongText");
+				}
 				$oMyChangeOp->Set("objclass", get_class($this));
 				$oMyChangeOp->Set("objkey", $this->GetKey());
 				$oMyChangeOp->Set("attcode", $sAttCode);
@@ -352,7 +359,14 @@ abstract class CMDBObject extends DBObject
 			elseif ($oAttDef instanceOf AttributeText)
 			{
 				// Data blobs
-				$oMyChangeOp = MetaModel::NewObject("CMDBChangeOpSetAttributeText");
+				if ($oAttDef->GetFormat() == 'html')
+				{
+					$oMyChangeOp = MetaModel::NewObject("CMDBChangeOpSetAttributeHTML");
+				}
+				else
+				{
+					$oMyChangeOp = MetaModel::NewObject("CMDBChangeOpSetAttributeText");
+				}
 				$oMyChangeOp->Set("objclass", get_class($this));
 				$oMyChangeOp->Set("objkey", $this->GetKey());
 				$oMyChangeOp->Set("attcode", $sAttCode);

+ 10 - 2
core/config.class.inc.php

@@ -1,5 +1,5 @@
 <?php
-// Copyright (C) 2010-2013 Combodo SARL
+// Copyright (C) 2010-2016 Combodo SARL
 //
 //   This file is part of iTop.
 //
@@ -30,7 +30,7 @@ define('ACCESS_READONLY', 0);
 /**
  * Configuration read/write
  *
- * @copyright   Copyright (C) 2010-2012 Combodo SARL
+ * @copyright   Copyright (C) 2010-2016 Combodo SARL
  * @license     http://opensource.org/licenses/AGPL-3.0
  */
 
@@ -875,6 +875,14 @@ class Config
 			'source_of_value' => '',
 			'show_in_conf_sample' => false,
 		), 
+		'html_sanitizer' => array(
+			'type' => 'string',
+			'description' => 'The class to use for HTML sanitization: HTMLDOMSanitizer, HTMLPurifierSanitizer or HTMLNullSanitizer',
+			'default' => 'HTMLDOMSanitizer',
+			'value' => '',
+			'source_of_value' => '',
+			'show_in_conf_sample' => false,
+		), 
 	);
 
 	public function IsProperty($sPropCode)

+ 11 - 5
core/csvbulkexport.class.inc.php

@@ -1,5 +1,5 @@
 <?php
-// Copyright (C) 2015 Combodo SARL
+// Copyright (C) 2015-2016 Combodo SARL
 //
 //   This file is part of iTop.
 //
@@ -19,7 +19,7 @@
 /**
  * Bulk export: CSV export
  *
- * @copyright   Copyright (C) 2015 Combodo SARL
+ * @copyright   Copyright (C) 2015-2016 Combodo SARL
  * @license     http://opensource.org/licenses/AGPL-3.0
  */
 
@@ -33,6 +33,7 @@ class CSVBulkExport extends TabularBulkExport
 		$oP->p(" *\tcharset: (optional) character set for encoding the result (default is 'UTF-8').");
 		$oP->p(" *\ttext-qualifier: (optional) character to be used around text strings (default is '\"').");
 		$oP->p(" *\tno_localize: set to 1 to retrieve non-localized values (for instance for ENUM values). Default is 0 (= localized values)");
+		$oP->p(" *\tformatted_text: set to 1 to export case logs and formatted text fields with their HTML markup. Default is 0 (= plain text)");
 	}
 
 	public function ReadParameters()
@@ -55,6 +56,7 @@ class CSVBulkExport extends TabularBulkExport
 		}
 
 		$this->aStatusInfo['charset'] = strtoupper(utils::ReadParam('charset', 'UTF-8', true, 'raw_data'));
+		$this->aStatusInfo['formatted_text'] = (bool)utils::ReadParam('formatted_text', 0, true);
 	}
 
 
@@ -79,7 +81,7 @@ class CSVBulkExport extends TabularBulkExport
 
 	public function EnumFormParts()
 	{
-		return array_merge(parent::EnumFormParts(), array('csv_options' => array('separator', 'charset', 'text-qualifier', 'no_localize') ,'interactive_fields_csv' => array('interactive_fields_csv')));
+		return array_merge(parent::EnumFormParts(), array('csv_options' => array('separator', 'charset', 'text-qualifier', 'no_localize', 'formatted_text') ,'interactive_fields_csv' => array('interactive_fields_csv')));
 	}
 
 	public function DisplayFormPart(WebPage $oP, $sPartId)
@@ -157,6 +159,10 @@ class CSVBulkExport extends TabularBulkExport
 				}
 				$oP->add('</select>');
 
+				$sChecked = (utils::ReadParam('formatted_text', 0) == 1) ? ' checked ' : '';
+				$oP->add('<h3>'.Dict::S('Core:BulkExport:TextFormat').'</h3>');
+				$oP->add('<input type="checkbox" id="csv_formatted_text" name="formatted_text" value="1"'.$sChecked.'><label for="csv_formatted_text"> '.Dict::S('Core:BulkExport:OptionFormattedText').'</label>');
+				
 				$oP->add('</td></tr></table>');
 				
 				$oP->add('</fieldset>');
@@ -182,7 +188,7 @@ class CSVBulkExport extends TabularBulkExport
 				break;
 					
 			default:
-				$sRet = trim($oObj->GetAsCSV($sAttCode), '"');
+				$sRet = trim($oObj->GetAsCSV($sAttCode), '"');				
 		}
 		return $sRet;
 	}
@@ -251,7 +257,7 @@ class CSVBulkExport extends TabularBulkExport
 							break;
 								
 						default:
-							$sField = $oObj->GetAsCSV($sAttCode, $this->aStatusInfo['separator'], $this->aStatusInfo['text_qualifier'], $this->bLocalizeOutput);
+							$sField = $oObj->GetAsCSV($sAttCode, $this->aStatusInfo['separator'], $this->aStatusInfo['text_qualifier'], $this->bLocalizeOutput, !$this->aStatusInfo['formatted_text']);
 					}
 				}
 				if ($this->aStatusInfo['charset'] != 'UTF-8')

+ 4 - 4
core/dbobject.class.php

@@ -754,10 +754,10 @@ abstract class DBObject implements iDisplay
 		return $oAtt->GetAsXML($this->Get($sAttCode), $this, $bLocalize);
 	}
 
-	public function GetAsCSV($sAttCode, $sSeparator = ',', $sTextQualifier = '"', $bLocalize = true)
+	public function GetAsCSV($sAttCode, $sSeparator = ',', $sTextQualifier = '"', $bLocalize = true, $bConvertToPlainText = false)
 	{
 		$oAtt = MetaModel::GetAttributeDef(get_class($this), $sAttCode);
-		return $oAtt->GetAsCSV($this->Get($sAttCode), $sSeparator, $sTextQualifier, $this, $bLocalize);
+		return $oAtt->GetAsCSV($this->Get($sAttCode), $sSeparator, $sTextQualifier, $this, $bLocalize, $bConvertToPlainText);
 	}
 
 	public function GetOriginalAsHTML($sAttCode, $bLocalize = true)
@@ -772,10 +772,10 @@ abstract class DBObject implements iDisplay
 		return $oAtt->GetAsXML($this->GetOriginal($sAttCode), $this, $bLocalize);
 	}
 
-	public function GetOriginalAsCSV($sAttCode, $sSeparator = ',', $sTextQualifier = '"', $bLocalize = true)
+	public function GetOriginalAsCSV($sAttCode, $sSeparator = ',', $sTextQualifier = '"', $bLocalize = true, $bConvertToPlainText = false)
 	{
 		$oAtt = MetaModel::GetAttributeDef(get_class($this), $sAttCode);
-		return $oAtt->GetAsCSV($this->GetOriginal($sAttCode), $sSeparator, $sTextQualifier, $this, $bLocalize);
+		return $oAtt->GetAsCSV($this->GetOriginal($sAttCode), $sSeparator, $sTextQualifier, $this, $bLocalize, $bConvertToPlainText);
 	}
 
 	public static function MakeHyperLink($sObjClass, $sObjKey, $sLabel = '', $sUrlMakerClass = null, $bWithNavigationContext = true)

+ 43 - 3
core/email.class.inc.php

@@ -1,5 +1,5 @@
 <?php
-// Copyright (C) 2010-2012 Combodo SARL
+// Copyright (C) 2010-2016 Combodo SARL
 //
 //   This file is part of iTop.
 //
@@ -20,7 +20,7 @@
 /**
  * Send an email (abstraction for synchronous/asynchronous modes)
  *
- * @copyright   Copyright (C) 2010-2012 Combodo SARL
+ * @copyright   Copyright (C) 2010-2016 Combodo SARL
  * @license     http://opensource.org/licenses/AGPL-3.0
  */
 
@@ -157,6 +157,9 @@ class EMail
 
 	protected function SendSynchronous(&$aIssues, $oLog = null)
 	{
+		// If the body of the message is in HTML, embed all images based on attachments
+		$this->EmbedInlineImages();
+		
 		$this->LoadConfig();
 
 		$sTransport = self::$m_oConfig->Get('email_transport');
@@ -208,9 +211,46 @@ class EMail
 			return EMAIL_SEND_OK;
 		}
 	}
+	
+	/**
+	 * Reprocess the body of the message (if it is an HTML message)
+	 * to replace the URL of images based on attachments by a link
+	 * to an embedded image (i.e. cid:....)
+	 */
+	protected function EmbedInlineImages()
+	{
+		if ($this->m_aData['body']['mimeType'] == 'text/html')
+		{
+			$oDOMDoc = new DOMDocument();
+			$oDOMDoc->preserveWhitespace = true;
+			@$oDOMDoc->loadHTML('<?xml encoding="UTF-8"?>'.$this->m_aData['body']['body']); // For loading HTML chunks where the character set is not specified
+			
+			$oXPath = new DOMXPath($oDOMDoc);
+			$sXPath = "//img[@data-att-id]";
+			$oImagesList = $oXPath->query($sXPath);
+			
+			if ($oImagesList->length != 0)
+			{
+				foreach($oImagesList as $oImg)
+				{
+					$iAttId = $oImg->getAttribute('data-att-id');
+					$oAttachment = MetaModel::GetObject('Attachment', $iAttId, false, true /* Allow All Data */);
+					if ($oAttachment)
+					{
+						$oDoc = $oAttachment->Get('contents');
+						$oSwiftImage = new Swift_Image($oDoc->GetData(), $oDoc->GetFileName(), $oDoc->GetMimeType());
+						$sCid = $this->m_oMessage->embed($oSwiftImage);
+						$oImg->setAttribute('src', $sCid);
+					}
+				}
+			}
+			$sHtmlBody = $oDOMDoc->saveHTML();
+			$this->m_oMessage->setBody($sHtmlBody, 'text/html', 'UTF-8');
+		}
+	}
 
 	public function Send(&$aIssues, $bForceSynchronous = false, $oLog = null)
-	{	
+	{
 		if ($bForceSynchronous)
 		{
 			return $this->SendSynchronous($aIssues, $oLog);

+ 41 - 7
core/excelbulkexport.class.inc.php

@@ -1,5 +1,5 @@
 <?php
-// Copyright (C) 2015 Combodo SARL
+// Copyright (C) 2015-2016 Combodo SARL
 //
 //   This file is part of iTop.
 //
@@ -19,7 +19,7 @@
 /**
  * Bulk export: Excel (xlsx) export
  *
- * @copyright   Copyright (C) 2015 Combodo SARL
+ * @copyright   Copyright (C) 2015-2016 Combodo SARL
  * @license     http://opensource.org/licenses/AGPL-3.0
  */
 
@@ -46,12 +46,18 @@ class ExcelBulkExport extends TabularBulkExport
 	{
 		$oP->p(" * xlsx format options:");
 		$oP->p(" *\tfields: the comma separated list of field codes to export (e.g: name,org_id,service_name...).");
+		$oP->p(" *\tformatted_text: set to 1 to export case logs and formatted text fields with their HTML markup. Default is 0 (= plain text)");
 	}
 
-
+	public function ReadParameters()
+	{
+		parent::ReadParameters();
+		$this->aStatusInfo['formatted_text'] = (bool)utils::ReadParam('formatted_text', 0, true);
+	}
+	
 	public function EnumFormParts()
 	{
-		return array_merge(parent::EnumFormParts(), array('interactive_fields_xlsx' => array('interactive_fields_xlsx')));
+		return array_merge(parent::EnumFormParts(), array('xlsx_options' => array('formatted_text') ,'interactive_fields_xlsx' => array('interactive_fields_xlsx')));
 	}
 
 	public function DisplayFormPart(WebPage $oP, $sPartId)
@@ -62,7 +68,20 @@ class ExcelBulkExport extends TabularBulkExport
 				$this->GetInteractiveFieldsWidget($oP, 'interactive_fields_xlsx');
 				break;
 					
-			default:
+			case 'xlsx_options':
+				$oP->add('<fieldset><legend>'.Dict::S('Core:BulkExport:XLSXOptions').'</legend>');
+				$oP->add('<table class="export_parameters"><tr><td style="vertical-align:top">');
+				
+				$sChecked = (utils::ReadParam('formatted_text', 0) == 1) ? ' checked ' : '';
+				$oP->add('<h3>'.Dict::S('Core:BulkExport:TextFormat').'</h3>');
+				$oP->add('<input type="checkbox" id="xlsx_formatted_text" name="formatted_text" value="1"'.$sChecked.'><label for="xlsx_formatted_text"> '.Dict::S('Core:BulkExport:OptionFormattedText').'</label>');
+				
+				$oP->add('</td></tr></table>');
+				
+				$oP->add('</fieldset>');
+				break;
+
+				default:
 				return parent:: DisplayFormPart($oP, $sPartId);
 		}
 	}
@@ -103,8 +122,16 @@ class ExcelBulkExport extends TabularBulkExport
 			$value = $oObj->Get($sAttCode);
 			if ($value instanceOf ormCaseLog)
 			{
+				 if (array_key_exists('formatted_text', $this->aStatusInfo) && $this->aStatusInfo['formatted_text'])
+				 {
+				 	$sText = $value->GetText();
+				 }
+				 else
+				 {
+				 	$sText = $value->GetAsPlainText();
+				 }
 				// Extract the case log as text and remove the "===" which make Excel think that the cell contains a formula the next time you edit it!
-				$sRet = trim(preg_replace('/========== ([^=]+) ============/', '********** $1 ************', $value->GetText()));
+				$sRet = trim(preg_replace('/========== ([^=]+) ============/', '********** $1 ************', $sText));
 			}
 			else if ($value instanceOf DBObjectSet)
 			{
@@ -114,7 +141,14 @@ class ExcelBulkExport extends TabularBulkExport
 			else
 			{
 				$oAttDef = MetaModel::GetAttributeDef(get_class($oObj), $sAttCode);
-				$sRet = $oAttDef->GetEditValue($value, $oObj);
+				 if (array_key_exists('formatted_text', $this->aStatusInfo) && $this->aStatusInfo['formatted_text'])
+				 {
+					$sRet = $oAttDef->GetEditValue($value, $oObj);
+				 }
+				 else
+				 {
+				 	$sRet = $oAttDef->GetAsPlainText($value, $oObj);
+				 }
 			}
 		}
 		return $sRet;

+ 338 - 0
core/htmlsanitizer.class.inc.php

@@ -0,0 +1,338 @@
+<?php
+/**
+ * Base class for all possible implementations of HTML Sanitization
+ */
+abstract class HTMLSanitizer
+{
+	public function __construct()
+	{
+		// Do nothing..
+	}
+	
+	/**
+	 * Sanitizes the given HTML document
+	 * @param string $sHTML
+	 * @return string
+	 */
+	abstract public function DoSanitize($sHTML);
+	
+	/**
+	 * Sanitize an HTML string with the configured sanitizer, falling back to HTMLDOMSanitizer in case of Exception or invalid configuration
+	 * @param string $sHTML
+	 * @return string
+	 */
+	public static function Sanitize($sHTML)
+	{
+		$sSanitizerClass = MetaModel::GetConfig()->Get('html_sanitizer');
+		if(!class_exists($sSanitizerClass))
+		{
+			IssueLog::Warning('The configured "html_sanitizer" class "'.$sSanitizerClass.'" is not a valid class. Will use HTMLDOMSanitizer as the default sanitizer.');
+			$sSanitizerClass = 'HTMLDOMSanitizer';
+		}
+		else if(!is_subclass_of($sSanitizerClass, 'HTMLSanitizer'))
+		{
+			IssueLog::Warning('The configured "html_sanitizer" class "'.$sSanitizerClass.'" is not a subclass of HTMLSanitizer. Will use HTMLDOMSanitizer as the default sanitizer.');
+			$sSanitizerClass = 'HTMLDOMSanitizer';
+		}
+		
+		try
+		{
+			$oSanitizer = new $sSanitizerClass();
+			$sCleanHTML = $oSanitizer->DoSanitize($sHTML);
+		}
+		catch(Exception $e)
+		{
+			if($sSanitizerClass != 'HTMLDOMSanitizer')
+			{
+				IssueLog::Warning('Failed to sanitize an HTML string with "'.$sSanitizerClass.'". The following exception occured: '.$e->getMessage());
+				IssueLog::Warning('Will try to sanitize with HTMLDOMSanitizer.');
+				// try again with the HTMLDOMSanitizer
+				$oSanitizer = new HTMLDOMSanitizer();
+				$sCleanHTML = $oSanitizer->DoSanitize($sHTML);
+			}
+			else
+			{
+				IssueLog::Error('Failed to sanitize an HTML string with "HTMLDOMSanitizer". The following exception occured: '.$e->getMessage());
+				IssueLog::Error('The HTML will NOT be sanitized.');
+				$sCleanHTML = $sHTML;	
+			}
+		}
+		return $sCleanHTML;
+	}
+}
+
+/**
+ * Dummy HTMLSanitizer which does nothing at all!
+ * Can be used if HTML Sanitization is not important
+ * (for example when importing "safe" data during an on-boarding)
+ * and performance is at stake
+ *
+ */
+class HTMLNullSanitizer extends HTMLSanitizer
+{
+	/**
+	 * (non-PHPdoc)
+	 * @see HTMLSanitizer::Sanitize()
+	 */
+	public function DoSanitize($sHTML)
+	{
+		return $sHTML;
+	}
+	
+}
+
+/**
+ * A standard-compliant HTMLSanitizer based on the HTMLPurifier library by Edward Z. Yang
+ * Complete but quite slow
+ * http://htmlpurifier.org
+ */
+/*
+class HTMLPurifierSanitizer extends HTMLSanitizer
+{
+	protected static $oPurifier = null;
+	
+	public function __construct()
+	{
+		if (self::$oPurifier == null)
+		{
+			$sLibPath = APPROOT.'lib/htmlpurifier/HTMLPurifier.auto.php';
+			if (!file_exists($sLibPath))
+			{
+				throw new Exception("Missing library '$sLibPath', cannot use HTMLPurifierSanitizer.");
+			}
+			require_once($sLibPath);
+			
+			$oPurifierConfig = HTMLPurifier_Config::createDefault();
+			$oPurifierConfig->set('Core.Encoding', 'UTF-8'); // defaults to 'UTF-8'
+			$oPurifierConfig->set('HTML.Doctype', 'XHTML 1.0 Strict'); // defaults to 'XHTML 1.0 Transitional'
+			$oPurifierConfig->set('URI.AllowedSchemes', array (
+				'http' => true,
+				'https' => true,
+				'data' => true, // This one is not present by default
+			));
+			$sPurifierCache = APPROOT.'data/HTMLPurifier';
+			if (!is_dir($sPurifierCache))
+			{
+				mkdir($sPurifierCache);
+			}
+			if (!is_dir($sPurifierCache))
+			{
+				throw new Exception("Could not create the cache directory '$sPurifierCache'");
+			}
+			$oPurifierConfig->set('Cache.SerializerPath', $sPurifierCache); // no trailing slash
+			self::$oPurifier = new HTMLPurifier($oPurifierConfig);
+		}
+	}
+	
+	public function DoSanitize($sHTML)
+	{
+		$sCleanHtml = self::$oPurifier->purify($sHTML);
+		return $sCleanHtml;		
+	}
+}
+*/
+
+class HTMLDOMSanitizer extends HTMLSanitizer
+{
+	protected $oDoc;
+	protected static $aTagsWhiteList = array(
+		'html' => array(),
+		'body' => array(),
+		'a' => array('href', 'name', 'style'),
+		'p' => array('style'),
+		'br' => array(),
+		'span' => array('style'),
+		'div' => array('style'),
+		'b' => array(),
+		'i' => array(),
+		'em' => array(),
+		'strong' => array(),
+		'img' => array('src','style'),
+		'ul' => array('style'),
+		'ol' => array('style'),
+		'li' => array('style'),
+		'h1' => array('style'),
+		'h2' => array('style'),
+		'h3' => array('style'),
+		'h4' => array('style'),
+		'nav' => array('style'),
+		'section' => array('style'),
+		'code' => array('style'),
+		'table' => array('style', 'width'),
+		'thead' => array('style'),
+		'tbody' => array('style'),
+		'tr' => array('style'),
+		'td' => array('style', 'colspan'),
+		'th' => array('style'),
+		'fieldset' => array('style'),
+		'legend' => array('style'),
+		'font' => array('face', 'color', 'style', 'size'),
+		'big' => array(),
+		'small' => array(),
+		'tt' => array(),
+		'code' => array(),
+		'kbd' => array(),
+		'samp' => array(),
+		'var' => array(),
+		'del' => array(),
+		's' => array(), // strikethrough
+		'ins' => array(),
+		'cite' => array(),
+		'q' => array(),
+		'hr' => array('style'),
+		'pre' => array(),
+		'center' => array(),
+	);
+	
+	protected static $aAttrsWhiteList = array(
+		'href' => '/^(http:|https:)/i',
+		'src' => '/^(http:|https:|data:)/i',
+	);
+	
+	protected static $aStylesWhiteList = array(
+		'background-color', 'color', 'font', 'font-style', 'font-size', 'font-family', 'padding', 'margin', 'border', 'cellpadding', 'cellspacing', 'bordercolor', 'border-collapse', 'width', 'height',
+	);
+	
+	public function DoSanitize($sHTML)
+	{
+		$this->oDoc = new DOMDocument();
+		$this->oDoc->preserveWhitespace = true;
+		@$this->oDoc->loadHTML('<?xml encoding="UTF-8"?>'.$sHTML); // For loading HTML chunks where the character set is not specified
+		
+		$this->CleanNode($this->oDoc);
+		
+		$oXPath = new DOMXPath($this->oDoc);
+		$sXPath = "//body";
+		$oNodesList = $oXPath->query($sXPath);
+		
+		if ($oNodesList->length == 0)
+		{
+			// No body, save the whole document
+			$sCleanHtml = $this->oDoc->saveHTML();
+		}
+		else
+		{
+			// Export only the content of the body tag
+			$sCleanHtml = $this->oDoc->saveHTML($oNodesList->item(0));
+			// remove the body tag itself
+			$sCleanHtml = str_replace( array('<body>', '</body>'), '', $sCleanHtml);
+		}
+		
+		return $sCleanHtml;
+	}
+	
+	protected function CleanNode(DOMNode $oElement)
+	{
+		$aAttrToRemove = array();
+		// Gather the attributes to remove
+		if ($oElement->hasAttributes())
+		{
+			foreach($oElement->attributes as $oAttr)
+			{
+				$sAttr = strtolower($oAttr->name);
+				if (!in_array($sAttr, self::$aTagsWhiteList[strtolower($oElement->tagName)]))
+				{
+					// Forbidden (or unknown) attribute
+					$aAttrToRemove[] = $oAttr->name;
+				}
+				else if (!$this->IsValidAttributeContent($sAttr, $oAttr->value))
+				{
+					// Invalid content
+					$aAttrToRemove[] = $oAttr->name;
+				}
+				else if ($sAttr == 'style')
+				{
+					// Special processing for style tags
+					$sCleanStyle = $this->CleanStyle($oAttr->value);
+					if ($sCleanStyle == '')
+					{
+						// Invalid content
+						$aAttrToRemove[] = $oAttr->name;
+					}
+					else
+					{
+						$oElement->setAttribute($oAttr->name, $sCleanStyle);
+					}
+				}
+			}
+			// Now remove them
+			foreach($aAttrToRemove as $sName)
+			{
+				$oElement->removeAttribute($sName);
+			}
+		}
+		
+		if ($oElement->hasChildNodes())
+		{
+			$aChildElementsToRemove = array();
+			// Gather the child noes to remove
+			foreach($oElement->childNodes as $oNode)
+			{
+				if (($oNode instanceof DOMElement) && (!array_key_exists(strtolower($oNode->tagName), self::$aTagsWhiteList)))
+				{
+					$aChildElementsToRemove[] = $oNode;
+				}
+				else if ($oNode instanceof DOMComment)
+				{
+					$aChildElementsToRemove[] = $oNode;
+				}
+				else
+				{
+					// Recurse
+					$this->CleanNode($oNode);
+					if (($oNode instanceof DOMElement) && (strtolower($oNode->tagName) == 'img'))
+					{
+						$this->ProcessImage($oNode);
+					}
+				}
+			}
+			// Now remove them
+			foreach($aChildElementsToRemove as $oDomElement)
+			{
+				$oElement->removeChild($oDomElement);
+			}
+		}
+	}
+	
+	/**
+	 * Add an extra attribute data-att-id for images which are based on an actual attachment
+	 * so that we can later reconstruct the full "src" URL when needed
+	 * @param DOMNode $oElement
+	 */
+	protected function ProcessImage(DOMNode $oElement)
+	{
+		$sSrc = $oElement->getAttribute('src');
+		$sDownloadUrl = str_replace(array('.', '?'), array('\.', '\?'), ATTACHMENT_DOWNLOAD_URL); // Escape . and ?
+		$sUrlPattern = '|'.$sDownloadUrl.'([0-9]+)|';
+		if (preg_match($sUrlPattern, $sSrc, $aMatches))
+		{
+			$oElement->setAttribute('data-att-id', $aMatches[1]);
+		}
+	}
+	
+	protected function CleanStyle($sStyle)
+	{
+		$aAllowedStyles = array();
+		$aItems = explode(';', $sStyle);
+		{
+			foreach($aItems as $sItem)
+			{
+				$aElements = explode(':', trim($sItem));
+				if (in_array(trim(strtolower($aElements[0])), static::$aStylesWhiteList))
+				{
+					$aAllowedStyles[] = trim($sItem);
+				}
+			}
+		}
+		return implode(';', $aAllowedStyles);
+	}
+	
+	protected function IsValidAttributeContent($sAttributeName, $sValue)
+	{
+		if (array_key_exists($sAttributeName, self::$aAttrsWhiteList))
+		{
+			return preg_match(self::$aAttrsWhiteList[$sAttributeName], $sValue);
+		}
+		return true;
+	}
+}

+ 3 - 4
core/metamodel.class.php

@@ -3538,7 +3538,6 @@ abstract class MetaModel
 			{
 				// Skip this attribute if not originaly defined in this class
 				if (self::$m_aAttribOrigins[$sClass][$sAttCode] != $sClass) continue;
-
 				foreach($oAttDef->GetSQLColumns(true) as $sField => $sDBFieldSpec)
 				{
 					// Keep track of columns used by iTop
@@ -4893,9 +4892,9 @@ abstract class MetaModel
 				{
 					// Expand the parameters for the object
 					$sName = substr($sSearch, 0, $iPos);
-					if (preg_match_all('/\\$'.$sName.'->([^\\$]+)\\$/', $sInput, $aMatches))
+					if (preg_match_all('/\\$'.$sName.'-(>|&gt;)([^\\$]+)\\$/', $sInput, $aMatches)) // Support both syntaxes: $this->xxx$ or $this-&gt;xxx$ for HTML compatibility
 					{
-						foreach($aMatches[1] as $sPlaceholderAttCode)
+						foreach($aMatches[2] as $idx => $sPlaceholderAttCode)
 						{
 							try
 							{
@@ -4903,7 +4902,7 @@ abstract class MetaModel
 								if ($sReplacement !== null)
 								{
 									$aReplacements[] = $sReplacement;
-									$aSearches[] = '$'.$sName.'->'.$sPlaceholderAttCode.'$';
+									$aSearches[] = '$'.$sName.'-'.$aMatches[1][$idx].$sPlaceholderAttCode.'$';
 								}
 							}
 							catch(Exception $e)

+ 101 - 17
core/ormcaselog.class.inc.php

@@ -1,5 +1,5 @@
 <?php
-// Copyright (C) 2010-2015 Combodo SARL
+// Copyright (C) 2010-2016 Combodo SARL
 //
 //   This file is part of iTop.
 //
@@ -23,7 +23,7 @@ define('CASELOG_SEPARATOR', "\n".'========== %1$s : %2$s (%3$d) ============'."\
 /**
  * Class to store a "case log" in a structured way, keeping track of its successive entries
  *  
- * @copyright   Copyright (C) 2010-2015 Combodo SARL
+ * @copyright   Copyright (C) 2010-2016 Combodo SARL
  * @license     http://opensource.org/licenses/AGPL-3.0
  */
 class ormCaseLog {
@@ -43,9 +43,17 @@ class ormCaseLog {
 		$this->m_bModified = false;
 	}
 	
-	public function GetText()
+	public function GetText($bConvertToPlainText = false)
 	{
-		return $this->m_sLog;
+		if ($bConvertToPlainText)
+		{
+			// Rebuild the log, but filtering any HTML markup for the all 'html' entries in the log
+			return $this->GetAsPlainText();
+		}
+		else
+		{
+			return $this->m_sLog;
+		}
 	}
 	
 	public static function FromJSON($oJson)
@@ -97,11 +105,24 @@ class ormCaseLog {
 					$sDate = '';
 				}
 			}
+			$sFormat = array_key_exists('format',  $this->m_aIndex[$index]) ?  $this->m_aIndex[$index]['format'] : 'text';
+			switch($sFormat)
+			{
+				case 'text':
+				$sHtmlEntry = utils::TextToHtml($sTextEntry);
+				break;
+				
+				case 'html':
+				$sHtmlEntry = $sTextEntry;
+				$sTextEntry = utils::HtmlToText($sHtmlEntry);
+				break;
+			}
 			$aEntries[] = array(
 				'date' => $sDate,
 				'user_login' => $this->m_aIndex[$index]['user_name'],
 				'user_id' => $this->m_aIndex[$index]['user_id'],
-				'message' => $sTextEntry
+				'message' => $sTextEntry,
+				'message_html' => $sHtmlEntry,
 			);
 		}
 
@@ -113,7 +134,8 @@ class ormCaseLog {
 			$aEntries[] = array(
 				'date' => '',
 				'user_login' => '',
-				'message' => $sTextEntry
+				'message' => $sTextEntry,
+				'message_html' => utils::TextToHtml($sTextEntry),
 			);
 		}
 
@@ -122,6 +144,22 @@ class ormCaseLog {
 		return $aRet;
 	}
 	
+	/**
+	 * Returns a "plain text" version of the log (equivalent to $this->m_sLog) where all the HTML markup from the 'html' entries have been removed
+	 * @return string
+	 */
+	public function GetAsPlainText()
+	{
+		$sPlainText = '';
+		$aJSON = $this->GetForJSON();
+		foreach($aJSON['entries'] as $aData)
+		{
+			$sSeparator = sprintf(CASELOG_SEPARATOR, $aData['date'], $aData['user_login'], $aData['user_id']);
+			$sPlainText .= $sSeparator.$aData['message'];
+		}
+		return $sPlainText;	
+	}
+	
 	public function GetIndex()
 	{
 		return $this->m_aIndex;
@@ -152,7 +190,16 @@ class ormCaseLog {
 		{
 			$iPos += $aIndex[$index]['separator_length'];
 			$sTextEntry = substr($this->m_sLog, $iPos, $aIndex[$index]['text_length']);
-			$sTextEntry = str_replace(array("\r\n", "\n", "\r"), "<br/>", htmlentities($sTextEntry, ENT_QUOTES, 'UTF-8'));
+			$sCSSClass = 'caselog_entry_html';
+			if (!array_key_exists('format', $aIndex[$index]) || ($aIndex[$index]['format'] == 'text'))
+			{
+				$sCSSClass = 'caselog_entry';
+				$sTextEntry = str_replace(array("\r\n", "\n", "\r"), "<br/>", htmlentities($sTextEntry, ENT_QUOTES, 'UTF-8'));
+			}
+			else
+			{
+				$sTextEntry = utils::FixInlineAttachments($sTextEntry);
+			}
 			$iPos += $aIndex[$index]['text_length'];
 
 			$sEntry = '<div class="caselog_header" style="'.$sStyleCaseLogHeader.'">';
@@ -180,7 +227,7 @@ class ormCaseLog {
 			}
 			$sEntry .= sprintf(Dict::S('UI:CaseLog:Header_Date_UserName'), '<span class="caselog_header_date">'.$sDate.'</span>', '<span class="caselog_header_user">'.$aIndex[$index]['user_name'].'</span>');
 			$sEntry .= '</div>';
-			$sEntry .= '<div class="caselog_entry" style="'.$sStyleCaseLogEntry.'">';
+			$sEntry .= '<div class="'.$sCSSClass.'" style="'.$sStyleCaseLogEntry.'">';
 			$sEntry .= $sTextEntry;
 			$sEntry .= '</div>';
 			$sHtml = $sHtml.$sEntry;
@@ -227,7 +274,20 @@ class ormCaseLog {
 		{
 			$iPos += $aIndex[$index]['separator_length'];
 			$sTextEntry = substr($this->m_sLog, $iPos, $aIndex[$index]['text_length']);
-			$sTextEntry = str_replace(array("\r\n", "\n", "\r"), "<br/>", htmlentities($sTextEntry, ENT_QUOTES, 'UTF-8'));
+			$sCSSClass = 'case_log_simple_html_entry_html';
+			if (!array_key_exists('format', $aIndex[$index]) || ($aIndex[$index]['format'] == 'text'))
+			{
+				$sCSSClass = 'case_log_simple_html_entry';
+				$sTextEntry = str_replace(array("\r\n", "\n", "\r"), "<br/>", htmlentities($sTextEntry, ENT_QUOTES, 'UTF-8'));
+				if (!is_null($aTransfoHandler))
+				{
+					$sTextEntry = call_user_func($aTransfoHandler, $sTextEntry);
+				}
+			}
+			else
+			{
+				$sTextEntry = utils::FixInlineAttachments($sTextEntry);
+			}			
 			$iPos += $aIndex[$index]['text_length'];
 
 			$sEntry = '<li>';
@@ -254,7 +314,7 @@ class ormCaseLog {
 				}
 			}
 			$sEntry .= sprintf(Dict::S('UI:CaseLog:Header_Date_UserName'), '<span class="caselog_header_date">'.$sDate.'</span>', '<span class="caselog_header_user">'.$aIndex[$index]['user_name'].'</span>');
-			$sEntry .= '<div class="case_log_simple_html_entry" style="'.$sStyleCaseLogEntry.'">';
+			$sEntry .= '<div class="'.$sCSSClass.'" style="'.$sStyleCaseLogEntry.'">';
 			$sEntry .= $sTextEntry;
 			$sEntry .= '</div>';
 			$sEntry .= '</li>';
@@ -317,10 +377,19 @@ class ormCaseLog {
 			}
 			$iPos += $aIndex[$index]['separator_length'];
 			$sTextEntry = substr($this->m_sLog, $iPos, $aIndex[$index]['text_length']);
-			$sTextEntry = str_replace(array("\r\n", "\n", "\r"), "<br/>", htmlentities($sTextEntry, ENT_QUOTES, 'UTF-8'));
-			if (!is_null($aTransfoHandler))
+			$sCSSClass= 'caselog_entry_html';
+			if (!array_key_exists('format', $aIndex[$index]) || ($aIndex[$index]['format'] == 'text'))
 			{
-				$sTextEntry = call_user_func($aTransfoHandler, $sTextEntry);
+				$sCSSClass= 'caselog_entry';
+				$sTextEntry = str_replace(array("\r\n", "\n", "\r"), "<br/>", htmlentities($sTextEntry, ENT_QUOTES, 'UTF-8'));
+				if (!is_null($aTransfoHandler))
+				{
+					$sTextEntry = call_user_func($aTransfoHandler, $sTextEntry);
+				}
+			}
+			else
+			{
+				$sTextEntry = utils::FixInlineAttachments($sTextEntry);
 			}
 			$iPos += $aIndex[$index]['text_length'];
 
@@ -349,7 +418,7 @@ class ormCaseLog {
 			}
 			$sEntry .= sprintf(Dict::S('UI:CaseLog:Header_Date_UserName'), $sDate, $aIndex[$index]['user_name']);
 			$sEntry .= '</div>';
-			$sEntry .= '<div class="caselog_entry"'.$sDisplay.'>';
+			$sEntry .= '<div class="'.$sCSSClass.'"'.$sDisplay.'>';
 			$sEntry .= $sTextEntry;
 			$sEntry .= '</div>';
 			$sHtml = $sHtml.$sEntry;
@@ -358,6 +427,7 @@ class ormCaseLog {
 		// Process the case of an eventual remainder (quick migration of AttributeText fields)
 		if ($iPos < (strlen($this->m_sLog) - 1))
 		{
+			// In this case the format is always "text"
 			$sTextEntry = substr($this->m_sLog, $iPos);
 			$sTextEntry = str_replace(array("\r\n", "\n", "\r"), "<br/>", htmlentities($sTextEntry, ENT_QUOTES, 'UTF-8'));
 			if (!is_null($aTransfoHandler))
@@ -402,6 +472,7 @@ class ormCaseLog {
 	 */
 	public function AddLogEntry($sText, $sOnBehalfOf = '')
 	{
+		$sText = HTMLSanitizer::Sanitize($sText);
 		$bMergeEntries = false;
 		$sDate = date(Dict::S('UI:CaseLog:DateFormat'));
 		if ($sOnBehalfOf == '')
@@ -439,7 +510,8 @@ class ormCaseLog {
 				'user_id' => $iUserId,	
 				'date' => time(),	
 				'text_length' => $aLatestEntry['text_length'] + $iTextlength,	
-				'separator_length' => $iSepLength,	
+				'separator_length' => $iSepLength,
+				'format' => 'html',	
 			);
 			
 		}
@@ -455,6 +527,7 @@ class ormCaseLog {
 				'date' => time(),	
 				'text_length' => $iTextlength,	
 				'separator_length' => $iSepLength,	
+				'format' => 'html',	
 			);
 		}
 		$this->m_bModified = true;
@@ -463,7 +536,7 @@ class ormCaseLog {
 
 	public function AddLogEntryFromJSON($oJson, $bCheckUserId = true)
 	{
-		$sText = isset($oJson->message) ? $oJson->message : '';
+		$sText = HTMLSanitizer::Sanitize(isset($oJson->message) ? $oJson->message : '');
 
 		if (isset($oJson->user_id))
 		{
@@ -505,6 +578,16 @@ class ormCaseLog {
 		{
 			$iDate = time();
 		}
+		if (isset($oJson->format))
+		{
+			$sFormat = $oJson->format;
+		}
+		else
+		{
+			// TODO: what is the default format ? text ?
+			$sFormat = 'html';
+		}
+		
 		$sDate = date(Dict::S('UI:CaseLog:DateFormat'), $iDate);
 
 		$sSeparator = sprintf(CASELOG_SEPARATOR, $sDate, $sOnBehalfOf, $iUserId);
@@ -516,7 +599,8 @@ class ormCaseLog {
 			'user_id' => $iUserId,	
 			'date' => $iDate,	
 			'text_length' => $iTextlength,	
-			'separator_length' => $iSepLength,	
+			'separator_length' => $iSepLength,
+			'format' => $sFormat,
 		);
 
 		$this->m_bModified = true;

+ 51 - 4
css/light-grey.css

@@ -280,16 +280,33 @@ legend.transparent {
 }
 
 
-.ui-widget-content td a.cke_toolbox_collapser {
+.ui-widget-content td a.cke_button, .ui-widget-content td a.cke_combo_button, .ui-widget-content td a.cke_toolbox_collapser, cke_dialog a {
   padding-left: 0;
+  background-image: none;
 }
 
 
-p a:hover, td a:hover {
+.ui-widget-content td a:hover, p a:hover, td a:hover {
   text-decoration: underline;
   color: #e87c1e;
-  padding-left: 14px;
-  background: url(../images/mini-arrow-orange.gif) no-repeat left;
+}
+
+
+.cke_reset_all *:hover {
+  text-decoration: none;
+  color: black;
+}
+
+
+table.cke_dialog_contents a.cke_dialog_ui_button_ok {
+  color: black;
+  border-color: #e87c1e;
+  background: #e87c1e;
+}
+
+
+.cke_notifications_area {
+  display: none;
 }
 
 
@@ -2154,3 +2171,33 @@ span.refresh-button {
 }
 
 
+.history_entry {
+  position: relative !important;
+  max-width: 100%;
+}
+
+
+.history_entry_truncated {
+  max-height: 7em;
+  overflow: hidden;
+}
+
+
+.history_truncated_toggler {
+  position: absolute !important;
+  bottom: 0;
+  right: 0;
+  display: block;
+  cursor: pointer;
+  width: 16px;
+  height: 16px;
+  background-image: url(ui-lightness/images/ui-icons_222222_256x240.png);
+  background-position: -16px -192px;
+}
+
+
+.history_entry_truncated .history_truncated_toggler {
+  background-position: 0 -192px;
+}
+
+

+ 38 - 5
css/light-grey.scss

@@ -224,16 +224,27 @@ legend.transparent {
 	padding-left:14px;
 	background: url(../images/mini-arrow-orange.gif) no-repeat left;
 }
-.ui-widget-content td a.cke_toolbox_collapser {
+.ui-widget-content td a.cke_button, .ui-widget-content td a.cke_toolbox_collapser, .ui-widget-content td a.cke_combo_button, cke_dialog a {
 	padding-left: 0;
+	background-image: none;
 }
-p a:hover, td a:hover {
+
+.ui-widget-content td a:hover, p a:hover, td a:hover {
 	text-decoration:underline;
 	color:$highlight-color;
-	padding-left:14px;
-	background: url(../images/mini-arrow-orange.gif) no-repeat left;
 }
-
+.cke_reset_all *:hover {
+	text-decoration: none;
+	color: #000;
+}
+table.cke_dialog_contents a.cke_dialog_ui_button_ok {
+    color: #000;
+    border-color: $highlight-color;
+    background: $highlight-color;
+}
+.cke_notifications_area {
+	display: none;
+}
 td a.no-arrow, td a.no-arrow:visited, .SearchDrawer a.no-arrow, .SearchDrawer a.no-arrow:visited {
 	text-decoration:none;
 	color:#000000;
@@ -1583,3 +1594,25 @@ span.refresh-button {
 .printable-tab .case-log-history-entry .case-log-history-entry-toggle {
 	display: none;
 }
+.history_entry {
+	position: relative !important;
+	max-width: 100%;
+}
+.history_entry_truncated {
+	max-height: 7em;
+	overflow: hidden;
+}
+.history_truncated_toggler {
+	position: absolute !important;
+	bottom: 0;
+	right: 0;
+	display: block;
+	cursor: pointer;
+	width: 16px;
+	height: 16px;
+	background-image: url(ui-lightness/images/ui-icons_222222_256x240.png);
+	background-position: -16px -192px;
+}
+.history_entry_truncated .history_truncated_toggler {
+	background-position: 0 -192px;
+}

+ 3 - 3
datamodels/1.x/itop-attachments/main.attachments.php

@@ -1,5 +1,5 @@
 <?php
-// Copyright (C) 2010-2012 Combodo SARL
+// Copyright (C) 2010-2016 Combodo SARL
 //
 //   This file is part of iTop.
 //
@@ -288,7 +288,7 @@ EOF
 							{
 								$('#display_attachment_'+data.att_id).hover( function() { $(this).children(':button').toggleClass('btn_hidden'); } );
 							}
-							$('#attachment_plugin').trigger('add_attachment', [data.att_id, data.msg]);
+							$('#attachment_plugin').trigger('add_attachment', [data.att_id, data.msg, false /* not an inline image */]);
 							
 							//alert(data.msg);
 						}
@@ -342,7 +342,7 @@ EOF
 						$sIcon = utils::GetAbsoluteUrlAppRoot().AttachmentPlugIn::GetFileIcon($sFileName);
 						$sDownloadLink = utils::GetAbsoluteUrlAppRoot().'pages/ajax.render.php?operation=download_document&class=Attachment&id='.$iAttId.'&field=contents';
 						$oPage->add('<div class="attachment" id="display_attachment_'.$iAttId.'"><a href="'.$sDownloadLink.'"><img src="'.$sIcon.'"><br/>'.$sFileName.'<input type="hidden" name="attachments[]" value="'.$iAttId.'"/></a><br/>&nbsp;<input id="btn_remove_'.$iAttId.'" type="button" class="btn_hidden" value="Delete" onClick="RemoveNewAttachment('.$iAttId.');"/>&nbsp;</div>');
-						$oPage->add_ready_script("$('#attachment_plugin').trigger('add_attachment', [$iAttId, '".addslashes($sFileName)."']);");
+						$oPage->add_ready_script("$('#attachment_plugin').trigger('add_attachment', [$iAttId, '".addslashes($sFileName)."', false /* not an line image */]);");
 					}
 				}
 			}

+ 114 - 2
datamodels/2.x/itop-attachments/ajax.attachment.php

@@ -1,5 +1,5 @@
 <?php
-// Copyright (C) 2010-2012 Combodo SARL
+// Copyright (C) 2010-2016 Combodo SARL
 //
 //   This file is part of iTop.
 //
@@ -20,7 +20,7 @@
 /**
  * Handles various ajax requests
  *
- * @copyright   Copyright (C) 2010-2012 Combodo SARL
+ * @copyright   Copyright (C) 2010-2016 Combodo SARL
  * @license     http://opensource.org/licenses/AGPL-3.0
  */
 
@@ -97,6 +97,118 @@ try
 	}
 	break;
 
+
+	case 'cke_img_upload':
+	// Image uploaded via CKEditor
+	$aResult = array(
+	'uploaded' => 0,
+	'fileName' => '',
+	'url' => '',
+	'icon' => '',
+	'msg' => '',
+	'att_id' => 0,
+	'preview' => 'false',
+	);
+
+	$sObjClass = stripslashes(utils::ReadParam('obj_class', '', false, 'class'));
+	$sTempId = utils::ReadParam('temp_id', '');
+	if (empty($sObjClass))
+	{
+		$aResult['error'] = "Missing argument 'obj_class'";
+	}
+	elseif (empty($sTempId))
+	{
+		$aResult['error'] = "Missing argument 'temp_id'";
+	}
+	else
+	{
+		try
+		{
+			$oDoc = utils::ReadPostedDocument('upload');
+			$oAttachment = MetaModel::NewObject('Attachment');
+			$oAttachment->Set('expire', time() + 3600); // one hour...
+			$oAttachment->Set('temp_id', $sTempId);
+			$oAttachment->Set('item_class', $sObjClass);
+			$oAttachment->SetDefaultOrgId();
+			$oAttachment->Set('contents', $oDoc);
+			$iAttId = $oAttachment->DBInsert();
+
+			$aResult['uploaded'] = 1;
+			$aResult['msg'] = $oDoc->GetFileName();
+			$aResult['fileName'] = $oDoc->GetFileName();
+			$aResult['url'] = utils::GetAbsoluteUrlAppRoot().ATTACHMENT_DOWNLOAD_URL.$iAttId;
+			$aResult['icon'] = utils::GetAbsoluteUrlAppRoot().AttachmentPlugIn::GetFileIcon($oDoc->GetFileName());
+			$aResult['att_id'] = $iAttId;
+			$aResult['preview'] = $oDoc->IsPreviewAvailable() ? 'true' : 'false';
+		}
+		catch (FileUploadException $e)
+		{
+			$aResult['error'] = $e->GetMessage();
+		}
+	}
+	$oPage->add(json_encode($aResult));
+	break;
+	
+	case 'cke_browse':
+	$oPage = new NiceWebPage('Browse for image...');
+	$oPage->add_linked_stylesheet(utils::GetAbsoluteUrlModulesRoot().'itop-attachments/css/magnific-popup.css');
+	$oPage->add_linked_script(utils::GetAbsoluteUrlModulesRoot().'itop-attachments/js/jquery.magnific-popup.min.js');
+	$sImgUrl = utils::GetAbsoluteUrlAppRoot().ATTACHMENT_DOWNLOAD_URL;
+	$oPage->add_script(
+<<<EOF
+        // Helper function to get parameters from the query string.
+        function getUrlParam( paramName ) {
+            var reParam = new RegExp( '(?:[\?&]|&)' + paramName + '=([^&]+)', 'i' );
+            var match = window.location.search.match( reParam );
+
+            return ( match && match.length > 1 ) ? match[1] : null;
+        }
+        // Simulate user action of selecting a file to be returned to CKEditor.
+        function returnFileUrl(iAttId, sAltText) {
+
+            var funcNum = getUrlParam( 'CKEditorFuncNum' );
+            var fileUrl = '$sImgUrl'+iAttId;
+            window.opener.CKEDITOR.tools.callFunction( funcNum, fileUrl, function() {
+                // Get the reference to a dialog window.
+                var dialog = this.getDialog();
+                // Check if this is the Image Properties dialog window.
+                if ( dialog.getName() == 'image' ) {
+                    // Get the reference to a text field that stores the "alt" attribute.
+                    var element = dialog.getContentElement( 'info', 'txtAlt' );
+                    // Assign the new value.
+                    if ( element )
+                        element.setValue(sAltText);
+                }
+                // Return "false" to stop further execution. In such case CKEditor will ignore the second argument ("fileUrl")
+                // and the "onSelect" function assigned to the button that called the file manager (if defined).
+                // return false;
+            } );
+            window.close();
+        }
+EOF
+	);
+	$oPage->add_ready_script(
+<<<EOF
+$('.img-picker').magnificPopup({type: 'image', closeOnContentClick: true });
+EOF
+	);
+	$sTempId = utils::ReadParam('temp_id');
+	$sClass = utils::ReadParam('obj_class', '', false, 'class');
+	$iObjectId = utils::ReadParam('obj_key', 0, false, 'integer');
+	$sOQL = "SELECT Attachment WHERE ((temp_id = :temp_id) OR (item_class = :obj_class AND item_id = :obj_id))";
+	$oSet = new DBObjectSet(DBObjectSearch::FromOQL($sOQL), array(), array('temp_id' => $sTempId, 'obj_class' => $sClass, 'obj_id' => $iObjectId));
+	while($oAttachment = $oSet->Fetch())
+	{
+		$oDoc = $oAttachment->Get('contents');
+		if ($oDoc->GetMainMimeType() == 'image')
+		{
+			$sDocName = addslashes(htmlentities($oDoc->GetFileName(), ENT_QUOTES, 'UTF-8'));
+			$iAttId = $oAttachment->GetKey();
+			$oPage->add("<div style=\"float:left;margin:1em;text-align:center;\"><img class=\"img-picker\" style=\"max-width:300px;cursor:zoom-in\" href=\"{$sImgUrl}{$iAttId}\" alt=\"$sDocName\" title=\"$sDocName\" src=\"{$sImgUrl}{$iAttId}\"><br/><button onclick=\"returnFileUrl($iAttId, '$sDocName')\">Insert</button></div>");
+		}
+	}
+	break;
+	
 	default:
 		$oPage->p("Missing argument 'operation'");
 	}

+ 374 - 0
datamodels/2.x/itop-attachments/css/magnific-popup.css

@@ -0,0 +1,374 @@
+/* Magnific Popup CSS */
+.mfp-bg {
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  z-index: 1042;
+  overflow: hidden;
+  position: fixed;
+  background: #0b0b0b;
+  opacity: 0.8;
+  filter: alpha(opacity=80); }
+
+.mfp-wrap {
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  z-index: 1043;
+  position: fixed;
+  outline: none !important;
+  -webkit-backface-visibility: hidden; }
+
+.mfp-container {
+  text-align: center;
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  left: 0;
+  top: 0;
+  padding: 0 8px;
+  -webkit-box-sizing: border-box;
+  -moz-box-sizing: border-box;
+  box-sizing: border-box; }
+
+.mfp-container:before {
+  content: '';
+  display: inline-block;
+  height: 100%;
+  vertical-align: middle; }
+
+.mfp-align-top .mfp-container:before {
+  display: none; }
+
+.mfp-content {
+  position: relative;
+  display: inline-block;
+  vertical-align: middle;
+  margin: 0 auto;
+  text-align: left;
+  z-index: 1045; }
+
+.mfp-inline-holder .mfp-content, .mfp-ajax-holder .mfp-content {
+  width: 100%;
+  cursor: auto; }
+
+.mfp-ajax-cur {
+  cursor: progress; }
+
+.mfp-zoom-out-cur, .mfp-zoom-out-cur .mfp-image-holder .mfp-close {
+  cursor: -moz-zoom-out;
+  cursor: -webkit-zoom-out;
+  cursor: zoom-out; }
+
+.mfp-zoom {
+  cursor: pointer;
+  cursor: -webkit-zoom-in;
+  cursor: -moz-zoom-in;
+  cursor: zoom-in; }
+
+.mfp-auto-cursor .mfp-content {
+  cursor: auto; }
+
+.mfp-close, .mfp-arrow, .mfp-preloader, .mfp-counter {
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  user-select: none; }
+
+.mfp-loading.mfp-figure {
+  display: none; }
+
+.mfp-hide {
+  display: none !important; }
+
+.mfp-preloader {
+  color: #CCC;
+  position: absolute;
+  top: 50%;
+  width: auto;
+  text-align: center;
+  margin-top: -0.8em;
+  left: 8px;
+  right: 8px;
+  z-index: 1044; }
+  .mfp-preloader a {
+    color: #CCC; }
+    .mfp-preloader a:hover {
+      color: #FFF; }
+
+.mfp-s-ready .mfp-preloader {
+  display: none; }
+
+.mfp-s-error .mfp-content {
+  display: none; }
+
+button.mfp-close, button.mfp-arrow {
+  overflow: visible;
+  cursor: pointer;
+  background: transparent;
+  border: 0;
+  -webkit-appearance: none;
+  display: block;
+  outline: none;
+  padding: 0;
+  z-index: 1046;
+  -webkit-box-shadow: none;
+  box-shadow: none; }
+button::-moz-focus-inner {
+  padding: 0;
+  border: 0; }
+
+.mfp-close {
+  width: 44px;
+  height: 44px;
+  line-height: 44px;
+  position: absolute;
+  right: 0;
+  top: 0;
+  text-decoration: none;
+  text-align: center;
+  opacity: 0.65;
+  filter: alpha(opacity=65);
+  padding: 0 0 18px 10px;
+  color: #FFF;
+  font-style: normal;
+  font-size: 28px;
+  font-family: Arial, Baskerville, monospace; }
+  .mfp-close:hover, .mfp-close:focus {
+    opacity: 1;
+    filter: alpha(opacity=100); }
+  .mfp-close:active {
+    top: 1px; }
+
+.mfp-close-btn-in .mfp-close {
+  color: #333; }
+
+.mfp-image-holder .mfp-close, .mfp-iframe-holder .mfp-close {
+  color: #FFF;
+  right: -6px;
+  text-align: right;
+  padding-right: 6px;
+  width: 100%; }
+
+.mfp-counter {
+  position: absolute;
+  top: 0;
+  right: 0;
+  color: #CCC;
+  font-size: 12px;
+  line-height: 18px;
+  white-space: nowrap; }
+
+.mfp-arrow {
+  position: absolute;
+  opacity: 0.65;
+  filter: alpha(opacity=65);
+  margin: 0;
+  top: 50%;
+  margin-top: -55px;
+  padding: 0;
+  width: 90px;
+  height: 110px;
+  -webkit-tap-highlight-color: rgba(0, 0, 0, 0); }
+  .mfp-arrow:active {
+    margin-top: -54px; }
+  .mfp-arrow:hover, .mfp-arrow:focus {
+    opacity: 1;
+    filter: alpha(opacity=100); }
+  .mfp-arrow:before, .mfp-arrow:after, .mfp-arrow .mfp-b, .mfp-arrow .mfp-a {
+    content: '';
+    display: block;
+    width: 0;
+    height: 0;
+    position: absolute;
+    left: 0;
+    top: 0;
+    margin-top: 35px;
+    margin-left: 35px;
+    border: medium inset transparent; }
+  .mfp-arrow:after, .mfp-arrow .mfp-a {
+    border-top-width: 13px;
+    border-bottom-width: 13px;
+    top: 8px; }
+  .mfp-arrow:before, .mfp-arrow .mfp-b {
+    border-top-width: 21px;
+    border-bottom-width: 21px;
+    opacity: 0.7; }
+
+.mfp-arrow-left {
+  left: 0; }
+  .mfp-arrow-left:after, .mfp-arrow-left .mfp-a {
+    border-right: 17px solid #FFF;
+    margin-left: 31px; }
+  .mfp-arrow-left:before, .mfp-arrow-left .mfp-b {
+    margin-left: 25px;
+    border-right: 27px solid #3F3F3F; }
+
+.mfp-arrow-right {
+  right: 0; }
+  .mfp-arrow-right:after, .mfp-arrow-right .mfp-a {
+    border-left: 17px solid #FFF;
+    margin-left: 39px; }
+  .mfp-arrow-right:before, .mfp-arrow-right .mfp-b {
+    border-left: 27px solid #3F3F3F; }
+
+.mfp-iframe-holder {
+  padding-top: 40px;
+  padding-bottom: 40px; }
+  .mfp-iframe-holder .mfp-content {
+    line-height: 0;
+    width: 100%;
+    max-width: 900px; }
+  .mfp-iframe-holder .mfp-close {
+    top: -40px; }
+
+.mfp-iframe-scaler {
+  width: 100%;
+  height: 0;
+  overflow: hidden;
+  padding-top: 56.25%; }
+  .mfp-iframe-scaler iframe {
+    position: absolute;
+    display: block;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    box-shadow: 0 0 8px rgba(0, 0, 0, 0.6);
+    background: #000; }
+
+/* Main image in popup */
+img.mfp-img {
+  width: auto;
+  max-width: 100%;
+  height: auto;
+  display: block;
+  line-height: 0;
+  -webkit-box-sizing: border-box;
+  -moz-box-sizing: border-box;
+  box-sizing: border-box;
+  padding: 40px 0 40px;
+  margin: 0 auto; }
+
+/* The shadow behind the image */
+.mfp-figure {
+  line-height: 0; }
+  .mfp-figure:after {
+    content: '';
+    position: absolute;
+    left: 0;
+    top: 40px;
+    bottom: 40px;
+    display: block;
+    right: 0;
+    width: auto;
+    height: auto;
+    z-index: -1;
+    box-shadow: 0 0 8px rgba(0, 0, 0, 0.6);
+    background: #444; }
+  .mfp-figure small {
+    color: #BDBDBD;
+    display: block;
+    font-size: 12px;
+    line-height: 14px; }
+  .mfp-figure figure {
+    margin: 0; }
+
+.mfp-bottom-bar {
+  margin-top: -36px;
+  position: absolute;
+  top: 100%;
+  left: 0;
+  width: 100%;
+  cursor: auto; }
+
+.mfp-title {
+  text-align: left;
+  line-height: 18px;
+  color: #F3F3F3;
+  word-wrap: break-word;
+  padding-right: 36px; }
+
+.mfp-image-holder .mfp-content {
+  max-width: 100%; }
+
+.mfp-gallery .mfp-image-holder .mfp-figure {
+  cursor: pointer; }
+
+@media screen and (max-width: 800px) and (orientation: landscape), screen and (max-height: 300px) {
+  /**
+       * Remove all paddings around the image on small screen
+       */
+  .mfp-img-mobile .mfp-image-holder {
+    padding-left: 0;
+    padding-right: 0; }
+  .mfp-img-mobile img.mfp-img {
+    padding: 0; }
+  .mfp-img-mobile .mfp-figure:after {
+    top: 0;
+    bottom: 0; }
+  .mfp-img-mobile .mfp-figure small {
+    display: inline;
+    margin-left: 5px; }
+  .mfp-img-mobile .mfp-bottom-bar {
+    background: rgba(0, 0, 0, 0.6);
+    bottom: 0;
+    margin: 0;
+    top: auto;
+    padding: 3px 5px;
+    position: fixed;
+    -webkit-box-sizing: border-box;
+    -moz-box-sizing: border-box;
+    box-sizing: border-box; }
+    .mfp-img-mobile .mfp-bottom-bar:empty {
+      padding: 0; }
+  .mfp-img-mobile .mfp-counter {
+    right: 5px;
+    top: 3px; }
+  .mfp-img-mobile .mfp-close {
+    top: 0;
+    right: 0;
+    width: 35px;
+    height: 35px;
+    line-height: 35px;
+    background: rgba(0, 0, 0, 0.6);
+    position: fixed;
+    text-align: center;
+    padding: 0; }
+ }
+
+@media all and (max-width: 900px) {
+  .mfp-arrow {
+    -webkit-transform: scale(0.75);
+    transform: scale(0.75); }
+
+  .mfp-arrow-left {
+    -webkit-transform-origin: 0;
+    transform-origin: 0; }
+
+  .mfp-arrow-right {
+    -webkit-transform-origin: 100%;
+    transform-origin: 100%; }
+
+  .mfp-container {
+    padding-left: 6px;
+    padding-right: 6px; }
+ }
+
+.mfp-ie7 .mfp-img {
+  padding: 0; }
+.mfp-ie7 .mfp-bottom-bar {
+  width: 600px;
+  left: 50%;
+  margin-left: -300px;
+  margin-top: 5px;
+  padding-bottom: 5px; }
+.mfp-ie7 .mfp-container {
+  padding: 0; }
+.mfp-ie7 .mfp-content {
+  padding-top: 44px; }
+.mfp-ie7 .mfp-close {
+  top: 0;
+  right: 0;
+  padding-top: 0; }

+ 2060 - 0
datamodels/2.x/itop-attachments/js/jquery.magnific-popup.js

@@ -0,0 +1,2060 @@
+/*! Magnific Popup - v1.0.0 - 2015-01-03
+* http://dimsemenov.com/plugins/magnific-popup/
+* Copyright (c) 2015 Dmitry Semenov; */
+;(function (factory) { 
+if (typeof define === 'function' && define.amd) { 
+ // AMD. Register as an anonymous module. 
+ define(['jquery'], factory); 
+ } else if (typeof exports === 'object') { 
+ // Node/CommonJS 
+ factory(require('jquery')); 
+ } else { 
+ // Browser globals 
+ factory(window.jQuery || window.Zepto); 
+ } 
+ }(function($) { 
+
+/*>>core*/
+/**
+ * 
+ * Magnific Popup Core JS file
+ * 
+ */
+
+
+/**
+ * Private static constants
+ */
+var CLOSE_EVENT = 'Close',
+	BEFORE_CLOSE_EVENT = 'BeforeClose',
+	AFTER_CLOSE_EVENT = 'AfterClose',
+	BEFORE_APPEND_EVENT = 'BeforeAppend',
+	MARKUP_PARSE_EVENT = 'MarkupParse',
+	OPEN_EVENT = 'Open',
+	CHANGE_EVENT = 'Change',
+	NS = 'mfp',
+	EVENT_NS = '.' + NS,
+	READY_CLASS = 'mfp-ready',
+	REMOVING_CLASS = 'mfp-removing',
+	PREVENT_CLOSE_CLASS = 'mfp-prevent-close';
+
+
+/**
+ * Private vars 
+ */
+/*jshint -W079 */
+var mfp, // As we have only one instance of MagnificPopup object, we define it locally to not to use 'this'
+	MagnificPopup = function(){},
+	_isJQ = !!(window.jQuery),
+	_prevStatus,
+	_window = $(window),
+	_document,
+	_prevContentType,
+	_wrapClasses,
+	_currPopupType;
+
+
+/**
+ * Private functions
+ */
+var _mfpOn = function(name, f) {
+		mfp.ev.on(NS + name + EVENT_NS, f);
+	},
+	_getEl = function(className, appendTo, html, raw) {
+		var el = document.createElement('div');
+		el.className = 'mfp-'+className;
+		if(html) {
+			el.innerHTML = html;
+		}
+		if(!raw) {
+			el = $(el);
+			if(appendTo) {
+				el.appendTo(appendTo);
+			}
+		} else if(appendTo) {
+			appendTo.appendChild(el);
+		}
+		return el;
+	},
+	_mfpTrigger = function(e, data) {
+		mfp.ev.triggerHandler(NS + e, data);
+
+		if(mfp.st.callbacks) {
+			// converts "mfpEventName" to "eventName" callback and triggers it if it's present
+			e = e.charAt(0).toLowerCase() + e.slice(1);
+			if(mfp.st.callbacks[e]) {
+				mfp.st.callbacks[e].apply(mfp, $.isArray(data) ? data : [data]);
+			}
+		}
+	},
+	_getCloseBtn = function(type) {
+		if(type !== _currPopupType || !mfp.currTemplate.closeBtn) {
+			mfp.currTemplate.closeBtn = $( mfp.st.closeMarkup.replace('%title%', mfp.st.tClose ) );
+			_currPopupType = type;
+		}
+		return mfp.currTemplate.closeBtn;
+	},
+	// Initialize Magnific Popup only when called at least once
+	_checkInstance = function() {
+		if(!$.magnificPopup.instance) {
+			/*jshint -W020 */
+			mfp = new MagnificPopup();
+			mfp.init();
+			$.magnificPopup.instance = mfp;
+		}
+	},
+	// CSS transition detection, http://stackoverflow.com/questions/7264899/detect-css-transitions-using-javascript-and-without-modernizr
+	supportsTransitions = function() {
+		var s = document.createElement('p').style, // 's' for style. better to create an element if body yet to exist
+			v = ['ms','O','Moz','Webkit']; // 'v' for vendor
+
+		if( s['transition'] !== undefined ) {
+			return true; 
+		}
+			
+		while( v.length ) {
+			if( v.pop() + 'Transition' in s ) {
+				return true;
+			}
+		}
+				
+		return false;
+	};
+
+
+
+/**
+ * Public functions
+ */
+MagnificPopup.prototype = {
+
+	constructor: MagnificPopup,
+
+	/**
+	 * Initializes Magnific Popup plugin. 
+	 * This function is triggered only once when $.fn.magnificPopup or $.magnificPopup is executed
+	 */
+	init: function() {
+		var appVersion = navigator.appVersion;
+		mfp.isIE7 = appVersion.indexOf("MSIE 7.") !== -1; 
+		mfp.isIE8 = appVersion.indexOf("MSIE 8.") !== -1;
+		mfp.isLowIE = mfp.isIE7 || mfp.isIE8;
+		mfp.isAndroid = (/android/gi).test(appVersion);
+		mfp.isIOS = (/iphone|ipad|ipod/gi).test(appVersion);
+		mfp.supportsTransition = supportsTransitions();
+
+		// We disable fixed positioned lightbox on devices that don't handle it nicely.
+		// If you know a better way of detecting this - let me know.
+		mfp.probablyMobile = (mfp.isAndroid || mfp.isIOS || /(Opera Mini)|Kindle|webOS|BlackBerry|(Opera Mobi)|(Windows Phone)|IEMobile/i.test(navigator.userAgent) );
+		_document = $(document);
+
+		mfp.popupsCache = {};
+	},
+
+	/**
+	 * Opens popup
+	 * @param  data [description]
+	 */
+	open: function(data) {
+
+		var i;
+
+		if(data.isObj === false) { 
+			// convert jQuery collection to array to avoid conflicts later
+			mfp.items = data.items.toArray();
+
+			mfp.index = 0;
+			var items = data.items,
+				item;
+			for(i = 0; i < items.length; i++) {
+				item = items[i];
+				if(item.parsed) {
+					item = item.el[0];
+				}
+				if(item === data.el[0]) {
+					mfp.index = i;
+					break;
+				}
+			}
+		} else {
+			mfp.items = $.isArray(data.items) ? data.items : [data.items];
+			mfp.index = data.index || 0;
+		}
+
+		// if popup is already opened - we just update the content
+		if(mfp.isOpen) {
+			mfp.updateItemHTML();
+			return;
+		}
+		
+		mfp.types = []; 
+		_wrapClasses = '';
+		if(data.mainEl && data.mainEl.length) {
+			mfp.ev = data.mainEl.eq(0);
+		} else {
+			mfp.ev = _document;
+		}
+
+		if(data.key) {
+			if(!mfp.popupsCache[data.key]) {
+				mfp.popupsCache[data.key] = {};
+			}
+			mfp.currTemplate = mfp.popupsCache[data.key];
+		} else {
+			mfp.currTemplate = {};
+		}
+
+
+
+		mfp.st = $.extend(true, {}, $.magnificPopup.defaults, data ); 
+		mfp.fixedContentPos = mfp.st.fixedContentPos === 'auto' ? !mfp.probablyMobile : mfp.st.fixedContentPos;
+
+		if(mfp.st.modal) {
+			mfp.st.closeOnContentClick = false;
+			mfp.st.closeOnBgClick = false;
+			mfp.st.showCloseBtn = false;
+			mfp.st.enableEscapeKey = false;
+		}
+		
+
+		// Building markup
+		// main containers are created only once
+		if(!mfp.bgOverlay) {
+
+			// Dark overlay
+			mfp.bgOverlay = _getEl('bg').on('click'+EVENT_NS, function() {
+				mfp.close();
+			});
+
+			mfp.wrap = _getEl('wrap').attr('tabindex', -1).on('click'+EVENT_NS, function(e) {
+				if(mfp._checkIfClose(e.target)) {
+					mfp.close();
+				}
+			});
+
+			mfp.container = _getEl('container', mfp.wrap);
+		}
+
+		mfp.contentContainer = _getEl('content');
+		if(mfp.st.preloader) {
+			mfp.preloader = _getEl('preloader', mfp.container, mfp.st.tLoading);
+		}
+
+
+		// Initializing modules
+		var modules = $.magnificPopup.modules;
+		for(i = 0; i < modules.length; i++) {
+			var n = modules[i];
+			n = n.charAt(0).toUpperCase() + n.slice(1);
+			mfp['init'+n].call(mfp);
+		}
+		_mfpTrigger('BeforeOpen');
+
+
+		if(mfp.st.showCloseBtn) {
+			// Close button
+			if(!mfp.st.closeBtnInside) {
+				mfp.wrap.append( _getCloseBtn() );
+			} else {
+				_mfpOn(MARKUP_PARSE_EVENT, function(e, template, values, item) {
+					values.close_replaceWith = _getCloseBtn(item.type);
+				});
+				_wrapClasses += ' mfp-close-btn-in';
+			}
+		}
+
+		if(mfp.st.alignTop) {
+			_wrapClasses += ' mfp-align-top';
+		}
+
+	
+
+		if(mfp.fixedContentPos) {
+			mfp.wrap.css({
+				overflow: mfp.st.overflowY,
+				overflowX: 'hidden',
+				overflowY: mfp.st.overflowY
+			});
+		} else {
+			mfp.wrap.css({ 
+				top: _window.scrollTop(),
+				position: 'absolute'
+			});
+		}
+		if( mfp.st.fixedBgPos === false || (mfp.st.fixedBgPos === 'auto' && !mfp.fixedContentPos) ) {
+			mfp.bgOverlay.css({
+				height: _document.height(),
+				position: 'absolute'
+			});
+		}
+
+		
+
+		if(mfp.st.enableEscapeKey) {
+			// Close on ESC key
+			_document.on('keyup' + EVENT_NS, function(e) {
+				if(e.keyCode === 27) {
+					mfp.close();
+				}
+			});
+		}
+
+		_window.on('resize' + EVENT_NS, function() {
+			mfp.updateSize();
+		});
+
+
+		if(!mfp.st.closeOnContentClick) {
+			_wrapClasses += ' mfp-auto-cursor';
+		}
+		
+		if(_wrapClasses)
+			mfp.wrap.addClass(_wrapClasses);
+
+
+		// this triggers recalculation of layout, so we get it once to not to trigger twice
+		var windowHeight = mfp.wH = _window.height();
+
+		
+		var windowStyles = {};
+
+		if( mfp.fixedContentPos ) {
+            if(mfp._hasScrollBar(windowHeight)){
+                var s = mfp._getScrollbarSize();
+                if(s) {
+                    windowStyles.marginRight = s;
+                }
+            }
+        }
+
+		if(mfp.fixedContentPos) {
+			if(!mfp.isIE7) {
+				windowStyles.overflow = 'hidden';
+			} else {
+				// ie7 double-scroll bug
+				$('body, html').css('overflow', 'hidden');
+			}
+		}
+
+		
+		
+		var classesToadd = mfp.st.mainClass;
+		if(mfp.isIE7) {
+			classesToadd += ' mfp-ie7';
+		}
+		if(classesToadd) {
+			mfp._addClassToMFP( classesToadd );
+		}
+
+		// add content
+		mfp.updateItemHTML();
+
+		_mfpTrigger('BuildControls');
+
+		// remove scrollbar, add margin e.t.c
+		$('html').css(windowStyles);
+		
+		// add everything to DOM
+		mfp.bgOverlay.add(mfp.wrap).prependTo( mfp.st.prependTo || $(document.body) );
+
+		// Save last focused element
+		mfp._lastFocusedEl = document.activeElement;
+		
+		// Wait for next cycle to allow CSS transition
+		setTimeout(function() {
+			
+			if(mfp.content) {
+				mfp._addClassToMFP(READY_CLASS);
+				mfp._setFocus();
+			} else {
+				// if content is not defined (not loaded e.t.c) we add class only for BG
+				mfp.bgOverlay.addClass(READY_CLASS);
+			}
+			
+			// Trap the focus in popup
+			_document.on('focusin' + EVENT_NS, mfp._onFocusIn);
+
+		}, 16);
+
+		mfp.isOpen = true;
+		mfp.updateSize(windowHeight);
+		_mfpTrigger(OPEN_EVENT);
+
+		return data;
+	},
+
+	/**
+	 * Closes the popup
+	 */
+	close: function() {
+		if(!mfp.isOpen) return;
+		_mfpTrigger(BEFORE_CLOSE_EVENT);
+
+		mfp.isOpen = false;
+		// for CSS3 animation
+		if(mfp.st.removalDelay && !mfp.isLowIE && mfp.supportsTransition )  {
+			mfp._addClassToMFP(REMOVING_CLASS);
+			setTimeout(function() {
+				mfp._close();
+			}, mfp.st.removalDelay);
+		} else {
+			mfp._close();
+		}
+	},
+
+	/**
+	 * Helper for close() function
+	 */
+	_close: function() {
+		_mfpTrigger(CLOSE_EVENT);
+
+		var classesToRemove = REMOVING_CLASS + ' ' + READY_CLASS + ' ';
+
+		mfp.bgOverlay.detach();
+		mfp.wrap.detach();
+		mfp.container.empty();
+
+		if(mfp.st.mainClass) {
+			classesToRemove += mfp.st.mainClass + ' ';
+		}
+
+		mfp._removeClassFromMFP(classesToRemove);
+
+		if(mfp.fixedContentPos) {
+			var windowStyles = {marginRight: ''};
+			if(mfp.isIE7) {
+				$('body, html').css('overflow', '');
+			} else {
+				windowStyles.overflow = '';
+			}
+			$('html').css(windowStyles);
+		}
+		
+		_document.off('keyup' + EVENT_NS + ' focusin' + EVENT_NS);
+		mfp.ev.off(EVENT_NS);
+
+		// clean up DOM elements that aren't removed
+		mfp.wrap.attr('class', 'mfp-wrap').removeAttr('style');
+		mfp.bgOverlay.attr('class', 'mfp-bg');
+		mfp.container.attr('class', 'mfp-container');
+
+		// remove close button from target element
+		if(mfp.st.showCloseBtn &&
+		(!mfp.st.closeBtnInside || mfp.currTemplate[mfp.currItem.type] === true)) {
+			if(mfp.currTemplate.closeBtn)
+				mfp.currTemplate.closeBtn.detach();
+		}
+
+
+		if(mfp._lastFocusedEl) {
+			$(mfp._lastFocusedEl).focus(); // put tab focus back
+		}
+		mfp.currItem = null;	
+		mfp.content = null;
+		mfp.currTemplate = null;
+		mfp.prevHeight = 0;
+
+		_mfpTrigger(AFTER_CLOSE_EVENT);
+	},
+	
+	updateSize: function(winHeight) {
+
+		if(mfp.isIOS) {
+			// fixes iOS nav bars https://github.com/dimsemenov/Magnific-Popup/issues/2
+			var zoomLevel = document.documentElement.clientWidth / window.innerWidth;
+			var height = window.innerHeight * zoomLevel;
+			mfp.wrap.css('height', height);
+			mfp.wH = height;
+		} else {
+			mfp.wH = winHeight || _window.height();
+		}
+		// Fixes #84: popup incorrectly positioned with position:relative on body
+		if(!mfp.fixedContentPos) {
+			mfp.wrap.css('height', mfp.wH);
+		}
+
+		_mfpTrigger('Resize');
+
+	},
+
+	/**
+	 * Set content of popup based on current index
+	 */
+	updateItemHTML: function() {
+		var item = mfp.items[mfp.index];
+
+		// Detach and perform modifications
+		mfp.contentContainer.detach();
+
+		if(mfp.content)
+			mfp.content.detach();
+
+		if(!item.parsed) {
+			item = mfp.parseEl( mfp.index );
+		}
+
+		var type = item.type;	
+
+		_mfpTrigger('BeforeChange', [mfp.currItem ? mfp.currItem.type : '', type]);
+		// BeforeChange event works like so:
+		// _mfpOn('BeforeChange', function(e, prevType, newType) { });
+		
+		mfp.currItem = item;
+
+		
+
+		
+
+		if(!mfp.currTemplate[type]) {
+			var markup = mfp.st[type] ? mfp.st[type].markup : false;
+
+			// allows to modify markup
+			_mfpTrigger('FirstMarkupParse', markup);
+
+			if(markup) {
+				mfp.currTemplate[type] = $(markup);
+			} else {
+				// if there is no markup found we just define that template is parsed
+				mfp.currTemplate[type] = true;
+			}
+		}
+
+		if(_prevContentType && _prevContentType !== item.type) {
+			mfp.container.removeClass('mfp-'+_prevContentType+'-holder');
+		}
+		
+		var newContent = mfp['get' + type.charAt(0).toUpperCase() + type.slice(1)](item, mfp.currTemplate[type]);
+		mfp.appendContent(newContent, type);
+
+		item.preloaded = true;
+
+		_mfpTrigger(CHANGE_EVENT, item);
+		_prevContentType = item.type;
+		
+		// Append container back after its content changed
+		mfp.container.prepend(mfp.contentContainer);
+
+		_mfpTrigger('AfterChange');
+	},
+
+
+	/**
+	 * Set HTML content of popup
+	 */
+	appendContent: function(newContent, type) {
+		mfp.content = newContent;
+		
+		if(newContent) {
+			if(mfp.st.showCloseBtn && mfp.st.closeBtnInside &&
+				mfp.currTemplate[type] === true) {
+				// if there is no markup, we just append close button element inside
+				if(!mfp.content.find('.mfp-close').length) {
+					mfp.content.append(_getCloseBtn());
+				}
+			} else {
+				mfp.content = newContent;
+			}
+		} else {
+			mfp.content = '';
+		}
+
+		_mfpTrigger(BEFORE_APPEND_EVENT);
+		mfp.container.addClass('mfp-'+type+'-holder');
+
+		mfp.contentContainer.append(mfp.content);
+	},
+
+
+
+	
+	/**
+	 * Creates Magnific Popup data object based on given data
+	 * @param  {int} index Index of item to parse
+	 */
+	parseEl: function(index) {
+		var item = mfp.items[index],
+			type;
+
+		if(item.tagName) {
+			item = { el: $(item) };
+		} else {
+			type = item.type;
+			item = { data: item, src: item.src };
+		}
+
+		if(item.el) {
+			var types = mfp.types;
+
+			// check for 'mfp-TYPE' class
+			for(var i = 0; i < types.length; i++) {
+				if( item.el.hasClass('mfp-'+types[i]) ) {
+					type = types[i];
+					break;
+				}
+			}
+
+			item.src = item.el.attr('data-mfp-src');
+			if(!item.src) {
+				item.src = item.el.attr('href');
+			}
+		}
+
+		item.type = type || mfp.st.type || 'inline';
+		item.index = index;
+		item.parsed = true;
+		mfp.items[index] = item;
+		_mfpTrigger('ElementParse', item);
+
+		return mfp.items[index];
+	},
+
+
+	/**
+	 * Initializes single popup or a group of popups
+	 */
+	addGroup: function(el, options) {
+		var eHandler = function(e) {
+			e.mfpEl = this;
+			mfp._openClick(e, el, options);
+		};
+
+		if(!options) {
+			options = {};
+		} 
+
+		var eName = 'click.magnificPopup';
+		options.mainEl = el;
+		
+		if(options.items) {
+			options.isObj = true;
+			el.off(eName).on(eName, eHandler);
+		} else {
+			options.isObj = false;
+			if(options.delegate) {
+				el.off(eName).on(eName, options.delegate , eHandler);
+			} else {
+				options.items = el;
+				el.off(eName).on(eName, eHandler);
+			}
+		}
+	},
+	_openClick: function(e, el, options) {
+		var midClick = options.midClick !== undefined ? options.midClick : $.magnificPopup.defaults.midClick;
+
+
+		if(!midClick && ( e.which === 2 || e.ctrlKey || e.metaKey ) ) {
+			return;
+		}
+
+		var disableOn = options.disableOn !== undefined ? options.disableOn : $.magnificPopup.defaults.disableOn;
+
+		if(disableOn) {
+			if($.isFunction(disableOn)) {
+				if( !disableOn.call(mfp) ) {
+					return true;
+				}
+			} else { // else it's number
+				if( _window.width() < disableOn ) {
+					return true;
+				}
+			}
+		}
+		
+		if(e.type) {
+			e.preventDefault();
+
+			// This will prevent popup from closing if element is inside and popup is already opened
+			if(mfp.isOpen) {
+				e.stopPropagation();
+			}
+		}
+			
+
+		options.el = $(e.mfpEl);
+		if(options.delegate) {
+			options.items = el.find(options.delegate);
+		}
+		mfp.open(options);
+	},
+
+
+	/**
+	 * Updates text on preloader
+	 */
+	updateStatus: function(status, text) {
+
+		if(mfp.preloader) {
+			if(_prevStatus !== status) {
+				mfp.container.removeClass('mfp-s-'+_prevStatus);
+			}
+
+			if(!text && status === 'loading') {
+				text = mfp.st.tLoading;
+			}
+
+			var data = {
+				status: status,
+				text: text
+			};
+			// allows to modify status
+			_mfpTrigger('UpdateStatus', data);
+
+			status = data.status;
+			text = data.text;
+
+			mfp.preloader.html(text);
+
+			mfp.preloader.find('a').on('click', function(e) {
+				e.stopImmediatePropagation();
+			});
+
+			mfp.container.addClass('mfp-s-'+status);
+			_prevStatus = status;
+		}
+	},
+
+
+	/*
+		"Private" helpers that aren't private at all
+	 */
+	// Check to close popup or not
+	// "target" is an element that was clicked
+	_checkIfClose: function(target) {
+
+		if($(target).hasClass(PREVENT_CLOSE_CLASS)) {
+			return;
+		}
+
+		var closeOnContent = mfp.st.closeOnContentClick;
+		var closeOnBg = mfp.st.closeOnBgClick;
+
+		if(closeOnContent && closeOnBg) {
+			return true;
+		} else {
+
+			// We close the popup if click is on close button or on preloader. Or if there is no content.
+			if(!mfp.content || $(target).hasClass('mfp-close') || (mfp.preloader && target === mfp.preloader[0]) ) {
+				return true;
+			}
+
+			// if click is outside the content
+			if(  (target !== mfp.content[0] && !$.contains(mfp.content[0], target))  ) {
+				if(closeOnBg) {
+					// last check, if the clicked element is in DOM, (in case it's removed onclick)
+					if( $.contains(document, target) ) {
+						return true;
+					}
+				}
+			} else if(closeOnContent) {
+				return true;
+			}
+
+		}
+		return false;
+	},
+	_addClassToMFP: function(cName) {
+		mfp.bgOverlay.addClass(cName);
+		mfp.wrap.addClass(cName);
+	},
+	_removeClassFromMFP: function(cName) {
+		this.bgOverlay.removeClass(cName);
+		mfp.wrap.removeClass(cName);
+	},
+	_hasScrollBar: function(winHeight) {
+		return (  (mfp.isIE7 ? _document.height() : document.body.scrollHeight) > (winHeight || _window.height()) );
+	},
+	_setFocus: function() {
+		(mfp.st.focus ? mfp.content.find(mfp.st.focus).eq(0) : mfp.wrap).focus();
+	},
+	_onFocusIn: function(e) {
+		if( e.target !== mfp.wrap[0] && !$.contains(mfp.wrap[0], e.target) ) {
+			mfp._setFocus();
+			return false;
+		}
+	},
+	_parseMarkup: function(template, values, item) {
+		var arr;
+		if(item.data) {
+			values = $.extend(item.data, values);
+		}
+		_mfpTrigger(MARKUP_PARSE_EVENT, [template, values, item] );
+
+		$.each(values, function(key, value) {
+			if(value === undefined || value === false) {
+				return true;
+			}
+			arr = key.split('_');
+			if(arr.length > 1) {
+				var el = template.find(EVENT_NS + '-'+arr[0]);
+
+				if(el.length > 0) {
+					var attr = arr[1];
+					if(attr === 'replaceWith') {
+						if(el[0] !== value[0]) {
+							el.replaceWith(value);
+						}
+					} else if(attr === 'img') {
+						if(el.is('img')) {
+							el.attr('src', value);
+						} else {
+							el.replaceWith( '<img src="'+value+'" class="' + el.attr('class') + '" />' );
+						}
+					} else {
+						el.attr(arr[1], value);
+					}
+				}
+
+			} else {
+				template.find(EVENT_NS + '-'+key).html(value);
+			}
+		});
+	},
+
+	_getScrollbarSize: function() {
+		// thx David
+		if(mfp.scrollbarSize === undefined) {
+			var scrollDiv = document.createElement("div");
+			scrollDiv.style.cssText = 'width: 99px; height: 99px; overflow: scroll; position: absolute; top: -9999px;';
+			document.body.appendChild(scrollDiv);
+			mfp.scrollbarSize = scrollDiv.offsetWidth - scrollDiv.clientWidth;
+			document.body.removeChild(scrollDiv);
+		}
+		return mfp.scrollbarSize;
+	}
+
+}; /* MagnificPopup core prototype end */
+
+
+
+
+/**
+ * Public static functions
+ */
+$.magnificPopup = {
+	instance: null,
+	proto: MagnificPopup.prototype,
+	modules: [],
+
+	open: function(options, index) {
+		_checkInstance();	
+
+		if(!options) {
+			options = {};
+		} else {
+			options = $.extend(true, {}, options);
+		}
+			
+
+		options.isObj = true;
+		options.index = index || 0;
+		return this.instance.open(options);
+	},
+
+	close: function() {
+		return $.magnificPopup.instance && $.magnificPopup.instance.close();
+	},
+
+	registerModule: function(name, module) {
+		if(module.options) {
+			$.magnificPopup.defaults[name] = module.options;
+		}
+		$.extend(this.proto, module.proto);			
+		this.modules.push(name);
+	},
+
+	defaults: {   
+
+		// Info about options is in docs:
+		// http://dimsemenov.com/plugins/magnific-popup/documentation.html#options
+		
+		disableOn: 0,	
+
+		key: null,
+
+		midClick: false,
+
+		mainClass: '',
+
+		preloader: true,
+
+		focus: '', // CSS selector of input to focus after popup is opened
+		
+		closeOnContentClick: false,
+
+		closeOnBgClick: true,
+
+		closeBtnInside: true, 
+
+		showCloseBtn: true,
+
+		enableEscapeKey: true,
+
+		modal: false,
+
+		alignTop: false,
+	
+		removalDelay: 0,
+
+		prependTo: null,
+		
+		fixedContentPos: 'auto', 
+	
+		fixedBgPos: 'auto',
+
+		overflowY: 'auto',
+
+		closeMarkup: '<button title="%title%" type="button" class="mfp-close">&times;</button>',
+
+		tClose: 'Close (Esc)',
+
+		tLoading: 'Loading...'
+
+	}
+};
+
+
+
+$.fn.magnificPopup = function(options) {
+	_checkInstance();
+
+	var jqEl = $(this);
+
+	// We call some API method of first param is a string
+	if (typeof options === "string" ) {
+
+		if(options === 'open') {
+			var items,
+				itemOpts = _isJQ ? jqEl.data('magnificPopup') : jqEl[0].magnificPopup,
+				index = parseInt(arguments[1], 10) || 0;
+
+			if(itemOpts.items) {
+				items = itemOpts.items[index];
+			} else {
+				items = jqEl;
+				if(itemOpts.delegate) {
+					items = items.find(itemOpts.delegate);
+				}
+				items = items.eq( index );
+			}
+			mfp._openClick({mfpEl:items}, jqEl, itemOpts);
+		} else {
+			if(mfp.isOpen)
+				mfp[options].apply(mfp, Array.prototype.slice.call(arguments, 1));
+		}
+
+	} else {
+		// clone options obj
+		options = $.extend(true, {}, options);
+		
+		/*
+		 * As Zepto doesn't support .data() method for objects 
+		 * and it works only in normal browsers
+		 * we assign "options" object directly to the DOM element. FTW!
+		 */
+		if(_isJQ) {
+			jqEl.data('magnificPopup', options);
+		} else {
+			jqEl[0].magnificPopup = options;
+		}
+
+		mfp.addGroup(jqEl, options);
+
+	}
+	return jqEl;
+};
+
+
+//Quick benchmark
+/*
+var start = performance.now(),
+	i,
+	rounds = 1000;
+
+for(i = 0; i < rounds; i++) {
+
+}
+console.log('Test #1:', performance.now() - start);
+
+start = performance.now();
+for(i = 0; i < rounds; i++) {
+
+}
+console.log('Test #2:', performance.now() - start);
+*/
+
+
+/*>>core*/
+
+/*>>inline*/
+
+var INLINE_NS = 'inline',
+	_hiddenClass,
+	_inlinePlaceholder, 
+	_lastInlineElement,
+	_putInlineElementsBack = function() {
+		if(_lastInlineElement) {
+			_inlinePlaceholder.after( _lastInlineElement.addClass(_hiddenClass) ).detach();
+			_lastInlineElement = null;
+		}
+	};
+
+$.magnificPopup.registerModule(INLINE_NS, {
+	options: {
+		hiddenClass: 'hide', // will be appended with `mfp-` prefix
+		markup: '',
+		tNotFound: 'Content not found'
+	},
+	proto: {
+
+		initInline: function() {
+			mfp.types.push(INLINE_NS);
+
+			_mfpOn(CLOSE_EVENT+'.'+INLINE_NS, function() {
+				_putInlineElementsBack();
+			});
+		},
+
+		getInline: function(item, template) {
+
+			_putInlineElementsBack();
+
+			if(item.src) {
+				var inlineSt = mfp.st.inline,
+					el = $(item.src);
+
+				if(el.length) {
+
+					// If target element has parent - we replace it with placeholder and put it back after popup is closed
+					var parent = el[0].parentNode;
+					if(parent && parent.tagName) {
+						if(!_inlinePlaceholder) {
+							_hiddenClass = inlineSt.hiddenClass;
+							_inlinePlaceholder = _getEl(_hiddenClass);
+							_hiddenClass = 'mfp-'+_hiddenClass;
+						}
+						// replace target inline element with placeholder
+						_lastInlineElement = el.after(_inlinePlaceholder).detach().removeClass(_hiddenClass);
+					}
+
+					mfp.updateStatus('ready');
+				} else {
+					mfp.updateStatus('error', inlineSt.tNotFound);
+					el = $('<div>');
+				}
+
+				item.inlineElement = el;
+				return el;
+			}
+
+			mfp.updateStatus('ready');
+			mfp._parseMarkup(template, {}, item);
+			return template;
+		}
+	}
+});
+
+/*>>inline*/
+
+/*>>ajax*/
+var AJAX_NS = 'ajax',
+	_ajaxCur,
+	_removeAjaxCursor = function() {
+		if(_ajaxCur) {
+			$(document.body).removeClass(_ajaxCur);
+		}
+	},
+	_destroyAjaxRequest = function() {
+		_removeAjaxCursor();
+		if(mfp.req) {
+			mfp.req.abort();
+		}
+	};
+
+$.magnificPopup.registerModule(AJAX_NS, {
+
+	options: {
+		settings: null,
+		cursor: 'mfp-ajax-cur',
+		tError: '<a href="%url%">The content</a> could not be loaded.'
+	},
+
+	proto: {
+		initAjax: function() {
+			mfp.types.push(AJAX_NS);
+			_ajaxCur = mfp.st.ajax.cursor;
+
+			_mfpOn(CLOSE_EVENT+'.'+AJAX_NS, _destroyAjaxRequest);
+			_mfpOn('BeforeChange.' + AJAX_NS, _destroyAjaxRequest);
+		},
+		getAjax: function(item) {
+
+			if(_ajaxCur) {
+				$(document.body).addClass(_ajaxCur);
+			}
+
+			mfp.updateStatus('loading');
+
+			var opts = $.extend({
+				url: item.src,
+				success: function(data, textStatus, jqXHR) {
+					var temp = {
+						data:data,
+						xhr:jqXHR
+					};
+
+					_mfpTrigger('ParseAjax', temp);
+
+					mfp.appendContent( $(temp.data), AJAX_NS );
+
+					item.finished = true;
+
+					_removeAjaxCursor();
+
+					mfp._setFocus();
+
+					setTimeout(function() {
+						mfp.wrap.addClass(READY_CLASS);
+					}, 16);
+
+					mfp.updateStatus('ready');
+
+					_mfpTrigger('AjaxContentAdded');
+				},
+				error: function() {
+					_removeAjaxCursor();
+					item.finished = item.loadError = true;
+					mfp.updateStatus('error', mfp.st.ajax.tError.replace('%url%', item.src));
+				}
+			}, mfp.st.ajax.settings);
+
+			mfp.req = $.ajax(opts);
+
+			return '';
+		}
+	}
+});
+
+
+
+
+
+	
+
+/*>>ajax*/
+
+/*>>image*/
+var _imgInterval,
+	_getTitle = function(item) {
+		if(item.data && item.data.title !== undefined) 
+			return item.data.title;
+
+		var src = mfp.st.image.titleSrc;
+
+		if(src) {
+			if($.isFunction(src)) {
+				return src.call(mfp, item);
+			} else if(item.el) {
+				return item.el.attr(src) || '';
+			}
+		}
+		return '';
+	};
+
+$.magnificPopup.registerModule('image', {
+
+	options: {
+		markup: '<div class="mfp-figure">'+
+					'<div class="mfp-close"></div>'+
+					'<figure>'+
+						'<div class="mfp-img"></div>'+
+						'<figcaption>'+
+							'<div class="mfp-bottom-bar">'+
+								'<div class="mfp-title"></div>'+
+								'<div class="mfp-counter"></div>'+
+							'</div>'+
+						'</figcaption>'+
+					'</figure>'+
+				'</div>',
+		cursor: 'mfp-zoom-out-cur',
+		titleSrc: 'title', 
+		verticalFit: true,
+		tError: '<a href="%url%">The image</a> could not be loaded.'
+	},
+
+	proto: {
+		initImage: function() {
+			var imgSt = mfp.st.image,
+				ns = '.image';
+
+			mfp.types.push('image');
+
+			_mfpOn(OPEN_EVENT+ns, function() {
+				if(mfp.currItem.type === 'image' && imgSt.cursor) {
+					$(document.body).addClass(imgSt.cursor);
+				}
+			});
+
+			_mfpOn(CLOSE_EVENT+ns, function() {
+				if(imgSt.cursor) {
+					$(document.body).removeClass(imgSt.cursor);
+				}
+				_window.off('resize' + EVENT_NS);
+			});
+
+			_mfpOn('Resize'+ns, mfp.resizeImage);
+			if(mfp.isLowIE) {
+				_mfpOn('AfterChange', mfp.resizeImage);
+			}
+		},
+		resizeImage: function() {
+			var item = mfp.currItem;
+			if(!item || !item.img) return;
+
+			if(mfp.st.image.verticalFit) {
+				var decr = 0;
+				// fix box-sizing in ie7/8
+				if(mfp.isLowIE) {
+					decr = parseInt(item.img.css('padding-top'), 10) + parseInt(item.img.css('padding-bottom'),10);
+				}
+				item.img.css('max-height', mfp.wH-decr);
+			}
+		},
+		_onImageHasSize: function(item) {
+			if(item.img) {
+				
+				item.hasSize = true;
+
+				if(_imgInterval) {
+					clearInterval(_imgInterval);
+				}
+				
+				item.isCheckingImgSize = false;
+
+				_mfpTrigger('ImageHasSize', item);
+
+				if(item.imgHidden) {
+					if(mfp.content)
+						mfp.content.removeClass('mfp-loading');
+					
+					item.imgHidden = false;
+				}
+
+			}
+		},
+
+		/**
+		 * Function that loops until the image has size to display elements that rely on it asap
+		 */
+		findImageSize: function(item) {
+
+			var counter = 0,
+				img = item.img[0],
+				mfpSetInterval = function(delay) {
+
+					if(_imgInterval) {
+						clearInterval(_imgInterval);
+					}
+					// decelerating interval that checks for size of an image
+					_imgInterval = setInterval(function() {
+						if(img.naturalWidth > 0) {
+							mfp._onImageHasSize(item);
+							return;
+						}
+
+						if(counter > 200) {
+							clearInterval(_imgInterval);
+						}
+
+						counter++;
+						if(counter === 3) {
+							mfpSetInterval(10);
+						} else if(counter === 40) {
+							mfpSetInterval(50);
+						} else if(counter === 100) {
+							mfpSetInterval(500);
+						}
+					}, delay);
+				};
+
+			mfpSetInterval(1);
+		},
+
+		getImage: function(item, template) {
+
+			var guard = 0,
+
+				// image load complete handler
+				onLoadComplete = function() {
+					if(item) {
+						if (item.img[0].complete) {
+							item.img.off('.mfploader');
+							
+							if(item === mfp.currItem){
+								mfp._onImageHasSize(item);
+
+								mfp.updateStatus('ready');
+							}
+
+							item.hasSize = true;
+							item.loaded = true;
+
+							_mfpTrigger('ImageLoadComplete');
+							
+						}
+						else {
+							// if image complete check fails 200 times (20 sec), we assume that there was an error.
+							guard++;
+							if(guard < 200) {
+								setTimeout(onLoadComplete,100);
+							} else {
+								onLoadError();
+							}
+						}
+					}
+				},
+
+				// image error handler
+				onLoadError = function() {
+					if(item) {
+						item.img.off('.mfploader');
+						if(item === mfp.currItem){
+							mfp._onImageHasSize(item);
+							mfp.updateStatus('error', imgSt.tError.replace('%url%', item.src) );
+						}
+
+						item.hasSize = true;
+						item.loaded = true;
+						item.loadError = true;
+					}
+				},
+				imgSt = mfp.st.image;
+
+
+			var el = template.find('.mfp-img');
+			if(el.length) {
+				var img = document.createElement('img');
+				img.className = 'mfp-img';
+				if(item.el && item.el.find('img').length) {
+					img.alt = item.el.find('img').attr('alt');
+				}
+				item.img = $(img).on('load.mfploader', onLoadComplete).on('error.mfploader', onLoadError);
+				img.src = item.src;
+
+				// without clone() "error" event is not firing when IMG is replaced by new IMG
+				// TODO: find a way to avoid such cloning
+				if(el.is('img')) {
+					item.img = item.img.clone();
+				}
+
+				img = item.img[0];
+				if(img.naturalWidth > 0) {
+					item.hasSize = true;
+				} else if(!img.width) {										
+					item.hasSize = false;
+				}
+			}
+
+			mfp._parseMarkup(template, {
+				title: _getTitle(item),
+				img_replaceWith: item.img
+			}, item);
+
+			mfp.resizeImage();
+
+			if(item.hasSize) {
+				if(_imgInterval) clearInterval(_imgInterval);
+
+				if(item.loadError) {
+					template.addClass('mfp-loading');
+					mfp.updateStatus('error', imgSt.tError.replace('%url%', item.src) );
+				} else {
+					template.removeClass('mfp-loading');
+					mfp.updateStatus('ready');
+				}
+				return template;
+			}
+
+			mfp.updateStatus('loading');
+			item.loading = true;
+
+			if(!item.hasSize) {
+				item.imgHidden = true;
+				template.addClass('mfp-loading');
+				mfp.findImageSize(item);
+			} 
+
+			return template;
+		}
+	}
+});
+
+
+
+/*>>image*/
+
+/*>>zoom*/
+var hasMozTransform,
+	getHasMozTransform = function() {
+		if(hasMozTransform === undefined) {
+			hasMozTransform = document.createElement('p').style.MozTransform !== undefined;
+		}
+		return hasMozTransform;		
+	};
+
+$.magnificPopup.registerModule('zoom', {
+
+	options: {
+		enabled: false,
+		easing: 'ease-in-out',
+		duration: 300,
+		opener: function(element) {
+			return element.is('img') ? element : element.find('img');
+		}
+	},
+
+	proto: {
+
+		initZoom: function() {
+			var zoomSt = mfp.st.zoom,
+				ns = '.zoom',
+				image;
+				
+			if(!zoomSt.enabled || !mfp.supportsTransition) {
+				return;
+			}
+
+			var duration = zoomSt.duration,
+				getElToAnimate = function(image) {
+					var newImg = image.clone().removeAttr('style').removeAttr('class').addClass('mfp-animated-image'),
+						transition = 'all '+(zoomSt.duration/1000)+'s ' + zoomSt.easing,
+						cssObj = {
+							position: 'fixed',
+							zIndex: 9999,
+							left: 0,
+							top: 0,
+							'-webkit-backface-visibility': 'hidden'
+						},
+						t = 'transition';
+
+					cssObj['-webkit-'+t] = cssObj['-moz-'+t] = cssObj['-o-'+t] = cssObj[t] = transition;
+
+					newImg.css(cssObj);
+					return newImg;
+				},
+				showMainContent = function() {
+					mfp.content.css('visibility', 'visible');
+				},
+				openTimeout,
+				animatedImg;
+
+			_mfpOn('BuildControls'+ns, function() {
+				if(mfp._allowZoom()) {
+
+					clearTimeout(openTimeout);
+					mfp.content.css('visibility', 'hidden');
+
+					// Basically, all code below does is clones existing image, puts in on top of the current one and animated it
+					
+					image = mfp._getItemToZoom();
+
+					if(!image) {
+						showMainContent();
+						return;
+					}
+
+					animatedImg = getElToAnimate(image); 
+					
+					animatedImg.css( mfp._getOffset() );
+
+					mfp.wrap.append(animatedImg);
+
+					openTimeout = setTimeout(function() {
+						animatedImg.css( mfp._getOffset( true ) );
+						openTimeout = setTimeout(function() {
+
+							showMainContent();
+
+							setTimeout(function() {
+								animatedImg.remove();
+								image = animatedImg = null;
+								_mfpTrigger('ZoomAnimationEnded');
+							}, 16); // avoid blink when switching images 
+
+						}, duration); // this timeout equals animation duration
+
+					}, 16); // by adding this timeout we avoid short glitch at the beginning of animation
+
+
+					// Lots of timeouts...
+				}
+			});
+			_mfpOn(BEFORE_CLOSE_EVENT+ns, function() {
+				if(mfp._allowZoom()) {
+
+					clearTimeout(openTimeout);
+
+					mfp.st.removalDelay = duration;
+
+					if(!image) {
+						image = mfp._getItemToZoom();
+						if(!image) {
+							return;
+						}
+						animatedImg = getElToAnimate(image);
+					}
+					
+					
+					animatedImg.css( mfp._getOffset(true) );
+					mfp.wrap.append(animatedImg);
+					mfp.content.css('visibility', 'hidden');
+					
+					setTimeout(function() {
+						animatedImg.css( mfp._getOffset() );
+					}, 16);
+				}
+
+			});
+
+			_mfpOn(CLOSE_EVENT+ns, function() {
+				if(mfp._allowZoom()) {
+					showMainContent();
+					if(animatedImg) {
+						animatedImg.remove();
+					}
+					image = null;
+				}	
+			});
+		},
+
+		_allowZoom: function() {
+			return mfp.currItem.type === 'image';
+		},
+
+		_getItemToZoom: function() {
+			if(mfp.currItem.hasSize) {
+				return mfp.currItem.img;
+			} else {
+				return false;
+			}
+		},
+
+		// Get element postion relative to viewport
+		_getOffset: function(isLarge) {
+			var el;
+			if(isLarge) {
+				el = mfp.currItem.img;
+			} else {
+				el = mfp.st.zoom.opener(mfp.currItem.el || mfp.currItem);
+			}
+
+			var offset = el.offset();
+			var paddingTop = parseInt(el.css('padding-top'),10);
+			var paddingBottom = parseInt(el.css('padding-bottom'),10);
+			offset.top -= ( $(window).scrollTop() - paddingTop );
+
+
+			/*
+			
+			Animating left + top + width/height looks glitchy in Firefox, but perfect in Chrome. And vice-versa.
+
+			 */
+			var obj = {
+				width: el.width(),
+				// fix Zepto height+padding issue
+				height: (_isJQ ? el.innerHeight() : el[0].offsetHeight) - paddingBottom - paddingTop
+			};
+
+			// I hate to do this, but there is no another option
+			if( getHasMozTransform() ) {
+				obj['-moz-transform'] = obj['transform'] = 'translate(' + offset.left + 'px,' + offset.top + 'px)';
+			} else {
+				obj.left = offset.left;
+				obj.top = offset.top;
+			}
+			return obj;
+		}
+
+	}
+});
+
+
+
+/*>>zoom*/
+
+/*>>iframe*/
+
+var IFRAME_NS = 'iframe',
+	_emptyPage = '//about:blank',
+	
+	_fixIframeBugs = function(isShowing) {
+		if(mfp.currTemplate[IFRAME_NS]) {
+			var el = mfp.currTemplate[IFRAME_NS].find('iframe');
+			if(el.length) { 
+				// reset src after the popup is closed to avoid "video keeps playing after popup is closed" bug
+				if(!isShowing) {
+					el[0].src = _emptyPage;
+				}
+
+				// IE8 black screen bug fix
+				if(mfp.isIE8) {
+					el.css('display', isShowing ? 'block' : 'none');
+				}
+			}
+		}
+	};
+
+$.magnificPopup.registerModule(IFRAME_NS, {
+
+	options: {
+		markup: '<div class="mfp-iframe-scaler">'+
+					'<div class="mfp-close"></div>'+
+					'<iframe class="mfp-iframe" src="//about:blank" frameborder="0" allowfullscreen></iframe>'+
+				'</div>',
+
+		srcAction: 'iframe_src',
+
+		// we don't care and support only one default type of URL by default
+		patterns: {
+			youtube: {
+				index: 'youtube.com', 
+				id: 'v=', 
+				src: '//www.youtube.com/embed/%id%?autoplay=1'
+			},
+			vimeo: {
+				index: 'vimeo.com/',
+				id: '/',
+				src: '//player.vimeo.com/video/%id%?autoplay=1'
+			},
+			gmaps: {
+				index: '//maps.google.',
+				src: '%id%&output=embed'
+			}
+		}
+	},
+
+	proto: {
+		initIframe: function() {
+			mfp.types.push(IFRAME_NS);
+
+			_mfpOn('BeforeChange', function(e, prevType, newType) {
+				if(prevType !== newType) {
+					if(prevType === IFRAME_NS) {
+						_fixIframeBugs(); // iframe if removed
+					} else if(newType === IFRAME_NS) {
+						_fixIframeBugs(true); // iframe is showing
+					} 
+				}// else {
+					// iframe source is switched, don't do anything
+				//}
+			});
+
+			_mfpOn(CLOSE_EVENT + '.' + IFRAME_NS, function() {
+				_fixIframeBugs();
+			});
+		},
+
+		getIframe: function(item, template) {
+			var embedSrc = item.src;
+			var iframeSt = mfp.st.iframe;
+				
+			$.each(iframeSt.patterns, function() {
+				if(embedSrc.indexOf( this.index ) > -1) {
+					if(this.id) {
+						if(typeof this.id === 'string') {
+							embedSrc = embedSrc.substr(embedSrc.lastIndexOf(this.id)+this.id.length, embedSrc.length);
+						} else {
+							embedSrc = this.id.call( this, embedSrc );
+						}
+					}
+					embedSrc = this.src.replace('%id%', embedSrc );
+					return false; // break;
+				}
+			});
+			
+			var dataObj = {};
+			if(iframeSt.srcAction) {
+				dataObj[iframeSt.srcAction] = embedSrc;
+			}
+			mfp._parseMarkup(template, dataObj, item);
+
+			mfp.updateStatus('ready');
+
+			return template;
+		}
+	}
+});
+
+
+
+/*>>iframe*/
+
+/*>>gallery*/
+/**
+ * Get looped index depending on number of slides
+ */
+var _getLoopedId = function(index) {
+		var numSlides = mfp.items.length;
+		if(index > numSlides - 1) {
+			return index - numSlides;
+		} else  if(index < 0) {
+			return numSlides + index;
+		}
+		return index;
+	},
+	_replaceCurrTotal = function(text, curr, total) {
+		return text.replace(/%curr%/gi, curr + 1).replace(/%total%/gi, total);
+	};
+
+$.magnificPopup.registerModule('gallery', {
+
+	options: {
+		enabled: false,
+		arrowMarkup: '<button title="%title%" type="button" class="mfp-arrow mfp-arrow-%dir%"></button>',
+		preload: [0,2],
+		navigateByImgClick: true,
+		arrows: true,
+
+		tPrev: 'Previous (Left arrow key)',
+		tNext: 'Next (Right arrow key)',
+		tCounter: '%curr% of %total%'
+	},
+
+	proto: {
+		initGallery: function() {
+
+			var gSt = mfp.st.gallery,
+				ns = '.mfp-gallery',
+				supportsFastClick = Boolean($.fn.mfpFastClick);
+
+			mfp.direction = true; // true - next, false - prev
+			
+			if(!gSt || !gSt.enabled ) return false;
+
+			_wrapClasses += ' mfp-gallery';
+
+			_mfpOn(OPEN_EVENT+ns, function() {
+
+				if(gSt.navigateByImgClick) {
+					mfp.wrap.on('click'+ns, '.mfp-img', function() {
+						if(mfp.items.length > 1) {
+							mfp.next();
+							return false;
+						}
+					});
+				}
+
+				_document.on('keydown'+ns, function(e) {
+					if (e.keyCode === 37) {
+						mfp.prev();
+					} else if (e.keyCode === 39) {
+						mfp.next();
+					}
+				});
+			});
+
+			_mfpOn('UpdateStatus'+ns, function(e, data) {
+				if(data.text) {
+					data.text = _replaceCurrTotal(data.text, mfp.currItem.index, mfp.items.length);
+				}
+			});
+
+			_mfpOn(MARKUP_PARSE_EVENT+ns, function(e, element, values, item) {
+				var l = mfp.items.length;
+				values.counter = l > 1 ? _replaceCurrTotal(gSt.tCounter, item.index, l) : '';
+			});
+
+			_mfpOn('BuildControls' + ns, function() {
+				if(mfp.items.length > 1 && gSt.arrows && !mfp.arrowLeft) {
+					var markup = gSt.arrowMarkup,
+						arrowLeft = mfp.arrowLeft = $( markup.replace(/%title%/gi, gSt.tPrev).replace(/%dir%/gi, 'left') ).addClass(PREVENT_CLOSE_CLASS),			
+						arrowRight = mfp.arrowRight = $( markup.replace(/%title%/gi, gSt.tNext).replace(/%dir%/gi, 'right') ).addClass(PREVENT_CLOSE_CLASS);
+
+					var eName = supportsFastClick ? 'mfpFastClick' : 'click';
+					arrowLeft[eName](function() {
+						mfp.prev();
+					});			
+					arrowRight[eName](function() {
+						mfp.next();
+					});	
+
+					// Polyfill for :before and :after (adds elements with classes mfp-a and mfp-b)
+					if(mfp.isIE7) {
+						_getEl('b', arrowLeft[0], false, true);
+						_getEl('a', arrowLeft[0], false, true);
+						_getEl('b', arrowRight[0], false, true);
+						_getEl('a', arrowRight[0], false, true);
+					}
+
+					mfp.container.append(arrowLeft.add(arrowRight));
+				}
+			});
+
+			_mfpOn(CHANGE_EVENT+ns, function() {
+				if(mfp._preloadTimeout) clearTimeout(mfp._preloadTimeout);
+
+				mfp._preloadTimeout = setTimeout(function() {
+					mfp.preloadNearbyImages();
+					mfp._preloadTimeout = null;
+				}, 16);		
+			});
+
+
+			_mfpOn(CLOSE_EVENT+ns, function() {
+				_document.off(ns);
+				mfp.wrap.off('click'+ns);
+			
+				if(mfp.arrowLeft && supportsFastClick) {
+					mfp.arrowLeft.add(mfp.arrowRight).destroyMfpFastClick();
+				}
+				mfp.arrowRight = mfp.arrowLeft = null;
+			});
+
+		}, 
+		next: function() {
+			mfp.direction = true;
+			mfp.index = _getLoopedId(mfp.index + 1);
+			mfp.updateItemHTML();
+		},
+		prev: function() {
+			mfp.direction = false;
+			mfp.index = _getLoopedId(mfp.index - 1);
+			mfp.updateItemHTML();
+		},
+		goTo: function(newIndex) {
+			mfp.direction = (newIndex >= mfp.index);
+			mfp.index = newIndex;
+			mfp.updateItemHTML();
+		},
+		preloadNearbyImages: function() {
+			var p = mfp.st.gallery.preload,
+				preloadBefore = Math.min(p[0], mfp.items.length),
+				preloadAfter = Math.min(p[1], mfp.items.length),
+				i;
+
+			for(i = 1; i <= (mfp.direction ? preloadAfter : preloadBefore); i++) {
+				mfp._preloadItem(mfp.index+i);
+			}
+			for(i = 1; i <= (mfp.direction ? preloadBefore : preloadAfter); i++) {
+				mfp._preloadItem(mfp.index-i);
+			}
+		},
+		_preloadItem: function(index) {
+			index = _getLoopedId(index);
+
+			if(mfp.items[index].preloaded) {
+				return;
+			}
+
+			var item = mfp.items[index];
+			if(!item.parsed) {
+				item = mfp.parseEl( index );
+			}
+
+			_mfpTrigger('LazyLoad', item);
+
+			if(item.type === 'image') {
+				item.img = $('<img class="mfp-img" />').on('load.mfploader', function() {
+					item.hasSize = true;
+				}).on('error.mfploader', function() {
+					item.hasSize = true;
+					item.loadError = true;
+					_mfpTrigger('LazyLoadError', item);
+				}).attr('src', item.src);
+			}
+
+
+			item.preloaded = true;
+		}
+	}
+});
+
+/*
+Touch Support that might be implemented some day
+
+addSwipeGesture: function() {
+	var startX,
+		moved,
+		multipleTouches;
+
+		return;
+
+	var namespace = '.mfp',
+		addEventNames = function(pref, down, move, up, cancel) {
+			mfp._tStart = pref + down + namespace;
+			mfp._tMove = pref + move + namespace;
+			mfp._tEnd = pref + up + namespace;
+			mfp._tCancel = pref + cancel + namespace;
+		};
+
+	if(window.navigator.msPointerEnabled) {
+		addEventNames('MSPointer', 'Down', 'Move', 'Up', 'Cancel');
+	} else if('ontouchstart' in window) {
+		addEventNames('touch', 'start', 'move', 'end', 'cancel');
+	} else {
+		return;
+	}
+	_window.on(mfp._tStart, function(e) {
+		var oE = e.originalEvent;
+		multipleTouches = moved = false;
+		startX = oE.pageX || oE.changedTouches[0].pageX;
+	}).on(mfp._tMove, function(e) {
+		if(e.originalEvent.touches.length > 1) {
+			multipleTouches = e.originalEvent.touches.length;
+		} else {
+			//e.preventDefault();
+			moved = true;
+		}
+	}).on(mfp._tEnd + ' ' + mfp._tCancel, function(e) {
+		if(moved && !multipleTouches) {
+			var oE = e.originalEvent,
+				diff = startX - (oE.pageX || oE.changedTouches[0].pageX);
+
+			if(diff > 20) {
+				mfp.next();
+			} else if(diff < -20) {
+				mfp.prev();
+			}
+		}
+	});
+},
+*/
+
+
+/*>>gallery*/
+
+/*>>retina*/
+
+var RETINA_NS = 'retina';
+
+$.magnificPopup.registerModule(RETINA_NS, {
+	options: {
+		replaceSrc: function(item) {
+			return item.src.replace(/\.\w+$/, function(m) { return '@2x' + m; });
+		},
+		ratio: 1 // Function or number.  Set to 1 to disable.
+	},
+	proto: {
+		initRetina: function() {
+			if(window.devicePixelRatio > 1) {
+
+				var st = mfp.st.retina,
+					ratio = st.ratio;
+
+				ratio = !isNaN(ratio) ? ratio : ratio();
+
+				if(ratio > 1) {
+					_mfpOn('ImageHasSize' + '.' + RETINA_NS, function(e, item) {
+						item.img.css({
+							'max-width': item.img[0].naturalWidth / ratio,
+							'width': '100%'
+						});
+					});
+					_mfpOn('ElementParse' + '.' + RETINA_NS, function(e, item) {
+						item.src = st.replaceSrc(item, ratio);
+					});
+				}
+			}
+
+		}
+	}
+});
+
+/*>>retina*/
+
+/*>>fastclick*/
+/**
+ * FastClick event implementation. (removes 300ms delay on touch devices)
+ * Based on https://developers.google.com/mobile/articles/fast_buttons
+ *
+ * You may use it outside the Magnific Popup by calling just:
+ *
+ * $('.your-el').mfpFastClick(function() {
+ *     console.log('Clicked!');
+ * });
+ *
+ * To unbind:
+ * $('.your-el').destroyMfpFastClick();
+ * 
+ * 
+ * Note that it's a very basic and simple implementation, it blocks ghost click on the same element where it was bound.
+ * If you need something more advanced, use plugin by FT Labs https://github.com/ftlabs/fastclick
+ * 
+ */
+
+(function() {
+	var ghostClickDelay = 1000,
+		supportsTouch = 'ontouchstart' in window,
+		unbindTouchMove = function() {
+			_window.off('touchmove'+ns+' touchend'+ns);
+		},
+		eName = 'mfpFastClick',
+		ns = '.'+eName;
+
+
+	// As Zepto.js doesn't have an easy way to add custom events (like jQuery), so we implement it in this way
+	$.fn.mfpFastClick = function(callback) {
+
+		return $(this).each(function() {
+
+			var elem = $(this),
+				lock;
+
+			if( supportsTouch ) {
+
+				var timeout,
+					startX,
+					startY,
+					pointerMoved,
+					point,
+					numPointers;
+
+				elem.on('touchstart' + ns, function(e) {
+					pointerMoved = false;
+					numPointers = 1;
+
+					point = e.originalEvent ? e.originalEvent.touches[0] : e.touches[0];
+					startX = point.clientX;
+					startY = point.clientY;
+
+					_window.on('touchmove'+ns, function(e) {
+						point = e.originalEvent ? e.originalEvent.touches : e.touches;
+						numPointers = point.length;
+						point = point[0];
+						if (Math.abs(point.clientX - startX) > 10 ||
+							Math.abs(point.clientY - startY) > 10) {
+							pointerMoved = true;
+							unbindTouchMove();
+						}
+					}).on('touchend'+ns, function(e) {
+						unbindTouchMove();
+						if(pointerMoved || numPointers > 1) {
+							return;
+						}
+						lock = true;
+						e.preventDefault();
+						clearTimeout(timeout);
+						timeout = setTimeout(function() {
+							lock = false;
+						}, ghostClickDelay);
+						callback();
+					});
+				});
+
+			}
+
+			elem.on('click' + ns, function() {
+				if(!lock) {
+					callback();
+				}
+			});
+		});
+	};
+
+	$.fn.destroyMfpFastClick = function() {
+		$(this).off('touchstart' + ns + ' click' + ns);
+		if(supportsTouch) _window.off('touchmove'+ns+' touchend'+ns);
+	};
+})();
+
+/*>>fastclick*/
+ _checkInstance(); }));

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 3 - 0
datamodels/2.x/itop-attachments/js/jquery.magnific-popup.min.js


+ 115 - 16
datamodels/2.x/itop-attachments/main.attachments.php

@@ -1,5 +1,5 @@
 <?php
-// Copyright (C) 2010-2015 Combodo SARL
+// Copyright (C) 2010-2016 Combodo SARL
 //
 //   This file is part of iTop.
 //
@@ -16,7 +16,6 @@
 //   You should have received a copy of the GNU Affero General Public License
 //   along with iTop. If not, see <http://www.gnu.org/licenses/>
 
-
 class AttachmentPlugIn implements iApplicationUIExtension, iApplicationObjectExtension
 {
 	protected static $m_bIsModified = false;
@@ -204,6 +203,7 @@ class AttachmentPlugIn implements iApplicationUIExtension, iApplicationObjectExt
 			$sTitle = ($oSet->Count() > 0)? Dict::Format('Attachments:TabTitle_Count', $oSet->Count()) : Dict::S('Attachments:EmptyTabTitle');
 			$oPage->SetCurrentTab($sTitle);
 		}
+		$sMaxWidth = MetaModel::GetModuleSetting('itop-attachment', 'inline_image_max_width', '450px');
 		$oPage->add_style(
 <<<EOF
 .attachment {
@@ -233,6 +233,9 @@ class AttachmentPlugIn implements iApplicationUIExtension, iApplicationObjectExt
 	padding: 0;
 	float: none;
 }
+.inline-image {
+	cursor: zoom-in;
+}
 EOF
 		);
 		$oPage->add('<fieldset>');
@@ -243,15 +246,24 @@ EOF
 			$sIsDeleteEnabled = $this->m_bDeleteEnabled ? 'true' : 'false';
 			$iTransactionId = $oPage->GetTransactionId();
 			$sClass = get_class($oObject);
+			$iObjectId = $oObject->Getkey();
 			$sTempId = session_id().'_'.$iTransactionId;
 			$sDeleteBtn = Dict::S('Attachments:DeleteBtn');
 			$oPage->add_script(
 <<<EOF
 	function RemoveAttachment(att_id)
 	{
-		$('#attachment_'+att_id).attr('name', 'removed_attachments[]');
-		$('#display_attachment_'+att_id).hide();
-		$('#attachment_plugin').trigger('remove_attachment', [att_id]);
+		var bDelete = true;
+		if ($('#display_attachment_'+att_id).hasClass('image-in-use'))
+		{
+				bDelete = window.confirm('This image is used in a description. Delete it anyway?');
+		}
+		if (bDelete)
+		{
+			$('#attachment_'+att_id).attr('name', 'removed_attachments[]');
+			$('#display_attachment_'+att_id).hide();
+			$('#attachment_plugin').trigger('remove_attachment', [att_id]);
+		}
 		return false; // Do not submit the form !
 	}
 EOF
@@ -264,7 +276,7 @@ EOF
 				$sFileName = $oDoc->GetFileName();
 				$sIcon = utils::GetAbsoluteUrlAppRoot().AttachmentPlugIn::GetFileIcon($sFileName);
 				$sPreview = $oDoc->IsPreviewAvailable() ? 'true' : 'false';
-				$sDownloadLink = utils::GetAbsoluteUrlAppRoot().'pages/ajax.render.php?operation=download_document&class=Attachment&id='.$iAttId.'&field=contents';
+				$sDownloadLink = utils::GetAbsoluteUrlAppRoot().ATTACHMENT_DOWNLOAD_URL.$iAttId;
 				$oPage->add('<div class="attachment" id="display_attachment_'.$iAttId.'"><a data-preview="'.$sPreview.'" href="'.$sDownloadLink.'"><img src="'.$sIcon.'"><br/>'.$sFileName.'<input id="attachment_'.$iAttId.'" type="hidden" name="attachments[]" value="'.$iAttId.'"/></a><br/>&nbsp;<input id="btn_remove_'.$iAttId.'" type="button" class="btn_hidden" value="Delete" onClick="RemoveAttachment('.$iAttId.');"/>&nbsp;</div>');
 			}
 			
@@ -291,10 +303,10 @@ EOF
 						$oDoc = $oAttachment->Get('contents');
 						$sFileName = $oDoc->GetFileName();
 						$sIcon = utils::GetAbsoluteUrlAppRoot().AttachmentPlugIn::GetFileIcon($sFileName);
-						$sDownloadLink = utils::GetAbsoluteUrlAppRoot().'pages/ajax.render.php?operation=download_document&class=Attachment&id='.$iAttId.'&field=contents';
+						$sDownloadLink = utils::GetAbsoluteUrlAppRoot().ATTACHMENT_DOWNLOAD_URL.$iAttId;
 						$sPreview = $oDoc->IsPreviewAvailable() ? 'true' : 'false';
 						$oPage->add('<div class="attachment" id="display_attachment_'.$iAttId.'"><a data-preview="'.$sPreview.'" href="'.$sDownloadLink.'"><img src="'.$sIcon.'"><br/>'.$sFileName.'<input id="attachment_'.$iAttId.'" type="hidden" name="attachments[]" value="'.$iAttId.'"/></a><br/>&nbsp;<input id="btn_remove_'.$iAttId.'" type="button" class="btn_hidden" value="Delete" onClick="RemoveAttachment('.$iAttId.');"/>&nbsp;</div>');
-						$oPage->add_ready_script("$('#attachment_plugin').trigger('add_attachment', [$iAttId, '".addslashes($sFileName)."']);");
+						$oPage->add_ready_script("$('#attachment_plugin').trigger('add_attachment', [$iAttId, '".addslashes($sFileName)."', false /* not an line image */]);");
 					}
 				}
 			}
@@ -305,9 +317,21 @@ EOF
 			$oPage->p(Dict::S('Attachments:AddAttachment').'<input type="file" name="file" id="file"><span style="display:none;" id="attachment_loading">&nbsp;<img src="../images/indicator.gif"></span> '.$sMaxUpload);
 			
 			$oPage->add_linked_script(utils::GetAbsoluteUrlAppRoot().'js/jquery.iframe-transport.js');
-			$oPage->add_linked_script(utils::GetAbsoluteUrlAppRoot().'js/jquery.fileupload.js');
-
-$oPage->add_ready_script(
+			$oPage->add_linked_script(utils::GetAbsoluteUrlAppRoot().'js/jquery.fileupload.js');		
+			
+			$oPage->add_linked_stylesheet(utils::GetAbsoluteUrlModulesRoot().'itop-attachments/css/magnific-popup.css');
+			$oPage->add_linked_script(utils::GetAbsoluteUrlModulesRoot().'itop-attachments/js/jquery.magnific-popup.min.js');
+			$maxWidth = MetaModel::GetModuleSetting('itop-standard-email-synchro', 'inline_image_max_width', '');
+			if ($maxWidth !== '')
+			{
+				$sStyle = "style=\"max-width:{$maxWidth}px;cursor:zoom-in;\"";
+			}
+			else
+			{
+				$sStyle = "style=\"cursor:zoom-in;\"";
+			}
+			$sDownloadLink = utils::GetAbsoluteUrlAppRoot().ATTACHMENT_DOWNLOAD_URL;		
+			$oPage->add_ready_script(
 <<< EOF
     $('#file').fileupload({
 		url: GetAbsoluteUrlModulesRoot()+'itop-attachments/ajax.attachment.php',
@@ -323,13 +347,13 @@ $oPage->add_ready_script(
 				}
 				else
 				{
-					var sDownloadLink = GetAbsoluteUrlAppRoot()+'pages/ajax.render.php?operation=download_document&class=Attachment&id='+data.result.att_id+'&field=contents';
+					var sDownloadLink = '$sDownloadLink'+data.result.att_id;
 					$('#attachments').append('<div class="attachment" id="display_attachment_'+data.result.att_id+'"><a data-preview="'+data.result.preview+'" href="'+sDownloadLink+'"><img src="'+data.result.icon+'"><br/>'+data.result.msg+'<input id="attachment_'+data.result.att_id+'" type="hidden" name="attachments[]" value="'+data.result.att_id+'"/></a><br/><input type="button" class="btn_hidden" value="{$sDeleteBtn}" onClick="RemoveAttachment('+data.result.att_id+');"/></div>');
 					if($sIsDeleteEnabled)
 					{
 						$('#display_attachment_'+data.result.att_id).hover( function() { $(this).children(':button').toggleClass('btn_hidden'); } );
 					}
-					$('#attachment_plugin').trigger('add_attachment', [data.result.att_id, data.result.msg]);
+					$('#attachment_plugin').trigger('add_attachment', [data.result.att_id, data.result.msg, false /* inline image */]);
 				}
 			}
         },
@@ -380,7 +404,65 @@ $oPage->add_ready_script(
 	        window.dropZoneTimeout = null;
 	        dropZone.removeClass('drag_in');
 	    }, 300);
-	});   
+	});
+
+	// Hook the file upload of all CKEditor instances
+	$('.htmlEditor').each(function() {
+		var oEditor = $(this).ckeditorGet();
+		oEditor.config.extraPlugins = 'uploadimage';
+		oEditor.config.uploadUrl = GetAbsoluteUrlModulesRoot()+'itop-attachments/ajax.attachment.php';
+		oEditor.config.filebrowserBrowseUrl = GetAbsoluteUrlModulesRoot()+'itop-attachments/ajax.attachment.php?operation=cke_browse&temp_id=$sTempId&obj_class=$sClass&obj_key=$iObjectId';
+		oEditor.on( 'fileUploadResponse', function( evt ) {
+		    // Get XHR and response.
+		    var data = evt.data,
+		        xhr = data.fileLoader.xhr,
+		        response = xhr.responseText.split( '|' );
+				
+			var oValues = JSON.parse(response[0]);
+				
+			var sDownloadLink = '$sDownloadLink'+oValues.att_id;
+			$('#attachments').append('<div class="attachment" id="display_attachment_'+oValues.att_id+'"><a data-preview="'+oValues.preview+'" href="'+sDownloadLink+'"><img src="'+oValues.icon+'"><br/>'+oValues.msg+'<input id="attachment_'+oValues.att_id+'" type="hidden" name="attachments[]" value="'+oValues.att_id+'"/></a><br/><input type="button" class="btn_hidden" value="{$sDeleteBtn}" onClick="RemoveAttachment('+oValues.att_id+');"/></div>');
+			if(true)
+			{
+				$('#display_attachment_'+oValues.att_id).hover( function() { $(this).children(':button').toggleClass('btn_hidden'); } );
+			}
+			$('#attachment_plugin').trigger('add_attachment', [oValues.att_id, oValues.msg, true /* inline image */]);
+		} );
+				
+		oEditor.on( 'fileUploadRequest', function( evt ) {
+		    evt.data.fileLoader.uploadUrl += '?operation=cke_img_upload&temp_id=$sTempId&obj_class=$sClass';
+		}, null, null, 4 ); // Listener with priority 4 will be executed before priority 5.
+								
+			});
+			
+	$('img[data-att-id]').each(function() {
+		if ('$sMaxWidth' != '')
+		{
+			$(this).css({'max-width': '$sMaxWidth', width: '', height: '', 'max-height': ''});
+		}
+		$(this).addClass('inline-image').attr('href', $(this).attr('src'));
+	}).magnificPopup({type: 'image', closeOnContentClick: true });
+	
+	// check if the attachments are used by inline images
+	window.setTimeout( function() {
+		$('.attachment a').each(function() {
+			var sUrl = $(this).attr('href');
+			if($('img[src="'+sUrl+'"]').length > 0)
+			{
+				$(this).addClass('image-in-use').find('img').wrap('<div class="image-in-use-wrapper" style="position:relative;display:inline-block;"></div>');
+			}
+		});
+		$('.htmlEditor').each(function() {
+			var oEditor = $(this).ckeditorGet();
+			var sHtml = oEditor.getData();
+			var jElement = $('<div/>').html(sHtml).contents();
+			jElement.find('img').each(function() {
+				var sSrc = $(this).attr('src');
+				$('.attachment a[href="'+sSrc+'"]').parent().addClass('image-in-use').find('img').wrap('<div class="image-in-use-wrapper" style="position:relative;display:inline-block;"></div>');
+			});
+		});
+		$('.image-in-use-wrapper').append('<div style="position:absolute;top:0;left:0;"><img src="../images/transp-lock.png"></div>');
+	}, 200 );
 EOF
 );
 			$oPage->p('<span style="display:none;" id="attachment_loading">Loading, please wait...</span>');
@@ -406,7 +488,7 @@ EOF
 					$sFileName = $oDoc->GetFileName();
 					$sIcon = utils::GetAbsoluteUrlAppRoot().AttachmentPlugIn::GetFileIcon($sFileName);
 					$sPreview = $oDoc->IsPreviewAvailable() ? 'true' : 'false';
-					$sDownloadLink = utils::GetAbsoluteUrlAppRoot().'pages/ajax.render.php?operation=download_document&class=Attachment&id='.$iAttId.'&field=contents';
+					$sDownloadLink = utils::GetAbsoluteUrlAppRoot().ATTACHMENT_DOWNLOAD_URL.$iAttId;
 					$oPage->add('<div class="attachment" id="attachment_'.$iAttId.'"><a data-preview="'.$sPreview.'" href="'.$sDownloadLink.'"><img src="'.$sIcon.'"><br/>'.$sFileName.'</a><input type="hidden" name="attachments[]" value="'.$iAttId.'"/><br/>&nbsp;&nbsp;</div>');
 				}
 			}
@@ -415,7 +497,24 @@ EOF
 		$oPage->add('</fieldset>');
 		$sPreviewNotAvailable = addslashes(Dict::S('Attachments:PreviewNotAvailable'));
 		$iMaxWidth = MetaModel::GetModuleSetting('itop-attachments', 'preview_max_width', 290);
-		$oPage->add_ready_script("$(document).tooltip({ items: '.attachment a',  position: { my: 'left top', at: 'right top', using: function( position, feedback ) { $( this ).css( position ); }}, content: function() { if ($(this).attr('data-preview') == 'true') { return('<img style=\"max-width:{$iMaxWidth}px\" src=\"'+$(this).attr('href')+'\"></img>');} else { return '$sPreviewNotAvailable'; }}});");
+		$oPage->add_ready_script(
+<<<EOF
+	$(document).tooltip({
+		items: '.attachment a',
+		position: { my: 'left top', at: 'right top', using: function( position, feedback ) { $( this ).css( position ); }},
+		content: function() { if ($(this).attr('data-preview') == 'true') { return('<img style=\"max-width:{$iMaxWidth}px\" src=\"'+$(this).attr('href')+'\"></img>');} else { return '$sPreviewNotAvailable'; }}
+	});
+			
+	$('img[data-att-id]').each(function() {
+		if ('$sMaxWidth' != '')
+		{
+			$(this).css({'max-width': '$sMaxWidth', width: '', height: '', 'max-height': ''});
+		}
+		$(this).addClass('inline-image');
+		$(this).attr('href', $(this).attr('src'));
+	}).magnificPopup({type: 'image', closeOnContentClick: true });
+EOF
+		);
 	}
 
 	protected static function UpdateAttachments($oObject, $oChange = null)

+ 1 - 0
datamodels/2.x/itop-tickets/datamodel.itop-tickets.xml

@@ -130,6 +130,7 @@
           <sql>description</sql>
           <default_value/>
           <is_null_allowed>false</is_null_allowed>
+          <format>html</format>
         </field>
         <field id="start_date" xsi:type="AttributeDateTime">
           <always_load_in_tables>true</always_load_in_tables>

+ 2 - 1
dictionaries/cs.dictionary.itop.core.php

@@ -1,6 +1,6 @@
 <?php
 
-// Copyright (C) 2010-2014 Combodo SARL
+// Copyright (C) 2010-2016 Combodo SARL
 //
 //   This file is part of iTop.
 //
@@ -857,6 +857,7 @@ Dict::Add('CS CZ', 'Czech', 'Čeština', array(
     'Core:BulkExport:SpreadsheetOptions' => 'Možnosti tabulky',
     'Core:BulkExport:OptionNoLocalize' => 'Nepřekládat hodnoty číselníků',
     'Core:BulkExport:OptionLinkSets' => 'Zahrnout odkazované objekty',
+	'Core:BulkExport:OptionFormattedText' => 'Preserve text formatting~~',
     'Core:BulkExport:ScopeDefinition' => 'Definice objektů k exportu',
     'Core:BulkExportLabelOQLExpression' => 'Dotaz OQL:',
     'Core:BulkExportLabelPhrasebookEntry' => 'Query Phrasebook Entry:~~',

+ 2 - 1
dictionaries/da.dictionary.itop.core.php

@@ -1,5 +1,5 @@
 <?php
-// Copyright (C) 2010-2012 Combodo SARL
+// Copyright (C) 2010-2016 Combodo SARL
 //
 //   This file is part of iTop.
 //
@@ -2478,6 +2478,7 @@ Operators:<br/>
 	'Core:BulkExport:SpreadsheetOptions' => 'Spreadsheet Options~~',
 	'Core:BulkExport:OptionNoLocalize' => 'Do not localize the values (for Enumerated fields)~~',
 	'Core:BulkExport:OptionLinkSets' => 'Include linked objects~~',
+	'Core:BulkExport:OptionFormattedText' => 'Preserve text formatting~~',
 	'Core:BulkExport:ScopeDefinition' => 'Definition of the objects to export~~',
 	'Core:BulkExportLabelOQLExpression' => 'OQL Query:~~',
 	'Core:BulkExportLabelPhrasebookEntry' => 'Query Phrasebook Entry:~~',

+ 3 - 2
dictionaries/de.dictionary.itop.core.php

@@ -1,5 +1,5 @@
 <?php
-// Copyright (C) 2010-2012 Combodo SARL
+// Copyright (C) 2010-2016 Combodo SARL
 //
 //   This file is part of iTop.
 //
@@ -19,7 +19,7 @@
 /**
  * @author	Stephan Rosenke <stephan.rosenke@itomig.de>
 
- * @copyright   Copyright (C) 2010-2012 Combodo SARL
+ * @copyright   Copyright (C) 2010-2016 Combodo SARL
  * @licence	http://opensource.org/licenses/AGPL-3.0
  */
 
@@ -590,6 +590,7 @@ Operatoren:<br/>
 	'Core:BulkExport:SpreadsheetOptions' => 'Spreadsheet-Optionen',
 	'Core:BulkExport:OptionNoLocalize' => 'Werte von Aufzählungsfeldern nicht lokalisieren',
 	'Core:BulkExport:OptionLinkSets' => 'Include linked objects~~',
+	'Core:BulkExport:OptionFormattedText' => 'Preserve Textformatierung',
 	'Core:BulkExport:ScopeDefinition' => 'Definition der zu exportierenden Objekte',
 	'Core:BulkExportLabelOQLExpression' => 'OQL-Abfrage',
 	'Core:BulkExportLabelPhrasebookEntry' => 'Query-Bibliotheks-Eintrag:',

+ 3 - 2
dictionaries/dictionary.itop.core.php

@@ -1,5 +1,5 @@
 <?php
-// Copyright (C) 2010-2014 Combodo SARL
+// Copyright (C) 2010-2016 Combodo SARL
 //
 //   This file is part of iTop.
 //
@@ -20,7 +20,7 @@
 /**
  * Localized data
  *
- * @copyright   Copyright (C) 2010-2012 Combodo SARL
+ * @copyright   Copyright (C) 2010-2016 Combodo SARL
  * @license     http://opensource.org/licenses/AGPL-3.0
  */
 
@@ -842,6 +842,7 @@ Dict::Add('EN US', 'English', 'English', array(
 	'Core:BulkExport:SpreadsheetOptions' => 'Spreadsheet Options',
 	'Core:BulkExport:OptionNoLocalize' => 'Do not localize the values (for Enumerated fields)',
 	'Core:BulkExport:OptionLinkSets' => 'Include linked objects',
+	'Core:BulkExport:OptionFormattedText' => 'Preserve text formatting',
 	'Core:BulkExport:ScopeDefinition' => 'Definition of the objects to export',
 	'Core:BulkExportLabelOQLExpression' => 'OQL Query:',
 	'Core:BulkExportLabelPhrasebookEntry' => 'Query Phrasebook Entry:',

+ 3 - 2
dictionaries/es_cr.dictionary.itop.core.php

@@ -1,5 +1,5 @@
 <?php
-// Copyright (C) 2010-2012 Combodo SARL
+// Copyright (C) 2010-2016 Combodo SARL
 //
 //   This file is part of iTop.
 //
@@ -20,7 +20,7 @@
 /**
  * Localized data
  *
- * @copyright   Copyright (C) 2010-2012 Combodo SARL
+ * @copyright   Copyright (C) 2010-2016 Combodo SARL
  * @license     http://opensource.org/licenses/AGPL-3.0
  */
 
@@ -836,6 +836,7 @@ Dict::Add('ES CR', 'Spanish', 'Español, Castellano', array(
 	'Core:BulkExport:SpreadsheetOptions' => 'Spreadsheet Options~~',
 	'Core:BulkExport:OptionNoLocalize' => 'Do not localize the values (for Enumerated fields)~~',
 	'Core:BulkExport:OptionLinkSets' => 'Include linked objects~~',
+	'Core:BulkExport:OptionFormattedText' => 'Preserve text formatting~~',
 	'Core:BulkExport:ScopeDefinition' => 'Definition of the objects to export~~',
 	'Core:BulkExportLabelOQLExpression' => 'OQL Query:~~',
 	'Core:BulkExportLabelPhrasebookEntry' => 'Query Phrasebook Entry:~~',

+ 3 - 2
dictionaries/fr.dictionary.itop.core.php

@@ -1,5 +1,5 @@
 <?php
-// Copyright (C) 2010-2014 Combodo SARL
+// Copyright (C) 2010-2016 Combodo SARL
 //
 //   This file is part of iTop.
 //
@@ -18,7 +18,7 @@
 
 
 /**
- * @copyright   Copyright (C) 2010-2012 Combodo SARL
+ * @copyright   Copyright (C) 2010-2016 Combodo SARL
  * @license     http://opensource.org/licenses/AGPL-3.0
  */
 
@@ -700,6 +700,7 @@ Opérateurs :<br/>
 	'Core:BulkExport:SpreadsheetOptions' => 'Options du format HTML pour Excel',
 	'Core:BulkExport:OptionNoLocalize' => 'Ne pas traduire les valeurs (pour les champs de type "Enum")',
 	'Core:BulkExport:OptionLinkSets' => 'Inclure les objets liés',
+	'Core:BulkExport:OptionFormattedText' => 'Préserver le formatage du texte',
 	'Core:BulkExport:ScopeDefinition' => 'Définition des objets à exporter',
 	'Core:BulkExportLabelOQLExpression' => 'Requête OQL:',
 	'Core:BulkExportLabelPhrasebookEntry' => 'Entrée du livre des requêtes:',

+ 3 - 2
dictionaries/hu.dictionary.itop.core.php

@@ -1,5 +1,5 @@
 <?php
-// Copyright (C) 2010-2012 Combodo SARL
+// Copyright (C) 2010-2016 Combodo SARL
 //
 //   This file is part of iTop.
 //
@@ -17,7 +17,7 @@
 //   along with iTop. If not, see <http://www.gnu.org/licenses/>
 
 /**
- * @copyright   Copyright (C) 2010-2012 Combodo SARL
+ * @copyright   Copyright (C) 2010-2016 Combodo SARL
  * @license     http://opensource.org/licenses/AGPL-3.0
  */
 
@@ -593,6 +593,7 @@ Operators:<br/>
 	'Core:BulkExport:SpreadsheetOptions' => 'Spreadsheet Options~~',
 	'Core:BulkExport:OptionNoLocalize' => 'Do not localize the values (for Enumerated fields)~~',
 	'Core:BulkExport:OptionLinkSets' => 'Include linked objects~~',
+	'Core:BulkExport:OptionFormattedText' => 'Preserve text formatting~~',
 	'Core:BulkExport:ScopeDefinition' => 'Definition of the objects to export~~',
 	'Core:BulkExportLabelOQLExpression' => 'OQL Query:~~',
 	'Core:BulkExportLabelPhrasebookEntry' => 'Query Phrasebook Entry:~~',

+ 3 - 2
dictionaries/it.dictionary.itop.core.php

@@ -1,5 +1,5 @@
 <?php
-// Copyright (C) 2010-2012 Combodo SARL
+// Copyright (C) 2010-2016 Combodo SARL
 //
 //   This file is part of iTop.
 //
@@ -20,7 +20,7 @@
 /**
  * Localized data
  *
- * @copyright   Copyright (C) 2010-2012 Combodo SARL
+ * @copyright   Copyright (C) 2010-2016 Combodo SARL
  * @license     http://opensource.org/licenses/AGPL-3.0
  */
 
@@ -825,6 +825,7 @@ Dict::Add('IT IT', 'Italian', 'Italiano', array(
 	'Core:BulkExport:SpreadsheetOptions' => 'Spreadsheet Options~~',
 	'Core:BulkExport:OptionNoLocalize' => 'Do not localize the values (for Enumerated fields)~~',
 	'Core:BulkExport:OptionLinkSets' => 'Include linked objects~~',
+	'Core:BulkExport:OptionFormattedText' => 'Preserve text formatting~~',
 	'Core:BulkExport:ScopeDefinition' => 'Definition of the objects to export~~',
 	'Core:BulkExportLabelOQLExpression' => 'OQL Query:~~',
 	'Core:BulkExportLabelPhrasebookEntry' => 'Query Phrasebook Entry:~~',

+ 3 - 2
dictionaries/ja.dictionary.itop.core.php

@@ -1,5 +1,5 @@
 <?php
-// Copyright (C) 2010-2012 Combodo SARL
+// Copyright (C) 2010-2016 Combodo SARL
 //
 //   This file is part of iTop.
 //
@@ -17,7 +17,7 @@
 //   along with iTop. If not, see <http://www.gnu.org/licenses/>
 
 /**
- * @copyright   Copyright (C) 2010-2012 Combodo SARL
+ * @copyright   Copyright (C) 2010-2016 Combodo SARL
  * @licence	http://opensource.org/licenses/AGPL-3.0
  */
 
@@ -615,6 +615,7 @@ Operators:<br/>
 	'Core:BulkExport:SpreadsheetOptions' => 'Spreadsheet Options~~',
 	'Core:BulkExport:OptionLinkSets' => 'Include linked objects~~',
 	'Core:BulkExport:OptionNoLocalize' => 'Do not localize the values (for Enumerated fields)~~',
+	'Core:BulkExport:OptionFormattedText' => 'Preserve text formatting~~',
 	'Core:BulkExport:ScopeDefinition' => 'Definition of the objects to export~~',
 	'Core:BulkExportLabelOQLExpression' => 'OQL Query:~~',
 	'Core:BulkExportLabelPhrasebookEntry' => 'Query Phrasebook Entry:~~',

+ 3 - 2
dictionaries/nl.dictionary.itop.core.php

@@ -1,5 +1,5 @@
 <?php
-// Copyright (C) 2010-2012 Combodo SARL
+// Copyright (C) 2010-2016 Combodo SARL
 //
 //   This file is part of iTop.
 //
@@ -23,7 +23,7 @@
  * Linux & Open Source Professionals
  * http://www.linprofs.com
  * 
- * @copyright   Copyright (C) 2010-2012 Combodo SARL
+ * @copyright   Copyright (C) 2010-2016 Combodo SARL
  * @licence	http://opensource.org/licenses/AGPL-3.0
  */
 
@@ -845,6 +845,7 @@ Dict::Add('NL NL', 'Dutch', 'Nederlands', array(
 	'Core:BulkExport:SpreadsheetOptions' => 'Spreadsheet Options~~',
 	'Core:BulkExport:OptionLinkSets' => 'Include linked objects~~',
 	'Core:BulkExport:OptionNoLocalize' => 'Do not localize the values (for Enumerated fields)~~',
+	'Core:BulkExport:OptionFormattedText' => 'Preserve text formatting~~',
 	'Core:BulkExport:ScopeDefinition' => 'Definition of the objects to export~~',
 	'Core:BulkExportLabelOQLExpression' => 'OQL Query:~~',
 	'Core:BulkExportLabelPhrasebookEntry' => 'Query Phrasebook Entry:~~',

+ 3 - 2
dictionaries/pt_br.dictionary.itop.core.php

@@ -1,5 +1,5 @@
 <?php
-// Copyright (C) 2010-2012 Combodo SARL
+// Copyright (C) 2010-2016 Combodo SARL
 //
 //   This file is part of iTop.
 //
@@ -20,7 +20,7 @@
 /**
  * Localized data
  *
- * @copyright   Copyright (C) 2010-2012 Combodo SARL
+ * @copyright   Copyright (C) 2010-2016 Combodo SARL
  * @license     http://opensource.org/licenses/AGPL-3.0
  */
 
@@ -838,6 +838,7 @@ Dict::Add('PT BR', 'Brazilian', 'Brazilian', array(
 	'Core:BulkExport:SpreadsheetOptions' => 'Spreadsheet Options~~',
 	'Core:BulkExport:OptionLinkSets' => 'Include linked objects~~',
 	'Core:BulkExport:OptionNoLocalize' => 'Do not localize the values (for Enumerated fields)~~',
+	'Core:BulkExport:OptionFormattedText' => 'Preserve text formatting~~',
 	'Core:BulkExport:ScopeDefinition' => 'Definition of the objects to export~~',
 	'Core:BulkExportLabelOQLExpression' => 'OQL Query:~~',
 	'Core:BulkExportLabelPhrasebookEntry' => 'Query Phrasebook Entry:~~',

+ 1 - 0
dictionaries/ru.dictionary.itop.core.php

@@ -833,6 +833,7 @@ Dict::Add('RU RU', 'Russian', 'Русский', array(
 	'Core:BulkExport:SpreadsheetOptions' => 'Spreadsheet Options~~',
 	'Core:BulkExport:OptionLinkSets' => 'Include linked objects~~',
 	'Core:BulkExport:OptionNoLocalize' => 'Do not localize the values (for Enumerated fields)~~',
+	'Core:BulkExport:OptionFormattedText' => 'Preserve text formatting~~',
 	'Core:BulkExport:ScopeDefinition' => 'Definition of the objects to export~~',
 	'Core:BulkExportLabelOQLExpression' => 'OQL Query:~~',
 	'Core:BulkExportLabelPhrasebookEntry' => 'Query Phrasebook Entry:~~',

+ 3 - 2
dictionaries/tr.dictionary.itop.core.php

@@ -1,5 +1,5 @@
 <?php
-// Copyright (C) 2010-2012 Combodo SARL
+// Copyright (C) 2010-2016 Combodo SARL
 //
 //   This file is part of iTop.
 //
@@ -21,7 +21,7 @@
  * Localized data
  *
  * @author      Izzet Sirin <izzet.sirin@htr.com.tr>
- * @copyright   Copyright (C) 2010-2012 Combodo SARL
+ * @copyright   Copyright (C) 2010-2016 Combodo SARL
  * @license     http://opensource.org/licenses/AGPL-3.0
  */
 
@@ -765,6 +765,7 @@ Operators:<br/>
 	'Core:BulkExport:SpreadsheetOptions' => 'Spreadsheet Options~~',
 	'Core:BulkExport:OptionLinkSets' => 'Include linked objects~~',
 	'Core:BulkExport:OptionNoLocalize' => 'Do not localize the values (for Enumerated fields)~~',
+	'Core:BulkExport:OptionFormattedText' => 'Preserve text formatting~~',
 	'Core:BulkExport:ScopeDefinition' => 'Definition of the objects to export~~',
 	'Core:BulkExportLabelOQLExpression' => 'OQL Query:~~',
 	'Core:BulkExportLabelPhrasebookEntry' => 'Query Phrasebook Entry:~~',

+ 3 - 2
dictionaries/zh.dictionary.itop.core.php

@@ -1,5 +1,5 @@
 <?php
-// Copyright (C) 2010-2012 Combodo SARL
+// Copyright (C) 2010-2016 Combodo SARL
 //
 //   This file is part of iTop.
 //
@@ -21,7 +21,7 @@
  * Localized data
  *
  * @author      Robert Deng <denglx@gmail.com>
- * @copyright   Copyright (C) 2010-2012 Combodo SARL
+ * @copyright   Copyright (C) 2010-2016 Combodo SARL
  * @license     http://opensource.org/licenses/AGPL-3.0
  */
 
@@ -764,6 +764,7 @@ Operators:<br/>
 	'Core:BulkExport:SpreadsheetOptions' => 'Spreadsheet Options~~',
 	'Core:BulkExport:OptionLinkSets' => 'Include linked objects~~',
 	'Core:BulkExport:OptionNoLocalize' => 'Do not localize the values (for Enumerated fields)~~',
+	'Core:BulkExport:OptionFormattedText' => 'Preserve text formatting~~',
 	'Core:BulkExport:ScopeDefinition' => 'Definition of the objects to export~~',
 	'Core:BulkExportLabelOQLExpression' => 'OQL Query:~~',
 	'Core:BulkExportLabelPhrasebookEntry' => 'Query Phrasebook Entry:~~',

+ 10 - 0
js/forms-json-utils.js

@@ -249,6 +249,16 @@ function ValidateCKEditField(sFieldId, sPattern, bMandatory, sFormId, nullValue)
 		else
 		{
 			sTextContent = oFormattedContents.contents().find("body").text();
+			
+			if (sTextContent == '')
+			{
+				// No plain text, maybe there is just an image...
+				var oImg = oFormattedContents.contents().find("body img");
+				if (oImg.length != 0)
+				{
+					sTextContent = 'image';
+				}
+			}
 		}
 	
 		if (bMandatory && (sTextContent == ''))

+ 16 - 11
js/jquery.tablesorter.pager.js

@@ -15,19 +15,22 @@ function sprintf(format, etc) {
 			
 			function setPageSize(table,size, bReload) {
 				var c = table.config;
-				c.selectedSize = size;
-				if (size == -1)
+				if (c != undefined)
 				{
-					size = c.totalRows;
-				}
-				c.size = size;
-				c.totalPages = Math.ceil(c.totalRows / c.size);
-				c.pagerPositionSet = false;
-				if (bReload)
-				{
-					moveToPage(table);
+					c.selectedSize = size;
+					if (size == -1)
+					{
+						size = c.totalRows;
+					}
+					c.size = size;
+					c.totalPages = Math.ceil(c.totalRows / c.size);
+					c.pagerPositionSet = false;
+					if (bReload)
+					{
+						moveToPage(table);
+					}
+					fixPosition(table);					
 				}
-				fixPosition(table);
 			}
 			
 			function fixPosition(table) {
@@ -246,6 +249,8 @@ function sprintf(format, etc) {
 			function applySelection(table)
 			{
 				var c = table.config;
+				if (c == undefined) return;
+				
 				if (c.selectionMode == 'negative')
 				{
 					$(table).find(':checkbox[name^=selectObj]').attr('checked', true);

+ 1 - 1
js/simple_graph.js

@@ -834,7 +834,7 @@ $(function()
 				jTab.find('span').html(sTabText+' <img style="vertical-align:bottom" src="../images/indicator.gif">');
 			}
 			$.post(sUrl, oParams, function(data) {
-				var sDownloadLink = GetAbsoluteUrlAppRoot()+'pages/ajax.render.php?operation=download_document&class=Attachment&id='+data.att_id+'&field=contents';
+				var sDownloadLink = GetAbsoluteUrlAppRoot()+'pages/ajax.render.php?operation=download_document&class=Attachment&field=contents&id='+data.att_id;
 				var sIcon = GetAbsoluteUrlModulesRoot()+'itop-attachments/icons/pdf.png';
 				if (jTab != null)
 				{

BIN
portal/images/company_logo.png


+ 1 - 0
setup/compiler.class.inc.php

@@ -1219,6 +1219,7 @@ EOF;
 				// Added if present...
 				//
 				$aParameters['validation_pattern'] = $this->GetPropString($oField, 'validation_pattern');
+				$aParameters['format'] = $this->GetPropString($oField, 'format');
 				$aParameters['width'] = $this->GetPropString($oField, 'width');
 				$aParameters['height'] = $this->GetPropString($oField, 'height');
 				$aParameters['digits'] = $this->GetPropNumber($oField, 'digits');

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.