From 043fc882559e3ed31c1387fbd5ef158510c5668f Mon Sep 17 00:00:00 2001 From: benakamoorthi <benaka.moorthi@gmail.com> Date: Fri, 14 Dec 2012 08:56:21 +0000 Subject: [PATCH] Fixes #1253, added annotations plugin that allows attaching notes to different days. Notes: * Modified renderers so they would render arrays better. Before, arrays were added to DataTables and array keys were either lost or ignored, now they are rendered. * Fixed issue w/ JSON rendering that rendered arrays when the PHP arrays didn't have contiguous keys. * Augmented some exception messages. * Added utility method processRequest to Piwik_API_Request to ease use of the class. git-svn-id: http://dev.piwik.org/svn/trunk@7612 59fd770c-687e-43c8-a1e3-f5a4ff64c105 --- config/global.ini.php | 1 + core/API/Request.php | 22 + core/API/ResponseBuilder.php | 94 +-- core/DataTable/Renderer.php | 70 ++- core/DataTable/Renderer/Json.php | 23 +- core/DataTable/Renderer/Xml.php | 119 +++- core/Date.php | 2 +- core/FrontController.php | 2 +- core/Piwik.php | 40 ++ core/Updates/1.9.3-b10.php | 34 + core/Version.php | 2 +- core/ViewDataTable.php | 15 + .../GenerateGraphHTML/ChartEvolution.php | 1 + lang/en.php | 16 + plugins/API/API.php | 2 +- plugins/Annotations/API.php | 364 +++++++++++ plugins/Annotations/AnnotationList.php | 446 ++++++++++++++ plugins/Annotations/Annotations.php | 70 +++ plugins/Annotations/Controller.php | 226 +++++++ plugins/Annotations/templates/annotation.tpl | 43 ++ .../templates/annotationManager.tpl | 27 + plugins/Annotations/templates/annotations.js | 583 ++++++++++++++++++ plugins/Annotations/templates/annotations.tpl | 30 + .../templates/evolutionAnnotations.tpl | 12 + plugins/Annotations/templates/styles.css | 194 ++++++ plugins/CoreHome/templates/calendar.js | 21 +- plugins/CoreHome/templates/datatable.css | 6 + plugins/CoreHome/templates/datatable.js | 155 ++++- .../CoreHome/templates/datatable_footer.tpl | 11 +- .../PHPUnit/Core/API/ResponseBuilderTest.php | 69 --- .../Core/DataTable/Renderer/JSONTest.php | 68 ++ .../Core/DataTable/Renderer/XMLTest.php | 95 +++ tests/PHPUnit/Core/PiwikTest.php | 25 + tests/PHPUnit/Integration/AnnotationsTest.php | 415 +++++++++++++ .../test_ImportLogs__Goals.getGoals.xml | 2 +- ...st_OneVisitorTwoVisits__Goals.getGoals.xml | 4 +- ...sits_withCookieSupport__Goals.getGoals.xml | 4 +- .../test_annotations__Annotations.get.xml | 11 + ...st_annotations__Annotations.getAll_day.xml | 13 + ..._annotations__Annotations.getAll_month.xml | 85 +++ ...t_annotations__Annotations.getAll_week.xml | 21 + ...t_annotations__Annotations.getAll_year.xml | 133 ++++ ...tations.getAnnotationCountForDates_day.xml | 12 + ...tions.getAnnotationCountForDates_month.xml | 12 + ...ations.getAnnotationCountForDates_week.xml | 12 + ...ations.getAnnotationCountForDates_year.xml | 12 + ...tations_lastN__Annotations.getAll_week.xml | 85 +++ ...ations.getAnnotationCountForDates_week.xml | 47 ++ ...ultipleSites__Annotations.getAll_month.xml | 135 ++++ ...tions.getAnnotationCountForDates_month.xml | 21 + ...ations_range__Annotations.getAll_range.xml | 69 +++ ...tions.getAnnotationCountForDates_range.xml | 229 +++++++ tests/PHPUnit/IntegrationTestCase.php | 1 + themes/default/images/grey_marker.png | Bin 0 -> 1627 bytes themes/default/images/star.png | Bin 0 -> 757 bytes themes/default/images/star_empty.png | Bin 0 -> 658 bytes themes/default/images/yellow_marker.png | Bin 0 -> 1618 bytes 57 files changed, 4024 insertions(+), 187 deletions(-) create mode 100755 core/Updates/1.9.3-b10.php create mode 100755 plugins/Annotations/API.php create mode 100755 plugins/Annotations/AnnotationList.php create mode 100755 plugins/Annotations/Annotations.php create mode 100755 plugins/Annotations/Controller.php create mode 100755 plugins/Annotations/templates/annotation.tpl create mode 100755 plugins/Annotations/templates/annotationManager.tpl create mode 100755 plugins/Annotations/templates/annotations.js create mode 100755 plugins/Annotations/templates/annotations.tpl create mode 100755 plugins/Annotations/templates/evolutionAnnotations.tpl create mode 100755 plugins/Annotations/templates/styles.css create mode 100755 tests/PHPUnit/Integration/AnnotationsTest.php create mode 100755 tests/PHPUnit/Integration/expected/test_annotations__Annotations.get.xml create mode 100755 tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAll_day.xml create mode 100755 tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAll_month.xml create mode 100755 tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAll_week.xml create mode 100755 tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAll_year.xml create mode 100755 tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAnnotationCountForDates_day.xml create mode 100755 tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAnnotationCountForDates_month.xml create mode 100755 tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAnnotationCountForDates_week.xml create mode 100755 tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAnnotationCountForDates_year.xml create mode 100755 tests/PHPUnit/Integration/expected/test_annotations_lastN__Annotations.getAll_week.xml create mode 100755 tests/PHPUnit/Integration/expected/test_annotations_lastN__Annotations.getAnnotationCountForDates_week.xml create mode 100755 tests/PHPUnit/Integration/expected/test_annotations_multipleSites__Annotations.getAll_month.xml create mode 100755 tests/PHPUnit/Integration/expected/test_annotations_multipleSites__Annotations.getAnnotationCountForDates_month.xml create mode 100755 tests/PHPUnit/Integration/expected/test_annotations_range__Annotations.getAll_range.xml create mode 100755 tests/PHPUnit/Integration/expected/test_annotations_range__Annotations.getAnnotationCountForDates_range.xml create mode 100755 themes/default/images/grey_marker.png create mode 100755 themes/default/images/star.png create mode 100755 themes/default/images/star_empty.png create mode 100755 themes/default/images/yellow_marker.png diff --git a/config/global.ini.php b/config/global.ini.php index af26509b60..14d8bbaf8b 100644 --- a/config/global.ini.php +++ b/config/global.ini.php @@ -520,6 +520,7 @@ Plugins[] = CustomVariables Plugins[] = PrivacyManager Plugins[] = ImageGraph Plugins[] = DoNotTrack +Plugins[] = Annotations [PluginsInstalled] PluginsInstalled[] = Login diff --git a/core/API/Request.php b/core/API/Request.php index b7f8c62bfb..337fee441c 100644 --- a/core/API/Request.php +++ b/core/API/Request.php @@ -170,4 +170,26 @@ class Piwik_API_Request } return $a; } + + /** + * Helper method to process an API request using the variables in $_GET and $_POST. + * + * @param string $method The API method to call, ie, Actions.getPageTitles + * @param array $paramOverride The parameter name-value pairs to use instead of what's + * in $_GET & $_POST. + * @param mixed The result of the API request. + */ + public static function processRequest( $method, $paramOverride = array() ) + { + // set up request params + $params = $_GET + $_POST; + $params['format'] = 'original'; + $params['module'] = 'API'; + $params['method'] = $method; + $params = $paramOverride + $params; + + // process request + $request = new Piwik_API_Request($params); + return $request->process(); + } } diff --git a/core/API/ResponseBuilder.php b/core/API/ResponseBuilder.php index afc43abebb..a85f1c67d8 100644 --- a/core/API/ResponseBuilder.php +++ b/core/API/ResponseBuilder.php @@ -159,7 +159,7 @@ class Piwik_API_ResponseBuilder /** * Apply the specified renderer to the DataTable * - * @param Piwik_DataTable $dataTable + * @param Piwik_DataTable|array $dataTable * @return string */ protected function getRenderedDataTable($dataTable) @@ -350,9 +350,7 @@ class Piwik_API_ResponseBuilder return $multiDimensional; } - $dataTable = new Piwik_DataTable(); - $dataTable->addRowsFromSimpleArray($array); - return $this->getRenderedDataTable($dataTable); + return $this->getRenderedDataTable($array); } /** @@ -402,13 +400,7 @@ class Piwik_API_ResponseBuilder return $array; case 'xml': - @header("Content-Type: text/xml;charset=utf-8"); - $xml = - "<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" . - "<result>\n". - self::convertMultiDimensionalArrayToXml($array). - "\n</result>"; - return $xml; + return $this->getRenderedDataTable($array); default: break; } @@ -419,82 +411,6 @@ class Piwik_API_ResponseBuilder return false; } - /** - * Render a multidimensional array to XML - * - * @param array $array can contain scalar, arrays, Piwik_DataTable and Piwik_DataTable_Array - * @param int $level - * @return string - */ - public static function convertMultiDimensionalArrayToXml($array, $level = 0) - { - $xml=""; - foreach ($array as $key=>$value) - { - if(is_numeric($key)) - { - $key = "row"; - } - - $key = str_replace(' ', '_', $key); - $marginLeft = str_repeat("\t", $level + 1); - - switch(true) - { - // Case dimension is a PHP array - case (is_array($value)): - - if(empty($value)) - { - $xml .= $marginLeft . "<$key/>\n"; - } - else - { - $xml.= $marginLeft . - "<$key>\n". - self::convertMultiDimensionalArrayToXml($value, $level + 1). - "\n". $marginLeft . - "</$key>\n"; - } - break; - - // Case dimension is a Piwik_DataTable_Array or a Piwik_DataTable - case ($value instanceof Piwik_DataTable_Array || $value instanceof Piwik_DataTable): - - if($value->getRowsCount() == 0) - { - $xml .= $marginLeft . "<$key/>\n"; - } - else - { - $XMLRenderer = new Piwik_DataTable_Renderer_Xml(); - $XMLRenderer->setTable($value); - $renderedReport = $XMLRenderer->render(); - - $renderedReport = preg_replace("/<\?xml.*\?>\n/", "", $renderedReport); - $markupToRemove = $value instanceof Piwik_DataTable_Array ? "results" : "result"; - $renderedReport = preg_replace("/\n?<\/?". $markupToRemove .">\n?/", "", $renderedReport); - - // Add one level of margin to each line - $renderedReport = $marginLeft . preg_replace("/\n/", "\n" . $marginLeft, $renderedReport); - - $xml.= $marginLeft . "<$key>\n"; - $xml.= $renderedReport; - $xml.= "\n" . $marginLeft . "</$key>\n"; - } - - break; - - // Case scalar - default: - - $xml.= $marginLeft . "<$key>".Piwik_DataTable_Renderer::formatValueXml($value)."</$key>\n"; - break; - } - } - return $xml; - } - /** * Render a multidimensional array to Json * Handle Piwik_DataTable|Piwik_DataTable_Array elements in the first dimension only, following case does not work: @@ -513,9 +429,7 @@ class Piwik_API_ResponseBuilder */ public static function convertMultiDimensionalArrayToJson($array) { - // Naive but works for our current use cases - $arrayKeys = array_keys($array); - $isAssociative = !is_numeric($arrayKeys[0]); + $isAssociative = Piwik::isAssociativeArray($array); if($isAssociative) { diff --git a/core/DataTable/Renderer.php b/core/DataTable/Renderer.php index b66e1f67b2..8f48e88903 100644 --- a/core/DataTable/Renderer.php +++ b/core/DataTable/Renderer.php @@ -130,10 +130,11 @@ abstract class Piwik_DataTable_Renderer */ public function setTable($table) { - if(!($table instanceof Piwik_DataTable) + if (!is_array($table) + && !($table instanceof Piwik_DataTable) && !($table instanceof Piwik_DataTable_Array)) { - throw new Exception("The renderer accepts only a Piwik_DataTable or an array of DataTable (Piwik_DataTable_Array) object."); + throw new Exception("DataTable renderers renderer accepts only Piwik_DataTable and Piwik_DataTable_Array instances, and array instances."); } $this->table = $table; } @@ -355,4 +356,69 @@ abstract class Piwik_DataTable_Renderer $this->idSite = $idSite; } + /** + * Returns true if an array should be wrapped before rendering. This is used to + * mimic quirks in the old rendering logic (for backwards compatibility). The + * specific meaning of 'wrap' is left up to the Renderer. For XML, this means a + * new <row> node. For JSON, this means wrapping in an array. + * + * In the old code, arrays were added to new DataTable instances, and then rendered. + * This transformation wrapped associative arrays except under certain circumstances, + * including: + * - single element (ie, array('nb_visits' => 0)) + * - empty array (ie, array()) + * - array w/ arrays/DataTable instances as values (ie, + * array('name' => 'myreport', + * 'reportData' => new Piwik_DataTable()) + * OR array('name' => 'myreport', + * 'reportData' => array(...)) ) + * + * @param array $array + * @param bool|null $isAssociativeArray Whether the array is associative or not. + * If null, it is determined. + * @return bool + */ + protected static function shouldWrapArrayBeforeRendering( $array, $isAssociativeArray = null ) + { + if (empty($array)) + { + return false; + } + + if ($isAssociativeArray === null) + { + $isAssociativeArray = Piwik::isAssociativeArray($array); + } + + $wrap = true; + if ($isAssociativeArray) + { + // we don't wrap if the array has one element that is a value + $firstValue = reset($array); + if (count($array) === 1 + && (!is_array($firstValue) + && !is_object($firstValue))) + { + $wrap = false; + } + else + { + foreach ($array as $value) + { + if (is_array($value) + || is_object($value)) + { + $wrap = false; + break; + } + } + } + } + else + { + $wrap = false; + } + + return $wrap; + } } diff --git a/core/DataTable/Renderer/Json.php b/core/DataTable/Renderer/Json.php index 16fee39e2c..812eb4e76a 100644 --- a/core/DataTable/Renderer/Json.php +++ b/core/DataTable/Renderer/Json.php @@ -54,12 +54,23 @@ class Piwik_DataTable_Renderer_Json extends Piwik_DataTable_Renderer */ protected function renderTable($table) { - $renderer = new Piwik_DataTable_Renderer_Php(); - $renderer->setTable($table); - $renderer->setRenderSubTables($this->isRenderSubtables()); - $renderer->setSerialize(false); - $renderer->setHideIdSubDatableFromResponse($this->hideIdSubDatatable); - $array = $renderer->flatRender(); + if (is_array($table)) + { + $array = $table; + if (self::shouldWrapArrayBeforeRendering($array)) + { + $array = array($array); + } + } + else + { + $renderer = new Piwik_DataTable_Renderer_Php(); + $renderer->setTable($table); + $renderer->setRenderSubTables($this->isRenderSubtables()); + $renderer->setSerialize(false); + $renderer->setHideIdSubDatableFromResponse($this->hideIdSubDatatable); + $array = $renderer->flatRender(); + } if(!is_array($array)) { diff --git a/core/DataTable/Renderer/Xml.php b/core/DataTable/Renderer/Xml.php index 74bb78be41..ea3c546f6d 100644 --- a/core/DataTable/Renderer/Xml.php +++ b/core/DataTable/Renderer/Xml.php @@ -60,6 +60,11 @@ class Piwik_DataTable_Renderer_Xml extends Piwik_DataTable_Renderer */ protected function getArrayFromDataTable($table) { + if (is_array($table)) + { + return $table; + } + $renderer = new Piwik_DataTable_Renderer_Php(); $renderer->setRenderSubTables($this->isRenderSubtables()); $renderer->setSerialize(false); @@ -137,7 +142,7 @@ class Piwik_DataTable_Renderer_Xml extends Piwik_DataTable_Renderer return $out; } - if($table instanceof Piwik_DataTable) + if ($table instanceof Piwik_DataTable) { $out = $this->renderDataTable($array); if($returnOnlyDataTableXml) @@ -147,6 +152,118 @@ class Piwik_DataTable_Renderer_Xml extends Piwik_DataTable_Renderer $out = "<result>\n$out</result>"; return $out; } + + if (is_array($array)) + { + $out = $this->renderArray($array, $prefixLines."\t"); + if ($returnOnlyDataTableXml) + { + return $out; + } + return "<result>\n$out</result>"; + } + } + + /** + * Renders an array as XML. + * + * @param array $array The array to render. + * @param string $prefixLines The string to prefix each line in the output. + * @return string + */ + private function renderArray( $array, $prefixLines ) + { + $isAssociativeArray = Piwik::isAssociativeArray($array); + + // check if array contains arrays, and if not wrap the result in an extra <row> element + // (only check if this is the root renderArray call) + // NOTE: this is for backwards compatibility. before, array's were added to a new DataTable. + // if the array had arrays, they were added as multiple rows, otherwise it was treated as + // one row. removing will change API output. + $wrapInRow = $prefixLines === "\t" && self::shouldWrapArrayBeforeRendering($array, $isAssociativeArray); + + // render the array + $result = ""; + if ($wrapInRow) + { + $result .= "$prefixLines<row>\n"; + $prefixLines .= "\t"; + } + foreach ($array as $key => $value) + { + // based on the type of array & the key, determine how this node will look + if ($isAssociativeArray) + { + if (is_numeric($key)) + { + $prefix = "<row key=\"$key\">"; + $suffix = "</row>"; + $emptyNode = "<row key=\"$key\"/>"; + } + else + { + $prefix = "<$key>"; + $suffix = "</$key>"; + $emptyNode = "<$key />"; + } + } + else + { + $prefix = "<row>"; + $suffix = "</row>"; + $emptyNode = "<row/>"; + } + + // render the array item + if (is_array($value)) + { + $result .= $prefixLines.$prefix."\n"; + $result .= $this->renderArray($value, $prefixLines."\t"); + $result .= $prefixLines.$suffix."\n"; + } + else if ($value instanceof Piwik_DataTable + || $value instanceof Piwik_DataTable_Array) + { + if ($value->getRowsCount() == 0) + { + $result .= $prefixLines.$emptyNode."\n"; + } + else + { + $result .= $prefixLines.$prefix."\n"; + if ($value instanceof Piwik_DataTable_Array) + { + $result .= $this->renderDataTableArray($value, $this->getArrayFromDataTable($value), $prefixLines); + } + else if ($value instanceof Piwik_DataTable_Simple) + { + $result .= $this->renderDataTableSimple($this->getArrayFromDataTable($value), $prefixLines); + } + else + { + $result .= $this->renderDataTable($this->getArrayFromDataTable($value), $prefixLines); + } + $result .= $prefixLines.$suffix."\n"; + } + } + else + { + $xmlValue = self::formatValueXml($value); + if (strlen($xmlValue) != 0) + { + $result .= $prefixLines.$prefix.$xmlValue.$suffix."\n"; + } + else + { + $result .= $prefixLines.$emptyNode."\n"; + } + } + } + if ($wrapInRow) + { + $result .= substr($prefixLines, 0, strlen($prefixLines) - 1)."</row>\n"; + } + return $result; } /** diff --git a/core/Date.php b/core/Date.php index 3da0b5bdf1..a86aa5652a 100644 --- a/core/Date.php +++ b/core/Date.php @@ -62,7 +62,7 @@ class Piwik_Date */ static public function factory($dateString, $timezone = null) { - $invalidDateException = new Exception(Piwik_TranslateException('General_ExceptionInvalidDateFormat', array("YYYY-MM-DD, or 'today' or 'yesterday'", "strtotime", "http://php.net/strtotime"))); + $invalidDateException = new Exception(Piwik_TranslateException('General_ExceptionInvalidDateFormat', array("YYYY-MM-DD, or 'today' or 'yesterday'", "strtotime", "http://php.net/strtotime")).": $dateString"); if($dateString instanceof self) { $dateString = $dateString->toString(); diff --git a/core/FrontController.php b/core/FrontController.php index 654818abaa..80c2500910 100644 --- a/core/FrontController.php +++ b/core/FrontController.php @@ -126,7 +126,7 @@ class Piwik_FrontController // Piwik::log("Dispatching $module / $action, parameters: ".var_export($parameters, $return = true)); if( !is_callable(array($controller, $action))) { - throw new Exception("Action $action not found in the controller $controllerClassName."); + throw new Exception("Action '$action' not found in the controller '$controllerClassName'."); } // Generic hook that plugins can use to modify any input to the function, diff --git a/core/Piwik.php b/core/Piwik.php index e070dc0591..c8bf3e9b5d 100644 --- a/core/Piwik.php +++ b/core/Piwik.php @@ -2667,4 +2667,44 @@ class Piwik $oType = is_object($o) ? get_class($o) : gettype($o); throw new Exception("Invalid variable type '$oType', expected one of following: ".implode(', ', $types)); } + + /** + * Returns true if an array is an associative array, false if otherwise. + * + * This method determines if an array is associative by checking that the + * first element's key is 0, and that each successive element's key is + * one greater than the last. + * + * @param array $array + * @return bool + */ + static public function isAssociativeArray( $array ) + { + reset($array); + if (!is_numeric(key($array)) + || key($array) != 0) // first key must be 0 + { + return true; + } + + // check that each key is == next key - 1 w/o actually indexing the array + while (true) + { + $current = key($array); + + next($array); + $next = key($array); + + if ($next === null) + { + break; + } + else if ($current + 1 != $next) + { + return true; + } + } + + return false; + } } diff --git a/core/Updates/1.9.3-b10.php b/core/Updates/1.9.3-b10.php new file mode 100755 index 0000000000..fc60dc32f3 --- /dev/null +++ b/core/Updates/1.9.3-b10.php @@ -0,0 +1,34 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * @version $Id$ + * + * @category Piwik + * @package Updates + */ + +/** + * @package Updates + */ +class Piwik_Updates_1_9_3_b10 extends Piwik_Updates +{ + static function isMajorUpdate() + { + return false; + } + + static function update() + { + try + { + Piwik_PluginsManager::getInstance()->activatePlugin('Annotations'); + } + catch(Exception $e) + { + // pass + } + } +} diff --git a/core/Version.php b/core/Version.php index a16a7a3e67..9bb369ceba 100644 --- a/core/Version.php +++ b/core/Version.php @@ -21,5 +21,5 @@ final class Piwik_Version * Current Piwik version * @var string */ - const VERSION = '1.9.3-b9'; + const VERSION = '1.9.3-b10'; } diff --git a/core/ViewDataTable.php b/core/ViewDataTable.php index 628e05be69..c6ffb8e91a 100644 --- a/core/ViewDataTable.php +++ b/core/ViewDataTable.php @@ -292,6 +292,7 @@ abstract class Piwik_ViewDataTable $this->viewProperties['show_table_all_columns'] = Piwik_Common::getRequestVar('show_table_all_columns', true); $this->viewProperties['show_all_views_icons'] = Piwik_Common::getRequestVar('show_all_views_icons', true); $this->viewProperties['hide_all_views_icons'] = Piwik_Common::getRequestVar('hide_all_views_icons', false); + $this->viewProperties['hide_annotations_view'] = Piwik_Common::getRequestVar('hide_annotations_view', true); $this->viewProperties['show_bar_chart'] = Piwik_Common::getRequestVar('show_barchart', true); $this->viewProperties['show_pie_chart'] = Piwik_Common::getRequestVar('show_piechart', true); $this->viewProperties['show_tag_cloud'] = Piwik_Common::getRequestVar('show_tag_cloud', true); @@ -958,6 +959,20 @@ abstract class Piwik_ViewDataTable $this->viewProperties['hide_all_views_icons'] = true; } + /** + * Whether or not to show the annotations view. This method has no effect if + * the Annotations plugin is not loaded. + */ + public function showAnnotationsView() + { + if (!Piwik_PluginsManager::getInstance()->isPluginLoaded('Annotations')) + { + return; + } + + $this->viewProperties['hide_annotations_view'] = false; + } + /** * Whether or not to show the bar chart icon. */ diff --git a/core/ViewDataTable/GenerateGraphHTML/ChartEvolution.php b/core/ViewDataTable/GenerateGraphHTML/ChartEvolution.php index 5974126d80..f4a7ac61b0 100644 --- a/core/ViewDataTable/GenerateGraphHTML/ChartEvolution.php +++ b/core/ViewDataTable/GenerateGraphHTML/ChartEvolution.php @@ -55,6 +55,7 @@ class Piwik_ViewDataTable_GenerateGraphHTML_ChartEvolution extends Piwik_ViewDat $this->disableShowAllViewsIcons(); $this->disableShowTable(); $this->disableShowAllColumns(); + $this->showAnnotationsView(); } /** diff --git a/lang/en.php b/lang/en.php index e8011d1089..4c0f8d04c3 100644 --- a/lang/en.php +++ b/lang/en.php @@ -1875,4 +1875,20 @@ And thank you for using Piwik!', 'Overlay_ErrorNotLoadingDetails' => 'Maybe the page loaded on the right doesn\'t have the Piwik tracker code. In this case, try launching Overlay for a different page from the pages report.', 'Overlay_ErrorNotLoadingDetailsSSL' => 'Since you\'re using Piwik over https, the most likely cause is that your website doesn\'t support SSL. Try using Piwik over http.', 'Overlay_ErrorNotLoadingLink' => 'Click here to get more tips for troubleshooting', + 'Annotations_PluginDescription' => 'Allows you to attach notes to different days so you will be remember why your data looks the way it does.', + 'Annotations_Annotations' => 'Annotations', + 'Annotations_EnterAnnotationText' => 'Enter your note...', + 'CoreHome_Annotations_IconDesc_js' => 'View notes for this date range.', + 'CoreHome_Annotations_IconDescHideNotes_js' => 'Hide notes for this date range.', + 'Annotations_NoAnnotations' => 'There are no notes for this date range.', + 'CoreHome_Annotations_ViewAndAddAnnotations_js' => 'View and add annotations for %s...', + 'CoreHome_Annotations_HideAnnotationsFor_js' => 'Hide annotations for %s...', + 'CoreHome_Annotations_AddAnnotationsFor_js' => 'Add annotations for %s...', + 'Annotations_ClickToAdd' => 'Click to add an annotation.', + 'Annotations_ClickToEdit' => 'Click to edit this annotation.', + 'Annotations_ClickToDelete' => 'Click to delete this annotation.', + 'Annotations_ClickToStarOrUnstar' => 'Click to star or unstar this annotation.', + 'Annotations_YouCannotModifyThisNote' => 'You cannot modify this note, because you did not create it, nor do you do not have admin access for this site.', + 'Annotations_CreateNewAnnotation' => 'Create new annotation...', + 'Annotations_LoginToAnnotate' => 'Login to create an annotation.', ); diff --git a/plugins/API/API.php b/plugins/API/API.php index 3be22d02f0..1c173ba628 100644 --- a/plugins/API/API.php +++ b/plugins/API/API.php @@ -634,7 +634,7 @@ class Piwik_API_API } } - return $availableReports; + return array_values($availableReports); // make sure array has contiguous key values } diff --git a/plugins/Annotations/API.php b/plugins/Annotations/API.php new file mode 100755 index 0000000000..cc49d59492 --- /dev/null +++ b/plugins/Annotations/API.php @@ -0,0 +1,364 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * @version $Id$ + * + * @category Piwik_Plugins + * @package Piwik_Annotations + */ + +/** + * @see plugins/Annotations/AnnotationList.php + */ +require_once PIWIK_INCLUDE_PATH.'/plugins/Annotations/AnnotationList.php'; + +/** + * API for annotations plugin. Provides methods to create, modify, delete & query + * annotations. + * + * @package Piwik_Annotations + */ +class Piwik_Annotations_API +{ + static private $instance = null; + + /** + * Returns this API's singleton instance. + * + * @return Piwik_Annotations_API + */ + static public function getInstance() + { + if (self::$instance == null) + { + self::$instance = new self; + } + return self::$instance; + } + + /** + * Create a new annotation for a site. + * + * @param string $idSite The site ID to add the annotation to. + * @param string $date The date the annotation is attached to. + * @param string $note The text of the annotation. + * @param string $starred Either 0 or 1. Whether the annotation should be starred. + * @return array Returns an array of two elements. The first element (indexed by + * 'annotation') is the new annotation. The second element (indexed + * by 'idNote' is the new note's ID). + */ + public function add( $idSite, $date, $note, $starred = 0 ) + { + // can only add a note to one site + if (!is_numeric($idSite)) + { + throw new Exception("Invalid idSite: '$idSite'. Note: Cannot add one note to multiple sites."); + } + + // make sure date is a valid date + Piwik_Date::factory($date); + + // check permissions + $this->checkUserCanAddNotesFor($idSite); + + // add, save & return a new annotation + $annotations = new Piwik_Annotations_AnnotationList($idSite); + + $newAnnotation = $annotations->add($idSite, $date, $note, $starred); + $annotations->save($idSite); + + return $newAnnotation; + } + + /** + * Modifies an annotation for a site and returns the modified annotation + * and its ID. + * + * If the current user is not allowed to modify an annotation, an exception + * will be thrown. A user can modify a note if: + * - the user has admin access for the site, OR + * - the user has view access, is not the anonymous user and is the user that + * created the note + * + * @param string $idSite The site ID to add the annotation to. + * @param string $idNote The ID of the note. + * @param string|null $date The date the annotation is attached to. If null, the annotation's + * date is not modified. + * @param string|null $note The text of the annotation. If null, the annotation's text + * is not modified. + * @param string|null $starred Either 0 or 1. Whether the annotation should be starred. + * If null, the annotation is not starred/un-starred. + * @return array Returns an array of two elements. The first element (indexed by + * 'annotation') is the new annotation. The second element (indexed + * by 'idNote' is the new note's ID). + */ + public function save( $idSite, $idNote, $date = null, $note = null, $starred = null ) + { + // cannot update notes for multiple sites + if (!is_numeric($idSite)) + { + throw new Exception("Invalid idSite: '$idSite'. Note: Cannot modify more than one note at a time."); + } + + // make sure date is a valid date + if ($date !== null) + { + Piwik_Date::factory($date); + } + + // get the annotations for the site + $annotations = new Piwik_Annotations_AnnotationList($idSite); + + // check permissions + $this->checkUserCanModifyOrDelete($idSite, $annotations->get($idSite, $idNote)); + + // modify the annotation, and save the whole list + $annotations->update($idSite, $idNote, $date, $note, $starred); + $annotations->save($idSite); + + return $annotations->get($idSite, $idNote); + } + + /** + * Removes an annotation from a site's list of annotations. + * + * If the current user is not allowed to delete the annotation, an exception + * will be thrown. A user can delete a note if: + * - the user has admin access for the site, OR + * - the user has view access, is not the anonymous user and is the user that + * created the note + * + * @param string $idSite The site ID to add the annotation to. + * @param string $idNote The ID of the note to delete. + */ + public function delete( $idSite, $idNote ) + { + // check that $idSite is single + if (!is_numeric($idSite)) + { + throw new Exception("Invalid idSite: '$idSite'. Note: Cannot delete multiple notes."); + } + + $annotations = new Piwik_Annotations_AnnotationList($idSite); + + // check permissions + $this->checkUserCanModifyOrDelete($idSite, $annotations->get($idSite, $idNote)); + + // remove the note & save the list + $annotations->remove($idSite, $idNote); + $annotations->save($idSite); + } + + /** + * Returns a single note for one site. + * + * @param string $idSite The site ID to add the annotation to. + * @param string $idNote The ID of the note to get. + * @return array The annotation. It will contain the following properties: + * - date: The date the annotation was recorded for. + * - note: The note text. + * - starred: Whether the note is starred or not. + * - user: The user that created the note. + * - canEditOrDelete: Whether the user that called this method can edit or + * delete the annotation returned. + */ + public function get( $idSite, $idNote ) + { + // getting a single note means only ONE idSite + if (!is_numeric($idSite)) + { + throw new Exception("Invalid idSite: '$idSite'. Note: Specify only one site ID when getting ONE note."); + } + + Piwik::checkUserHasViewAccess($idSite); + + // get single annotation + $annotations = new Piwik_Annotations_AnnotationList($idSite); + return $annotations->get($idSite, $idNote); + } + + /** + * Returns every annotation for a specific site within a specific date range. + * The date range is specified by a date, the period type (day/week/month/year) + * and an optional number of N periods in the past to include. + * + * @param string $idSite The site ID to add the annotation to. Can be one ID or + * a list of site IDs. + * @param string|false $date The date of the period. + * @param string $period The period type. + * @param int|false $lastN Whether to include the last N number of periods in the + * date range or not. + * @return array An array that indexes arrays of annotations by site ID. ie, + * array( + * 5 => array( + * array(...), // annotation #1 + * array(...), // annotation #2 + * ), + * 8 => array(...) + * ) + */ + public function getAll( $idSite, $date = false, $period = 'day', $lastN = false ) + { + Piwik::checkUserHasViewAccess($idSite); + + $annotations = new Piwik_Annotations_AnnotationList($idSite); + + // if date/period are supplied, determine start/end date for search + list($startDate, $endDate) = self::getDateRangeForPeriod($date, $period, $lastN); + + return $annotations->search($startDate, $endDate); + } + + /** + * Returns the count of annotations for a list of periods, including the count of + * starred annotations. + * + * @param string $idSite The site ID to add the annotation to. + * @param string|false $date The date of the period. + * @param string $period The period type. + * @param int|false $lastN Whether to get counts for the last N number of periods or not. + * @return array An array mapping site IDs to arrays holding dates & the count of + * annotations made for those dates. eg, + * array( + * 5 => array( + * array('2012-01-02', array('count' => 4, 'starred' => 2)), + * array('2012-01-03', array('count' => 0, 'starred' => 0)), + * array('2012-01-04', array('count' => 2, 'starred' => 0)), + * ), + * 6 => array( + * array('2012-01-02', array('count' => 1, 'starred' => 0)), + * array('2012-01-03', array('count' => 4, 'starred' => 3)), + * array('2012-01-04', array('count' => 2, 'starred' => 0)), + * ), + * ... + * ) + */ + public function getAnnotationCountForDates( $idSite, $date, $period, $lastN = false ) + { + Piwik::checkUserHasViewAccess($idSite); + + // get start & end date for request. lastN is ignored if $period == 'range' + list($startDate, $endDate) = self::getDateRangeForPeriod($date, $period, $lastN); + if ($period == 'range') + { + $period = 'day'; + } + + // create list of dates + $dates = array(); + for (; $startDate->getTimestamp() <= $endDate->getTimestamp(); $startDate = $startDate->addPeriod(1, $period)) + { + $dates[] = $startDate; + } + // we add one for the end of the last period (used in for loop below to bound annotation dates) + $dates[] = $startDate; + + // get annotations for the site + $annotations = new Piwik_Annotations_AnnotationList($idSite); + + // create result w/ 0-counts + $result = array(); + for ($i = 0; $i != count($dates) - 1; ++$i) + { + $date = $dates[$i]; + $nextDate = $dates[$i + 1]; + $strDate = $date->toString(); + + foreach ($annotations->getIdSites() as $idSite) + { + $result[$idSite][$strDate] = $annotations->count($idSite, $date, $nextDate); + } + } + + // convert associative array into array of pairs (so it can be traversed by index) + $pairResult = array(); + foreach ($result as $idSite => $counts) + { + foreach ($counts as $date => $count) + { + $pairResult[$idSite][] = array($date, $count); + } + } + return $pairResult; + } + + /** + * Throws if the current user is not allowed to modify or delete an annotation. + * + * @param int $idSite The site ID the annotation belongs to. + * @param array $annotation The annotation. + * @throws Exception if the current user is not allowed to modify/delete $annotation. + */ + private function checkUserCanModifyOrDelete( $idSite, $annotation ) + { + if (!$annotation['canEditOrDelete']) + { + throw new Exception(Piwik_Translate('Annotations_YouCannotModifyThisNote')); + } + } + + /** + * Throws if the current user is not allowed to create annotations for a site. + * + * @param int $idSite The site ID. + * @throws Exception if the current user is anonymous or does not have view access + * for site w/ id=$idSite. + */ + private static function checkUserCanAddNotesFor( $idSite ) + { + if (!Piwik_Annotations_AnnotationList::canUserAddNotesFor($idSite)) + { + throw new Exception("The current user is not allowed to add notes for site #$idSite."); + } + } + + /** + * Returns start & end dates for the range described by a period and optional lastN + * argument. + * + * @param string $date|false The start date of the period (or the date range of a range + * period). + * @param string $period The period type ('day', 'week', 'month', 'year' or 'range'). + * @param int|false $lastN Whether to include the last N periods in the range or not. + * Ignored if period == range. + * + * @ignore + */ + public static function getDateRangeForPeriod( $date, $period, $lastN = false ) + { + if ($date === false) + { + return array(false, false); + } + + // if the range is just a normal period (or the period is a range in which case lastN is ignored) + if ($lastN === false + || $period == 'range') + { + if ($period == 'range') + { + $oPeriod = new Piwik_Period_Range('day', $date); + } + else + { + $oPeriod = Piwik_Period::factory($period, Piwik_Date::factory($date)); + } + + $startDate = $oPeriod->getDateStart(); + $endDate = $oPeriod->getDateEnd(); + } + else // if the range includes the last N periods + { + list($date, $lastN) = + Piwik_ViewDataTable_GenerateGraphHTML_ChartEvolution::getDateRangeAndLastN($period, $date, $lastN); + list($startDate, $endDate) = explode(',', $date); + + $startDate = Piwik_Date::factory($startDate); + $endDate = Piwik_Date::factory($endDate); + } + return array($startDate, $endDate); + } +} diff --git a/plugins/Annotations/AnnotationList.php b/plugins/Annotations/AnnotationList.php new file mode 100755 index 0000000000..8031d5c468 --- /dev/null +++ b/plugins/Annotations/AnnotationList.php @@ -0,0 +1,446 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * @version $Id$ + * + * @category Piwik_Plugins + * @package Piwik_Annotations + */ + +/** + * This class can be used to query & modify annotations for multiple sites + * at once. + * + * Example use: + * $annotations = new Piwik_Annotations_AnnotationList($idSites = "1,2,5"); + * $annotation = $annotations->get($idSite = 1, $idNote = 4); + * // do stuff w/ annotation + * $annotations->update($idSite = 2, $idNote = 4, $note = "This is the new text."); + * $annotations->save($idSite); + * + * Note: There is a concurrency issue w/ this code. If two users try to save + * an annotation for the same site, it's possible one of their changes will + * never get made (as it will be overwritten by the other's). + * + * @package Piwik_Annotations + */ +class Piwik_Annotations_AnnotationList +{ + const ANNOTATION_COLLECTION_OPTION_SUFFIX = '_annotations'; + + /** + * List of site IDs this instance holds annotations for. + * + * @var array + */ + private $idSites; + + /** + * Array that associates lists of annotations with site IDs. + * + * @var array + */ + private $annotations; + + /** + * Constructor. Loads annotations from the database. + * + * @param string|int $idSites The list of site IDs to load annotations for. + */ + public function __construct( $idSites ) + { + if ($idSites === 'all') + { + $this->idSites = Piwik_SitesManager_API::getInstance()->getSitesIdWithAtLeastViewAccess(); + } + else + { + $this->idSites = Piwik_Site::getIdSitesFromIdSitesString($idSites); + } + + $this->annotations = $this->getAnnotationsForSite(); + } + + /** + * Returns the list of site IDs this list contains annotations for. + * + * @return array + */ + public function getIdSites() + { + return $this->idSites; + } + + /** + * Creates a new annotation for a site. This method does not perist the result. + * To save the new annotation in the database, call $this->save. + * + * @param int $idSite The ID of the site to add an annotation to. + * @param string $date The date the annotation is in reference to. + * @param string $note The text of the new annotation. + * @param int $starred Either 1 or 0. If 1, the new annotation has been starred, + * otherwise it will start out unstarred. + * @return array The added annotation. + * @throws Exception if $idSite is not an ID that was supplied upon construction. + */ + public function add($idSite, $date, $note, $starred = 0) + { + $this->checkIdSiteIsLoaded($idSite); + + $this->annotations[$idSite][] = self::makeAnnotation($date, $note, $starred); + + // get the id of the new annotation + end($this->annotations[$idSite]); + $newNoteId = key($this->annotations[$idSite]); + + return $this->get($idSite, $newNoteId); + } + + /** + * Persists the annotations list for a site, overwriting whatever exists. + * + * @param int $idSite The ID of the site to save annotations for. + * @throws Exception if $idSite is not an ID that was supplied upon construction. + */ + public function save($idSite) + { + $this->checkIdSiteIsLoaded($idSite); + + $optionName = self::getAnnotationCollectionOptionName($idSite); + Piwik_SetOption($optionName, serialize($this->annotations[$idSite])); + } + + /** + * Modifies an annotation in this instance's collection of annotations. + * + * Note: This method does not perist the change in the DB. The save method must + * be called for that. + * + * @param int $idSite The ID of the site whose annotation will be updated. + * @param int $idNote The ID of the note. + * @param string|null $date The new date of the annotation, eg '2012-01-01'. If + * null, no change is made. + * @param string|null $note The new text of the annotation. If null, no change + * is made. + * @param int|null $starred Either 1 or 0, whether the annotation should be + * starred or not. If null, no change is made. + * @throws Exception if $idSite is not an ID that was supplied upon construction. + * @throws Exception if $idNote does not refer to valid note for the site. + */ + public function update( $idSite, $idNote, $date = null, $note = null, $starred = null ) + { + $this->checkIdSiteIsLoaded($idSite); + $this->checkNoteExists($idSite, $idNote); + + $annotation =& $this->annotations[$idSite][$idNote]; + if ($date !== null) + { + $annotation['date'] = $date; + } + if ($note !== null) + { + $annotation['note'] = $note; + } + if ($starred !== null) + { + $annotation['starred'] = $starred; + } + } + + /** + * Removes a note from a site's collection of annotations. + * + * Note: This method does not perist the change in the DB. The save method must + * be called for that. + * + * @param int $idSite The ID of the site whose annotation will be updated. + * @param int $idNote The ID of the note. + * @throws Exception if $idSite is not an ID that was supplied upon construction. + * @throws Exception if $idNote does not refer to valid note for the site. + */ + public function remove( $idSite, $idNote ) + { + $this->checkIdSiteIsLoaded($idSite); + $this->checkNoteExists($idSite, $idNote); + + unset($this->annotations[$idSite][$idNote]); + } + + /** + * Retrieves an annotation by ID. + * + * This function returns an array with the following elements: + * - idNote: The ID of the annotation. + * - date: The date of the annotation. + * - note: The text of the annotation. + * - starred: 1 or 0, whether the annotation is stared; + * - user: (unless current user is anonymous) The user that created the annotation. + * - canEditOrDelete: True if the user can edit/delete the annotation. + * + * @param int $idSite The ID of the site to get an annotation for. + * @param int $idNote The ID of the note to get. + * @param array The annotation. + * @throws Exception if $idSite is not an ID that was supplied upon construction. + * @throws Exception if $idNote does not refer to valid note for the site. + */ + public function get( $idSite, $idNote ) + { + $this->checkIdSiteIsLoaded($idSite); + $this->checkNoteExists($idSite, $idNote); + + $annotation = $this->annotations[$idSite][$idNote]; + $this->augmentAnnotationData($idSite, $idNote, $annotation); + return $annotation; + } + + /** + * Returns all annotations within a specific date range. The result is + * an array that maps site IDs with arrays of annotations within the range. + * + * Note: The date range is inclusive. + * + * @see self::get for info on what attributes stored within annotations. + * + * @param Piwik_Date $startDate The start of the date range. + * @param Piwik_Date $endDate THe end of the date range. + * @return array Array mapping site IDs with arrays of annotations, eg: + * array( + * '5' => array( + * array(...), // annotation + * array(...), // annotation + * ... + * ), + * '6' => array( + * array(...), // annotation + * array(...), // annotation + * ... + * ), + * ) + */ + public function search( $startDate, $endDate ) + { + // collect annotations that are within the right date range & belong to the right + // report + $result = array(); + foreach ($this->annotations as $idSite => $annotationForSite) + { + foreach ($annotationForSite as $idNote => $annotation) + { + if ($startDate !== false) + { + $annotationDate = Piwik_Date::factory($annotation['date']); + if ($annotationDate->getTimestamp() < $startDate->getTimestamp() + || $annotationDate->getTimestamp() > $endDate->getTimestamp()) + { + continue; + } + } + + $this->augmentAnnotationData($idSite, $idNote, $annotation); + $result[$idSite][] = $annotation; + } + + // sort by annotation date + if (!empty($result[$idSite])) + { + uasort($result[$idSite], array($this, 'compareAnnotationDate')); + } + } + return $result; + } + + /** + * Counts annotations & starred annotations within a date range and returns + * the counts. The date range includes the start date, but not the end date. + * + * @param int $idSite The ID of the site to count annotations for. + * @param string|false $startDate The start date of the range or false if no + * range check is desired. + * @param string|false $endDate The end date of the range or false if no + * range check is desired. + * @return array eg, array('count' => 5, 'starred' => 2) + */ + public function count( $idSite, $startDate, $endDate ) + { + $this->checkIdSiteIsLoaded($idSite); + + // count the annotations + $count = $starred = 0; + foreach ($this->annotations[$idSite] as $annotation) + { + $annotationDate = Piwik_Date::factory($annotation['date']); + + // if annotation start date is between start date & end date, increment count + if ($annotationDate->getTimestamp() >= $startDate->getTimestamp() + && $annotationDate->getTimestamp() < $endDate->getTimestamp()) + { + ++$count; + + if ($annotation['starred']) + { + ++$starred; + } + } + } + + return array('count' => $count, 'starred' => $starred); + } + + /** + * Utility function. Creates a new annotation. + * + * @param string $date + * @param string $note + * @param int $starred + */ + private function makeAnnotation( $date, $note, $starred = 0 ) + { + return array('date' => $date, + 'note' => $note, + 'starred' => (int)$starred, + 'user' => Piwik::getCurrentUserLogin()); + } + + /** + * Retrieves annotations from the database for the sites supplied to the + * constructor. + * + * @return array Lists of annotations mapped by site ID. + */ + private function getAnnotationsForSite() + { + $result = array(); + foreach ($this->idSites as $id) + { + $optionName = self::getAnnotationCollectionOptionName($id); + $serialized = Piwik_GetOption($optionName); + + if ($serialized !== false) + { + $result[$id] = unserialize($serialized); + } + else + { + $result[$id] = array(); + } + } + return $result; + } + + /** + * Utility function that checks if a site ID was supplied and if not, + * throws an exception. + * + * We can only modify/read annotations for sites that we've actually + * loaded the annotations for. + * + * @param int $idSite + * @throws Exception + */ + private function checkIdSiteIsLoaded( $idSite ) + { + if (!in_array($idSite, $this->idSites)) + { + throw new Exception("This AnnotationList was not initialized with idSite '$idSite'."); + } + } + + /** + * Utility function that checks if a note exists for a site, and if not, + * throws an exception. + * + * @param int $idSite + * @param int $idNote + * @throws Exception + */ + private function checkNoteExists( $idSite, $idNote ) + { + if (empty($this->annotations[$idSite][$idNote])) + { + throw new Exception("There is no note with id '$idNote' for site with id '$idSite'."); + } + } + + /** + * Returns true if the current user can modify or delete a specific annotation. + * + * A user can modify/delete a note if the user has admin access for the site OR + * the user has view access, is not the anonymous user and is the user that + * created the note in question. + * + * @param int $idSite The site ID the annotation belongs to. + * @param array $annotation The annotation. + * @return bool + */ + public static function canUserModifyOrDelete( $idSite, $annotation ) + { + // user can save if user is admin or if has view access, is not anonymous & is user who wrote note + $canEdit = Piwik::isUserHasAdminAccess($idSite) + || (!Piwik::isUserIsAnonymous() + && Piwik::getCurrentUserLogin() == $annotation['user']); + return $canEdit; + } + + /** + * Adds extra data to an annotation, including the annotation's ID and whether + * the current user can edit or delete it. + * + * Also, if the current user is anonymous, the user attribute is removed. + * + * @param int $idSite + * @param int $idNote + * @param array $annotation + */ + private function augmentAnnotationData( $idSite, $idNote, &$annotation ) + { + $annotation['idNote'] = $idNote; + $annotation['canEditOrDelete'] = self::canUserModifyOrDelete($idSite, $annotation); + + // we don't supply user info if the current user is anonymous + if (Piwik::isUserIsAnonymous()) + { + unset($annotation['user']); + } + } + + /** + * Utility function that compares two annotations. + * + * @param array $lhs An annotation. + * @param array $rhs An annotation. + * @return int -1, 0 or 1 + */ + public function compareAnnotationDate( $lhs, $rhs ) + { + if ($lhs['date'] == $rhs['date']) + { + return $lhs['idNote'] <= $rhs['idNote'] ? -1 : 1; + } + + return $lhs['date'] < $rhs['date'] ? -1 : 1; // string comparison works because date format should be YYYY-MM-DD + } + + /** + * Returns true if the current user can add notes for a specific site. + * + * @param int $idSite The site to add notes to. + */ + public static function canUserAddNotesFor( $idSite ) + { + return Piwik::isUserHasViewAccess($idSite) + && !Piwik::isUserIsAnonymous($idSite); + } + + /** + * Returns the option name used to store annotations for a site. + * + * @param int $idSite The site ID. + */ + public static function getAnnotationCollectionOptionName( $idSite ) + { + return $idSite.self::ANNOTATION_COLLECTION_OPTION_SUFFIX; + } +} diff --git a/plugins/Annotations/Annotations.php b/plugins/Annotations/Annotations.php new file mode 100755 index 0000000000..ed339812e6 --- /dev/null +++ b/plugins/Annotations/Annotations.php @@ -0,0 +1,70 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * @version $Id$ + * + * @category Piwik_Plugins + * @package Piwik_Annotations + */ + +/** + * Annotations plugins. Provides the ability to attach text notes to + * dates for each sites. Notes can be viewed, modified, deleted or starred. + * + * @package Piwik_Annotations + */ +class Piwik_Annotations extends Piwik_Plugin +{ + /** + * Returns information about this plugin. + * + * @return array + */ + public function getInformation() + { + return array( + 'description' => Piwik_Translate('Annotations_PluginDescription'), + 'author' => 'Piwik', + 'author_homepage' => 'http://piwik.org/', + 'version' => Piwik_Version::VERSION, + ); + } + + /** + * Returns list of event hooks. + * + * @return array + */ + public function getListHooksRegistered() + { + return array( + 'AssetManager.getCssFiles' => 'getCssFiles', + 'AssetManager.getJsFiles' => 'getJsFiles' + ); + } + + /** + * Adds css files for this plugin to the list in the event notification. + * + * @param Piwik_Event_Notification $notification notification object + */ + function getCssFiles( $notification ) + { + $cssFiles = &$notification->getNotificationObject(); + $cssFiles[] = "plugins/Annotations/templates/styles.css"; + } + + /** + * Adds js files for this plugin to the list in the event notification. + * + * @param Piwik_Event_Notification $notification notification object + */ + function getJsFiles( $notification ) + { + $jsFiles = &$notification->getNotificationObject(); + $jsFiles[] = "plugins/Annotations/templates/annotations.js"; + } +} diff --git a/plugins/Annotations/Controller.php b/plugins/Annotations/Controller.php new file mode 100755 index 0000000000..0912e9aac5 --- /dev/null +++ b/plugins/Annotations/Controller.php @@ -0,0 +1,226 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * @version $Id$ + * + * @category Piwik_Plugins + * @package Piwik_Annotations + */ + +/** + * Controller for the Annotations plugin. + * + * @package Piwik_Annotations + */ +class Piwik_Annotations_Controller extends Piwik_Controller +{ + /** + * Controller action that returns HTML displaying annotations for a site and + * specific date range. + * + * Query Param Input: + * - idSite: The ID of the site to get annotations for. Only one allowed. + * - date: The date to get annotations for. If lastN is not supplied, this is the start date, + * otherwise the start date in the last period. + * - period: The period type. + * - lastN: If supplied, the last N # of periods will be included w/ the range specified + * by date + period. + * + * Output: + * - HTML displaying annotations for a specific range. + * + * @param bool $fetch True if the annotation manager should be returned as a string, + * false if it should be echo-ed. + * @param string $date Override for 'date' query parameter. + * @param string $period Override for 'period' query parameter. + * @param string $lastN Override for 'lastN' query parameter. + * @return string|void + */ + public function getAnnotationManager( $fetch = false, $date = false, $period = false, $lastN = false ) + { + $idSite = Piwik_Common::getRequestVar('idSite'); + + if ($date === false) + { + $date = Piwik_Common::getRequestVar('date', false); + } + + if ($period === false) + { + $period = Piwik_Common::getRequestVar('period', 'day'); + } + + if ($lastN === false) + { + $lastN = Piwik_Common::getRequestVar('lastN', false); + } + + // create & render the view + $view = Piwik_View::factory('annotationManager'); + + $allAnnotations = Piwik_API_Request::processRequest( + 'Annotations.getAll', array('date' => $date, 'period' => $period, 'lastN' => $lastN)); + $view->annotations = empty($allAnnotations[$idSite]) ? array() : $allAnnotations[$idSite]; + + $view->period = $period; + $view->lastN = $lastN; + + list($startDate, $endDate) = Piwik_Annotations_API::getDateRangeForPeriod($date, $period, $lastN); + $view->startDate = $startDate->toString(); + $view->endDate = $endDate->toString(); + + $dateFormat = Piwik_Translate('CoreHome_ShortDateFormatWithYear'); + $view->startDatePretty = $startDate->getLocalized($dateFormat); + $view->endDatePretty = $endDate->getLocalized($dateFormat); + + $view->canUserAddNotes = Piwik_Annotations_AnnotationList::canUserAddNotesFor($idSite); + + if ($fetch) + { + return $view->render(); + } + else + { + echo $view->render(); + } + } + + /** + * Controller action that modifies an annotation and returns HTML displaying + * the modified annotation. + * + * Query Param Input: + * - idSite: The ID of the site the annotation belongs to. Only one ID is allowed. + * - idNote: The ID of the annotation. + * - date: The new date value for the annotation. (optional) + * - note: The new text for the annotation. (optional) + * - starred: Either 1 or 0. Whether the note should be starred or not. (optional) + * + * Output: + * - HTML displaying modified annotation. + * + * If an optional query param is not supplied, that part of the annotation is + * not modified. + */ + public function saveAnnotation() + { + if ($_SERVER["REQUEST_METHOD"] == "POST") + { + $this->checkTokenInUrl(); + + $view = Piwik_View::factory('annotation'); + + // NOTE: permissions checked in API method + // save the annotation + $view->annotation = Piwik_API_Request::processRequest("Annotations.save"); + + echo $view->render(); + } + } + + /** + * Controller action that adds a new annotation for a site and returns new + * annotation manager HTML for the site and date range. + * + * Query Param Input: + * - idSite: The ID of the site to add an annotation to. + * - date: The date for the new annotation. + * - note: The text of the annotation. + * - starred: Either 1 or 0, whether the annotation should be starred or not. + * Defaults to 0. + * - managerDate: The date for the annotation manager. If a range is given, the start + * date is used for the new annotation. + * - managerPeriod: For rendering the annotation manager. @see self::getAnnotationManager + * for more info. + * - lastN: For rendering the annotation manager. @see self::getAnnotationManager + * for more info. + * Output: + * - @see self::getAnnotationManager + */ + public function addAnnotation() + { + if ($_SERVER["REQUEST_METHOD"] == "POST") + { + $this->checkTokenInUrl(); + + // the date used is for the annotation manager HTML that gets echo'd. we + // use this date for the new annotation, unless it is a date range, in + // which case we use the first date of the range. + $date = Piwik_Common::getRequestVar('date'); + if (strpos($date, ',') !== false) + { + $date = reset(explode(',', $date)); + } + + // add the annotation. NOTE: permissions checked in API method + Piwik_API_Request::processRequest("Annotations.add", array('date' => $date)); + + $managerDate = Piwik_Common::getRequestVar('managerDate', false); + $managerPeriod = Piwik_Common::getRequestVar('managerPeriod', false); + echo $this->getAnnotationManager($fetch = true, $managerDate, $managerPeriod); + } + } + + /** + * Controller action that deletes an annotation and returns new annotation + * manager HTML for the site & date range. + * + * Query Param Input: + * - idSite: The ID of the site this annotation belongs to. + * - idNote: The ID of the annotation to delete. + * - date: For rendering the annotation manager. @see self::getAnnotationManager + * for more info. + * - period: For rendering the annotation manager. @see self::getAnnotationManager + * for more info. + * - lastN: For rendering the annotation manager. @see self::getAnnotationManager + * for more info. + * + * Output: + * - @see self::getAnnotationManager + */ + public function deleteAnnotation() + { + if ($_SERVER["REQUEST_METHOD"] == "POST") + { + $this->checkTokenInUrl(); + + // delete annotation. NOTE: permissions checked in API method + Piwik_API_Request::processRequest("Annotations.delete"); + + echo $this->getAnnotationManager($fetch = true); + } + } + + /** + * Controller action that echo's HTML that displays marker icons for an + * evolution graph's x-axis. The marker icons still need to be positioned + * by the JavaScript. + * + * Query Param Input: + * - idSite: The ID of the site this annotation belongs to. Only one is allowed. + * - date: The date to check for annotations. If lastN is not supplied, this is + * the start of the date range used to check for annotations. If supplied, + * this is the start of the last period in the date range. + * - period: The period type. + * - lastN: If supplied, the last N # of periods are included in the date range + * used to check for annotations. + * + * Output: + * - HTML that displays marker icons for an evolution graph based on the + * number of annotations & starred annotations in the graph's date range. + */ + public function getEvolutionIcons() + { + // get annotation the count + $annotationCounts = Piwik_API_Request::processRequest("Annotations.getAnnotationCountForDates"); + + // create & render the view + $view = Piwik_View::factory('evolutionAnnotations'); + $view->annotationCounts = reset($annotationCounts); // only one idSite allowed for this action + + echo $view->render(); + } +} diff --git a/plugins/Annotations/templates/annotation.tpl b/plugins/Annotations/templates/annotation.tpl new file mode 100755 index 0000000000..1a94cf60d6 --- /dev/null +++ b/plugins/Annotations/templates/annotation.tpl @@ -0,0 +1,43 @@ +<tr class="annotation" data-id="{$annotation.idNote}" data-date="{$annotation.date}"> + <td class="annotation-meta"> + <div class="annotation-star{if $annotation.canEditOrDelete} annotation-star-changeable{/if}" data-starred="{$annotation.starred}" {if $annotation.canEditOrDelete}title="{'Annotations_ClickToStarOrUnstar'|translate}"{/if}> + {if $annotation.starred} + <img src="themes/default/images/star.png"/> + {else} + <img src="themes/default/images/star_empty.png"/> + {/if} + </div> + <div class="annotation-period {if $annotation.canEditOrDelete}annotation-enter-edit-mode{/if}">({$annotation.date})</div> + {if $annotation.canEditOrDelete} + <div class="annotation-period-edit" style="display:none"> + <a href="#">{$annotation.date}</a> + <div class="datepicker" style="display:none"/> + </div> + {/if} + </td> + <td class="annotation-value"> + <div class="annotation-view-mode"> + <span {if $annotation.canEditOrDelete}title="{'Annotations_ClickToEdit'|translate}" class="annotation-enter-edit-mode"{/if}>{$annotation.note|unescape|escape:'html'}</span> + {if $annotation.canEditOrDelete} + <a href="#" class="edit-annotation annotation-enter-edit-mode" title="{'Annotations_ClickToEdit'|translate}">{'General_Edit'|translate}...</a> + {/if} + </div> + {if $annotation.canEditOrDelete} + <div class="annotation-edit-mode" style="display:none"> + <input class="annotation-edit" type="text" value="{$annotation.note|unescape|escape:'html'}"/> + <br/> + <input class="annotation-save submit" type="button" value="{'General_Save'|translate}"/> + <input class="annotation-cancel submit" type="button" value="{'General_Cancel'|translate}"/> + </div> + {/if} + </td> + {if isset($annotation.user) && $userLogin != 'anonymous'} + <td class="annotation-user-cell"> + <span class="annotation-user">{$annotation.user|unescape|escape:'html'}</span><br/> + {if $annotation.canEditOrDelete} + <a href="#" class="delete-annotation" style="display:none" title="{'Annotations_ClickToDelete'|translate}">Delete</a> + {/if} + </td> + {/if} +</tr> + diff --git a/plugins/Annotations/templates/annotationManager.tpl b/plugins/Annotations/templates/annotationManager.tpl new file mode 100755 index 0000000000..4afa631cfd --- /dev/null +++ b/plugins/Annotations/templates/annotationManager.tpl @@ -0,0 +1,27 @@ +<div class="annotation-manager" + {if $startDate neq $endDate}data-date="{$startDate},{$endDate}" data-period="range" + {else}data-date="{$startDate}" data-period="{$period}" + {/if}> + +<div class="annotations-header"> + <span>{'Annotations_Annotations'|translate}</span> +</div> + +<div class="annotation-list-range">{$startDatePretty}{if $startDate neq $endDate} — {$endDatePretty}{/if}</div> + +<div class="annotation-list"> +{include file="Annotations/templates/annotations.tpl"} + +<span class="loadingPiwik" style="display:none"><img src="themes/default/images/loading-blue.gif"/>{'General_Loading_js'|translate}</span> + +</div> + +<div class="annotation-controls"> + {if $canUserAddNotes} + <a href="#" class="add-annotation" title="{'Annotations_ClickToAdd'|translate}">{'Annotations_CreateNewAnnotation'|translate}</a> + {elseif $userLogin eq 'anonymous'} + <a href="index.php?module=Login">{'Annotations_LoginToAnnotate'|translate}</a> + {/if} +</div> + +</div> diff --git a/plugins/Annotations/templates/annotations.js b/plugins/Annotations/templates/annotations.js new file mode 100755 index 0000000000..0a7ed20edd --- /dev/null +++ b/plugins/Annotations/templates/annotations.js @@ -0,0 +1,583 @@ +/*! + * Piwik - Web Analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +(function($, piwik) { + +var annotationsApi = { + + // calls Annotations.getAnnotationManager + getAnnotationManager: function(idSite, date, period, lastN, callback) + { + var ajaxParams = + { + module: 'Annotations', + action: 'getAnnotationManager', + idSite: idSite, + date: date, + period: period, + }; + if (lastN) + { + ajaxParams.lastN = lastN; + } + + var ajaxRequest = new ajaxHelper(); + ajaxRequest.addParams(ajaxParams, 'get'); + ajaxRequest.setCallback(callback); + ajaxRequest.setFormat('html'); + ajaxRequest.send(false); + }, + + // calls Annotations.addAnnotation + addAnnotation: function(idSite, managerDate, managerPeriod, date, note, callback) + { + var ajaxParams = + { + module: 'Annotations', + action: 'addAnnotation', + idSite: idSite, + date: date, + managerDate: managerDate, + managerPeriod: managerPeriod, + note: note + }; + + var ajaxRequest = new ajaxHelper(); + ajaxRequest.addParams(ajaxParams, 'get'); + ajaxRequest.addParams({token_auth: piwik.token_auth}, 'post'); + ajaxRequest.setCallback(callback); + ajaxRequest.setFormat('html'); + ajaxRequest.send(false); + }, + + // calls Annotations.saveAnnotation + saveAnnotation: function(idSite, idNote, date, noteData, callback) + { + var ajaxParams = + { + module: 'Annotations', + action: 'saveAnnotation', + idSite: idSite, + idNote: idNote, + date: date + }; + + for (var key in noteData) + { + ajaxParams[key] = noteData[key]; + } + + var ajaxRequest = new ajaxHelper(); + ajaxRequest.addParams(ajaxParams, 'get'); + ajaxRequest.addParams({token_auth: piwik.token_auth}, 'post'); + ajaxRequest.setCallback(callback); + ajaxRequest.setFormat('html'); + ajaxRequest.send(false); + }, + + // calls Annotations.deleteAnnotation + deleteAnnotation: function(idSite, idNote, managerDate, managerPeriod, callback) + { + var ajaxParams = + { + module: 'Annotations', + action: 'deleteAnnotation', + idSite: idSite, + idNote: idNote, + date: managerDate, + period: managerPeriod + }; + + var ajaxRequest = new ajaxHelper(); + ajaxRequest.addParams(ajaxParams, 'get'); + ajaxRequest.addParams({token_auth: piwik.token_auth}, 'post'); + ajaxRequest.setCallback(callback); + ajaxRequest.setFormat('html'); + ajaxRequest.send(false); + }, + + // calls Annotations.getEvolutionIcons + getEvolutionIcons: function(idSite, date, period, lastN, callback) + { + var ajaxParams = + { + module: 'Annotations', + action: 'getEvolutionIcons', + idSite: idSite, + date: date, + period: period, + }; + if (lastN) + { + ajaxParams.lastN = lastN; + } + + var ajaxRequest = new ajaxHelper(); + ajaxRequest.addParams(ajaxParams, 'get'); + ajaxRequest.setFormat('html'); + ajaxRequest.setCallback(callback); + ajaxRequest.send(false); + }, +}; + +var today = new Date(); + +/** + * Returns options to configure an annotation's datepicker shown in edit mode. + * + * @param {Element} annotation The annotation element. + */ +var getDatePickerOptions = function(annotation) +{ + var annotationDateStr = annotation.attr('data-date'), + parts = annotationDateStr.split('-'), + annotationDate = new Date(parts[0], parts[1] - 1, parts[2]); + + var result = piwik.getBaseDatePickerOptions(annotationDate); + + // make sure days before site start & after today cannot be selected + var piwikMinDate = result.minDate; + result.beforeShowDay = function (date) + { + var valid = true; + + // if date is after today or before date of site creation, it cannot be selected + if (date > today + || date < piwikMinDate) + { + valid = false; + } + + return [valid, '']; + }; + + // on select a date, change the text of the edit date link + result.onSelect = function (dateText) + { + $('.annotation-period-edit>a', annotation).text(dateText); + }; + + return result; +}; + +/** + * Switches the current mode of an annotation between the view/edit modes. + * + * @param {Element} inAnnotationElement An element within the annotation to toggle the mode of. + * Should be two levels nested in the .annotation-value + * element. + * @return {Element} The .annotation-value element. + */ +var toggleAnnotationMode = function(inAnnotationElement) +{ + var annotation = $(inAnnotationElement).closest('.annotation'); + $('.annotation-period,.annotation-period-edit,.delete-annotation,' + + '.annotation-edit-mode,.annotation-view-mode', annotation).toggle(); + + return $(inAnnotationElement).find('.annotation-value'); +}; + +/** + * Creates the datepicker for an annotation element. + * + * @param {Element} annotation The annotation element. + */ +var createDatePicker = function ( annotation ) +{ + $('.datepicker', annotation).datepicker(getDatePickerOptions(annotation)).hide(); +}; + +/** + * Creates datepickers for every period edit in an annotation manager. + * + * @param {Element} manager The annotation manager element. + */ +var createDatePickers = function ( manager ) +{ + $('.annotation-period-edit', manager).each(function() { + createDatePicker($(this).parent().parent()); + }); +} + +/** + * Replaces the HTML of an annotation manager element, and resets date/period + * attributes. + * + * @param {Element} manager The annotation manager. + * @param {string} tml The HTML of the new annotation manager. + */ +var replaceAnnotationManager = function(manager, html) +{ + var newManager = $(html); + manager.html(newManager.html()) + .attr('data-date', newManager.attr('data-date')) + .attr('data-period', newManager.attr('data-period')); + createDatePickers(manager); +}; + +/** + * Returns true if an annotation element is starred, false if otherwise. + * + * @param {Element} annotation The annotation element. + * @return {bool} + */ +var isAnnotationStarred = function(annotation) +{ + return +$('.annotation-star', annotation).attr('data-starred') == 1 ? true : false; +}; + +/** + * Replaces the HTML of an annotation element with HTML returned from Piwik, and + * makes sure the data attributes are correct. + * + * @param {Element} annotation The annotation element. + * @param {string} html The replacement HTML (or alternatively, the replacement + * element/jQuery object). + */ +var replaceAnnotationHtml = function ( annotation, html ) +{ + var newHtml = $(html); + annotation.html(newHtml.html()).attr('data-date', newHtml.attr('data-date')); + createDatePicker(annotation); +} + +/** + * Binds events to an annotation manager element. + * + * @param {Element} manager The annotation manager. + * @param {int} idSite The site ID the manager is showing annotations for. + * @param {function} onAnnotationCountChange Callback that is called when there is a change + * in the number of annotations and/or starred annotations, + * eg, when a user adds a new one or deletes an existing one. + */ +var bindAnnotationManagerEvents = function(manager, idSite, onAnnotationCountChange) +{ + if (!onAnnotationCountChange) + { + onAnnotationCountChange = function() {}; + } + + // show new annotation row if create new annotation link is clicked + manager.on('click', '.add-annotation', function(e) { + e.preventDefault(); + + $('.new-annotation-row', manager).show(); + $(this).hide(); + + return false; + }); + + // hide new annotation row if cancel button clicked + manager.on('click', '.new-annotation-cancel', function() { + var newAnnotationRow = $(this).parent().parent(); + newAnnotationRow.hide(); + + $('.add-annotation', newAnnotationRow.closest('.annotation-manager')).show(); + }); + + // save new annotation when new annotation row save is clicked + manager.on('click', '.new-annotation-save', function() { + var addRow = $(this).parent().parent(), + addNoteInput = addRow.find('.new-annotation-edit'), + noteDate = addRow.find('.annotation-period-edit>a').text(); + + // do nothing if input is empty + if (!addNoteInput.val()) + { + return; + } + + // disable input & link + addNoteInput.attr('disabled', 'disabled'); + + // add a new annotation for the site, date & period + annotationsApi.addAnnotation( + idSite, + manager.attr('data-date'), + manager.attr('data-period'), + noteDate, + addNoteInput.val(), + function(response) { + replaceAnnotationManager(manager, response); + + // increment annotation count for this date + onAnnotationCountChange(noteDate, 1, 0); + } + ); + }); + + // add new annotation when enter key pressed on new annotation input + manager.on('keypress', '.new-annotation-edit', function(e) { + if (e.which == 13) + { + $(this).parent().find('.new-annotation-save').click(); + } + }); + + // show annotation editor if edit link, annotation text or period text is clicked + manager.on('click', '.annotation-enter-edit-mode', function(e) { + e.preventDefault(); + + var annotationContent = toggleAnnotationMode(this); + annotationContent.find('.annotation-edit').focus(); + + return false; + }); + + // hide annotation editor if cancel button is clicked + manager.on('click', '.annotation-cancel', function() { + toggleAnnotationMode(this); + }); + + // save annotation if save button clicked + manager.on('click', '.annotation-edit-mode .annotation-save', function() { + var annotation = $(this).parent().parent().parent(), + input = $('.annotation-edit', annotation), + dateEditText = $('.annotation-period-edit>a', annotation).text(); + + // if annotation value/date has not changed, just show the view mode instead of edit + if (input[0].defaultValue == input.val() + && dateEditText == annotation.attr('data-date')) + { + toggleAnnotationMode(this); + return; + } + + // disable input while ajax is happening + input.attr('disabled', 'disabled'); + + // save the note w/ the new note text & date + annotationsApi.saveAnnotation( + idSite, + annotation.attr('data-id'), + dateEditText, + { + note: input.val() + }, + function(response) { + response = $(response); + + var newDate = response.attr('data-date'), + isStarred = isAnnotationStarred(response), + originalDate = annotation.attr('data-date'); + + replaceAnnotationHtml(annotation, response); + + // if the date has been changed, update the evolution icon counts to reflect the change + if (originalDate != newDate) + { + // reduce count for original date + onAnnotationCountChange(originalDate, -1, isStarred ? -1 : 0); + + // increase count for new date + onAnnotationCountChange(newDate, 1, isStarred ? 1 : 0); + } + } + ); + }); + + // save annotation if 'enter' pressed on input + manager.on('keypress', '.annotation-value input', function(e) { + if (e.which == 13) + { + $(this).parent().find('.annotation-save').click(); + } + }); + + // delete annotation if delete link clicked + manager.on('click', '.delete-annotation', function(e) { + e.preventDefault(); + + var annotation = $(this).parent().parent(); + $(this).attr('disabled', 'disabled'); + + // delete annotation by ajax + annotationsApi.deleteAnnotation( + idSite, + annotation.attr('data-id'), + manager.attr('data-date'), + manager.attr('data-period'), + function (response) { + manager.html($(response).html()); + + // update evolution icons + var isStarred = isAnnotationStarred(annotation); + onAnnotationCountChange(annotation.attr('data-date'), -1, isStarred ? -1 : 0); + } + ); + + return false; + }); + + // star/unstar annotation if star clicked + manager.on('click', '.annotation-star-changeable', function(e) { + var annotation = $(this).parent().parent(), + newStarredVal = $(this).attr('data-starred') == 0 ? 1 : 0 // flip existing 'starred' value + ; + + // perform ajax request to star annotation + annotationsApi.saveAnnotation( + idSite, + annotation.attr('data-id'), + annotation.attr('data-date'), + { + starred: newStarredVal + }, + function (response) { + replaceAnnotationHtml(annotation, response); + + // change starred count for this annotation in evolution graph based on what we're + // changing the starred value to + onAnnotationCountChange(annotation.attr('data-date'), 0, newStarredVal == 0 ? -1 : 1); + } + ); + }); + + // when period edit is clicked, show datepicker + manager.on('click', '.annotation-period-edit>a', function(e) { + e.preventDefault(); + $('.datepicker', $(this).parent()).toggle(); + return false; + }); + + // make sure datepicker popups are closed if someone clicks elsewhere + $('body').on('mouseup', function(e) { + var container = $('.annotation-period-edit>.datepicker:visible').parent(); + + if (!container.has(e.target).length) + { + container.find('.datepicker').hide(); + } + }); +}; + +/** + * Shows an annotation manager under a report for a specific site & date range. + * + * @param {Element} domElem The element of the report to show the annotation manger + * under. + * @param {int} idSite The ID of the site to show the annotations of. + * @param {string} date The start date of the period. + * @param {string} period The period type. + * @param {int} Whether to include the last N periods in the date range or not. Can + * be undefined. + */ +var showAnnotationViewer = function(domElem, idSite, date, period, lastN, callback) +{ + var addToAnnotationCount = function(date, amt, starAmt) + { + if (date.indexOf(',') != -1) + { + date = date.split(',')[0]; + } + + $('.evolution-annotations>span', domElem).each(function() { + if ($(this).attr('data-date') == date) + { + // get counts from attributes (and convert them to ints) + var starredCount = +$(this).attr('data-starred'), + annotationCount = +$(this).attr('data-count'); + + // modify the starred count & make sure the correct image is used + var newStarCount = starredCount + starAmt, + newImg = 'themes/default/images/' + (newStarCount > 0 ? 'yellow_marker.png' : 'grey_marker.png'); + $(this).attr('data-starred', newStarCount).find('img').attr('src', newImg); + + // modify the annotation count & hide/show based on new count + var newCount = annotationCount + amt; + $(this).attr('data-count', newCount).css('opacity', newCount > 0 ? 1 : 0); + + return false; + } + }); + }; + + var manager = $('.annotation-manager', domElem); + if (manager.length) + { + // if annotations for the requested date + period are already loaded, then just toggle the + // visibility of the annotation viewer. otherwise, we reload the annotations. + if (manager.attr('data-date') == date + && manager.attr('data-period') == period) + { + // toggle manager view + if (manager.is(':hidden')) + { + manager.slideDown('slow', function () { if (callback) callback(manager) }); + } + else + { + manager.slideUp('slow', function () { if (callback) callback(manager) }); + } + } + else + { + // show nothing but the loading gif + $('.annotations', manager).html(''); + $('.loadingPiwik', manager).show(); + + // reload annotation manager for new date/period + annotationsApi.getAnnotationManager(idSite, date, period, lastN, function(response) { + replaceAnnotationManager(manager, response); + + createDatePickers(manager); + + // show if hidden + if (manager.is(':hidden')) + { + manager.slideDown('slow', function () { if (callback) callback(manager) }); + } + else + { + if (callback) + { + callback(manager); + } + } + }); + } + } + else + { + var loading = $('.loadingPiwikBelow', domElem).css({display: 'block'}); + + // the annotations for this report have not been retrieved yet, so do an ajax request + // & show the result + annotationsApi.getAnnotationManager(idSite, date, period, lastN, function(response) { + var manager = $(response).hide(); + + // if an error occurred (and response does not contain the annotation manager), do nothing + if (!manager.hasClass('annotation-manager')) + { + return; + } + + // create datepickers for each shown annotation + createDatePickers(manager); + + bindAnnotationManagerEvents(manager, idSite, addToAnnotationCount); + + loading.css('visibility', 'hidden'); + + // add & show annotation manager + $('.dataTableFeatures', domElem).append(manager); + manager.slideDown('slow', function() { + loading.hide().css('visibility', 'visible'); + + if (callback) callback(manager) + }); + }); + } +}; + +// make showAnnotationViewer & annotationsApi globally accessible +piwik.annotations = { + showAnnotationViewer: showAnnotationViewer, + api: annotationsApi +}; + +}(jQuery, piwik)); diff --git a/plugins/Annotations/templates/annotations.tpl b/plugins/Annotations/templates/annotations.tpl new file mode 100755 index 0000000000..f15750e548 --- /dev/null +++ b/plugins/Annotations/templates/annotations.tpl @@ -0,0 +1,30 @@ +<div class="annotations"> + +{if empty($annotations)} + +<div class="empty-annotation-list">{'Annotations_NoAnnotations'|translate}</div> + +{/if} + +<table> +{foreach from=$annotations item=annotation} +{include file="Annotations/templates/annotation.tpl"} +{/foreach} +<tr class="new-annotation-row" style="display:none" data-date="{$startDate}"> + <td class="annotation-meta"> + <div class="annotation-star"> </div> + <div class="annotation-period-edit"> + <a href="#">{$startDate}</a> + <div class="datepicker" style="display:none"/> + </div> + </td> + <td class="annotation-value"> + <input type="text" value="" class="new-annotation-edit" placeholder="{'Annotations_EnterAnnotationText'|translate}"/><br/> + <input type="button" class="submit new-annotation-save" value="{'General_Save'|translate}"/> + <input type="button" class="submit new-annotation-cancel" value="{'General_Cancel'|translate}"/> + </td> + <td class="annotation-user-cell"><span class="annotation-user">{$userLogin}</span></td> +</tr> +</table> + +</div> diff --git a/plugins/Annotations/templates/evolutionAnnotations.tpl b/plugins/Annotations/templates/evolutionAnnotations.tpl new file mode 100755 index 0000000000..5248bddbf2 --- /dev/null +++ b/plugins/Annotations/templates/evolutionAnnotations.tpl @@ -0,0 +1,12 @@ +<div class="evolution-annotations"> +{foreach from=$annotationCounts item=dateCountPair} + {assign var=date value=$dateCountPair[0]} + {assign var=counts value=$dateCountPair[1]} + <span data-date="{$date}" data-count="{$counts.count}" data-starred="{$counts.starred}" + {if $counts.count eq 0}title="{'CoreHome_Annotations_AddAnnotationsFor_js'|translate:$date}" + {else}title="{'CoreHome_Annotations_ViewAndAddAnnotations_js'|translate:$date}" + {/if}> + <img src="themes/default/images/{if $counts.starred > 0}yellow_marker.png{else}grey_marker.png{/if}" width="16" height="16"/> + </span> +{/foreach} +</div> diff --git a/plugins/Annotations/templates/styles.css b/plugins/Annotations/templates/styles.css new file mode 100755 index 0000000000..4831bafcb1 --- /dev/null +++ b/plugins/Annotations/templates/styles.css @@ -0,0 +1,194 @@ +.evolution-annotations { + position: relative; + height: 16px; + width: 100%; + margin-top: 12px; + margin-bottom: -28px; + cursor: pointer; +} + +.evolution-annotations > span { + position: absolute; +} + +.annotation-manager { + text-align: left; + margin-top: -18px; +} + +.annotations-header { + display: inline-block; + width: 128px; + text-align: right; + font-size: 12px; + font-style: italic; + margin-bottom: 8px; + vertical-align: top; + color: #666; +} + +.annotation-controls { + display:inline-block; + margin-left: 132px; +} + +.annotation-controls>a { + font-size: 11px; + font-style: italic; + color: #666; + cursor:pointer; + padding:3px 0 6px 0; + display:inline-block; +} + +.annotation-controls>a:hover { + text-decoration:none; +} + +.annotation-list { + margin-left: 8px; +} + +.annotation-list table { + width: 100%; +} + +.annotation-list-range { + display: inline-block; + font-size: 12px; + font-style: italic; + color: #666; + vertical-align: top; + margin: 0 0 8px 8px; +} + +.empty-annotation-list,.annotation-list .loadingPiwik { + display: block; + + font-style: italic; + color: #666; + margin: 0 0 12px 140px; +} + +.annotation-meta { + width: 128px; + text-align: right; + vertical-align: top; + font-size:14px; +} + +.annotation-user { + font-style: italic; + font-size: 11px; + color:#444; +} + +.annotation-user-cell { + vertical-align: top; + width: 92px; +} + +.annotation-period { + display:inline-block; + font-style: italic; + margin: 0 8px 8px 8px; + vertical-align: top; +} + +.annotation-value { + margin: 0 12px 12px 8px; + vertical-align: top; + position: relative; + font-size:14px; +} + +.annotation-enter-edit-mode { + cursor: pointer; +} + +.annotation-edit,.new-annotation-edit { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + width:98%; +} + +.annotation-star { + display: inline-block; + margin: 0 8px 8px 0; + width: 16px; +} + +.annotation-star-changeable { + cursor: pointer; +} + +.delete-annotation { + font-size:12px; + font-style: italic; + color: red; + text-decoration: none; + display: inline-block; +} + +.delete-annotation:hover { + text-decoration: underline; +} + +.annotation-manager .submit { + float:none; +} + +.edit-annotation { + font-size:10px; + color:#666; + font-style:italic; +} +.edit-annotation:hover { + text-decoration:none; +} + +.annotationView { + float: right; + margin-left: 5px; + position: relative; + cursor:pointer; +} + +.annotationView > span { + font-style: italic; + display: inline-block; + margin: 4px 4px 0 4px; +} + +.annotation-period-edit { + display:inline-block; + background:white; + color:#444; + font-size:12px; + border: 1px solid #e4e5e4; + padding:5px 5px 6px 3px; + border-radius:4px; + -moz-border-radius:4px; + -webkit-border-radius:4px; +} +.annotation-period-edit:hover { + background:#f1f0eb; + border-color:#a9a399; +} +.annotation-period-edit>a { + text-decoration:none; + cursor:pointer; + display:block; +} +.annotation-period-edit>.datepicker { + position:absolute; + margin-top:6px; + margin-left:-5px; + z-index:15; + background:white; + border: 1px solid #e4e5e4; + border-radius:4px; + -moz-border-radius:4px; + -webkit-border-radius:4px; +} diff --git a/plugins/CoreHome/templates/calendar.js b/plugins/CoreHome/templates/calendar.js index 9588577b23..096728e0c6 100644 --- a/plugins/CoreHome/templates/calendar.js +++ b/plugins/CoreHome/templates/calendar.js @@ -121,11 +121,9 @@ function isDateInCurrentPeriod( date ) return [true, '']; } -var updateDate; -function getDatePickerOptions() +piwik.getBaseDatePickerOptions = function(defaultDate) { return { - onSelect: function () { updateDate.apply(this, arguments); }, showOtherMonths: false, dateFormat: 'yy-mm-dd', firstDay: 1, @@ -134,11 +132,10 @@ function getDatePickerOptions() prevText: "", nextText: "", currentText: "", - beforeShowDay: isDateInCurrentPeriod, - defaultDate: currentDate, + defaultDate: defaultDate, changeMonth: true, changeYear: true, - stepMonths: selectedPeriod == 'year' ? 12 : 1, + stepMonths: 1, // jquery-ui-i18n 1.7.2 lacks some translations, so we use our own dayNamesMin: [ _pk_translate('CoreHome_DaySu_js'), @@ -190,7 +187,17 @@ function getDatePickerOptions() _pk_translate('CoreHome_MonthOctober_js'), _pk_translate('CoreHome_MonthNovember_js'), _pk_translate('CoreHome_MonthDecember_js')] - } + }; +}; + +var updateDate; +function getDatePickerOptions() +{ + var result = piwik.getBaseDatePickerOptions(currentDate); + result.beforeShowDay = isDateInCurrentPeriod; + result.stepMonths = selectedPeriod == 'year' ? 12 : 1; + result.onSelect = function () { updateDate.apply(this, arguments); }; + return result; }; $(document).ready(function() { diff --git a/plugins/CoreHome/templates/datatable.css b/plugins/CoreHome/templates/datatable.css index 126451750c..a19fdc0564 100644 --- a/plugins/CoreHome/templates/datatable.css +++ b/plugins/CoreHome/templates/datatable.css @@ -343,6 +343,11 @@ table thead div { height: 0; } +.dataTable .loadingPiwikBelow { + padding-bottom:5px; + display:block; + text-align:center; +} .dataTableFooterIcons { display:block; @@ -623,6 +628,7 @@ body .piwik-tooltip.rowActionTooltip { position: relative; margin-left: 5px; min-height: 20px; + z-index:1; } .limitSelection.hidden { diff --git a/plugins/CoreHome/templates/datatable.js b/plugins/CoreHome/templates/datatable.js index 03c1684e22..a2e57f098a 100644 --- a/plugins/CoreHome/templates/datatable.js +++ b/plugins/CoreHome/templates/datatable.js @@ -227,6 +227,8 @@ dataTable.prototype = self.handleLimit(domElem); self.handleSearchBox(domElem); self.handleOffsetInformation(domElem); + self.handleAnnotationsButton(domElem); + self.handleEvolutionAnnotations(domElem); self.handleExportBox(domElem); self.applyCosmetics(domElem); self.handleSubDataTable(domElem); @@ -535,7 +537,156 @@ dataTable.prototype = } ); }, - + + handleEvolutionAnnotations: function(domElem) + { + var self = this; + if (self.param.viewDataTable == 'graphEvolution' + && $('.annotationView', domElem).length > 0) + { + // get dates w/ annotations across evolution period (have to do it through AJAX since we + // determine placement using the elements created by jqplot) + piwik.annotations.api.getEvolutionIcons( + self.param.idSite, + self.param.date, + self.param.period, + self.param['evolution_' + self.param.period + '_last_n'], + function (response) + { + var canvases = $('.piwik-graph .jqplot-xaxis canvas', domElem), + datatableFeatures = $('.dataTableFeatures', domElem), + noteSize = 16, + annotationAxisHeight = 30 // css height + padding + margin + ; + + // set position of evolution annotation icons + var annotations = $(response).css({ + top: -datatableFeatures.height() - annotationAxisHeight + noteSize / 2, + left: 6 // padding-left of .jqplot-evolution element (in graph.tpl) + }); + + // set position of each individual icon + $('span', annotations).each(function(i) { + var canvas = $(canvases[i]), + canvasCenterX = canvas.position().left + (canvas.width() / 2); + $(this).css({ + left: canvasCenterX - noteSize / 2, + // show if there are annotations for this x-axis tick + opacity: +$(this).attr('data-count') > 0 ? 1 : 0 + }); + }); + + // add new section under axis + datatableFeatures.append(annotations); + + // on hover of x-axis, show note icon over correct part of x-axis + $('span', annotations).hover( + function() { $(this).css('opacity', 1); }, + function() { + if ($(this).attr('data-count') == 0) // only hide if there are no annotations for this note + { + $(this).css('opacity', 0); + } + } + ); + + // when clicking an annotation, show the annotation viewer for that day + $('span', annotations).click(function() { + var spanSelf = $(this), + date = spanSelf.attr('data-date'), + oldDate = $('.annotation-manager', domElem).attr('data-date'); + if (date) + { + piwik.annotations.showAnnotationViewer( + domElem, + self.param.idSite, + date, + self.param.period, + undefined, // lastN + function (manager) { + manager.attr('data-is-range', 0); + $('.annotationView img', domElem) + .attr('title', _pk_translate('CoreHome_Annotations_IconDesc_js')); + + var viewAndAdd = _pk_translate('CoreHome_Annotations_ViewAndAddAnnotations_js'), + hideNotes = _pk_translate('CoreHome_Annotations_HideAnnotationsFor_js'); + + // change the tooltip of the previously clicked evolution icon (if any) + if (oldDate) + { + $('span', annotations).each(function() { + if ($(this).attr('data-date') == oldDate) + { + $(this).attr('title', viewAndAdd.replace("%s", oldDate)); + return false; + } + }); + } + + // change the tooltip of the clicked evolution icon + if (manager.is(':hidden')) + { + spanSelf.attr('title', viewAndAdd.replace("%s", date)); + } + else + { + spanSelf.attr('title', hideNotes.replace("%s", date)); + } + } + ); + } + }); + } + ); + } + }, + + handleAnnotationsButton: function(domElem) + { + var self = this; + if (self.param.idSubtable) // no annotations for subtables, just whole reports + { + return; + } + + // show the annotations view on click + $('.annotationView', domElem).click(function() { + var annotationManager = $('.annotation-manager', domElem); + + if (annotationManager.length > 0 + && annotationManager.attr('data-is-range') == 1) + { + if (annotationManager.is(':hidden')) + { + annotationManager.slideDown('slow'); // showing + $('img', this).attr('title', _pk_translate('CoreHome_Annotations_IconDescHideNotes_js')); + } + else + { + annotationManager.slideUp('slow'); // hiding + $('img', this).attr('title', _pk_translate('CoreHome_Annotations_IconDesc_js')); + } + } + else + { + // show the annotation viewer for the whole date range + var lastN = self.param['evolution_' + self.param.period + '_last_n']; + piwik.annotations.showAnnotationViewer( + domElem, + self.param.idSite, + self.param.date, + self.param.period, + lastN, + function(manager) { + manager.attr('data-is-range', 1); + } + ); + + // change the tooltip of the view annotation icon + $('img', this).attr('title', _pk_translate('CoreHome_Annotations_IconDescHideNotes_js')); + } + }); + }, // DataTable view box (simple table, all columns table, Goals table, pie graph, tag cloud, graph, ...) handleExportBox: function(domElem) @@ -1472,6 +1623,7 @@ actionDataTable.prototype = reloadAjaxDataTable: dataTable.prototype.reloadAjaxDataTable, handleConfigurationBox: dataTable.prototype.handleConfigurationBox, handleSearchBox: dataTable.prototype.handleSearchBox, + handleAnnotationsButton: dataTable.prototype.handleAnnotationsButton, handleExportBox: dataTable.prototype.handleExportBox, handleSort: dataTable.prototype.handleSort, handleColumnDocumentation: dataTable.prototype.handleColumnDocumentation, @@ -1526,6 +1678,7 @@ actionDataTable.prototype = self.applyCosmetics(domElem); self.handleRowActions(domElem); self.handleLimit(domElem); + self.handleAnnotationsButton(domElem); self.handleExportBox(domElem); self.handleSort(domElem); self.handleOffsetInformation(domElem); diff --git a/plugins/CoreHome/templates/datatable_footer.tpl b/plugins/CoreHome/templates/datatable_footer.tpl index 4388a40c3c..11222fac2d 100644 --- a/plugins/CoreHome/templates/datatable_footer.tpl +++ b/plugins/CoreHome/templates/datatable_footer.tpl @@ -59,8 +59,7 @@ </span> </div> - {/if} - + {/if} <div class="tableIconsGroup"> <span class="exportToFormatIcons"><a class="tableIcon" var="export"><img width="16" height="16" src="themes/default/images/export.png" title="{'General_ExportThisReport'|translate}" /></a></span> <span class="exportToFormatItems" style="display:none"> @@ -95,6 +94,12 @@ {/if} </ul> </div> + {if !$properties.hide_annotations_view} + <div class="annotationView"> + <a class="tableIcon"><img width="16" height="16" src="themes/default/images/grey_marker.png" title="{'CoreHome_Annotations_IconDesc_js'|translate}"/></a> + <span>{'Annotations_Annotations'|translate}</span> + </div> + {/if} </div> {/if} @@ -116,4 +121,6 @@ </div> +<span class="loadingPiwikBelow" style='display:none'><img src="themes/default/images/loading-blue.gif" /> {'General_LoadingData'|translate}</span> + <div class="dataTableSpacer"></div> diff --git a/tests/PHPUnit/Core/API/ResponseBuilderTest.php b/tests/PHPUnit/Core/API/ResponseBuilderTest.php index ab98164197..fbfb80bb53 100644 --- a/tests/PHPUnit/Core/API/ResponseBuilderTest.php +++ b/tests/PHPUnit/Core/API/ResponseBuilderTest.php @@ -76,73 +76,4 @@ class API_ResponseBuilderTest extends PHPUnit_Framework_TestCase $actual = Piwik_API_ResponseBuilder::convertMultiDimensionalArrayToJson($input); $this->assertEquals($expected, $actual); } - - /** - * Two dimensions standard array - * - * @group Core - * @group API - * @group API_ResponseBuilder - */ - public function testConvertMultiDimensionalStandardArrayToXML() - { - $input = array( "firstElement", - array( - "firstElement", - "secondElement", - ), - "thirdElement"); - - $expected = '<row>firstElement</row><row><row>firstElement</row><row>secondElement</row></row><row>thirdElement</row>'; - $actual = preg_replace("/[\t\n]+/", '', Piwik_API_ResponseBuilder::convertMultiDimensionalArrayToXml($input)); - $this->assertEquals($expected, $actual); - } - - /** - * Two dimensions associative array - * - * @group Core - * @group API - * @group API_ResponseBuilder - */ - public function testConvertMultiDimensionalAssociativeArrayToXML() - { - $input = array( - "firstElement" => "isFirst", - "secondElement" => array( - "firstElement" => "isFirst", - "secondElement" => "isSecond", - ), - "thirdElement" => "isThird"); - - $expected = '<firstElement>isFirst</firstElement><secondElement><firstElement>isFirst</firstElement><secondElement>isSecond</secondElement></secondElement><thirdElement>isThird</thirdElement>'; - $actual = preg_replace("/[\t\n]+/", '', Piwik_API_ResponseBuilder::convertMultiDimensionalArrayToXml($input)); - $this->assertEquals($expected, $actual); - } - - /** - * Two dimensions mixed array - * - * @group Core - * @group API - * @group API_ResponseBuilder - */ - public function testConvertMultiDimensionalMixedArrayToXML() - { - $input = array( - "firstElement" => "isFirst", - array( - "firstElement", - "secondElement", - ), - "thirdElement" => array( - "firstElement" => "isFirst", - "secondElement" => "isSecond", - ) - ); - - $expected = '<firstElement>isFirst</firstElement><row><row>firstElement</row><row>secondElement</row></row><thirdElement><firstElement>isFirst</firstElement><secondElement>isSecond</secondElement></thirdElement>'; - $actual = preg_replace("/[\t\n]+/", '', Piwik_API_ResponseBuilder::convertMultiDimensionalArrayToXml($input)); - $this->assertEquals($expected, $actual); - } } diff --git a/tests/PHPUnit/Core/DataTable/Renderer/JSONTest.php b/tests/PHPUnit/Core/DataTable/Renderer/JSONTest.php index 4b2e921b41..c5970b3997 100644 --- a/tests/PHPUnit/Core/DataTable/Renderer/JSONTest.php +++ b/tests/PHPUnit/Core/DataTable/Renderer/JSONTest.php @@ -391,4 +391,72 @@ class DataTable_Renderer_JSONTest extends PHPUnit_Framework_TestCase $this->assertEquals($expected, $rendered); } + /** + * @group Core + * @group DataTable + * @group DataTable_Renderer + * @group DataTable_Renderer_XML + */ + public function testRenderArray1() + { + $data = array(); + + $render = new Piwik_DataTable_Renderer_Json(); + $render->setTable($data); + $expected = '[]'; + + $this->assertEquals($expected, $render->render()); + } + + /** + * @group Core + * @group DataTable + * @group DataTable_Renderer + * @group DataTable_Renderer_XML + */ + public function testRenderArray2() + { + $data = array('a', 'b', 'c', array('a' => 'b'), array(1, 2)); + + $render = new Piwik_DataTable_Renderer_Json(); + $render->setTable($data); + $expected = '["a","b","c",{"a":"b"},[1,2]]'; + + $this->assertEquals($expected, $render->render()); + } + + /** + * @group Core + * @group DataTable + * @group DataTable_Renderer + * @group DataTable_Renderer_XML + */ + public function testRenderArray3() + { + $data = array('a' => 'b', 'c' => 'd', 'e' => 'f', 5 => 'g'); + + $render = new Piwik_DataTable_Renderer_Json(); + $render->setTable($data); + $expected = '[{"a":"b","c":"d","e":"f","5":"g"}]'; + + $this->assertEquals($expected, $render->render()); + } + + /** + * @group Core + * @group DataTable + * @group DataTable_Renderer + * @group DataTable_Renderer_XML + */ + public function testRenderArray4() + { + $data = array('a' => 'b', 'c' => array(1,2,3,4), 'e' => array('f' => 'g', 'h' => 'i', 'j' => 'k')); + + $render = new Piwik_DataTable_Renderer_Json(); + $render->setTable($data); + $expected = '{"a":"b","c":[1,2,3,4],"e":{"f":"g","h":"i","j":"k"}}'; + + $this->assertEquals($expected, $render->render()); + } + } diff --git a/tests/PHPUnit/Core/DataTable/Renderer/XMLTest.php b/tests/PHPUnit/Core/DataTable/Renderer/XMLTest.php index c4798a9b12..0594c0a1da 100644 --- a/tests/PHPUnit/Core/DataTable/Renderer/XMLTest.php +++ b/tests/PHPUnit/Core/DataTable/Renderer/XMLTest.php @@ -547,4 +547,99 @@ class DataTable_Renderer_XMLTest extends PHPUnit_Framework_TestCase $rendered = $render->render(); $this->assertEquals($expected, $rendered); } + + /** + * @group Core + * @group DataTable + * @group DataTable_Renderer + * @group DataTable_Renderer_XML + */ + public function testRenderArray1() + { + $data = array(); + + $render = new Piwik_DataTable_Renderer_Xml(); + $render->setTable($data); + $expected = '<?xml version="1.0" encoding="utf-8" ?> +<result />'; + + $this->assertEquals($expected, $render->render()); + } + + /** + * @group Core + * @group DataTable + * @group DataTable_Renderer + * @group DataTable_Renderer_XML + */ + public function testRenderArray2() + { + $data = array('a', 'b', 'c'); + + $render = new Piwik_DataTable_Renderer_Xml(); + $render->setTable($data); + $expected = '<?xml version="1.0" encoding="utf-8" ?> +<result> + <row>a</row> + <row>b</row> + <row>c</row> +</result>'; + + $this->assertEquals($expected, $render->render()); + } + + /** + * @group Core + * @group DataTable + * @group DataTable_Renderer + * @group DataTable_Renderer_XML + */ + public function testRenderArray3() + { + $data = array('a' => 'b', 'c' => 'd', 'e' => 'f', 5 => 'g'); + + $render = new Piwik_DataTable_Renderer_Xml(); + $render->setTable($data); + $expected = '<?xml version="1.0" encoding="utf-8" ?> +<result> + <row> + <a>b</a> + <c>d</c> + <e>f</e> + <row key="5">g</row> + </row> +</result>'; + + $this->assertEquals($expected, $render->render()); + } + + /** + * @group Core + * @group DataTable + * @group DataTable_Renderer + * @group DataTable_Renderer_XML + */ + public function testRenderArray4() + { + $data = array('c' => array(1,2,3,4), 'e' => array('f' => 'g', 'h' => 'i', 'j' => 'k')); + + $render = new Piwik_DataTable_Renderer_Xml(); + $render->setTable($data); + $expected = '<?xml version="1.0" encoding="utf-8" ?> +<result> + <c> + <row>1</row> + <row>2</row> + <row>3</row> + <row>4</row> + </c> + <e> + <f>g</f> + <h>i</h> + <j>k</j> + </e> +</result>'; + + $this->assertEquals($expected, $render->render()); + } } diff --git a/tests/PHPUnit/Core/PiwikTest.php b/tests/PHPUnit/Core/PiwikTest.php index e7077c17c3..ac43311e6d 100644 --- a/tests/PHPUnit/Core/PiwikTest.php +++ b/tests/PHPUnit/Core/PiwikTest.php @@ -204,4 +204,29 @@ class PiwikTest extends DatabaseTestCase Piwik::getPrettyValue($idsite, $columnName, $value, false, false) ); } + + /** + * Data provider for testIsAssociativeArray. + */ + public function getIsAssociativeArrayTestCases() + { + return array( + array(array(0 => 'a', 1 => 'b', 2 => 'c', 3 => 'd', 4 => 'e', 5 => 'f'), false), + array(array(-1 => 'a', 0 => 'a', 1 => 'a', 2 => 'a', 3 => 'a'), true), + array(array(4 => 'a', 5 => 'a', 6 => 'a', 7 => 'a', 8 => 'a'), true), + array(array(0 => 'a', 2 => 'a', 3 => 'a', 4 => 'a', 5 => 'a'), true), + array(array('abc' => 'a', 0 => 'b', 'sdfds' => 'd'), true), + array(array('abc' => 'def'), true) + ); + } + + /** + * @group Core + * @group Piwik + * @dataProvider getIsAssociativeArrayTestCases + */ + public function testIsAssociativeArray( $array, $expected ) + { + $this->assertEquals($expected, Piwik::isAssociativeArray($array)); + } } diff --git a/tests/PHPUnit/Integration/AnnotationsTest.php b/tests/PHPUnit/Integration/AnnotationsTest.php new file mode 100755 index 0000000000..01fb767d82 --- /dev/null +++ b/tests/PHPUnit/Integration/AnnotationsTest.php @@ -0,0 +1,415 @@ +<?php +/** + * Piwik - Open source web analytics + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * @version $Id$ + */ + +class AnnotationsTest extends IntegrationTestCase +{ + protected static $dateTime = '2011-01-01 00:11:42'; + protected static $idSite1 = 1; + protected static $idSite2 = 2; + + public static function setUpBeforeClass() + { + parent::setUpBeforeClass(); + try { + self::setUpWebsitesAndGoals(); + self::addAnnotations(); + } catch(Exception $e) { + // Skip whole test suite if an error occurs while setup + throw new PHPUnit_Framework_SkippedTestSuiteError($e->getMessage()); + } + } + + public function getOutputPrefix() + { + return 'annotations'; + } + + public function getApiForTesting() + { + return array( + + // get + array('Annotations.get', array('idSite' => self::$idSite1, + 'date' => '2012-01-01', + 'periods' => 'day', + 'otherRequestParameters' => array('idNote' => 1))), + + // getAll + array('Annotations.getAll', array('idSite' => self::$idSite1, + 'date' => '2011-12-01', + 'periods' => array('day', 'week', 'month'))), + array('Annotations.getAll', array('idSite' => self::$idSite1, + 'date' => '2012-01-01', + 'periods' => array('year'))), + array('Annotations.getAll', array('idSite' => self::$idSite1, + 'date' => '2012-03-01', + 'periods' => array('week'), + 'otherRequestParameters' => array('lastN' => 6), + 'testSuffix' => '_lastN')), + array('Annotations.getAll', array('idSite' => self::$idSite1, + 'date' => '2012-01-15,2012-02-15', + 'periods' => array('range'), + 'otherRequestParameters' => array('lastN' => 6), + 'testSuffix' => '_range')), + array('Annotations.getAll', array('idSite' => 'all', + 'date' => '2012-01-01', + 'periods' => array('month'), + 'testSuffix' => '_multipleSites')), + + // getAnnotationCountForDates + array('Annotations.getAnnotationCountForDates', array('idSite' => self::$idSite1, + 'date' => '2011-12-01', + 'periods' => array('day', 'week', 'month'))), + array('Annotations.getAnnotationCountForDates', array('idSite' => self::$idSite1, + 'date' => '2012-01-01', + 'periods' => array('year'))), + array('Annotations.getAnnotationCountForDates', array('idSite' => self::$idSite1, + 'date' => '2012-03-01', + 'periods' => array('week'), + 'otherRequestParameters' => array('lastN' => 6), + 'testSuffix' => '_lastN')), + array('Annotations.getAnnotationCountForDates', array('idSite' => self::$idSite1, + 'date' => '2012-01-15,2012-02-15', + 'periods' => array('range'), + 'otherRequestParameters' => array('lastN' => 6), + 'testSuffix' => '_range')), + array('Annotations.getAnnotationCountForDates', array('idSite' => 'all', + 'date' => '2012-01-01', + 'periods' => array('month'), + 'testSuffix' => '_multipleSites')), + ); + } + + /** + * @dataProvider getApiForTesting + * @group Integration + * @group Annotations + */ + public function testApi($api, $params) + { + $this->runApiTests($api, $params); + } + + /** + * @group Integration + * @group Annotations + */ + public function testAddMultipleSitesFail() + { + try + { + Piwik_Annotations_API::getInstance()->add("1,2,3", "2012-01-01", "whatever"); + $this->fail("add should fail when given multiple sites in idSite"); + } + catch (Exception $ex) + { + // pass + } + } + + /** + * @group Integration + * @group Annotations + */ + public function testAddInvalidDateFail() + { + try + { + Piwik_Annotations_API::getInstance()->add(self::$idSite1, "invaliddate", "whatever"); + $this->fail("add should fail when given invalid date"); + } + catch (Exception $ex) + { + // pass + } + } + + /** + * @group Integration + * @group Annotations + */ + public function testSaveMultipleSitesFail() + { + try + { + Piwik_Annotations_API::getInstance()->save("1,2,3", 0); + $this->fail("save should fail when given multiple sites"); + } + catch (Exception $ex) + { + // pass + } + } + + /** + * @group Integration + * @group Annotations + */ + public function testSaveInvalidDateFail() + { + try + { + Piwik_Annotations_API::getInstance()->save(self::$idSite1, 0, "invaliddate"); + $this->fail("save should fail when given an invalid date"); + } + catch (Exception $ex) + { + // pass + } + } + + /** + * @group Integration + * @group Annotations + */ + public function testSaveInvalidNoteIdFail() + { + try + { + Piwik_Annotations_API::getInstance()->save(self::$idSite1, -1); + $this->fail("save should fail when given an invalid note id"); + } + catch (Exception $ex) + { + // pass + } + } + + /** + * @group Integration + * @group Annotations + */ + public function testDeleteMultipleSitesFail() + { + try + { + Piwik_Annotations_API::getInstance()->delete("1,2,3", 0); + $this->fail("delete should fail when given multiple site IDs"); + } + catch (Exception $ex) + { + // pass + } + } + + /** + * @group Integration + * @group Annotations + */ + public function testDeleteInvalidNoteIdFail() + { + try + { + Piwik_Annotations_API::getInstance()->delete(self::$idSite1, -1); + $this->fail("delete should fail when given an invalid site ID"); + } + catch (Exception $ex) + { + // pass + } + } + + /** + * @group Integration + * @group Annotations + */ + public function testGetMultipleSitesFail() + { + try + { + Piwik_Annotations_API::getInstance()->get("1,2,3", 0); + $this->fail("get should fail when given multiple site IDs"); + } + catch (Exception $ex) + { + // pass + } + } + + /** + * @group Integration + * @group Annotations + */ + public function testGetInvalidNoteIdFail() + { + try + { + Piwik_Annotations_API::getInstance()->get(self::$idSite1, -1); + $this->fail("get should fail when given an invalid note ID"); + } + catch (Exception $ex) + { + // pass + } + } + + /** + * @group Integration + * @group Annotations + */ + public function testSaveSuccess() + { + Piwik_Annotations_API::getInstance()->save( + self::$idSite1, 0, $date = '2011-04-01', $note = 'new note text', $starred = 1); + + $expectedAnnotation = array( + 'date' => '2011-04-01', + 'note' => 'new note text', + 'starred' => 1, + 'user' => 'superUserLogin', + 'idNote' => 0, + 'canEditOrDelete' => true + ); + $this->assertEquals($expectedAnnotation, Piwik_Annotations_API::getInstance()->get(self::$idSite1, 0)); + } + + /** + * @group Integration + * @group Annotations + */ + public function testSaveNoChangesSuccess() + { + Piwik_Annotations_API::getInstance()->save(self::$idSite1, 1); + + $expectedAnnotation = array( + 'date' => '2011-12-02', + 'note' => '1: Site 1 annotation for 2011-12-02', + 'starred' => 0, + 'user' => 'superUserLogin', + 'idNote' => 1, + 'canEditOrDelete' => true + ); + $this->assertEquals($expectedAnnotation, Piwik_Annotations_API::getInstance()->get(self::$idSite1, 1)); + } + + /** + * @group Integration + * @group Annotations + */ + public function testDeleteSuccess() + { + Piwik_Annotations_API::getInstance()->delete(self::$idSite1, 1); + + try + { + Piwik_Annotations_API::getInstance()->get(self::$idSite1, 1); + $this->fail("failed to delete annotation"); + } + catch (Exception $ex) + { + // pass + } + } + + public function getPermissionsFailData() + { + return array( + // getAll + array(false, false, "module=API&method=Annotations.getAll&idSite=1&date=2012-01-01&period=year", true, "getAll should throw if user does not have view access"), + + // get + array(false, false, "module=API&method=Annotations.get&idSite=1&idNote=0", true, "get should throw if user does not have view access"), + + // getAnnotationCountForDates + array(false, false, "module=API&method=Annotations.getAnnotationCountForDates&idSite=1&date=2012-01-01&period=year", true, "getAnnotationCountForDates should throw if user does not have view access"), + + // add + array(false, false, "module=API&method=Annotations.add&idSite=1&date=2011-02-01¬e=whatever", true, "add should throw if user does not have view access"), + array(false, true, "module=API&method=Annotations.add&idSite=1&date=2011-02-01¬e=whatever2", false, "add should not throw if user has view access"), + array(true, true, "module=API&method=Annotations.add&idSite=1&date=2011-02-01¬e=whatever3", false, "add should not throw if user has admin access"), + + // save + array(false, false, "module=API&method=Annotations.save&idSite=1&idNote=0&date=2011-03-01¬e=newnote", true, "save should throw if user does not have view access"), + array(false, true, "module=API&method=Annotations.save&idSite=1&idNote=0&date=2011-03-01¬e=newnote", true, "save should throw if user has view access but did not edit note"), + array(true, true, "module=API&method=Annotations.save&idSite=1&idNote=0&date=2011-03-01¬e=newnote", false, "save should not throw if user has admin access"), + + // delete + array(false, false, "module=API&method=Annotations.delete&idSite=1&idNote=0", true, "delete should throw if user does not have view access"), + array(false, true, "module=API&method=Annotations.delete&idSite=1&idNote=0", true, "delete should throw if user does not have view access"), + array(true, true, "module=API&method=Annotations.delete&idSite=1&idNote=0", false, "delete should not throw if user has admin access"), + ); + } + + /** + * @dataProvider getPermissionsFailData + * @group Integration + * @group Annotations + */ + public function testMethodPermissions( $hasAdminAccess, $hasViewAccess, $request, $checkException, $failMessage ) + { + // create fake access that denies user access + $access = new FakeAccess(); + FakeAccess::$superUser = false; + FakeAccess::$idSitesAdmin = $hasAdminAccess ? array(self::$idSite1) : array(); + FakeAccess::$idSitesView = $hasViewAccess ? array(self::$idSite1) : array(); + Zend_Registry::set('access', $access); + + if ($checkException) + { + try + { + $request = new Piwik_Api_Request($request); + $request->process(); + $this->fail($failMessage); + } + catch (Exception $ex) + { + // pass + } + } + else + { + $request = new Piwik_Api_Request($request); + $request->process(); + } + } + + private static function addAnnotations() + { + // create fake access for fake username + $access = new FakeAccess(); + FakeAccess::$superUser = true; + Zend_Registry::set('access', $access); + + // add two annotations per week for three months, starring every third annotation + // first month in 2011, second two in 2012 + $count = 0; + $dateStart = Piwik_Date::factory('2011-12-01'); + $dateEnd = Piwik_Date::factory('2012-03-01'); + while ($dateStart->getTimestamp() < $dateEnd->getTimestamp()) + { + $starred = $count % 3 == 0 ? 1 : 0; + $site1Text = "$count: Site 1 annotation for ".$dateStart->toString(); + $site2Text = "$count: Site 2 annotation for ".$dateStart->toString(); + + Piwik_Annotations_API::getInstance()->add(self::$idSite1, $dateStart->toString(), $site1Text, $starred); + Piwik_Annotations_API::getInstance()->add(self::$idSite2, $dateStart->toString(), $site2Text, $starred); + + $nextDay = $dateStart->addDay(1); + ++$count; + + $starred = $count % 3 == 0 ? 1 : 0; + $site1Text = "$count: Site 1 annotation for ".$nextDay->toString(); + $site2Text = "$count: Site 2 annotation for ".$nextDay->toString(); + + Piwik_Annotations_API::getInstance()->add(self::$idSite1, $nextDay->toString(), $site1Text, $starred); + Piwik_Annotations_API::getInstance()->add(self::$idSite2, $nextDay->toString(), $site2Text, $starred); + + $dateStart = $dateStart->addPeriod(1, 'WEEK'); + ++$count; + } + } + + private static function setUpWebsitesAndGoals() + { + // add two websites + self::createWebsite(self::$dateTime, $ecommerce = 1); + self::createWebsite(self::$dateTime, $ecommerce = 1); + } +} diff --git a/tests/PHPUnit/Integration/expected/test_ImportLogs__Goals.getGoals.xml b/tests/PHPUnit/Integration/expected/test_ImportLogs__Goals.getGoals.xml index 14e6786d82..b33fbbd767 100755 --- a/tests/PHPUnit/Integration/expected/test_ImportLogs__Goals.getGoals.xml +++ b/tests/PHPUnit/Integration/expected/test_ImportLogs__Goals.getGoals.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8" ?> <result> - <row> + <row key="1"> <idsite>1</idsite> <idgoal>1</idgoal> <name>all</name> diff --git a/tests/PHPUnit/Integration/expected/test_OneVisitorTwoVisits__Goals.getGoals.xml b/tests/PHPUnit/Integration/expected/test_OneVisitorTwoVisits__Goals.getGoals.xml index 792ce9ebcd..c9cc82f044 100644 --- a/tests/PHPUnit/Integration/expected/test_OneVisitorTwoVisits__Goals.getGoals.xml +++ b/tests/PHPUnit/Integration/expected/test_OneVisitorTwoVisits__Goals.getGoals.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8" ?> <result> - <row> + <row key="1"> <idsite>1</idsite> <idgoal>1</idgoal> <name>triggered js</name> @@ -9,7 +9,7 @@ <revenue>0</revenue> <deleted>0</deleted> </row> - <row> + <row key="2"> <idsite>1</idsite> <idgoal>2</idgoal> <name>matching purchase.htm</name> diff --git a/tests/PHPUnit/Integration/expected/test_OneVisitorTwoVisits_withCookieSupport__Goals.getGoals.xml b/tests/PHPUnit/Integration/expected/test_OneVisitorTwoVisits_withCookieSupport__Goals.getGoals.xml index 792ce9ebcd..c9cc82f044 100644 --- a/tests/PHPUnit/Integration/expected/test_OneVisitorTwoVisits_withCookieSupport__Goals.getGoals.xml +++ b/tests/PHPUnit/Integration/expected/test_OneVisitorTwoVisits_withCookieSupport__Goals.getGoals.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8" ?> <result> - <row> + <row key="1"> <idsite>1</idsite> <idgoal>1</idgoal> <name>triggered js</name> @@ -9,7 +9,7 @@ <revenue>0</revenue> <deleted>0</deleted> </row> - <row> + <row key="2"> <idsite>1</idsite> <idgoal>2</idgoal> <name>matching purchase.htm</name> diff --git a/tests/PHPUnit/Integration/expected/test_annotations__Annotations.get.xml b/tests/PHPUnit/Integration/expected/test_annotations__Annotations.get.xml new file mode 100755 index 0000000000..8ac7b4520a --- /dev/null +++ b/tests/PHPUnit/Integration/expected/test_annotations__Annotations.get.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8" ?> +<result> + <row> + <date>2011-12-02</date> + <note>1: Site 1 annotation for 2011-12-02</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>1</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> +</result> \ No newline at end of file diff --git a/tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAll_day.xml b/tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAll_day.xml new file mode 100755 index 0000000000..c65b44912c --- /dev/null +++ b/tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAll_day.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="utf-8" ?> +<result> + <row key="1"> + <row> + <date>2011-12-01</date> + <note>0: Site 1 annotation for 2011-12-01</note> + <starred>1</starred> + <user>superUserLogin</user> + <idNote>0</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + </row> +</result> \ No newline at end of file diff --git a/tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAll_month.xml b/tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAll_month.xml new file mode 100755 index 0000000000..fa326bf6fd --- /dev/null +++ b/tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAll_month.xml @@ -0,0 +1,85 @@ +<?xml version="1.0" encoding="utf-8" ?> +<result> + <row key="1"> + <row> + <date>2011-12-01</date> + <note>0: Site 1 annotation for 2011-12-01</note> + <starred>1</starred> + <user>superUserLogin</user> + <idNote>0</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2011-12-02</date> + <note>1: Site 1 annotation for 2011-12-02</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>1</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2011-12-08</date> + <note>2: Site 1 annotation for 2011-12-08</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>2</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2011-12-09</date> + <note>3: Site 1 annotation for 2011-12-09</note> + <starred>1</starred> + <user>superUserLogin</user> + <idNote>3</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2011-12-15</date> + <note>4: Site 1 annotation for 2011-12-15</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>4</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2011-12-16</date> + <note>5: Site 1 annotation for 2011-12-16</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>5</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2011-12-22</date> + <note>6: Site 1 annotation for 2011-12-22</note> + <starred>1</starred> + <user>superUserLogin</user> + <idNote>6</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2011-12-23</date> + <note>7: Site 1 annotation for 2011-12-23</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>7</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2011-12-29</date> + <note>8: Site 1 annotation for 2011-12-29</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>8</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2011-12-30</date> + <note>9: Site 1 annotation for 2011-12-30</note> + <starred>1</starred> + <user>superUserLogin</user> + <idNote>9</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + </row> +</result> \ No newline at end of file diff --git a/tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAll_week.xml b/tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAll_week.xml new file mode 100755 index 0000000000..3d0078beac --- /dev/null +++ b/tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAll_week.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8" ?> +<result> + <row key="1"> + <row> + <date>2011-12-01</date> + <note>0: Site 1 annotation for 2011-12-01</note> + <starred>1</starred> + <user>superUserLogin</user> + <idNote>0</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2011-12-02</date> + <note>1: Site 1 annotation for 2011-12-02</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>1</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + </row> +</result> \ No newline at end of file diff --git a/tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAll_year.xml b/tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAll_year.xml new file mode 100755 index 0000000000..f2f2835d16 --- /dev/null +++ b/tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAll_year.xml @@ -0,0 +1,133 @@ +<?xml version="1.0" encoding="utf-8" ?> +<result> + <row key="1"> + <row> + <date>2012-01-05</date> + <note>10: Site 1 annotation for 2012-01-05</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>10</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-01-06</date> + <note>11: Site 1 annotation for 2012-01-06</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>11</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-01-12</date> + <note>12: Site 1 annotation for 2012-01-12</note> + <starred>1</starred> + <user>superUserLogin</user> + <idNote>12</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-01-13</date> + <note>13: Site 1 annotation for 2012-01-13</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>13</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-01-19</date> + <note>14: Site 1 annotation for 2012-01-19</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>14</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-01-20</date> + <note>15: Site 1 annotation for 2012-01-20</note> + <starred>1</starred> + <user>superUserLogin</user> + <idNote>15</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-01-26</date> + <note>16: Site 1 annotation for 2012-01-26</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>16</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-01-27</date> + <note>17: Site 1 annotation for 2012-01-27</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>17</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-02-02</date> + <note>18: Site 1 annotation for 2012-02-02</note> + <starred>1</starred> + <user>superUserLogin</user> + <idNote>18</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-02-03</date> + <note>19: Site 1 annotation for 2012-02-03</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>19</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-02-09</date> + <note>20: Site 1 annotation for 2012-02-09</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>20</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-02-10</date> + <note>21: Site 1 annotation for 2012-02-10</note> + <starred>1</starred> + <user>superUserLogin</user> + <idNote>21</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-02-16</date> + <note>22: Site 1 annotation for 2012-02-16</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>22</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-02-17</date> + <note>23: Site 1 annotation for 2012-02-17</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>23</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-02-23</date> + <note>24: Site 1 annotation for 2012-02-23</note> + <starred>1</starred> + <user>superUserLogin</user> + <idNote>24</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-02-24</date> + <note>25: Site 1 annotation for 2012-02-24</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>25</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + </row> +</result> \ No newline at end of file diff --git a/tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAnnotationCountForDates_day.xml b/tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAnnotationCountForDates_day.xml new file mode 100755 index 0000000000..7409bdf738 --- /dev/null +++ b/tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAnnotationCountForDates_day.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8" ?> +<result> + <row key="1"> + <row> + <row>2011-12-01</row> + <row> + <count>1</count> + <starred>1</starred> + </row> + </row> + </row> +</result> \ No newline at end of file diff --git a/tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAnnotationCountForDates_month.xml b/tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAnnotationCountForDates_month.xml new file mode 100755 index 0000000000..152be4e272 --- /dev/null +++ b/tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAnnotationCountForDates_month.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8" ?> +<result> + <row key="1"> + <row> + <row>2011-12-01</row> + <row> + <count>10</count> + <starred>4</starred> + </row> + </row> + </row> +</result> \ No newline at end of file diff --git a/tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAnnotationCountForDates_week.xml b/tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAnnotationCountForDates_week.xml new file mode 100755 index 0000000000..d428059285 --- /dev/null +++ b/tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAnnotationCountForDates_week.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8" ?> +<result> + <row key="1"> + <row> + <row>2011-11-28</row> + <row> + <count>2</count> + <starred>1</starred> + </row> + </row> + </row> +</result> \ No newline at end of file diff --git a/tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAnnotationCountForDates_year.xml b/tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAnnotationCountForDates_year.xml new file mode 100755 index 0000000000..3d501c35f1 --- /dev/null +++ b/tests/PHPUnit/Integration/expected/test_annotations__Annotations.getAnnotationCountForDates_year.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8" ?> +<result> + <row key="1"> + <row> + <row>2012-01-01</row> + <row> + <count>16</count> + <starred>5</starred> + </row> + </row> + </row> +</result> \ No newline at end of file diff --git a/tests/PHPUnit/Integration/expected/test_annotations_lastN__Annotations.getAll_week.xml b/tests/PHPUnit/Integration/expected/test_annotations_lastN__Annotations.getAll_week.xml new file mode 100755 index 0000000000..d3aecc147b --- /dev/null +++ b/tests/PHPUnit/Integration/expected/test_annotations_lastN__Annotations.getAll_week.xml @@ -0,0 +1,85 @@ +<?xml version="1.0" encoding="utf-8" ?> +<result> + <row key="1"> + <row> + <date>2012-01-26</date> + <note>16: Site 1 annotation for 2012-01-26</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>16</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-01-27</date> + <note>17: Site 1 annotation for 2012-01-27</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>17</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-02-02</date> + <note>18: Site 1 annotation for 2012-02-02</note> + <starred>1</starred> + <user>superUserLogin</user> + <idNote>18</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-02-03</date> + <note>19: Site 1 annotation for 2012-02-03</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>19</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-02-09</date> + <note>20: Site 1 annotation for 2012-02-09</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>20</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-02-10</date> + <note>21: Site 1 annotation for 2012-02-10</note> + <starred>1</starred> + <user>superUserLogin</user> + <idNote>21</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-02-16</date> + <note>22: Site 1 annotation for 2012-02-16</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>22</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-02-17</date> + <note>23: Site 1 annotation for 2012-02-17</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>23</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-02-23</date> + <note>24: Site 1 annotation for 2012-02-23</note> + <starred>1</starred> + <user>superUserLogin</user> + <idNote>24</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-02-24</date> + <note>25: Site 1 annotation for 2012-02-24</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>25</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + </row> +</result> \ No newline at end of file diff --git a/tests/PHPUnit/Integration/expected/test_annotations_lastN__Annotations.getAnnotationCountForDates_week.xml b/tests/PHPUnit/Integration/expected/test_annotations_lastN__Annotations.getAnnotationCountForDates_week.xml new file mode 100755 index 0000000000..2abbab5b3f --- /dev/null +++ b/tests/PHPUnit/Integration/expected/test_annotations_lastN__Annotations.getAnnotationCountForDates_week.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="utf-8" ?> +<result> + <row key="1"> + <row> + <row>2012-01-23</row> + <row> + <count>2</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-01-30</row> + <row> + <count>2</count> + <starred>1</starred> + </row> + </row> + <row> + <row>2012-02-06</row> + <row> + <count>2</count> + <starred>1</starred> + </row> + </row> + <row> + <row>2012-02-13</row> + <row> + <count>2</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-02-20</row> + <row> + <count>2</count> + <starred>1</starred> + </row> + </row> + <row> + <row>2012-02-27</row> + <row> + <count>0</count> + <starred>0</starred> + </row> + </row> + </row> +</result> \ No newline at end of file diff --git a/tests/PHPUnit/Integration/expected/test_annotations_multipleSites__Annotations.getAll_month.xml b/tests/PHPUnit/Integration/expected/test_annotations_multipleSites__Annotations.getAll_month.xml new file mode 100755 index 0000000000..0d3c8ae176 --- /dev/null +++ b/tests/PHPUnit/Integration/expected/test_annotations_multipleSites__Annotations.getAll_month.xml @@ -0,0 +1,135 @@ +<?xml version="1.0" encoding="utf-8" ?> +<result> + <row key="1"> + <row> + <date>2012-01-05</date> + <note>10: Site 1 annotation for 2012-01-05</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>10</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-01-06</date> + <note>11: Site 1 annotation for 2012-01-06</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>11</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-01-12</date> + <note>12: Site 1 annotation for 2012-01-12</note> + <starred>1</starred> + <user>superUserLogin</user> + <idNote>12</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-01-13</date> + <note>13: Site 1 annotation for 2012-01-13</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>13</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-01-19</date> + <note>14: Site 1 annotation for 2012-01-19</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>14</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-01-20</date> + <note>15: Site 1 annotation for 2012-01-20</note> + <starred>1</starred> + <user>superUserLogin</user> + <idNote>15</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-01-26</date> + <note>16: Site 1 annotation for 2012-01-26</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>16</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-01-27</date> + <note>17: Site 1 annotation for 2012-01-27</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>17</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + </row> + <row key="2"> + <row> + <date>2012-01-05</date> + <note>10: Site 2 annotation for 2012-01-05</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>10</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-01-06</date> + <note>11: Site 2 annotation for 2012-01-06</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>11</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-01-12</date> + <note>12: Site 2 annotation for 2012-01-12</note> + <starred>1</starred> + <user>superUserLogin</user> + <idNote>12</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-01-13</date> + <note>13: Site 2 annotation for 2012-01-13</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>13</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-01-19</date> + <note>14: Site 2 annotation for 2012-01-19</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>14</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-01-20</date> + <note>15: Site 2 annotation for 2012-01-20</note> + <starred>1</starred> + <user>superUserLogin</user> + <idNote>15</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-01-26</date> + <note>16: Site 2 annotation for 2012-01-26</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>16</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-01-27</date> + <note>17: Site 2 annotation for 2012-01-27</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>17</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + </row> +</result> \ No newline at end of file diff --git a/tests/PHPUnit/Integration/expected/test_annotations_multipleSites__Annotations.getAnnotationCountForDates_month.xml b/tests/PHPUnit/Integration/expected/test_annotations_multipleSites__Annotations.getAnnotationCountForDates_month.xml new file mode 100755 index 0000000000..8549b62364 --- /dev/null +++ b/tests/PHPUnit/Integration/expected/test_annotations_multipleSites__Annotations.getAnnotationCountForDates_month.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8" ?> +<result> + <row key="1"> + <row> + <row>2012-01-01</row> + <row> + <count>8</count> + <starred>2</starred> + </row> + </row> + </row> + <row key="2"> + <row> + <row>2012-01-01</row> + <row> + <count>8</count> + <starred>2</starred> + </row> + </row> + </row> +</result> \ No newline at end of file diff --git a/tests/PHPUnit/Integration/expected/test_annotations_range__Annotations.getAll_range.xml b/tests/PHPUnit/Integration/expected/test_annotations_range__Annotations.getAll_range.xml new file mode 100755 index 0000000000..2e351524dc --- /dev/null +++ b/tests/PHPUnit/Integration/expected/test_annotations_range__Annotations.getAll_range.xml @@ -0,0 +1,69 @@ +<?xml version="1.0" encoding="utf-8" ?> +<result> + <row key="1"> + <row> + <date>2012-01-19</date> + <note>14: Site 1 annotation for 2012-01-19</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>14</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-01-20</date> + <note>15: Site 1 annotation for 2012-01-20</note> + <starred>1</starred> + <user>superUserLogin</user> + <idNote>15</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-01-26</date> + <note>16: Site 1 annotation for 2012-01-26</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>16</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-01-27</date> + <note>17: Site 1 annotation for 2012-01-27</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>17</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-02-02</date> + <note>18: Site 1 annotation for 2012-02-02</note> + <starred>1</starred> + <user>superUserLogin</user> + <idNote>18</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-02-03</date> + <note>19: Site 1 annotation for 2012-02-03</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>19</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-02-09</date> + <note>20: Site 1 annotation for 2012-02-09</note> + <starred>0</starred> + <user>superUserLogin</user> + <idNote>20</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + <row> + <date>2012-02-10</date> + <note>21: Site 1 annotation for 2012-02-10</note> + <starred>1</starred> + <user>superUserLogin</user> + <idNote>21</idNote> + <canEditOrDelete>1</canEditOrDelete> + </row> + </row> +</result> \ No newline at end of file diff --git a/tests/PHPUnit/Integration/expected/test_annotations_range__Annotations.getAnnotationCountForDates_range.xml b/tests/PHPUnit/Integration/expected/test_annotations_range__Annotations.getAnnotationCountForDates_range.xml new file mode 100755 index 0000000000..77c83c9c84 --- /dev/null +++ b/tests/PHPUnit/Integration/expected/test_annotations_range__Annotations.getAnnotationCountForDates_range.xml @@ -0,0 +1,229 @@ +<?xml version="1.0" encoding="utf-8" ?> +<result> + <row key="1"> + <row> + <row>2012-01-15</row> + <row> + <count>0</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-01-16</row> + <row> + <count>0</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-01-17</row> + <row> + <count>0</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-01-18</row> + <row> + <count>0</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-01-19</row> + <row> + <count>1</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-01-20</row> + <row> + <count>1</count> + <starred>1</starred> + </row> + </row> + <row> + <row>2012-01-21</row> + <row> + <count>0</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-01-22</row> + <row> + <count>0</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-01-23</row> + <row> + <count>0</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-01-24</row> + <row> + <count>0</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-01-25</row> + <row> + <count>0</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-01-26</row> + <row> + <count>1</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-01-27</row> + <row> + <count>1</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-01-28</row> + <row> + <count>0</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-01-29</row> + <row> + <count>0</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-01-30</row> + <row> + <count>0</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-01-31</row> + <row> + <count>0</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-02-01</row> + <row> + <count>0</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-02-02</row> + <row> + <count>1</count> + <starred>1</starred> + </row> + </row> + <row> + <row>2012-02-03</row> + <row> + <count>1</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-02-04</row> + <row> + <count>0</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-02-05</row> + <row> + <count>0</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-02-06</row> + <row> + <count>0</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-02-07</row> + <row> + <count>0</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-02-08</row> + <row> + <count>0</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-02-09</row> + <row> + <count>1</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-02-10</row> + <row> + <count>1</count> + <starred>1</starred> + </row> + </row> + <row> + <row>2012-02-11</row> + <row> + <count>0</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-02-12</row> + <row> + <count>0</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-02-13</row> + <row> + <count>0</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-02-14</row> + <row> + <count>0</count> + <starred>0</starred> + </row> + </row> + <row> + <row>2012-02-15</row> + <row> + <count>0</count> + <starred>0</starred> + </row> + </row> + </row> +</result> \ No newline at end of file diff --git a/tests/PHPUnit/IntegrationTestCase.php b/tests/PHPUnit/IntegrationTestCase.php index 8a03840dd8..bf5f27cd7a 100755 --- a/tests/PHPUnit/IntegrationTestCase.php +++ b/tests/PHPUnit/IntegrationTestCase.php @@ -174,6 +174,7 @@ abstract class IntegrationTestCase extends PHPUnit_Framework_TestCase 'Transitions', 'API', 'ImageGraph', + 'Annotations', ); const DEFAULT_USER_PASSWORD = 'nopass'; diff --git a/themes/default/images/grey_marker.png b/themes/default/images/grey_marker.png new file mode 100755 index 0000000000000000000000000000000000000000..b0e3e3e6aecd6e59cac279667c71c4fee45aa394 GIT binary patch literal 1627 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`EX7WqAsj$Z!;#Vf<Z~8yL>4nJ za0`Jj<E6WGe}IBAC9V-A!TD(=<%vb942~)JNvR5MnMJAP`9;~q3eLgCGp@NY1J#Lw z)HxTWCYEI8=P86_=B6?j=^L8s8(1jJSrq}aOc10X!q>+tIX_n~5oC^DMQ#CujeSKy zVsdtBi9%9pdS;%jl7fPQl0s&Rtx~wDuYqrYb81GWM^#a3aFt(3a#eP+Wr~u$9hXgo z6;N|-YDuC(MQ%=Bu~mhw64*>DAR8pCucQE0Qj%?}1aWkPZ-9bxeo?A|iJqZuvVpOQ zf{B@)k-3qjxtWeaaAJvqS7M%mk-37AfdP;(vNANZGBE@?1`L$!xPY`xQA(Oskc%7C zP9V=#DWjyMz)D}gyu4hm+*mKaC|%#s($Z4jz)0W7NEfI=x41H|B(Xv_uUHvk2+SOp z)Z*l#%mQ$5fy_-z$}cUkRZ;?31P4&hB^JOf$}5Hj9xxd7D-sLz4fPE4;U)t$+5iQu zz!8yO6q28xV}~WqY(P3u6d`Oy=udS?EJ?KkhKGf&fswAEd5D3Lm9d$XiD?v)euyG8 z?Y{XbnQ4_s+KqLMOhODTtqcsTOpKt~krY9-+vtM=0x4j?p$_sBnz#ai082@RhgU&q zQ4Tm-Qj+ykb5e6t^Gb?=VP=RLW+};5Y57IDi6wTKxryni`UQFEHu?xbyzYaz8kj7A z$x<JlE@4iGM<q=dh;XNg@eo64X^E+9PznPB<8)6K#}JF&sS`JL-F6Uh?RV;lk=PdS z@YLLB@mDb%kET4XvvcTO)q0j)XU+b@^%LFnOeU~w3sC*Ucw4e%0@vF`FIlQGbrxQ| zrMT0;;BfW#bCt$@9T!d)$}mg`vVOo&vvW!vW4lFPz0uSTp4Uls8EaKNg<eio>C9N` zs#z-Wy_@6QjD-F7--~r0?bqnaxLw9kD${;5=h(-JBT>IgeLiI}eYrB*_PjC&Q<K7h z&o;NVsdgzfEZ>+Ywc&P-8N-i#{jYcJSl9jjH$zO;?)|JAV)WW;ua}m@mTxW7esDC3 z+hJO2ME1sOlTsxarr$b!<3pv;hn$|s_1Ck{JvXkL$80eFeB|0NiDZU(_brdbFPgIJ za@2k^Uu7kw(pc}<>q<(OOnNic#!n3@Pd>j}&~N$W*;~IU`d;SGpRi2x+AW~3?&k@8 z;X3?5M=5C9(kZE$N=k_m^OpaW=cp2O{g}}s$<bpd;`*^-?RPCl|B26cKB=_3^Y1*1 zc-QY$t5&S)TBPwV|Eur*!V51;e(k%PSO5L@e?A6}%U^E)u#pvhz(3X6D16BppWna& Op25@A&t;ucLK6ThY4}|L literal 0 HcmV?d00001 diff --git a/themes/default/images/star.png b/themes/default/images/star.png new file mode 100755 index 0000000000000000000000000000000000000000..52d161eab4370b9d2d3350b97c0c653cbbe9f6ed GIT binary patch literal 757 zcmV<R0t)?!P)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV0000PbVXQnQ*UN; zcVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!mq|oHRCwBKQcY+RQ4pTD*`%8$Y0@OL z*oIb%putvKltK^nXb&nv5y6A?qImWu2p&X4FN$7@B8V0}iQr!arGHWr5!6d-#MW9g z!IswkC7b=r+viM@wKjF&<IR3=W_D)2$DnL`+Zf!s43*YbKJPoknKu%A$VmYG7WDNp zV6O*Q@xm<w`2Cv-TrZzz|0Ty}8SD;;qAxq7RX|us{AHlY>)*DM94+^~36CG-7ckCA z-cW!nrvmtVC7&_q3fAwB0SL&he(w^ZR6tcXwkTEhtm~z}AiBf#ow0CTXN-K}=&sT> z72q6EfM$;pi_4M-$&wxND2;LA=&sUaY;CZTq<z#x+Z%Ls7AFu82)8z@E@%4TKgM1S zxNuH5S6kQCwWH&7T$Uv-H<vKc1}mQ~{Y9xOelu4Un1-r-`}{by@_QzUS68TjGs;ny ztek3z_r}A~=9sQ!K(lh7n;P!JLUjUQtS+V(GVfA0x9(v4BZTb~aE!XS*s`bhhyin9 zA=c#*rfccfm+>d)UO=Qb$*8IV-+@RYvW@y2^ZGaHmCJLsu)wo!H%lW>?$QI44xHeS z$#7U%v8jlMbE!R2YX+;`8u&am_}ms$%Nj&$tsv*Donk1KawxYb<JH?``8c!&L%v9r zoRhd|n~9;R$;9yVBsVovaT`)iP>E0s#ZnGgaRH0VxD6b4dovtr+8XG|tA;T?@-vmq znm1LokeHcXiz$k$3Kf#)=k=+l!|*ZjX}th-;r%)b!Jck7spSiiiHWuMFUQ3BYj@%8 z^ASjKE|RF}7SrGXO|#*~JxDt5j+RK<s76F31|6eG>%qfkFld;NN3Mnhcrgk~LrJ)F n{IGR!-!3>tvD7%i_#?moaXc(Dvb(%Q00000NkvXXu0mjf4+do@ literal 0 HcmV?d00001 diff --git a/themes/default/images/star_empty.png b/themes/default/images/star_empty.png new file mode 100755 index 0000000000000000000000000000000000000000..965f4bacb8248523d65afbd577346cfd08f28b01 GIT binary patch literal 658 zcmV;D0&V??P)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV0000PbVXQnQ*UN; zcVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!G)Y83RCwBKQ@f7ZKoqsX2=Pk5AcBYh zZA3s3si+nyO*%dx`~XF0_z3=iiiVN~DIG;BloTixK>@S^K?6dd4H$=)*I;n&v23z( z80mVh&OP@rbH;43SY$?{5tGm7nH%d~-+!0`)oPWA$Kwo#4{WtsF@C?_%!}MA0B$y$ z)qKtXWm(qDX0sU(?{c}^aQ6E>Pn!V$bUJA~V_D3VjY7xc@h%(=%P{x`4$kvsG0(i+ zZjp>mZ=%uY76u;}V&=t}0Yy<5mSuU5$KwhF0*}DxSbj?8DJJ|^;BvWqHiH;7l}e$V z){>@>+wJzJTQae^T<#@x_?So}JcUAG6$*t8giCr{)OWAGI~I$Dvr?(}hr=O`*0fS5 z+3WQVD5z4Gw|2XojYJ|M9b*klH%V1hd#lxYRjbtkhr{6~#lK6E^kiJ3%5mJUU@-Wl z-|usi$po?~&kY!2;DD<a{-*-;oy}%{dcB^sUa!yQ0>BUh2i&WS^?IE-pU=!@vta~5 zuu?FXOeO`r!wcP1V2FVOE^?5oP0(mGH2KJaKA-Q&;cy)1^LeyXDy3oYfguJCxX8h> z;<MZBw2GvAGLy+TrqijbTrS@&m&-17{M~Fe-_yA*Znyg~7!1@-r!yaq$Jd9%;;nSM zU5>_lO=K@69q14JN|&Y`g_K%|qWFR5c_+O{mADW3-JGrkpfO1kqNE3sb4X+2rtBy{ sm_(hwrPJwTEEao5m{Wf%%3lEn0O815@iXI*8~^|S07*qoM6N<$f<rhiApigX literal 0 HcmV?d00001 diff --git a/themes/default/images/yellow_marker.png b/themes/default/images/yellow_marker.png new file mode 100755 index 0000000000000000000000000000000000000000..a2506a2a7d7d2da2d36133becc98637444f05f0b GIT binary patch literal 1618 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`EX7WqAsj$Z!;#Vf<Z~8yL>4nJ za0`Jj<E6WGe}IBAC9V-A!TD(=<%vb942~)JNvR5MnMJAP`9;~q3eLgCGp@NY1J#Lw z)HxTWCYEI8=P86_=B6?j=^L8s8(1jJSrq}aOc10X!q>+tIX_n~5oC^DMQ#CujeSKy zVsdtBi9%9pdS;%jl7fPQl0s&Rtx~wDuYqrYb81GWM^#a3aFt(3a#eP+Wr~u$9hXgo z6;N|-YDuC(MQ%=Bu~mhw64*>DAR8pCucQE0Qj%?}1aWkPZ-9bxeo?A|iJqZuvVpOQ zf{B@)k-3qjxtWeaaAJvqS7M%mk-37AfdP;(vNANZGBE@?1`L$!xPY`xQA(Oskc%7C zP9V=#DWjyMz)D}gyu4hm+*mKaC|%#s($Z4jz)0W7NEfI=x41H|B(Xv_uUHvk2+SOp z)Z*l#%mQ$5fy_-z$}cUkRZ;?31P4&hB^JOf$}5Hj9xxd7D-sLz4fPE4;U)t$+5iQu zz!8yO6q28xV}~WqY(P3u6d`Oy=udS?EJ?KkhKGf&fswAEd5D3Lm9d$XiD?v)euyG8 z?Y{XbnQ4_s+KqLMOhODTtqcsTOpKt~krY9-+vtM=0x4j?p$_sBnz#ai082@RhgU&q zQ4Tm-Qj+ykb5e6t^Gb?=VP=RLW+};5Y57IDi6wTKxryni`UQFEHu?xbyzYaz8kj7A z$x<JlE@4iGM<q=dh;XNg@eo64X^E+9PznPBW1pvsV~9m>?WFzQPJtrF?Zch5R%UcA zTHdlH-7inlrM{usyLeV`BipTnfaJH0U-DT*Ew+5g7VPBkJh@1LhjZu4-Mrz!`L_Hr z7IQw^&j0>&uJ)9m5+@GE1Buf(CNNIBwU}E$*n3rRkAT_(1Mg#tJ=k?;%)a<HNHkAd zfpz!BC8sQy`o6gwS!}@_m2mrSe9_K2#nXHJV%ip$q~DZ}P!QRxB(nFAe8homiW!-{ z@;4Y<mZ_B*%Gw>ZeRSCNQL<GHmqWFfBuhfcj_BKK_1|2(zTa5=K=Yq#M^8WH+EmxS z=KklRolFkZ-!AMnNMZQ2RO|EP#~;2_xG&zpbzlSgu|2<}9O@=CyF8qFt3{k?bAs<n zo->D9#JSdAX=qVan72rcb@uu9Z|f7v&;R_x7qmgrd(|xk_9MYhy7EI9a+V4&_~E8H zPbvM?e+C9SRqLf%Cec>jp?81#|9-An{-|Pp(TR4}g4ku7_Vw~F2!H>AVZptW#fl3R zOW!UjE@3DrI>3A2mRR@Pc`pytu-AV4c(>lDSn>h8;NC^+|L;2ksuUPJUHx3vIVCg! E0OvaEZ2$lO literal 0 HcmV?d00001 -- GitLab