diff --git a/application/Espo/Acl/User.php b/application/Espo/Acl/User.php index ff24bdc312..95d15b1d6a 100644 --- a/application/Espo/Acl/User.php +++ b/application/Espo/Acl/User.php @@ -30,6 +30,7 @@ namespace Espo\Acl; use \Espo\ORM\Entity; +use \Espo\Entities\User as EntityUser; class User extends \Espo\Core\Acl\Base { @@ -37,5 +38,20 @@ class User extends \Espo\Core\Acl\Base { return $user->id === $entity->id; } -} + public function checkEntityDelete(EntityUser $user, Entity $entity, $data) + { + if ($entity->id === 'system') { + return false; + } + return parent::checkEntityDelete($user, $entity, $data); + } + + public function checkEntityEdit(EntityUser $user, Entity $entity, $data) + { + if ($entity->id === 'system') { + return false; + } + return $this->checkEntity($user, $entity, $data, 'edit'); + } +} diff --git a/application/Espo/Core/Acl/Table.php b/application/Espo/Core/Acl/Table.php index d1c4e9ee00..04513c56e7 100644 --- a/application/Espo/Core/Acl/Table.php +++ b/application/Espo/Core/Acl/Table.php @@ -73,6 +73,8 @@ class Table protected $forbiddenFieldsCache = array(); + protected $isStrictModeForced = false; + protected $isStrictMode = false; public function __construct(User $user, Config $config = null, FileManager $fileManager = null, Metadata $metadata = null, FieldManagerUtil $fieldManager = null) @@ -83,7 +85,11 @@ class Table 'fieldTableQuickAccess' => (object) [], ]; - $this->isStrictMode = $config->get('aclStrictMode', false); + if ($this->isStrictModeForced) { + $this->isStrictMode = true; + } else { + $this->isStrictMode = $config->get('aclStrictMode', false); + } $this->user = $user; @@ -405,7 +411,12 @@ class Table return; } - $data = $this->metadata->get('app.'.$this->type.'.default.scopeLevel', array()); + $defaultsGroupName = 'default'; + if ($this->isStrictMode) { + $defaultsGroupName = 'strictDefault'; + } + + $data = $this->metadata->get(['app', $this->type, $defaultsGroupName, 'scopeLevel'], []); foreach ($data as $scope => $item) { if (isset($table->$scope)) continue; @@ -416,7 +427,7 @@ class Table $table->$scope = $value; } - $defaultFieldData = $this->metadata->get('app.'.$this->type.'.default.fieldLevel', array()); + $defaultFieldData = $this->metadata->get(['app', $this->type, $defaultsGroupName, 'fieldLevel'], []); foreach ($this->getScopeList() as $scope) { if (isset($table->$scope) && $table->$scope === false) continue; @@ -424,7 +435,7 @@ class Table $fieldList = array_keys($this->getMetadata()->get("entityDefs.{$scope}.fields", [])); - $defaultScopeFieldData = $this->metadata->get('app.'.$this->type.'.default.scopeFieldLevel.' . $scope, array()); + $defaultScopeFieldData = $this->metadata->get('app.'.$this->type.'.'.$defaultsGroupName.'.scopeFieldLevel.' . $scope, []); foreach (array_merge($defaultFieldData, $defaultScopeFieldData) as $field => $f) { if (!in_array($field, $fieldList)) continue; diff --git a/application/Espo/Core/AclPortal/Table.php b/application/Espo/Core/AclPortal/Table.php index 44735bda4a..c2835e5187 100644 --- a/application/Espo/Core/AclPortal/Table.php +++ b/application/Espo/Core/AclPortal/Table.php @@ -50,6 +50,8 @@ class Table extends \Espo\Core\Acl\Table protected $levelList = ['yes', 'all', 'account', 'contact', 'own', 'no']; + protected $isStrictModeForced = true; + public function __construct(User $user, Portal $portal, Config $config = null, FileManager $fileManager = null, Metadata $metadata = null, FieldManagerUtil $fieldManager = null) { if (empty($portal)) { diff --git a/application/Espo/Core/CronManager.php b/application/Espo/Core/CronManager.php index 8cfa9b3f08..50ad1e556e 100644 --- a/application/Espo/Core/CronManager.php +++ b/application/Espo/Core/CronManager.php @@ -116,9 +116,10 @@ class CronManager { $lastRunData = $this->getFileManager()->getPhpContents($this->lastRunTime); - $lastRunTime = time() - intval($this->getConfig()->get('cron.minExecutionTime')) - 1; if (is_array($lastRunData) && !empty($lastRunData['time'])) { $lastRunTime = $lastRunData['time']; + } else { + $lastRunTime = time() - intval($this->getConfig()->get('cronMinInterval', 0)) - 1; } return $lastRunTime; @@ -136,9 +137,9 @@ class CronManager { $currentTime = time(); $lastRunTime = $this->getLastRunTime(); - $minTime = $this->getConfig()->get('cron.minExecutionTime'); + $cronMinInterval = $this->getConfig()->get('cronMinInterval', 0); - if ($currentTime > ($lastRunTime + $minTime) ) { + if ($currentTime > ($lastRunTime + $cronMinInterval)) { return true; } @@ -167,8 +168,27 @@ class CronManager $pendingJobList = $this->getCronJobUtil()->getPendingJobList(); foreach ($pendingJobList as $job) { + $skip = false; + $this->getEntityManager()->getPdo()->query('LOCK TABLES `job` WRITE'); + if ($this->getCronJobUtil()->isJobPending($job->id)) { + if ($job->get('scheduledJobId')) { + if ($this->getCronJobUtil()->isScheduledJobRunning($job->get('scheduledJobId'), $job->get('targetId'), $job->get('targetType'))) { + $skip = true; + } + } + } else { + $skip = true; + } + + if ($skip) { + $this->getEntityManager()->getPdo()->query('UNLOCK TABLES'); + continue; + } + $job->set('status', self::RUNNING); + $job->set('pid', $this->getCronJobUtil()->getPid()); $this->getEntityManager()->saveEntity($job); + $this->getEntityManager()->getPdo()->query('UNLOCK TABLES'); $isSuccess = true; $skipLog = false; diff --git a/application/Espo/Core/DataManager.php b/application/Espo/Core/DataManager.php index 24a7e10e64..ac5e928120 100644 --- a/application/Espo/Core/DataManager.php +++ b/application/Espo/Core/DataManager.php @@ -143,13 +143,17 @@ class DataManager if ($job) { $entityManager->removeEntity($job); } + $name = $jobName; + if (!empty($defs['name'])) { + $name = $defs['name']; + } $job = $entityManager->getEntity('ScheduledJob'); $job->set(array( 'job' => $jobName, 'status' => 'Active', 'scheduling' => $defs['scheduling'], 'isInternal' => true, - 'name' => $jobName + 'name' => $name )); $entityManager->saveEntity($job); } diff --git a/application/Espo/Core/Export/Csv.php b/application/Espo/Core/Export/Csv.php index 4230a6774d..1d0c14b249 100644 --- a/application/Espo/Core/Export/Csv.php +++ b/application/Espo/Core/Export/Csv.php @@ -31,19 +31,35 @@ namespace Espo\Core\Export; use \Espo\Core\Exceptions\Error; +use \Espo\Core\ORM\Entity; + class Csv extends \Espo\Core\Injectable { protected $dependencyList = [ 'config', - 'preferences' + 'preferences', + 'metadata' ]; + public function loadAdditionalFields(Entity $entity, $fieldList) + { + foreach ($fieldList as $field) { + if ($this->getInjection('metadata')->get(['entityDefs', $entity->getEntityType(), 'fields', $field, 'type']) === 'linkMultiple') { + if (!$entity->has($field . 'Ids')) { + $entity->loadLinkMultipleField($field); + } + } + } + } + public function process($entityType, $params, $dataList) { if (!is_array($params['attributeList'])) { throw new Error(); } + $dataList = $this->prepareDataList($dataList); + $attributeList = $params['attributeList']; $delimiter = $this->getInjection('preferences')->get('exportDelimiter'); @@ -62,4 +78,21 @@ class Csv extends \Espo\Core\Injectable return $csv; } + + protected function prepareDataList($dataList) + { + $prepareDataList = []; + foreach ($dataList as $row) { + $preparedRow = []; + foreach ($row as $item) { + if (is_array($item) || is_object($item)) { + $item = \Espo\Core\Utils\Json::encode($item); + } + $preparedRow[] = $item; + } + $prepareDataList[] = $preparedRow; + } + + return $prepareDataList; + } } \ No newline at end of file diff --git a/application/Espo/Core/Export/Xlsx.php b/application/Espo/Core/Export/Xlsx.php index 6adef723b4..26be562e0c 100644 --- a/application/Espo/Core/Export/Xlsx.php +++ b/application/Espo/Core/Export/Xlsx.php @@ -78,6 +78,13 @@ class Xlsx extends \Espo\Core\Injectable } } } + foreach ($fieldList as $field) { + if ($this->getMetadata()->get(['entityDefs', $entity->getEntityType(), 'fields', $field, 'type']) === 'linkMultiple') { + if (!$entity->has($field . 'Ids')) { + $entity->loadLinkMultipleField($field); + } + } + } } public function addAdditionalAttributes($entityType, &$attributeList, $fieldList) @@ -367,6 +374,52 @@ class Xlsx extends \Espo\Core\Injectable } $sheet->setCellValue("$col$rowNumber", $value); } + } else if ($type == 'linkMultiple') { + if (array_key_exists($name . 'Ids', $row) && array_key_exists($name . 'Names', $row)) { + $nameList = []; + foreach ($row[$name . 'Ids'] as $relatedId) { + $relatedName = $relatedId; + if (property_exists($row[$name . 'Names'], $relatedId)) { + $relatedName = $row[$name . 'Names']->$relatedId; + } + $nameList[] = $relatedName; + } + $sheet->setCellValue("$col$rowNumber", implode(', ', $nameList)); + } + } else if ($type == 'address') { + $value = ''; + if (!empty($row[$name . 'Street'])) { + $value = $value .= $row[$name.'Street']; + } + if (!empty($row[$name.'City']) || !empty($row[$name.'State']) || !empty($row[$name.'PostalCode'])) { + if ($value) { + $value .= "\n"; + } + if (!empty($row[$name.'City'])) { + $value .= $row[$name.'City']; + if ( + !empty($row[$name.'State']) || !empty($row[$name.'PostalCode']) + ) { + $value .= ', '; + } + } + if (!empty($row[$name.'State'])) { + $value .= $row[$name.'State']; + if (!empty($row[$name.'PostalCode'])) { + $value .= ' '; + } + } + if (!empty($row[$name.'PostalCode'])) { + $value .= $row[$name.'PostalCode']; + } + } + if (!empty($row[$name.'Country'])) { + if ($value) { + $value .= "\n"; + } + $value .= $row[$name.'Country']; + } + $sheet->setCellValue("$col$rowNumber", $value); } else { if (array_key_exists($name, $row)) { $sheet->setCellValue("$col$rowNumber", $row[$name]); diff --git a/application/Espo/Core/Mail/Importer.php b/application/Espo/Core/Mail/Importer.php index 3eb9f35738..59732056d9 100644 --- a/application/Espo/Core/Mail/Importer.php +++ b/application/Espo/Core/Mail/Importer.php @@ -154,31 +154,8 @@ class Importer } if ($duplicate = $this->findDuplicate($email)) { - if ($assignedUserId) { - $duplicate->addLinkMultipleId('users', $assignedUserId); - $duplicate->addLinkMultipleId('assignedUsers', $assignedUserId); - } - if (!empty($userIdList)) { - foreach ($userIdList as $uId) { - $duplicate->addLinkMultipleId('users', $uId); - } - } - - if ($folderData) { - foreach ($folderData as $uId => $folderId) { - $email->setLinkMultipleColumn('users', 'folderId', $uId, $folderId); - } - } - - $duplicate->set('isBeingImported', true); - - $this->getEntityManager()->saveEntity($duplicate); - - if (!empty($teamsIdList)) { - foreach ($teamsIdList as $teamId) { - $this->getEntityManager()->getRepository('Email')->relate($duplicate, 'teams', $teamId); - } - } + $duplicate = $this->getEntityManager()->getEntity('Email', $duplicate->id); + $this->processDuplicate($duplicate, $assignedUserId, $userIdList, $folderData, $teamsIdList); return $duplicate; } @@ -299,6 +276,26 @@ class Importer } } + $this->getEntityManager()->getPdo()->query('LOCK TABLES `email` WRITE'); + + if ($duplicate = $this->findDuplicate($email)) { + $this->getEntityManager()->getPdo()->query('UNLOCK TABLES'); + $duplicate = $this->getEntityManager()->getEntity('Email', $duplicate->id); + $this->processDuplicate($duplicate, $assignedUserId, $userIdList, $folderData, $teamsIdList); + return $duplicate; + } + + if (!$email->get('messageId')) { + $email->setDummyMessageId(); + } + + $this->getEntityManager()->saveEntity($email, [ + 'skipAll' => true, + 'keepNew' => true + ]); + + $this->getEntityManager()->getPdo()->query('UNLOCK TABLES'); + $this->getEntityManager()->saveEntity($email); foreach ($inlineAttachmentList as $attachment) { @@ -352,7 +349,7 @@ class Importer protected function findDuplicate(Entity $email) { if ($email->get('messageId')) { - $duplicate = $this->getEntityManager()->getRepository('Email')->where(array( + $duplicate = $this->getEntityManager()->getRepository('Email')->select(['id'])->where(array( 'messageId' => $email->get('messageId') ))->findOne(); if ($duplicate) { @@ -360,4 +357,33 @@ class Importer } } } + + protected function processDuplicate(Entity $duplicate, $assignedUserId, $userIdList, $folderData, $teamsIdList) + { + if ($assignedUserId) { + $duplicate->addLinkMultipleId('users', $assignedUserId); + $duplicate->addLinkMultipleId('assignedUsers', $assignedUserId); + } + if (!empty($userIdList)) { + foreach ($userIdList as $uId) { + $duplicate->addLinkMultipleId('users', $uId); + } + } + + if ($folderData) { + foreach ($folderData as $uId => $folderId) { + $duplicate->setLinkMultipleColumn('users', 'folderId', $uId, $folderId); + } + } + + $duplicate->set('isBeingImported', true); + + $this->getEntityManager()->saveEntity($duplicate); + + if (!empty($teamsIdList)) { + foreach ($teamsIdList as $teamId) { + $this->getEntityManager()->getRepository('Email')->relate($duplicate, 'teams', $teamId); + } + } + } } diff --git a/application/Espo/Core/Mail/Parsers/PhpMimeMailParser/Parser.php b/application/Espo/Core/Mail/Parsers/PhpMimeMailParser/Parser.php index 256604cd06..23aa6e3ea0 100644 --- a/application/Espo/Core/Mail/Parsers/PhpMimeMailParser/Parser.php +++ b/application/Espo/Core/Mail/Parsers/PhpMimeMailParser/Parser.php @@ -33,7 +33,7 @@ use \PhpMimeMailParser\Attachment; class Parser extends \PhpMimeMailParser\Parser { - public function getAttachments() + public function getAttachments($include_inline = true) { $attachments = []; $dispositions = ['attachment', 'inline']; diff --git a/application/Espo/Core/Mail/Sender.php b/application/Espo/Core/Mail/Sender.php index 92b9aefef9..ed7b121896 100644 --- a/application/Espo/Core/Mail/Sender.php +++ b/application/Espo/Core/Mail/Sender.php @@ -349,7 +349,7 @@ class Sender try { $messageId = $email->get('messageId'); - if (empty($messageId) || !is_string($messageId) || strlen($messageId) < 4) { + if (empty($messageId) || !is_string($messageId) || strlen($messageId) < 4 || strpos($messageId, 'dummy:') === 0) { $messageId = $this->generateMessageId($email); $email->set('messageId', '<' . $messageId . '>'); } else { diff --git a/application/Espo/Core/ORM/Entity.php b/application/Espo/Core/ORM/Entity.php index 099b5e8497..f9b289912f 100644 --- a/application/Espo/Core/ORM/Entity.php +++ b/application/Espo/Core/ORM/Entity.php @@ -41,6 +41,24 @@ class Entity extends \Espo\ORM\Entity return $this->hasAttribute($field . 'Id'); } + public function loadParentNameField($field) + { + if (!$this->hasAttribute($field. 'Id') || !$this->hasAttribute($field . 'Type')) return; + + $parentId = $this->get($field . 'Id'); + $parentType = $this->get($field . 'Type'); + + if ($parentId && $parentType) { + if (!$this->entityManager->hasRepository($parentType)) return; + $repository = $this->entityManager->getRepository($parentType); + + $foreignEntity = $repository->select(['id', 'name'])->where(['id' => $parentId])->findOne(); + if ($foreignEntity) { + $this->set($field . 'Name', $foreignEntity->get('name')); + } + } + } + public function loadLinkMultipleField($field, $columns = null) { if (!$this->hasRelation($field) || !$this->hasAttribute($field . 'Ids')) return; @@ -69,12 +87,20 @@ class Entity extends \Espo\ORM\Entity } } + $defs['select'] = ['id', 'name']; + + $hasType = false; + if ($this->hasField($field . 'Types')) { + $hasType = true; + $defs['select'][] = 'type'; + } + $collection = $this->get($field, $defs); - $ids = array(); - $names = new \stdClass(); - $types = new \stdClass(); + $ids = []; + $names = (object) []; + $types = (object) []; if (!empty($columns)) { - $columnsData = new \stdClass(); + $columnsData = (object) []; } if ($collection) { @@ -82,7 +108,9 @@ class Entity extends \Espo\ORM\Entity $id = $e->id; $ids[] = $id; $names->$id = $e->get('name'); - $types->$id = $e->get('type'); + if ($hasType) { + $types->$id = $e->get('type'); + } if (!empty($columns)) { $columnsData->$id = new \stdClass(); foreach ($columns as $column => $f) { @@ -94,7 +122,9 @@ class Entity extends \Espo\ORM\Entity $this->set($field . 'Ids', $ids); $this->set($field . 'Names', $names); - $this->set($field . 'Types', $types); + if ($hasType) { + $this->set($field . 'Types', $types); + } if (!empty($columns)) { $this->set($field . 'Columns', $columnsData); } diff --git a/application/Espo/Core/ORM/Repositories/RDB.php b/application/Espo/Core/ORM/Repositories/RDB.php index 9059671737..5f396cbaab 100644 --- a/application/Espo/Core/ORM/Repositories/RDB.php +++ b/application/Espo/Core/ORM/Repositories/RDB.php @@ -176,7 +176,7 @@ class RDB extends \Espo\ORM\Repositories\RDB implements Injectable protected function beforeRemove(Entity $entity, array $options = array()) { parent::beforeRemove($entity, $options); - if (!$this->hooksDisabled) { + if (!$this->hooksDisabled && empty($options['skipHooks'])) { $this->getEntityManager()->getHookManager()->process($this->entityType, 'beforeRemove', $entity, $options); } @@ -192,14 +192,14 @@ class RDB extends \Espo\ORM\Repositories\RDB implements Injectable protected function afterRemove(Entity $entity, array $options = array()) { parent::afterRemove($entity, $options); - if (!$this->hooksDisabled) { + if (!$this->hooksDisabled && empty($options['skipHooks'])) { $this->getEntityManager()->getHookManager()->process($this->entityType, 'afterRemove', $entity, $options); } } protected function afterMassRelate(Entity $entity, $relationName, array $params = array(), array $options = array()) { - if (!$this->hooksDisabled) { + if (!$this->hooksDisabled && empty($options['skipHooks'])) { $hookData = array( 'relationName' => $relationName, 'relationParams' => $params @@ -220,7 +220,7 @@ class RDB extends \Espo\ORM\Repositories\RDB implements Injectable if ($foreign instanceof Entity) { $foreignEntity = $foreign; - if (!$this->hooksDisabled) { + if (!$this->hooksDisabled && empty($options['skipHooks'])) { $hookData = array( 'relationName' => $relationName, 'relationData' => $data, @@ -237,7 +237,7 @@ class RDB extends \Espo\ORM\Repositories\RDB implements Injectable if ($foreign instanceof Entity) { $foreignEntity = $foreign; - if (!$this->hooksDisabled) { + if (!$this->hooksDisabled && empty($options['skipHooks'])) { $hookData = array( 'relationName' => $relationName, 'foreignEntity' => $foreignEntity @@ -251,7 +251,7 @@ class RDB extends \Espo\ORM\Repositories\RDB implements Injectable { parent::beforeSave($entity, $options); - if (!$this->hooksDisabled) { + if (!$this->hooksDisabled && empty($options['skipHooks'])) { $this->getEntityManager()->getHookManager()->process($this->entityType, 'beforeSave', $entity, $options); } } @@ -271,7 +271,7 @@ class RDB extends \Espo\ORM\Repositories\RDB implements Injectable $this->processFileFieldsSave($entity); } - if (!$this->hooksDisabled) { + if (!$this->hooksDisabled && empty($options['skipHooks'])) { $this->getEntityManager()->getHookManager()->process($this->entityType, 'afterSave', $entity, $options); } } @@ -285,35 +285,40 @@ class RDB extends \Espo\ORM\Repositories\RDB implements Injectable if (!$entity->has('id')) { $entity->set('id', Util::generateId()); } + } - if ($entity->hasAttribute('createdAt')) { - if (empty($options['import']) || !$entity->has('createdAt')) { - $entity->set('createdAt', $nowString); - } - } - if ($entity->hasAttribute('modifiedAt')) { - $entity->set('modifiedAt', $nowString); - } - if ($entity->hasAttribute('createdById')) { - if (empty($options['skipCreatedBy']) && (empty($options['import']) || !$entity->has('createdById'))) { - if ($this->getEntityManager()->getUser()) { - $entity->set('createdById', $this->getEntityManager()->getUser()->id); + if (empty($options['skipAll'])) { + if ($entity->isNew()) { + if ($entity->hasAttribute('createdAt')) { + if (empty($options['import']) || !$entity->has('createdAt')) { + $entity->set('createdAt', $nowString); } } - } - } else { - if (empty($options['silent']) && empty($options['skipModifiedBy'])) { if ($entity->hasAttribute('modifiedAt')) { $entity->set('modifiedAt', $nowString); } - if ($entity->hasAttribute('modifiedById')) { - if ($this->getEntityManager()->getUser()) { - $entity->set('modifiedById', $this->getEntityManager()->getUser()->id); - $entity->set('modifiedByName', $this->getEntityManager()->getUser()->get('name')); + if ($entity->hasAttribute('createdById')) { + if (empty($options['skipCreatedBy']) && (empty($options['import']) || !$entity->has('createdById'))) { + if ($this->getEntityManager()->getUser()) { + $entity->set('createdById', $this->getEntityManager()->getUser()->id); + } + } + } + } else { + if (empty($options['silent']) && empty($options['skipModifiedBy'])) { + if ($entity->hasAttribute('modifiedAt')) { + $entity->set('modifiedAt', $nowString); + } + if ($entity->hasAttribute('modifiedById')) { + if ($this->getEntityManager()->getUser()) { + $entity->set('modifiedById', $this->getEntityManager()->getUser()->id); + $entity->set('modifiedByName', $this->getEntityManager()->getUser()->get('name')); + } } } } } + $this->restoreData = $restoreData; $result = parent::save($entity, $options); diff --git a/application/Espo/Core/Templates/i18n/lt_LT/BasePlus.json b/application/Espo/Core/Templates/i18n/lt_LT/BasePlus.json index cce10944a6..7dbb435806 100644 --- a/application/Espo/Core/Templates/i18n/lt_LT/BasePlus.json +++ b/application/Espo/Core/Templates/i18n/lt_LT/BasePlus.json @@ -1,6 +1,6 @@ { "links": { - "meetings": "Susirinkimai", + "meetings": "Susitikimai", "calls": "Skambučiai", "tasks": "Užduotys" }, diff --git a/application/Espo/Core/Templates/i18n/lt_LT/Company.json b/application/Espo/Core/Templates/i18n/lt_LT/Company.json index 123ff22618..1a7cd5896f 100644 --- a/application/Espo/Core/Templates/i18n/lt_LT/Company.json +++ b/application/Espo/Core/Templates/i18n/lt_LT/Company.json @@ -2,10 +2,10 @@ "fields": { "billingAddress": "Mokėtojo adresas", "shippingAddress": "Siuntimo adresas", - "website": "Interntinė Svetainė" + "website": "Internetinė Svetainė" }, "links": { - "meetings": "Susirinkimai", + "meetings": "Susitikimai", "calls": "Skambučiai", "tasks": "Užduotys" }, diff --git a/application/Espo/Core/Templates/i18n/lt_LT/Person.json b/application/Espo/Core/Templates/i18n/lt_LT/Person.json index 3754d136cd..cafb590f15 100644 --- a/application/Espo/Core/Templates/i18n/lt_LT/Person.json +++ b/application/Espo/Core/Templates/i18n/lt_LT/Person.json @@ -3,7 +3,7 @@ "address": "Adresas" }, "links": { - "meetings": "Susirinkimai", + "meetings": "Susitikimai", "calls": "Skambučiai", "tasks": "Užduotys" }, diff --git a/application/Espo/Core/Utils/AdminNotificationManager.php b/application/Espo/Core/Utils/AdminNotificationManager.php index 797f8f5ce2..425e92cb27 100644 --- a/application/Espo/Core/Utils/AdminNotificationManager.php +++ b/application/Espo/Core/Utils/AdminNotificationManager.php @@ -186,11 +186,10 @@ class AdminNotificationManager $notification->set(array( 'type' => 'message', 'data' => array( - 'userId' => $this->getUser()->id, - 'userName' => $this->getUser()->get('name') + 'userId' => $userId, ), - 'userId' => $user->id, - 'message' => $actionData['messageTemplate'] + 'userId' => $userId, + 'message' => $message )); $this->getEntityManager()->saveEntity($notification); } diff --git a/application/Espo/Core/Utils/Cron/Job.php b/application/Espo/Core/Utils/Cron/Job.php index 5b96d32642..9d2be42134 100644 --- a/application/Espo/Core/Utils/Cron/Job.php +++ b/application/Espo/Core/Utils/Cron/Job.php @@ -28,10 +28,12 @@ ************************************************************************/ namespace Espo\Core\Utils\Cron; -use \PDO; -use \Espo\Core\CronManager; -use \Espo\Core\Utils\Config; -use \Espo\Core\ORM\EntityManager; + +use PDO; +use Espo\Core\CronManager; +use Espo\Core\Utils\Config; +use Espo\Core\ORM\EntityManager; +use Espo\Core\Utils\System; class Job { @@ -64,15 +66,17 @@ class Job return $this->cronScheduledJob; } - /** - * Get Pending Jobs - * - * @return array - */ + public function isJobPending($id) + { + return !!$this->getEntityManager()->getRepository('Job')->select(['id'])->where([ + 'id' => $id, + 'status' => CronManager::PENDING + ])->findOne(); + } + public function getPendingJobList() { - $jobConfigs = $this->getConfig()->get('cron'); - $limit = !empty($jobConfigs['maxJobNumber']) ? intval($jobConfigs['maxJobNumber']) : 0; + $limit = intval($this->getConfig()->get('jobMaxPortion', 0)); $selectParams = [ 'select' => [ @@ -88,7 +92,7 @@ class Job 'data' ], 'whereClause' => [ - 'status' => 'Pending', + 'status' => CronManager::PENDING, 'executeTime<=' => date('Y-m-d H:i:s') ], 'orderBy' => 'executeTime' @@ -97,17 +101,21 @@ class Job $selectParams['offset'] = 0; $selectParams['limit'] = $limit; } - $jobList = $this->getEntityManager()->getRepository('Job')->find($selectParams); - $runningScheduledJobIdList = $this->getRunningScheduledJobIdList(); + return $this->getEntityManager()->getRepository('Job')->find($selectParams); + } - $actualJobList = []; - foreach ($jobList as $job) { - if ($job->get('scheduledJobId') && in_array($job->get('scheduledJobId'), $runningScheduledJobIdList)) continue; - $actualJobList[] = $job; + public function isScheduledJobRunning($scheduledJobId, $targetId = null, $targetType = null) + { + $where = [ + 'scheduledJobId' => $scheduledJobId, + 'status' => CronManager::RUNNING + ]; + if ($targetId && $targetType) { + $where['targetId'] = $targetId; + $where['targetType'] = $targetType; } - - return $actualJobList; + return !!$this->getEntityManager()->getRepository('Job')->select(['id'])->where($where)->findOne(); } public function getRunningScheduledJobIdList() @@ -175,39 +183,62 @@ class Job */ public function markFailedJobs() { - $jobConfigs = $this->getConfig()->get('cron'); + $this->markFailedJobsByPeriod('jobPeriodForActiveProcess'); + $this->markFailedJobsByPeriod('jobPeriod'); + } - $currentTime = time(); - $periodTime = $currentTime - intval($jobConfigs['jobPeriod']); + protected function markFailedJobsByPeriod($period) + { + $time = time() - $this->getConfig()->get($period); $pdo = $this->getEntityManager()->getPDO(); $select = " - SELECT id, scheduled_job_id, execute_time, target_id, target_type FROM `job` + SELECT id, scheduled_job_id, execute_time, target_id, target_type, pid FROM `job` WHERE - `status` = '" . CronManager::RUNNING ."' AND execute_time < '".date('Y-m-d H:i:s', $periodTime)."' + `status` = '" . CronManager::RUNNING ."' AND execute_time < '".date('Y-m-d H:i:s', $time)."' "; $sth = $pdo->prepare($select); $sth->execute(); $jobData = array(); - while ($row = $sth->fetch(PDO::FETCH_ASSOC)){ - $jobData[$row['id']] = $row; + + switch ($period) { + case 'jobPeriod': + while ($row = $sth->fetch(PDO::FETCH_ASSOC)) { + if (empty($row['pid']) || !System::isProcessActive($row['pid'])) { + $jobData[$row['id']] = $row; + } + } + break; + + case 'jobPeriodForActiveProcess': + while ($row = $sth->fetch(PDO::FETCH_ASSOC)) { + $jobData[$row['id']] = $row; + } + break; } - $update = " - UPDATE job - SET `status` = '". CronManager::FAILED ."' - WHERE id IN ('".implode("', '", array_keys($jobData))."') - "; - $sth = $pdo->prepare($update); - $sth->execute(); + if (!empty($jobData)) { + $jobQuotedIdList = []; + foreach ($jobData as $jobId => $job) { + $jobQuotedIdList[] = $pdo->quote($jobId); + } - //add status 'Failed' to SchediledJobLog - $cronScheduledJob = $this->getCronScheduledJob(); - foreach ($jobData as $jobId => $job) { - if (!empty($job['scheduled_job_id'])) { - $cronScheduledJob->addLogRecord($job['scheduled_job_id'], CronManager::FAILED, $job['execute_time'], $job['target_id'], $job['target_type']); + $update = " + UPDATE job + SET `status` = '" . CronManager::FAILED . "', attempts = 0 + WHERE id IN (".implode(", ", $jobQuotedIdList).") + "; + + $sth = $pdo->prepare($update); + $sth->execute(); + + $cronScheduledJob = $this->getCronScheduledJob(); + foreach ($jobData as $jobId => $job) { + if (!empty($job['scheduled_job_id'])) { + $cronScheduledJob->addLogRecord($job['scheduled_job_id'], CronManager::FAILED, $job['execute_time'], $job['target_id'], $job['target_type']); + } } } } @@ -246,20 +277,29 @@ class Job $query = " SELECT id FROM `job` WHERE - scheduled_job_id = '" . $row['scheduled_job_id'] . "' AND - `status` = '" . CronManager::PENDING ."' + scheduled_job_id = ".$pdo->quote($row['scheduled_job_id'])." + AND `status` = '" . CronManager::PENDING ."' ORDER BY execute_time DESC LIMIT 1, 100000 "; $sth = $pdo->prepare($query); $sth->execute(); - $jobIds = $sth->fetchAll(PDO::FETCH_COLUMN); + $jobIdList = $sth->fetchAll(PDO::FETCH_COLUMN); + + if (empty($jobIdList)) { + continue; + } + + $quotedJobIdList = []; + foreach ($jobIdList as $jobId) { + $quotedJobIdList[] = $pdo->quote($jobId); + } $update = " UPDATE job SET deleted = 1 WHERE - id IN ('". implode("', '", $jobIds)."') + id IN (".implode(", ", $quotedJobIdList).") "; $sth = $pdo->prepare($update); @@ -300,13 +340,18 @@ class Job UPDATE job SET `status` = '" . CronManager::PENDING ."', - attempts = '".$attempts."', - failed_attempts = '".$failedAttempts."' + attempts = ".$pdo->quote($attempts).", + failed_attempts = ".$pdo->quote($failedAttempts)." WHERE - id = '".$row['id']."' + id = ".$pdo->quote($row['id'])." "; $pdo->prepare($update)->execute(); } } } + + public function getPid() + { + return System::getPid(); + } } \ No newline at end of file diff --git a/application/Espo/Core/Utils/Cron/ScheduledJob.php b/application/Espo/Core/Utils/Cron/ScheduledJob.php index 58b993805c..4ac4214619 100644 --- a/application/Espo/Core/Utils/Cron/ScheduledJob.php +++ b/application/Espo/Core/Utils/Cron/ScheduledJob.php @@ -91,7 +91,7 @@ class ScheduledJob } $scheduledJob->set('lastRun', $runTime); - $entityManager->saveEntity($scheduledJob); + $entityManager->saveEntity($scheduledJob, ['silent' => true]); $scheduledJobLog = $entityManager->getEntity('ScheduledJobLogRecord'); $scheduledJobLog->set(array( diff --git a/application/Espo/Core/Utils/System.php b/application/Espo/Core/Utils/System.php index 975b1c0110..e2abd5a383 100644 --- a/application/Espo/Core/Utils/System.php +++ b/application/Espo/Core/Utils/System.php @@ -134,4 +134,40 @@ class System return $version; } + + /** + * Pet process ID + * + * @return integer + */ + public static function getPid() + { + if (function_exists('getmypid')) { + return getmypid(); + } + } + + /** + * Check if process is active + * + * @param integer $pid + * + * @return boolean + */ + public static function isProcessActive($pid) + { + if (empty($pid)) { + return false; + } + + if (!function_exists('posix_getsid')) { + return false; + } + + if (posix_getsid($pid) === false) { + return false; + } + + return true; + } } \ No newline at end of file diff --git a/application/Espo/Core/defaults/config.php b/application/Espo/Core/defaults/config.php index c5ee3ae6cd..b7b8c54b68 100644 --- a/application/Espo/Core/defaults/config.php +++ b/application/Espo/Core/defaults/config.php @@ -167,8 +167,8 @@ return array ( 'cleanupJobPeriod' => '1 month', 'cleanupActionHistoryPeriod' => '15 days', 'cleanupAuthTokenPeriod' => '1 month', - 'currencyFormat' => 1, - 'currencyDecimalPlaces' => null, + 'currencyFormat' => 2, + 'currencyDecimalPlaces' => 2, 'aclStrictMode' => false, 'aclAllowDeleteCreated' => false, 'inlineAttachmentUploadMaxSize' => 20, diff --git a/application/Espo/Core/defaults/systemConfig.php b/application/Espo/Core/defaults/systemConfig.php index 81be93ce48..514f1c4501 100644 --- a/application/Espo/Core/defaults/systemConfig.php +++ b/application/Espo/Core/defaults/systemConfig.php @@ -56,16 +56,11 @@ return array ( 'defaultPermissions' => 'reset.html', ), ), - 'cron' => array( - /** Max number of jobs per one execution. */ - 'maxJobNumber' => 15, - /** Max execution time (in seconds) allocated for a sinle job. If exceeded then set to Failed.*/ - 'jobPeriod' => 7800, - /** Min time (in seconds) between two cron runs. */ - 'minExecutionTime' => 50, - /** Attempts to re-run failed jobs. */ - 'attempts' => 2 - ), + 'jobMaxPortion' => 15, /** Max number of jobs per one execution. */ + 'jobPeriod' => 7800, /** Max execution time (in seconds) allocated for a sinle job. If exceeded then set to Failed.*/ + 'jobPeriodForActiveProcess' => 36000, /** Max execution time (in seconds) allocated for a sinle job with active process. If exceeded then set to Failed.*/ + 'jobRerunAttemptNumber' => 1, /** Number of attempts to re-run failed jobs. */ + 'cronMinInterval' => 4, /** Min interval (in seconds) between two cron runs. */ 'crud' => array( 'get' => 'read', 'post' => 'create', @@ -117,7 +112,10 @@ return array ( 'defaultPermissions' => 'smtpSecurity', 'smtpUsername', 'smtpPassword', - 'cron', + 'jobMaxPortion', + 'jobPeriod', + 'jobRerunAttemptNumber', + 'cronMinInterval', 'authenticationMethod', 'adminPanelIframeUrl', 'ldapHost', diff --git a/application/Espo/Entities/Email.php b/application/Espo/Entities/Email.php index 98b8a9bf80..920950bb8e 100644 --- a/application/Espo/Entities/Email.php +++ b/application/Espo/Entities/Email.php @@ -174,5 +174,9 @@ class Email extends \Espo\Core\ORM\Entity } return []; } -} + public function setDummyMessageId() + { + $this->set('messageId', 'dummy:' . \Espo\Core\Utils\Util::generateId()); + } +} diff --git a/application/Espo/Jobs/NewVersionChecker.php b/application/Espo/Jobs/CheckNewVersion.php similarity index 90% rename from application/Espo/Jobs/NewVersionChecker.php rename to application/Espo/Jobs/CheckNewVersion.php index 663ef4f95e..c67779e6f9 100644 --- a/application/Espo/Jobs/NewVersionChecker.php +++ b/application/Espo/Jobs/CheckNewVersion.php @@ -31,7 +31,7 @@ namespace Espo\Jobs; use Espo\Core\Exceptions; -class NewVersionChecker extends \Espo\Core\Jobs\Base +class CheckNewVersion extends \Espo\Core\Jobs\Base { public function run() { @@ -41,11 +41,10 @@ class NewVersionChecker extends \Espo\Core\Jobs\Base $job = $this->getEntityManager()->getEntity('Job'); $job->set(array( - 'name' => 'NewVersionChecker', + 'name' => 'Check for New Version (job)', 'serviceName' => 'AdminNotifications', - 'method' => 'newVersionChecker', - 'methodName' => 'newVersionChecker', - 'executeTime' => $this->getRunTime(), + 'methodName' => 'jobCheckNewVersion', + 'executeTime' => $this->getRunTime() )); $this->getEntityManager()->saveEntity($job); diff --git a/application/Espo/Modules/Crm/Controllers/Opportunity.php b/application/Espo/Modules/Crm/Controllers/Opportunity.php index 42a554f70a..64bd7d48d0 100644 --- a/application/Espo/Modules/Crm/Controllers/Opportunity.php +++ b/application/Espo/Modules/Crm/Controllers/Opportunity.php @@ -43,8 +43,9 @@ class Opportunity extends \Espo\Core\Controllers\Record $dateFrom = $request->get('dateFrom'); $dateTo = $request->get('dateTo'); + $dateFilter = $request->get('dateFilter'); - return $this->getService('Opportunity')->reportByLeadSource($dateFrom, $dateTo); + return $this->getService('Opportunity')->reportByLeadSource($dateFilter, $dateFrom, $dateTo); } public function actionReportByStage($params, $data, $request) @@ -56,8 +57,9 @@ class Opportunity extends \Espo\Core\Controllers\Record $dateFrom = $request->get('dateFrom'); $dateTo = $request->get('dateTo'); + $dateFilter = $request->get('dateFilter'); - return $this->getService('Opportunity')->reportByStage($dateFrom, $dateTo); + return $this->getService('Opportunity')->reportByStage($dateFilter, $dateFrom, $dateTo); } public function actionReportSalesByMonth($params, $data, $request) @@ -69,8 +71,9 @@ class Opportunity extends \Espo\Core\Controllers\Record $dateFrom = $request->get('dateFrom'); $dateTo = $request->get('dateTo'); + $dateFilter = $request->get('dateFilter'); - return $this->getService('Opportunity')->reportSalesByMonth($dateFrom, $dateTo); + return $this->getService('Opportunity')->reportSalesByMonth($dateFilter, $dateFrom, $dateTo); } public function actionReportSalesPipeline($params, $data, $request) @@ -82,8 +85,9 @@ class Opportunity extends \Espo\Core\Controllers\Record $dateFrom = $request->get('dateFrom'); $dateTo = $request->get('dateTo'); + $dateFilter = $request->get('dateFilter'); - return $this->getService('Opportunity')->reportSalesPipeline($dateFrom, $dateTo); + return $this->getService('Opportunity')->reportSalesPipeline($dateFilter, $dateFrom, $dateTo); } } diff --git a/application/Espo/Modules/Crm/Resources/i18n/lt_LT/Account.json b/application/Espo/Modules/Crm/Resources/i18n/lt_LT/Account.json index be6ab7f65f..e5db561d6d 100644 --- a/application/Espo/Modules/Crm/Resources/i18n/lt_LT/Account.json +++ b/application/Espo/Modules/Crm/Resources/i18n/lt_LT/Account.json @@ -2,7 +2,7 @@ "fields": { "name": "Vardas", "emailAddress": "El. paštas", - "website": "Interntinė Svetainė", + "website": "Internetinė Svetainė", "phoneNumber": "Telefonas", "billingAddress": "Mokėtojo adresas", "shippingAddress": "Siuntimo adresas", diff --git a/application/Espo/Modules/Crm/Resources/i18n/lt_LT/Campaign.json b/application/Espo/Modules/Crm/Resources/i18n/lt_LT/Campaign.json index c14b11b5b1..eb492dfd1e 100644 --- a/application/Espo/Modules/Crm/Resources/i18n/lt_LT/Campaign.json +++ b/application/Espo/Modules/Crm/Resources/i18n/lt_LT/Campaign.json @@ -12,6 +12,7 @@ "openedCount": "Atverta", "clickedCount": "Paspausta", "optedOutCount": "Pasirinkta", + "bouncedCount": "Atmesta", "leadCreatedCount": "Potencialūs klientai sukurti", "revenue": "Pajamos", "revenueConverted": "Pajamos (konvertuotos)", diff --git a/application/Espo/Modules/Crm/Resources/i18n/lt_LT/Case.json b/application/Espo/Modules/Crm/Resources/i18n/lt_LT/Case.json index b8bcf7f1d6..a5a46645f9 100644 --- a/application/Espo/Modules/Crm/Resources/i18n/lt_LT/Case.json +++ b/application/Espo/Modules/Crm/Resources/i18n/lt_LT/Case.json @@ -18,7 +18,7 @@ "account": "Įmonė", "contact": "Kontaktas (pirminis)", "Contacts": "Kontaktai", - "meetings": "Susirinkimai", + "meetings": "Susitikimai", "calls": "Skambučiai", "tasks": "Užduotys", "emails": "El. laiška", diff --git a/application/Espo/Modules/Crm/Resources/i18n/lt_LT/Global.json b/application/Espo/Modules/Crm/Resources/i18n/lt_LT/Global.json index f0370b09e4..247a82cccd 100644 --- a/application/Espo/Modules/Crm/Resources/i18n/lt_LT/Global.json +++ b/application/Espo/Modules/Crm/Resources/i18n/lt_LT/Global.json @@ -4,7 +4,7 @@ "contacts": "Kontaktai", "opportunities": "Galimybės", "leads": "Potencialūs klientai", - "meetings": "Susirinkimai", + "meetings": "Susitikimai", "calls": "Skambučiai", "tasks": "Užduotys", "emails": "El. laiška", @@ -21,7 +21,7 @@ "Lead": "Potencialus klientas", "Target": "Adresatas", "Opportunity": "Galimybė", - "Meeting": "Susirinkimas", + "Meeting": "Susitikimas", "Calendar": "Kalendorius", "Call": "Skambutis", "Task": "Užduotis", @@ -42,7 +42,7 @@ "Lead": "Potencialūs klientai", "Target": "Adresatai", "Opportunity": "Galimybės", - "Meeting": "Susirinkimai", + "Meeting": "Susitikimai", "Calendar": "Kalendorius", "Call": "Skambučiai", "Task": "Užduotys", diff --git a/application/Espo/Modules/Crm/Resources/i18n/lt_LT/Lead.json b/application/Espo/Modules/Crm/Resources/i18n/lt_LT/Lead.json index ad65407d1c..de411c1f40 100644 --- a/application/Espo/Modules/Crm/Resources/i18n/lt_LT/Lead.json +++ b/application/Espo/Modules/Crm/Resources/i18n/lt_LT/Lead.json @@ -9,7 +9,7 @@ "name": "Vardas", "emailAddress": "El. paštas", "title": "Pavadinimas", - "website": "Interntinė Svetainė", + "website": "Internetinė Svetainė", "phoneNumber": "Telefonas", "accountName": "Įmonės pavadinimas", "doNotCall": "Neskambinti", diff --git a/application/Espo/Modules/Crm/Resources/metadata/dashlets/OpportunitiesByLeadSource.json b/application/Espo/Modules/Crm/Resources/metadata/dashlets/OpportunitiesByLeadSource.json index 98ef8068e7..2f1726c19f 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/dashlets/OpportunitiesByLeadSource.json +++ b/application/Espo/Modules/Crm/Resources/metadata/dashlets/OpportunitiesByLeadSource.json @@ -2,6 +2,7 @@ "view":"crm:views/dashlets/opportunities-by-lead-source", "aclScope": "Opportunity", "options": { + "view": "crm:views/dashlets/options/chart", "fields": { "title": { "type": "varchar", @@ -14,6 +15,12 @@ "dateTo": { "type": "date", "required": true + }, + "dateFilter": { + "type": "enum", + "options": ["currentYear", "currentQuarter", "currentMonth", "ever", "between"], + "default": "currentYear", + "translation": "Global.options.dateSearchRanges" } }, "layout": [ @@ -22,12 +29,19 @@ [ {"name": "title"} ], + [ + {"name": "dateFilter"}, + false + ], [ {"name": "dateFrom"}, {"name": "dateTo"} ] ] } - ] + ], + "defaults": { + "dateFilter": "currentYear" + } } } diff --git a/application/Espo/Modules/Crm/Resources/metadata/dashlets/OpportunitiesByStage.json b/application/Espo/Modules/Crm/Resources/metadata/dashlets/OpportunitiesByStage.json index f52e22a9f7..ef75b4ba2a 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/dashlets/OpportunitiesByStage.json +++ b/application/Espo/Modules/Crm/Resources/metadata/dashlets/OpportunitiesByStage.json @@ -2,6 +2,7 @@ "view":"crm:views/dashlets/opportunities-by-stage", "aclScope": "Opportunity", "options": { + "view": "crm:views/dashlets/options/chart", "fields": { "title": { "type": "varchar", @@ -14,6 +15,12 @@ "dateTo": { "type": "date", "required": true + }, + "dateFilter": { + "type": "enum", + "options": ["currentYear", "currentQuarter", "currentMonth", "ever", "between"], + "default": "currentYear", + "translation": "Global.options.dateSearchRanges" } }, "layout": [ @@ -22,12 +29,19 @@ [ {"name": "title"} ], + [ + {"name": "dateFilter"}, + false + ], [ {"name": "dateFrom"}, {"name": "dateTo"} ] ] } - ] + ], + "defaults": { + "dateFilter": "currentYear" + } } } diff --git a/application/Espo/Modules/Crm/Resources/metadata/dashlets/SalesByMonth.json b/application/Espo/Modules/Crm/Resources/metadata/dashlets/SalesByMonth.json index 8711a39f9e..fbcef30f5b 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/dashlets/SalesByMonth.json +++ b/application/Espo/Modules/Crm/Resources/metadata/dashlets/SalesByMonth.json @@ -2,6 +2,7 @@ "view":"crm:views/dashlets/sales-by-month", "aclScope": "Opportunity", "options": { + "view": "crm:views/dashlets/options/chart", "fields": { "title": { "type": "varchar", @@ -14,6 +15,12 @@ "dateTo": { "type": "date", "required": true + }, + "dateFilter": { + "type": "enum", + "options": ["currentYear", "currentQuarter", "between"], + "default": "currentYear", + "translation": "Global.options.dateSearchRanges" } }, "layout": [ @@ -22,12 +29,19 @@ [ {"name": "title"} ], + [ + {"name": "dateFilter"}, + false + ], [ {"name": "dateFrom"}, {"name": "dateTo"} ] ] } - ] + ], + "defaults": { + "dateFilter": "currentYear" + } } } diff --git a/application/Espo/Modules/Crm/Resources/metadata/dashlets/SalesPipeline.json b/application/Espo/Modules/Crm/Resources/metadata/dashlets/SalesPipeline.json index c25584a9a3..6c6a2642fd 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/dashlets/SalesPipeline.json +++ b/application/Espo/Modules/Crm/Resources/metadata/dashlets/SalesPipeline.json @@ -2,6 +2,7 @@ "view":"crm:views/dashlets/sales-pipeline", "aclScope": "Opportunity", "options": { + "view": "crm:views/dashlets/options/chart", "fields": { "title": { "type": "varchar", @@ -14,13 +15,23 @@ "dateTo": { "type": "date", "required": true + }, + "dateFilter": { + "type": "enum", + "options": ["currentYear", "currentQuarter", "currentMonth", "ever", "between"], + "default": "currentYear", + "translation": "Global.options.dateSearchRanges" } }, "layout": [ { "rows": [ [ - {"name": "title"} + {"name": "title", "span": 2} + ], + [ + {"name": "dateFilter"}, + false ], [ {"name": "dateFrom"}, @@ -28,6 +39,9 @@ ] ] } - ] + ], + "defaults": { + "dateFilter": "currentYear" + } } } diff --git a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Account.json b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Account.json index 112f8cf6ef..1d520d6e25 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Account.json +++ b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Account.json @@ -195,7 +195,6 @@ "layoutListDisabled": true, "layoutMassUpdateDisabled": true, "layoutFiltersDisabled": true, - "exportDisabled": true, "entity": "TargetList" }, "originalLead": { diff --git a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Contact.json b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Contact.json index 99689b868c..c6cede1479 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Contact.json +++ b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Contact.json @@ -253,7 +253,6 @@ "layoutListDisabled": true, "layoutMassUpdateDisabled": true, "importDisabled": true, - "exportDisabled": true, "noLoad": true }, "targetList": { diff --git a/application/Espo/Modules/Crm/Services/Opportunity.php b/application/Espo/Modules/Crm/Services/Opportunity.php index ed420137d0..05b201e410 100644 --- a/application/Espo/Modules/Crm/Services/Opportunity.php +++ b/application/Espo/Modules/Crm/Services/Opportunity.php @@ -36,8 +36,12 @@ use \Espo\Core\Exceptions\Forbidden; class Opportunity extends \Espo\Services\Record { - public function reportSalesPipeline($dateFrom, $dateTo) + public function reportSalesPipeline($dateFilter, $dateFrom = null, $dateTo = null) { + if ($dateFilter !== 'between' && $dateFilter !== 'ever') { + list($dateFrom, $dateTo) = $this->getDateRangeByFilter($dateFilter); + } + $pdo = $this->getEntityManager()->getPDO(); $options = $this->getMetadata()->get('entityDefs.Opportunity.fields.stage.options'); @@ -48,8 +52,16 @@ class Opportunity extends \Espo\Services\Record JOIN currency ON currency.id = opportunity.amount_currency WHERE opportunity.deleted = 0 AND + "; + + if ($dateFilter !== 'ever') { + $sql .= " opportunity.close_date >= ".$pdo->quote($dateFrom)." AND opportunity.close_date < ".$pdo->quote($dateTo)." AND + "; + } + + $sql .= " opportunity.stage <> 'Closed Lost' GROUP BY opportunity.stage ORDER BY FIELD(opportunity.stage, '".implode("','", $options)."') @@ -68,8 +80,12 @@ class Opportunity extends \Espo\Services\Record return $result; } - public function reportByLeadSource($dateFrom, $dateTo) + public function reportByLeadSource($dateFilter, $dateFrom = null, $dateTo = null) { + if ($dateFilter !== 'between' && $dateFilter !== 'ever') { + list($dateFrom, $dateTo) = $this->getDateRangeByFilter($dateFilter); + } + $pdo = $this->getEntityManager()->getPDO(); $sql = " @@ -78,8 +94,15 @@ class Opportunity extends \Espo\Services\Record JOIN currency ON currency.id = opportunity.amount_currency WHERE opportunity.deleted = 0 AND + "; + if ($dateFilter !== 'ever') { + $sql .= " opportunity.close_date >= ".$pdo->quote($dateFrom)." AND opportunity.close_date < ".$pdo->quote($dateTo)." AND + "; + } + + $sql .= " opportunity.stage <> 'Closed Lost' AND opportunity.lead_source <> '' GROUP BY opportunity.lead_source @@ -98,8 +121,12 @@ class Opportunity extends \Espo\Services\Record return $result; } - public function reportByStage($dateFrom, $dateTo) + public function reportByStage($dateFilter, $dateFrom = null, $dateTo = null) { + if ($dateFilter !== 'between' && $dateFilter !== 'ever') { + list($dateFrom, $dateTo) = $this->getDateRangeByFilter($dateFilter); + } + $pdo = $this->getEntityManager()->getPDO(); $options = $this->getMetadata()->get('entityDefs.Opportunity.fields.stage.options'); @@ -110,8 +137,16 @@ class Opportunity extends \Espo\Services\Record JOIN currency ON currency.id = opportunity.amount_currency WHERE opportunity.deleted = 0 AND + "; + + if ($dateFilter !== 'ever') { + $sql .= " opportunity.close_date >= ".$pdo->quote($dateFrom)." AND opportunity.close_date < ".$pdo->quote($dateTo)." AND + "; + } + + $sql .= " opportunity.stage <> 'Closed Lost' AND opportunity.stage <> 'Closed Won' GROUP BY opportunity.stage @@ -131,8 +166,12 @@ class Opportunity extends \Espo\Services\Record return $result; } - public function reportSalesByMonth($dateFrom, $dateTo) + public function reportSalesByMonth($dateFilter, $dateFrom = null, $dateTo = null) { + if ($dateFilter !== 'between' && $dateFilter !== 'ever') { + list($dateFrom, $dateTo) = $this->getDateRangeByFilter($dateFilter); + } + $pdo = $this->getEntityManager()->getPDO(); $sql = " @@ -141,10 +180,17 @@ class Opportunity extends \Espo\Services\Record JOIN currency ON currency.id = opportunity.amount_currency WHERE opportunity.deleted = 0 AND + "; + + if ($dateFilter !== 'ever') { + $sql .= " opportunity.close_date >= ".$pdo->quote($dateFrom)." AND opportunity.close_date < ".$pdo->quote($dateTo)." AND - opportunity.stage = 'Closed Won' + "; + } + $sql .= " + opportunity.stage = 'Closed Won' GROUP BY DATE_FORMAT(opportunity.close_date, '%Y-%m') ORDER BY `month` "; @@ -159,8 +205,73 @@ class Opportunity extends \Espo\Services\Record $result[$row['month']] = floatval($row['amount']); } - return $result; + $dt = new \DateTime($dateFrom); + $dtTo = new \DateTime($dateTo); + $dtTo->setDate($dtTo->format('Y'), $dtTo->format('m'), 1); + if ($dt && $dtTo) { + $interval = new \DateInterval('P1M'); + while ($dt->getTimestamp() <= $dtTo->getTimestamp()) { + $month = $dt->format('Y-m'); + if (!array_key_exists($month, $result)) { + $result[$month] = 0; + } + $dt->add($interval); + } + } + + + $keyList = array_keys($result); + sort($keyList); + + $today = new \DateTime(); + + $endPosition = count($keyList) - 1; + for ($i = count($keyList) - 1; $i >= 0; $i--) { + $key = $keyList[$i]; + $dt = new \DateTime($key . '-01'); + + if ($dt->getTimestamp() < $today->getTimestamp()) { + break; + } + if (empty($result[$key])) { + $endPosition = $i; + } else { + break; + } + } + + $keyList = array_slice($keyList, 0, $endPosition); + + return (object) [ + 'keyList' => $keyList, + 'dataMap' => $result + ]; } + protected function getDateRangeByFilter($dateFilter) + { + switch ($dateFilter) { + case 'currentYear': + $dt = new \DateTime(); + return [ + $dt->modify('first day of January this year')->format('Y-m-d'), + $dt->add(new \DateInterval('P1Y'))->format('Y-m-d') + ]; + case 'currentQuarter': + $dt = new \DateTime(); + $quarter = ceil($dt->format('m') / 3); + $dt->modify('first day of January this year'); + return [ + $dt->add(new \DateInterval('P'.(($quarter - 1) * 3).'M'))->format('Y-m-d'), + $dt->add(new \DateInterval('P3M'))->format('Y-m-d') + ]; + case 'currentMonth': + $dt = new \DateTime(); + return [ + $dt->modify('first day of this month')->format('Y-m-d'), + $dt->add(new \DateInterval('P1M'))->format('Y-m-d') + ]; + } + return [0, 0]; + } } - diff --git a/application/Espo/ORM/DB/Query/Base.php b/application/Espo/ORM/DB/Query/Base.php index b17d70f534..cea8c7f81c 100644 --- a/application/Espo/ORM/DB/Query/Base.php +++ b/application/Espo/ORM/DB/Query/Base.php @@ -250,7 +250,7 @@ abstract class Base case 'WEEK_0': return "CONCAT(YEAR({$part}), '/', WEEK({$part}, 0))"; case 'WEEK_1': - return "CONCAT(YEAR({$part}), '/', WEEK({$part}, 1))"; + return "CONCAT(YEAR({$part}), '/', WEEK({$part}, 5))"; case 'MONTH_NUMBER': $function = 'MONTH'; break; @@ -266,7 +266,7 @@ abstract class Base case 'WEEK_NUMBER_0': return "WEEK({$part}, 0)"; case 'WEEK_NUMBER_1': - return "WEEK({$part}, 1)"; + return "WEEK({$part}, 5)"; case 'HOUR_NUMBER': $function = 'HOUR'; break; diff --git a/application/Espo/ORM/EntityManager.php b/application/Espo/ORM/EntityManager.php index 16f05afe12..d894f73fef 100644 --- a/application/Espo/ORM/EntityManager.php +++ b/application/Espo/ORM/EntityManager.php @@ -163,14 +163,14 @@ class EntityManager public function saveEntity(Entity $entity, array $options = array()) { - $entityName = $entity->getEntityName(); - return $this->getRepository($entityName)->save($entity, $options); + $entityType = $entity->getEntityType(); + return $this->getRepository($entityType)->save($entity, $options); } public function removeEntity(Entity $entity, array $options = array()) { - $entityName = $entity->getEntityName(); - return $this->getRepository($entityName)->remove($entity, $options); + $entityType = $entity->getEntityType(); + return $this->getRepository($entityType)->remove($entity, $options); } public function getRepository($name) @@ -186,6 +186,11 @@ class EntityManager $this->metadata->setData($data); } + public function hasRepository($name) + { + return $this->getMetadata()->has($name); + } + public function getMetadata() { return $this->metadata; diff --git a/application/Espo/ORM/Metadata.php b/application/Espo/ORM/Metadata.php index a370f1bd68..81d62e8f31 100644 --- a/application/Espo/ORM/Metadata.php +++ b/application/Espo/ORM/Metadata.php @@ -45,4 +45,12 @@ class Metadata } return $this->data[$entityType]; } + + public function has($entityType) + { + if (!array_key_exists($entityType, $this->data)) { + return null; + } + return true; + } } diff --git a/application/Espo/ORM/Repositories/RDB.php b/application/Espo/ORM/Repositories/RDB.php index 0021289103..b0f049288c 100644 --- a/application/Espo/ORM/Repositories/RDB.php +++ b/application/Espo/ORM/Repositories/RDB.php @@ -136,7 +136,9 @@ class RDB extends \Espo\ORM\Repository public function save(Entity $entity, array $options = array()) { - $this->beforeSave($entity, $options); + if (empty($options['skipBeforeSave']) && empty($options['skipAll'])) { + $this->beforeSave($entity, $options); + } if ($entity->isNew() && !$entity->isSaved()) { $result = $this->getMapper()->insert($entity); } else { @@ -144,9 +146,13 @@ class RDB extends \Espo\ORM\Repository } if ($result) { $entity->setIsSaved(true); - $this->afterSave($entity, $options); + if (empty($options['skipAfterSave']) && empty($options['skipAll'])) { + $this->afterSave($entity, $options); + } if ($entity->isNew()) { - $entity->setIsNew(false); + if (empty($options['keepNew'])) { + $entity->setIsNew(false); + } } else { if ($entity->isFetched()) { $entity->updateFetchedValues(); diff --git a/application/Espo/Repositories/Email.php b/application/Espo/Repositories/Email.php index c6feca9618..0ce50ee4b6 100644 --- a/application/Espo/Repositories/Email.php +++ b/application/Espo/Repositories/Email.php @@ -185,6 +185,10 @@ class Email extends \Espo\Core\ORM\Repositories\RDB protected function beforeSave(Entity $entity, array $options = array()) { + if ($entity->isNew() && !$entity->get('messageId')) { + $entity->setDummyMessageId(); + } + $eaRepository = $this->getEntityManager()->getRepository('EmailAddress'); if ($entity->has('attachmentsIds')) { @@ -322,8 +326,8 @@ class Email extends \Espo\Core\ORM\Repositories\RDB } } - if ($entity->get('isBeingImportered')) { - $entity->set('isBeingImportered', false); + if ($entity->get('isBeingImported')) { + $entity->set('isBeingImported', false); } } diff --git a/application/Espo/Repositories/Job.php b/application/Espo/Repositories/Job.php index 135ea1c0b7..2d68e9bd88 100644 --- a/application/Espo/Repositories/Job.php +++ b/application/Espo/Repositories/Job.php @@ -33,6 +33,10 @@ use Espo\ORM\Entity; class Job extends \Espo\Core\ORM\Repositories\RDB { + protected $hooksDisabled = true; + + protected $processFieldsAfterSaveDisabled = true; + protected function init() { parent::init(); @@ -46,14 +50,13 @@ class Job extends \Espo\Core\ORM\Repositories\RDB public function beforeSave(Entity $entity, array $options = array()) { - if (!$entity->has('executeTime')) { + if (!$entity->has('executeTime') && $entity->isNew()) { $entity->set('executeTime', date('Y-m-d H:i:s')); } if (!$entity->has('attempts') && $entity->isNew()) { - $attempts = $this->getConfig()->get('cron.attempts', 0); + $attempts = $this->getConfig()->get('jobRerunAttemptNumber', 0); $entity->set('attempts', $attempts); } } } - diff --git a/application/Espo/Repositories/ScheduledJob.php b/application/Espo/Repositories/ScheduledJob.php new file mode 100644 index 0000000000..091d8af027 --- /dev/null +++ b/application/Espo/Repositories/ScheduledJob.php @@ -0,0 +1,39 @@ +findOne(); if ($user) { - throw new Error(); + throw new Conflict(json_encode(['reason' => 'userNameExists'])); } } else { if ($entity->isFieldChanged('userName')) { @@ -64,7 +65,7 @@ class User extends \Espo\Core\ORM\Repositories\RDB 'id!=' => $entity->id ))->findOne(); if ($user) { - throw new Error(); + throw new Conflict(json_encode(['reason' => 'userNameExists'])); } } } @@ -113,4 +114,3 @@ class User extends \Espo\Core\ORM\Repositories\RDB return false; } } - diff --git a/application/Espo/Resources/i18n/en_US/DashletOptions.json b/application/Espo/Resources/i18n/en_US/DashletOptions.json index 607f953f08..b4fca4596e 100644 --- a/application/Espo/Resources/i18n/en_US/DashletOptions.json +++ b/application/Espo/Resources/i18n/en_US/DashletOptions.json @@ -14,7 +14,8 @@ "boolFilterList": "Additional Filters", "sortBy": "Order (field)", "sortDirection": "Order (direction)", - "expandedLayout": "Layout" + "expandedLayout": "Layout", + "dateFilter": "Date Filter" }, "options": { "mode": { diff --git a/application/Espo/Resources/i18n/en_US/Email.json b/application/Espo/Resources/i18n/en_US/Email.json index eba2acf6bc..1fb52fd252 100644 --- a/application/Espo/Resources/i18n/en_US/Email.json +++ b/application/Espo/Resources/i18n/en_US/Email.json @@ -37,7 +37,15 @@ "emailAccounts": "Personal Accounts", "hasAttachment": "Has Attachment", "assignedUsers": "Assigned Users", - "sentBy": "Sent By" + "sentBy": "Sent By", + "ccEmailAddresses": "CC Email Addresses", + "messageId": "Message Id", + "messageIdInternal": "Message Id (Internal)", + "folderId": "Folder Id", + "fromName": "From Name", + "fromString": "From String", + "isSystem": "Is System" + }, "links": { "replied": "Replied", diff --git a/application/Espo/Resources/i18n/en_US/ScheduledJob.json b/application/Espo/Resources/i18n/en_US/ScheduledJob.json index 5d8c8f1bbc..27ca26ffe0 100644 --- a/application/Espo/Resources/i18n/en_US/ScheduledJob.json +++ b/application/Espo/Resources/i18n/en_US/ScheduledJob.json @@ -18,7 +18,8 @@ "CheckEmailAccounts": "Check Personal Email Accounts", "SendEmailReminders": "Send Email Reminders", "AuthTokenControl": "Auth Token Control", - "SendEmailNotifications": "Send Email Notifications" + "SendEmailNotifications": "Send Email Notifications", + "CheckNewVersion": "Check for New Version" }, "cronSetup": { "linux": "Note: Add this line to the crontab file to run Espo Scheduled Jobs:", diff --git a/application/Espo/Resources/i18n/en_US/Settings.json b/application/Espo/Resources/i18n/en_US/Settings.json index 4f21e4b241..d0fe49a51c 100644 --- a/application/Espo/Resources/i18n/en_US/Settings.json +++ b/application/Espo/Resources/i18n/en_US/Settings.json @@ -68,10 +68,10 @@ "theme": "Theme", "userThemesDisabled": "Disable User Themes", "emailMessageMaxSize": "Email Max Size (Mb)", - "massEmailMaxPerHourCount": "Max count of emails sent per hour", + "massEmailMaxPerHourCount": "Max number of emails sent per hour", "personalEmailMaxPortionSize": "Max email portion size for personal account fetching", "inboundEmailMaxPortionSize": "Max email portion size for group account fetching", - "maxEmailAccountCount": "Max count of personal email accounts per user", + "maxEmailAccountCount": "Max number of personal email accounts per user", "authTokenLifetime": "Auth Token Lifetime (hours)", "authTokenMaxIdleTime": "Auth Token Max Idle Time (hours)", "dashboardLayout": "Dashboard Layout (default)", diff --git a/application/Espo/Resources/i18n/en_US/User.json b/application/Espo/Resources/i18n/en_US/User.json index 626c97b05e..c3d1785937 100644 --- a/application/Espo/Resources/i18n/en_US/User.json +++ b/application/Espo/Resources/i18n/en_US/User.json @@ -81,7 +81,8 @@ "forbidden": "Forbidden, please try later", "uniqueLinkHasBeenSent": "The unique URL has been sent to the specified email address.", "passwordChangedByRequest": "Password has been changed.", - "setupSmtpBefore": "You need to setup SMTP settings to make the system be able to send password in email." + "setupSmtpBefore": "You need to setup SMTP settings to make the system be able to send password in email.", + "userNameExists": "User Name already exists" }, "options": { "gender": { diff --git a/application/Espo/Resources/i18n/lt_LT/EntityManager.json b/application/Espo/Resources/i18n/lt_LT/EntityManager.json index 1d1f33f2df..64a6a59951 100644 --- a/application/Espo/Resources/i18n/lt_LT/EntityManager.json +++ b/application/Espo/Resources/i18n/lt_LT/EntityManager.json @@ -46,6 +46,9 @@ "desc": "Mažėjančia tvarka" } }, + "messages": { + "linkAlreadyExists": "Sąsajos pavadinimo konfliktas." + }, "tooltips": { "statusField": "Šio lauko atnaujinimai įrašomi sraute.", "textFilterFields": "Laukeliai, naudojami teksto paieškoje.", diff --git a/application/Espo/Resources/i18n/lt_LT/Global.json b/application/Espo/Resources/i18n/lt_LT/Global.json index d35fa9468c..f4c18d00fd 100644 --- a/application/Espo/Resources/i18n/lt_LT/Global.json +++ b/application/Espo/Resources/i18n/lt_LT/Global.json @@ -42,7 +42,7 @@ "PhoneNumber": "Telefono numeris" }, "scopeNamesPlural": { - "Email": "El. laiška", + "Email": "El. laiškai", "User": "Vartotojai", "Team": "Komandos", "Role": "Rolės", @@ -54,7 +54,7 @@ "ExternalAccount": "Išorinės paskyros", "Extension": "Plėtiniai", "Dashboard": "Prietaisų skydelis", - "InboundEmail": "Grupės el. pašto paskyros ", + "InboundEmail": "Grupės el. pašto paskyros", "Stream": "Srautas", "Import": "Importuoti rezultatus", "Template": "Šablonai", @@ -196,6 +196,7 @@ "List View": "Rodyti kaip sąrašą\n", "Tree View": "Rodyti kaip medį", "Unlink All": "Atsieti visus", + "Total": "Iš viso", "Print to PDF": "Spausdinti į PDF", "Default": "Numatytas", "Number": "Numeris", @@ -291,7 +292,7 @@ "firstName": "Vardas", "lastName": "Pavardė", "salutationName": "Sveikinimas", - "assignedUser": "Priskirta vartotojas", + "assignedUser": "Priskirtas darbuotojas", "assignedUsers": "Priskirti vartotojai", "emailAddress": "El. paštas", "assignedUserName": "Priskirto vartotojo vardas", @@ -313,7 +314,7 @@ "children": "Antrinis" }, "links": { - "assignedUser": "Priskirta vartotojas", + "assignedUser": "Priskirtas darbuotojas", "createdBy": "Sukurta", "modifiedBy": "Koreguota", "team": "Komanda", diff --git a/application/Espo/Resources/i18n/lt_LT/Import.json b/application/Espo/Resources/i18n/lt_LT/Import.json index 0f294c6ffe..039b809670 100644 --- a/application/Espo/Resources/i18n/lt_LT/Import.json +++ b/application/Espo/Resources/i18n/lt_LT/Import.json @@ -26,6 +26,7 @@ "Header Row Value": "Antraštinės eilutės vertė", "Field": "Laukelis", "What to Import?": "Ką importuoti?", + "Entity Type": "Vedinio tipas", "What to do?": "Ką daryti?", "Properties": "Savybės", "Header Row": "Antraštinė eilutė", @@ -56,6 +57,7 @@ }, "fields": { "file": "Failas", + "entityType": "Vedinio tipas", "imported": "Importuoti įrašai", "duplicates": "Dublikuoti įrašai", "updated": "Atnaujinti įrašai", diff --git a/application/Espo/Resources/i18n/lt_LT/Integration.json b/application/Espo/Resources/i18n/lt_LT/Integration.json index 7a6486ee36..6dde9dda66 100644 --- a/application/Espo/Resources/i18n/lt_LT/Integration.json +++ b/application/Espo/Resources/i18n/lt_LT/Integration.json @@ -1,5 +1,6 @@ { "fields": { + "enabled": "Įjungta", "clientId": "Kliento ID", "redirectUri": "Nukreipti URL", "apiKey": "API raktas" diff --git a/application/Espo/Resources/i18n/lt_LT/Settings.json b/application/Espo/Resources/i18n/lt_LT/Settings.json index 52544e82a2..96c7cd7dfa 100644 --- a/application/Espo/Resources/i18n/lt_LT/Settings.json +++ b/application/Espo/Resources/i18n/lt_LT/Settings.json @@ -41,13 +41,12 @@ "exportDisabled": "Išjungti eksportavimą (leidžiama tik admionistratoriui)", "b2cMode": "B2C režimas", "avatarsDisabled": "Išjungti piktogramas", + "displayListViewRecordCount": "Rodyti visus (sąrašo rodmenyje)", "theme": "Tema", "userThemesDisabled": "Išjungti naudotojo temas", "emailMessageMaxSize": "Maksimalus el. laiško dydis (Mb)", - "massEmailMaxPerHourCount": "Maksimalus per valandą siunčiamų el. laiškų skaičius", "personalEmailMaxPortionSize": "Maksimalus el. pašto siuntimo dydis asmeninei paskyrai", "inboundEmailMaxPortionSize": "Maksimalus el. laiškų kiekis grupės paskyrai laikomas", - "maxEmailAccountCount": "Maksimalus el. pašto paskyrų kiekis vienam vartotojui", "authTokenLifetime": "Autentikavimo žymės gyvavimas (valandomis)", "authTokenMaxIdleTime": "Autentikavimo žymės maksimalus laikas (valandomis)", "dashboardLayout": "Prietaisų skydelio išdėstymas (numatytas)", diff --git a/application/Espo/Resources/layouts/Preferences/portal/detail.json b/application/Espo/Resources/layouts/Preferences/detailPortal.json similarity index 100% rename from application/Espo/Resources/layouts/Preferences/portal/detail.json rename to application/Espo/Resources/layouts/Preferences/detailPortal.json diff --git a/application/Espo/Resources/layouts/Role/detail.json b/application/Espo/Resources/layouts/Role/detail.json index 460e0b0896..6080bb67cb 100644 --- a/application/Espo/Resources/layouts/Role/detail.json +++ b/application/Espo/Resources/layouts/Role/detail.json @@ -3,13 +3,11 @@ "rows": [ [ {"name": "name"}, - {"name": "exportPermission"} - ], - [ - {"name": "assignmentPermission"}, + {"name": "exportPermission"}, {"name": "userPermission"} ], [ + {"name": "assignmentPermission"}, {"name": "portalPermission"}, {"name": "groupEmailAccountPermission"} ] diff --git a/application/Espo/Resources/layouts/Settings/currency.json b/application/Espo/Resources/layouts/Settings/currency.json index de72270c38..08f17630f7 100644 --- a/application/Espo/Resources/layouts/Settings/currency.json +++ b/application/Espo/Resources/layouts/Settings/currency.json @@ -2,7 +2,8 @@ { "label": "Currency Settings", "rows": [ - [{"name": "defaultCurrency"}, {"name": "currencyList"}] + [{"name": "defaultCurrency"}, {"name": "currencyFormat"}], + [{"name": "currencyList"}, {"name": "currencyDecimalPlaces"}] ] }, { diff --git a/application/Espo/Resources/layouts/Settings/settings.json b/application/Espo/Resources/layouts/Settings/settings.json index ec85c84904..6599ad7f6a 100644 --- a/application/Espo/Resources/layouts/Settings/settings.json +++ b/application/Espo/Resources/layouts/Settings/settings.json @@ -11,12 +11,10 @@ { "label": "Locale", "rows": [ - [{"name": "dateFormat"}, {"name": "timeZone"}], - [{"name": "timeFormat"}, {"name": "weekStart"}], - [{"name": "language"}, false], - [{"name": "currencyFormat"}, {"name": "thousandSeparator"}], - [{"name": "currencyDecimalPlaces"}, {"name": "decimalMark"}], - [{"name": "addressFormat"}, false], + [{"name": "language"}, {"name": "timeZone"}], + [{"name": "dateFormat"}, {"name": "weekStart"}], + [{"name": "timeFormat"}, {"name": "thousandSeparator"}], + [{"name": "addressFormat"}, {"name": "decimalMark"}], [{"name": "addressPreview"}, false] ] diff --git a/application/Espo/Resources/metadata/app/acl.json b/application/Espo/Resources/metadata/app/acl.json index ebe3ae0243..ba87b4375f 100644 --- a/application/Espo/Resources/metadata/app/acl.json +++ b/application/Espo/Resources/metadata/app/acl.json @@ -64,9 +64,6 @@ "Attachment": { "parent": false }, - "User": { - "gender": false - }, "EmailFolder": { "assignedUser": false }, @@ -78,8 +75,30 @@ }, "default": { "scopeLevel": { + "User": { + "read": "all" + } }, "fieldLevel": { + }, + "scopeFieldLevel": { + "User": { + "gender": false + } + } + }, + "strictDefault": { + "scopeLevel": { + "User": { + "read": "own" + } + }, + "fieldLevel": { + }, + "scopeFieldLevel": { + "User": { + "gender": false + } } }, "valuePermissionList": [ diff --git a/application/Espo/Resources/metadata/app/aclPortal.json b/application/Espo/Resources/metadata/app/aclPortal.json index 52fc51e720..095aa0b669 100644 --- a/application/Espo/Resources/metadata/app/aclPortal.json +++ b/application/Espo/Resources/metadata/app/aclPortal.json @@ -80,12 +80,13 @@ "isInternal": false, "isGlobal": false }, - "User": { - "gender": false + "Email": { + "inboundEmails": false, + "emailAccounts": false } } }, - "default": { + "strictDefault": { "scopeLevel": { }, "fieldLevel": { @@ -113,22 +114,21 @@ "edit": "no" }, "leads": false + }, + "KnowledgeBaseArticle": { + "assignedUser": false + }, + "User": { + "gender": false } } }, "valuePermissionList": [ "exportPermission" ], - "permissionsDefaults": { - "exportPermission": "no" - }, "permissionsStrictDefaults": { "exportPermission": "no" }, - "scopeLevelTypesDefaults": { - "boolean": false, - "record": false - }, "scopeLevelTypesStrictDefaults": { "boolean": false, "record": false diff --git a/application/Espo/Resources/metadata/entityDefs/Email.json b/application/Espo/Resources/metadata/entityDefs/Email.json index ed14886458..0f4f0ff074 100644 --- a/application/Espo/Resources/metadata/entityDefs/Email.json +++ b/application/Espo/Resources/metadata/entityDefs/Email.json @@ -131,7 +131,8 @@ "messageId": { "type": "varchar", "maxLength": 255, - "readOnly": true + "readOnly": true, + "index": true }, "messageIdInternal": { "type": "varchar", @@ -416,9 +417,6 @@ "textFilterFields": ["name", "bodyPlain", "body"] }, "indexes": { - "dateSentAssignedUser": { - "columns": ["dateSent", "assignedUserId"] - }, "dateSent": { "columns": ["dateSent", "deleted"] }, diff --git a/application/Espo/Resources/metadata/entityDefs/Job.json b/application/Espo/Resources/metadata/entityDefs/Job.json index f0da2187b4..843aac66bc 100644 --- a/application/Espo/Resources/metadata/entityDefs/Job.json +++ b/application/Espo/Resources/metadata/entityDefs/Job.json @@ -39,6 +39,9 @@ "link": "scheduledJob", "field": "job" }, + "pid": { + "type": "int" + }, "attempts": { "type": "int" }, diff --git a/application/Espo/Resources/metadata/entityDefs/ScheduledJob.json b/application/Espo/Resources/metadata/entityDefs/ScheduledJob.json index 98abbef193..6cb3d2137a 100644 --- a/application/Espo/Resources/metadata/entityDefs/ScheduledJob.json +++ b/application/Espo/Resources/metadata/entityDefs/ScheduledJob.json @@ -66,8 +66,8 @@ "asc": true }, "jobSchedulingMap": { - "CheckInboundEmails": "*/4 * * * *", - "CheckEmailAccounts": "*/5 * * * *", + "CheckInboundEmails": "*/2 * * * *", + "CheckEmailAccounts": "*/1 * * * *", "SendEmailReminders": "*/2 * * * *", "Cleanup": "1 1 * * 0", "AuthTokenControl": "*/6 * * * *", @@ -78,7 +78,8 @@ "isSystem": true, "scheduling": "1 */12 * * *" }, - "NewVersionChecker": { + "CheckNewVersion": { + "name": "Check for New Version", "isSystem": true, "scheduling": "15 5 * * *" } diff --git a/application/Espo/Resources/metadata/entityDefs/Settings.json b/application/Espo/Resources/metadata/entityDefs/Settings.json index 5b2ab1409b..75503a4c8c 100644 --- a/application/Espo/Resources/metadata/entityDefs/Settings.json +++ b/application/Espo/Resources/metadata/entityDefs/Settings.json @@ -337,10 +337,14 @@ "tooltip": true }, "inboundEmailMaxPortionSize": { - "type": "int" + "type": "int", + "min": 1, + "max": 500 }, "personalEmailMaxPortionSize": { - "type": "int" + "type": "int", + "min": 1, + "max": 500 }, "maxEmailAccountCount": { "type": "int" diff --git a/application/Espo/Resources/metadata/themes/Espo.json b/application/Espo/Resources/metadata/themes/Espo.json index 68e3ef6163..3457af895e 100644 --- a/application/Espo/Resources/metadata/themes/Espo.json +++ b/application/Espo/Resources/metadata/themes/Espo.json @@ -5,5 +5,12 @@ "dashboardCellMargin": 19, "navbarHeight": 44, "modalFooterAtTheTop": true, - "modalFullHeight": true + "modalFullHeight": true, + "fontSize": 14, + "textColor": "#333", + "chartGridColor": "#ddd", + "chartSuccessColor": "#87C956", + "chartTickColor": "#e8eced", + "chartColorList": ["#6FA8D6", "#4E6CAD", "#EDC555", "#ED8F42", "#DE6666", "#7CC4A4", "#8A7CC2", "#D4729B"], + "chartColorAlternativeList": ["#6FA8D6", "#EDC555", "#ED8F42", "#7CC4A4", "#D4729B"] } \ No newline at end of file diff --git a/application/Espo/Resources/metadata/themes/EspoRtl.json b/application/Espo/Resources/metadata/themes/EspoRtl.json index 5bf66f9f5b..8187ec8b15 100644 --- a/application/Espo/Resources/metadata/themes/EspoRtl.json +++ b/application/Espo/Resources/metadata/themes/EspoRtl.json @@ -5,5 +5,12 @@ "dashboardCellMargin": 19, "navbarHeight": 44, "modalFooterAtTheTop": true, - "modalFullHeight": true + "modalFullHeight": true, + "fontSize": 14, + "textColor": "#333", + "chartGridColor": "#ddd", + "chartTickColor": "#e8eced", + "chartSuccessColor": "#87C956", + "chartColorList": ["#6FA8D6", "#4E6CAD", "#EDC555", "#ED8F42", "#DE6666", "#7CC4A4", "#8A7CC2", "#D4729B"], + "chartColorAlternativeList": ["#6FA8D6", "#EDC555", "#ED8F42", "#7CC4A4", "#D4729B"] } \ No newline at end of file diff --git a/application/Espo/Resources/metadata/themes/EspoVertical.json b/application/Espo/Resources/metadata/themes/EspoVertical.json index b7ad366217..39f81c4468 100644 --- a/application/Espo/Resources/metadata/themes/EspoVertical.json +++ b/application/Espo/Resources/metadata/themes/EspoVertical.json @@ -8,5 +8,12 @@ "dashboardCellHeight": 155, "dashboardCellMargin": 19, "modalFooterAtTheTop": true, - "modalFullHeight": true + "modalFullHeight": true, + "fontSize": 14, + "textColor": "#333", + "chartGridColor": "#ddd", + "chartTickColor": "#e8eced", + "chartSuccessColor": "#87C956", + "chartColorList": ["#6FA8D6", "#4E6CAD", "#EDC555", "#ED8F42", "#DE6666", "#7CC4A4", "#8A7CC2", "#D4729B"], + "chartColorAlternativeList": ["#6FA8D6", "#EDC555", "#ED8F42", "#7CC4A4", "#D4729B"] } \ No newline at end of file diff --git a/application/Espo/Resources/metadata/themes/Hazyblue.json b/application/Espo/Resources/metadata/themes/Hazyblue.json index f4b601f94f..785d36583a 100644 --- a/application/Espo/Resources/metadata/themes/Hazyblue.json +++ b/application/Espo/Resources/metadata/themes/Hazyblue.json @@ -5,5 +5,12 @@ "dashboardCellMargin": 19, "navbarHeight": 44, "modalFooterAtTheTop": true, - "modalFullHeight": true + "modalFullHeight": true, + "fontSize": 14, + "textColor": "#333", + "chartGridColor": "#ddd", + "chartTickColor": "#e8eced", + "chartSuccessColor": "#85b75f", + "chartColorList": ["#6FA8D6", "#4E6CAD", "#EDC555", "#ED8F42", "#DE6666", "#7CC4A4", "#8A7CC2", "#D4729B"], + "chartColorAlternativeList": ["#6FA8D6", "#EDC555", "#ED8F42", "#7CC4A4", "#D4729B"] } \ No newline at end of file diff --git a/application/Espo/Resources/metadata/themes/HazyblueVertical.json b/application/Espo/Resources/metadata/themes/HazyblueVertical.json index a4d1a20b59..00ff6c7a5f 100644 --- a/application/Espo/Resources/metadata/themes/HazyblueVertical.json +++ b/application/Espo/Resources/metadata/themes/HazyblueVertical.json @@ -8,5 +8,12 @@ "dashboardCellHeight": 155, "dashboardCellMargin": 19, "modalFooterAtTheTop": true, - "modalFullHeight": true + "modalFullHeight": true, + "fontSize": 14, + "textColor": "#333", + "chartGridColor": "#ddd", + "chartTickColor": "#e8eced", + "chartSuccessColor": "#85b75f", + "chartColorList": ["#6FA8D6", "#4E6CAD", "#EDC555", "#ED8F42", "#DE6666", "#7CC4A4", "#8A7CC2", "#D4729B"], + "chartColorAlternativeList": ["#6FA8D6", "#EDC555", "#ED8F42", "#7CC4A4", "#D4729B"] } \ No newline at end of file diff --git a/application/Espo/Resources/metadata/themes/Sakura.json b/application/Espo/Resources/metadata/themes/Sakura.json index c499cd81eb..ae4785c682 100644 --- a/application/Espo/Resources/metadata/themes/Sakura.json +++ b/application/Espo/Resources/metadata/themes/Sakura.json @@ -5,5 +5,12 @@ "dashboardCellMargin": 19, "navbarHeight": 44, "modalFooterAtTheTop": true, - "modalFullHeight": true + "modalFullHeight": true, + "fontSize": 14, + "textColor": "#333", + "chartGridColor": "#ddd", + "chartTickColor": "#e8eced", + "chartSuccessColor": "#83CD77", + "chartColorList": ["#6FA8D6", "#4E6CAD", "#EDC555", "#ED8F42", "#DE6666", "#7CC4A4", "#8A7CC2", "#D4729B"], + "chartColorAlternativeList": ["#6FA8D6", "#EDC555", "#ED8F42", "#7CC4A4", "#D4729B"] } \ No newline at end of file diff --git a/application/Espo/Resources/metadata/themes/SakuraVertical.json b/application/Espo/Resources/metadata/themes/SakuraVertical.json index 68bd61cb8c..8e419a0d22 100644 --- a/application/Espo/Resources/metadata/themes/SakuraVertical.json +++ b/application/Espo/Resources/metadata/themes/SakuraVertical.json @@ -8,5 +8,12 @@ "dashboardCellHeight": 155, "dashboardCellMargin": 19, "modalFooterAtTheTop": true, - "modalFullHeight": true + "modalFullHeight": true, + "fontSize": 14, + "textColor": "#333", + "chartGridColor": "#ddd", + "chartTickColor": "#e8eced", + "chartSuccessColor": "#83CD77", + "chartColorList": ["#6FA8D6", "#4E6CAD", "#EDC555", "#ED8F42", "#DE6666", "#7CC4A4", "#8A7CC2", "#D4729B"], + "chartColorAlternativeList": ["#6FA8D6", "#EDC555", "#ED8F42", "#7CC4A4", "#D4729B"] } \ No newline at end of file diff --git a/application/Espo/Resources/metadata/themes/Violet.json b/application/Espo/Resources/metadata/themes/Violet.json index abb43296f5..7f36d4b3ac 100644 --- a/application/Espo/Resources/metadata/themes/Violet.json +++ b/application/Espo/Resources/metadata/themes/Violet.json @@ -5,5 +5,12 @@ "dashboardCellMargin": 19, "navbarHeight": 44, "modalFooterAtTheTop": true, - "modalFullHeight": true + "modalFullHeight": true, + "textColor": "#333", + "fontSize": 14, + "chartGridColor": "#ddd", + "chartTickColor": "#e8eced", + "chartSuccessColor": "#7BC169", + "chartColorList": ["#6FA8D6", "#4E6CAD", "#EDC555", "#ED8F42", "#DE6666", "#7CC4A4", "#8A7CC2", "#D4729B"], + "chartColorAlternativeList": ["#6FA8D6", "#EDC555", "#ED8F42", "#7CC4A4", "#D4729B"] } \ No newline at end of file diff --git a/application/Espo/Resources/metadata/themes/VioletVertical.json b/application/Espo/Resources/metadata/themes/VioletVertical.json index b88b822757..ce896ad24f 100644 --- a/application/Espo/Resources/metadata/themes/VioletVertical.json +++ b/application/Espo/Resources/metadata/themes/VioletVertical.json @@ -8,5 +8,12 @@ "dashboardCellHeight": 155, "dashboardCellMargin": 19, "modalFooterAtTheTop": true, - "modalFullHeight": true + "modalFullHeight": true, + "fontSize": 14, + "textColor": "#333", + "chartGridColor": "#ddd", + "chartTickColor": "#e8eced", + "chartSuccessColor": "#7BC169", + "chartColorList": ["#6FA8D6", "#4E6CAD", "#EDC555", "#ED8F42", "#DE6666", "#7CC4A4", "#8A7CC2", "#D4729B"], + "chartColorAlternativeList": ["#6FA8D6", "#EDC555", "#ED8F42", "#7CC4A4", "#D4729B"] } \ No newline at end of file diff --git a/application/Espo/Services/AdminNotifications.php b/application/Espo/Services/AdminNotifications.php index c6c8bf3bab..8b1bdf2c32 100644 --- a/application/Espo/Services/AdminNotifications.php +++ b/application/Espo/Services/AdminNotifications.php @@ -31,7 +31,7 @@ namespace Espo\Services; class AdminNotifications extends \Espo\Core\Services\Base { - public function newVersionChecker($data) + public function jobCheckNewVersion($data) { $config = $this->getConfig(); diff --git a/application/Espo/Services/App.php b/application/Espo/Services/App.php index 272f7474d0..1b40e333e3 100644 --- a/application/Espo/Services/App.php +++ b/application/Espo/Services/App.php @@ -209,4 +209,14 @@ class App extends \Espo\Core\Services\Base } return $value; } + + public function jobClearCache() + { + $this->getInjection('container')->get('dataManager')->clearCache(); + } + + public function jobRebuild() + { + $this->getInjection('container')->get('dataManager')->rebuild(); + } } diff --git a/application/Espo/Services/Email.php b/application/Espo/Services/Email.php index 8e10bc3475..44d4ead58c 100644 --- a/application/Espo/Services/Email.php +++ b/application/Espo/Services/Email.php @@ -119,9 +119,11 @@ class Email extends Record } } } + + $emailAccountService = $this->getServiceFactory()->create('EmailAccount'); + $emailAccount = $emailAccountService->findAccountForUser($this->getUser(), $fromAddress); + if (!$smtpParams) { - $emailAccountService = $this->getServiceFactory()->create('EmailAccount'); - $emailAccount = $emailAccountService->findAccountForUser($this->getUser(), $fromAddress); if ($emailAccount) { $smtpParams = $emailAccountService->getSmtpParamsFromAccount($emailAccount); if ($smtpParams) { diff --git a/application/Espo/Services/EmailAccount.php b/application/Espo/Services/EmailAccount.php index 5a3c322558..a1e53dbe8b 100644 --- a/application/Espo/Services/EmailAccount.php +++ b/application/Espo/Services/EmailAccount.php @@ -320,8 +320,8 @@ class EmailAccount extends Record } if (!empty($email)) { + $this->getEntityManager()->getRepository('EmailAccount')->relate($emailAccount, 'emails', $email); if (!$email->isFetched()) { - $this->getEntityManager()->getRepository('EmailAccount')->relate($emailAccount, 'emails', $email); $this->noteAboutEmail($email); } } @@ -368,6 +368,9 @@ class EmailAccount extends Record $email = $importer->importMessage($parserName, $message, $userId, $teamIdList, $userIdList, $filterCollection, $fetchOnlyHeader, $folderData); } catch (\Exception $e) { $GLOBALS['log']->error('EmailAccount '.$emailAccount->id.' (Import Message w/ '.$parserName.'): [' . $e->getCode() . '] ' .$e->getMessage()); + if ($e->getCode() === 'HY000' && strpos($e->getMessage(), '1100') !== false) { + $this->getEntityManager()->getPdo()->query('UNLOCK TABLES'); + } } return $email; } diff --git a/application/Espo/Services/InboundEmail.php b/application/Espo/Services/InboundEmail.php index a902074bf9..3f541d2d48 100644 --- a/application/Espo/Services/InboundEmail.php +++ b/application/Espo/Services/InboundEmail.php @@ -394,6 +394,9 @@ class InboundEmail extends \Espo\Services\Record $email = $importer->importMessage($parserName, $message, $userId, $teamIdList, $userIdList, $filterCollection, $fetchOnlyHeader, $folderData); } catch (\Exception $e) { $GLOBALS['log']->error('InboundEmail '.$emailAccount->id.' (Import Message w/ '.$parserName.'): [' . $e->getCode() . '] ' .$e->getMessage()); + if ($e->getCode() === 'HY000' && strpos($e->getMessage(), '1100') !== false) { + $this->getEntityManager()->getPdo()->query('UNLOCK TABLES'); + } } return $email; } diff --git a/application/Espo/Services/Record.php b/application/Espo/Services/Record.php index d77482e4fd..7e09fbfd4b 100644 --- a/application/Espo/Services/Record.php +++ b/application/Espo/Services/Record.php @@ -282,13 +282,10 @@ class Record extends \Espo\Core\Services\Base $fieldDefs = $this->getMetadata()->get('entityDefs.' . $entity->getEntityType() . '.fields', array()); foreach ($fieldDefs as $field => $defs) { if (isset($defs['type']) && $defs['type'] == 'linkParent') { - $id = $entity->get($field . 'Id'); - $scope = $entity->get($field . 'Type'); - - if ($scope) { - if ($foreignEntity = $this->getEntityManager()->getEntity($scope, $id)) { - $entity->set($field . 'Name', $foreignEntity->get('name')); - } + $parentId = $entity->get($field . 'Id'); + $parentType = $entity->get($field . 'Type'); + if ($parentId && $parentType) { + $entity->loadParentNameField($field); } } } @@ -1065,7 +1062,7 @@ class Record extends \Espo\Core\Services\Base $ids = $params['ids']; foreach ($ids as $id) { $entity = $this->getEntity($id); - if ($this->getAcl()->check($entity, 'edit')) { + if ($this->getAcl()->check($entity, 'edit') && $this->checkEntityForMassUpdate($entity, $data)) { $entity->set($data); if ($this->checkAssignment($entity)) { if ($repository->save($entity)) { @@ -1095,7 +1092,7 @@ class Record extends \Espo\Core\Services\Base $collection = $repository->find($selectParams); foreach ($collection as $entity) { - if ($this->getAcl()->check($entity, 'edit')) { + if ($this->getAcl()->check($entity, 'edit') && $this->checkEntityForMassUpdate($entity, $data)) { $entity->set($data); if ($this->checkAssignment($entity)) { if ($repository->save($entity)) { @@ -1128,6 +1125,11 @@ class Record extends \Espo\Core\Services\Base return true; } + protected function checkEntityForMassUpdate(Entity $entity, $data) + { + return true; + } + public function massRemove(array $params) { $idsRemoved = array(); @@ -1306,14 +1308,16 @@ class Record extends \Espo\Core\Services\Base return false; } - public function checkAttributeIsAllowedForExport($entity, $attribute) + public function checkAttributeIsAllowedForExport($entity, $attribute, $isExportAllFields = false) { $entity = $this->getEntityManager()->getEntity($this->getEntityType()); if (in_array($attribute, $this->internalAttributeList)) { return false; } - + if (!$isExportAllFields) { + return true; + } $isNotStorable = $entity->getAttributeParam($attribute, 'notStorable'); if (!$isNotStorable) { return true; @@ -1441,7 +1445,7 @@ class Record extends \Espo\Core\Services\Base if (in_array($attribute, $attributeListToSkip)) { continue; } - if ($this->checkAttributeIsAllowedForExport($seed, $attribute)) { + if ($this->checkAttributeIsAllowedForExport($seed, $attribute, true)) { $attributeList[] = $attribute; } } diff --git a/application/Espo/Services/User.php b/application/Espo/Services/User.php index 80afe2b1ca..6c780ce7f2 100644 --- a/application/Espo/Services/User.php +++ b/application/Espo/Services/User.php @@ -212,6 +212,15 @@ class User extends Record return $passwordHash->hash($password); } + protected function filterInput($data) + { + parent::filterInput($data); + + if (!$this->getUser()->get('isSuperAdmin')) { + unset($data->isSuperAdmin); + } + } + public function createEntity($data) { $newPassword = null; @@ -219,9 +228,6 @@ class User extends Record $newPassword = $data->password; $data->password = $this->hashPassword($data->password); } - if (!$this->getUser()->get('isSuperAdmin')) { - unset($data->isSuperAdmin); - } $user = parent::createEntity($data); @@ -251,9 +257,6 @@ class User extends Record unset($data->isActive); unset($data->isPortalUser); } - if (!$this->getUser()->get('isSuperAdmin')) { - unset($data->isSuperAdmin); - } $user = parent::updateEntity($id, $data); @@ -470,6 +473,25 @@ class User extends Record return true; } + protected function checkEntityForMassUpdate(Entity $entity, $data) + { + if ($entity->id == 'system') { + return false; + } + if ($entity->id == $this->getUser()->id) { + if (property_exists($data, 'isActive')) { + return false; + } + if (property_exists($data, 'isPortalUser')) { + return false; + } + if (property_exists($data, 'isSuperAdmin')) { + return false; + } + } + return true; + } + public function afterUpdate(Entity $entity, array $data = array()) { parent::afterUpdate($entity, $data); diff --git a/client/lib/backbone-min.js b/client/lib/backbone-min.js index 1a1f708dad..f246b10d65 100644 --- a/client/lib/backbone-min.js +++ b/client/lib/backbone-min.js @@ -1,2 +1,1920 @@ -(function(t){var e=typeof self=="object"&&self.self==self&&self||typeof global=="object"&&global.global==global&&global;if(typeof define==="function"&&define.amd){define(["underscore","jquery","exports"],function(i,r,n){e.Backbone=t(e,n,i,r)})}else if(typeof exports!=="undefined"){var i=require("underscore"),r;try{r=require("jquery")}catch(n){}t(e,exports,i,r)}else{e.Backbone=t(e,{},e._,e.jQuery||e.Zepto||e.ender||e.$)}})(function(t,e,i,r){var n=t.Backbone;var s=Array.prototype.slice;e.VERSION="1.2.3";e.$=r;e.noConflict=function(){t.Backbone=n;return this};e.emulateHTTP=false;e.emulateJSON=false;var a=function(t,e,r){switch(t){case 1:return function(){return i[e](this[r])};case 2:return function(t){return i[e](this[r],t)};case 3:return function(t,n){return i[e](this[r],h(t,this),n)};case 4:return function(t,n,s){return i[e](this[r],h(t,this),n,s)};default:return function(){var t=s.call(arguments);t.unshift(this[r]);return i[e].apply(i,t)}}};var o=function(t,e,r){i.each(e,function(e,n){if(i[n])t.prototype[n]=a(e,n,r)})};var h=function(t,e){if(i.isFunction(t))return t;if(i.isObject(t)&&!e._isModel(t))return u(t);if(i.isString(t))return function(e){return e.get(t)};return t};var u=function(t){var e=i.matches(t);return function(t){return e(t.attributes)}};var l=e.Events={};var c=/\s+/;var f=function(t,e,r,n,s){var a=0,o;if(r&&typeof r==="object"){if(n!==void 0&&"context"in s&&s.context===void 0)s.context=n;for(o=i.keys(r);a7);this._useHashChange=this._wantsHashChange&&this._hasHashChange;this._wantsPushState=!!this.options.pushState;this._hasPushState=!!(this.history&&this.history.pushState);this._usePushState=this._wantsPushState&&this._hasPushState;this.fragment=this.getFragment();this.root=("/"+this.root+"/").replace(O,"/");if(this._wantsHashChange&&this._wantsPushState){if(!this._hasPushState&&!this.atRoot()){var e=this.root.slice(0,-1)||"/";this.location.replace(e+"#"+this.getPath());return true}else if(this._hasPushState&&this.atRoot()){this.navigate(this.getHash(),{replace:true})}}if(!this._hasHashChange&&this._wantsHashChange&&!this._usePushState){this.iframe=document.createElement("iframe");this.iframe.src="javascript:0";this.iframe.style.display="none";this.iframe.tabIndex=-1;var r=document.body;var n=r.insertBefore(this.iframe,r.firstChild).contentWindow;n.document.open();n.document.close();n.location.hash="#"+this.fragment}var s=window.addEventListener||function(t,e){return attachEvent("on"+t,e)};if(this._usePushState){s("popstate",this.checkUrl,false)}else if(this._useHashChange&&!this.iframe){s("hashchange",this.checkUrl,false)}else if(this._wantsHashChange){this._checkUrlInterval=setInterval(this.checkUrl,this.interval)}if(!this.options.silent)return this.loadUrl()},stop:function(){var t=window.removeEventListener||function(t,e){return detachEvent("on"+t,e)};if(this._usePushState){t("popstate",this.checkUrl,false)}else if(this._useHashChange&&!this.iframe){t("hashchange",this.checkUrl,false)}if(this.iframe){document.body.removeChild(this.iframe);this.iframe=null}if(this._checkUrlInterval)clearInterval(this._checkUrlInterval);M.started=false},route:function(t,e){this.handlers.unshift({route:t,callback:e})},checkUrl:function(t){var e=this.getFragment();if(e===this.fragment&&this.iframe){e=this.getHash(this.iframe.contentWindow)}if(e===this.fragment)return false;if(this.iframe)this.navigate(e);this.loadUrl()},loadUrl:function(t){if(!this.matchRoot())return false;t=this.fragment=this.getFragment(t);return i.some(this.handlers,function(e){if(e.route.test(t)){e.callback(t);return true}})},navigate:function(t,e){if(!M.started)return false;if(!e||e===true)e={trigger:!!e};t=this.getFragment(t||"");var i=this.root;if(t===""||t.charAt(0)==="?"){i=i.slice(0,-1)||"/"}var r=i+t;t=this.decodeFragment(t.replace(U,""));if(this.fragment===t)return;this.fragment=t;if(this._usePushState){this.history[e.replace?"replaceState":"pushState"]({},document.title,r)}else if(this._wantsHashChange){this._updateHash(this.location,t,e.replace);if(this.iframe&&t!==this.getHash(this.iframe.contentWindow)){var n=this.iframe.contentWindow;if(!e.replace){n.document.open();n.document.close()}this._updateHash(n.location,t,e.replace)}}else{return this.location.assign(r)}if(e.trigger)return this.loadUrl(t)},_updateHash:function(t,e,i){if(i){var r=t.href.replace(/(javascript:|#).*$/,"");t.replace(r+"#"+e)}else{t.hash="#"+e}}});e.history=new M;var q=function(t,e){var r=this;var n;if(t&&i.has(t,"constructor")){n=t.constructor}else{n=function(){return r.apply(this,arguments)}}i.extend(n,r,e);var s=function(){this.constructor=n};s.prototype=r.prototype;n.prototype=new s;if(t)i.extend(n.prototype,t);n.__super__=r.prototype;return n};y.extend=x.extend=$.extend=I.extend=M.extend=q;var F=function(){throw new Error('A "url" property or function must be specified')};var z=function(t,e){var i=e.error;e.error=function(r){if(i)i.call(e.context,t,r,e);t.trigger("error",t,r,e)}};return e}); -//# sourceMappingURL=backbone-min.map \ No newline at end of file +// Backbone.js 1.3.3 + +// (c) 2010-2016 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors +// Backbone may be freely distributed under the MIT license. +// For all details and documentation: +// http://backbonejs.org + +(function(factory) { + + // Establish the root object, `window` (`self`) in the browser, or `global` on the server. + // We use `self` instead of `window` for `WebWorker` support. + var root = (typeof self == 'object' && self.self === self && self) || + (typeof global == 'object' && global.global === global && global); + + // Set up Backbone appropriately for the environment. Start with AMD. + if (typeof define === 'function' && define.amd) { + define(['underscore', 'jquery', 'exports'], function(_, $, exports) { + // Export global even in AMD case in case this script is loaded with + // others that may still expect a global Backbone. + root.Backbone = factory(root, exports, _, $); + }); + + // Next for Node.js or CommonJS. jQuery may not be needed as a module. + } else if (typeof exports !== 'undefined') { + var _ = require('underscore'), $; + try { $ = require('jquery'); } catch (e) {} + factory(root, exports, _, $); + + // Finally, as a browser global. + } else { + root.Backbone = factory(root, {}, root._, (root.jQuery || root.Zepto || root.ender || root.$)); + } + +})(function(root, Backbone, _, $) { + + // Initial Setup + // ------------- + + // Save the previous value of the `Backbone` variable, so that it can be + // restored later on, if `noConflict` is used. + var previousBackbone = root.Backbone; + + // Create a local reference to a common array method we'll want to use later. + var slice = Array.prototype.slice; + + // Current version of the library. Keep in sync with `package.json`. + Backbone.VERSION = '1.3.3'; + + // For Backbone's purposes, jQuery, Zepto, Ender, or My Library (kidding) owns + // the `$` variable. + Backbone.$ = $; + + // Runs Backbone.js in *noConflict* mode, returning the `Backbone` variable + // to its previous owner. Returns a reference to this Backbone object. + Backbone.noConflict = function() { + root.Backbone = previousBackbone; + return this; + }; + + // Turn on `emulateHTTP` to support legacy HTTP servers. Setting this option + // will fake `"PATCH"`, `"PUT"` and `"DELETE"` requests via the `_method` parameter and + // set a `X-Http-Method-Override` header. + Backbone.emulateHTTP = false; + + // Turn on `emulateJSON` to support legacy servers that can't deal with direct + // `application/json` requests ... this will encode the body as + // `application/x-www-form-urlencoded` instead and will send the model in a + // form param named `model`. + Backbone.emulateJSON = false; + + // Proxy Backbone class methods to Underscore functions, wrapping the model's + // `attributes` object or collection's `models` array behind the scenes. + // + // collection.filter(function(model) { return model.get('age') > 10 }); + // collection.each(this.addView); + // + // `Function#apply` can be slow so we use the method's arg count, if we know it. + var addMethod = function(length, method, attribute) { + switch (length) { + case 1: return function() { + return _[method](this[attribute]); + }; + case 2: return function(value) { + return _[method](this[attribute], value); + }; + case 3: return function(iteratee, context) { + return _[method](this[attribute], cb(iteratee, this), context); + }; + case 4: return function(iteratee, defaultVal, context) { + return _[method](this[attribute], cb(iteratee, this), defaultVal, context); + }; + default: return function() { + var args = slice.call(arguments); + args.unshift(this[attribute]); + return _[method].apply(_, args); + }; + } + }; + var addUnderscoreMethods = function(Class, methods, attribute) { + _.each(methods, function(length, method) { + if (_[method]) Class.prototype[method] = addMethod(length, method, attribute); + }); + }; + + // Support `collection.sortBy('attr')` and `collection.findWhere({id: 1})`. + var cb = function(iteratee, instance) { + if (_.isFunction(iteratee)) return iteratee; + if (_.isObject(iteratee) && !instance._isModel(iteratee)) return modelMatcher(iteratee); + if (_.isString(iteratee)) return function(model) { return model.get(iteratee); }; + return iteratee; + }; + var modelMatcher = function(attrs) { + var matcher = _.matches(attrs); + return function(model) { + return matcher(model.attributes); + }; + }; + + // Backbone.Events + // --------------- + + // A module that can be mixed in to *any object* in order to provide it with + // a custom event channel. You may bind a callback to an event with `on` or + // remove with `off`; `trigger`-ing an event fires all callbacks in + // succession. + // + // var object = {}; + // _.extend(object, Backbone.Events); + // object.on('expand', function(){ alert('expanded'); }); + // object.trigger('expand'); + // + var Events = Backbone.Events = {}; + + // Regular expression used to split event strings. + var eventSplitter = /\s+/; + + // Iterates over the standard `event, callback` (as well as the fancy multiple + // space-separated events `"change blur", callback` and jQuery-style event + // maps `{event: callback}`). + var eventsApi = function(iteratee, events, name, callback, opts) { + var i = 0, names; + if (name && typeof name === 'object') { + // Handle event maps. + if (callback !== void 0 && 'context' in opts && opts.context === void 0) opts.context = callback; + for (names = _.keys(name); i < names.length ; i++) { + events = eventsApi(iteratee, events, names[i], name[names[i]], opts); + } + } else if (name && eventSplitter.test(name)) { + // Handle space-separated event names by delegating them individually. + for (names = name.split(eventSplitter); i < names.length; i++) { + events = iteratee(events, names[i], callback, opts); + } + } else { + // Finally, standard events. + events = iteratee(events, name, callback, opts); + } + return events; + }; + + // Bind an event to a `callback` function. Passing `"all"` will bind + // the callback to all events fired. + Events.on = function(name, callback, context) { + return internalOn(this, name, callback, context); + }; + + // Guard the `listening` argument from the public API. + var internalOn = function(obj, name, callback, context, listening) { + obj._events = eventsApi(onApi, obj._events || {}, name, callback, { + context: context, + ctx: obj, + listening: listening + }); + + if (listening) { + var listeners = obj._listeners || (obj._listeners = {}); + listeners[listening.id] = listening; + } + + return obj; + }; + + // Inversion-of-control versions of `on`. Tell *this* object to listen to + // an event in another object... keeping track of what it's listening to + // for easier unbinding later. + Events.listenTo = function(obj, name, callback) { + if (!obj) return this; + var id = obj._listenId || (obj._listenId = _.uniqueId('l')); + var listeningTo = this._listeningTo || (this._listeningTo = {}); + var listening = listeningTo[id]; + + // This object is not listening to any other events on `obj` yet. + // Setup the necessary references to track the listening callbacks. + if (!listening) { + var thisId = this._listenId || (this._listenId = _.uniqueId('l')); + listening = listeningTo[id] = {obj: obj, objId: id, id: thisId, listeningTo: listeningTo, count: 0}; + } + + // Bind callbacks on obj, and keep track of them on listening. + internalOn(obj, name, callback, this, listening); + return this; + }; + + // The reducing API that adds a callback to the `events` object. + var onApi = function(events, name, callback, options) { + if (callback) { + var handlers = events[name] || (events[name] = []); + var context = options.context, ctx = options.ctx, listening = options.listening; + if (listening) listening.count++; + + handlers.push({callback: callback, context: context, ctx: context || ctx, listening: listening}); + } + return events; + }; + + // Remove one or many callbacks. If `context` is null, removes all + // callbacks with that function. If `callback` is null, removes all + // callbacks for the event. If `name` is null, removes all bound + // callbacks for all events. + Events.off = function(name, callback, context) { + if (!this._events) return this; + this._events = eventsApi(offApi, this._events, name, callback, { + context: context, + listeners: this._listeners + }); + return this; + }; + + // Tell this object to stop listening to either specific events ... or + // to every object it's currently listening to. + Events.stopListening = function(obj, name, callback) { + var listeningTo = this._listeningTo; + if (!listeningTo) return this; + + var ids = obj ? [obj._listenId] : _.keys(listeningTo); + + for (var i = 0; i < ids.length; i++) { + var listening = listeningTo[ids[i]]; + + // If listening doesn't exist, this object is not currently + // listening to obj. Break out early. + if (!listening) break; + + listening.obj.off(name, callback, this); + } + + return this; + }; + + // The reducing API that removes a callback from the `events` object. + var offApi = function(events, name, callback, options) { + if (!events) return; + + var i = 0, listening; + var context = options.context, listeners = options.listeners; + + // Delete all events listeners and "drop" events. + if (!name && !callback && !context) { + var ids = _.keys(listeners); + for (; i < ids.length; i++) { + listening = listeners[ids[i]]; + delete listeners[listening.id]; + delete listening.listeningTo[listening.objId]; + } + return; + } + + var names = name ? [name] : _.keys(events); + for (; i < names.length; i++) { + name = names[i]; + var handlers = events[name]; + + // Bail out if there are no events stored. + if (!handlers) break; + + // Replace events if there are any remaining. Otherwise, clean up. + var remaining = []; + for (var j = 0; j < handlers.length; j++) { + var handler = handlers[j]; + if ( + callback && callback !== handler.callback && + callback !== handler.callback._callback || + context && context !== handler.context + ) { + remaining.push(handler); + } else { + listening = handler.listening; + if (listening && --listening.count === 0) { + delete listeners[listening.id]; + delete listening.listeningTo[listening.objId]; + } + } + } + + // Update tail event if the list has any events. Otherwise, clean up. + if (remaining.length) { + events[name] = remaining; + } else { + delete events[name]; + } + } + return events; + }; + + // Bind an event to only be triggered a single time. After the first time + // the callback is invoked, its listener will be removed. If multiple events + // are passed in using the space-separated syntax, the handler will fire + // once for each event, not once for a combination of all events. + Events.once = function(name, callback, context) { + // Map the event into a `{event: once}` object. + var events = eventsApi(onceMap, {}, name, callback, _.bind(this.off, this)); + if (typeof name === 'string' && context == null) callback = void 0; + return this.on(events, callback, context); + }; + + // Inversion-of-control versions of `once`. + Events.listenToOnce = function(obj, name, callback) { + // Map the event into a `{event: once}` object. + var events = eventsApi(onceMap, {}, name, callback, _.bind(this.stopListening, this, obj)); + return this.listenTo(obj, events); + }; + + // Reduces the event callbacks into a map of `{event: onceWrapper}`. + // `offer` unbinds the `onceWrapper` after it has been called. + var onceMap = function(map, name, callback, offer) { + if (callback) { + var once = map[name] = _.once(function() { + offer(name, once); + callback.apply(this, arguments); + }); + once._callback = callback; + } + return map; + }; + + // Trigger one or many events, firing all bound callbacks. Callbacks are + // passed the same arguments as `trigger` is, apart from the event name + // (unless you're listening on `"all"`, which will cause your callback to + // receive the true name of the event as the first argument). + Events.trigger = function(name) { + if (!this._events) return this; + + var length = Math.max(0, arguments.length - 1); + var args = Array(length); + for (var i = 0; i < length; i++) args[i] = arguments[i + 1]; + + eventsApi(triggerApi, this._events, name, void 0, args); + return this; + }; + + // Handles triggering the appropriate event callbacks. + var triggerApi = function(objEvents, name, callback, args) { + if (objEvents) { + var events = objEvents[name]; + var allEvents = objEvents.all; + if (events && allEvents) allEvents = allEvents.slice(); + if (events) triggerEvents(events, args); + if (allEvents) triggerEvents(allEvents, [name].concat(args)); + } + return objEvents; + }; + + // A difficult-to-believe, but optimized internal dispatch function for + // triggering events. Tries to keep the usual cases speedy (most internal + // Backbone events have 3 arguments). + var triggerEvents = function(events, args) { + var ev, i = -1, l = events.length, a1 = args[0], a2 = args[1], a3 = args[2]; + switch (args.length) { + case 0: while (++i < l) (ev = events[i]).callback.call(ev.ctx); return; + case 1: while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1); return; + case 2: while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1, a2); return; + case 3: while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1, a2, a3); return; + default: while (++i < l) (ev = events[i]).callback.apply(ev.ctx, args); return; + } + }; + + // Aliases for backwards compatibility. + Events.bind = Events.on; + Events.unbind = Events.off; + + // Allow the `Backbone` object to serve as a global event bus, for folks who + // want global "pubsub" in a convenient place. + _.extend(Backbone, Events); + + // Backbone.Model + // -------------- + + // Backbone **Models** are the basic data object in the framework -- + // frequently representing a row in a table in a database on your server. + // A discrete chunk of data and a bunch of useful, related methods for + // performing computations and transformations on that data. + + // Create a new model with the specified attributes. A client id (`cid`) + // is automatically generated and assigned for you. + var Model = Backbone.Model = function(attributes, options) { + var attrs = attributes || {}; + options || (options = {}); + this.cid = _.uniqueId(this.cidPrefix); + this.attributes = {}; + if (options.collection) this.collection = options.collection; + if (options.parse) attrs = this.parse(attrs, options) || {}; + var defaults = _.result(this, 'defaults'); + attrs = _.defaults(_.extend({}, defaults, attrs), defaults); + this.set(attrs, options); + this.changed = {}; + this.initialize.apply(this, arguments); + }; + + // Attach all inheritable methods to the Model prototype. + _.extend(Model.prototype, Events, { + + // A hash of attributes whose current and previous value differ. + changed: null, + + // The value returned during the last failed validation. + validationError: null, + + // The default name for the JSON `id` attribute is `"id"`. MongoDB and + // CouchDB users may want to set this to `"_id"`. + idAttribute: 'id', + + // The prefix is used to create the client id which is used to identify models locally. + // You may want to override this if you're experiencing name clashes with model ids. + cidPrefix: 'c', + + // Initialize is an empty function by default. Override it with your own + // initialization logic. + initialize: function(){}, + + // Return a copy of the model's `attributes` object. + toJSON: function(options) { + return _.clone(this.attributes); + }, + + // Proxy `Backbone.sync` by default -- but override this if you need + // custom syncing semantics for *this* particular model. + sync: function() { + return Backbone.sync.apply(this, arguments); + }, + + // Get the value of an attribute. + get: function(attr) { + return this.attributes[attr]; + }, + + // Get the HTML-escaped value of an attribute. + escape: function(attr) { + return _.escape(this.get(attr)); + }, + + // Returns `true` if the attribute contains a value that is not null + // or undefined. + has: function(attr) { + return this.get(attr) != null; + }, + + // Special-cased proxy to underscore's `_.matches` method. + matches: function(attrs) { + return !!_.iteratee(attrs, this)(this.attributes); + }, + + // Set a hash of model attributes on the object, firing `"change"`. This is + // the core primitive operation of a model, updating the data and notifying + // anyone who needs to know about the change in state. The heart of the beast. + set: function(key, val, options) { + if (key == null) return this; + + // Handle both `"key", value` and `{key: value}` -style arguments. + var attrs; + if (typeof key === 'object') { + attrs = key; + options = val; + } else { + (attrs = {})[key] = val; + } + + options || (options = {}); + + // Run validation. + if (!this._validate(attrs, options)) return false; + + // Extract attributes and options. + var unset = options.unset; + var silent = options.silent; + var changes = []; + var changing = this._changing; + this._changing = true; + + if (!changing) { + this._previousAttributes = _.clone(this.attributes); + this.changed = {}; + } + + var current = this.attributes; + var changed = this.changed; + var prev = this._previousAttributes; + + // For each `set` attribute, update or delete the current value. + for (var attr in attrs) { + val = attrs[attr]; + if (!_.isEqual(current[attr], val)) changes.push(attr); + if (!_.isEqual(prev[attr], val)) { + changed[attr] = val; + } else { + delete changed[attr]; + } + unset ? delete current[attr] : current[attr] = val; + } + + // Update the `id`. + if (this.idAttribute in attrs) this.id = this.get(this.idAttribute); + + // Trigger all relevant attribute changes. + if (!silent) { + if (changes.length) this._pending = options; + for (var i = 0; i < changes.length; i++) { + this.trigger('change:' + changes[i], this, current[changes[i]], options); + } + } + + // You might be wondering why there's a `while` loop here. Changes can + // be recursively nested within `"change"` events. + if (changing) return this; + if (!silent) { + while (this._pending) { + options = this._pending; + this._pending = false; + this.trigger('change', this, options); + } + } + this._pending = false; + this._changing = false; + return this; + }, + + // Remove an attribute from the model, firing `"change"`. `unset` is a noop + // if the attribute doesn't exist. + unset: function(attr, options) { + return this.set(attr, void 0, _.extend({}, options, {unset: true})); + }, + + // Clear all attributes on the model, firing `"change"`. + clear: function(options) { + var attrs = {}; + for (var key in this.attributes) attrs[key] = void 0; + return this.set(attrs, _.extend({}, options, {unset: true})); + }, + + // Determine if the model has changed since the last `"change"` event. + // If you specify an attribute name, determine if that attribute has changed. + hasChanged: function(attr) { + if (attr == null) return !_.isEmpty(this.changed); + return _.has(this.changed, attr); + }, + + // Return an object containing all the attributes that have changed, or + // false if there are no changed attributes. Useful for determining what + // parts of a view need to be updated and/or what attributes need to be + // persisted to the server. Unset attributes will be set to undefined. + // You can also pass an attributes object to diff against the model, + // determining if there *would be* a change. + changedAttributes: function(diff) { + if (!diff) return this.hasChanged() ? _.clone(this.changed) : false; + var old = this._changing ? this._previousAttributes : this.attributes; + var changed = {}; + for (var attr in diff) { + var val = diff[attr]; + if (_.isEqual(old[attr], val)) continue; + changed[attr] = val; + } + return _.size(changed) ? changed : false; + }, + + // Get the previous value of an attribute, recorded at the time the last + // `"change"` event was fired. + previous: function(attr) { + if (attr == null || !this._previousAttributes) return null; + return this._previousAttributes[attr]; + }, + + // Get all of the attributes of the model at the time of the previous + // `"change"` event. + previousAttributes: function() { + return _.clone(this._previousAttributes); + }, + + // Fetch the model from the server, merging the response with the model's + // local attributes. Any changed attributes will trigger a "change" event. + fetch: function(options) { + options = _.extend({parse: true}, options); + var model = this; + var success = options.success; + options.success = function(resp) { + var serverAttrs = options.parse ? model.parse(resp, options) : resp; + if (!model.set(serverAttrs, options)) return false; + if (success) success.call(options.context, model, resp, options); + model.trigger('sync', model, resp, options); + }; + wrapError(this, options); + return this.sync('read', this, options); + }, + + // Set a hash of model attributes, and sync the model to the server. + // If the server returns an attributes hash that differs, the model's + // state will be `set` again. + save: function(key, val, options) { + // Handle both `"key", value` and `{key: value}` -style arguments. + var attrs; + if (key == null || typeof key === 'object') { + attrs = key; + options = val; + } else { + (attrs = {})[key] = val; + } + + options = _.extend({validate: true, parse: true}, options); + var wait = options.wait; + + // If we're not waiting and attributes exist, save acts as + // `set(attr).save(null, opts)` with validation. Otherwise, check if + // the model will be valid when the attributes, if any, are set. + if (attrs && !wait) { + if (!this.set(attrs, options)) return false; + } else if (!this._validate(attrs, options)) { + return false; + } + + // After a successful server-side save, the client is (optionally) + // updated with the server-side state. + var model = this; + var success = options.success; + var attributes = this.attributes; + options.success = function(resp) { + // Ensure attributes are restored during synchronous saves. + model.attributes = attributes; + var serverAttrs = options.parse ? model.parse(resp, options) : resp; + if (wait) serverAttrs = _.extend({}, attrs, serverAttrs); + if (serverAttrs && !model.set(serverAttrs, options)) return false; + if (success) success.call(options.context, model, resp, options); + model.trigger('sync', model, resp, options); + }; + wrapError(this, options); + + // Set temporary attributes if `{wait: true}` to properly find new ids. + if (attrs && wait) this.attributes = _.extend({}, attributes, attrs); + + var method = this.isNew() ? 'create' : (options.patch ? 'patch' : 'update'); + if (method === 'patch' && !options.attrs) options.attrs = attrs; + var xhr = this.sync(method, this, options); + + // Restore attributes. + this.attributes = attributes; + + return xhr; + }, + + // Destroy this model on the server if it was already persisted. + // Optimistically removes the model from its collection, if it has one. + // If `wait: true` is passed, waits for the server to respond before removal. + destroy: function(options) { + options = options ? _.clone(options) : {}; + var model = this; + var success = options.success; + var wait = options.wait; + + var destroy = function() { + model.stopListening(); + model.trigger('destroy', model, model.collection, options); + }; + + options.success = function(resp) { + if (wait) destroy(); + if (success) success.call(options.context, model, resp, options); + if (!model.isNew()) model.trigger('sync', model, resp, options); + }; + + var xhr = false; + if (this.isNew()) { + _.defer(options.success); + } else { + wrapError(this, options); + xhr = this.sync('delete', this, options); + } + if (!wait) destroy(); + return xhr; + }, + + // Default URL for the model's representation on the server -- if you're + // using Backbone's restful methods, override this to change the endpoint + // that will be called. + url: function() { + var base = + _.result(this, 'urlRoot') || + _.result(this.collection, 'url') || + urlError(); + if (this.isNew()) return base; + var id = this.get(this.idAttribute); + return base.replace(/[^\/]$/, '$&/') + encodeURIComponent(id); + }, + + // **parse** converts a response into the hash of attributes to be `set` on + // the model. The default implementation is just to pass the response along. + parse: function(resp, options) { + return resp; + }, + + // Create a new model with identical attributes to this one. + clone: function() { + return new this.constructor(this.attributes); + }, + + // A model is new if it has never been saved to the server, and lacks an id. + isNew: function() { + return !this.has(this.idAttribute); + }, + + // Check if the model is currently in a valid state. + isValid: function(options) { + return this._validate({}, _.extend({}, options, {validate: true})); + }, + + // Run validation against the next complete set of model attributes, + // returning `true` if all is well. Otherwise, fire an `"invalid"` event. + _validate: function(attrs, options) { + if (!options.validate || !this.validate) return true; + attrs = _.extend({}, this.attributes, attrs); + var error = this.validationError = this.validate(attrs, options) || null; + if (!error) return true; + this.trigger('invalid', this, error, _.extend(options, {validationError: error})); + return false; + } + + }); + + // Underscore methods that we want to implement on the Model, mapped to the + // number of arguments they take. + var modelMethods = {keys: 1, values: 1, pairs: 1, invert: 1, pick: 0, + omit: 0, chain: 1, isEmpty: 1}; + + // Mix in each Underscore method as a proxy to `Model#attributes`. + addUnderscoreMethods(Model, modelMethods, 'attributes'); + + // Backbone.Collection + // ------------------- + + // If models tend to represent a single row of data, a Backbone Collection is + // more analogous to a table full of data ... or a small slice or page of that + // table, or a collection of rows that belong together for a particular reason + // -- all of the messages in this particular folder, all of the documents + // belonging to this particular author, and so on. Collections maintain + // indexes of their models, both in order, and for lookup by `id`. + + // Create a new **Collection**, perhaps to contain a specific type of `model`. + // If a `comparator` is specified, the Collection will maintain + // its models in sort order, as they're added and removed. + var Collection = Backbone.Collection = function(models, options) { + options || (options = {}); + if (options.model) this.model = options.model; + if (options.comparator !== void 0) this.comparator = options.comparator; + this._reset(); + this.initialize.apply(this, arguments); + if (models) this.reset(models, _.extend({silent: true}, options)); + }; + + // Default options for `Collection#set`. + var setOptions = {add: true, remove: true, merge: true}; + var addOptions = {add: true, remove: false}; + + // Splices `insert` into `array` at index `at`. + var splice = function(array, insert, at) { + at = Math.min(Math.max(at, 0), array.length); + var tail = Array(array.length - at); + var length = insert.length; + var i; + for (i = 0; i < tail.length; i++) tail[i] = array[i + at]; + for (i = 0; i < length; i++) array[i + at] = insert[i]; + for (i = 0; i < tail.length; i++) array[i + length + at] = tail[i]; + }; + + // Define the Collection's inheritable methods. + _.extend(Collection.prototype, Events, { + + // The default model for a collection is just a **Backbone.Model**. + // This should be overridden in most cases. + model: Model, + + // Initialize is an empty function by default. Override it with your own + // initialization logic. + initialize: function(){}, + + // The JSON representation of a Collection is an array of the + // models' attributes. + toJSON: function(options) { + return this.map(function(model) { return model.toJSON(options); }); + }, + + // Proxy `Backbone.sync` by default. + sync: function() { + return Backbone.sync.apply(this, arguments); + }, + + // Add a model, or list of models to the set. `models` may be Backbone + // Models or raw JavaScript objects to be converted to Models, or any + // combination of the two. + add: function(models, options) { + return this.set(models, _.extend({merge: false}, options, addOptions)); + }, + + // Remove a model, or a list of models from the set. + remove: function(models, options) { + options = _.extend({}, options); + var singular = !_.isArray(models); + models = singular ? [models] : models.slice(); + var removed = this._removeModels(models, options); + if (!options.silent && removed.length) { + options.changes = {added: [], merged: [], removed: removed}; + this.trigger('update', this, options); + } + return singular ? removed[0] : removed; + }, + + // Update a collection by `set`-ing a new list of models, adding new ones, + // removing models that are no longer present, and merging models that + // already exist in the collection, as necessary. Similar to **Model#set**, + // the core operation for updating the data contained by the collection. + set: function(models, options) { + if (models == null) return; + + options = _.extend({}, setOptions, options); + if (options.parse && !this._isModel(models)) { + models = this.parse(models, options) || []; + } + + var singular = !_.isArray(models); + models = singular ? [models] : models.slice(); + + var at = options.at; + if (at != null) at = +at; + if (at > this.length) at = this.length; + if (at < 0) at += this.length + 1; + + var set = []; + var toAdd = []; + var toMerge = []; + var toRemove = []; + var modelMap = {}; + + var add = options.add; + var merge = options.merge; + var remove = options.remove; + + var sort = false; + var sortable = this.comparator && at == null && options.sort !== false; + var sortAttr = _.isString(this.comparator) ? this.comparator : null; + + // Turn bare objects into model references, and prevent invalid models + // from being added. + var model, i; + for (i = 0; i < models.length; i++) { + model = models[i]; + + // If a duplicate is found, prevent it from being added and + // optionally merge it into the existing model. + var existing = this.get(model); + if (existing) { + if (merge && model !== existing) { + var attrs = this._isModel(model) ? model.attributes : model; + if (options.parse) attrs = existing.parse(attrs, options); + existing.set(attrs, options); + toMerge.push(existing); + if (sortable && !sort) sort = existing.hasChanged(sortAttr); + } + if (!modelMap[existing.cid]) { + modelMap[existing.cid] = true; + set.push(existing); + } + models[i] = existing; + + // If this is a new, valid model, push it to the `toAdd` list. + } else if (add) { + model = models[i] = this._prepareModel(model, options); + if (model) { + toAdd.push(model); + this._addReference(model, options); + modelMap[model.cid] = true; + set.push(model); + } + } + } + + // Remove stale models. + if (remove) { + for (i = 0; i < this.length; i++) { + model = this.models[i]; + if (!modelMap[model.cid]) toRemove.push(model); + } + if (toRemove.length) this._removeModels(toRemove, options); + } + + // See if sorting is needed, update `length` and splice in new models. + var orderChanged = false; + var replace = !sortable && add && remove; + if (set.length && replace) { + orderChanged = this.length !== set.length || _.some(this.models, function(m, index) { + return m !== set[index]; + }); + this.models.length = 0; + splice(this.models, set, 0); + this.length = this.models.length; + } else if (toAdd.length) { + if (sortable) sort = true; + splice(this.models, toAdd, at == null ? this.length : at); + this.length = this.models.length; + } + + // Silently sort the collection if appropriate. + if (sort) this.sort({silent: true}); + + // Unless silenced, it's time to fire all appropriate add/sort/update events. + if (!options.silent) { + for (i = 0; i < toAdd.length; i++) { + if (at != null) options.index = at + i; + model = toAdd[i]; + model.trigger('add', model, this, options); + } + if (sort || orderChanged) this.trigger('sort', this, options); + if (toAdd.length || toRemove.length || toMerge.length) { + options.changes = { + added: toAdd, + removed: toRemove, + merged: toMerge + }; + this.trigger('update', this, options); + } + } + + // Return the added (or merged) model (or models). + return singular ? models[0] : models; + }, + + // When you have more items than you want to add or remove individually, + // you can reset the entire set with a new list of models, without firing + // any granular `add` or `remove` events. Fires `reset` when finished. + // Useful for bulk operations and optimizations. + reset: function(models, options) { + options = options ? _.clone(options) : {}; + for (var i = 0; i < this.models.length; i++) { + this._removeReference(this.models[i], options); + } + options.previousModels = this.models; + this._reset(); + models = this.add(models, _.extend({silent: true}, options)); + if (!options.silent) this.trigger('reset', this, options); + return models; + }, + + // Add a model to the end of the collection. + push: function(model, options) { + return this.add(model, _.extend({at: this.length}, options)); + }, + + // Remove a model from the end of the collection. + pop: function(options) { + var model = this.at(this.length - 1); + return this.remove(model, options); + }, + + // Add a model to the beginning of the collection. + unshift: function(model, options) { + return this.add(model, _.extend({at: 0}, options)); + }, + + // Remove a model from the beginning of the collection. + shift: function(options) { + var model = this.at(0); + return this.remove(model, options); + }, + + // Slice out a sub-array of models from the collection. + slice: function() { + return slice.apply(this.models, arguments); + }, + + // Get a model from the set by id, cid, model object with id or cid + // properties, or an attributes object that is transformed through modelId. + get: function(obj) { + if (obj == null) return void 0; + return this._byId[obj] || + this._byId[this.modelId(obj.attributes || obj)] || + obj.cid && this._byId[obj.cid]; + }, + + // Returns `true` if the model is in the collection. + has: function(obj) { + return this.get(obj) != null; + }, + + // Get the model at the given index. + at: function(index) { + if (index < 0) index += this.length; + return this.models[index]; + }, + + // Return models with matching attributes. Useful for simple cases of + // `filter`. + where: function(attrs, first) { + return this[first ? 'find' : 'filter'](attrs); + }, + + // Return the first model with matching attributes. Useful for simple cases + // of `find`. + findWhere: function(attrs) { + return this.where(attrs, true); + }, + + // Force the collection to re-sort itself. You don't need to call this under + // normal circumstances, as the set will maintain sort order as each item + // is added. + sort: function(options) { + var comparator = this.comparator; + if (!comparator) throw new Error('Cannot sort a set without a comparator'); + options || (options = {}); + + var length = comparator.length; + if (_.isFunction(comparator)) comparator = _.bind(comparator, this); + + // Run sort based on type of `comparator`. + if (length === 1 || _.isString(comparator)) { + this.models = this.sortBy(comparator); + } else { + this.models.sort(comparator); + } + if (!options.silent) this.trigger('sort', this, options); + return this; + }, + + // Pluck an attribute from each model in the collection. + pluck: function(attr) { + return this.map(attr + ''); + }, + + // Fetch the default set of models for this collection, resetting the + // collection when they arrive. If `reset: true` is passed, the response + // data will be passed through the `reset` method instead of `set`. + fetch: function(options) { + options = _.extend({parse: true}, options); + var success = options.success; + var collection = this; + options.success = function(resp) { + var method = options.reset ? 'reset' : 'set'; + collection[method](resp, options); + if (success) success.call(options.context, collection, resp, options); + collection.trigger('sync', collection, resp, options); + }; + wrapError(this, options); + return this.sync('read', this, options); + }, + + // Create a new instance of a model in this collection. Add the model to the + // collection immediately, unless `wait: true` is passed, in which case we + // wait for the server to agree. + create: function(model, options) { + options = options ? _.clone(options) : {}; + var wait = options.wait; + model = this._prepareModel(model, options); + if (!model) return false; + if (!wait) this.add(model, options); + var collection = this; + var success = options.success; + options.success = function(m, resp, callbackOpts) { + if (wait) collection.add(m, callbackOpts); + if (success) success.call(callbackOpts.context, m, resp, callbackOpts); + }; + model.save(null, options); + return model; + }, + + // **parse** converts a response into a list of models to be added to the + // collection. The default implementation is just to pass it through. + parse: function(resp, options) { + return resp; + }, + + // Create a new collection with an identical list of models as this one. + clone: function() { + return new this.constructor(this.models, { + model: this.model, + comparator: this.comparator + }); + }, + + // Define how to uniquely identify models in the collection. + modelId: function(attrs) { + return attrs[this.model.prototype.idAttribute || 'id']; + }, + + // Private method to reset all internal state. Called when the collection + // is first initialized or reset. + _reset: function() { + this.length = 0; + this.models = []; + this._byId = {}; + }, + + // Prepare a hash of attributes (or other model) to be added to this + // collection. + _prepareModel: function(attrs, options) { + if (this._isModel(attrs)) { + if (!attrs.collection) attrs.collection = this; + return attrs; + } + options = options ? _.clone(options) : {}; + options.collection = this; + var model = new this.model(attrs, options); + if (!model.validationError) return model; + this.trigger('invalid', this, model.validationError, options); + return false; + }, + + // Internal method called by both remove and set. + _removeModels: function(models, options) { + var removed = []; + for (var i = 0; i < models.length; i++) { + var model = this.get(models[i]); + if (!model) continue; + + var index = this.indexOf(model); + this.models.splice(index, 1); + this.length--; + + // Remove references before triggering 'remove' event to prevent an + // infinite loop. #3693 + delete this._byId[model.cid]; + var id = this.modelId(model.attributes); + if (id != null) delete this._byId[id]; + + if (!options.silent) { + options.index = index; + model.trigger('remove', model, this, options); + } + + removed.push(model); + this._removeReference(model, options); + } + return removed; + }, + + // Method for checking whether an object should be considered a model for + // the purposes of adding to the collection. + _isModel: function(model) { + return model instanceof Model; + }, + + // Internal method to create a model's ties to a collection. + _addReference: function(model, options) { + this._byId[model.cid] = model; + var id = this.modelId(model.attributes); + if (id != null) this._byId[id] = model; + model.on('all', this._onModelEvent, this); + }, + + // Internal method to sever a model's ties to a collection. + _removeReference: function(model, options) { + delete this._byId[model.cid]; + var id = this.modelId(model.attributes); + if (id != null) delete this._byId[id]; + if (this === model.collection) delete model.collection; + model.off('all', this._onModelEvent, this); + }, + + // Internal method called every time a model in the set fires an event. + // Sets need to update their indexes when models change ids. All other + // events simply proxy through. "add" and "remove" events that originate + // in other collections are ignored. + _onModelEvent: function(event, model, collection, options) { + if (model) { + if ((event === 'add' || event === 'remove') && collection !== this) return; + if (event === 'destroy') this.remove(model, options); + if (event === 'change') { + var prevId = this.modelId(model.previousAttributes()); + var id = this.modelId(model.attributes); + if (prevId !== id) { + if (prevId != null) delete this._byId[prevId]; + if (id != null) this._byId[id] = model; + } + } + } + this.trigger.apply(this, arguments); + } + + }); + + // Underscore methods that we want to implement on the Collection. + // 90% of the core usefulness of Backbone Collections is actually implemented + // right here: + var collectionMethods = {forEach: 3, each: 3, map: 3, collect: 3, reduce: 0, + foldl: 0, inject: 0, reduceRight: 0, foldr: 0, find: 3, detect: 3, filter: 3, + select: 3, reject: 3, every: 3, all: 3, some: 3, any: 3, include: 3, includes: 3, + contains: 3, invoke: 0, max: 3, min: 3, toArray: 1, size: 1, first: 3, + head: 3, take: 3, initial: 3, rest: 3, tail: 3, drop: 3, last: 3, + without: 0, difference: 0, indexOf: 3, shuffle: 1, lastIndexOf: 3, + isEmpty: 1, chain: 1, sample: 3, partition: 3, groupBy: 3, countBy: 3, + sortBy: 3, indexBy: 3, findIndex: 3, findLastIndex: 3}; + + // Mix in each Underscore method as a proxy to `Collection#models`. + addUnderscoreMethods(Collection, collectionMethods, 'models'); + + // Backbone.View + // ------------- + + // Backbone Views are almost more convention than they are actual code. A View + // is simply a JavaScript object that represents a logical chunk of UI in the + // DOM. This might be a single item, an entire list, a sidebar or panel, or + // even the surrounding frame which wraps your whole app. Defining a chunk of + // UI as a **View** allows you to define your DOM events declaratively, without + // having to worry about render order ... and makes it easy for the view to + // react to specific changes in the state of your models. + + // Creating a Backbone.View creates its initial element outside of the DOM, + // if an existing element is not provided... + var View = Backbone.View = function(options) { + this.cid = _.uniqueId('view'); + _.extend(this, _.pick(options, viewOptions)); + this._ensureElement(); + this.initialize.apply(this, arguments); + }; + + // Cached regex to split keys for `delegate`. + var delegateEventSplitter = /^(\S+)\s*(.*)$/; + + // List of view options to be set as properties. + var viewOptions = ['model', 'collection', 'el', 'id', 'attributes', 'className', 'tagName', 'events']; + + // Set up all inheritable **Backbone.View** properties and methods. + _.extend(View.prototype, Events, { + + // The default `tagName` of a View's element is `"div"`. + tagName: 'div', + + // jQuery delegate for element lookup, scoped to DOM elements within the + // current view. This should be preferred to global lookups where possible. + $: function(selector) { + return this.$el.find(selector); + }, + + // Initialize is an empty function by default. Override it with your own + // initialization logic. + initialize: function(){}, + + // **render** is the core function that your view should override, in order + // to populate its element (`this.el`), with the appropriate HTML. The + // convention is for **render** to always return `this`. + render: function() { + return this; + }, + + // Remove this view by taking the element out of the DOM, and removing any + // applicable Backbone.Events listeners. + remove: function() { + this._removeElement(); + this.stopListening(); + return this; + }, + + // Remove this view's element from the document and all event listeners + // attached to it. Exposed for subclasses using an alternative DOM + // manipulation API. + _removeElement: function() { + this.$el.remove(); + }, + + // Change the view's element (`this.el` property) and re-delegate the + // view's events on the new element. + setElement: function(element) { + this.undelegateEvents(); + this._setElement(element); + this.delegateEvents(); + return this; + }, + + // Creates the `this.el` and `this.$el` references for this view using the + // given `el`. `el` can be a CSS selector or an HTML string, a jQuery + // context or an element. Subclasses can override this to utilize an + // alternative DOM manipulation API and are only required to set the + // `this.el` property. + _setElement: function(el) { + this.$el = el instanceof Backbone.$ ? el : Backbone.$(el); + this.el = this.$el[0]; + }, + + // Set callbacks, where `this.events` is a hash of + // + // *{"event selector": "callback"}* + // + // { + // 'mousedown .title': 'edit', + // 'click .button': 'save', + // 'click .open': function(e) { ... } + // } + // + // pairs. Callbacks will be bound to the view, with `this` set properly. + // Uses event delegation for efficiency. + // Omitting the selector binds the event to `this.el`. + delegateEvents: function(events) { + events || (events = _.result(this, 'events')); + if (!events) return this; + this.undelegateEvents(); + for (var key in events) { + var method = events[key]; + if (!_.isFunction(method)) method = this[method]; + if (!method) continue; + var match = key.match(delegateEventSplitter); + this.delegate(match[1], match[2], _.bind(method, this)); + } + return this; + }, + + // Add a single event listener to the view's element (or a child element + // using `selector`). This only works for delegate-able events: not `focus`, + // `blur`, and not `change`, `submit`, and `reset` in Internet Explorer. + delegate: function(eventName, selector, listener) { + this.$el.on(eventName + '.delegateEvents' + this.cid, selector, listener); + return this; + }, + + // Clears all callbacks previously bound to the view by `delegateEvents`. + // You usually don't need to use this, but may wish to if you have multiple + // Backbone views attached to the same DOM element. + undelegateEvents: function() { + if (this.$el) this.$el.off('.delegateEvents' + this.cid); + return this; + }, + + // A finer-grained `undelegateEvents` for removing a single delegated event. + // `selector` and `listener` are both optional. + undelegate: function(eventName, selector, listener) { + this.$el.off(eventName + '.delegateEvents' + this.cid, selector, listener); + return this; + }, + + // Produces a DOM element to be assigned to your view. Exposed for + // subclasses using an alternative DOM manipulation API. + _createElement: function(tagName) { + return document.createElement(tagName); + }, + + // Ensure that the View has a DOM element to render into. + // If `this.el` is a string, pass it through `$()`, take the first + // matching element, and re-assign it to `el`. Otherwise, create + // an element from the `id`, `className` and `tagName` properties. + _ensureElement: function() { + if (!this.el) { + var attrs = _.extend({}, _.result(this, 'attributes')); + if (this.id) attrs.id = _.result(this, 'id'); + if (this.className) attrs['class'] = _.result(this, 'className'); + this.setElement(this._createElement(_.result(this, 'tagName'))); + this._setAttributes(attrs); + } else { + this.setElement(_.result(this, 'el')); + } + }, + + // Set attributes from a hash on this view's element. Exposed for + // subclasses using an alternative DOM manipulation API. + _setAttributes: function(attributes) { + this.$el.attr(attributes); + } + + }); + + // Backbone.sync + // ------------- + + // Override this function to change the manner in which Backbone persists + // models to the server. You will be passed the type of request, and the + // model in question. By default, makes a RESTful Ajax request + // to the model's `url()`. Some possible customizations could be: + // + // * Use `setTimeout` to batch rapid-fire updates into a single request. + // * Send up the models as XML instead of JSON. + // * Persist models via WebSockets instead of Ajax. + // + // Turn on `Backbone.emulateHTTP` in order to send `PUT` and `DELETE` requests + // as `POST`, with a `_method` parameter containing the true HTTP method, + // as well as all requests with the body as `application/x-www-form-urlencoded` + // instead of `application/json` with the model in a param named `model`. + // Useful when interfacing with server-side languages like **PHP** that make + // it difficult to read the body of `PUT` requests. + Backbone.sync = function(method, model, options) { + var type = methodMap[method]; + + // Default options, unless specified. + _.defaults(options || (options = {}), { + emulateHTTP: Backbone.emulateHTTP, + emulateJSON: Backbone.emulateJSON + }); + + // Default JSON-request options. + var params = {type: type, dataType: 'json'}; + + // Ensure that we have a URL. + if (!options.url) { + params.url = _.result(model, 'url') || urlError(); + } + + // Ensure that we have the appropriate request data. + if (options.data == null && model && (method === 'create' || method === 'update' || method === 'patch')) { + params.contentType = 'application/json'; + params.data = JSON.stringify(options.attrs || model.toJSON(options)); + } + + // For older servers, emulate JSON by encoding the request into an HTML-form. + if (options.emulateJSON) { + params.contentType = 'application/x-www-form-urlencoded'; + params.data = params.data ? {model: params.data} : {}; + } + + // For older servers, emulate HTTP by mimicking the HTTP method with `_method` + // And an `X-HTTP-Method-Override` header. + if (options.emulateHTTP && (type === 'PUT' || type === 'DELETE' || type === 'PATCH')) { + params.type = 'POST'; + if (options.emulateJSON) params.data._method = type; + var beforeSend = options.beforeSend; + options.beforeSend = function(xhr) { + xhr.setRequestHeader('X-HTTP-Method-Override', type); + if (beforeSend) return beforeSend.apply(this, arguments); + }; + } + + // Don't process data on a non-GET request. + if (params.type !== 'GET' && !options.emulateJSON) { + params.processData = false; + } + + // Pass along `textStatus` and `errorThrown` from jQuery. + var error = options.error; + options.error = function(xhr, textStatus, errorThrown) { + options.textStatus = textStatus; + options.errorThrown = errorThrown; + if (error) error.call(options.context, xhr, textStatus, errorThrown); + }; + + // Make the request, allowing the user to override any Ajax options. + var xhr = options.xhr = Backbone.ajax(_.extend(params, options)); + model.trigger('request', model, xhr, options); + return xhr; + }; + + // Map from CRUD to HTTP for our default `Backbone.sync` implementation. + var methodMap = { + 'create': 'POST', + 'update': 'PUT', + 'patch': 'PATCH', + 'delete': 'DELETE', + 'read': 'GET' + }; + + // Set the default implementation of `Backbone.ajax` to proxy through to `$`. + // Override this if you'd like to use a different library. + Backbone.ajax = function() { + return Backbone.$.ajax.apply(Backbone.$, arguments); + }; + + // Backbone.Router + // --------------- + + // Routers map faux-URLs to actions, and fire events when routes are + // matched. Creating a new one sets its `routes` hash, if not set statically. + var Router = Backbone.Router = function(options) { + options || (options = {}); + if (options.routes) this.routes = options.routes; + this._bindRoutes(); + this.initialize.apply(this, arguments); + }; + + // Cached regular expressions for matching named param parts and splatted + // parts of route strings. + var optionalParam = /\((.*?)\)/g; + var namedParam = /(\(\?)?:\w+/g; + var splatParam = /\*\w+/g; + var escapeRegExp = /[\-{}\[\]+?.,\\\^$|#\s]/g; + + // Set up all inheritable **Backbone.Router** properties and methods. + _.extend(Router.prototype, Events, { + + // Initialize is an empty function by default. Override it with your own + // initialization logic. + initialize: function(){}, + + // Manually bind a single named route to a callback. For example: + // + // this.route('search/:query/p:num', 'search', function(query, num) { + // ... + // }); + // + route: function(route, name, callback) { + if (!_.isRegExp(route)) route = this._routeToRegExp(route); + if (_.isFunction(name)) { + callback = name; + name = ''; + } + if (!callback) callback = this[name]; + var router = this; + Backbone.history.route(route, function(fragment) { + var args = router._extractParameters(route, fragment); + if (router.execute(callback, args, name) !== false) { + router.trigger.apply(router, ['route:' + name].concat(args)); + router.trigger('route', name, args); + Backbone.history.trigger('route', router, name, args); + } + }); + return this; + }, + + // Execute a route handler with the provided parameters. This is an + // excellent place to do pre-route setup or post-route cleanup. + execute: function(callback, args, name) { + if (callback) callback.apply(this, args); + }, + + // Simple proxy to `Backbone.history` to save a fragment into the history. + navigate: function(fragment, options) { + Backbone.history.navigate(fragment, options); + return this; + }, + + // Bind all defined routes to `Backbone.history`. We have to reverse the + // order of the routes here to support behavior where the most general + // routes can be defined at the bottom of the route map. + _bindRoutes: function() { + if (!this.routes) return; + this.routes = _.result(this, 'routes'); + var route, routes = _.keys(this.routes); + while ((route = routes.pop()) != null) { + this.route(route, this.routes[route]); + } + }, + + // Convert a route string into a regular expression, suitable for matching + // against the current location hash. + _routeToRegExp: function(route) { + route = route.replace(escapeRegExp, '\\$&') + .replace(optionalParam, '(?:$1)?') + .replace(namedParam, function(match, optional) { + return optional ? match : '([^/?]+)'; + }) + .replace(splatParam, '([^?]*?)'); + return new RegExp('^' + route + '(?:\\?([\\s\\S]*))?$'); + }, + + // Given a route, and a URL fragment that it matches, return the array of + // extracted decoded parameters. Empty or unmatched parameters will be + // treated as `null` to normalize cross-browser behavior. + _extractParameters: function(route, fragment) { + var params = route.exec(fragment).slice(1); + return _.map(params, function(param, i) { + // Don't decode the search params. + if (i === params.length - 1) return param || null; + return param ? decodeURIComponent(param) : null; + }); + } + + }); + + // Backbone.History + // ---------------- + + // Handles cross-browser history management, based on either + // [pushState](http://diveintohtml5.info/history.html) and real URLs, or + // [onhashchange](https://developer.mozilla.org/en-US/docs/DOM/window.onhashchange) + // and URL fragments. If the browser supports neither (old IE, natch), + // falls back to polling. + var History = Backbone.History = function() { + this.handlers = []; + this.checkUrl = _.bind(this.checkUrl, this); + + // Ensure that `History` can be used outside of the browser. + if (typeof window !== 'undefined') { + this.location = window.location; + this.history = window.history; + } + }; + + // Cached regex for stripping a leading hash/slash and trailing space. + var routeStripper = /^[#\/]|\s+$/g; + + // Cached regex for stripping leading and trailing slashes. + var rootStripper = /^\/+|\/+$/g; + + // Cached regex for stripping urls of hash. + var pathStripper = /#.*$/; + + // Has the history handling already been started? + History.started = false; + + // Set up all inheritable **Backbone.History** properties and methods. + _.extend(History.prototype, Events, { + + // The default interval to poll for hash changes, if necessary, is + // twenty times a second. + interval: 50, + + // Are we at the app root? + atRoot: function() { + var path = this.location.pathname.replace(/[^\/]$/, '$&/'); + return path === this.root && !this.getSearch(); + }, + + // Does the pathname match the root? + matchRoot: function() { + var path = this.decodeFragment(this.location.pathname); + var rootPath = path.slice(0, this.root.length - 1) + '/'; + return rootPath === this.root; + }, + + // Unicode characters in `location.pathname` are percent encoded so they're + // decoded for comparison. `%25` should not be decoded since it may be part + // of an encoded parameter. + decodeFragment: function(fragment) { + return decodeURI(fragment.replace(/%25/g, '%2525')); + }, + + // In IE6, the hash fragment and search params are incorrect if the + // fragment contains `?`. + getSearch: function() { + var match = this.location.href.replace(/#.*/, '').match(/\?.+/); + return match ? match[0] : ''; + }, + + // Gets the true hash value. Cannot use location.hash directly due to bug + // in Firefox where location.hash will always be decoded. + getHash: function(window) { + var match = (window || this).location.href.match(/#(.*)$/); + return match ? match[1] : ''; + }, + + // Get the pathname and search params, without the root. + getPath: function() { + var path = this.decodeFragment( + this.location.pathname + this.getSearch() + ).slice(this.root.length - 1); + return path.charAt(0) === '/' ? path.slice(1) : path; + }, + + // Get the cross-browser normalized URL fragment from the path or hash. + getFragment: function(fragment) { + if (fragment == null) { + if (this._usePushState || !this._wantsHashChange) { + fragment = this.getPath(); + } else { + fragment = this.getHash(); + } + } + return fragment.replace(routeStripper, ''); + }, + + // Start the hash change handling, returning `true` if the current URL matches + // an existing route, and `false` otherwise. + start: function(options) { + if (History.started) throw new Error('Backbone.history has already been started'); + History.started = true; + + // Figure out the initial configuration. Do we need an iframe? + // Is pushState desired ... is it available? + this.options = _.extend({root: '/'}, this.options, options); + this.root = this.options.root; + this._wantsHashChange = this.options.hashChange !== false; + this._hasHashChange = 'onhashchange' in window && (document.documentMode === void 0 || document.documentMode > 7); + this._useHashChange = this._wantsHashChange && this._hasHashChange; + this._wantsPushState = !!this.options.pushState; + this._hasPushState = !!(this.history && this.history.pushState); + this._usePushState = this._wantsPushState && this._hasPushState; + this.fragment = this.getFragment(); + + // Normalize root to always include a leading and trailing slash. + this.root = ('/' + this.root + '/').replace(rootStripper, '/'); + + // Transition from hashChange to pushState or vice versa if both are + // requested. + if (this._wantsHashChange && this._wantsPushState) { + + // If we've started off with a route from a `pushState`-enabled + // browser, but we're currently in a browser that doesn't support it... + if (!this._hasPushState && !this.atRoot()) { + var rootPath = this.root.slice(0, -1) || '/'; + this.location.replace(rootPath + '#' + this.getPath()); + // Return immediately as browser will do redirect to new url + return true; + + // Or if we've started out with a hash-based route, but we're currently + // in a browser where it could be `pushState`-based instead... + } else if (this._hasPushState && this.atRoot()) { + this.navigate(this.getHash(), {replace: true}); + } + + } + + // Proxy an iframe to handle location events if the browser doesn't + // support the `hashchange` event, HTML5 history, or the user wants + // `hashChange` but not `pushState`. + if (!this._hasHashChange && this._wantsHashChange && !this._usePushState) { + this.iframe = document.createElement('iframe'); + this.iframe.src = 'javascript:0'; + this.iframe.style.display = 'none'; + this.iframe.tabIndex = -1; + var body = document.body; + // Using `appendChild` will throw on IE < 9 if the document is not ready. + var iWindow = body.insertBefore(this.iframe, body.firstChild).contentWindow; + iWindow.document.open(); + iWindow.document.close(); + iWindow.location.hash = '#' + this.fragment; + } + + // Add a cross-platform `addEventListener` shim for older browsers. + var addEventListener = window.addEventListener || function(eventName, listener) { + return attachEvent('on' + eventName, listener); + }; + + // Depending on whether we're using pushState or hashes, and whether + // 'onhashchange' is supported, determine how we check the URL state. + if (this._usePushState) { + addEventListener('popstate', this.checkUrl, false); + } else if (this._useHashChange && !this.iframe) { + addEventListener('hashchange', this.checkUrl, false); + } else if (this._wantsHashChange) { + this._checkUrlInterval = setInterval(this.checkUrl, this.interval); + } + + if (!this.options.silent) return this.loadUrl(); + }, + + // Disable Backbone.history, perhaps temporarily. Not useful in a real app, + // but possibly useful for unit testing Routers. + stop: function() { + // Add a cross-platform `removeEventListener` shim for older browsers. + var removeEventListener = window.removeEventListener || function(eventName, listener) { + return detachEvent('on' + eventName, listener); + }; + + // Remove window listeners. + if (this._usePushState) { + removeEventListener('popstate', this.checkUrl, false); + } else if (this._useHashChange && !this.iframe) { + removeEventListener('hashchange', this.checkUrl, false); + } + + // Clean up the iframe if necessary. + if (this.iframe) { + document.body.removeChild(this.iframe); + this.iframe = null; + } + + // Some environments will throw when clearing an undefined interval. + if (this._checkUrlInterval) clearInterval(this._checkUrlInterval); + History.started = false; + }, + + // Add a route to be tested when the fragment changes. Routes added later + // may override previous routes. + route: function(route, callback) { + this.handlers.unshift({route: route, callback: callback}); + }, + + // Checks the current URL to see if it has changed, and if it has, + // calls `loadUrl`, normalizing across the hidden iframe. + checkUrl: function(e) { + var current = this.getFragment(); + + // If the user pressed the back button, the iframe's hash will have + // changed and we should use that for comparison. + if (current === this.fragment && this.iframe) { + current = this.getHash(this.iframe.contentWindow); + } + + if (current === this.fragment) return false; + if (this.iframe) this.navigate(current); + this.loadUrl(); + }, + + // Attempt to load the current URL fragment. If a route succeeds with a + // match, returns `true`. If no defined routes matches the fragment, + // returns `false`. + loadUrl: function(fragment) { + // If the root doesn't match, no routes can match either. + if (!this.matchRoot()) return false; + fragment = this.fragment = this.getFragment(fragment); + return _.some(this.handlers, function(handler) { + if (handler.route.test(fragment)) { + handler.callback(fragment); + return true; + } + }); + }, + + // Save a fragment into the hash history, or replace the URL state if the + // 'replace' option is passed. You are responsible for properly URL-encoding + // the fragment in advance. + // + // The options object can contain `trigger: true` if you wish to have the + // route callback be fired (not usually desirable), or `replace: true`, if + // you wish to modify the current URL without adding an entry to the history. + navigate: function(fragment, options) { + if (!History.started) return false; + if (!options || options === true) options = {trigger: !!options}; + + // Normalize the fragment. + fragment = this.getFragment(fragment || ''); + + // Don't include a trailing slash on the root. + var rootPath = this.root; + if (fragment === '' || fragment.charAt(0) === '?') { + rootPath = rootPath.slice(0, -1) || '/'; + } + var url = rootPath + fragment; + + // Strip the hash and decode for matching. + fragment = this.decodeFragment(fragment.replace(pathStripper, '')); + + if (this.fragment === fragment) return; + this.fragment = fragment; + + // If pushState is available, we use it to set the fragment as a real URL. + if (this._usePushState) { + this.history[options.replace ? 'replaceState' : 'pushState']({}, document.title, url); + + // If hash changes haven't been explicitly disabled, update the hash + // fragment to store history. + } else if (this._wantsHashChange) { + this._updateHash(this.location, fragment, options.replace); + if (this.iframe && fragment !== this.getHash(this.iframe.contentWindow)) { + var iWindow = this.iframe.contentWindow; + + // Opening and closing the iframe tricks IE7 and earlier to push a + // history entry on hash-tag change. When replace is true, we don't + // want this. + if (!options.replace) { + iWindow.document.open(); + iWindow.document.close(); + } + + this._updateHash(iWindow.location, fragment, options.replace); + } + + // If you've told us that you explicitly don't want fallback hashchange- + // based history, then `navigate` becomes a page refresh. + } else { + return this.location.assign(url); + } + if (options.trigger) return this.loadUrl(fragment); + }, + + // Update the hash location, either replacing the current entry, or adding + // a new one to the browser history. + _updateHash: function(location, fragment, replace) { + if (replace) { + var href = location.href.replace(/(javascript:|#).*$/, ''); + location.replace(href + '#' + fragment); + } else { + // Some browsers require that `hash` contains a leading #. + location.hash = '#' + fragment; + } + } + + }); + + // Create the default Backbone.history. + Backbone.history = new History; + + // Helpers + // ------- + + // Helper function to correctly set up the prototype chain for subclasses. + // Similar to `goog.inherits`, but uses a hash of prototype properties and + // class properties to be extended. + var extend = function(protoProps, staticProps) { + var parent = this; + var child; + + // The constructor function for the new subclass is either defined by you + // (the "constructor" property in your `extend` definition), or defaulted + // by us to simply call the parent constructor. + if (protoProps && _.has(protoProps, 'constructor')) { + child = protoProps.constructor; + } else { + child = function(){ return parent.apply(this, arguments); }; + } + + // Add static properties to the constructor function, if supplied. + _.extend(child, parent, staticProps); + + // Set the prototype chain to inherit from `parent`, without calling + // `parent`'s constructor function and add the prototype properties. + child.prototype = _.create(parent.prototype, protoProps); + child.prototype.constructor = child; + + // Set a convenience property in case the parent's prototype is needed + // later. + child.__super__ = parent.prototype; + + return child; + }; + + // Set up inheritance for the model, collection, router, view and history. + Model.extend = Collection.extend = Router.extend = View.extend = History.extend = extend; + + // Throw an error when a URL is needed, and none is supplied. + var urlError = function() { + throw new Error('A "url" property or function must be specified'); + }; + + // Wrap an optional error callback with a fallback error event. + var wrapError = function(model, options) { + var error = options.error; + options.error = function(resp) { + if (error) error.call(options.context, model, resp, options); + model.trigger('error', model, resp, options); + }; + }; + + return Backbone; +}); \ No newline at end of file diff --git a/client/lib/bull.js b/client/lib/bull.js index 130b64fc54..6bc34aee7a 100644 --- a/client/lib/bull.js +++ b/client/lib/bull.js @@ -1158,7 +1158,11 @@ var Bull = Bull || {}; var proceed = function (layoutTemplate) { var injection = _.extend(layoutDefs, data || {}); - callback(_.template(layoutTemplate, injection)); + var template = _.template(layoutTemplate, injection); + if (typeof template === 'function') { + template = template(injection); + } + callback(template); }.bind(this); var layoutTemplate = this._getCachedLayoutTemplate(layoutType); diff --git a/client/lib/underscore-min.js b/client/lib/underscore-min.js index 3434d6c590..f01025b7bc 100644 --- a/client/lib/underscore-min.js +++ b/client/lib/underscore-min.js @@ -1,6 +1,6 @@ -// Underscore.js 1.6.0 +// Underscore.js 1.8.3 // http://underscorejs.org -// (c) 2009-2014 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors +// (c) 2009-2015 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors // Underscore may be freely distributed under the MIT license. -(function(){var n=this,t=n._,r={},e=Array.prototype,u=Object.prototype,i=Function.prototype,a=e.push,o=e.slice,c=e.concat,l=u.toString,f=u.hasOwnProperty,s=e.forEach,p=e.map,h=e.reduce,v=e.reduceRight,g=e.filter,d=e.every,m=e.some,y=e.indexOf,b=e.lastIndexOf,x=Array.isArray,w=Object.keys,_=i.bind,j=function(n){return n instanceof j?n:this instanceof j?void(this._wrapped=n):new j(n)};"undefined"!=typeof exports?("undefined"!=typeof module&&module.exports&&(exports=module.exports=j),exports._=j):n._=j,j.VERSION="1.6.0";var A=j.each=j.forEach=function(n,t,e){if(null==n)return n;if(s&&n.forEach===s)n.forEach(t,e);else if(n.length===+n.length){for(var u=0,i=n.length;i>u;u++)if(t.call(e,n[u],u,n)===r)return}else for(var a=j.keys(n),u=0,i=a.length;i>u;u++)if(t.call(e,n[a[u]],a[u],n)===r)return;return n};j.map=j.collect=function(n,t,r){var e=[];return null==n?e:p&&n.map===p?n.map(t,r):(A(n,function(n,u,i){e.push(t.call(r,n,u,i))}),e)};var O="Reduce of empty array with no initial value";j.reduce=j.foldl=j.inject=function(n,t,r,e){var u=arguments.length>2;if(null==n&&(n=[]),h&&n.reduce===h)return e&&(t=j.bind(t,e)),u?n.reduce(t,r):n.reduce(t);if(A(n,function(n,i,a){u?r=t.call(e,r,n,i,a):(r=n,u=!0)}),!u)throw new TypeError(O);return r},j.reduceRight=j.foldr=function(n,t,r,e){var u=arguments.length>2;if(null==n&&(n=[]),v&&n.reduceRight===v)return e&&(t=j.bind(t,e)),u?n.reduceRight(t,r):n.reduceRight(t);var i=n.length;if(i!==+i){var a=j.keys(n);i=a.length}if(A(n,function(o,c,l){c=a?a[--i]:--i,u?r=t.call(e,r,n[c],c,l):(r=n[c],u=!0)}),!u)throw new TypeError(O);return r},j.find=j.detect=function(n,t,r){var e;return k(n,function(n,u,i){return t.call(r,n,u,i)?(e=n,!0):void 0}),e},j.filter=j.select=function(n,t,r){var e=[];return null==n?e:g&&n.filter===g?n.filter(t,r):(A(n,function(n,u,i){t.call(r,n,u,i)&&e.push(n)}),e)},j.reject=function(n,t,r){return j.filter(n,function(n,e,u){return!t.call(r,n,e,u)},r)},j.every=j.all=function(n,t,e){t||(t=j.identity);var u=!0;return null==n?u:d&&n.every===d?n.every(t,e):(A(n,function(n,i,a){return(u=u&&t.call(e,n,i,a))?void 0:r}),!!u)};var k=j.some=j.any=function(n,t,e){t||(t=j.identity);var u=!1;return null==n?u:m&&n.some===m?n.some(t,e):(A(n,function(n,i,a){return u||(u=t.call(e,n,i,a))?r:void 0}),!!u)};j.contains=j.include=function(n,t){return null==n?!1:y&&n.indexOf===y?n.indexOf(t)!=-1:k(n,function(n){return n===t})},j.invoke=function(n,t){var r=o.call(arguments,2),e=j.isFunction(t);return j.map(n,function(n){return(e?t:n[t]).apply(n,r)})},j.pluck=function(n,t){return j.map(n,j.property(t))},j.where=function(n,t){return j.filter(n,j.matches(t))},j.findWhere=function(n,t){return j.find(n,j.matches(t))},j.max=function(n,t,r){if(!t&&j.isArray(n)&&n[0]===+n[0]&&n.length<65535)return Math.max.apply(Math,n);var e=-1/0,u=-1/0;return A(n,function(n,i,a){var o=t?t.call(r,n,i,a):n;o>u&&(e=n,u=o)}),e},j.min=function(n,t,r){if(!t&&j.isArray(n)&&n[0]===+n[0]&&n.length<65535)return Math.min.apply(Math,n);var e=1/0,u=1/0;return A(n,function(n,i,a){var o=t?t.call(r,n,i,a):n;u>o&&(e=n,u=o)}),e},j.shuffle=function(n){var t,r=0,e=[];return A(n,function(n){t=j.random(r++),e[r-1]=e[t],e[t]=n}),e},j.sample=function(n,t,r){return null==t||r?(n.length!==+n.length&&(n=j.values(n)),n[j.random(n.length-1)]):j.shuffle(n).slice(0,Math.max(0,t))};var E=function(n){return null==n?j.identity:j.isFunction(n)?n:j.property(n)};j.sortBy=function(n,t,r){return t=E(t),j.pluck(j.map(n,function(n,e,u){return{value:n,index:e,criteria:t.call(r,n,e,u)}}).sort(function(n,t){var r=n.criteria,e=t.criteria;if(r!==e){if(r>e||r===void 0)return 1;if(e>r||e===void 0)return-1}return n.index-t.index}),"value")};var F=function(n){return function(t,r,e){var u={};return r=E(r),A(t,function(i,a){var o=r.call(e,i,a,t);n(u,o,i)}),u}};j.groupBy=F(function(n,t,r){j.has(n,t)?n[t].push(r):n[t]=[r]}),j.indexBy=F(function(n,t,r){n[t]=r}),j.countBy=F(function(n,t){j.has(n,t)?n[t]++:n[t]=1}),j.sortedIndex=function(n,t,r,e){r=E(r);for(var u=r.call(e,t),i=0,a=n.length;a>i;){var o=i+a>>>1;r.call(e,n[o])t?[]:o.call(n,0,t)},j.initial=function(n,t,r){return o.call(n,0,n.length-(null==t||r?1:t))},j.last=function(n,t,r){return null==n?void 0:null==t||r?n[n.length-1]:o.call(n,Math.max(n.length-t,0))},j.rest=j.tail=j.drop=function(n,t,r){return o.call(n,null==t||r?1:t)},j.compact=function(n){return j.filter(n,j.identity)};var M=function(n,t,r){return t&&j.every(n,j.isArray)?c.apply(r,n):(A(n,function(n){j.isArray(n)||j.isArguments(n)?t?a.apply(r,n):M(n,t,r):r.push(n)}),r)};j.flatten=function(n,t){return M(n,t,[])},j.without=function(n){return j.difference(n,o.call(arguments,1))},j.partition=function(n,t){var r=[],e=[];return A(n,function(n){(t(n)?r:e).push(n)}),[r,e]},j.uniq=j.unique=function(n,t,r,e){j.isFunction(t)&&(e=r,r=t,t=!1);var u=r?j.map(n,r,e):n,i=[],a=[];return A(u,function(r,e){(t?e&&a[a.length-1]===r:j.contains(a,r))||(a.push(r),i.push(n[e]))}),i},j.union=function(){return j.uniq(j.flatten(arguments,!0))},j.intersection=function(n){var t=o.call(arguments,1);return j.filter(j.uniq(n),function(n){return j.every(t,function(t){return j.contains(t,n)})})},j.difference=function(n){var t=c.apply(e,o.call(arguments,1));return j.filter(n,function(n){return!j.contains(t,n)})},j.zip=function(){for(var n=j.max(j.pluck(arguments,"length").concat(0)),t=new Array(n),r=0;n>r;r++)t[r]=j.pluck(arguments,""+r);return t},j.object=function(n,t){if(null==n)return{};for(var r={},e=0,u=n.length;u>e;e++)t?r[n[e]]=t[e]:r[n[e][0]]=n[e][1];return r},j.indexOf=function(n,t,r){if(null==n)return-1;var e=0,u=n.length;if(r){if("number"!=typeof r)return e=j.sortedIndex(n,t),n[e]===t?e:-1;e=0>r?Math.max(0,u+r):r}if(y&&n.indexOf===y)return n.indexOf(t,r);for(;u>e;e++)if(n[e]===t)return e;return-1},j.lastIndexOf=function(n,t,r){if(null==n)return-1;var e=null!=r;if(b&&n.lastIndexOf===b)return e?n.lastIndexOf(t,r):n.lastIndexOf(t);for(var u=e?r:n.length;u--;)if(n[u]===t)return u;return-1},j.range=function(n,t,r){arguments.length<=1&&(t=n||0,n=0),r=arguments[2]||1;for(var e=Math.max(Math.ceil((t-n)/r),0),u=0,i=new Array(e);e>u;)i[u++]=n,n+=r;return i};var R=function(){};j.bind=function(n,t){var r,e;if(_&&n.bind===_)return _.apply(n,o.call(arguments,1));if(!j.isFunction(n))throw new TypeError;return r=o.call(arguments,2),e=function(){if(!(this instanceof e))return n.apply(t,r.concat(o.call(arguments)));R.prototype=n.prototype;var u=new R;R.prototype=null;var i=n.apply(u,r.concat(o.call(arguments)));return Object(i)===i?i:u}},j.partial=function(n){var t=o.call(arguments,1);return function(){for(var r=0,e=t.slice(),u=0,i=e.length;i>u;u++)e[u]===j&&(e[u]=arguments[r++]);for(;r=f?(clearTimeout(a),a=null,o=l,i=n.apply(e,u),e=u=null):a||r.trailing===!1||(a=setTimeout(c,f)),i}},j.debounce=function(n,t,r){var e,u,i,a,o,c=function(){var l=j.now()-a;t>l?e=setTimeout(c,t-l):(e=null,r||(o=n.apply(i,u),i=u=null))};return function(){i=this,u=arguments,a=j.now();var l=r&&!e;return e||(e=setTimeout(c,t)),l&&(o=n.apply(i,u),i=u=null),o}},j.once=function(n){var t,r=!1;return function(){return r?t:(r=!0,t=n.apply(this,arguments),n=null,t)}},j.wrap=function(n,t){return j.partial(t,n)},j.compose=function(){var n=arguments;return function(){for(var t=arguments,r=n.length-1;r>=0;r--)t=[n[r].apply(this,t)];return t[0]}},j.after=function(n,t){return function(){return--n<1?t.apply(this,arguments):void 0}},j.keys=function(n){if(!j.isObject(n))return[];if(w)return w(n);var t=[];for(var r in n)j.has(n,r)&&t.push(r);return t},j.values=function(n){for(var t=j.keys(n),r=t.length,e=new Array(r),u=0;r>u;u++)e[u]=n[t[u]];return e},j.pairs=function(n){for(var t=j.keys(n),r=t.length,e=new Array(r),u=0;r>u;u++)e[u]=[t[u],n[t[u]]];return e},j.invert=function(n){for(var t={},r=j.keys(n),e=0,u=r.length;u>e;e++)t[n[r[e]]]=r[e];return t},j.functions=j.methods=function(n){var t=[];for(var r in n)j.isFunction(n[r])&&t.push(r);return t.sort()},j.extend=function(n){return A(o.call(arguments,1),function(t){if(t)for(var r in t)n[r]=t[r]}),n},j.pick=function(n){var t={},r=c.apply(e,o.call(arguments,1));return A(r,function(r){r in n&&(t[r]=n[r])}),t},j.omit=function(n){var t={},r=c.apply(e,o.call(arguments,1));for(var u in n)j.contains(r,u)||(t[u]=n[u]);return t},j.defaults=function(n){return A(o.call(arguments,1),function(t){if(t)for(var r in t)n[r]===void 0&&(n[r]=t[r])}),n},j.clone=function(n){return j.isObject(n)?j.isArray(n)?n.slice():j.extend({},n):n},j.tap=function(n,t){return t(n),n};var S=function(n,t,r,e){if(n===t)return 0!==n||1/n==1/t;if(null==n||null==t)return n===t;n instanceof j&&(n=n._wrapped),t instanceof j&&(t=t._wrapped);var u=l.call(n);if(u!=l.call(t))return!1;switch(u){case"[object String]":return n==String(t);case"[object Number]":return n!=+n?t!=+t:0==n?1/n==1/t:n==+t;case"[object Date]":case"[object Boolean]":return+n==+t;case"[object RegExp]":return n.source==t.source&&n.global==t.global&&n.multiline==t.multiline&&n.ignoreCase==t.ignoreCase}if("object"!=typeof n||"object"!=typeof t)return!1;for(var i=r.length;i--;)if(r[i]==n)return e[i]==t;var a=n.constructor,o=t.constructor;if(a!==o&&!(j.isFunction(a)&&a instanceof a&&j.isFunction(o)&&o instanceof o)&&"constructor"in n&&"constructor"in t)return!1;r.push(n),e.push(t);var c=0,f=!0;if("[object Array]"==u){if(c=n.length,f=c==t.length)for(;c--&&(f=S(n[c],t[c],r,e)););}else{for(var s in n)if(j.has(n,s)&&(c++,!(f=j.has(t,s)&&S(n[s],t[s],r,e))))break;if(f){for(s in t)if(j.has(t,s)&&!c--)break;f=!c}}return r.pop(),e.pop(),f};j.isEqual=function(n,t){return S(n,t,[],[])},j.isEmpty=function(n){if(null==n)return!0;if(j.isArray(n)||j.isString(n))return 0===n.length;for(var t in n)if(j.has(n,t))return!1;return!0},j.isElement=function(n){return!(!n||1!==n.nodeType)},j.isArray=x||function(n){return"[object Array]"==l.call(n)},j.isObject=function(n){return n===Object(n)},A(["Arguments","Function","String","Number","Date","RegExp"],function(n){j["is"+n]=function(t){return l.call(t)=="[object "+n+"]"}}),j.isArguments(arguments)||(j.isArguments=function(n){return!(!n||!j.has(n,"callee"))}),"function"!=typeof/./&&(j.isFunction=function(n){return"function"==typeof n}),j.isFinite=function(n){return isFinite(n)&&!isNaN(parseFloat(n))},j.isNaN=function(n){return j.isNumber(n)&&n!=+n},j.isBoolean=function(n){return n===!0||n===!1||"[object Boolean]"==l.call(n)},j.isNull=function(n){return null===n},j.isUndefined=function(n){return n===void 0},j.has=function(n,t){return f.call(n,t)},j.noConflict=function(){return n._=t,this},j.identity=function(n){return n},j.constant=function(n){return function(){return n}},j.property=function(n){return function(t){return t[n]}},j.matches=function(n){return function(t){if(t===n)return!0;for(var r in n)if(n[r]!==t[r])return!1;return!0}},j.times=function(n,t,r){for(var e=Array(Math.max(0,n)),u=0;n>u;u++)e[u]=t.call(r,u);return e},j.random=function(n,t){return null==t&&(t=n,n=0),n+Math.floor(Math.random()*(t-n+1))},j.now=Date.now||function(){return(new Date).getTime()};var T={escape:{"&":"&","<":"<",">":">",'"':""","'":"'"}};T.unescape=j.invert(T.escape);var I={escape:new RegExp("["+j.keys(T.escape).join("")+"]","g"),unescape:new RegExp("("+j.keys(T.unescape).join("|")+")","g")};j.each(["escape","unescape"],function(n){j[n]=function(t){return null==t?"":(""+t).replace(I[n],function(t){return T[n][t]})}}),j.result=function(n,t){if(null==n)return void 0;var r=n[t];return j.isFunction(r)?r.call(n):r},j.mixin=function(n){A(j.functions(n),function(t){var r=j[t]=n[t];j.prototype[t]=function(){var n=[this._wrapped];return a.apply(n,arguments),z.call(this,r.apply(j,n))}})};var N=0;j.uniqueId=function(n){var t=++N+"";return n?n+t:t},j.templateSettings={evaluate:/<%([\s\S]+?)%>/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g};var q=/(.)^/,B={"'":"'","\\":"\\","\r":"r","\n":"n"," ":"t","\u2028":"u2028","\u2029":"u2029"},D=/\\|'|\r|\n|\t|\u2028|\u2029/g;j.template=function(n,t,r){var e;r=j.defaults({},r,j.templateSettings);var u=new RegExp([(r.escape||q).source,(r.interpolate||q).source,(r.evaluate||q).source].join("|")+"|$","g"),i=0,a="__p+='";n.replace(u,function(t,r,e,u,o){return a+=n.slice(i,o).replace(D,function(n){return"\\"+B[n]}),r&&(a+="'+\n((__t=("+r+"))==null?'':_.escape(__t))+\n'"),e&&(a+="'+\n((__t=("+e+"))==null?'':__t)+\n'"),u&&(a+="';\n"+u+"\n__p+='"),i=o+t.length,t}),a+="';\n",r.variable||(a="with(obj||{}){\n"+a+"}\n"),a="var __t,__p='',__j=Array.prototype.join,"+"print=function(){__p+=__j.call(arguments,'');};\n"+a+"return __p;\n";try{e=new Function(r.variable||"obj","_",a)}catch(o){throw o.source=a,o}if(t)return e(t,j);var c=function(n){return e.call(this,n,j)};return c.source="function("+(r.variable||"obj")+"){\n"+a+"}",c},j.chain=function(n){return j(n).chain()};var z=function(n){return this._chain?j(n).chain():n};j.mixin(j),A(["pop","push","reverse","shift","sort","splice","unshift"],function(n){var t=e[n];j.prototype[n]=function(){var r=this._wrapped;return t.apply(r,arguments),"shift"!=n&&"splice"!=n||0!==r.length||delete r[0],z.call(this,r)}}),A(["concat","join","slice"],function(n){var t=e[n];j.prototype[n]=function(){return z.call(this,t.apply(this._wrapped,arguments))}}),j.extend(j.prototype,{chain:function(){return this._chain=!0,this},value:function(){return this._wrapped}}),"function"==typeof define&&define.amd&&define("underscore",[],function(){return j})}).call(this); +(function(){function n(n){function t(t,r,e,u,i,o){for(;i>=0&&o>i;i+=n){var a=u?u[i]:i;e=r(e,t[a],a,t)}return e}return function(r,e,u,i){e=b(e,i,4);var o=!k(r)&&m.keys(r),a=(o||r).length,c=n>0?0:a-1;return arguments.length<3&&(u=r[o?o[c]:c],c+=n),t(r,e,u,o,c,a)}}function t(n){return function(t,r,e){r=x(r,e);for(var u=O(t),i=n>0?0:u-1;i>=0&&u>i;i+=n)if(r(t[i],i,t))return i;return-1}}function r(n,t,r){return function(e,u,i){var o=0,a=O(e);if("number"==typeof i)n>0?o=i>=0?i:Math.max(i+a,o):a=i>=0?Math.min(i+1,a):i+a+1;else if(r&&i&&a)return i=r(e,u),e[i]===u?i:-1;if(u!==u)return i=t(l.call(e,o,a),m.isNaN),i>=0?i+o:-1;for(i=n>0?o:a-1;i>=0&&a>i;i+=n)if(e[i]===u)return i;return-1}}function e(n,t){var r=I.length,e=n.constructor,u=m.isFunction(e)&&e.prototype||a,i="constructor";for(m.has(n,i)&&!m.contains(t,i)&&t.push(i);r--;)i=I[r],i in n&&n[i]!==u[i]&&!m.contains(t,i)&&t.push(i)}var u=this,i=u._,o=Array.prototype,a=Object.prototype,c=Function.prototype,f=o.push,l=o.slice,s=a.toString,p=a.hasOwnProperty,h=Array.isArray,v=Object.keys,g=c.bind,y=Object.create,d=function(){},m=function(n){return n instanceof m?n:this instanceof m?void(this._wrapped=n):new m(n)};"undefined"!=typeof exports?("undefined"!=typeof module&&module.exports&&(exports=module.exports=m),exports._=m):u._=m,m.VERSION="1.8.3";var b=function(n,t,r){if(t===void 0)return n;switch(null==r?3:r){case 1:return function(r){return n.call(t,r)};case 2:return function(r,e){return n.call(t,r,e)};case 3:return function(r,e,u){return n.call(t,r,e,u)};case 4:return function(r,e,u,i){return n.call(t,r,e,u,i)}}return function(){return n.apply(t,arguments)}},x=function(n,t,r){return null==n?m.identity:m.isFunction(n)?b(n,t,r):m.isObject(n)?m.matcher(n):m.property(n)};m.iteratee=function(n,t){return x(n,t,1/0)};var _=function(n,t){return function(r){var e=arguments.length;if(2>e||null==r)return r;for(var u=1;e>u;u++)for(var i=arguments[u],o=n(i),a=o.length,c=0;a>c;c++){var f=o[c];t&&r[f]!==void 0||(r[f]=i[f])}return r}},j=function(n){if(!m.isObject(n))return{};if(y)return y(n);d.prototype=n;var t=new d;return d.prototype=null,t},w=function(n){return function(t){return null==t?void 0:t[n]}},A=Math.pow(2,53)-1,O=w("length"),k=function(n){var t=O(n);return"number"==typeof t&&t>=0&&A>=t};m.each=m.forEach=function(n,t,r){t=b(t,r);var e,u;if(k(n))for(e=0,u=n.length;u>e;e++)t(n[e],e,n);else{var i=m.keys(n);for(e=0,u=i.length;u>e;e++)t(n[i[e]],i[e],n)}return n},m.map=m.collect=function(n,t,r){t=x(t,r);for(var e=!k(n)&&m.keys(n),u=(e||n).length,i=Array(u),o=0;u>o;o++){var a=e?e[o]:o;i[o]=t(n[a],a,n)}return i},m.reduce=m.foldl=m.inject=n(1),m.reduceRight=m.foldr=n(-1),m.find=m.detect=function(n,t,r){var e;return e=k(n)?m.findIndex(n,t,r):m.findKey(n,t,r),e!==void 0&&e!==-1?n[e]:void 0},m.filter=m.select=function(n,t,r){var e=[];return t=x(t,r),m.each(n,function(n,r,u){t(n,r,u)&&e.push(n)}),e},m.reject=function(n,t,r){return m.filter(n,m.negate(x(t)),r)},m.every=m.all=function(n,t,r){t=x(t,r);for(var e=!k(n)&&m.keys(n),u=(e||n).length,i=0;u>i;i++){var o=e?e[i]:i;if(!t(n[o],o,n))return!1}return!0},m.some=m.any=function(n,t,r){t=x(t,r);for(var e=!k(n)&&m.keys(n),u=(e||n).length,i=0;u>i;i++){var o=e?e[i]:i;if(t(n[o],o,n))return!0}return!1},m.contains=m.includes=m.include=function(n,t,r,e){return k(n)||(n=m.values(n)),("number"!=typeof r||e)&&(r=0),m.indexOf(n,t,r)>=0},m.invoke=function(n,t){var r=l.call(arguments,2),e=m.isFunction(t);return m.map(n,function(n){var u=e?t:n[t];return null==u?u:u.apply(n,r)})},m.pluck=function(n,t){return m.map(n,m.property(t))},m.where=function(n,t){return m.filter(n,m.matcher(t))},m.findWhere=function(n,t){return m.find(n,m.matcher(t))},m.max=function(n,t,r){var e,u,i=-1/0,o=-1/0;if(null==t&&null!=n){n=k(n)?n:m.values(n);for(var a=0,c=n.length;c>a;a++)e=n[a],e>i&&(i=e)}else t=x(t,r),m.each(n,function(n,r,e){u=t(n,r,e),(u>o||u===-1/0&&i===-1/0)&&(i=n,o=u)});return i},m.min=function(n,t,r){var e,u,i=1/0,o=1/0;if(null==t&&null!=n){n=k(n)?n:m.values(n);for(var a=0,c=n.length;c>a;a++)e=n[a],i>e&&(i=e)}else t=x(t,r),m.each(n,function(n,r,e){u=t(n,r,e),(o>u||1/0===u&&1/0===i)&&(i=n,o=u)});return i},m.shuffle=function(n){for(var t,r=k(n)?n:m.values(n),e=r.length,u=Array(e),i=0;e>i;i++)t=m.random(0,i),t!==i&&(u[i]=u[t]),u[t]=r[i];return u},m.sample=function(n,t,r){return null==t||r?(k(n)||(n=m.values(n)),n[m.random(n.length-1)]):m.shuffle(n).slice(0,Math.max(0,t))},m.sortBy=function(n,t,r){return t=x(t,r),m.pluck(m.map(n,function(n,r,e){return{value:n,index:r,criteria:t(n,r,e)}}).sort(function(n,t){var r=n.criteria,e=t.criteria;if(r!==e){if(r>e||r===void 0)return 1;if(e>r||e===void 0)return-1}return n.index-t.index}),"value")};var F=function(n){return function(t,r,e){var u={};return r=x(r,e),m.each(t,function(e,i){var o=r(e,i,t);n(u,e,o)}),u}};m.groupBy=F(function(n,t,r){m.has(n,r)?n[r].push(t):n[r]=[t]}),m.indexBy=F(function(n,t,r){n[r]=t}),m.countBy=F(function(n,t,r){m.has(n,r)?n[r]++:n[r]=1}),m.toArray=function(n){return n?m.isArray(n)?l.call(n):k(n)?m.map(n,m.identity):m.values(n):[]},m.size=function(n){return null==n?0:k(n)?n.length:m.keys(n).length},m.partition=function(n,t,r){t=x(t,r);var e=[],u=[];return m.each(n,function(n,r,i){(t(n,r,i)?e:u).push(n)}),[e,u]},m.first=m.head=m.take=function(n,t,r){return null==n?void 0:null==t||r?n[0]:m.initial(n,n.length-t)},m.initial=function(n,t,r){return l.call(n,0,Math.max(0,n.length-(null==t||r?1:t)))},m.last=function(n,t,r){return null==n?void 0:null==t||r?n[n.length-1]:m.rest(n,Math.max(0,n.length-t))},m.rest=m.tail=m.drop=function(n,t,r){return l.call(n,null==t||r?1:t)},m.compact=function(n){return m.filter(n,m.identity)};var S=function(n,t,r,e){for(var u=[],i=0,o=e||0,a=O(n);a>o;o++){var c=n[o];if(k(c)&&(m.isArray(c)||m.isArguments(c))){t||(c=S(c,t,r));var f=0,l=c.length;for(u.length+=l;l>f;)u[i++]=c[f++]}else r||(u[i++]=c)}return u};m.flatten=function(n,t){return S(n,t,!1)},m.without=function(n){return m.difference(n,l.call(arguments,1))},m.uniq=m.unique=function(n,t,r,e){m.isBoolean(t)||(e=r,r=t,t=!1),null!=r&&(r=x(r,e));for(var u=[],i=[],o=0,a=O(n);a>o;o++){var c=n[o],f=r?r(c,o,n):c;t?(o&&i===f||u.push(c),i=f):r?m.contains(i,f)||(i.push(f),u.push(c)):m.contains(u,c)||u.push(c)}return u},m.union=function(){return m.uniq(S(arguments,!0,!0))},m.intersection=function(n){for(var t=[],r=arguments.length,e=0,u=O(n);u>e;e++){var i=n[e];if(!m.contains(t,i)){for(var o=1;r>o&&m.contains(arguments[o],i);o++);o===r&&t.push(i)}}return t},m.difference=function(n){var t=S(arguments,!0,!0,1);return m.filter(n,function(n){return!m.contains(t,n)})},m.zip=function(){return m.unzip(arguments)},m.unzip=function(n){for(var t=n&&m.max(n,O).length||0,r=Array(t),e=0;t>e;e++)r[e]=m.pluck(n,e);return r},m.object=function(n,t){for(var r={},e=0,u=O(n);u>e;e++)t?r[n[e]]=t[e]:r[n[e][0]]=n[e][1];return r},m.findIndex=t(1),m.findLastIndex=t(-1),m.sortedIndex=function(n,t,r,e){r=x(r,e,1);for(var u=r(t),i=0,o=O(n);o>i;){var a=Math.floor((i+o)/2);r(n[a])i;i++,n+=r)u[i]=n;return u};var E=function(n,t,r,e,u){if(!(e instanceof t))return n.apply(r,u);var i=j(n.prototype),o=n.apply(i,u);return m.isObject(o)?o:i};m.bind=function(n,t){if(g&&n.bind===g)return g.apply(n,l.call(arguments,1));if(!m.isFunction(n))throw new TypeError("Bind must be called on a function");var r=l.call(arguments,2),e=function(){return E(n,e,t,this,r.concat(l.call(arguments)))};return e},m.partial=function(n){var t=l.call(arguments,1),r=function(){for(var e=0,u=t.length,i=Array(u),o=0;u>o;o++)i[o]=t[o]===m?arguments[e++]:t[o];for(;e=e)throw new Error("bindAll must be passed function names");for(t=1;e>t;t++)r=arguments[t],n[r]=m.bind(n[r],n);return n},m.memoize=function(n,t){var r=function(e){var u=r.cache,i=""+(t?t.apply(this,arguments):e);return m.has(u,i)||(u[i]=n.apply(this,arguments)),u[i]};return r.cache={},r},m.delay=function(n,t){var r=l.call(arguments,2);return setTimeout(function(){return n.apply(null,r)},t)},m.defer=m.partial(m.delay,m,1),m.throttle=function(n,t,r){var e,u,i,o=null,a=0;r||(r={});var c=function(){a=r.leading===!1?0:m.now(),o=null,i=n.apply(e,u),o||(e=u=null)};return function(){var f=m.now();a||r.leading!==!1||(a=f);var l=t-(f-a);return e=this,u=arguments,0>=l||l>t?(o&&(clearTimeout(o),o=null),a=f,i=n.apply(e,u),o||(e=u=null)):o||r.trailing===!1||(o=setTimeout(c,l)),i}},m.debounce=function(n,t,r){var e,u,i,o,a,c=function(){var f=m.now()-o;t>f&&f>=0?e=setTimeout(c,t-f):(e=null,r||(a=n.apply(i,u),e||(i=u=null)))};return function(){i=this,u=arguments,o=m.now();var f=r&&!e;return e||(e=setTimeout(c,t)),f&&(a=n.apply(i,u),i=u=null),a}},m.wrap=function(n,t){return m.partial(t,n)},m.negate=function(n){return function(){return!n.apply(this,arguments)}},m.compose=function(){var n=arguments,t=n.length-1;return function(){for(var r=t,e=n[t].apply(this,arguments);r--;)e=n[r].call(this,e);return e}},m.after=function(n,t){return function(){return--n<1?t.apply(this,arguments):void 0}},m.before=function(n,t){var r;return function(){return--n>0&&(r=t.apply(this,arguments)),1>=n&&(t=null),r}},m.once=m.partial(m.before,2);var M=!{toString:null}.propertyIsEnumerable("toString"),I=["valueOf","isPrototypeOf","toString","propertyIsEnumerable","hasOwnProperty","toLocaleString"];m.keys=function(n){if(!m.isObject(n))return[];if(v)return v(n);var t=[];for(var r in n)m.has(n,r)&&t.push(r);return M&&e(n,t),t},m.allKeys=function(n){if(!m.isObject(n))return[];var t=[];for(var r in n)t.push(r);return M&&e(n,t),t},m.values=function(n){for(var t=m.keys(n),r=t.length,e=Array(r),u=0;r>u;u++)e[u]=n[t[u]];return e},m.mapObject=function(n,t,r){t=x(t,r);for(var e,u=m.keys(n),i=u.length,o={},a=0;i>a;a++)e=u[a],o[e]=t(n[e],e,n);return o},m.pairs=function(n){for(var t=m.keys(n),r=t.length,e=Array(r),u=0;r>u;u++)e[u]=[t[u],n[t[u]]];return e},m.invert=function(n){for(var t={},r=m.keys(n),e=0,u=r.length;u>e;e++)t[n[r[e]]]=r[e];return t},m.functions=m.methods=function(n){var t=[];for(var r in n)m.isFunction(n[r])&&t.push(r);return t.sort()},m.extend=_(m.allKeys),m.extendOwn=m.assign=_(m.keys),m.findKey=function(n,t,r){t=x(t,r);for(var e,u=m.keys(n),i=0,o=u.length;o>i;i++)if(e=u[i],t(n[e],e,n))return e},m.pick=function(n,t,r){var e,u,i={},o=n;if(null==o)return i;m.isFunction(t)?(u=m.allKeys(o),e=b(t,r)):(u=S(arguments,!1,!1,1),e=function(n,t,r){return t in r},o=Object(o));for(var a=0,c=u.length;c>a;a++){var f=u[a],l=o[f];e(l,f,o)&&(i[f]=l)}return i},m.omit=function(n,t,r){if(m.isFunction(t))t=m.negate(t);else{var e=m.map(S(arguments,!1,!1,1),String);t=function(n,t){return!m.contains(e,t)}}return m.pick(n,t,r)},m.defaults=_(m.allKeys,!0),m.create=function(n,t){var r=j(n);return t&&m.extendOwn(r,t),r},m.clone=function(n){return m.isObject(n)?m.isArray(n)?n.slice():m.extend({},n):n},m.tap=function(n,t){return t(n),n},m.isMatch=function(n,t){var r=m.keys(t),e=r.length;if(null==n)return!e;for(var u=Object(n),i=0;e>i;i++){var o=r[i];if(t[o]!==u[o]||!(o in u))return!1}return!0};var N=function(n,t,r,e){if(n===t)return 0!==n||1/n===1/t;if(null==n||null==t)return n===t;n instanceof m&&(n=n._wrapped),t instanceof m&&(t=t._wrapped);var u=s.call(n);if(u!==s.call(t))return!1;switch(u){case"[object RegExp]":case"[object String]":return""+n==""+t;case"[object Number]":return+n!==+n?+t!==+t:0===+n?1/+n===1/t:+n===+t;case"[object Date]":case"[object Boolean]":return+n===+t}var i="[object Array]"===u;if(!i){if("object"!=typeof n||"object"!=typeof t)return!1;var o=n.constructor,a=t.constructor;if(o!==a&&!(m.isFunction(o)&&o instanceof o&&m.isFunction(a)&&a instanceof a)&&"constructor"in n&&"constructor"in t)return!1}r=r||[],e=e||[];for(var c=r.length;c--;)if(r[c]===n)return e[c]===t;if(r.push(n),e.push(t),i){if(c=n.length,c!==t.length)return!1;for(;c--;)if(!N(n[c],t[c],r,e))return!1}else{var f,l=m.keys(n);if(c=l.length,m.keys(t).length!==c)return!1;for(;c--;)if(f=l[c],!m.has(t,f)||!N(n[f],t[f],r,e))return!1}return r.pop(),e.pop(),!0};m.isEqual=function(n,t){return N(n,t)},m.isEmpty=function(n){return null==n?!0:k(n)&&(m.isArray(n)||m.isString(n)||m.isArguments(n))?0===n.length:0===m.keys(n).length},m.isElement=function(n){return!(!n||1!==n.nodeType)},m.isArray=h||function(n){return"[object Array]"===s.call(n)},m.isObject=function(n){var t=typeof n;return"function"===t||"object"===t&&!!n},m.each(["Arguments","Function","String","Number","Date","RegExp","Error"],function(n){m["is"+n]=function(t){return s.call(t)==="[object "+n+"]"}}),m.isArguments(arguments)||(m.isArguments=function(n){return m.has(n,"callee")}),"function"!=typeof/./&&"object"!=typeof Int8Array&&(m.isFunction=function(n){return"function"==typeof n||!1}),m.isFinite=function(n){return isFinite(n)&&!isNaN(parseFloat(n))},m.isNaN=function(n){return m.isNumber(n)&&n!==+n},m.isBoolean=function(n){return n===!0||n===!1||"[object Boolean]"===s.call(n)},m.isNull=function(n){return null===n},m.isUndefined=function(n){return n===void 0},m.has=function(n,t){return null!=n&&p.call(n,t)},m.noConflict=function(){return u._=i,this},m.identity=function(n){return n},m.constant=function(n){return function(){return n}},m.noop=function(){},m.property=w,m.propertyOf=function(n){return null==n?function(){}:function(t){return n[t]}},m.matcher=m.matches=function(n){return n=m.extendOwn({},n),function(t){return m.isMatch(t,n)}},m.times=function(n,t,r){var e=Array(Math.max(0,n));t=b(t,r,1);for(var u=0;n>u;u++)e[u]=t(u);return e},m.random=function(n,t){return null==t&&(t=n,n=0),n+Math.floor(Math.random()*(t-n+1))},m.now=Date.now||function(){return(new Date).getTime()};var B={"&":"&","<":"<",">":">",'"':""","'":"'","`":"`"},T=m.invert(B),R=function(n){var t=function(t){return n[t]},r="(?:"+m.keys(n).join("|")+")",e=RegExp(r),u=RegExp(r,"g");return function(n){return n=null==n?"":""+n,e.test(n)?n.replace(u,t):n}};m.escape=R(B),m.unescape=R(T),m.result=function(n,t,r){var e=null==n?void 0:n[t];return e===void 0&&(e=r),m.isFunction(e)?e.call(n):e};var q=0;m.uniqueId=function(n){var t=++q+"";return n?n+t:t},m.templateSettings={evaluate:/<%([\s\S]+?)%>/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g};var K=/(.)^/,z={"'":"'","\\":"\\","\r":"r","\n":"n","\u2028":"u2028","\u2029":"u2029"},D=/\\|'|\r|\n|\u2028|\u2029/g,L=function(n){return"\\"+z[n]};m.template=function(n,t,r){!t&&r&&(t=r),t=m.defaults({},t,m.templateSettings);var e=RegExp([(t.escape||K).source,(t.interpolate||K).source,(t.evaluate||K).source].join("|")+"|$","g"),u=0,i="__p+='";n.replace(e,function(t,r,e,o,a){return i+=n.slice(u,a).replace(D,L),u=a+t.length,r?i+="'+\n((__t=("+r+"))==null?'':_.escape(__t))+\n'":e?i+="'+\n((__t=("+e+"))==null?'':__t)+\n'":o&&(i+="';\n"+o+"\n__p+='"),t}),i+="';\n",t.variable||(i="with(obj||{}){\n"+i+"}\n"),i="var __t,__p='',__j=Array.prototype.join,"+"print=function(){__p+=__j.call(arguments,'');};\n"+i+"return __p;\n";try{var o=new Function(t.variable||"obj","_",i)}catch(a){throw a.source=i,a}var c=function(n){return o.call(this,n,m)},f=t.variable||"obj";return c.source="function("+f+"){\n"+i+"}",c},m.chain=function(n){var t=m(n);return t._chain=!0,t};var P=function(n,t){return n._chain?m(t).chain():t};m.mixin=function(n){m.each(m.functions(n),function(t){var r=m[t]=n[t];m.prototype[t]=function(){var n=[this._wrapped];return f.apply(n,arguments),P(this,r.apply(m,n))}})},m.mixin(m),m.each(["pop","push","reverse","shift","sort","splice","unshift"],function(n){var t=o[n];m.prototype[n]=function(){var r=this._wrapped;return t.apply(r,arguments),"shift"!==n&&"splice"!==n||0!==r.length||delete r[0],P(this,r)}}),m.each(["concat","join","slice"],function(n){var t=o[n];m.prototype[n]=function(){return P(this,t.apply(this._wrapped,arguments))}}),m.prototype.value=function(){return this._wrapped},m.prototype.valueOf=m.prototype.toJSON=m.prototype.value,m.prototype.toString=function(){return""+this._wrapped},"function"==typeof define&&define.amd&&define("underscore",[],function(){return m})}).call(this); //# sourceMappingURL=underscore-min.map \ No newline at end of file diff --git a/client/modules/crm/src/views/calendar/calendar.js b/client/modules/crm/src/views/calendar/calendar.js index b796ffbe1d..15a3492984 100644 --- a/client/modules/crm/src/views/calendar/calendar.js +++ b/client/modules/crm/src/views/calendar/calendar.js @@ -158,6 +158,10 @@ Espo.define('crm:views/calendar/calendar', ['view', 'lib!full-calendar'], functi if (Object.prototype.toString.call(this.enabledScopeList) !== '[object Array]') { this.enabledScopeList = []; } + + this.once('remove', function () { + this.isRemoved = true; + }, this); }, toggleScopeFilter: function (name) { @@ -376,6 +380,7 @@ Espo.define('crm:views/calendar/calendar', ['view', 'lib!full-calendar'], functi }, adjustSize: function () { + if (this.isRemoved) return; var height = this.getCalculatedHeight(); this.$calendar.fullCalendar('option', 'contentHeight', height); }, diff --git a/client/modules/crm/src/views/dashlets/abstract/chart.js b/client/modules/crm/src/views/dashlets/abstract/chart.js index 6018db8abb..d0a6dd9a08 100644 --- a/client/modules/crm/src/views/dashlets/abstract/chart.js +++ b/client/modules/crm/src/views/dashlets/abstract/chart.js @@ -36,17 +36,38 @@ Espo.define('crm:views/dashlets/abstract/chart', ['views/dashlets/abstract/base' thousandSeparator: ',', - colors: ['#6FA8D6', '#4E6CAD', '#EDC555', '#ED8F42', '#DE6666', '#7CC4A4', '#8A7CC2', '#D4729B'], + defaultColorList: ['#6FA8D6', '#4E6CAD', '#EDC555', '#ED8F42', '#DE6666', '#7CC4A4', '#8A7CC2', '#D4729B'], successColor: '#85b75f', - outlineColor: '#333', + gridColor: '#ddd', + + tickColor: '#e8eced', + + textColor: '#333', + + hoverColor: '#FF3F19', + + legendColumnWidth: 90, + + legendColumnNumber: 6, + + labelFormatter: function (v) { + return '' + v + ''; + }, init: function () { Dep.prototype.init.call(this); this.flotr = Flotr; + this.successColor = this.getThemeManager().getParam('chartSuccessColor') || this.successColor; + this.colorList = this.getThemeManager().getParam('chartColorList') || this.defaultColorList; + this.tickColor = this.getThemeManager().getParam('chartTickColor') || this.tickColor; + this.gridColor = this.getThemeManager().getParam('chartGridColor') || this.gridColor; + this.textColor = this.getThemeManager().getParam('textColor') || this.textColor; + this.hoverColor = this.getThemeManager().getParam('hoverColor') || this.hoverColor; + if (this.getPreferences().has('decimalMark')) { this.decimalMark = this.getPreferences().get('decimalMark') } else { @@ -64,7 +85,7 @@ Espo.define('crm:views/dashlets/abstract/chart', ['views/dashlets/abstract/base' this.once('after:render', function () { $(window).on('resize.chart' + this.name, function () { - this.drow(); + this.draw(); }.bind(this)); }, this); @@ -73,34 +94,86 @@ Espo.define('crm:views/dashlets/abstract/chart', ['views/dashlets/abstract/base' }, this); }, - formatNumber: function (value) { + formatNumber: function (value, isCurrency) { if (value !== null) { + var maxDecimalPlaces = 2; + var currencyDecimalPlaces = this.getConfig().get('currencyDecimalPlaces'); + + if (isCurrency) { + if (currencyDecimalPlaces === 0) { + value = Math.round(value); + } else if (currencyDecimalPlaces) { + value = Math.round(value * Math.pow(10, currencyDecimalPlaces)) / (Math.pow(10, currencyDecimalPlaces)); + } else { + value = Math.round(value * Math.pow(10, maxDecimalPlaces)) / (Math.pow(10, maxDecimalPlaces)); + } + } else { + var maxDecimalPlaces = 4; + value = Math.round(value * Math.pow(10, maxDecimalPlaces)) / (Math.pow(10, maxDecimalPlaces)); + } + var parts = value.toString().split("."); parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, this.thousandSeparator); - if (parts[1] == 0) { - parts.splice(1, 1); + + if (isCurrency) { + if (currencyDecimalPlaces === 0) { + delete parts[1]; + } else if (currencyDecimalPlaces) { + var decimalPartLength = 0; + if (parts.length > 1) { + decimalPartLength = parts[1].length; + } else { + parts[1] = ''; + } + + if (currencyDecimalPlaces && decimalPartLength < currencyDecimalPlaces) { + var limit = currencyDecimalPlaces - decimalPartLength; + for (var i = 0; i < limit; i++) { + parts[1] += '0'; + } + } + } } - return parts.join(this.decimalMark); + + var value = parts.join(this.decimalMark); + return value; } return ''; }, + getLegentColumnNumber: function () { + var width = this.$el.closest('.panel-body').width(); + var legendColumnNumber = Math.floor(width / this.legendColumnWidth); + return legendColumnNumber || this.legendColumnNumber; + }, + + getLegentHeight: function () { + var lineNumber = Math.ceil(this.chartData.length / this.getLegentColumnNumber()); + var legendHeight = 0; + + var lineHeight = this.getThemeManager().getParam('dashletChartLegentRowHeight') || 22; + if (lineNumber > 0) { + legendHeight = lineHeight * lineNumber; + } + return legendHeight; + }, + afterRender: function () { + this.$el.closest('.panel-body').css({ + 'overflow-y': 'visible', + 'overflow-x': 'visible' + }); this.fetch(function (data) { this.chartData = this.prepareData(data); var $container = this.$container = this.$el.find('.chart-container'); - - var height = 'calc(100% - 22px)'; - if (this.chartData.length > 5) { - height = 'calc(100% - 42px)'; - } - - $container.css('height', height); + var legendHeight = this.getLegentHeight(); + var heightCss = 'calc(100% - '+legendHeight.toString()+'px)'; + $container.css('height', heightCss); setTimeout(function () { if (!$container.size() || !$container.is(":visible")) return; - this.drow(); + this.draw(); }.bind(this), 1); }); }, @@ -121,6 +194,9 @@ Espo.define('crm:views/dashlets/abstract/chart', ['views/dashlets/abstract/base' }); }, + getDateFilter: function () { + return this.getOption('dateFilter') || 'currentYear'; + } + }); }); - diff --git a/client/modules/crm/src/views/dashlets/opportunities-by-lead-source.js b/client/modules/crm/src/views/dashlets/opportunities-by-lead-source.js index 27100cc2af..86eea478fa 100644 --- a/client/modules/crm/src/views/dashlets/opportunities-by-lead-source.js +++ b/client/modules/crm/src/views/dashlets/opportunities-by-lead-source.js @@ -33,7 +33,12 @@ Espo.define('crm:views/dashlets/opportunities-by-lead-source', 'crm:views/dashle name: 'OpportunitiesByLeadSource', url: function () { - return 'Opportunity/action/reportByLeadSource?dateFrom=' + this.getOption('dateFrom') + '&dateTo=' + this.getOption('dateTo'); + var url = 'Opportunity/action/reportByLeadSource?dateFilter='+ this.getDateFilter(); + + if (this.getDateFilter() === 'between') { + url += '&dateFrom=' + this.getOption('dateFrom') + '&dateTo=' + this.getOption('dateTo'); + } + return url; }, prepareData: function (response) { @@ -55,13 +60,13 @@ Espo.define('crm:views/dashlets/opportunities-by-lead-source', 'crm:views/dashle setup: function () { this.currency = this.getConfig().get('defaultCurrency'); - this.currencySymbol = ''; + this.currencySymbol = this.getMetadata().get(['app', 'currency', 'symbolMap', this.currency]) || ''; }, - drow: function () { + draw: function () { var self = this; this.flotr.draw(this.$container.get(0), this.chartData, { - colors: this.colors, + colors: this.colorList, shadowSize: false, pie: { show: true, @@ -69,18 +74,25 @@ Espo.define('crm:views/dashlets/opportunities-by-lead-source', 'crm:views/dashle lineWidth: 1, fillOpacity: 1, sizeRatio: 0.8, + labelFormatter: function (total, value) { + var percentage = (100 * value / total).toFixed(2); + if (percentage < 7) return ''; + return ''+ percentage.toString() +'%' + ''; + } }, grid: { horizontalLines: false, verticalLines: false, outline: '', - color: this.outlineColor + tickColor: this.tickColor }, yaxis: { showLabels: false, + color: this.textColor }, xaxis: { showLabels: false, + color: this.textColor }, legend: { show: false, @@ -88,20 +100,26 @@ Espo.define('crm:views/dashlets/opportunities-by-lead-source', 'crm:views/dashle mouse: { track: true, relative: true, + lineColor: this.hoverColor, trackFormatter: function (obj) { - return self.formatNumber(obj.y) + ' ' + self.currency; - }, + var value = self.currencySymbol + self.formatNumber(obj.y, true); + + var fraction = obj.fraction || 0; + var percentage = (100 * fraction).toFixed(2).toString(); + + return (obj.series.label || self.translate('None')) + ':
' + value + ' / ' + percentage + '%'; + } }, legend: { show: true, - noColumns: 8, + noColumns: this.getLegentColumnNumber(), container: this.$el.find('.legend-container'), - labelBoxMargin: 0 - }, + labelBoxMargin: 0, + labelFormatter: self.labelFormatter.bind(self), + labelBoxBorderColor: 'transparent', + backgroundOpacity: 0 + } }); - }, - + } }); }); - - diff --git a/client/modules/crm/src/views/dashlets/opportunities-by-stage.js b/client/modules/crm/src/views/dashlets/opportunities-by-stage.js index 3641b427c0..8cc5421715 100644 --- a/client/modules/crm/src/views/dashlets/opportunities-by-stage.js +++ b/client/modules/crm/src/views/dashlets/opportunities-by-stage.js @@ -38,7 +38,12 @@ Espo.define('crm:views/dashlets/opportunities-by-stage', 'crm:views/dashlets/abs }, url: function () { - return 'Opportunity/action/reportByStage?dateFrom=' + this.getOption('dateFrom') + '&dateTo=' + this.getOption('dateTo'); + var url = 'Opportunity/action/reportByStage?dateFilter='+ this.getDateFilter(); + + if (this.getDateFilter() === 'between') { + url += '&dateFrom=' + this.getOption('dateFrom') + '&dateTo=' + this.getOption('dateTo'); + } + return url; }, prepareData: function (response) { @@ -60,7 +65,7 @@ Espo.define('crm:views/dashlets/opportunities-by-stage', 'crm:views/dashlets/abs data: [[item.value, d.length - i]], label: this.getLanguage().translateOption(item.stage, 'stage', 'Opportunity'), } - if (item.stage == 'Closed Won') { + if (item.stagsuccessColore == 'Closed Won') { o.color = this.successColor; } data.push(o); @@ -73,13 +78,13 @@ Espo.define('crm:views/dashlets/opportunities-by-stage', 'crm:views/dashlets/abs setup: function () { this.currency = this.getConfig().get('defaultCurrency'); - this.currencySymbol = ''; + this.currencySymbol = this.getMetadata().get(['app', 'currency', 'symbolMap', this.currency]) || ''; }, - drow: function () { + draw: function () { var self = this; this.flotr.draw(this.$container.get(0), this.chartData, { - colors: this.colors, + colors: this.colorList, shadowSize: false, bars: { show: true, @@ -87,44 +92,53 @@ Espo.define('crm:views/dashlets/opportunities-by-stage', 'crm:views/dashlets/abs shadowSize: 0, lineWidth: 1, fillOpacity: 1, - barWidth: 0.5, + barWidth: 0.5 }, grid: { horizontalLines: false, outline: 'sw', - color: this.outlineColor + color: this.gridColor, + tickColor: this.tickColor }, yaxis: { min: 0, showLabels: false, + color: this.textColor }, xaxis: { min: 0, + color: this.textColor, tickFormatter: function (value) { - if (value != 0) { - return self.formatNumber(value) + ' ' + self.currency; + if (value == 0) { + return ''; + } + if (value % 1 == 0) { + return self.currencySymbol + self.formatNumber(Math.floor(value)).toString(); } return ''; - }, + } }, mouse: { track: true, relative: true, position: 's', + lineColor: this.hoverColor, trackFormatter: function (obj) { - return self.formatNumber(obj.x) + ' ' + self.currency; - }, + var label = (obj.series.label || self.translate('None')); + var value = label + ':
' + self.currencySymbol + self.formatNumber(obj.x, true); + return value; + } }, legend: { show: true, - noColumns: 5, + noColumns: this.getLegentColumnNumber(), container: this.$el.find('.legend-container'), - labelBoxMargin: 0 - }, + labelBoxMargin: 0, + labelFormatter: self.labelFormatter.bind(self), + labelBoxBorderColor: 'transparent', + backgroundOpacity: 0 + } }); - }, - + } }); }); - - diff --git a/client/modules/crm/src/views/dashlets/options/chart.js b/client/modules/crm/src/views/dashlets/options/chart.js new file mode 100644 index 0000000000..f66f0194cf --- /dev/null +++ b/client/modules/crm/src/views/dashlets/options/chart.js @@ -0,0 +1,49 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM - Open Source CRM application. + * Copyright (C) 2014-2018 Yuri Kuznetsov, Taras Machyshyn, Oleksiy Avramenko + * Website: http://www.espocrm.com + * + * EspoCRM is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * EspoCRM is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with EspoCRM. If not, see http://www.gnu.org/licenses/. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU General Public License version 3. + * + * In accordance with Section 7(b) of the GNU General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +Espo.define('crm:views/dashlets/options/chart', 'views/dashlets/options/base', function (Dep) { + + return Dep.extend({ + + setupBeforeFinal: function () { + this.listenTo(this.model, 'change:dateFilter', this.controlDateFilter); + this.controlDateFilter(); + }, + + controlDateFilter: function () { + if (this.model.get('dateFilter') === 'between') { + this.showField('dateFrom'); + this.showField('dateTo'); + } else { + this.hideField('dateFrom'); + this.hideField('dateTo'); + } + } + + }); +}); diff --git a/client/modules/crm/src/views/dashlets/sales-by-month.js b/client/modules/crm/src/views/dashlets/sales-by-month.js index cc28fb8072..524370083d 100644 --- a/client/modules/crm/src/views/dashlets/sales-by-month.js +++ b/client/modules/crm/src/views/dashlets/sales-by-month.js @@ -38,17 +38,28 @@ Espo.define('crm:views/dashlets/sales-by-month', 'crm:views/dashlets/abstract/ch }, url: function () { - return 'Opportunity/action/reportSalesByMonth?dateFrom=' + this.getOption('dateFrom') + '&dateTo=' + this.getOption('dateTo'); + var url = 'Opportunity/action/reportSalesByMonth?dateFilter='+ this.getDateFilter(); + + if (this.getDateFilter() === 'between') { + url += '&dateFrom=' + this.getOption('dateFrom') + '&dateTo=' + this.getOption('dateTo'); + } + return url; + }, + + getLegentHeight: function () { + return 0; }, prepareData: function (response) { - var months = this.months = Object.keys(response).sort(); + var monthList = this.monthList = response.keyList; + + var dataMap = response.dataMap || {}; var values = []; - for (var month in response) { - values.push(response[month]); - } + monthList.forEach(function (month) { + values.push(dataMap[month]); + }, this); this.chartData = []; @@ -71,12 +82,12 @@ Espo.define('crm:views/dashlets/sales-by-month', 'crm:views/dashlets/abstract/ch setup: function () { this.currency = this.getConfig().get('defaultCurrency'); - this.currencySymbol = ''; + this.currencySymbol = this.getMetadata().get(['app', 'currency', 'symbolMap', this.currency]) || ''; this.colorBad = this.successColor; }, - drow: function () { + draw: function () { var self = this; this.flotr.draw(this.$container.get(0), this.chartData, { shadowSize: false, @@ -86,31 +97,37 @@ Espo.define('crm:views/dashlets/sales-by-month', 'crm:views/dashlets/abstract/ch shadowSize: 0, lineWidth: 1, fillOpacity: 1, - barWidth: 0.5, + barWidth: 0.5 }, grid: { horizontalLines: true, verticalLines: false, outline: 'sw', - color: this.outlineColor + color: this.gridColor, + tickColor: this.tickColor }, yaxis: { min: 0, showLabels: true, + color: this.textColor, tickFormatter: function (value) { if (value == 0) { return ''; } - return self.formatNumber(value) + ' ' + self.currency; - }, + if (value % 1 == 0) { + return self.currencySymbol + self.formatNumber(Math.floor(value)).toString(); + } + return ''; + } }, xaxis: { min: 0, + color: this.textColor, tickFormatter: function (value) { if (value % 1 == 0) { var i = parseInt(value); - if (i in self.months) { - return moment(self.months[i] + '-01').format('MMM YYYY'); + if (i in self.monthList) { + return moment(self.monthList[i] + '-01').format('MMM YYYY'); } } return ''; @@ -119,13 +136,17 @@ Espo.define('crm:views/dashlets/sales-by-month', 'crm:views/dashlets/abstract/ch mouse: { track: true, relative: true, + lineColor: this.hoverColor, trackFormatter: function (obj) { - return self.formatNumber(obj.y) + ' ' + self.currency; - }, + var i = parseInt(obj.x); + var value = ''; + if (i in self.monthList) { + value += moment(self.monthList[i] + '-01').format('MMM YYYY') + ':
'; + } + return value + self.currencySymbol + self.formatNumber(obj.y, true); + } } - }); - }, + }) + } }); }); - - diff --git a/client/modules/crm/src/views/dashlets/sales-pipeline.js b/client/modules/crm/src/views/dashlets/sales-pipeline.js index bbc96c0975..98ba47b8de 100644 --- a/client/modules/crm/src/views/dashlets/sales-pipeline.js +++ b/client/modules/crm/src/views/dashlets/sales-pipeline.js @@ -38,7 +38,12 @@ Espo.define('crm:views/dashlets/sales-pipeline', 'crm:views/dashlets/abstract/ch }, url: function () { - return 'Opportunity/action/reportSalesPipeline?dateFrom=' + this.getOption('dateFrom') + '&dateTo=' + this.getOption('dateTo'); + var url = 'Opportunity/action/reportSalesPipeline?dateFilter='+ this.getDateFilter(); + + if (this.getDateFilter() === 'between') { + url += '&dateFrom=' + this.getOption('dateFrom') + '&dateTo=' + this.getOption('dateTo'); + } + return url; }, prepareData: function (response) { @@ -80,63 +85,15 @@ Espo.define('crm:views/dashlets/sales-pipeline', 'crm:views/dashlets/abstract/ch setup: function () { this.currency = this.getConfig().get('defaultCurrency'); - this.currencySymbol = ''; - - var data = [ - { - value: 12000, - stage: 'Prospecting' - }, - { - value: 5050, - stage: 'Qualification' - }, - { - value: 4050, - stage: 'Needs Analysis' - }, - { - value: 3230, - stage: 'Value Proposition' - }, - { - value: 2000, - stage: 'Proposal/Price Quote' - }, - { - value: 1200.5, - stage: 'Negotiation/Review' - }, - { - value: 700, - stage: 'Closed Won' - }, - ]; + this.currencySymbol = this.getMetadata().get(['app', 'currency', 'symbolMap', this.currency]) || ''; this.chartData = []; - - for (var i = 0; i < data.length; i++) { - var item = data[i]; - var value = item.value; - var nextValue = ((i + 1) < data.length) ? data[i + 1].value : value; - var o = { - data: [[i, value], [i + 1, nextValue]], - label: item.stage - }; - - this.chartData.push(o); - } - - this.maxY = 1000; - if (data.length) { - this.maxY = data[0].value + (data[0].value / 20); - } }, - drow: function () { + draw: function () { var self = this; - var colors = Espo.Utils.clone(this.colors); + var colors = Espo.Utils.clone(this.colorList); this.chartData.forEach(function (item, i) { if (i + 1 > colors.length) { @@ -154,46 +111,51 @@ Espo.define('crm:views/dashlets/sales-pipeline', 'crm:views/dashlets/abstract/ch lines: { show: true, fill: true, - fillOpacity: 1, + fillOpacity: 1 }, points: { - show: true, + show: true }, grid: { - horizontalLines: false, - outline: 'sw', - color: this.outlineColor + color: this.tickColor, + verticalLines: false, + outline: 'ew', + tickColor: this.tickColor }, yaxis: { min: 0, max: this.maxY, - showLabels: false, + showLabels: false }, xaxis: { min: 0, - showLabels: false, + showLabels: false }, mouse: { track: true, relative: true, position: 'ne', + lineColor: this.hoverColor, trackFormatter: function (obj) { if (obj.x >= self.chartData.length) { return null; } - return self.formatNumber(obj.y) + ' ' + self.currency; - }, + var label = self.chartData[parseInt(obj.x)].label; + var label = (label || self.translate('None')); + return label + ':
' + self.currencySymbol + self.formatNumber(obj.y, true); + } }, legend: { show: true, - noColumns: 5, + noColumns: this.getLegentColumnNumber(), container: this.$el.find('.legend-container'), - labelBoxMargin: 0 - }, + labelBoxMargin: 0, + labelFormatter: self.labelFormatter.bind(self), + labelBoxBorderColor: 'transparent', + backgroundOpacity: 0 + } }); - }, + } }); }); - - diff --git a/client/res/layout-types/record.tpl b/client/res/layout-types/record.tpl index e3311977d9..a5fef71fe7 100644 --- a/client/res/layout-types/record.tpl +++ b/client/res/layout-types/record.tpl @@ -19,8 +19,55 @@ <% _.each(panel.rows, function (row, rowNumber) { %>
<% _.each(row, function (cell, cellNumber) { %> + + <% + var spanClassBase; + if (columnCount === 1) { + spanClassBase = 'col-sm-12'; + } else if (columnCount === 2) { + spanClassBase = 'col-sm-6'; + } else if (columnCount === 3) { + spanClassBase = 'col-sm-4'; + } else if (columnCount === 4) { + spanClassBase = 'col-md-3 col-sm-6'; + } else { + spanClass = 'col-sm-12'; + } + %> <% if (cell != false) { %> -
+ <% + var spanClass; + if (columnCount === 1 || cell.fullWidth) { + spanClass = 'col-sm-12'; + } else if (columnCount === 2) { + if (cell.span === 2) { + spanClass = 'col-sm-12'; + } else { + spanClass = 'col-sm-6'; + } + } else if (columnCount === 3) { + if (cell.span === 2) { + spanClass = 'col-sm-8'; + } else if (cell.span === 3) { + spanClass = 'col-sm-12'; + } else { + spanClass = 'col-sm-4'; + } + } else if (columnCount === 4) { + if (cell.span === 2) { + spanClass = 'col-sm-6'; + } else if (cell.span === 3) { + spanClass = 'col-sm-9'; + } else if (cell.span === 4) { + spanClass = 'col-sm-12'; + } else { + spanClass = 'col-md-3 col-sm-6'; + } + } else { + spanClass = 'col-sm-12'; + } + %> +
<% if (!cell.noLabel) { %>
<% } else { %> -
+
<% } %> <% }); %>
diff --git a/client/res/templates/about.tpl b/client/res/templates/about.tpl index 57aef47381..395179258c 100644 --- a/client/res/templates/about.tpl +++ b/client/res/templates/about.tpl @@ -6,7 +6,6 @@
-
Version {{version}}

Copyright © 2014-2018 EspoCRM: Yuri Kuznetsov, Taras Machyshyn, Oleksiy Avramenko. @@ -39,7 +38,6 @@ In accordance with Section 7(b) of the GNU General Public License version 3, these Appropriate Legal Notices must retain the display of the "EspoCRM" word.

-
diff --git a/client/res/templates/admin/integrations/edit.tpl b/client/res/templates/admin/integrations/edit.tpl index 1440ab6750..958c0050da 100644 --- a/client/res/templates/admin/integrations/edit.tpl +++ b/client/res/templates/admin/integrations/edit.tpl @@ -6,7 +6,7 @@
-
+
{{{enabled}}}
diff --git a/client/res/templates/admin/integrations/oauth2.tpl b/client/res/templates/admin/integrations/oauth2.tpl index 5f84be33f1..a827988adb 100644 --- a/client/res/templates/admin/integrations/oauth2.tpl +++ b/client/res/templates/admin/integrations/oauth2.tpl @@ -6,7 +6,7 @@
-
+
{{{enabled}}}
diff --git a/client/res/templates/admin/layouts/grid-panel.tpl b/client/res/templates/admin/layouts/grid-panel.tpl index 2a32e6f214..e87182313b 100644 --- a/client/res/templates/admin/layouts/grid-panel.tpl +++ b/client/res/templates/admin/layouts/grid-panel.tpl @@ -1,4 +1,4 @@ -
+
  diff --git a/client/res/templates/admin/layouts/grid.tpl b/client/res/templates/admin/layouts/grid.tpl index 5ab8f6a478..d24c78dc23 100644 --- a/client/res/templates/admin/layouts/grid.tpl +++ b/client/res/templates/admin/layouts/grid.tpl @@ -142,7 +142,7 @@ {{/if}} - -
+ +
diff --git a/client/res/templates/fields/currency/list-1.tpl b/client/res/templates/fields/currency/list-1.tpl new file mode 100644 index 0000000000..e1b2948b5c --- /dev/null +++ b/client/res/templates/fields/currency/list-1.tpl @@ -0,0 +1,5 @@ +{{#if value}} + {{value}} {{currencyValue}} +{{else}} + {{translate 'None'}} +{{/if}} diff --git a/client/res/templates/fields/currency/list-2.tpl b/client/res/templates/fields/currency/list-2.tpl new file mode 100644 index 0000000000..826723bf51 --- /dev/null +++ b/client/res/templates/fields/currency/list-2.tpl @@ -0,0 +1,5 @@ +{{#if value}} + {{currencySymbol}}{{value}} +{{else}} + {{translate 'None'}} +{{/if}} diff --git a/client/res/templates/fields/currency/list.tpl b/client/res/templates/fields/currency/list.tpl new file mode 100644 index 0000000000..e1b2948b5c --- /dev/null +++ b/client/res/templates/fields/currency/list.tpl @@ -0,0 +1,5 @@ +{{#if value}} + {{value}} {{currencyValue}} +{{else}} + {{translate 'None'}} +{{/if}} diff --git a/client/res/templates/fields/int/list.tpl b/client/res/templates/fields/int/list.tpl new file mode 100644 index 0000000000..0e4cb212f2 --- /dev/null +++ b/client/res/templates/fields/int/list.tpl @@ -0,0 +1 @@ +{{#if isNotEmpty}}{{value}}{{/if}} diff --git a/client/src/collection.js b/client/src/collection.js index 719365afad..bc80cdf7d3 100644 --- a/client/src/collection.js +++ b/client/src/collection.js @@ -46,6 +46,8 @@ Espo.define('collection', [], function () { whereAdditional: null, + lengthCorrection: 0, + _user: null, initialize: function (models, options) { @@ -66,6 +68,11 @@ Espo.define('collection', [], function () { Backbone.Collection.prototype._onModelEvent.apply(this, arguments); }, + reset: function (models, options) { + this.lengthCorrection = 0; + Backbone.Collection.prototype.reset.call(this, models, options); + }, + sort: function (field, asc) { this.sortBy = field; this.asc = asc; @@ -122,7 +129,7 @@ Espo.define('collection', [], function () { options.data.maxSize = options.maxSize; } - options.data.offset = options.more ? this.length : this.offset; + options.data.offset = options.more ? this.length + this.lengthCorrection : this.offset; options.data.sortBy = this.sortBy; options.data.asc = this.asc; options.data.where = this.getWhere(); @@ -143,5 +150,3 @@ Espo.define('collection', [], function () { return Collection; }); - - diff --git a/client/src/views/admin/layouts/grid.js b/client/src/views/admin/layouts/grid.js index 59fb7be541..73e4758ba6 100644 --- a/client/src/views/admin/layouts/grid.js +++ b/client/src/views/admin/layouts/grid.js @@ -371,6 +371,10 @@ Espo.define('views/admin/layouts/grid', 'views/admin/layouts/base', function (De style: $(this).find('header').data('style') || 'default', rows: [] }; + var name = $(this).find('header').data('name'); + if (name) { + o.name = name; + } if ($label.attr('data-is-custom')) { o.customLabel = $label.text(); } else { diff --git a/client/src/views/dashlets/options/base.js b/client/src/views/dashlets/options/base.js index e80e12e4eb..7fff83c8d4 100644 --- a/client/src/views/dashlets/options/base.js +++ b/client/src/views/dashlets/options/base.js @@ -104,6 +104,8 @@ Espo.define('views/dashlets/options/base', ['views/modal', 'views/record/detail' model.dashletName = this.name; + this.setupBeforeFinal(); + this.createView('record', 'views/record/detail-middle', { model: model, recordHelper: this.recordHelper, @@ -121,6 +123,8 @@ Espo.define('views/dashlets/options/base', ['views/modal', 'views/record/detail' this.header = this.getLanguage().translate('Dashlet Options') + ': ' + this.getLanguage().translate(this.name, 'dashlets'); }, + setupBeforeFinal: function () {}, + fetchAttributes: function () { var attributes = {}; this.fieldList.forEach(function (field) { diff --git a/client/src/views/fields/currency.js b/client/src/views/fields/currency.js index 96a8260b73..bc73dceff5 100644 --- a/client/src/views/fields/currency.js +++ b/client/src/views/fields/currency.js @@ -40,7 +40,11 @@ Espo.define('views/fields/currency', 'views/fields/float', function (Dep) { detailTemplate2: 'fields/currency/detail-2', - listTemplate: 'fields/currency/detail', + listTemplate: 'fields/currency/list', + + listTemplate1: 'fields/currency/list-1', + + listTemplate2: 'fields/currency/list-2', detailTemplateNoCurrency: 'fields/currency/detail-no-currency', @@ -70,7 +74,12 @@ Espo.define('views/fields/currency', 'views/fields/float', function (Dep) { _getTemplateName: function () { if (this.mode == 'detail' || this.mode == 'list') { - var prop = 'detailTemplate' + this.getCurrencyFormat().toString(); + var prop + if (this.mode == 'list') { + var prop = 'listTemplate' + this.getCurrencyFormat().toString(); + } else { + var prop = 'detailTemplate' + this.getCurrencyFormat().toString(); + } if (this.options.hideCurrency) { prop = 'detailTemplateNoCurrency'; } diff --git a/client/src/views/fields/int.js b/client/src/views/fields/int.js index 3359ba068d..25bedd932b 100644 --- a/client/src/views/fields/int.js +++ b/client/src/views/fields/int.js @@ -32,6 +32,8 @@ Espo.define('views/fields/int', 'views/fields/base', function (Dep) { type: 'int', + listTemplate: 'fields/int/list', + detailTemplate: 'fields/int/detail', editTemplate: 'fields/int/edit', diff --git a/client/src/views/preferences/record/edit.js b/client/src/views/preferences/record/edit.js index fd345c61ec..a4debfd01b 100644 --- a/client/src/views/preferences/record/edit.js +++ b/client/src/views/preferences/record/edit.js @@ -87,6 +87,10 @@ Espo.define('views/preferences/record/edit', 'views/record/edit', function (Dep) setup: function () { Dep.prototype.setup.call(this); + if (this.model.get('isPortalUser')) { + this.layoutName = 'detailPortal'; + } + if (this.model.id == this.getUser().id) { this.on('after:save', function () { var data = this.model.toJSON(); diff --git a/client/src/views/record/base.js b/client/src/views/record/base.js index 5733c0a313..651077622b 100644 --- a/client/src/views/record/base.js +++ b/client/src/views/record/base.js @@ -461,12 +461,14 @@ Espo.define('views/record/base', ['view', 'view-record-helper', 'dynamic-logic'] var r = xhr.getAllResponseHeaders(); var response = null; - if (xhr.status == 409) { - var header = xhr.getResponseHeader('X-Status-Reason'); - try { - var response = JSON.parse(header); - } catch (e) { - console.error('Error while parsing response'); + if (~[409, 500].indexOf(xhr.status)) { + var statusReasonHeader = xhr.getResponseHeader('X-Status-Reason'); + if (statusReasonHeader) { + try { + var response = JSON.parse(statusReasonHeader); + } catch (e) { + console.error('Could not parse X-Status-Reason header'); + } } } @@ -476,10 +478,11 @@ Espo.define('views/record/base', ['view', 'view-record-helper', 'dynamic-logic'] } } - if (response) { - if (response.reason == 'Duplicate') { + if (response && response.reason) { + var methodName = 'errorHandler' + Espo.Utils.upperCaseFirst(response.reason.toString()); + if (methodName in this) { xhr.errorIsHandled = true; - self.showDuplicate(response.data); + this[methodName](response.data); } } @@ -599,7 +602,7 @@ Espo.define('views/record/base', ['view', 'view-record-helper', 'dynamic-logic'] this.model.set(defaultHash, {silent: true}); }, - showDuplicate: function (duplicates) { + errorHandlerDuplicate: function (duplicates) { }, _handleDependencyAttributes: function () { diff --git a/client/src/views/record/detail.js b/client/src/views/record/detail.js index 7444e2ea7b..b4dd934941 100644 --- a/client/src/views/record/detail.js +++ b/client/src/views/record/detail.js @@ -851,7 +851,7 @@ Espo.define('views/record/detail', ['views/record/base', 'view-record-helper'], this.enableButtons(); }, - showDuplicate: function (duplicates) { + errorHandlerDuplicate: function (duplicates) { this.notify(false); this.createView('duplicate', 'views/modals/duplicate', { scope: this.entityType, @@ -1106,6 +1106,9 @@ Espo.define('views/record/detail', ['views/record/base', 'view-record-helper'], if ('noLabel' in cellDefs) { cell.noLabel = cellDefs.noLabel; } + if ('span' in cellDefs) { + cell.span = cellDefs.span; + } row.push(cell); } @@ -1123,9 +1126,11 @@ Espo.define('views/record/detail', ['views/record/base', 'view-record-helper'], return; } + var gridLayoutType = this.gridLayoutType || 'record'; + if (this.detailLayout) { this.gridLayout = { - type: 'record', + type: gridLayoutType, layout: this.convertDetailLayout(this.detailLayout) }; callback(this.gridLayout); @@ -1134,7 +1139,7 @@ Espo.define('views/record/detail', ['views/record/base', 'view-record-helper'], this._helper.layoutManager.get(this.model.name, this.layoutName, function (simpleLayout) { this.gridLayout = { - type: 'record', + type: gridLayoutType, layout: this.convertDetailLayout(simpleLayout) }; callback(this.gridLayout); diff --git a/client/src/views/record/list.js b/client/src/views/record/list.js index 7dc23acf4a..1a714d0eec 100644 --- a/client/src/views/record/list.js +++ b/client/src/views/record/list.js @@ -1151,10 +1151,10 @@ Espo.define('views/record/list', 'view', function (Dep) { var final = function () { $showMore.parent().append($showMore); if ( - (collection.total > collection.length || collection.total == -1) + (collection.total > collection.length + collection.lengthCorrection || collection.total == -1) ) { - this.$el.find('.more-count').text(collection.total - this.collection.length); - $showMore.removeClass('hide'); + this.$el.find('.more-count').text(collection.total - collection.length - collection.lengthCorrection); + $showMore.removeClass('hidden'); } $showMore.children('a').removeClass('disabled'); @@ -1169,12 +1169,13 @@ Espo.define('views/record/list', 'view', function (Dep) { var success = function () { this.notify(false); - $showMore.addClass('hide'); - - var temp = collection.models[initialCount - 1]; + $showMore.addClass('hidden'); var rowCount = collection.length - initialCount; var rowsReady = 0; + if (collection.length <= initialCount) { + final(); + } for (var i = initialCount; i < collection.length; i++) { var model = collection.at(i); this.buildRow(i, model, function (view) { @@ -1197,6 +1198,12 @@ Espo.define('views/record/list', 'view', function (Dep) { this.noRebuild = true; }.bind(this); + this.listenToOnce(collection, 'update', function (collection, o) { + if (o.changes.merged.length) { + collection.lengthCorrection += o.changes.merged.length; + } + }, this); + collection.fetch({ success: success, remove: false, diff --git a/client/src/views/role/modals/add-field.js b/client/src/views/role/modals/add-field.js index a847568e3c..e5733edc41 100644 --- a/client/src/views/role/modals/add-field.js +++ b/client/src/views/role/modals/add-field.js @@ -67,7 +67,9 @@ Espo.define('views/role/modals/add-field', 'views/modal', function (Dep) { var d = fields[field]; if (field in this.options.ignoreFieldList) return; if (d.disabled) return; - + if (this.getMetadata().get(['app', this.options.type, 'mandatory', 'scopeFieldLevel', this.scope, field]) !== null) { + return; + } fieldList.push(field); }, this); diff --git a/client/src/views/role/record/detail.js b/client/src/views/role/record/detail.js index 70f558f195..340cc69f16 100644 --- a/client/src/views/role/record/detail.js +++ b/client/src/views/role/record/detail.js @@ -38,6 +38,8 @@ Espo.define('views/role/record/detail', 'views/record/detail', function (Dep) { editModeDisabled: true, + columnCount: 3, + setup: function () { Dep.prototype.setup.call(this); this.createView('extra', this.tableView, { diff --git a/client/src/views/role/record/edit.js b/client/src/views/role/record/edit.js index 73d99e42b3..a0d1c2a808 100644 --- a/client/src/views/role/record/edit.js +++ b/client/src/views/role/record/edit.js @@ -36,6 +36,8 @@ Espo.define('views/role/record/edit', 'views/record/edit', function (Dep) { isWide: true, + columnCount: 3, + events: _.extend({ }, Dep.prototype.events), diff --git a/client/src/views/role/record/table.js b/client/src/views/role/record/table.js index d1d962c616..e11ddd4a0b 100644 --- a/client/src/views/role/record/table.js +++ b/client/src/views/role/record/table.js @@ -512,7 +512,8 @@ Espo.define('views/role/record/table', 'view', function (Dep) { this.createView('addField', 'views/role/modals/add-field', { scope: scope, - ignoreFieldList: ignoreFieldList + ignoreFieldList: ignoreFieldList, + type: this.type }, function (view) { view.render(); diff --git a/client/src/views/site/navbar.js b/client/src/views/site/navbar.js index bccb7091d0..63a2b2b74e 100644 --- a/client/src/views/site/navbar.js +++ b/client/src/views/site/navbar.js @@ -61,14 +61,7 @@ Espo.define('views/site/navbar', 'view', function (Dep) { this.quickCreate(scope); }, 'click a.minimizer': function () { - var $body = $('body'); - if ($body.hasClass('minimized')) { - $body.removeClass('minimized'); - this.getStorage().clear('state', 'layoutMinimized'); - } else { - $body.addClass('minimized'); - this.getStorage().set('state', 'layoutMinimized', true); - } + this.switchMinimizer(); }, 'click a.action': function (e) { var $el = $(e.currentTarget); @@ -83,6 +76,22 @@ Espo.define('views/site/navbar', 'view', function (Dep) { } }, + switchMinimizer: function () { + var $body = $('body'); + if ($body.hasClass('minimized')) { + $body.removeClass('minimized'); + this.getStorage().clear('state', 'layoutMinimized'); + } else { + $body.addClass('minimized'); + this.getStorage().set('state', 'layoutMinimized', true); + } + if (window.Event) { + try { + window.dispatchEvent(new Event('resize')); + } catch (e) {} + } + }, + getLogoSrc: function () { var companyLogoId = this.getConfig().get('companyLogoId'); if (!companyLogoId) { @@ -198,7 +207,7 @@ Espo.define('views/site/navbar', 'view', function (Dep) { $moreDd = $('#nav-more-tabs-dropdown'); $moreLi = $moreDd.closest('li'); - var navbarBaseWidth = this.getThemeManager().getParam('navbarBaseWidth') || 512; + var navbarBaseWidth = this.getThemeManager().getParam('navbarBaseWidth') || 516; var updateWidth = function () { var windowWidth = $(window.document).width(); diff --git a/client/src/views/user/modals/access.js b/client/src/views/user/modals/access.js index c03d4eff0a..9c727c9edc 100644 --- a/client/src/views/user/modals/access.js +++ b/client/src/views/user/modals/access.js @@ -65,10 +65,20 @@ Espo.define('views/user/modals/access', 'views/modal', function (Dep) { } ]; + var fieldTable = Espo.Utils.cloneDeep(this.options.aclData.fieldTable || {}); + for (var scope in fieldTable) { + var scopeData = fieldTable[scope] || {}; + for (var field in scopeData) { + if (this.getMetadata().get(['app', 'acl', 'mandatory', 'scopeFieldLevel', scope, field]) !== null) { + delete scopeData[field]; + } + } + } + this.createView('table', 'views/role/record/table', { acl: { data: this.options.aclData.table, - fieldData: this.options.aclData.fieldTable, + fieldData: fieldTable, }, final: true }); diff --git a/client/src/views/user/record/edit.js b/client/src/views/user/record/edit.js index 1b40f06fdb..43c4c8c194 100644 --- a/client/src/views/user/record/edit.js +++ b/client/src/views/user/record/edit.js @@ -185,9 +185,12 @@ Espo.define('views/user/record/edit', ['views/record/edit', 'views/user/record/d } return data; + }, + + errorHandlerUserNameExists: function () { + Espo.Ui.error(this.translate('userNameExists', 'messages', 'User')) } }); }); - diff --git a/frontend/less/espo/custom.less b/frontend/less/espo/custom.less index 6024581a84..fc889c6a06 100644 --- a/frontend/less/espo/custom.less +++ b/frontend/less/espo/custom.less @@ -220,9 +220,24 @@ table.table > thead th { padding: 0 0 10px; } - .margin { - margin: 8px 0; + margin: @table-cell-padding 0; +} + +.margin-top { + margin-top: @table-cell-padding; +} + +.margin-bottom { + margin-bottom: @table-cell-padding; +} + +.margin-bottom.margin { + margin-top: 0; +} + +.margin.list-group-item:first-child { + margin-top: 0; } ul.dropdown-menu > li.checkbox { @@ -319,17 +334,45 @@ ul.dropdown-menu > li.checkbox:last-child { } .field .link-container { - margin-bottom: -1px; + margin-bottom: 0; +} + +.list-group-item { + margin-bottom: 0; + border-top-width: 0; +} + +.list-group-item:first-child { + border-top-width: 1px; +} + +.list-group-item.ui-sortable-helper { + border-top-width: 1px !important; + border-bottom-width: 1px !important; + border-left-width: 1px !important; + border-right-width: 1px !important; } .panel > .panel-body.panel-body-form { padding-bottom: @panel-padding - @form-group-margin-bottom; } +.list-group-item { + background-color: transparent; +} + .panel-body .field > .link-container > .list-group-item { background-color: @panel-bg; } +.panel-body .list-group-item { + background-color: @panel-bg; +} + +.field .link-container .list-group-item:last-child { + border-bottom-width: 0; +} + .field .link-container .list-group-item > div { margin: -3px 0 -3px; } @@ -514,7 +557,26 @@ select[multiple].input-sm { z-index: 3; } -.panel-body > p:last-child { +.panel-body, +section { + > p:first-child, + > h1:first-child, + > h2:first-child, + > h3:first-child, + > h4:first-child, + > h5:first-child { + margin-top: 0; + } + + > p:last-child, + ul, + li, + .table { + margin-bottom: 0; + } +} + +.well > p:last-child { margin-bottom: 0; } @@ -578,10 +640,30 @@ select[multiple].input-sm { > table th:last-child { padding-right: @panel-padding; } + + > .list-group-item { + border-left-width: 0; + border-right-width: 0; + } + + > .table-bordered { + border-left: 0; + border-right: 0; + + td:last-child, + th:last-child { + border-right: 0; + } + + td:first-child, + th:first-child { + border-left: 0; + } + } } .panel-body .no-bottom-margin { - margin-bottom: -@panel-padding; + margin-bottom: -@panel-padding !important; } .table.less-padding > thead > tr > th, @@ -1194,19 +1276,48 @@ stream-head-text-container .span { line-height: @line-height-computed; } -.list-group-item { - background-color: transparent; -} - .note-editable blockquote { font-size: @font-size-base; } +.dashlet-body .legend-container { + overflow-x: hidden; + overflow-y: hidden; +} + .legend-container table td { padding: 2px 2px; line-height: 1em; } +.flotr-mouse-value { + cursor: default; + font-size: 0.9em; +} + +.flotr-grid-label-x { + max-height: 2.1em; + line-height: 1.1em; + overflow: hidden; +} + +.dashlet-body .legend-container td.flotr-legend-label > span { + max-height: 2em; + display: inline-block; + overflow: hidden; +} + +.legend-container .flotr-legend-color-box > div { + position: relative; + top: 1px; +} + +.legend-container .flotr-legend-color-box > div > div > div { + position: relative; + top: -2px; + left: -2px; +} + .textcomplete-item a { cursor: default; } diff --git a/install/core/Installer.php b/install/core/Installer.php index d68e06bfa7..b940100477 100644 --- a/install/core/Installer.php +++ b/install/core/Installer.php @@ -299,7 +299,15 @@ class Installer } if (!isset($entity)) { - $entity = $this->getEntityManager()->getEntity($entityName); + if (isset($data['name'])) { + $entity = $this->getEntityManager()->getRepository($entityName)->where(array( + 'name' => $data['name'], + ))->findOne(); + } + + if (!isset($entity)) { + $entity = $this->getEntityManager()->getEntity($entityName); + } } $entity->set($data); diff --git a/install/core/afterInstall/records.php b/install/core/afterInstall/records.php index 74fa0e0270..5ec8fd6864 100644 --- a/install/core/afterInstall/records.php +++ b/install/core/afterInstall/records.php @@ -33,57 +33,57 @@ return array( 'name' => 'Case-to-Email auto-reply', 'subject' => 'Case has been created', 'body' => '

{Person.name},

Case \'{Case.name}\' has been created with number {Case.number} and assigned to {User.name}.

', - 'isHtml ' => '1', - ), + 'isHtml ' => '1' + ) ), 'ScheduledJob' => array( array( 'name' => 'Check Group Email Accounts', 'job' => 'CheckInboundEmails', 'status' => 'Active', - 'scheduling' => '*/4 * * * *', + 'scheduling' => '*/2 * * * *' ), array( 'name' => 'Check Personal Email Accounts', 'job' => 'CheckEmailAccounts', 'status' => 'Active', - 'scheduling' => '*/5 * * * *', + 'scheduling' => '*/1 * * * *' ), array( 'name' => 'Send Email Reminders', 'job' => 'SendEmailReminders', 'status' => 'Active', - 'scheduling' => '*/2 * * * *', + 'scheduling' => '*/2 * * * *' ), array( 'name' => 'Send Email Notifications', 'job' => 'SendEmailNotifications', 'status' => 'Active', - 'scheduling' => '*/2 * * * *', + 'scheduling' => '*/2 * * * *' ), array( 'name' => 'Clean-up', 'job' => 'Cleanup', 'status' => 'Active', - 'scheduling' => '1 1 * * 0', + 'scheduling' => '1 1 * * 0' ), array( 'name' => 'Send Mass Emails', 'job' => 'ProcessMassEmail', 'status' => 'Active', - 'scheduling' => '15 * * * *', + 'scheduling' => '15 * * * *' ), array( 'name' => 'Auth Token Control', 'job' => 'AuthTokenControl', 'status' => 'Active', - 'scheduling' => '*/6 * * * *', + 'scheduling' => '*/6 * * * *' ), array( 'name' => 'Control Knowledge Base Article Status', 'job' => 'ControlKnowledgeBaseArticleStatus', 'status' => 'Active', - 'scheduling' => '10 1 * * *', + 'scheduling' => '10 1 * * *' ) - ), + ) ); \ No newline at end of file diff --git a/install/core/tpl/errors.tpl b/install/core/tpl/errors.tpl index 3a43db3eeb..981e94782c 100644 --- a/install/core/tpl/errors.tpl +++ b/install/core/tpl/errors.tpl @@ -1,6 +1,5 @@
{$errors}
-
+
+
+ +
+
diff --git a/install/core/tpl/header.tpl b/install/core/tpl/header.tpl index d224a356db..cf97a6dc9b 100644 --- a/install/core/tpl/header.tpl +++ b/install/core/tpl/header.tpl @@ -1,4 +1,4 @@ -
+
diff --git a/install/core/tpl/setupConfirmation.tpl b/install/core/tpl/setupConfirmation.tpl index 2387d691c6..99f1c92c88 100644 --- a/install/core/tpl/setupConfirmation.tpl +++ b/install/core/tpl/setupConfirmation.tpl @@ -88,13 +88,14 @@ - -
- -
-
+
+
+
+
+ +