connection->executeQuery(
$sql,
array(array_keys($records)),
@@ -50,7 +55,7 @@ SQL;
);
while ($row = $statement->fetch()) {
- $records[$row['record_id']]['title'][$row['locale']] = $row['title'];
+ $records[$row['record_id']]['title'][$row['locale']] = $this->helper->sanitizeValue($row['title'], FieldMapping::TYPE_STRING);
}
}
}
diff --git a/lib/Alchemy/Phrasea/SearchEngine/Elastic/Mapping/DateFieldMapping.php b/lib/Alchemy/Phrasea/SearchEngine/Elastic/Mapping/DateFieldMapping.php
index a96ef45f4a..128a16467f 100644
--- a/lib/Alchemy/Phrasea/SearchEngine/Elastic/Mapping/DateFieldMapping.php
+++ b/lib/Alchemy/Phrasea/SearchEngine/Elastic/Mapping/DateFieldMapping.php
@@ -57,6 +57,9 @@ class DateFieldMapping extends ComplexFieldMapping
*/
protected function getProperties()
{
- return array_merge([ 'format' => $this->format ], parent::getProperties());
+ return array_merge([
+ 'format' => $this->format,
+ 'ignore_malformed' => true
+ ], parent::getProperties());
}
}
diff --git a/lib/Alchemy/Phrasea/SearchEngine/Elastic/RecordHelper.php b/lib/Alchemy/Phrasea/SearchEngine/Elastic/RecordHelper.php
index ffb0c71adf..74f3a297a8 100644
--- a/lib/Alchemy/Phrasea/SearchEngine/Elastic/RecordHelper.php
+++ b/lib/Alchemy/Phrasea/SearchEngine/Elastic/RecordHelper.php
@@ -89,31 +89,72 @@ class RecordHelper
return $this->collectionMap;
}
- /**
- * @param string $date
- * @return bool
- */
- public static function validateDate($date)
- {
- $d = DateTime::createFromFormat(FieldMapping::DATE_FORMAT_CAPTION_PHP, $date);
-
- return $d && $d->format(FieldMapping::DATE_FORMAT_CAPTION_PHP) == $date;
- }
-
/**
* @param string $value
* @return null|string
*/
public static function sanitizeDate($value)
{
- // introduced in https://github.com/alchemy-fr/Phraseanet/commit/775ce804e0257d3a06e4e068bd17330a79eb8370#diff-bee690ed259e0cf73a31dee5295d2edcR286
- // not sure if it's really needed
+ $v_fix = null;
try {
- $date = new \DateTime($value);
-
- return $date->format(FieldMapping::DATE_FORMAT_CAPTION_PHP);
+ $a = explode(';', preg_replace('/\D+/', ';', trim($value)));
+ switch (count($a)) {
+ case 1: // yyyy
+ $date = new \DateTime($a[0] . '-01-01'); // will throw if date is not valid
+ $v_fix = $date->format('Y');
+ break;
+ case 2: // yyyy;mm
+ $date = new \DateTime( $a[0] . '-' . $a[1] . '-01');
+ $v_fix = $date->format('Y-m');
+ break;
+ case 3: // yyyy;mm;dd
+ $date = new \DateTime($a[0] . '-' . $a[1] . '-' . $a[2]);
+ $v_fix = $date->format('Y-m-d');
+ break;
+ case 4:
+ $date = new \DateTime($a[0] . '-' . $a[1] . '-' . $a[2] . ' ' . $a[3] . ':00:00');
+ $v_fix = $date->format('Y-m-d H:i:s');
+ break;
+ case 5:
+ $date = new \DateTime($a[0] . '-' . $a[1] . '-' . $a[2] . ' ' . $a[3] . ':' . $a[4] . ':00');
+ $v_fix = $date->format('Y-m-d H:i:s');
+ break;
+ case 6:
+ $date = new \DateTime($a[0] . '-' . $a[1] . '-' . $a[2] . ' ' . $a[3] . ':' . $a[4] . ':' . $a[5]);
+ $v_fix = $date->format('Y-m-d H:i:s');
+ break;
+ }
} catch (\Exception $e) {
- return null;
+ // no-op, v_fix = null
+ }
+
+ return $v_fix;
+ }
+
+ public function sanitizeValue($value, $type)
+ {
+ switch ($type) {
+ case FieldMapping::TYPE_DATE:
+ return self::sanitizeDate($value);
+
+ case FieldMapping::TYPE_FLOAT:
+ case FieldMapping::TYPE_DOUBLE:
+ return (float) $value;
+
+ case FieldMapping::TYPE_INTEGER:
+ case FieldMapping::TYPE_LONG:
+ case FieldMapping::TYPE_SHORT:
+ case FieldMapping::TYPE_BYTE:
+ return (int) $value;
+
+ case FieldMapping::TYPE_BOOLEAN:
+ return (bool) $value;
+
+ case FieldMapping::TYPE_STRING:
+ return str_replace("\0", '', $value);
+
+ default:
+ return $value;
}
}
}
diff --git a/lib/Alchemy/Phrasea/SearchEngine/Elastic/Search/QueryHelper.php b/lib/Alchemy/Phrasea/SearchEngine/Elastic/Search/QueryHelper.php
index 1acdd5935c..071ac94b1c 100644
--- a/lib/Alchemy/Phrasea/SearchEngine/Elastic/Search/QueryHelper.php
+++ b/lib/Alchemy/Phrasea/SearchEngine/Elastic/Search/QueryHelper.php
@@ -110,41 +110,50 @@ class QueryHelper
}
}
- public static function getRangeFromDateString($string)
+ public static function getRangeFromDateString($value)
{
- $formats = ['Y/m/d', 'Y/m', 'Y'];
- $deltas = ['+1 day', '+1 month', '+1 year'];
- $to = null;
- while ($format = array_pop($formats)) {
- $delta = array_pop($deltas);
- $from = date_create_from_format($format, $string);
- if ($from !== false) {
- // Rewind to start of range
- $month = 1;
- $day = 1;
- switch ($format) {
- case 'Y/m/d':
- $day = (int) $from->format('d');
- case 'Y/m':
- $month = (int) $from->format('m');
- case 'Y':
- $year = (int) $from->format('Y');
- }
- date_date_set($from, $year, $month, $day);
- date_time_set($from, 0, 0, 0);
- // Create end of the the range
- $to = date_modify(clone $from, $delta);
- break;
+ $date_from = null;
+ $date_to = null;
+ try {
+ $a = explode(';', preg_replace('/\D+/', ';', trim($value)));
+ switch (count($a)) {
+ case 1: // yyyy
+ $date_to = clone($date_from = new \DateTime($a[0] . '-01-01 00:00:00')); // will throw if date is not valid
+ $date_to->add(new \DateInterval('P1Y'));
+ break;
+ case 2: // yyyy;mm
+ $date_to = clone($date_from = new \DateTime($a[0] . '-' . $a[1] . '-01 00:00:00')); // will throw if date is not valid
+ $date_to->add(new \DateInterval('P1M'));
+ break;
+ case 3: // yyyy;mm;dd
+ $date_to = clone($date_from = new \DateTime($a[0] . '-' . $a[1] . '-' . $a[2] . ' 00:00:00')); // will throw if date is not valid
+ $date_to->add(new \DateInterval('P1D'));
+ break;
+ case 4:
+ $date_to = clone($date_from = new \DateTime($a[0] . '-' . $a[1] . '-' . $a[2] . ' ' . $a[3] . ':00:00'));
+ $date_to->add(new \DateInterval('PT1H'));
+ break;
+ case 5:
+ $date_to = clone($date_from = new \DateTime($a[0] . '-' . $a[1] . '-' . $a[2] . ' ' . $a[3] . ':' . $a[4] . ':00'));
+ $date_to->add(new \DateInterval('PT1M'));
+ break;
+ case 6:
+ $date_to = clone($date_from = new \DateTime($a[0] . '-' . $a[1] . '-' . $a[2] . ' ' . $a[3] . ':' . $a[4] . ':' . $a[5]));
+ // $date_to->add(new \DateInterval('PT1S')); // no need since precision is 1 sec, a "equal" will be generated when from==to
+ break;
}
}
+ catch (\Exception $e) {
+ // no-op
+ }
- if (!$from || !$to) {
- throw new \InvalidArgumentException(sprintf('Invalid date "%s".', $string));
+ if ($date_from === null || $date_to === null) {
+ throw new \InvalidArgumentException(sprintf('Invalid date "%s".', $value));
}
return [
- 'from' => $from->format(FieldMapping::DATE_FORMAT_CAPTION_PHP),
- 'to' => $to->format(FieldMapping::DATE_FORMAT_CAPTION_PHP)
+ 'from' => $date_from->format('Y-m-d H:i:s'),
+ 'to' => $date_to->format('Y-m-d H:i:s')
];
}
}
diff --git a/lib/Alchemy/Phrasea/SearchEngine/Elastic/Search/QueryVisitor.php b/lib/Alchemy/Phrasea/SearchEngine/Elastic/Search/QueryVisitor.php
index 522666bcaa..52c0fa54a6 100644
--- a/lib/Alchemy/Phrasea/SearchEngine/Elastic/Search/QueryVisitor.php
+++ b/lib/Alchemy/Phrasea/SearchEngine/Elastic/Search/QueryVisitor.php
@@ -5,7 +5,7 @@ namespace Alchemy\Phrasea\SearchEngine\Elastic\Search;
use Alchemy\Phrasea\SearchEngine\Elastic\AST;
use Alchemy\Phrasea\SearchEngine\Elastic\Exception\Exception;
use Alchemy\Phrasea\SearchEngine\Elastic\FieldMapping;
-use Alchemy\Phrasea\SearchEngine\Elastic\Mapping;
+use Alchemy\Phrasea\SearchEngine\Elastic\RecordHelper;
use Alchemy\Phrasea\SearchEngine\Elastic\Structure\Structure;
use Hoa\Compiler\Llk\TreeNode;
use Hoa\Visitor\Element;
@@ -166,6 +166,12 @@ class QueryVisitor implements Visit
$key = $node->getChild(0)->accept($this);
$boundary = $node->getChild(1)->accept($this);
+ if ($this->isDateKey($key)) {
+ if(($v = RecordHelper::sanitizeDate($boundary)) !== null) {
+ $boundary = $v;
+ }
+ }
+
switch ($node->getId()) {
case NodeTypes::LT_EXPR:
return AST\KeyValue\RangeExpression::lessThan($key, $boundary);
@@ -195,11 +201,15 @@ class QueryVisitor implements Visit
try {
// Try to create a range for incomplete dates
$range = QueryHelper::getRangeFromDateString($right);
- return new AST\KeyValue\RangeExpression(
- $left,
- $range['from'], true,
- $range['to'], false
- );
+ if ($range['from'] === $range['to']) {
+ return new AST\KeyValue\EqualExpression($left, $range['from']);
+ } else {
+ return new AST\KeyValue\RangeExpression(
+ $left,
+ $range['from'], true,
+ $range['to'], false
+ );
+ }
} catch (\InvalidArgumentException $e) {
// Fall back to equal expression
}
diff --git a/lib/Alchemy/Phrasea/SearchEngine/Elastic/Structure/ValueChecker.php b/lib/Alchemy/Phrasea/SearchEngine/Elastic/Structure/ValueChecker.php
index 31f65c580e..f27defa2bb 100644
--- a/lib/Alchemy/Phrasea/SearchEngine/Elastic/Structure/ValueChecker.php
+++ b/lib/Alchemy/Phrasea/SearchEngine/Elastic/Structure/ValueChecker.php
@@ -3,7 +3,6 @@
namespace Alchemy\Phrasea\SearchEngine\Elastic\Structure;
use Alchemy\Phrasea\SearchEngine\Elastic\FieldMapping;
-use Alchemy\Phrasea\SearchEngine\Elastic\Mapping;
use Alchemy\Phrasea\SearchEngine\Elastic\RecordHelper;
use Assert\Assertion;
@@ -20,7 +19,7 @@ class ValueChecker
{
Assertion::allIsInstanceOf($list, Typed::class);
$is_numeric = is_numeric($value);
- $is_valid_date = RecordHelper::validateDate($value);
+ $is_valid_date = (RecordHelper::sanitizeDate($value) !== null);
$filtered = [];
foreach ($list as $item) {
switch ($item->getType()) {
diff --git a/lib/Alchemy/Phrasea/TaskManager/Job/WriteMetadataJob.php b/lib/Alchemy/Phrasea/TaskManager/Job/WriteMetadataJob.php
index f35b8e806e..795b0b2a83 100644
--- a/lib/Alchemy/Phrasea/TaskManager/Job/WriteMetadataJob.php
+++ b/lib/Alchemy/Phrasea/TaskManager/Job/WriteMetadataJob.php
@@ -127,7 +127,10 @@ class WriteMetadataJob extends AbstractJob
// check exiftool known tags to skip Phraseanet:tf-*
try {
- TagFactory::getFromRDFTagname($tagName);
+ $tag = TagFactory::getFromRDFTagname($tagName);
+ if(!$tag->isWritable()) {
+ continue;
+ }
} catch (TagUnknown $e) {
continue;
}
@@ -147,21 +150,34 @@ class WriteMetadataJob extends AbstractJob
$fieldValue = array_pop($fieldValues);
$value = $this->removeNulChar($fieldValue->getValue());
- $value = new Value\Mono($value);
+ // fix the dates edited into phraseanet
+ if($fieldStructure->get_type() === $fieldStructure::TYPE_DATE) {
+ try {
+ $value = self::fixDate($value); // will return NULL if the date is not valid
+ }
+ catch (\Exception $e) {
+ $value = null; // do NOT write back to iptc
+ }
+ }
+
+ if($value !== null) { // do not write invalid dates
+ $value = new Value\Mono($value);
+ }
}
- } catch(\Exception $e) {
+ } catch (\Exception $e) {
// the field is not set in the record, erase it
if ($fieldStructure->is_multi()) {
$value = new Value\Multi(array(''));
- }
- else {
+ } else {
$value = new Value\Mono('');
}
}
- $metadata->add(
- new Metadata\Metadata($fieldStructure->get_tag(), $value)
- );
+ if($value !== null) { // do not write invalid data
+ $metadata->add(
+ new Metadata\Metadata($fieldStructure->get_tag(), $value)
+ );
+ }
}
$writer = $this->getMetadataWriter($jobData->getApplication());
@@ -220,4 +236,34 @@ class WriteMetadataJob extends AbstractJob
{
return str_replace("\0", "", $value);
}
+
+ /**
+ * re-format a phraseanet date for iptc writing
+ * return NULL if the date is not valid
+ *
+ * @param string $value
+ * @return string|null
+ */
+ private static function fixDate($value)
+ {
+ $date = null;
+ try {
+ $a = explode(';', preg_replace('/\D+/', ';', trim($value)));
+ switch (count($a)) {
+ case 3: // yyyy;mm;dd
+ $date = new \DateTime($a[0] . '-' . $a[1] . '-' . $a[2]);
+ $date = $date->format('Y-m-d H:i:s');
+ break;
+ case 6: // yyyy;mm;dd;hh;mm;ss
+ $date = new \DateTime($a[0] . '-' . $a[1] . '-' . $a[2] . ' ' . $a[3] . ':' . $a[4] . ':' . $a[5]);
+ $date = $date->format('Y-m-d H:i:s');
+ break;
+ }
+ }
+ catch (\Exception $e) {
+ $date = null;
+ }
+
+ return $date;
+ }
}
diff --git a/lib/classes/cache/databox.php b/lib/classes/cache/databox.php
index 75460fa962..3687deb211 100644
--- a/lib/classes/cache/databox.php
+++ b/lib/classes/cache/databox.php
@@ -121,9 +121,9 @@ class cache_databox
$conn = $app->getApplicationBox()->get_connection();
- $sql = 'UPDATE sitepreff SET memcached_update = :date';
+ $sql = 'UPDATE sitepreff SET memcached_update = current_timestamp()';
$stmt = $conn->prepare($sql);
- $stmt->execute([':date' => $now]);
+ $stmt->execute();
$stmt->closeCursor();
self::$refreshing = false;
diff --git a/templates/web/admin/editusers.html.twig b/templates/web/admin/editusers.html.twig
index 4b07dea1d3..2421976775 100644
--- a/templates/web/admin/editusers.html.twig
+++ b/templates/web/admin/editusers.html.twig
@@ -512,7 +512,7 @@
{{ 'admin::compte-utilisateur poste' | trans }}
|
-
+
|
@@ -528,7 +528,7 @@
{{ 'admin::compte-utilisateur activite' | trans }}
|
-
+
|
diff --git a/templates/web/prod/WorkZone/Browser/Results.html.twig b/templates/web/prod/WorkZone/Browser/Results.html.twig
index 1985bcaec4..c6f43ee5a8 100644
--- a/templates/web/prod/WorkZone/Browser/Results.html.twig
+++ b/templates/web/prod/WorkZone/Browser/Results.html.twig
@@ -51,7 +51,7 @@
{% if Basket.getPusher() %}
- {% set user_name = '' ~ Basket.getPusher(app).get_display_name() ~ '' %}
+ {% set user_name = '' ~ Basket.getPusher(app).getDisplayName() ~ '' %}
{% trans with {'%user_name%' : user_name} %}Received from %user_name%{% endtrans %}
{% endif %}
diff --git a/tests/Alchemy/Tests/Phrasea/Controller/Api/ApiJsonTest.php b/tests/Alchemy/Tests/Phrasea/Controller/Api/ApiJsonTest.php
index d78194c4d7..c8f2cdb254 100644
--- a/tests/Alchemy/Tests/Phrasea/Controller/Api/ApiJsonTest.php
+++ b/tests/Alchemy/Tests/Phrasea/Controller/Api/ApiJsonTest.php
@@ -14,6 +14,7 @@ use Alchemy\Phrasea\Model\Entities\LazaretSession;
use Alchemy\Phrasea\Model\Entities\Task;
use Alchemy\Phrasea\Model\Entities\User;
use Alchemy\Phrasea\SearchEngine\SearchEngineOptions;
+use Alchemy\Phrasea\Status\StatusStructureProviderInterface;
use Doctrine\Common\Collections\ArrayCollection;
use Guzzle\Common\Exception\GuzzleException;
use Ramsey\Uuid\Uuid;
@@ -1256,13 +1257,33 @@ class ApiJsonTest extends ApiTestCase
$record1 = $this->getRecord1();
$route = '/api/v1/records/' . $record1->getDataboxId() . '/' . $record1->getRecordId() . '/setstatus/';
- $record_status = strrev($record1->getStatus());
+ $initialRecordStatus = $record_status = strrev($record1->getStatus());
+
+ /** @var StatusStructureProviderInterface $statusProvider */
+ $statusProvider = $app['status.provider'];
+
+ // initialize status structure for test eg: 4 to 15 bit
+ foreach (range(4, 15) as $n) {
+ $properties = [
+ 'searchable' => '0',
+ 'printable' => '0',
+ 'name' => 'status_test_' . $n,
+ 'labelon' => '',
+ 'labeloff' => '',
+ 'labels_on' => [],
+ 'labels_off' => [],
+ ];
+
+ $statusProvider->updateStatus($record1->getStatusStructure(), $n, $properties);
+ }
+
$statusStructure = $record1->getStatusStructure();
$tochange = [];
foreach ($statusStructure as $n => $datas) {
$tochange[$n] = substr($record_status, ($n - 1), 1) == '0' ? '1' : '0';
}
+
$this->evaluateMethodNotAllowedRoute($route, ['GET', 'PUT', 'DELETE']);
$response = $this->request('POST', $route, $this->getParameters(['status' => $tochange]), ['HTTP_Accept' => $this->getAcceptMimeType()]);
@@ -1281,6 +1302,27 @@ class ApiJsonTest extends ApiTestCase
$this->assertEquals(substr($record_status, ($n), 1), $tochange[$n]);
}
+ // test record_status in string
+ $record_status_expected = $record_status;
+
+ $pos = strpos($record_status, '1');
+ $bitToChange[$pos] = '1';
+
+ $response = $this->request('POST', $route, $this->getParameters(['status' => $bitToChange]), ['HTTP_Accept' => $this->getAcceptMimeType()]);
+ $content = $this->unserialize($response->getContent());
+
+ // Get fresh record_1
+ $testRecord = new \record_adapter($app, $testRecord->getDataboxId(), $testRecord->getRecordId());
+
+ $this->evaluateResponse200($response);
+ $this->evaluateMeta200($content);
+
+ $this->evaluateRecordsStatusResponse($testRecord, $content);
+
+ $record_new_status = strrev($testRecord->getStatus());
+ $this->assertEquals($record_status_expected, $record_new_status);
+
+
foreach ($tochange as $n => $value) {
$tochange[$n] = $value == '0' ? '1' : '0';
}
@@ -1301,6 +1343,8 @@ class ApiJsonTest extends ApiTestCase
$this->assertEquals(substr($record_status, ($n), 1), $tochange[$n]);
}
+ $this->assertEquals($initialRecordStatus, $record_status);
+
$record1->setStatus(str_repeat('0', 32));
}
diff --git a/tests/Alchemy/Tests/Phrasea/SearchEngine/AST/RangeExpressionTest.php b/tests/Alchemy/Tests/Phrasea/SearchEngine/AST/RangeExpressionTest.php
index 88a850fe03..d1d376e37b 100644
--- a/tests/Alchemy/Tests/Phrasea/SearchEngine/AST/RangeExpressionTest.php
+++ b/tests/Alchemy/Tests/Phrasea/SearchEngine/AST/RangeExpressionTest.php
@@ -58,6 +58,7 @@ class RangeExpressionTest extends \PHPUnit_Framework_TestCase
{
$query_context = $this->prophesize(QueryContext::class)->reveal();
$key_prophecy = $this->prophesize(Key::class);
+ $key_prophecy->getFieldType($query_context)->willReturn('text');
$key_prophecy->getIndexField($query_context)->willReturn('foo');
$key_prophecy->isValueCompatible('bar', $query_context)->willReturn(true);
$key = $key_prophecy->reveal();
@@ -73,6 +74,7 @@ class RangeExpressionTest extends \PHPUnit_Framework_TestCase
{
$query_context = $this->prophesize(QueryContext::class)->reveal();
$key = $this->prophesize(FieldKey::class);
+ $key->getFieldType($query_context)->willReturn('text');
$key->getIndexField($query_context)->willReturn('baz');
$key->isValueCompatible('bar', $query_context)->willReturn(true);
$key->postProcessQuery(Argument::any(), $query_context)->willReturnArgument(0);
diff --git a/tests/Alchemy/Tests/Phrasea/SearchEngine/resources/queries.csv b/tests/Alchemy/Tests/Phrasea/SearchEngine/resources/queries.csv
index 9f7d079880..5252788eaa 100644
--- a/tests/Alchemy/Tests/Phrasea/SearchEngine/resources/queries.csv
+++ b/tests/Alchemy/Tests/Phrasea/SearchEngine/resources/queries.csv
@@ -53,6 +53,7 @@ foo < 42|
foo ≤ 42|
foo > 42|
foo ≥ 42|
+foo = 2015/01/01|( == )
foo < 2015/01/01|
foo ≤ 2015/01/01|
foo > 2015/01/01|
@@ -93,19 +94,93 @@ id:90 AND foo|( AND )
id:90 foo|( AND )
recordid:90|
-# Timestamps
-created_on < "2015/01/01"|
-created_on ≤ "2015/01/01"|
-created_on = "2015/01/01"|
-created_on ≥ "2015/01/01"|
-created_on > "2015/01/01"|
-updated_on < "2015/01/01"|
-updated_on ≤ "2015/01/01"|
-updated_on = "2015/01/01"|
-updated_on ≥ "2015/01/01"|
-updated_on > "2015/01/01"|
-created_at > "2015/01/01"|
-updated_at > "2015/01/01"|
+# Timestamps yyyy
+created_on < "2015"|
+created_on ≤ "2015"|
+created_on = "2015"|
+created_on ≥ "2015"|
+created_on > "2015"|
+updated_on < "2015"|
+updated_on ≤ "2015"|
+updated_on = "2015"|
+updated_on ≥ "2015"|
+updated_on > "2015"|
+created_at > "2015"|
+updated_at > "2015"|
+
+# Timestamps yyyy/mm
+created_on < "2015/01"|
+created_on ≤ "2015/01"|
+created_on = "2015/01"|
+created_on ≥ "2015/01"|
+created_on > "2015/01"|
+updated_on < "2015/01"|
+updated_on ≤ "2015/01"|
+updated_on = "2015/01"|
+updated_on ≥ "2015/01"|
+updated_on > "2015/01"|
+created_at > "2015/01"|
+updated_at > "2015/01"|
+
+# Timestamps yyyy/mm/dd
+created_on < "2015/01/01"|
+created_on ≤ "2015/01/01"|
+created_on = "2015/01/01"|
+created_on ≥ "2015/01/01"|
+created_on > "2015/01/01"|
+updated_on < "2015/01/01"|
+updated_on ≤ "2015/01/01"|
+updated_on = "2015/01/01"|
+updated_on ≥ "2015/01/01"|
+updated_on > "2015/01/01"|
+created_at > "2015/01/01"|
+updated_at > "2015/01/01"|
+
+# Timestamps yyyy/mm/dd hh
+created_on < "2015/01/01 12"|
+created_on ≤ "2015/01/01 12"|
+created_on = "2015/01/01 12"|
+created_on ≥ "2015/01/01 12"|
+created_on > "2015/01/01 12"|
+updated_on < "2015/01/01 12"|
+updated_on ≤ "2015/01/01 12"|
+updated_on = "2015/01/01 12"|
+updated_on ≥ "2015/01/01 12"|
+updated_on > "2015/01/01 12"|
+created_at > "2015/01/01 12"|
+updated_at > "2015/01/01 12"|
+
+# Timestamps yyyy/mm/dd hh:mm
+created_on < "2015/01/01 12.34"|
+created_on ≤ "2015/01/01 12.34"|
+created_on = "2015/01/01 12.34"|
+created_on ≥ "2015/01/01 12.34"|
+created_on > "2015/01/01 12.34"|
+updated_on < "2015/01/01 12.34"|
+updated_on ≤ "2015/01/01 12.34"|
+updated_on = "2015/01/01 12.34"|
+updated_on ≥ "2015/01/01 12.34"|
+updated_on > "2015/01/01 12.34"|
+created_at > "2015/01/01 12.34"|
+updated_at > "2015/01/01 12.34"|
+
+# Timestamps yyyy/mm/dd hh.mm.ss
+created_on < "2015/01/01 12.34.56"|
+created_on ≤ "2015/01/01 12.34.56"|
+created_on = "2015/01/01 12.34.56"|( == )
+created_on ≥ "2015/01/01 12.34.56"|
+created_on > "2015/01/01 12.34.56"|
+updated_on < "2015/01/01 12.34.56"|
+updated_on ≤ "2015/01/01 12.34.56"|
+updated_on = "2015/01/01 12.34.56"|( == )
+updated_on ≥ "2015/01/01 12.34.56"|
+updated_on > "2015/01/01 12.34.56"|
+created_at > "2015/01/01 12.34.56"|
+updated_at > "2015/01/01 12.34.56"|
+
+# timestamps missing zeros
+created_on = "2015/1/2 1.3.5"|( == )
+
# Flag matcher
flag.foo:true|