diff --git a/sources/app/Controller/TaskImport.php b/sources/app/Controller/TaskImport.php
new file mode 100644
index 0000000..0e9d216
--- /dev/null
+++ b/sources/app/Controller/TaskImport.php
@@ -0,0 +1,72 @@
+getProject();
+
+ $this->response->html($this->projectLayout('task_import/step1', array(
+ 'project' => $project,
+ 'values' => $values,
+ 'errors' => $errors,
+ 'max_size' => ini_get('upload_max_filesize'),
+ 'delimiters' => Csv::getDelimiters(),
+ 'enclosures' => Csv::getEnclosures(),
+ 'title' => t('Import tasks from CSV file'),
+ )));
+ }
+
+ /**
+ * Process CSV file
+ *
+ */
+ public function step2()
+ {
+ $project = $this->getProject();
+ $values = $this->request->getValues();
+ $filename = $this->request->getFilePath('file');
+
+ if (! file_exists($filename)) {
+ $this->step1($values, array('file' => array(t('Unable to read your file'))));
+ }
+
+ $this->taskImport->projectId = $project['id'];
+
+ $csv = new Csv($values['delimiter'], $values['enclosure']);
+ $csv->setColumnMapping($this->taskImport->getColumnMapping());
+ $csv->read($filename, array($this->taskImport, 'import'));
+
+ if ($this->taskImport->counter > 0) {
+ $this->session->flash(t('%d task(s) have been imported successfully.', $this->taskImport->counter));
+ } else {
+ $this->session->flashError(t('Nothing have been imported!'));
+ }
+
+ $this->response->redirect($this->helper->url->to('taskImport', 'step1', array('project_id' => $project['id'])));
+ }
+
+ /**
+ * Generate template
+ *
+ */
+ public function template()
+ {
+ $this->response->forceDownload('tasks.csv');
+ $this->response->csv(array($this->taskImport->getColumnMapping()));
+ }
+}
diff --git a/sources/app/Controller/UserImport.php b/sources/app/Controller/UserImport.php
new file mode 100644
index 0000000..32b9a86
--- /dev/null
+++ b/sources/app/Controller/UserImport.php
@@ -0,0 +1,66 @@
+response->html($this->template->layout('user_import/step1', array(
+ 'values' => $values,
+ 'errors' => $errors,
+ 'max_size' => ini_get('upload_max_filesize'),
+ 'delimiters' => Csv::getDelimiters(),
+ 'enclosures' => Csv::getEnclosures(),
+ 'title' => t('Import users from CSV file'),
+ )));
+ }
+
+ /**
+ * Process CSV file
+ *
+ */
+ public function step2()
+ {
+ $values = $this->request->getValues();
+ $filename = $this->request->getFilePath('file');
+
+ if (! file_exists($filename)) {
+ $this->step1($values, array('file' => array(t('Unable to read your file'))));
+ }
+
+ $csv = new Csv($values['delimiter'], $values['enclosure']);
+ $csv->setColumnMapping($this->userImport->getColumnMapping());
+ $csv->read($filename, array($this->userImport, 'import'));
+
+ if ($this->userImport->counter > 0) {
+ $this->session->flash(t('%d user(s) have been imported successfully.', $this->userImport->counter));
+ } else {
+ $this->session->flashError(t('Nothing have been imported!'));
+ }
+
+ $this->response->redirect($this->helper->url->to('userImport', 'step1'));
+ }
+
+ /**
+ * Generate template
+ *
+ */
+ public function template()
+ {
+ $this->response->forceDownload('users.csv');
+ $this->response->csv(array($this->userImport->getColumnMapping()));
+ }
+}
diff --git a/sources/app/Controller/WebNotification.php b/sources/app/Controller/WebNotification.php
new file mode 100644
index 0000000..dca5cb4
--- /dev/null
+++ b/sources/app/Controller/WebNotification.php
@@ -0,0 +1,50 @@
+getUserId();
+
+ $this->userUnreadNotification->markAllAsRead($user_id);
+ $this->response->redirect($this->helper->url->to('app', 'notifications', array('user_id' => $user_id)));
+ }
+
+ /**
+ * Mark a notification as read
+ *
+ * @access public
+ */
+ public function remove()
+ {
+ $user_id = $this->getUserId();
+ $notification_id = $this->request->getIntegerParam('notification_id');
+
+ $this->userUnreadNotification->markAsRead($user_id, $notification_id);
+ $this->response->redirect($this->helper->url->to('app', 'notifications', array('user_id' => $user_id)));
+ }
+
+ private function getUserId()
+ {
+ $user_id = $this->request->getIntegerParam('user_id');
+
+ if (! $this->userSession->isAdmin() && $user_id != $this->userSession->getId()) {
+ $user_id = $this->userSession->getId();
+ }
+
+ return $user_id;
+ }
+}
diff --git a/sources/app/Core/Csv.php b/sources/app/Core/Csv.php
new file mode 100644
index 0000000..bec400e
--- /dev/null
+++ b/sources/app/Core/Csv.php
@@ -0,0 +1,212 @@
+delimiter = $delimiter;
+ $this->enclosure = $enclosure;
+ }
+
+ /**
+ * Get list of delimiters
+ *
+ * @static
+ * @access public
+ * @return array
+ */
+ public static function getDelimiters()
+ {
+ return array(
+ ',' => t('Comma'),
+ ';' => t('Semi-colon'),
+ '\t' => t('Tab'),
+ '|' => t('Vertical bar'),
+ );
+ }
+
+ /**
+ * Get list of enclosures
+ *
+ * @static
+ * @access public
+ * @return array
+ */
+ public static function getEnclosures()
+ {
+ return array(
+ '"' => t('Double Quote'),
+ "'" => t('Single Quote'),
+ '' => t('None'),
+ );
+ }
+
+ /**
+ * Check boolean field value
+ *
+ * @static
+ * @access public
+ * @return integer
+ */
+ public static function getBooleanValue($value)
+ {
+ if (! empty($value)) {
+ $value = trim(strtolower($value));
+ return $value === '1' || $value{0}
+ === 't' ? 1 : 0;
+ }
+
+ return 0;
+ }
+
+ /**
+ * Output CSV file to standard output
+ *
+ * @static
+ * @access public
+ * @param array $rows
+ */
+ public static function output(array $rows)
+ {
+ $csv = new static;
+ $csv->write('php://output', $rows);
+ }
+
+ /**
+ * Define column mapping between CSV and SQL columns
+ *
+ * @access public
+ * @param array $columns
+ * @return Csv
+ */
+ public function setColumnMapping(array $columns)
+ {
+ $this->columns = $columns;
+ return $this;
+ }
+
+ /**
+ * Read CSV file
+ *
+ * @access public
+ * @param string $filename
+ * @param callable $callback Example: function(array $row, $line_number)
+ * @return Csv
+ */
+ public function read($filename, $callback)
+ {
+ $file = new SplFileObject($filename);
+ $file->setFlags(SplFileObject::READ_CSV);
+ $file->setCsvControl($this->delimiter, $this->enclosure);
+ $line_number = 0;
+
+ foreach ($file as $row) {
+ $row = $this->filterRow($row);
+
+ if (! empty($row) && $line_number > 0) {
+ call_user_func_array($callback, array($this->associateColumns($row), $line_number));
+ }
+
+ $line_number++;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Write CSV file
+ *
+ * @access public
+ * @param string $filename
+ * @param array $rows
+ * @return Csv
+ */
+ public function write($filename, array $rows)
+ {
+ $file = new SplFileObject($filename, 'w');
+
+ foreach ($rows as $row) {
+ $file->fputcsv($row, $this->delimiter, $this->enclosure);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Associate columns header with row values
+ *
+ * @access private
+ * @param array $row
+ * @return array
+ */
+ private function associateColumns(array $row)
+ {
+ $line = array();
+ $index = 0;
+
+ foreach ($this->columns as $sql_name => $csv_name) {
+ if (isset($row[$index])) {
+ $line[$sql_name] = $row[$index];
+ } else {
+ $line[$sql_name] = '';
+ }
+
+ $index++;
+ }
+
+ return $line;
+ }
+
+ /**
+ * Filter empty rows
+ *
+ * @access private
+ * @param array $row
+ * @return array
+ */
+ private function filterRow(array $row)
+ {
+ return array_filter($row);
+ }
+}
diff --git a/sources/app/Core/DateParser.php b/sources/app/Core/DateParser.php
new file mode 100644
index 0000000..6577af0
--- /dev/null
+++ b/sources/app/Core/DateParser.php
@@ -0,0 +1,240 @@
+= $start && $date <= $end;
+ }
+
+ /**
+ * Get the total number of hours between 2 datetime objects
+ * Minutes are rounded to the nearest quarter
+ *
+ * @access public
+ * @param DateTime $d1
+ * @param DateTime $d2
+ * @return float
+ */
+ public function getHours(DateTime $d1, DateTime $d2)
+ {
+ $seconds = $this->getRoundedSeconds(abs($d1->getTimestamp() - $d2->getTimestamp()));
+ return round($seconds / 3600, 2);
+ }
+
+ /**
+ * Round the timestamp to the nearest quarter
+ *
+ * @access public
+ * @param integer $seconds Timestamp
+ * @return integer
+ */
+ public function getRoundedSeconds($seconds)
+ {
+ return (int) round($seconds / (15 * 60)) * (15 * 60);
+ }
+
+ /**
+ * Return a timestamp if the given date format is correct otherwise return 0
+ *
+ * @access public
+ * @param string $value Date to parse
+ * @param string $format Date format
+ * @return integer
+ */
+ public function getValidDate($value, $format)
+ {
+ $date = DateTime::createFromFormat($format, $value);
+
+ if ($date !== false) {
+ $errors = DateTime::getLastErrors();
+ if ($errors['error_count'] === 0 && $errors['warning_count'] === 0) {
+ $timestamp = $date->getTimestamp();
+ return $timestamp > 0 ? $timestamp : 0;
+ }
+ }
+
+ return 0;
+ }
+
+ /**
+ * Parse a date and return a unix timestamp, try different date formats
+ *
+ * @access public
+ * @param string $value Date to parse
+ * @return integer
+ */
+ public function getTimestamp($value)
+ {
+ foreach ($this->getAllFormats() as $format) {
+ $timestamp = $this->getValidDate($value, $format);
+
+ if ($timestamp !== 0) {
+ return $timestamp;
+ }
+ }
+
+ return 0;
+ }
+
+ /**
+ * Get ISO8601 date from user input
+ *
+ * @access public
+ * @param string $value Date to parse
+ * @return string
+ */
+ public function getIsoDate($value)
+ {
+ return date('Y-m-d', ctype_digit($value) ? $value : $this->getTimestamp($value));
+ }
+
+ /**
+ * Get all combinations of date/time formats
+ *
+ * @access public
+ * @return string[]
+ */
+ public function getAllFormats()
+ {
+ $formats = array();
+
+ foreach ($this->getDateFormats() as $date) {
+ foreach ($this->getTimeFormats() as $time) {
+ $formats[] = $date.' '.$time;
+ }
+ }
+
+ return array_merge($formats, $this->getDateFormats());
+ }
+
+ /**
+ * Return the list of supported date formats (for the parser)
+ *
+ * @access public
+ * @return string[]
+ */
+ public function getDateFormats()
+ {
+ return array(
+ $this->config->get('application_date_format', 'm/d/Y'),
+ 'Y-m-d',
+ 'Y_m_d',
+ );
+ }
+
+ /**
+ * Return the list of supported time formats (for the parser)
+ *
+ * @access public
+ * @return string[]
+ */
+ public function getTimeFormats()
+ {
+ return array(
+ 'H:i',
+ 'g:i A',
+ 'g:iA',
+ );
+ }
+
+ /**
+ * Return the list of available date formats (for the config page)
+ *
+ * @access public
+ * @return array
+ */
+ public function getAvailableFormats()
+ {
+ return array(
+ 'm/d/Y' => date('m/d/Y'),
+ 'd/m/Y' => date('d/m/Y'),
+ 'Y/m/d' => date('Y/m/d'),
+ 'd.m.Y' => date('d.m.Y'),
+ );
+ }
+
+ /**
+ * Remove the time from a timestamp
+ *
+ * @access public
+ * @param integer $timestamp Timestamp
+ * @return integer
+ */
+ public function removeTimeFromTimestamp($timestamp)
+ {
+ return mktime(0, 0, 0, date('m', $timestamp), date('d', $timestamp), date('Y', $timestamp));
+ }
+
+ /**
+ * Get a timetstamp from an ISO date format
+ *
+ * @access public
+ * @param string $date
+ * @return integer
+ */
+ public function getTimestampFromIsoFormat($date)
+ {
+ return $this->removeTimeFromTimestamp(ctype_digit($date) ? $date : strtotime($date));
+ }
+
+ /**
+ * Format date (form display)
+ *
+ * @access public
+ * @param array $values Database values
+ * @param string[] $fields Date fields
+ * @param string $format Date format
+ */
+ public function format(array &$values, array $fields, $format = '')
+ {
+ if ($format === '') {
+ $format = $this->config->get('application_date_format');
+ }
+
+ foreach ($fields as $field) {
+ if (! empty($values[$field])) {
+ $values[$field] = date($format, $values[$field]);
+ } else {
+ $values[$field] = '';
+ }
+ }
+ }
+
+ /**
+ * Convert date (form input data)
+ *
+ * @access public
+ * @param array $values Database values
+ * @param string[] $fields Date fields
+ * @param boolean $keep_time Keep time or not
+ */
+ public function convert(array &$values, array $fields, $keep_time = false)
+ {
+ foreach ($fields as $field) {
+ if (! empty($values[$field]) && ! is_numeric($values[$field])) {
+ $timestamp = $this->getTimestamp($values[$field]);
+ $values[$field] = $keep_time ? $timestamp : $this->removeTimeFromTimestamp($timestamp);
+ }
+ }
+ }
+}
diff --git a/sources/app/Core/Mail/Client.php b/sources/app/Core/Mail/Client.php
new file mode 100644
index 0000000..52caef7
--- /dev/null
+++ b/sources/app/Core/Mail/Client.php
@@ -0,0 +1,96 @@
+transports = new Container;
+ }
+
+ /**
+ * Send a HTML email
+ *
+ * @access public
+ * @param string $email
+ * @param string $name
+ * @param string $subject
+ * @param string $html
+ * @return EmailClient
+ */
+ public function send($email, $name, $subject, $html)
+ {
+ $this->container['logger']->debug('Sending email to '.$email.' ('.MAIL_TRANSPORT.')');
+
+ $start_time = microtime(true);
+ $author = 'Kanboard';
+
+ if ($this->userSession->isLogged()) {
+ $author = e('%s via Kanboard', $this->user->getFullname($this->session['user']));
+ }
+
+ $this->getTransport(MAIL_TRANSPORT)->sendEmail($email, $name, $subject, $html, $author);
+
+ if (DEBUG) {
+ $this->logger->debug('Email sent in '.round(microtime(true) - $start_time, 6).' seconds');
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get mail transport instance
+ *
+ * @access public
+ * @param string $transport
+ * @return EmailClientInterface
+ */
+ public function getTransport($transport)
+ {
+ return $this->transports[$transport];
+ }
+
+ /**
+ * Add a new mail transport
+ *
+ * @access public
+ * @param string $transport
+ * @param string $class
+ * @return EmailClient
+ */
+ public function setTransport($transport, $class)
+ {
+ $container = $this->container;
+
+ $this->transports[$transport] = function () use ($class, $container) {
+ return new $class($container);
+ };
+
+ return $this;
+ }
+}
diff --git a/sources/app/Core/Mail/ClientInterface.php b/sources/app/Core/Mail/ClientInterface.php
new file mode 100644
index 0000000..66263a9
--- /dev/null
+++ b/sources/app/Core/Mail/ClientInterface.php
@@ -0,0 +1,24 @@
+setSubject($subject)
+ ->setFrom(array(MAIL_FROM => $author))
+ ->setBody($html, 'text/html')
+ ->setTo(array($email => $name));
+
+ Swift_Mailer::newInstance($this->getTransport())->send($message);
+ } catch (Swift_TransportException $e) {
+ $this->logger->error($e->getMessage());
+ }
+ }
+
+ /**
+ * Get SwiftMailer transport
+ *
+ * @access protected
+ * @return \Swift_Transport
+ */
+ protected function getTransport()
+ {
+ return Swift_MailTransport::newInstance();
+ }
+}
diff --git a/sources/app/Core/Mail/Transport/Sendmail.php b/sources/app/Core/Mail/Transport/Sendmail.php
new file mode 100644
index 0000000..849e338
--- /dev/null
+++ b/sources/app/Core/Mail/Transport/Sendmail.php
@@ -0,0 +1,25 @@
+setUsername(MAIL_SMTP_USERNAME);
+ $transport->setPassword(MAIL_SMTP_PASSWORD);
+ $transport->setEncryption(MAIL_SMTP_ENCRYPTION);
+
+ return $transport;
+ }
+}
diff --git a/sources/app/Model/Metadata.php b/sources/app/Model/Metadata.php
new file mode 100644
index 0000000..83c8f49
--- /dev/null
+++ b/sources/app/Model/Metadata.php
@@ -0,0 +1,98 @@
+db
+ ->hashtable(static::TABLE)
+ ->eq($this->getEntityKey(), $entity_id)
+ ->asc('name')
+ ->getAll('name', 'value');
+ }
+
+ /**
+ * Get a metadata for the given entity
+ *
+ * @access public
+ * @param integer $entity_id
+ * @param string $name
+ * @param string $default
+ * @return mixed
+ */
+ public function get($entity_id, $name, $default = '')
+ {
+ return $this->db
+ ->table(static::TABLE)
+ ->eq($this->getEntityKey(), $entity_id)
+ ->eq('name', $name)
+ ->findOneColumn('value') ?: $default;
+ }
+
+ /**
+ * Return true if a metadata exists
+ *
+ * @access public
+ * @param integer $entity_id
+ * @param string $name
+ * @return boolean
+ */
+ public function exists($entity_id, $name)
+ {
+ return $this->db
+ ->table(static::TABLE)
+ ->eq($this->getEntityKey(), $entity_id)
+ ->eq('name', $name)
+ ->exists();
+ }
+
+ /**
+ * Update or insert new metadata
+ *
+ * @access public
+ * @param integer $entity_id
+ * @param array $values
+ */
+ public function save($entity_id, array $values)
+ {
+ $results = array();
+
+ $this->db->startTransaction();
+
+ foreach ($values as $key => $value) {
+ if ($this->exists($entity_id, $key)) {
+ $results[] = $this->db->table(static::TABLE)->eq($this->getEntityKey(), $entity_id)->eq('name', $key)->update(array('value' => $value));
+ } else {
+ $results[] = $this->db->table(static::TABLE)->insert(array('name' => $key, 'value' => $value, $this->getEntityKey() => $entity_id));
+ }
+ }
+
+ $this->db->closeTransaction();
+
+ return ! in_array(false, $results, true);
+ }
+}
diff --git a/sources/app/Model/ProjectMetadata.php b/sources/app/Model/ProjectMetadata.php
new file mode 100644
index 0000000..8549805
--- /dev/null
+++ b/sources/app/Model/ProjectMetadata.php
@@ -0,0 +1,30 @@
+project->getById($project_id);
+
+ $types = array_merge(
+ $this->projectNotificationType->getHiddenTypes(),
+ $this->projectNotificationType->getSelectedTypes($project_id)
+ );
+
+ foreach ($types as $type) {
+ $this->projectNotificationType->getType($type)->notifyProject($project, $event_name, $event_data);
+ }
+ }
+
+ /**
+ * Save settings for the given project
+ *
+ * @access public
+ * @param integer $project_id
+ * @param array $values
+ */
+ public function saveSettings($project_id, array $values)
+ {
+ $this->db->startTransaction();
+
+ $types = empty($values['notification_types']) ? array() : array_keys($values['notification_types']);
+ $this->projectNotificationType->saveSelectedTypes($project_id, $types);
+
+ $this->db->closeTransaction();
+ }
+
+ /**
+ * Read user settings to display the form
+ *
+ * @access public
+ * @param integer $project_id
+ * @return array
+ */
+ public function readSettings($project_id)
+ {
+ return array(
+ 'notification_types' => $this->projectNotificationType->getSelectedTypes($project_id),
+ );
+ }
+}
diff --git a/sources/app/Model/ProjectNotificationType.php b/sources/app/Model/ProjectNotificationType.php
new file mode 100644
index 0000000..a471959
--- /dev/null
+++ b/sources/app/Model/ProjectNotificationType.php
@@ -0,0 +1,57 @@
+db
+ ->table(self::TABLE)
+ ->eq('project_id', $project_id)
+ ->asc('notification_type')
+ ->findAllByColumn('notification_type');
+
+ return $this->filterTypes($types);
+ }
+
+ /**
+ * Save notification types for a given project
+ *
+ * @access public
+ * @param integer $project_id
+ * @param string[] $types
+ * @return boolean
+ */
+ public function saveSelectedTypes($project_id, array $types)
+ {
+ $results = array();
+ $this->db->table(self::TABLE)->eq('project_id', $project_id)->remove();
+
+ foreach ($types as $type) {
+ $results[] = $this->db->table(self::TABLE)->insert(array('project_id' => $project_id, 'notification_type' => $type));
+ }
+
+ return ! in_array(false, $results, true);
+ }
+}
diff --git a/sources/app/Model/Setting.php b/sources/app/Model/Setting.php
new file mode 100644
index 0000000..3507d42
--- /dev/null
+++ b/sources/app/Model/Setting.php
@@ -0,0 +1,96 @@
+db->hashtable(self::TABLE)->getAll('option', 'value');
+ }
+
+ /**
+ * Get a setting value
+ *
+ * @access public
+ * @param string $name
+ * @param string $default
+ * @return mixed
+ */
+ public function getOption($name, $default = '')
+ {
+ return $this->db
+ ->table(self::TABLE)
+ ->eq('option', $name)
+ ->findOneColumn('value') ?: $default;
+ }
+
+ /**
+ * Return true if a setting exists
+ *
+ * @access public
+ * @param string $name
+ * @return boolean
+ */
+ public function exists($name)
+ {
+ return $this->db
+ ->table(self::TABLE)
+ ->eq('option', $name)
+ ->exists();
+ }
+
+ /**
+ * Update or insert new settings
+ *
+ * @access public
+ * @param array $values
+ */
+ public function save(array $values)
+ {
+ $results = array();
+ $values = $this->prepare($values);
+
+ $this->db->startTransaction();
+
+ foreach ($values as $option => $value) {
+ if ($this->exists($option)) {
+ $results[] = $this->db->table(self::TABLE)->eq('option', $option)->update(array('value' => $value));
+ } else {
+ $results[] = $this->db->table(self::TABLE)->insert(array('option' => $option, 'value' => $value));
+ }
+ }
+
+ $this->db->closeTransaction();
+
+ return ! in_array(false, $results, true);
+ }
+}
diff --git a/sources/app/Model/TaskImport.php b/sources/app/Model/TaskImport.php
new file mode 100644
index 0000000..e8dd194
--- /dev/null
+++ b/sources/app/Model/TaskImport.php
@@ -0,0 +1,156 @@
+ 'Reference',
+ 'title' => 'Title',
+ 'description' => 'Description',
+ 'assignee' => 'Assignee Username',
+ 'creator' => 'Creator Username',
+ 'color' => 'Color Name',
+ 'column' => 'Column Name',
+ 'category' => 'Category Name',
+ 'swimlane' => 'Swimlane Name',
+ 'score' => 'Complexity',
+ 'time_estimated' => 'Time Estimated',
+ 'time_spent' => 'Time Spent',
+ 'date_due' => 'Due Date',
+ 'is_active' => 'Closed',
+ );
+ }
+
+ /**
+ * Import a single row
+ *
+ * @access public
+ * @param array $row
+ * @param integer $line_number
+ */
+ public function import(array $row, $line_number)
+ {
+ $row = $this->prepare($row);
+
+ if ($this->validateCreation($row)) {
+ if ($this->taskCreation->create($row) > 0) {
+ $this->logger->debug('TaskImport: imported successfully line '.$line_number);
+ $this->counter++;
+ } else {
+ $this->logger->error('TaskImport: creation error at line '.$line_number);
+ }
+ } else {
+ $this->logger->error('TaskImport: validation error at line '.$line_number);
+ }
+ }
+
+ /**
+ * Format row before validation
+ *
+ * @access public
+ * @param array $row
+ * @return array
+ */
+ public function prepare(array $row)
+ {
+ $values = array();
+ $values['project_id'] = $this->projectId;
+ $values['reference'] = $row['reference'];
+ $values['title'] = $row['title'];
+ $values['description'] = $row['description'];
+ $values['is_active'] = Csv::getBooleanValue($row['is_active']) == 1 ? 0 : 1;
+ $values['score'] = (int) $row['score'];
+ $values['time_estimated'] = (float) $row['time_estimated'];
+ $values['time_spent'] = (float) $row['time_spent'];
+
+ if (! empty($row['assignee'])) {
+ $values['owner_id'] = $this->user->getIdByUsername($row['assignee']);
+ }
+
+ if (! empty($row['creator'])) {
+ $values['creator_id'] = $this->user->getIdByUsername($row['creator']);
+ }
+
+ if (! empty($row['color'])) {
+ $values['color_id'] = $this->color->find($row['color']);
+ }
+
+ if (! empty($row['column'])) {
+ $values['column_id'] = $this->board->getColumnIdByTitle($this->projectId, $row['column']);
+ }
+
+ if (! empty($row['category'])) {
+ $values['category_id'] = $this->category->getIdByName($this->projectId, $row['category']);
+ }
+
+ if (! empty($row['swimlane'])) {
+ $values['swimlane_id'] = $this->swimlane->getIdByName($this->projectId, $row['swimlane']);
+ }
+
+ if (! empty($row['date_due'])) {
+ $values['date_due'] = $this->dateParser->getTimestampFromIsoFormat($row['date_due']);
+ }
+
+ $this->removeEmptyFields(
+ $values,
+ array('owner_id', 'creator_id', 'color_id', 'column_id', 'category_id', 'swimlane_id', 'date_due')
+ );
+
+ return $values;
+ }
+
+ /**
+ * Validate user creation
+ *
+ * @access public
+ * @param array $values
+ * @return boolean
+ */
+ public function validateCreation(array $values)
+ {
+ $v = new Validator($values, array(
+ new Validators\Integer('project_id', t('This value must be an integer')),
+ new Validators\Required('project_id', t('The project is required')),
+ new Validators\Required('title', t('The title is required')),
+ new Validators\MaxLength('title', t('The maximum length is %d characters', 200), 200),
+ new Validators\MaxLength('reference', t('The maximum length is %d characters', 50), 50),
+ ));
+
+ return $v->execute();
+ }
+}
diff --git a/sources/app/Model/TaskMetadata.php b/sources/app/Model/TaskMetadata.php
new file mode 100644
index 0000000..1fd1841
--- /dev/null
+++ b/sources/app/Model/TaskMetadata.php
@@ -0,0 +1,30 @@
+ 'Username',
+ 'password' => 'Password',
+ 'email' => 'Email',
+ 'name' => 'Full Name',
+ 'is_admin' => 'Administrator',
+ 'is_project_admin' => 'Project Administrator',
+ 'is_ldap_user' => 'Remote User',
+ );
+ }
+
+ /**
+ * Import a single row
+ *
+ * @access public
+ * @param array $row
+ * @param integer $line_number
+ */
+ public function import(array $row, $line_number)
+ {
+ $row = $this->prepare($row);
+
+ if ($this->validateCreation($row)) {
+ if ($this->user->create($row)) {
+ $this->logger->debug('UserImport: imported successfully line '.$line_number);
+ $this->counter++;
+ } else {
+ $this->logger->error('UserImport: creation error at line '.$line_number);
+ }
+ } else {
+ $this->logger->error('UserImport: validation error at line '.$line_number);
+ }
+ }
+
+ /**
+ * Format row before validation
+ *
+ * @access public
+ * @param array $row
+ * @return array
+ */
+ public function prepare(array $row)
+ {
+ $row['username'] = strtolower($row['username']);
+
+ foreach (array('is_admin', 'is_project_admin', 'is_ldap_user') as $field) {
+ $row[$field] = Csv::getBooleanValue($row[$field]);
+ }
+
+ $this->removeEmptyFields($row, array('password', 'email', 'name'));
+
+ return $row;
+ }
+
+ /**
+ * Validate user creation
+ *
+ * @access public
+ * @param array $values
+ * @return boolean
+ */
+ public function validateCreation(array $values)
+ {
+ $v = new Validator($values, array(
+ new Validators\MaxLength('username', t('The maximum length is %d characters', 50), 50),
+ new Validators\Unique('username', t('The username must be unique'), $this->db->getConnection(), User::TABLE, 'id'),
+ new Validators\MinLength('password', t('The minimum length is %d characters', 6), 6),
+ new Validators\Email('email', t('Email address invalid')),
+ new Validators\Integer('is_admin', t('This value must be an integer')),
+ new Validators\Integer('is_project_admin', t('This value must be an integer')),
+ new Validators\Integer('is_ldap_user', t('This value must be an integer')),
+ ));
+
+ return $v->execute();
+ }
+}
diff --git a/sources/app/Model/UserMetadata.php b/sources/app/Model/UserMetadata.php
new file mode 100644
index 0000000..411837b
--- /dev/null
+++ b/sources/app/Model/UserMetadata.php
@@ -0,0 +1,30 @@
+userSession->isLogged() ? $this->userSession->getId() : 0;
+ $users = $this->getUsersWithNotificationEnabled($event_data['task']['project_id'], $logged_user_id);
+
+ if (! empty($users)) {
+ foreach ($users as $user) {
+ if ($this->userNotificationFilter->shouldReceiveNotification($user, $event_data)) {
+ $this->sendUserNotification($user, $event_name, $event_data);
+ }
+ }
+
+ // Restore locales
+ $this->config->setupTranslations();
+ }
+ }
+
+ /**
+ * Send notification to someone
+ *
+ * @access public
+ * @param array $user User
+ * @param string $event_name
+ * @param array $event_data
+ */
+ public function sendUserNotification(array $user, $event_name, array $event_data)
+ {
+ Translator::unload();
+
+ // Use the user language otherwise use the application language (do not use the session language)
+ if (! empty($user['language'])) {
+ Translator::load($user['language']);
+ } else {
+ Translator::load($this->config->get('application_language', 'en_US'));
+ }
+
+ foreach ($this->userNotificationType->getSelectedTypes($user['id']) as $type) {
+ $this->userNotificationType->getType($type)->notifyUser($user, $event_name, $event_data);
+ }
+ }
+
+ /**
+ * Get a list of people with notifications enabled
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param integer $exclude_user_id User id to exclude
+ * @return array
+ */
+ public function getUsersWithNotificationEnabled($project_id, $exclude_user_id = 0)
+ {
+ if ($this->projectPermission->isEverybodyAllowed($project_id)) {
+ return $this->getEverybodyWithNotificationEnabled($exclude_user_id);
+ }
+
+ return $this->getProjectMembersWithNotificationEnabled($project_id, $exclude_user_id);
+ }
+
+ /**
+ * Enable notification for someone
+ *
+ * @access public
+ * @param integer $user_id
+ * @return boolean
+ */
+ public function enableNotification($user_id)
+ {
+ return $this->db->table(User::TABLE)->eq('id', $user_id)->update(array('notifications_enabled' => 1));
+ }
+
+ /**
+ * Disable notification for someone
+ *
+ * @access public
+ * @param integer $user_id
+ * @return boolean
+ */
+ public function disableNotification($user_id)
+ {
+ return $this->db->table(User::TABLE)->eq('id', $user_id)->update(array('notifications_enabled' => 0));
+ }
+
+ /**
+ * Save settings for the given user
+ *
+ * @access public
+ * @param integer $user_id User id
+ * @param array $values Form values
+ */
+ public function saveSettings($user_id, array $values)
+ {
+ $this->db->startTransaction();
+
+ if (isset($values['notifications_enabled']) && $values['notifications_enabled'] == 1) {
+ $this->enableNotification($user_id);
+
+ $filter = empty($values['notifications_filter']) ? UserNotificationFilter::FILTER_BOTH : $values['notifications_filter'];
+ $projects = empty($values['notification_projects']) ? array() : array_keys($values['notification_projects']);
+ $types = empty($values['notification_types']) ? array() : array_keys($values['notification_types']);
+
+ $this->userNotificationFilter->saveFilter($user_id, $filter);
+ $this->userNotificationFilter->saveSelectedProjects($user_id, $projects);
+ $this->userNotificationType->saveSelectedTypes($user_id, $types);
+ } else {
+ $this->disableNotification($user_id);
+ }
+
+ $this->db->closeTransaction();
+ }
+
+ /**
+ * Read user settings to display the form
+ *
+ * @access public
+ * @param integer $user_id User id
+ * @return array
+ */
+ public function readSettings($user_id)
+ {
+ $values = $this->db->table(User::TABLE)->eq('id', $user_id)->columns('notifications_enabled', 'notifications_filter')->findOne();
+ $values['notification_types'] = $this->userNotificationType->getSelectedTypes($user_id);
+ $values['notification_projects'] = $this->userNotificationFilter->getSelectedProjects($user_id);
+ return $values;
+ }
+
+ /**
+ * Get a list of project members with notification enabled
+ *
+ * @access private
+ * @param integer $project_id Project id
+ * @param integer $exclude_user_id User id to exclude
+ * @return array
+ */
+ private function getProjectMembersWithNotificationEnabled($project_id, $exclude_user_id)
+ {
+ return $this->db
+ ->table(ProjectPermission::TABLE)
+ ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name', User::TABLE.'.email', User::TABLE.'.language', User::TABLE.'.notifications_filter')
+ ->join(User::TABLE, 'id', 'user_id')
+ ->eq('project_id', $project_id)
+ ->eq('notifications_enabled', '1')
+ ->neq(User::TABLE.'.id', $exclude_user_id)
+ ->findAll();
+ }
+
+ /**
+ * Get a list of project members with notification enabled
+ *
+ * @access private
+ * @param integer $exclude_user_id User id to exclude
+ * @return array
+ */
+ private function getEverybodyWithNotificationEnabled($exclude_user_id)
+ {
+ return $this->db
+ ->table(User::TABLE)
+ ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name', User::TABLE.'.email', User::TABLE.'.language', User::TABLE.'.notifications_filter')
+ ->eq('notifications_enabled', '1')
+ ->neq(User::TABLE.'.id', $exclude_user_id)
+ ->findAll();
+ }
+}
diff --git a/sources/app/Model/UserNotificationFilter.php b/sources/app/Model/UserNotificationFilter.php
new file mode 100644
index 0000000..d4afd27
--- /dev/null
+++ b/sources/app/Model/UserNotificationFilter.php
@@ -0,0 +1,199 @@
+ t('All tasks'),
+ self::FILTER_ASSIGNEE => t('Only for tasks assigned to me'),
+ self::FILTER_CREATOR => t('Only for tasks created by me'),
+ self::FILTER_BOTH => t('Only for tasks created by me and assigned to me'),
+ );
+ }
+
+ /**
+ * Get user selected filter
+ *
+ * @access public
+ * @param integer $user_id
+ * @return integer
+ */
+ public function getSelectedFilter($user_id)
+ {
+ return $this->db->table(User::TABLE)->eq('id', $user_id)->findOneColumn('notifications_filter');
+ }
+
+ /**
+ * Save selected filter for a user
+ *
+ * @access public
+ * @param integer $user_id
+ * @param string $filter
+ */
+ public function saveFilter($user_id, $filter)
+ {
+ $this->db->table(User::TABLE)->eq('id', $user_id)->update(array(
+ 'notifications_filter' => $filter,
+ ));
+ }
+
+ /**
+ * Get user selected projects
+ *
+ * @access public
+ * @param integer $user_id
+ * @return array
+ */
+ public function getSelectedProjects($user_id)
+ {
+ return $this->db->table(self::PROJECT_TABLE)->eq('user_id', $user_id)->findAllByColumn('project_id');
+ }
+
+ /**
+ * Save selected projects for a user
+ *
+ * @access public
+ * @param integer $user_id
+ * @param integer[] $project_ids
+ */
+ public function saveSelectedProjects($user_id, array $project_ids)
+ {
+ $this->db->table(self::PROJECT_TABLE)->eq('user_id', $user_id)->remove();
+
+ foreach ($project_ids as $project_id) {
+ $this->db->table(self::PROJECT_TABLE)->insert(array(
+ 'user_id' => $user_id,
+ 'project_id' => $project_id,
+ ));
+ }
+ }
+
+ /**
+ * Return true if the user should receive notification
+ *
+ * @access public
+ * @param array $user
+ * @param array $event_data
+ * @return boolean
+ */
+ public function shouldReceiveNotification(array $user, array $event_data)
+ {
+ $filters = array(
+ 'filterNone',
+ 'filterAssignee',
+ 'filterCreator',
+ 'filterBoth',
+ );
+
+ foreach ($filters as $filter) {
+ if ($this->$filter($user, $event_data)) {
+ return $this->filterProject($user, $event_data);
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Return true if the user will receive all notifications
+ *
+ * @access public
+ * @param array $user
+ * @return boolean
+ */
+ public function filterNone(array $user)
+ {
+ return $user['notifications_filter'] == self::FILTER_NONE;
+ }
+
+ /**
+ * Return true if the user is the assignee and selected the filter "assignee"
+ *
+ * @access public
+ * @param array $user
+ * @param array $event_data
+ * @return boolean
+ */
+ public function filterAssignee(array $user, array $event_data)
+ {
+ return $user['notifications_filter'] == self::FILTER_ASSIGNEE && $event_data['task']['owner_id'] == $user['id'];
+ }
+
+ /**
+ * Return true if the user is the creator and enabled the filter "creator"
+ *
+ * @access public
+ * @param array $user
+ * @param array $event_data
+ * @return boolean
+ */
+ public function filterCreator(array $user, array $event_data)
+ {
+ return $user['notifications_filter'] == self::FILTER_CREATOR && $event_data['task']['creator_id'] == $user['id'];
+ }
+
+ /**
+ * Return true if the user is the assignee or the creator and selected the filter "both"
+ *
+ * @access public
+ * @param array $user
+ * @param array $event_data
+ * @return boolean
+ */
+ public function filterBoth(array $user, array $event_data)
+ {
+ return $user['notifications_filter'] == self::FILTER_BOTH &&
+ ($event_data['task']['creator_id'] == $user['id'] || $event_data['task']['owner_id'] == $user['id']);
+ }
+
+ /**
+ * Return true if the user want to receive notification for the selected project
+ *
+ * @access public
+ * @param array $user
+ * @param array $event_data
+ * @return boolean
+ */
+ public function filterProject(array $user, array $event_data)
+ {
+ $projects = $this->getSelectedProjects($user['id']);
+
+ if (! empty($projects)) {
+ return in_array($event_data['task']['project_id'], $projects);
+ }
+
+ return true;
+ }
+}
diff --git a/sources/app/Model/UserNotificationType.php b/sources/app/Model/UserNotificationType.php
new file mode 100644
index 0000000..89beb48
--- /dev/null
+++ b/sources/app/Model/UserNotificationType.php
@@ -0,0 +1,52 @@
+db->table(self::TABLE)->eq('user_id', $user_id)->asc('notification_type')->findAllByColumn('notification_type');
+ return $this->filterTypes($types);
+ }
+
+ /**
+ * Save notification types for a given user
+ *
+ * @access public
+ * @param integer $user_id
+ * @param string[] $types
+ * @return boolean
+ */
+ public function saveSelectedTypes($user_id, array $types)
+ {
+ $results = array();
+ $this->db->table(self::TABLE)->eq('user_id', $user_id)->remove();
+
+ foreach ($types as $type) {
+ $results[] = $this->db->table(self::TABLE)->insert(array('user_id' => $user_id, 'notification_type' => $type));
+ }
+
+ return ! in_array(false, $results, true);
+ }
+}
diff --git a/sources/app/Model/UserUnreadNotification.php b/sources/app/Model/UserUnreadNotification.php
new file mode 100644
index 0000000..cc0f326
--- /dev/null
+++ b/sources/app/Model/UserUnreadNotification.php
@@ -0,0 +1,93 @@
+db->table(self::TABLE)->insert(array(
+ 'user_id' => $user_id,
+ 'date_creation' => time(),
+ 'event_name' => $event_name,
+ 'event_data' => json_encode($event_data),
+ ));
+ }
+
+ /**
+ * Get all notifications for a user
+ *
+ * @access public
+ * @param integer $user_id
+ * @return array
+ */
+ public function getAll($user_id)
+ {
+ $events = $this->db->table(self::TABLE)->eq('user_id', $user_id)->asc('date_creation')->findAll();
+
+ foreach ($events as &$event) {
+ $event['event_data'] = json_decode($event['event_data'], true);
+ $event['title'] = $this->notification->getTitleWithoutAuthor($event['event_name'], $event['event_data']);
+ }
+
+ return $events;
+ }
+
+ /**
+ * Mark a notification as read
+ *
+ * @access public
+ * @param integer $user_id
+ * @param integer $notification_id
+ * @return boolean
+ */
+ public function markAsRead($user_id, $notification_id)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $notification_id)->eq('user_id', $user_id)->remove();
+ }
+
+ /**
+ * Mark all notifications as read for a user
+ *
+ * @access public
+ * @param integer $user_id
+ * @return boolean
+ */
+ public function markAllAsRead($user_id)
+ {
+ return $this->db->table(self::TABLE)->eq('user_id', $user_id)->remove();
+ }
+
+ /**
+ * Return true if the user as unread notifications
+ *
+ * @access public
+ * @param integer $user_id
+ * @return boolean
+ */
+ public function hasNotifications($user_id)
+ {
+ return $this->db->table(self::TABLE)->eq('user_id', $user_id)->exists();
+ }
+}
diff --git a/sources/app/Notification/ActivityStream.php b/sources/app/Notification/ActivityStream.php
new file mode 100644
index 0000000..325732e
--- /dev/null
+++ b/sources/app/Notification/ActivityStream.php
@@ -0,0 +1,47 @@
+userSession->isLogged()) {
+ $this->projectActivity->createEvent(
+ $project['id'],
+ $event_data['task']['id'],
+ $this->userSession->getId(),
+ $event_name,
+ $event_data
+ );
+ }
+ }
+}
diff --git a/sources/app/Notification/Mail.php b/sources/app/Notification/Mail.php
new file mode 100644
index 0000000..cd8af85
--- /dev/null
+++ b/sources/app/Notification/Mail.php
@@ -0,0 +1,139 @@
+emailClient->send(
+ $user['email'],
+ $user['name'] ?: $user['username'],
+ $this->getMailSubject($event_name, $event_data),
+ $this->getMailContent($event_name, $event_data)
+ );
+ }
+ }
+
+ /**
+ * Send notification to a project
+ *
+ * @access public
+ * @param array $project
+ * @param string $event_name
+ * @param array $event_data
+ */
+ public function notifyProject(array $project, $event_name, array $event_data)
+ {
+ }
+
+ /**
+ * Get the mail content for a given template name
+ *
+ * @access public
+ * @param string $event_name Event name
+ * @param array $event_data Event data
+ * @return string
+ */
+ public function getMailContent($event_name, array $event_data)
+ {
+ return $this->template->render(
+ 'notification/'.str_replace('.', '_', $event_name),
+ $event_data + array('application_url' => $this->config->get('application_url'))
+ );
+ }
+
+ /**
+ * Get the mail subject for a given template name
+ *
+ * @access public
+ * @param string $event_name Event name
+ * @param array $event_data Event data
+ * @return string
+ */
+ public function getMailSubject($event_name, array $event_data)
+ {
+ switch ($event_name) {
+ case File::EVENT_CREATE:
+ $subject = $this->getStandardMailSubject(e('New attachment'), $event_data);
+ break;
+ case Comment::EVENT_CREATE:
+ $subject = $this->getStandardMailSubject(e('New comment'), $event_data);
+ break;
+ case Comment::EVENT_UPDATE:
+ $subject = $this->getStandardMailSubject(e('Comment updated'), $event_data);
+ break;
+ case Subtask::EVENT_CREATE:
+ $subject = $this->getStandardMailSubject(e('New subtask'), $event_data);
+ break;
+ case Subtask::EVENT_UPDATE:
+ $subject = $this->getStandardMailSubject(e('Subtask updated'), $event_data);
+ break;
+ case Task::EVENT_CREATE:
+ $subject = $this->getStandardMailSubject(e('New task'), $event_data);
+ break;
+ case Task::EVENT_UPDATE:
+ $subject = $this->getStandardMailSubject(e('Task updated'), $event_data);
+ break;
+ case Task::EVENT_CLOSE:
+ $subject = $this->getStandardMailSubject(e('Task closed'), $event_data);
+ break;
+ case Task::EVENT_OPEN:
+ $subject = $this->getStandardMailSubject(e('Task opened'), $event_data);
+ break;
+ case Task::EVENT_MOVE_COLUMN:
+ $subject = $this->getStandardMailSubject(e('Column change'), $event_data);
+ break;
+ case Task::EVENT_MOVE_POSITION:
+ $subject = $this->getStandardMailSubject(e('Position change'), $event_data);
+ break;
+ case Task::EVENT_MOVE_SWIMLANE:
+ $subject = $this->getStandardMailSubject(e('Swimlane change'), $event_data);
+ break;
+ case Task::EVENT_ASSIGNEE_CHANGE:
+ $subject = $this->getStandardMailSubject(e('Assignee change'), $event_data);
+ break;
+ case Task::EVENT_OVERDUE:
+ $subject = e('[%s] Overdue tasks', $event_data['project_name']);
+ break;
+ default:
+ $subject = e('Notification');
+ }
+
+ return $subject;
+ }
+
+ /**
+ * Get the mail subject for a given label
+ *
+ * @access private
+ * @param string $label Label
+ * @param array $data Template data
+ * @return string
+ */
+ private function getStandardMailSubject($label, array $data)
+ {
+ return sprintf('[%s][%s] %s (#%d)', $data['task']['project_name'], $label, $data['task']['title'], $data['task']['id']);
+ }
+}
diff --git a/sources/app/Notification/NotificationInterface.php b/sources/app/Notification/NotificationInterface.php
new file mode 100644
index 0000000..8431a77
--- /dev/null
+++ b/sources/app/Notification/NotificationInterface.php
@@ -0,0 +1,32 @@
+userUnreadNotification->create($user['id'], $event_name, $event_data);
+ }
+
+ /**
+ * Send notification to a project
+ *
+ * @access public
+ * @param array $project
+ * @param string $event_name
+ * @param array $event_data
+ */
+ public function notifyProject(array $project, $event_name, array $event_data)
+ {
+ }
+}
diff --git a/sources/app/Notification/Webhook.php b/sources/app/Notification/Webhook.php
new file mode 100644
index 0000000..e187909
--- /dev/null
+++ b/sources/app/Notification/Webhook.php
@@ -0,0 +1,55 @@
+config->get('webhook_url');
+ $token = $this->config->get('webhook_token');
+
+ if (! empty($url)) {
+ if (strpos($url, '?') !== false) {
+ $url .= '&token='.$token;
+ } else {
+ $url .= '?token='.$token;
+ }
+
+ $payload = array(
+ 'event_name' => $event_name,
+ 'event_data' => $event_data,
+ );
+
+ $this->httpClient->postJson($url, $payload);
+ }
+ }
+}
diff --git a/sources/app/Template/project/notifications.php b/sources/app/Template/project/notifications.php
new file mode 100644
index 0000000..ac74308
--- /dev/null
+++ b/sources/app/Template/project/notifications.php
@@ -0,0 +1,20 @@
+
+
= t('Notifications') ?>
+
+
+
= t('There is no notification method registered.') ?>
+
+
+
\ No newline at end of file
diff --git a/sources/app/Template/task_import/step1.php b/sources/app/Template/task_import/step1.php
new file mode 100644
index 0000000..7619216
--- /dev/null
+++ b/sources/app/Template/task_import/step1.php
@@ -0,0 +1,34 @@
+
+
= t('Tasks Importation') ?>
+
+
+
+
= t('Instructions') ?>
+
+
+
+
= t('Your file must use the predefined CSV format') ?>
+
= t('Your file must be encoded in UTF-8') ?>
+
= t('The first row must be the header') ?>
+
= t('Duplicates are not verified for you') ?>
+
= t('The due date must use the ISO format: YYYY-MM-DD') ?>
\ No newline at end of file
diff --git a/sources/app/Template/user/integrations.php b/sources/app/Template/user/integrations.php
new file mode 100644
index 0000000..ef9d8e7
--- /dev/null
+++ b/sources/app/Template/user/integrations.php
@@ -0,0 +1,13 @@
+
").append(m.parseHTML(a)).find(d):a)}).complete(c&&function(a,b){g.each(c,e||[a.responseText,b,a])}),this},m.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(a,b){m.fn[b]=function(a){return this.on(b,a)}}),m.expr.filters.animated=function(a){return m.grep(m.timers,function(b){return a===b.elem}).length};var cc=a.document.documentElement;function dc(a){return m.isWindow(a)?a:9===a.nodeType?a.defaultView||a.parentWindow:!1}m.offset={setOffset:function(a,b,c){var d,e,f,g,h,i,j,k=m.css(a,"position"),l=m(a),n={};"static"===k&&(a.style.position="relative"),h=l.offset(),f=m.css(a,"top"),i=m.css(a,"left"),j=("absolute"===k||"fixed"===k)&&m.inArray("auto",[f,i])>-1,j?(d=l.position(),g=d.top,e=d.left):(g=parseFloat(f)||0,e=parseFloat(i)||0),m.isFunction(b)&&(b=b.call(a,c,h)),null!=b.top&&(n.top=b.top-h.top+g),null!=b.left&&(n.left=b.left-h.left+e),"using"in b?b.using.call(a,n):l.css(n)}},m.fn.extend({offset:function(a){if(arguments.length)return void 0===a?this:this.each(function(b){m.offset.setOffset(this,a,b)});var b,c,d={top:0,left:0},e=this[0],f=e&&e.ownerDocument;if(f)return b=f.documentElement,m.contains(b,e)?(typeof e.getBoundingClientRect!==K&&(d=e.getBoundingClientRect()),c=dc(f),{top:d.top+(c.pageYOffset||b.scrollTop)-(b.clientTop||0),left:d.left+(c.pageXOffset||b.scrollLeft)-(b.clientLeft||0)}):d},position:function(){if(this[0]){var a,b,c={top:0,left:0},d=this[0];return"fixed"===m.css(d,"position")?b=d.getBoundingClientRect():(a=this.offsetParent(),b=this.offset(),m.nodeName(a[0],"html")||(c=a.offset()),c.top+=m.css(a[0],"borderTopWidth",!0),c.left+=m.css(a[0],"borderLeftWidth",!0)),{top:b.top-c.top-m.css(d,"marginTop",!0),left:b.left-c.left-m.css(d,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var a=this.offsetParent||cc;while(a&&!m.nodeName(a,"html")&&"static"===m.css(a,"position"))a=a.offsetParent;return a||cc})}}),m.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(a,b){var c=/Y/.test(b);m.fn[a]=function(d){return V(this,function(a,d,e){var f=dc(a);return void 0===e?f?b in f?f[b]:f.document.documentElement[d]:a[d]:void(f?f.scrollTo(c?m(f).scrollLeft():e,c?e:m(f).scrollTop()):a[d]=e)},a,d,arguments.length,null)}}),m.each(["top","left"],function(a,b){m.cssHooks[b]=La(k.pixelPosition,function(a,c){return c?(c=Ja(a,b),Ha.test(c)?m(a).position()[b]+"px":c):void 0})}),m.each({Height:"height",Width:"width"},function(a,b){m.each({padding:"inner"+a,content:b,"":"outer"+a},function(c,d){m.fn[d]=function(d,e){var f=arguments.length&&(c||"boolean"!=typeof d),g=c||(d===!0||e===!0?"margin":"border");return V(this,function(b,c,d){var e;return m.isWindow(b)?b.document.documentElement["client"+a]:9===b.nodeType?(e=b.documentElement,Math.max(b.body["scroll"+a],e["scroll"+a],b.body["offset"+a],e["offset"+a],e["client"+a])):void 0===d?m.css(b,c,g):m.style(b,c,d,g)},b,f?d:void 0,f,null)}})}),m.fn.size=function(){return this.length},m.fn.andSelf=m.fn.addBack,"function"==typeof define&&define.amd&&define("jquery",[],function(){return m});var ec=a.jQuery,fc=a.$;return m.noConflict=function(b){return a.$===m&&(a.$=fc),b&&a.jQuery===m&&(a.jQuery=ec),m},typeof b===K&&(a.jQuery=a.$=m),m});
diff --git a/sources/doc/plugin-hooks.markdown b/sources/doc/plugin-hooks.markdown
new file mode 100644
index 0000000..eab2fa1
--- /dev/null
+++ b/sources/doc/plugin-hooks.markdown
@@ -0,0 +1,152 @@
+Plugin Hooks
+============
+
+Application Hooks
+-----------------
+
+Hooks can extend, replace, filter data or change the default behavior. Each hook is identified with a unique name, example: `controller:calendar:user:events`
+
+### Listen on hook events
+
+In your `initialize()` method you need to call the method `on()` of the class `Kanboard\Core\Plugin\Hook`:
+
+```php
+$this->hook->on('hook_name', $callable);
+```
+
+The first argument is the name of the hook and the second is a PHP callable.
+
+### Hooks executed only one time
+
+Some hooks can have only one listener:
+
+#### model:subtask-time-tracking:calculate:time-spent
+
+- Override time spent calculation when subtask timer is stopped
+- Arguments:
+ - `$user_id` (integer)
+ - `$start` (DateTime)
+ - `$end` (DateTime)
+
+#### model:subtask-time-tracking:calendar:events
+
+- Override subtask time tracking events to display the calendar
+- Arguments:
+ - `$user_id` (integer)
+ - `$events` (array)
+ - `$start` (string, ISO-8601 format)
+ - `$end` (string, ISO-8601 format)
+
+### Merge hooks
+
+"Merge hooks" act in the same way as the function `array_merge`. The hook callback must return an array. This array will be merged with the default one.
+
+Example to add events in the user calendar:
+
+```php
+class Plugin extends Base
+{
+ public function initialize()
+ {
+ $container = $this->container;
+
+ $this->hook->on('controller:calendar:user:events', function($user_id, $start, $end) use ($container) {
+ $model = new SubtaskForecast($container);
+ return $model->getCalendarEvents($user_id, $end); // Return new events
+ });
+ }
+}
+```
+
+List of merge hooks:
+
+#### controller:calendar:project:events
+
+- Add more events to the project calendar
+- Arguments:
+ - `$project_id` (integer)
+ - `$start` Calendar start date (string, ISO-8601 format)
+ - `$end` Calendar` end date (string, ISO-8601 format)
+
+#### controller:calendar:user:events
+
+- Add more events to the user calendar
+- Arguments:
+ - `$user_id` (integer)
+ - `$start` Calendar start date (string, ISO-8601 format)
+ - `$end` Calendar end date (string, ISO-8601 format)
+
+Asset Hooks
+-----------
+
+Asset hooks can be used to add easily a new stylesheet or a new javascript file in the layout. You can use this feature to create a theme and override all Kanboard default styles.
+
+Example to add a new stylesheet:
+
+```php
+hook->on('template:layout:css', 'plugins/Css/skin.css');
+ }
+}
+```
+
+List of asset Hooks:
+
+- `template:layout:css`
+- `template:layout:js`
+
+Template Hooks
+--------------
+
+Template hooks allow to add new content in existing templates.
+
+Example to add new content in the dashboard sidebar:
+
+```php
+$this->template->hook->attach('template:dashboard:sidebar', 'myplugin:dashboard/sidebar');
+```
+
+This call is usually defined in the `initialize()` method.
+The first argument is name of the hook and the second argument is the template name.
+
+Template names prefixed with the plugin name and colon indicate the location of the template.
+
+Example with `myplugin:dashboard/sidebar`:
+
+- `myplugin` is the name of your plugin (lowercase)
+- `dashboard/sidebar` is the template name
+- On the filesystem, the plugin will be located here: `plugins\Myplugin\Template\dashboard\sidebar.php`
+- Templates are written in pure PHP (don't forget to escape data)
+
+Template name without prefix are core templates.
+
+List of template hooks:
+
+- `template:auth:login-form:before`
+- `template:auth:login-form:after`
+- `template:dashboard:sidebar`
+- `template:config:sidebar`
+- `template:config:integrations`
+- `template:project:integrations`
+- `template:user:integrations`
+- `template:export:sidebar`
+- `template:layout:head`
+- `template:layout:top`
+- `template:layout:bottom`
+- `template:project:dropdown`
+- `template:project-user:sidebar`
+- `template:task:sidebar:information`
+- `template:task:sidebar:actions`
+- `template:user:sidebar:information`
+- `template:user:sidebar:actions`
+
+Other template hooks can be added if necessary, just ask on the issue tracker.
diff --git a/sources/doc/plugin-mail-transports.markdown b/sources/doc/plugin-mail-transports.markdown
new file mode 100644
index 0000000..cb7dd6c
--- /dev/null
+++ b/sources/doc/plugin-mail-transports.markdown
@@ -0,0 +1,50 @@
+Plugin: Add Mail Transport
+==========================
+
+By default Kanboard supports 3 standards mail transports:
+
+- Mail (PHP mail function)
+- Smtp
+- Sendmail command
+
+With the plugin API you can add a driver for any email provider.
+By example, your plugin can add a mail transport for a provider that uses an HTTP API.
+
+Implementation
+--------------
+
+Your plugin must implements the interface `Kanboard\Core\Mail\ClientInterface` and extends from `Kanboard\Core\Base`.
+
+The only method you need to implement is `sendEmail()`:
+
+```php
+interface ClientInterface
+{
+ /**
+ * Send a HTML email
+ *
+ * @access public
+ * @param string $email
+ * @param string $name
+ * @param string $subject
+ * @param string $html
+ * @param string $author
+ */
+ public function sendEmail($email, $name, $subject, $html, $author);
+}
+```
+
+To register your new mail transport, use the method `setTransport($transport, $class)` from the class `Kanboard\Core\Mail\Client`:
+
+```php
+$this->emailClient->setTransport('myprovider', '\Kanboard\Plugin\MyProvider\MyEmailHandler');
+```
+
+The second argument contains the absolute namespace of your concrete class.
+
+Examples of mail transport plugins
+----------------------------------
+
+- [Sendgrid](https://github.com/kanboard/plugin-sendgrid)
+- [Mailgun](https://github.com/kanboard/plugin-mailgun)
+- [Postmark](https://github.com/kanboard/plugin-postmark)
diff --git a/sources/doc/plugin-metadata.markdown b/sources/doc/plugin-metadata.markdown
new file mode 100644
index 0000000..a01b0dd
--- /dev/null
+++ b/sources/doc/plugin-metadata.markdown
@@ -0,0 +1,38 @@
+Metadata
+========
+
+You can attach metadata for each project, task and user.
+Metadata are custom fields, it's a key/value table.
+
+By example your plugin can store external information for a task or new settings for a project.
+Basically that allow you to exend the default fields without having to create new tables.
+
+Attach metadata to tasks
+------------------------
+
+```php
+
+// Return a dictionary of metadata (keys/values) for the $task_id
+$this->taskMetadata->getAll($task_id);
+
+// Get a value only for a task
+$this->taskMetadata->get($task_id, 'my_plugin_variable', 'default_value');
+
+// Return true if the metadata my_plugin_variable exists
+$this->taskMetadata->exists($task_id, 'my_plugin_variable');
+
+// Create or update metadata for the task
+$this->taskMetadata->save($task_id, ['my_plugin_variable' => 'something']);
+```
+
+Metadata types
+--------------
+
+- TaskMetadata: `$this->taskMetadata`
+- ProjectMetadata: `$this->projectMetadata`
+- UserMetadata: `$this->userMetadata`
+
+Notes
+-----
+
+- Always prefix the metadata name with your plugin name
diff --git a/sources/doc/plugin-notifications.markdown b/sources/doc/plugin-notifications.markdown
new file mode 100644
index 0000000..83fdb5e
--- /dev/null
+++ b/sources/doc/plugin-notifications.markdown
@@ -0,0 +1,60 @@
+Add Notification Types with Plugins
+===================================
+
+You can send notifications to almost any system by adding a new type.
+There are two kinds of notifications: project and user.
+
+- Project: Notifications configured at the project level
+- User: Notifications sent individually and configured at the user profile
+
+Register a new notification type
+--------------------------------
+
+In your plugin registration file call the method `setType()`:
+
+```php
+$this->userNotificationType->setType('irc', t('IRC'), '\Kanboard\Plugin\IRC\Notification\IrcHandler');
+$this->projectNotificationType->setType('irc', t('IRC'), '\Kanboard\Plugin\IRC\Notification\IrcHandler');
+```
+
+Your handler can be registered for user or project notification. You don't necessary need to support both.
+
+When your handler is registered, the end-user can choose to receive the new notification type or not.
+
+Notification Handler
+--------------------
+
+Your notification handler must implements the interface `Kanboard\Notification\NotificationInterface`:
+
+```php
+interface NotificationInterface
+{
+ /**
+ * Send notification to a user
+ *
+ * @access public
+ * @param array $user
+ * @param string $event_name
+ * @param array $event_data
+ */
+ public function notifyUser(array $user, $event_name, array $event_data);
+
+ /**
+ * Send notification to a project
+ *
+ * @access public
+ * @param array $project
+ * @param string $event_name
+ * @param array $event_data
+ */
+ public function notifyProject(array $project, $event_name, array $event_data);
+}
+```
+
+Example of notification plugins
+-------------------------------
+
+- [Slack](https://github.com/kanboard/plugin-slack)
+- [Hipchat](https://github.com/kanboard/plugin-hipchat)
+- [Jabber](https://github.com/kanboard/plugin-jabber)
+
diff --git a/sources/doc/plugin-overrides.markdown b/sources/doc/plugin-overrides.markdown
new file mode 100644
index 0000000..905808d
--- /dev/null
+++ b/sources/doc/plugin-overrides.markdown
@@ -0,0 +1,36 @@
+Plugin Overrides
+================
+
+Override HTTP Content Security Policy
+-------------------------------------
+
+If you would like to replace the default HTTP Content Security Policy header, you can use the method `setContentSecurityPolicy()`:
+
+```php
+setContentSecurityPolicy(array('script-src' => 'something'));
+ }
+}
+```
+
+Template Overrides
+------------------
+
+Any templates defined in the core can be overrided. By example, you can redefine the default layout or change email notifications.
+
+Example of template override:
+
+```php
+$this->template->setTemplateOverride('header', 'theme:layout/header');
+```
+
+The first argument is the original template name and the second argument the template to use as replacement.
diff --git a/sources/doc/plugin-registration.markdown b/sources/doc/plugin-registration.markdown
new file mode 100644
index 0000000..312f61b
--- /dev/null
+++ b/sources/doc/plugin-registration.markdown
@@ -0,0 +1,200 @@
+Plugin Registration
+===================
+
+Directory structure
+-------------------
+
+Plugins are stored in the `plugins` subdirectory. An example of a plugin directory structure:
+
+```bash
+plugins
+└── Budget <= Plugin name
+ ├── Asset <= Javascript/CSS files
+ ├── Controller
+ ├── LICENSE <= Plugin license
+ ├── Locale
+ │ ├── fr_FR
+ │ ├── it_IT
+ │ ├── ja_JP
+ │ └── zh_CN
+ ├── Model
+ ├── Plugin.php <= Plugin registration file
+ ├── README.md
+ ├── Schema <= Database migrations
+ ├── Template
+ └── Test <= Unit tests
+```
+
+Only the registration file `Plugin.php` is required. Other folders are optionals.
+
+The first letter of the plugin name must be capitalized.
+
+Plugin Registration File
+------------------------
+
+Kanboard will scan the directory `plugins` and load automatically everything under this directory. The file `Plugin.php` is used to load and register the plugin.
+
+Example of `Plugin.php` file (`plugins/Foobar/Plugin.php`):
+
+```php
+template->hook->attach('template:layout:head', 'theme:layout/head');
+ }
+}
+```
+
+This file should contains a class `Plugin` defined under the namespace `Kanboard\Plugin\Yourplugin` and extends `Kanboard\Core\Plugin\Base`.
+
+The only required method is `initialize()`. This method is called for each request when the plugin is loaded.
+
+Plugin Methods
+--------------
+
+Available methods from `Kanboard\Core\Plugin\Base`:
+
+- `initialize()`: Executed when the plugin is loaded
+- `getClasses()`: Return all classes that should be stored in the dependency injection container
+- `on($event, $callback)`: Listen on internal events
+- `getPluginName()`: Should return plugin name
+- `getPluginAuthor()`: Should return plugin author
+- `getPluginVersion()`: Should return plugin version
+- `getPluginDescription()`: Should return plugin description
+- `getPluginHomepage()`: Should return plugin Homepage (link)
+- `setContentSecurityPolicy(array $rules)`: Override default HTTP CSP rules
+
+Your plugin registration class also inherit from `Kanboard\Core\Base`, that means you can access to all classes and methods of Kanboard easily.
+
+This example will fetch the user #123:
+
+```php
+$this->user->getById(123);
+```
+
+Plugin Translations
+-------------------
+
+Plugin can be translated in the same way the rest of the application. You must load the translations yourself when the session is created:
+
+```php
+$this->on('session.bootstrap', function($container) {
+ Translator::load($container['config']->getCurrentLanguage(), __DIR__.'/Locale');
+});
+```
+
+The translations must be stored in `plugins/Myplugin/Locale/xx_XX/translations.php`.
+
+Dependency Injection Container
+------------------------------
+
+Kanboard use Pimple, a simple PHP Dependency Injection Container. However, Kanboard can register any class in the container easily.
+
+Those classes are available everywhere in the application and only one instance is created.
+
+Here an example to register your own models in the container:
+
+```php
+public function getClasses()
+{
+ return array(
+ 'Plugin\Budget\Model' => array(
+ 'HourlyRate',
+ 'Budget',
+ )
+ );
+}
+```
+
+Now, if you use a class that extends from `Core\Base`, you can access directly to those class instance:
+
+```php
+$this->hourlyRate->remove(123);
+$this->budget->getDailyBudgetBreakdown(456);
+
+// It's the same thing as using the container:
+$this->container['hourlyRate']->getAll();
+```
+
+Keys of the containers are unique across the application. If you override an existing class you will change the default behavior.
+
+Event Listening
+----------------
+
+Kanboard use internal events and your plugin can listen and perform actions on these events.
+
+```php
+$this->on('session.bootstrap', function($container) {
+ // Do something
+});
+```
+
+- The first argument is the event name
+- The second argument is a PHP callable function (closure or class method)
+
+Extend Automatic Actions
+------------------------
+
+To define a new automatic action with a plugin, you just need to call the method `extendActions()` from the class `Kanboard\Model\Action`, here an example:
+
+```php
+action->extendActions(
+ '\Kanboard\Plugin\AutomaticAction\Action\DoSomething', // Use absolute namespace
+ t('Do something when the task color change')
+ );
+ }
+}
+```
+
+- The first argument of the method `extendActions()` is the action class with the complete namespace path. **The namespace path must starts with a backslash** otherwise Kanboard will not be able to load your class.
+- The second argument is the description of your automatic action.
+
+The automatic action class must inherits from the class `Kanboard\Action\Base` and implements all abstract methods:
+
+- `getCompatibleEvents()`
+- `getActionRequiredParameters()`
+- `getEventRequiredParameters()`
+- `doAction(array $data)`
+- `hasRequiredCondition(array $data)`
+
+For more details you should take a look to existing automatic actions or this [plugin example](https://github.com/kanboard/plugin-example-automatic-action).
+
+Extend ACL
+----------
+
+Kanboard use an access list for privilege separations. Your extension can add new rules:
+
+```php
+$this->acl->extend('project_manager_acl', array('mycontroller' => '*'));
+```
+
+- The first argument is the ACL name
+- The second argument are the new rules
+ + Syntax to include only some actions: `array('controller' => array('action1', 'action2'))`
+ + Syntax to include all actions of a controller: `array('controller' => '*')`
+ + Everything is lowercase
+
+List of ACL:
+
+- `public_acl`: Public access without authentication
+- `project_member_acl`: Project member access
+- `project_manager_acl`: Project manager access
+- `project_admin_acl`: Project Admins
+- `admin_acl`: Administrators
diff --git a/sources/doc/plugin-schema-migrations.markdown b/sources/doc/plugin-schema-migrations.markdown
new file mode 100644
index 0000000..d595605
--- /dev/null
+++ b/sources/doc/plugin-schema-migrations.markdown
@@ -0,0 +1,38 @@
+Plugin Schema Migrations
+========================
+
+Kanboard execute database migrations automatically for you. Migrations must be stored in a folder **Schema** and the filename must be the same as the database driver:
+
+```bash
+Schema
+├── Mysql.php
+├── Postgres.php
+└── Sqlite.php
+```
+
+Each file contains all migrations, here an example for Sqlite:
+
+```php
+exec('CREATE TABLE IF NOT EXISTS something (
+ "id" INTEGER PRIMARY KEY,
+ "project_id" INTEGER NOT NULL,
+ "something" TEXT,
+ FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
+ )');
+}
+```
+
+- The constant `VERSION` is the last version of your schema
+- Each function is a migration `version_1()`, `version_2()`, etc...
+- A `PDO` instance is passed as first argument
+- Everything is executed inside a transaction, if something doesn't work a rollback is performed and the error is displayed to the user
+
+Kanboard will compare the version defined in your schema and the version stored in the database. If the versions are different, Kanboard will execute one by one each migration until to reach the last version.
diff --git a/sources/vendor/symfony/console/Tests/ClockMock.php b/sources/vendor/symfony/console/Tests/ClockMock.php
new file mode 100644
index 0000000..0e92316
--- /dev/null
+++ b/sources/vendor/symfony/console/Tests/ClockMock.php
@@ -0,0 +1,41 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\Console\Helper;
+
+use Symfony\Component\Console\Tests;
+
+function time()
+{
+ return Tests\time();
+}
+
+namespace Symfony\Component\Console\Tests;
+
+function with_clock_mock($enable = null)
+{
+ static $enabled;
+
+ if (null === $enable) {
+ return $enabled;
+ }
+
+ $enabled = $enable;
+}
+
+function time()
+{
+ if (!with_clock_mock()) {
+ return \time();
+ }
+
+ return $_SERVER['REQUEST_TIME'];
+}