summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/.htaccess1
-rw-r--r--app/Action/Base.php142
-rw-r--r--app/Action/TaskAssignColorCategory.php85
-rw-r--r--app/Action/TaskAssignColorUser.php85
-rw-r--r--app/Action/TaskAssignCurrentUser.php95
-rw-r--r--app/Action/TaskAssignSpecificUser.php85
-rw-r--r--app/Action/TaskClose.php79
-rw-r--r--app/Action/TaskDuplicateAnotherProject.php83
-rw-r--r--app/Controller/Action.php142
-rw-r--r--app/Controller/App.php29
-rw-r--r--app/Controller/Base.php238
-rw-r--r--app/Controller/Board.php411
-rw-r--r--app/Controller/Category.php189
-rw-r--r--app/Controller/Comment.php189
-rw-r--r--app/Controller/Config.php117
-rw-r--r--app/Controller/Project.php375
-rw-r--r--app/Controller/Task.php397
-rw-r--r--app/Controller/User.php310
-rw-r--r--app/Core/Event.php135
-rw-r--r--app/Core/Listener.php17
-rw-r--r--app/Core/Loader.php37
-rw-r--r--app/Core/Registry.php79
-rw-r--r--app/Core/Request.php56
-rw-r--r--app/Core/Response.php138
-rw-r--r--app/Core/Router.php111
-rw-r--r--app/Core/Session.php56
-rw-r--r--app/Core/Template.php72
-rw-r--r--app/Core/Translator.php155
-rw-r--r--app/Event/TaskModification.php51
-rw-r--r--app/Locales/es_ES/translations.php334
-rw-r--r--app/Locales/fr_FR/translations.php334
-rw-r--r--app/Locales/pl_PL/translations.php339
-rw-r--r--app/Locales/pt_BR/translations.php335
-rw-r--r--app/Model/Acl.php159
-rw-r--r--app/Model/Action.php267
-rw-r--r--app/Model/Base.php76
-rw-r--r--app/Model/Board.php340
-rw-r--r--app/Model/Category.php150
-rw-r--r--app/Model/Comment.php171
-rw-r--r--app/Model/Config.php182
-rw-r--r--app/Model/Google.php152
-rw-r--r--app/Model/LastLogin.php91
-rw-r--r--app/Model/Ldap.php79
-rw-r--r--app/Model/Project.php558
-rw-r--r--app/Model/RememberMe.php333
-rw-r--r--app/Model/Task.php627
-rw-r--r--app/Model/User.php426
-rw-r--r--app/Schema/Mysql.php236
-rw-r--r--app/Schema/Sqlite.php259
-rw-r--r--app/Templates/action_index.php77
-rw-r--r--app/Templates/action_params.php43
-rw-r--r--app/Templates/action_remove.php16
-rw-r--r--app/Templates/app_notfound.php9
-rw-r--r--app/Templates/board_assign.php35
-rw-r--r--app/Templates/board_edit.php66
-rw-r--r--app/Templates/board_index.php42
-rw-r--r--app/Templates/board_public.php79
-rw-r--r--app/Templates/board_remove.php17
-rw-r--r--app/Templates/board_show.php88
-rw-r--r--app/Templates/category_edit.php24
-rw-r--r--app/Templates/category_index.php48
-rw-r--r--app/Templates/category_remove.php16
-rw-r--r--app/Templates/comment_forbidden.php9
-rw-r--r--app/Templates/comment_remove.php18
-rw-r--r--app/Templates/comment_show.php36
-rw-r--r--app/Templates/config_index.php120
-rw-r--r--app/Templates/layout.php61
-rw-r--r--app/Templates/project_edit.php24
-rw-r--r--app/Templates/project_forbidden.php9
-rw-r--r--app/Templates/project_index.php98
-rw-r--r--app/Templates/project_new.php20
-rw-r--r--app/Templates/project_remove.php16
-rw-r--r--app/Templates/project_search.php93
-rw-r--r--app/Templates/project_tasks.php71
-rw-r--r--app/Templates/project_users.php44
-rw-r--r--app/Templates/task_close.php10
-rw-r--r--app/Templates/task_edit.php51
-rw-r--r--app/Templates/task_layout.php16
-rw-r--r--app/Templates/task_new.php51
-rw-r--r--app/Templates/task_open.php16
-rw-r--r--app/Templates/task_remove.php10
-rw-r--r--app/Templates/task_show.php94
-rw-r--r--app/Templates/task_sidebar.php17
-rw-r--r--app/Templates/user_edit.php64
-rw-r--r--app/Templates/user_forbidden.php9
-rw-r--r--app/Templates/user_index.php56
-rw-r--r--app/Templates/user_login.php28
-rw-r--r--app/Templates/user_new.php45
-rw-r--r--app/Templates/user_remove.php14
-rw-r--r--app/check_setup.php40
-rw-r--r--app/common.php98
-rw-r--r--app/helpers.php262
-rw-r--r--app/translator.php36
93 files changed, 11503 insertions, 0 deletions
diff --git a/app/.htaccess b/app/.htaccess
new file mode 100644
index 00000000..14249c50
--- /dev/null
+++ b/app/.htaccess
@@ -0,0 +1 @@
+Deny from all \ No newline at end of file
diff --git a/app/Action/Base.php b/app/Action/Base.php
new file mode 100644
index 00000000..14b0a3c0
--- /dev/null
+++ b/app/Action/Base.php
@@ -0,0 +1,142 @@
+<?php
+
+namespace Action;
+
+use Core\Listener;
+
+/**
+ * Base class for automatic actions
+ *
+ * @package action
+ * @author Frederic Guillot
+ */
+abstract class Base implements Listener
+{
+ /**
+ * Project id
+ *
+ * @access private
+ * @var integer
+ */
+ private $project_id = 0;
+
+ /**
+ * User parameters
+ *
+ * @access private
+ * @var array
+ */
+ private $params = array();
+
+ /**
+ * Execute the action
+ *
+ * @abstract
+ * @access public
+ * @param array $data Event data dictionary
+ * @return bool True if the action was executed or false when not executed
+ */
+ abstract public function doAction(array $data);
+
+ /**
+ * Get the required parameter for the action (defined by the user)
+ *
+ * @abstract
+ * @access public
+ * @return array
+ */
+ abstract public function getActionRequiredParameters();
+
+ /**
+ * Get the required parameter for the event (check if for the event data)
+ *
+ * @abstract
+ * @access public
+ * @return array
+ */
+ abstract public function getEventRequiredParameters();
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param integer $project_id Project id
+ */
+ public function __construct($project_id)
+ {
+ $this->project_id = $project_id;
+ }
+
+ /**
+ * Set an user defined parameter
+ *
+ * @access public
+ * @param string $name Parameter name
+ * @param mixed $value Value
+ */
+ public function setParam($name, $value)
+ {
+ $this->params[$name] = $value;
+ }
+
+ /**
+ * Get an user defined parameter
+ *
+ * @access public
+ * @param string $name Parameter name
+ * @param mixed $default_value Default value
+ * @return mixed
+ */
+ public function getParam($name, $default_value = null)
+ {
+ return isset($this->params[$name]) ? $this->params[$name] : $default_value;
+ }
+
+ /**
+ * Check if an action is executable (right project and required parameters)
+ *
+ * @access public
+ * @param array $data Event data dictionary
+ * @return bool True if the action is executable
+ */
+ public function isExecutable(array $data)
+ {
+ if (isset($data['project_id']) && $data['project_id'] == $this->project_id && $this->hasRequiredParameters($data)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if the event data has required parameters to execute the action
+ *
+ * @access public
+ * @param array $data Event data dictionary
+ * @return bool True if all keys are there
+ */
+ public function hasRequiredParameters(array $data)
+ {
+ foreach ($this->getEventRequiredParameters() as $parameter) {
+ if (! isset($data[$parameter])) return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Execute the action
+ *
+ * @access public
+ * @param array $data Event data dictionary
+ * @return bool True if the action was executed or false when not executed
+ */
+ public function execute(array $data)
+ {
+ if ($this->isExecutable($data)) {
+ return $this->doAction($data);
+ }
+
+ return false;
+ }
+}
diff --git a/app/Action/TaskAssignColorCategory.php b/app/Action/TaskAssignColorCategory.php
new file mode 100644
index 00000000..4304d084
--- /dev/null
+++ b/app/Action/TaskAssignColorCategory.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace Action;
+
+use Model\Task;
+
+/**
+ * Assign a color to a specific category
+ *
+ * @package action
+ * @author Frederic Guillot
+ */
+class TaskAssignColorCategory extends Base
+{
+ /**
+ * Task model
+ *
+ * @accesss private
+ * @var \Model\Task
+ */
+ private $task;
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param \Model\Task $task Task model instance
+ */
+ public function __construct($project_id, Task $task)
+ {
+ parent::__construct($project_id);
+ $this->task = $task;
+ }
+
+ /**
+ * Get the required parameter for the action (defined by the user)
+ *
+ * @access public
+ * @return array
+ */
+ public function getActionRequiredParameters()
+ {
+ return array(
+ 'color_id' => t('Color'),
+ 'category_id' => t('Category'),
+ );
+ }
+
+ /**
+ * Get the required parameter for the event
+ *
+ * @access public
+ * @return string[]
+ */
+ public function getEventRequiredParameters()
+ {
+ return array(
+ 'task_id',
+ 'category_id',
+ );
+ }
+
+ /**
+ * Execute the action
+ *
+ * @access public
+ * @param array $data Event data dictionary
+ * @return bool True if the action was executed or false when not executed
+ */
+ public function doAction(array $data)
+ {
+ if ($data['category_id'] == $this->getParam('category_id')) {
+
+ $this->task->update(array(
+ 'id' => $data['task_id'],
+ 'color_id' => $this->getParam('color_id'),
+ ));
+
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/app/Action/TaskAssignColorUser.php b/app/Action/TaskAssignColorUser.php
new file mode 100644
index 00000000..9ff140b3
--- /dev/null
+++ b/app/Action/TaskAssignColorUser.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace Action;
+
+use Model\Task;
+
+/**
+ * Assign a color to a specific user
+ *
+ * @package action
+ * @author Frederic Guillot
+ */
+class TaskAssignColorUser extends Base
+{
+ /**
+ * Task model
+ *
+ * @accesss private
+ * @var \Model\Task
+ */
+ private $task;
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param \Model\Task $task Task model instance
+ */
+ public function __construct($project_id, Task $task)
+ {
+ parent::__construct($project_id);
+ $this->task = $task;
+ }
+
+ /**
+ * Get the required parameter for the action (defined by the user)
+ *
+ * @access public
+ * @return array
+ */
+ public function getActionRequiredParameters()
+ {
+ return array(
+ 'color_id' => t('Color'),
+ 'user_id' => t('Assignee'),
+ );
+ }
+
+ /**
+ * Get the required parameter for the event
+ *
+ * @access public
+ * @return string[]
+ */
+ public function getEventRequiredParameters()
+ {
+ return array(
+ 'task_id',
+ 'owner_id',
+ );
+ }
+
+ /**
+ * Execute the action
+ *
+ * @access public
+ * @param array $data Event data dictionary
+ * @return bool True if the action was executed or false when not executed
+ */
+ public function doAction(array $data)
+ {
+ if ($data['owner_id'] == $this->getParam('user_id')) {
+
+ $this->task->update(array(
+ 'id' => $data['task_id'],
+ 'color_id' => $this->getParam('color_id'),
+ ));
+
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/app/Action/TaskAssignCurrentUser.php b/app/Action/TaskAssignCurrentUser.php
new file mode 100644
index 00000000..1c038966
--- /dev/null
+++ b/app/Action/TaskAssignCurrentUser.php
@@ -0,0 +1,95 @@
+<?php
+
+namespace Action;
+
+use Model\Task;
+use Model\Acl;
+
+/**
+ * Assign a task to the logged user
+ *
+ * @package action
+ * @author Frederic Guillot
+ */
+class TaskAssignCurrentUser extends Base
+{
+ /**
+ * Task model
+ *
+ * @accesss private
+ * @var \Model\Task
+ */
+ private $task;
+
+ /**
+ * Acl model
+ *
+ * @accesss private
+ * @var \Model\Acl
+ */
+ private $acl;
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param \Model\Task $task Task model instance
+ * @param \Model\Acl $acl Acl model instance
+ */
+ public function __construct($project_id, Task $task, Acl $acl)
+ {
+ parent::__construct($project_id);
+ $this->task = $task;
+ $this->acl = $acl;
+ }
+
+ /**
+ * Get the required parameter for the action (defined by the user)
+ *
+ * @access public
+ * @return array
+ */
+ public function getActionRequiredParameters()
+ {
+ return array(
+ 'column_id' => t('Column'),
+ );
+ }
+
+ /**
+ * Get the required parameter for the event
+ *
+ * @access public
+ * @return string[]
+ */
+ public function getEventRequiredParameters()
+ {
+ return array(
+ 'task_id',
+ 'column_id',
+ );
+ }
+
+ /**
+ * Execute the action
+ *
+ * @access public
+ * @param array $data Event data dictionary
+ * @return bool True if the action was executed or false when not executed
+ */
+ public function doAction(array $data)
+ {
+ if ($data['column_id'] == $this->getParam('column_id')) {
+
+ $this->task->update(array(
+ 'id' => $data['task_id'],
+ 'owner_id' => $this->acl->getUserId(),
+ ));
+
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/app/Action/TaskAssignSpecificUser.php b/app/Action/TaskAssignSpecificUser.php
new file mode 100644
index 00000000..8c379bcc
--- /dev/null
+++ b/app/Action/TaskAssignSpecificUser.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace Action;
+
+use Model\Task;
+
+/**
+ * Assign a task to a specific user
+ *
+ * @package action
+ * @author Frederic Guillot
+ */
+class TaskAssignSpecificUser extends Base
+{
+ /**
+ * Task model
+ *
+ * @accesss private
+ * @var \Model\Task
+ */
+ private $task;
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param \Model\Task $task Task model instance
+ */
+ public function __construct($project_id, Task $task)
+ {
+ parent::__construct($project_id);
+ $this->task = $task;
+ }
+
+ /**
+ * Get the required parameter for the action (defined by the user)
+ *
+ * @access public
+ * @return array
+ */
+ public function getActionRequiredParameters()
+ {
+ return array(
+ 'column_id' => t('Column'),
+ 'user_id' => t('Assignee'),
+ );
+ }
+
+ /**
+ * Get the required parameter for the event
+ *
+ * @access public
+ * @return string[]
+ */
+ public function getEventRequiredParameters()
+ {
+ return array(
+ 'task_id',
+ 'column_id',
+ );
+ }
+
+ /**
+ * Execute the action
+ *
+ * @access public
+ * @param array $data Event data dictionary
+ * @return bool True if the action was executed or false when not executed
+ */
+ public function doAction(array $data)
+ {
+ if ($data['column_id'] == $this->getParam('column_id')) {
+
+ $this->task->update(array(
+ 'id' => $data['task_id'],
+ 'owner_id' => $this->getParam('user_id'),
+ ));
+
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/app/Action/TaskClose.php b/app/Action/TaskClose.php
new file mode 100644
index 00000000..32887d3c
--- /dev/null
+++ b/app/Action/TaskClose.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace Action;
+
+use Model\Task;
+
+/**
+ * Close automatically a task
+ *
+ * @package action
+ * @author Frederic Guillot
+ */
+class TaskClose extends Base
+{
+ /**
+ * Task model
+ *
+ * @accesss private
+ * @var \Model\Task
+ */
+ private $task;
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param \Model\Task $task Task model instance
+ */
+ public function __construct($project_id, Task $task)
+ {
+ parent::__construct($project_id);
+ $this->task = $task;
+ }
+
+ /**
+ * Get the required parameter for the action (defined by the user)
+ *
+ * @access public
+ * @return array
+ */
+ public function getActionRequiredParameters()
+ {
+ return array(
+ 'column_id' => t('Column'),
+ );
+ }
+
+ /**
+ * Get the required parameter for the event
+ *
+ * @access public
+ * @return string[]
+ */
+ public function getEventRequiredParameters()
+ {
+ return array(
+ 'task_id',
+ 'column_id',
+ );
+ }
+
+ /**
+ * Execute the action
+ *
+ * @access public
+ * @param array $data Event data dictionary
+ * @return bool True if the action was executed or false when not executed
+ */
+ public function doAction(array $data)
+ {
+ if ($data['column_id'] == $this->getParam('column_id')) {
+ $this->task->close($data['task_id']);
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/app/Action/TaskDuplicateAnotherProject.php b/app/Action/TaskDuplicateAnotherProject.php
new file mode 100644
index 00000000..7ef0f6ab
--- /dev/null
+++ b/app/Action/TaskDuplicateAnotherProject.php
@@ -0,0 +1,83 @@
+<?php
+
+namespace Action;
+
+use Model\Task;
+
+/**
+ * Duplicate a task to another project
+ *
+ * @package action
+ * @author Frederic Guillot
+ */
+class TaskDuplicateAnotherProject extends Base
+{
+ /**
+ * Task model
+ *
+ * @accesss private
+ * @var \Model\Task
+ */
+ private $task;
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param \Model\Task $task Task model instance
+ */
+ public function __construct($project_id, Task $task)
+ {
+ parent::__construct($project_id);
+ $this->task = $task;
+ }
+
+ /**
+ * Get the required parameter for the action (defined by the user)
+ *
+ * @access public
+ * @return array
+ */
+ public function getActionRequiredParameters()
+ {
+ return array(
+ 'column_id' => t('Column'),
+ 'project_id' => t('Project'),
+ );
+ }
+
+ /**
+ * Get the required parameter for the event
+ *
+ * @access public
+ * @return string[]
+ */
+ public function getEventRequiredParameters()
+ {
+ return array(
+ 'task_id',
+ 'column_id',
+ 'project_id',
+ );
+ }
+
+ /**
+ * Execute the action
+ *
+ * @access public
+ * @param array $data Event data dictionary
+ * @return bool True if the action was executed or false when not executed
+ */
+ public function doAction(array $data)
+ {
+ if ($data['column_id'] == $this->getParam('column_id') && $data['project_id'] != $this->getParam('project_id')) {
+
+ $this->task->duplicateToAnotherProject($data['task_id'], $this->getParam('project_id'));
+
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/app/Controller/Action.php b/app/Controller/Action.php
new file mode 100644
index 00000000..b32a8906
--- /dev/null
+++ b/app/Controller/Action.php
@@ -0,0 +1,142 @@
+<?php
+
+namespace Controller;
+
+/**
+ * Automatic actions management
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class Action extends Base
+{
+ /**
+ * List of automatic actions for a given project
+ *
+ * @access public
+ */
+ public function index()
+ {
+ $project_id = $this->request->getIntegerParam('project_id');
+ $project = $this->project->getById($project_id);
+
+ if (! $project) {
+ $this->session->flashError(t('Project not found.'));
+ $this->response->redirect('?controller=project');
+ }
+
+ $this->response->html($this->template->layout('action_index', array(
+ 'values' => array('project_id' => $project['id']),
+ 'project' => $project,
+ 'actions' => $this->action->getAllByProject($project['id']),
+ 'available_actions' => $this->action->getAvailableActions(),
+ 'available_events' => $this->action->getAvailableEvents(),
+ 'available_params' => $this->action->getAllActionParameters(),
+ 'columns_list' => $this->board->getColumnsList($project['id']),
+ 'users_list' => $this->project->getUsersList($project['id'], false),
+ 'projects_list' => $this->project->getList(false),
+ 'colors_list' => $this->task->getColors(),
+ 'categories_list' => $this->category->getList($project['id'], false),
+ 'menu' => 'projects',
+ 'title' => t('Automatic actions')
+ )));
+ }
+
+ /**
+ * Define action parameters (step 2)
+ *
+ * @access public
+ */
+ public function params()
+ {
+ $project_id = $this->request->getIntegerParam('project_id');
+ $project = $this->project->getById($project_id);
+
+ if (! $project) {
+ $this->session->flashError(t('Project not found.'));
+ $this->response->redirect('?controller=project');
+ }
+
+ $values = $this->request->getValues();
+ $action = $this->action->load($values['action_name'], $values['project_id']);
+
+ $this->response->html($this->template->layout('action_params', array(
+ 'values' => $values,
+ 'action_params' => $action->getActionRequiredParameters(),
+ 'columns_list' => $this->board->getColumnsList($project['id']),
+ 'users_list' => $this->project->getUsersList($project['id'], false),
+ 'projects_list' => $this->project->getList(false),
+ 'colors_list' => $this->task->getColors(),
+ 'categories_list' => $this->category->getList($project['id'], false),
+ 'project' => $project,
+ 'menu' => 'projects',
+ 'title' => t('Automatic actions')
+ )));
+ }
+
+ /**
+ * Create a new action (last step)
+ *
+ * @access public
+ */
+ public function create()
+ {
+ $project_id = $this->request->getIntegerParam('project_id');
+ $project = $this->project->getById($project_id);
+
+ if (! $project) {
+ $this->session->flashError(t('Project not found.'));
+ $this->response->redirect('?controller=project');
+ }
+
+ $values = $this->request->getValues();
+
+ list($valid, $errors) = $this->action->validateCreation($values);
+
+ if ($valid) {
+
+ if ($this->action->create($values)) {
+ $this->session->flash(t('Your automatic action have been created successfully.'));
+ }
+ else {
+ $this->session->flashError(t('Unable to create your automatic action.'));
+ }
+ }
+
+ $this->response->redirect('?controller=action&action=index&project_id='.$project['id']);
+ }
+
+ /**
+ * Confirmation dialog before removing an action
+ *
+ * @access public
+ */
+ public function confirm()
+ {
+ $this->response->html($this->template->layout('action_remove', array(
+ 'action' => $this->action->getById($this->request->getIntegerParam('action_id')),
+ 'available_events' => $this->action->getAvailableEvents(),
+ 'available_actions' => $this->action->getAvailableActions(),
+ 'menu' => 'projects',
+ 'title' => t('Remove an action')
+ )));
+ }
+
+ /**
+ * Remove an action
+ *
+ * @access public
+ */
+ public function remove()
+ {
+ $action = $this->action->getById($this->request->getIntegerParam('action_id'));
+
+ if ($action && $this->action->remove($action['id'])) {
+ $this->session->flash(t('Action removed successfully.'));
+ } else {
+ $this->session->flashError(t('Unable to remove this action.'));
+ }
+
+ $this->response->redirect('?controller=action&action=index&project_id='.$action['project_id']);
+ }
+}
diff --git a/app/Controller/App.php b/app/Controller/App.php
new file mode 100644
index 00000000..64f9461f
--- /dev/null
+++ b/app/Controller/App.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Controller;
+
+use Model\Project;
+
+/**
+ * Application controller
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class App extends Base
+{
+ /**
+ * Redirect to the project creation page or the board controller
+ *
+ * @access public
+ */
+ public function index()
+ {
+ if ($this->project->countByStatus(Project::ACTIVE)) {
+ $this->response->redirect('?controller=board');
+ }
+ else {
+ $this->redirectNoProject();
+ }
+ }
+}
diff --git a/app/Controller/Base.php b/app/Controller/Base.php
new file mode 100644
index 00000000..bb9add4f
--- /dev/null
+++ b/app/Controller/Base.php
@@ -0,0 +1,238 @@
+<?php
+
+namespace Controller;
+
+use Core\Registry;
+use Core\Translator;
+use Model\LastLogin;
+
+/**
+ * Base controller
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+abstract class Base
+{
+ /**
+ * Request instance
+ *
+ * @accesss public
+ * @var \Core\Request
+ */
+ public $request;
+
+ /**
+ * Response instance
+ *
+ * @accesss public
+ * @var \Core\Response
+ */
+ public $response;
+
+ /**
+ * Template instance
+ *
+ * @accesss public
+ * @var \Core\Template
+ */
+ public $template;
+
+ /**
+ * Session instance
+ *
+ * @accesss public
+ * @var \Core\Session
+ */
+ public $session;
+
+ /**
+ * Registry instance
+ *
+ * @access private
+ * @var Core\Registry
+ */
+ private $registry;
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param \Core\Registry $registry Registry instance
+ */
+ public function __construct(Registry $registry)
+ {
+ $this->registry = $registry;
+ }
+
+ /**
+ * Load automatically models
+ *
+ * @access public
+ * @param string $name Model name
+ */
+ public function __get($name)
+ {
+ $class = '\Model\\'.ucfirst($name);
+ $this->registry->$name = new $class($this->registry->shared('db'), $this->registry->shared('event'));
+ return $this->registry->shared($name);
+ }
+
+ /**
+ * Method executed before each action
+ *
+ * @access public
+ */
+ public function beforeAction($controller, $action)
+ {
+ // Start the session
+ $this->session->open(BASE_URL_DIRECTORY, SESSION_SAVE_PATH);
+
+ // HTTP secure headers
+ $this->response->csp();
+ $this->response->nosniff();
+ $this->response->xss();
+ $this->response->hsts();
+ $this->response->xframe();
+
+ // Load translations
+ $language = $this->config->get('language', 'en_US');
+ if ($language !== 'en_US') Translator::load($language);
+
+ // Set timezone
+ date_default_timezone_set($this->config->get('timezone', 'UTC'));
+
+ // Authentication
+ if (! $this->acl->isLogged() && ! $this->acl->isPublicAction($controller, $action)) {
+
+ // Try the remember me authentication first
+ if (! $this->rememberMe->authenticate()) {
+
+ // Redirect to the login form if not authenticated
+ $this->response->redirect('?controller=user&action=login');
+ }
+ else {
+
+ $this->lastLogin->create(
+ LastLogin::AUTH_REMEMBER_ME,
+ $this->acl->getUserId(),
+ $this->user->getIpAddress(),
+ $this->user->getUserAgent()
+ );
+ }
+ }
+ else if ($this->rememberMe->hasCookie()) {
+ $this->rememberMe->refresh();
+ }
+
+ // Check if the user is allowed to see this page
+ if (! $this->acl->isPageAccessAllowed($controller, $action)) {
+ $this->response->redirect('?controller=user&action=forbidden');
+ }
+
+ // Attach events
+ $this->action->attachEvents();
+ $this->project->attachEvents();
+ }
+
+ /**
+ * Application not found page (404 error)
+ *
+ * @access public
+ */
+ public function notfound()
+ {
+ $this->response->html($this->template->layout('app_notfound', array('title' => t('Page not found'))));
+ }
+
+ /**
+ * Check if the current user have access to the given project
+ *
+ * @access protected
+ * @param integer $project_id Project id
+ */
+ protected function checkProjectPermissions($project_id)
+ {
+ if ($this->acl->isRegularUser()) {
+
+ if ($project_id > 0 && ! $this->project->isUserAllowed($project_id, $this->acl->getUserId())) {
+ $this->response->redirect('?controller=project&action=forbidden');
+ }
+ }
+ }
+
+ /**
+ * Redirection when there is no project in the database
+ *
+ * @access protected
+ */
+ protected function redirectNoProject()
+ {
+ $this->session->flash(t('There is no active project, the first step is to create a new project.'));
+ $this->response->redirect('?controller=project&action=create');
+ }
+
+ /**
+ * Display the template show task (common between different actions)
+ *
+ * @access protected
+ * @param array $task Task data
+ * @param array $comment_form Comment form data
+ * @param array $description_form Description form data
+ * @param array $comment_edit_form Comment edit form data
+ */
+ protected function showTask(array $task, array $comment_form = array(), array $description_form = array(), array $comment_edit_form = array())
+ {
+ if (empty($comment_form)) {
+ $comment_form = array(
+ 'values' => array('task_id' => $task['id'], 'user_id' => $this->acl->getUserId()),
+ 'errors' => array()
+ );
+ }
+
+ if (empty($description_form)) {
+ $description_form = array(
+ 'values' => array('id' => $task['id']),
+ 'errors' => array()
+ );
+ }
+
+ if (empty($comment_edit_form)) {
+ $comment_edit_form = array(
+ 'values' => array('id' => 0),
+ 'errors' => array()
+ );
+ }
+ else {
+ $hide_comment_form = true;
+ }
+
+ $this->response->html($this->taskLayout('task_show', array(
+ 'hide_comment_form' => isset($hide_comment_form),
+ 'comment_edit_form' => $comment_edit_form,
+ 'comment_form' => $comment_form,
+ 'description_form' => $description_form,
+ 'comments' => $this->comment->getAll($task['id']),
+ 'task' => $task,
+ 'columns_list' => $this->board->getColumnsList($task['project_id']),
+ 'colors_list' => $this->task->getColors(),
+ 'menu' => 'tasks',
+ 'title' => $task['title'],
+ )));
+ }
+
+ /**
+ * Common layout for task views
+ *
+ * @access protected
+ * @param string $template Template name
+ * @param array $params Template parameters
+ */
+ protected function taskLayout($template, array $params)
+ {
+ $content = $this->template->load($template, $params);
+ $params['task_content_for_layout'] = $content;
+
+ return $this->template->layout('task_layout', $params);
+ }
+}
diff --git a/app/Controller/Board.php b/app/Controller/Board.php
new file mode 100644
index 00000000..c727a422
--- /dev/null
+++ b/app/Controller/Board.php
@@ -0,0 +1,411 @@
+<?php
+
+namespace Controller;
+
+use Model\Project;
+use Model\User;
+
+/**
+ * Board controller
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class Board extends Base
+{
+ /**
+ * Move a column up
+ *
+ * @access public
+ */
+ public function moveUp()
+ {
+ $project_id = $this->request->getIntegerParam('project_id');
+ $column_id = $this->request->getIntegerParam('column_id');
+
+ $this->board->moveUp($project_id, $column_id);
+
+ $this->response->redirect('?controller=board&action=edit&project_id='.$project_id);
+ }
+
+ /**
+ * Move a column down
+ *
+ * @access public
+ */
+ public function moveDown()
+ {
+ $project_id = $this->request->getIntegerParam('project_id');
+ $column_id = $this->request->getIntegerParam('column_id');
+
+ $this->board->moveDown($project_id, $column_id);
+
+ $this->response->redirect('?controller=board&action=edit&project_id='.$project_id);
+ }
+
+ /**
+ * Change a task assignee directly from the board
+ *
+ * @access public
+ */
+ public function assign()
+ {
+ $task = $this->task->getById($this->request->getIntegerParam('task_id'));
+ $project = $this->project->getById($task['project_id']);
+ $projects = $this->project->getListByStatus(Project::ACTIVE);
+
+ if ($this->acl->isRegularUser()) {
+ $projects = $this->project->filterListByAccess($projects, $this->acl->getUserId());
+ }
+
+ if (! $project) $this->notfound();
+ $this->checkProjectPermissions($project['id']);
+
+ if ($this->request->isAjax()) {
+
+ $this->response->html($this->template->load('board_assign', array(
+ 'errors' => array(),
+ 'values' => $task,
+ 'users_list' => $this->project->getUsersList($project['id']),
+ 'projects' => $projects,
+ 'current_project_id' => $project['id'],
+ 'current_project_name' => $project['name'],
+ )));
+ }
+ else {
+
+ $this->response->html($this->template->layout('board_assign', array(
+ 'errors' => array(),
+ 'values' => $task,
+ 'users_list' => $this->project->getUsersList($project['id']),
+ 'projects' => $projects,
+ 'current_project_id' => $project['id'],
+ 'current_project_name' => $project['name'],
+ 'menu' => 'boards',
+ 'title' => t('Change assignee').' - '.$task['title'],
+ )));
+ }
+ }
+
+ /**
+ * Validate an assignee modification
+ *
+ * @access public
+ */
+ public function assignTask()
+ {
+ $values = $this->request->getValues();
+ $this->checkProjectPermissions($values['project_id']);
+
+ list($valid,) = $this->task->validateAssigneeModification($values);
+
+ if ($valid && $this->task->update($values)) {
+ $this->session->flash(t('Task updated successfully.'));
+ }
+ else {
+ $this->session->flashError(t('Unable to update your task.'));
+ }
+
+ $this->response->redirect('?controller=board&action=show&project_id='.$values['project_id']);
+ }
+
+ /**
+ * Display the public version of a board
+ * Access checked by a simple token, no user login, read only, auto-refresh
+ *
+ * @access public
+ */
+ public function readonly()
+ {
+ $token = $this->request->getStringParam('token');
+ $project = $this->project->getByToken($token);
+
+ // Token verification
+ if (! $project) {
+ $this->response->text('Not Authorized', 401);
+ }
+
+ // Display the board with a specific layout
+ $this->response->html($this->template->layout('board_public', array(
+ 'project' => $project,
+ 'columns' => $this->board->get($project['id']),
+ 'categories' => $this->category->getList($project['id'], false),
+ 'title' => $project['name'],
+ 'no_layout' => true,
+ 'auto_refresh' => true,
+ )));
+ }
+
+ /**
+ * Redirect the user to the default project
+ *
+ * @access public
+ */
+ public function index()
+ {
+ $projects = $this->project->getListByStatus(Project::ACTIVE);
+
+ if ($this->acl->isRegularUser()) {
+ $projects = $this->project->filterListByAccess($projects, $this->acl->getUserId());
+ }
+
+ if (empty($projects)) {
+
+ if ($this->acl->isAdminUser()) {
+ $this->redirectNoProject();
+ }
+ else {
+ $this->response->redirect('?controller=project&action=forbidden');
+ }
+ }
+ else if (! empty($_SESSION['user']['default_project_id']) && isset($projects[$_SESSION['user']['default_project_id']])) {
+ $project_id = $_SESSION['user']['default_project_id'];
+ $project_name = $projects[$_SESSION['user']['default_project_id']];
+ }
+ else {
+ list($project_id, $project_name) = each($projects);
+ }
+
+ $this->response->redirect('?controller=board&action=show&project_id='.$project_id);
+ }
+
+ /**
+ * Show a board for a given project
+ *
+ * @access public
+ */
+ public function show()
+ {
+ $project_id = $this->request->getIntegerParam('project_id');
+ $user_id = $this->request->getIntegerParam('user_id', User::EVERYBODY_ID);
+
+ $this->checkProjectPermissions($project_id);
+ $projects = $this->project->getListByStatus(Project::ACTIVE);
+
+ if ($this->acl->isRegularUser()) {
+ $projects = $this->project->filterListByAccess($projects, $this->acl->getUserId());
+ }
+
+ if (! isset($projects[$project_id])) {
+ $this->notfound();
+ }
+
+ $this->response->html($this->template->layout('board_index', array(
+ 'users' => $this->project->getUsersList($project_id, true, true),
+ 'filters' => array('user_id' => $user_id),
+ 'projects' => $projects,
+ 'current_project_id' => $project_id,
+ 'current_project_name' => $projects[$project_id],
+ 'board' => $this->board->get($project_id),
+ 'categories' => $this->category->getList($project_id, true, true),
+ 'menu' => 'boards',
+ 'title' => $projects[$project_id]
+ )));
+ }
+
+ /**
+ * Display a form to edit a board
+ *
+ * @access public
+ */
+ public function edit()
+ {
+ $project_id = $this->request->getIntegerParam('project_id');
+ $project = $this->project->getById($project_id);
+
+ if (! $project) $this->notfound();
+
+ $columns = $this->board->getColumns($project_id);
+ $values = array();
+
+ foreach ($columns as $column) {
+ $values['title['.$column['id'].']'] = $column['title'];
+ $values['task_limit['.$column['id'].']'] = $column['task_limit'] ?: null;
+ }
+
+ $this->response->html($this->template->layout('board_edit', array(
+ 'errors' => array(),
+ 'values' => $values + array('project_id' => $project_id),
+ 'columns' => $columns,
+ 'project' => $project,
+ 'menu' => 'projects',
+ 'title' => t('Edit board')
+ )));
+ }
+
+ /**
+ * Validate and update a board
+ *
+ * @access public
+ */
+ public function update()
+ {
+ $project_id = $this->request->getIntegerParam('project_id');
+ $project = $this->project->getById($project_id);
+
+ if (! $project) $this->notfound();
+
+ $columns = $this->board->getColumns($project_id);
+ $data = $this->request->getValues();
+ $values = $columns_list = array();
+
+ foreach ($columns as $column) {
+ $columns_list[$column['id']] = $column['title'];
+ $values['title['.$column['id'].']'] = isset($data['title'][$column['id']]) ? $data['title'][$column['id']] : '';
+ $values['task_limit['.$column['id'].']'] = isset($data['task_limit'][$column['id']]) ? $data['task_limit'][$column['id']] : 0;
+ }
+
+ list($valid, $errors) = $this->board->validateModification($columns_list, $values);
+
+ if ($valid) {
+
+ if ($this->board->update($data)) {
+ $this->session->flash(t('Board updated successfully.'));
+ $this->response->redirect('?controller=board&action=edit&project_id='.$project['id']);
+ }
+ else {
+ $this->session->flashError(t('Unable to update this board.'));
+ }
+ }
+
+ $this->response->html($this->template->layout('board_edit', array(
+ 'errors' => $errors,
+ 'values' => $values + array('project_id' => $project_id),
+ 'columns' => $columns,
+ 'project' => $project,
+ 'menu' => 'projects',
+ 'title' => t('Edit board')
+ )));
+ }
+
+ /**
+ * Validate and add a new column
+ *
+ * @access public
+ */
+ public function add()
+ {
+ $project_id = $this->request->getIntegerParam('project_id');
+ $project = $this->project->getById($project_id);
+
+ if (! $project) $this->notfound();
+
+ $columns = $this->board->getColumnsList($project_id);
+ $data = $this->request->getValues();
+ $values = array();
+
+ foreach ($columns as $column_id => $column_title) {
+ $values['title['.$column_id.']'] = $column_title;
+ }
+
+ list($valid, $errors) = $this->board->validateCreation($data);
+
+ if ($valid) {
+
+ if ($this->board->add($data)) {
+ $this->session->flash(t('Board updated successfully.'));
+ $this->response->redirect('?controller=board&action=edit&project_id='.$project['id']);
+ }
+ else {
+ $this->session->flashError(t('Unable to update this board.'));
+ }
+ }
+
+ $this->response->html($this->template->layout('board_edit', array(
+ 'errors' => $errors,
+ 'values' => $values + $data,
+ 'columns' => $columns,
+ 'project' => $project,
+ 'menu' => 'projects',
+ 'title' => t('Edit board')
+ )));
+ }
+
+ /**
+ * Confirmation dialog before removing a column
+ *
+ * @access public
+ */
+ public function confirm()
+ {
+ $this->response->html($this->template->layout('board_remove', array(
+ 'column' => $this->board->getColumn($this->request->getIntegerParam('column_id')),
+ 'menu' => 'projects',
+ 'title' => t('Remove a column from a board')
+ )));
+ }
+
+ /**
+ * Remove a column
+ *
+ * @access public
+ */
+ public function remove()
+ {
+ $column = $this->board->getColumn($this->request->getIntegerParam('column_id'));
+
+ if ($column && $this->board->removeColumn($column['id'])) {
+ $this->session->flash(t('Column removed successfully.'));
+ } else {
+ $this->session->flashError(t('Unable to remove this column.'));
+ }
+
+ $this->response->redirect('?controller=board&action=edit&project_id='.$column['project_id']);
+ }
+
+ /**
+ * Save the board (Ajax request made by the drag and drop)
+ *
+ * @access public
+ */
+ public function save()
+ {
+ $project_id = $this->request->getIntegerParam('project_id');
+ $values = $this->request->getValues();
+
+ if ($project_id > 0 && ! $this->project->isUserAllowed($project_id, $this->acl->getUserId())) {
+ $this->response->text('Not Authorized', 401);
+ }
+
+ if (isset($values['positions'])) {
+ $this->board->saveTasksPosition($values['positions']);
+ }
+
+ $this->response->html(
+ $this->template->load('board_show', array(
+ 'current_project_id' => $project_id,
+ 'board' => $this->board->get($project_id),
+ 'categories' => $this->category->getList($project_id, false),
+ )),
+ 201
+ );
+ }
+
+ /**
+ * Check if the board have been changed
+ *
+ * @access public
+ */
+ public function check()
+ {
+ $project_id = $this->request->getIntegerParam('project_id');
+ $timestamp = $this->request->getIntegerParam('timestamp');
+
+ if ($project_id > 0 && ! $this->project->isUserAllowed($project_id, $this->acl->getUserId())) {
+ $this->response->text('Not Authorized', 401);
+ }
+
+ if ($this->project->isModifiedSince($project_id, $timestamp)) {
+ $this->response->html(
+ $this->template->load('board_show', array(
+ 'current_project_id' => $project_id,
+ 'board' => $this->board->get($project_id),
+ 'categories' => $this->category->getList($project_id, false),
+ ))
+ );
+ }
+ else {
+ $this->response->status(304);
+ }
+ }
+}
diff --git a/app/Controller/Category.php b/app/Controller/Category.php
new file mode 100644
index 00000000..f96c1d4a
--- /dev/null
+++ b/app/Controller/Category.php
@@ -0,0 +1,189 @@
+<?php
+
+namespace Controller;
+
+/**
+ * Categories management
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class Category extends Base
+{
+ /**
+ * Get the current project (common method between actions)
+ *
+ * @access private
+ * @return array
+ */
+ private function getProject()
+ {
+ $project_id = $this->request->getIntegerParam('project_id');
+ $project = $this->project->getById($project_id);
+
+ if (! $project) {
+ $this->session->flashError(t('Project not found.'));
+ $this->response->redirect('?controller=project');
+ }
+
+ return $project;
+ }
+
+ /**
+ * Get the category (common method between actions)
+ *
+ * @access private
+ * @return array
+ */
+ private function getCategory($project_id)
+ {
+ $category = $this->category->getById($this->request->getIntegerParam('category_id'));
+
+ if (! $category) {
+ $this->session->flashError(t('Category not found.'));
+ $this->response->redirect('?controller=category&action=index&project_id='.$project_id);
+ }
+
+ return $category;
+ }
+
+ /**
+ * List of categories for a given project
+ *
+ * @access public
+ */
+ public function index()
+ {
+ $project = $this->getProject();
+
+ $this->response->html($this->template->layout('category_index', array(
+ 'categories' => $this->category->getList($project['id'], false),
+ 'values' => array('project_id' => $project['id']),
+ 'errors' => array(),
+ 'project' => $project,
+ 'menu' => 'projects',
+ 'title' => t('Categories')
+ )));
+ }
+
+ /**
+ * Validate and save a new project
+ *
+ * @access public
+ */
+ public function save()
+ {
+ $project = $this->getProject();
+
+ $values = $this->request->getValues();
+ list($valid, $errors) = $this->category->validateCreation($values);
+
+ if ($valid) {
+
+ if ($this->category->create($values)) {
+ $this->session->flash(t('Your category have been created successfully.'));
+ $this->response->redirect('?controller=category&action=index&project_id='.$project['id']);
+ }
+ else {
+ $this->session->flashError(t('Unable to create your category.'));
+ }
+ }
+
+ $this->response->html($this->template->layout('category_index', array(
+ 'categories' => $this->category->getList($project['id'], false),
+ 'values' => $values,
+ 'errors' => $errors,
+ 'project' => $project,
+ 'menu' => 'projects',
+ 'title' => t('Categories')
+ )));
+ }
+
+ /**
+ * Edit a category (display the form)
+ *
+ * @access public
+ */
+ public function edit()
+ {
+ $project = $this->getProject();
+ $category = $this->getCategory($project['id']);
+
+ $this->response->html($this->template->layout('category_edit', array(
+ 'values' => $category,
+ 'errors' => array(),
+ 'project' => $project,
+ 'menu' => 'projects',
+ 'title' => t('Categories')
+ )));
+ }
+
+ /**
+ * Edit a category (validate the form and update the database)
+ *
+ * @access public
+ */
+ public function update()
+ {
+ $project = $this->getProject();
+
+ $values = $this->request->getValues();
+ list($valid, $errors) = $this->category->validateModification($values);
+
+ if ($valid) {
+
+ if ($this->category->update($values)) {
+ $this->session->flash(t('Your category have been updated successfully.'));
+ $this->response->redirect('?controller=category&action=index&project_id='.$project['id']);
+ }
+ else {
+ $this->session->flashError(t('Unable to update your category.'));
+ }
+ }
+
+ $this->response->html($this->template->layout('category_edit', array(
+ 'values' => $values,
+ 'errors' => $errors,
+ 'project' => $project,
+ 'menu' => 'projects',
+ 'title' => t('Categories')
+ )));
+ }
+
+ /**
+ * Confirmation dialog before removing a category
+ *
+ * @access public
+ */
+ public function confirm()
+ {
+ $project = $this->getProject();
+ $category = $this->getCategory($project['id']);
+
+ $this->response->html($this->template->layout('category_remove', array(
+ 'project' => $project,
+ 'category' => $category,
+ 'menu' => 'projects',
+ 'title' => t('Remove a category')
+ )));
+ }
+
+ /**
+ * Remove a category
+ *
+ * @access public
+ */
+ public function remove()
+ {
+ $project = $this->getProject();
+ $category = $this->getCategory($project['id']);
+
+ if ($this->category->remove($category['id'])) {
+ $this->session->flash(t('Category removed successfully.'));
+ } else {
+ $this->session->flashError(t('Unable to remove this category.'));
+ }
+
+ $this->response->redirect('?controller=category&action=index&project_id='.$project['id']);
+ }
+}
diff --git a/app/Controller/Comment.php b/app/Controller/Comment.php
new file mode 100644
index 00000000..c9f226f7
--- /dev/null
+++ b/app/Controller/Comment.php
@@ -0,0 +1,189 @@
+<?php
+
+namespace Controller;
+
+/**
+ * Comment controller
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class Comment extends Base
+{
+ /**
+ * Forbidden page for comments
+ *
+ * @access public
+ */
+ public function forbidden()
+ {
+ $this->response->html($this->template->layout('comment_forbidden', array(
+ 'menu' => 'tasks',
+ 'title' => t('Access Forbidden')
+ )));
+ }
+
+ /**
+ * Add a comment
+ *
+ * @access public
+ */
+ public function save()
+ {
+ $task = $this->task->getById($this->request->getIntegerParam('task_id'), true);
+ $values = $this->request->getValues();
+
+ if (! $task) $this->notfound();
+ $this->checkProjectPermissions($task['project_id']);
+
+ list($valid, $errors) = $this->comment->validateCreation($values);
+
+ if ($valid) {
+
+ if ($this->comment->create($values)) {
+ $this->session->flash(t('Comment added successfully.'));
+ }
+ else {
+ $this->session->flashError(t('Unable to create your comment.'));
+ }
+
+ $this->response->redirect('?controller=task&action=show&task_id='.$task['id']);
+ }
+
+ $this->showTask(
+ $task,
+ array('values' => $values, 'errors' => $errors)
+ );
+ }
+
+ /**
+ * Edit a comment
+ *
+ * @access public
+ */
+ public function edit()
+ {
+ $task_id = $this->request->getIntegerParam('task_id');
+ $comment_id = $this->request->getIntegerParam('comment_id');
+
+ $task = $this->task->getById($task_id, true);
+ $comment = $this->comment->getById($comment_id);
+
+ if (! $task || ! $comment) $this->notfound();
+ $this->checkProjectPermissions($task['project_id']);
+
+ if ($this->acl->isAdminUser() || $comment['user_id'] == $this->acl->getUserId()) {
+
+ $this->showTask(
+ $task,
+ array(),
+ array(),
+ array('values' => array('id' => $comment['id']), 'errors' => array())
+ );
+ }
+
+ $this->forbidden();
+ }
+
+ /**
+ * Update and validate a comment
+ *
+ * @access public
+ */
+ public function update()
+ {
+ $task_id = $this->request->getIntegerParam('task_id');
+ $comment_id = $this->request->getIntegerParam('comment_id');
+
+ $task = $this->task->getById($task_id, true);
+ $comment = $this->comment->getById($comment_id);
+
+ $values = $this->request->getValues();
+
+ if (! $task || ! $comment) $this->notfound();
+ $this->checkProjectPermissions($task['project_id']);
+
+ if ($this->acl->isAdminUser() || $comment['user_id'] == $this->acl->getUserId()) {
+
+ list($valid, $errors) = $this->comment->validateModification($values);
+
+ if ($valid) {
+
+ if ($this->comment->update($values)) {
+ $this->session->flash(t('Comment updated successfully.'));
+ }
+ else {
+ $this->session->flashError(t('Unable to update your comment.'));
+ }
+
+ $this->response->redirect('?controller=task&action=show&task_id='.$task['id'].'#comment-'.$comment_id);
+ }
+
+ $this->showTask(
+ $task,
+ array(),
+ array(),
+ array('values' => $values, 'errors' => $errors)
+ );
+ }
+
+ $this->forbidden();
+ }
+
+ /**
+ * Confirmation dialog before removing a comment
+ *
+ * @access public
+ */
+ public function confirm()
+ {
+ $project_id = $this->request->getIntegerParam('project_id');
+ $comment_id = $this->request->getIntegerParam('comment_id');
+
+ $this->checkProjectPermissions($project_id);
+
+ $comment = $this->comment->getById($comment_id);
+ if (! $comment) $this->notfound();
+
+ if ($this->acl->isAdminUser() || $comment['user_id'] == $this->acl->getUserId()) {
+
+ $this->response->html($this->template->layout('comment_remove', array(
+ 'comment' => $comment,
+ 'project_id' => $project_id,
+ 'menu' => 'tasks',
+ 'title' => t('Remove a comment')
+ )));
+ }
+
+ $this->forbidden();
+ }
+
+ /**
+ * Remove a comment
+ *
+ * @access public
+ */
+ public function remove()
+ {
+ $project_id = $this->request->getIntegerParam('project_id');
+ $comment_id = $this->request->getIntegerParam('comment_id');
+
+ $this->checkProjectPermissions($project_id);
+
+ $comment = $this->comment->getById($comment_id);
+ if (! $comment) $this->notfound();
+
+ if ($this->acl->isAdminUser() || $comment['user_id'] == $this->acl->getUserId()) {
+
+ if ($this->comment->remove($comment['id'])) {
+ $this->session->flash(t('Comment removed successfully.'));
+ } else {
+ $this->session->flashError(t('Unable to remove this comment.'));
+ }
+
+ $this->response->redirect('?controller=task&action=show&task_id='.$comment['task_id']);
+ }
+
+ $this->forbidden();
+ }
+}
diff --git a/app/Controller/Config.php b/app/Controller/Config.php
new file mode 100644
index 00000000..b4a5b8d3
--- /dev/null
+++ b/app/Controller/Config.php
@@ -0,0 +1,117 @@
+<?php
+
+namespace Controller;
+
+/**
+ * Config controller
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class Config extends Base
+{
+ /**
+ * Display the settings page
+ *
+ * @access public
+ */
+ public function index()
+ {
+ $this->response->html($this->template->layout('config_index', array(
+ 'db_size' => $this->config->getDatabaseSize(),
+ 'user' => $_SESSION['user'],
+ 'projects' => $this->project->getList(),
+ 'languages' => $this->config->getLanguages(),
+ 'values' => $this->config->getAll(),
+ 'errors' => array(),
+ 'menu' => 'config',
+ 'title' => t('Settings'),
+ 'timezones' => $this->config->getTimezones(),
+ 'remember_me_sessions' => $this->rememberMe->getAll($this->acl->getUserId()),
+ 'last_logins' => $this->lastLogin->getAll($this->acl->getUserId()),
+ )));
+ }
+
+ /**
+ * Validate and save settings
+ *
+ * @access public
+ */
+ public function save()
+ {
+ $values = $this->request->getValues();
+ list($valid, $errors) = $this->config->validateModification($values);
+
+ if ($valid) {
+
+ if ($this->config->save($values)) {
+ $this->config->reload();
+ $this->session->flash(t('Settings saved successfully.'));
+ } else {
+ $this->session->flashError(t('Unable to save your settings.'));
+ }
+
+ $this->response->redirect('?controller=config');
+ }
+
+ $this->response->html($this->template->layout('config_index', array(
+ 'db_size' => $this->config->getDatabaseSize(),
+ 'user' => $_SESSION['user'],
+ 'projects' => $this->project->getList(),
+ 'languages' => $this->config->getLanguages(),
+ 'values' => $values,
+ 'errors' => $errors,
+ 'menu' => 'config',
+ 'title' => t('Settings'),
+ 'timezones' => $this->config->getTimezones(),
+ 'remember_me_sessions' => $this->rememberMe->getAll($this->acl->getUserId()),
+ 'last_logins' => $this->lastLogin->getAll($this->acl->getUserId()),
+ )));
+ }
+
+ /**
+ * Download the Sqlite database
+ *
+ * @access public
+ */
+ public function downloadDb()
+ {
+ $this->response->forceDownload('db.sqlite.gz');
+ $this->response->binary($this->config->downloadDatabase());
+ }
+
+ /**
+ * Optimize the Sqlite database
+ *
+ * @access public
+ */
+ public function optimizeDb()
+ {
+ $this->config->optimizeDatabase();
+ $this->session->flash(t('Database optimization done.'));
+ $this->response->redirect('?controller=config');
+ }
+
+ /**
+ * Regenerate all application tokens
+ *
+ * @access public
+ */
+ public function tokens()
+ {
+ $this->config->regenerateTokens();
+ $this->session->flash(t('All tokens have been regenerated.'));
+ $this->response->redirect('?controller=config');
+ }
+
+ /**
+ * Remove a "RememberMe" token
+ *
+ * @access public
+ */
+ public function removeRememberMeToken()
+ {
+ $this->rememberMe->remove($this->request->getIntegerParam('id'));
+ $this->response->redirect('?controller=config&action=index#remember-me');
+ }
+}
diff --git a/app/Controller/Project.php b/app/Controller/Project.php
new file mode 100644
index 00000000..5cb244a2
--- /dev/null
+++ b/app/Controller/Project.php
@@ -0,0 +1,375 @@
+<?php
+
+namespace Controller;
+
+use Model\Task;
+
+/**
+ * Project controller
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class Project extends Base
+{
+ /**
+ * Display access forbidden page
+ *
+ * @access public
+ */
+ public function forbidden()
+ {
+ $this->response->html($this->template->layout('project_forbidden', array(
+ 'menu' => 'projects',
+ 'title' => t('Access Forbidden')
+ )));
+ }
+
+ /**
+ * Task search for a given project
+ *
+ * @access public
+ */
+ public function search()
+ {
+ $project_id = $this->request->getIntegerParam('project_id');
+ $search = $this->request->getStringParam('search');
+
+ $project = $this->project->getById($project_id);
+ $tasks = array();
+ $nb_tasks = 0;
+
+ if (! $project) {
+ $this->session->flashError(t('Project not found.'));
+ $this->response->redirect('?controller=project');
+ }
+
+ $this->checkProjectPermissions($project['id']);
+
+ if ($search !== '') {
+
+ $filters = array(
+ array('column' => 'project_id', 'operator' => 'eq', 'value' => $project_id),
+ 'or' => array(
+ array('column' => 'title', 'operator' => 'like', 'value' => '%'.$search.'%'),
+ //array('column' => 'description', 'operator' => 'like', 'value' => '%'.$search.'%'),
+ )
+ );
+
+ $tasks = $this->task->find($filters);
+ $nb_tasks = count($tasks);
+ }
+
+ $this->response->html($this->template->layout('project_search', array(
+ 'tasks' => $tasks,
+ 'nb_tasks' => $nb_tasks,
+ 'values' => array(
+ 'search' => $search,
+ 'controller' => 'project',
+ 'action' => 'search',
+ 'project_id' => $project['id'],
+ ),
+ 'menu' => 'projects',
+ 'project' => $project,
+ 'columns' => $this->board->getColumnsList($project_id),
+ 'categories' => $this->category->getList($project['id'], false),
+ 'title' => $project['name'].($nb_tasks > 0 ? ' ('.$nb_tasks.')' : '')
+ )));
+ }
+
+ /**
+ * List of completed tasks for a given project
+ *
+ * @access public
+ */
+ public function tasks()
+ {
+ $project_id = $this->request->getIntegerParam('project_id');
+ $project = $this->project->getById($project_id);
+
+ if (! $project) {
+ $this->session->flashError(t('Project not found.'));
+ $this->response->redirect('?controller=project');
+ }
+
+ $this->checkProjectPermissions($project['id']);
+
+ $filters = array(
+ array('column' => 'project_id', 'operator' => 'eq', 'value' => $project_id),
+ array('column' => 'is_active', 'operator' => 'eq', 'value' => Task::STATUS_CLOSED),
+ );
+
+ $tasks = $this->task->find($filters);
+ $nb_tasks = count($tasks);
+
+ $this->response->html($this->template->layout('project_tasks', array(
+ 'menu' => 'projects',
+ 'project' => $project,
+ 'columns' => $this->board->getColumnsList($project_id),
+ 'categories' => $this->category->getList($project['id'], false),
+ 'tasks' => $tasks,
+ 'nb_tasks' => $nb_tasks,
+ 'title' => $project['name'].' ('.$nb_tasks.')'
+ )));
+ }
+
+ /**
+ * List of projects
+ *
+ * @access public
+ */
+ public function index()
+ {
+ $projects = $this->project->getAll(true, $this->acl->isRegularUser());
+ $nb_projects = count($projects);
+
+ $this->response->html($this->template->layout('project_index', array(
+ 'projects' => $projects,
+ 'nb_projects' => $nb_projects,
+ 'menu' => 'projects',
+ 'title' => t('Projects').' ('.$nb_projects.')'
+ )));
+ }
+
+ /**
+ * Display a form to create a new project
+ *
+ * @access public
+ */
+ public function create()
+ {
+ $this->response->html($this->template->layout('project_new', array(
+ 'errors' => array(),
+ 'values' => array(),
+ 'menu' => 'projects',
+ 'title' => t('New project')
+ )));
+ }
+
+ /**
+ * Validate and save a new project
+ *
+ * @access public
+ */
+ public function save()
+ {
+ $values = $this->request->getValues();
+ list($valid, $errors) = $this->project->validateCreation($values);
+
+ if ($valid) {
+
+ if ($this->project->create($values)) {
+ $this->session->flash(t('Your project have been created successfully.'));
+ $this->response->redirect('?controller=project');
+ }
+ else {
+ $this->session->flashError(t('Unable to create your project.'));
+ }
+ }
+
+ $this->response->html($this->template->layout('project_new', array(
+ 'errors' => $errors,
+ 'values' => $values,
+ 'menu' => 'projects',
+ 'title' => t('New Project')
+ )));
+ }
+
+ /**
+ * Display a form to edit a project
+ *
+ * @access public
+ */
+ public function edit()
+ {
+ $project = $this->project->getById($this->request->getIntegerParam('project_id'));
+
+ if (! $project) {
+ $this->session->flashError(t('Project not found.'));
+ $this->response->redirect('?controller=project');
+ }
+
+ $this->response->html($this->template->layout('project_edit', array(
+ 'errors' => array(),
+ 'values' => $project,
+ 'menu' => 'projects',
+ 'title' => t('Edit project')
+ )));
+ }
+
+ /**
+ * Validate and update a project
+ *
+ * @access public
+ */
+ public function update()
+ {
+ $values = $this->request->getValues() + array('is_active' => 0);
+ list($valid, $errors) = $this->project->validateModification($values);
+
+ if ($valid) {
+
+ if ($this->project->update($values)) {
+ $this->session->flash(t('Project updated successfully.'));
+ $this->response->redirect('?controller=project');
+ }
+ else {
+ $this->session->flashError(t('Unable to update this project.'));
+ }
+ }
+
+ $this->response->html($this->template->layout('project_edit', array(
+ 'errors' => $errors,
+ 'values' => $values,
+ 'menu' => 'projects',
+ 'title' => t('Edit Project')
+ )));
+ }
+
+ /**
+ * Confirmation dialog before to remove a project
+ *
+ * @access public
+ */
+ public function confirm()
+ {
+ $project = $this->project->getById($this->request->getIntegerParam('project_id'));
+
+ if (! $project) {
+ $this->session->flashError(t('Project not found.'));
+ $this->response->redirect('?controller=project');
+ }
+
+ $this->response->html($this->template->layout('project_remove', array(
+ 'project' => $project,
+ 'menu' => 'projects',
+ 'title' => t('Remove project')
+ )));
+ }
+
+ /**
+ * Remove a project
+ *
+ * @access public
+ */
+ public function remove()
+ {
+ $project_id = $this->request->getIntegerParam('project_id');
+
+ if ($project_id && $this->project->remove($project_id)) {
+ $this->session->flash(t('Project removed successfully.'));
+ } else {
+ $this->session->flashError(t('Unable to remove this project.'));
+ }
+
+ $this->response->redirect('?controller=project');
+ }
+
+ /**
+ * Enable a project
+ *
+ * @access public
+ */
+ public function enable()
+ {
+ $project_id = $this->request->getIntegerParam('project_id');
+
+ if ($project_id && $this->project->enable($project_id)) {
+ $this->session->flash(t('Project activated successfully.'));
+ } else {
+ $this->session->flashError(t('Unable to activate this project.'));
+ }
+
+ $this->response->redirect('?controller=project');
+ }
+
+ /**
+ * Disable a project
+ *
+ * @access public
+ */
+ public function disable()
+ {
+ $project_id = $this->request->getIntegerParam('project_id');
+
+ if ($project_id && $this->project->disable($project_id)) {
+ $this->session->flash(t('Project disabled successfully.'));
+ } else {
+ $this->session->flashError(t('Unable to disable this project.'));
+ }
+
+ $this->response->redirect('?controller=project');
+ }
+
+ /**
+ * Users list for the selected project
+ *
+ * @access public
+ */
+ public function users()
+ {
+ $project = $this->project->getById($this->request->getIntegerParam('project_id'));
+
+ if (! $project) {
+ $this->session->flashError(t('Project not found.'));
+ $this->response->redirect('?controller=project');
+ }
+
+ $this->response->html($this->template->layout('project_users', array(
+ 'project' => $project,
+ 'users' => $this->project->getAllUsers($project['id']),
+ 'menu' => 'projects',
+ 'title' => t('Edit project access list')
+ )));
+ }
+
+ /**
+ * Allow a specific user for the selected project
+ *
+ * @access public
+ */
+ public function allow()
+ {
+ $values = $this->request->getValues();
+ list($valid,) = $this->project->validateUserAccess($values);
+
+ if ($valid) {
+
+ if ($this->project->allowUser($values['project_id'], $values['user_id'])) {
+ $this->session->flash(t('Project updated successfully.'));
+ }
+ else {
+ $this->session->flashError(t('Unable to update this project.'));
+ }
+ }
+
+ $this->response->redirect('?controller=project&action=users&project_id='.$values['project_id']);
+ }
+
+ /**
+ * Revoke user access
+ *
+ * @access public
+ */
+ public function revoke()
+ {
+ $values = array(
+ 'project_id' => $this->request->getIntegerParam('project_id'),
+ 'user_id' => $this->request->getIntegerParam('user_id'),
+ );
+
+ list($valid,) = $this->project->validateUserAccess($values);
+
+ if ($valid) {
+
+ if ($this->project->revokeUser($values['project_id'], $values['user_id'])) {
+ $this->session->flash(t('Project updated successfully.'));
+ }
+ else {
+ $this->session->flashError(t('Unable to update this project.'));
+ }
+ }
+
+ $this->response->redirect('?controller=project&action=users&project_id='.$values['project_id']);
+ }
+}
diff --git a/app/Controller/Task.php b/app/Controller/Task.php
new file mode 100644
index 00000000..2291ad43
--- /dev/null
+++ b/app/Controller/Task.php
@@ -0,0 +1,397 @@
+<?php
+
+namespace Controller;
+
+use Model\Project;
+
+/**
+ * Task controller
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class Task extends Base
+{
+ /**
+ * Webhook to create a task (useful for external software)
+ *
+ * @access public
+ */
+ public function add()
+ {
+ $token = $this->request->getStringParam('token');
+
+ if ($this->config->get('webhooks_token') !== $token) {
+ $this->response->text('Not Authorized', 401);
+ }
+
+ $defaultProject = $this->project->getFirst();
+
+ $values = array(
+ 'title' => $this->request->getStringParam('title'),
+ 'description' => $this->request->getStringParam('description'),
+ 'color_id' => $this->request->getStringParam('color_id', 'blue'),
+ 'project_id' => $this->request->getIntegerParam('project_id', $defaultProject['id']),
+ 'owner_id' => $this->request->getIntegerParam('owner_id'),
+ 'column_id' => $this->request->getIntegerParam('column_id'),
+ 'category_id' => $this->request->getIntegerParam('category_id'),
+ );
+
+ if ($values['column_id'] == 0) {
+ $values['column_id'] = $this->board->getFirstColumn($values['project_id']);
+ }
+
+ list($valid,) = $this->task->validateCreation($values);
+
+ if ($valid && $this->task->create($values)) {
+ $this->response->text('OK');
+ }
+
+ $this->response->text('FAILED');
+ }
+
+ /**
+ * Show a task
+ *
+ * @access public
+ */
+ public function show()
+ {
+ $task = $this->task->getById($this->request->getIntegerParam('task_id'), true);
+
+ if (! $task) $this->notfound();
+ $this->checkProjectPermissions($task['project_id']);
+
+ $this->showTask($task);
+ }
+
+ /**
+ * Add a description from the show task page
+ *
+ * @access public
+ */
+ public function description()
+ {
+ $task = $this->task->getById($this->request->getIntegerParam('task_id'), true);
+ $values = $this->request->getValues();
+
+ if (! $task) $this->notfound();
+ $this->checkProjectPermissions($task['project_id']);
+
+ list($valid, $errors) = $this->task->validateDescriptionCreation($values);
+
+ if ($valid) {
+
+ if ($this->task->update($values)) {
+ $this->session->flash(t('Task updated successfully.'));
+ }
+ else {
+ $this->session->flashError(t('Unable to update your task.'));
+ }
+
+ $this->response->redirect('?controller=task&action=show&task_id='.$task['id']);
+ }
+
+ $this->showTask(
+ $task,
+ array(),
+ array('values' => $values, 'errors' => $errors)
+ );
+ }
+
+ /**
+ * Display a form to create a new task
+ *
+ * @access public
+ */
+ public function create()
+ {
+ $project_id = $this->request->getIntegerParam('project_id');
+ $this->checkProjectPermissions($project_id);
+
+ $this->response->html($this->template->layout('task_new', array(
+ 'errors' => array(),
+ 'values' => array(
+ 'project_id' => $project_id,
+ 'column_id' => $this->request->getIntegerParam('column_id'),
+ 'color_id' => $this->request->getStringParam('color_id'),
+ 'owner_id' => $this->request->getIntegerParam('owner_id'),
+ 'another_task' => $this->request->getIntegerParam('another_task'),
+ ),
+ 'projects_list' => $this->project->getListByStatus(\Model\Project::ACTIVE),
+ 'columns_list' => $this->board->getColumnsList($project_id),
+ 'users_list' => $this->project->getUsersList($project_id),
+ 'colors_list' => $this->task->getColors(),
+ 'categories_list' => $this->category->getList($project_id),
+ 'menu' => 'tasks',
+ 'title' => t('New task')
+ )));
+ }
+
+ /**
+ * Validate and save a new task
+ *
+ * @access public
+ */
+ public function save()
+ {
+ $values = $this->request->getValues();
+ $this->checkProjectPermissions($values['project_id']);
+
+ list($valid, $errors) = $this->task->validateCreation($values);
+
+ if ($valid) {
+
+ if ($this->task->create($values)) {
+ $this->session->flash(t('Task created successfully.'));
+
+ if (isset($values['another_task']) && $values['another_task'] == 1) {
+ unset($values['title']);
+ unset($values['description']);
+ $this->response->redirect('?controller=task&action=create&'.http_build_query($values));
+ }
+ else {
+ $this->response->redirect('?controller=board&action=show&project_id='.$values['project_id']);
+ }
+ }
+ else {
+ $this->session->flashError(t('Unable to create your task.'));
+ }
+ }
+
+ $this->response->html($this->template->layout('task_new', array(
+ 'errors' => $errors,
+ 'values' => $values,
+ 'projects_list' => $this->project->getListByStatus(Project::ACTIVE),
+ 'columns_list' => $this->board->getColumnsList($values['project_id']),
+ 'users_list' => $this->project->getUsersList($values['project_id']),
+ 'colors_list' => $this->task->getColors(),
+ 'categories_list' => $this->category->getList($values['project_id']),
+ 'menu' => 'tasks',
+ 'title' => t('New task')
+ )));
+ }
+
+ /**
+ * Display a form to edit a task
+ *
+ * @access public
+ */
+ public function edit()
+ {
+ $task = $this->task->getById($this->request->getIntegerParam('task_id'));
+
+ if (! $task) $this->notfound();
+ $this->checkProjectPermissions($task['project_id']);
+
+ if (! empty($task['date_due'])) {
+ $task['date_due'] = date(t('m/d/Y'), $task['date_due']);
+ }
+ else {
+ $task['date_due'] = '';
+ }
+
+ $task['score'] = $task['score'] ?: '';
+
+ $this->response->html($this->template->layout('task_edit', array(
+ 'errors' => array(),
+ 'values' => $task,
+ 'columns_list' => $this->board->getColumnsList($task['project_id']),
+ 'users_list' => $this->project->getUsersList($task['project_id']),
+ 'colors_list' => $this->task->getColors(),
+ 'categories_list' => $this->category->getList($task['project_id']),
+ 'menu' => 'tasks',
+ 'title' => t('Edit a task')
+ )));
+ }
+
+ /**
+ * Validate and update a task
+ *
+ * @access public
+ */
+ public function update()
+ {
+ $values = $this->request->getValues();
+ $this->checkProjectPermissions($values['project_id']);
+
+ list($valid, $errors) = $this->task->validateModification($values);
+
+ if ($valid) {
+
+ if ($this->task->update($values)) {
+ $this->session->flash(t('Task updated successfully.'));
+ $this->response->redirect('?controller=task&action=show&task_id='.$values['id']);
+ }
+ else {
+ $this->session->flashError(t('Unable to update your task.'));
+ }
+ }
+
+ $this->response->html($this->template->layout('task_edit', array(
+ 'errors' => $errors,
+ 'values' => $values,
+ 'columns_list' => $this->board->getColumnsList($values['project_id']),
+ 'users_list' => $this->project->getUsersList($values['project_id']),
+ 'colors_list' => $this->task->getColors(),
+ 'categories_list' => $this->category->getList($values['project_id']),
+ 'menu' => 'tasks',
+ 'title' => t('Edit a task')
+ )));
+ }
+
+ /**
+ * Hide a task
+ *
+ * @access public
+ */
+ public function close()
+ {
+ $task = $this->task->getById($this->request->getIntegerParam('task_id'));
+
+ if (! $task) $this->notfound();
+ $this->checkProjectPermissions($task['project_id']);
+
+ if ($this->task->close($task['id'])) {
+ $this->session->flash(t('Task closed successfully.'));
+ } else {
+ $this->session->flashError(t('Unable to close this task.'));
+ }
+
+ $this->response->redirect('?controller=task&action=show&task_id='.$task['id']);
+ }
+
+ /**
+ * Confirmation dialog before to close a task
+ *
+ * @access public
+ */
+ public function confirmClose()
+ {
+ $task = $this->task->getById($this->request->getIntegerParam('task_id'), true);
+
+ if (! $task) $this->notfound();
+ $this->checkProjectPermissions($task['project_id']);
+
+ $this->response->html($this->taskLayout('task_close', array(
+ 'task' => $task,
+ 'menu' => 'tasks',
+ 'title' => t('Close a task')
+ )));
+ }
+
+ /**
+ * Open a task
+ *
+ * @access public
+ */
+ public function open()
+ {
+ $task = $this->task->getById($this->request->getIntegerParam('task_id'));
+
+ if (! $task) $this->notfound();
+ $this->checkProjectPermissions($task['project_id']);
+
+ if ($this->task->open($task['id'])) {
+ $this->session->flash(t('Task opened successfully.'));
+ } else {
+ $this->session->flashError(t('Unable to open this task.'));
+ }
+
+ $this->response->redirect('?controller=task&action=show&task_id='.$task['id']);
+ }
+
+ /**
+ * Confirmation dialog before to open a task
+ *
+ * @access public
+ */
+ public function confirmOpen()
+ {
+ $task = $this->task->getById($this->request->getIntegerParam('task_id'), true);
+
+ if (! $task) $this->notfound();
+ $this->checkProjectPermissions($task['project_id']);
+
+ $this->response->html($this->taskLayout('task_open', array(
+ 'task' => $task,
+ 'menu' => 'tasks',
+ 'title' => t('Open a task')
+ )));
+ }
+
+ /**
+ * Remove a task
+ *
+ * @access public
+ */
+ public function remove()
+ {
+ $task = $this->task->getById($this->request->getIntegerParam('task_id'));
+
+ if (! $task) $this->notfound();
+ $this->checkProjectPermissions($task['project_id']);
+
+ if ($this->task->remove($task['id'])) {
+ $this->session->flash(t('Task removed successfully.'));
+ } else {
+ $this->session->flashError(t('Unable to remove this task.'));
+ }
+
+ $this->response->redirect('?controller=board&action=show&project_id='.$task['project_id']);
+ }
+
+ /**
+ * Confirmation dialog before removing a task
+ *
+ * @access public
+ */
+ public function confirmRemove()
+ {
+ $task = $this->task->getById($this->request->getIntegerParam('task_id'), true);
+
+ if (! $task) $this->notfound();
+ $this->checkProjectPermissions($task['project_id']);
+
+ $this->response->html($this->taskLayout('task_remove', array(
+ 'task' => $task,
+ 'menu' => 'tasks',
+ 'title' => t('Remove a task')
+ )));
+ }
+
+ /**
+ * Duplicate a task (fill the form for a new task)
+ *
+ * @access public
+ */
+ public function duplicate()
+ {
+ $task = $this->task->getById($this->request->getIntegerParam('task_id'));
+
+ if (! $task) $this->notfound();
+ $this->checkProjectPermissions($task['project_id']);
+
+ if (! empty($task['date_due'])) {
+ $task['date_due'] = date(t('m/d/Y'), $task['date_due']);
+ }
+ else {
+ $task['date_due'] = '';
+ }
+
+ $task['score'] = $task['score'] ?: '';
+
+ $this->response->html($this->template->layout('task_new', array(
+ 'errors' => array(),
+ 'values' => $task,
+ 'projects_list' => $this->project->getListByStatus(Project::ACTIVE),
+ 'columns_list' => $this->board->getColumnsList($task['project_id']),
+ 'users_list' => $this->project->getUsersList($task['project_id']),
+ 'colors_list' => $this->task->getColors(),
+ 'categories_list' => $this->category->getList($task['project_id']),
+ 'duplicate' => true,
+ 'menu' => 'tasks',
+ 'title' => t('New task')
+ )));
+ }
+}
diff --git a/app/Controller/User.php b/app/Controller/User.php
new file mode 100644
index 00000000..e3fd8253
--- /dev/null
+++ b/app/Controller/User.php
@@ -0,0 +1,310 @@
+<?php
+
+namespace Controller;
+
+/**
+ * User controller
+ *
+ * @package controller
+ * @author Frederic Guillot
+ */
+class User extends Base
+{
+ /**
+ * Display access forbidden page
+ *
+ * @access public
+ */
+ public function forbidden()
+ {
+ $this->response->html($this->template->layout('user_forbidden', array(
+ 'menu' => 'users',
+ 'title' => t('Access Forbidden')
+ )));
+ }
+
+ /**
+ * Logout and destroy session
+ *
+ * @access public
+ */
+ public function logout()
+ {
+ $this->rememberMe->destroy($this->acl->getUserId());
+ $this->session->close();
+ $this->response->redirect('?controller=user&action=login');
+ }
+
+ /**
+ * Display the form login
+ *
+ * @access public
+ */
+ public function login()
+ {
+ if (isset($_SESSION['user'])) $this->response->redirect('?controller=app');
+
+ $this->response->html($this->template->layout('user_login', array(
+ 'errors' => array(),
+ 'values' => array(),
+ 'no_layout' => true,
+ 'title' => t('Login')
+ )));
+ }
+
+ /**
+ * Check credentials
+ *
+ * @access public
+ */
+ public function check()
+ {
+ $values = $this->request->getValues();
+ list($valid, $errors) = $this->user->validateLogin($values);
+
+ if ($valid) {
+ $this->response->redirect('?controller=app');
+ }
+
+ $this->response->html($this->template->layout('user_login', array(
+ 'errors' => $errors,
+ 'values' => $values,
+ 'no_layout' => true,
+ 'title' => t('Login')
+ )));
+ }
+
+ /**
+ * List all users
+ *
+ * @access public
+ */
+ public function index()
+ {
+ $users = $this->user->getAll();
+ $nb_users = count($users);
+
+ $this->response->html(
+ $this->template->layout('user_index', array(
+ 'projects' => $this->project->getList(),
+ 'users' => $users,
+ 'nb_users' => $nb_users,
+ 'menu' => 'users',
+ 'title' => t('Users').' ('.$nb_users.')'
+ )));
+ }
+
+ /**
+ * Display a form to create a new user
+ *
+ * @access public
+ */
+ public function create()
+ {
+ $this->response->html($this->template->layout('user_new', array(
+ 'projects' => $this->project->getList(),
+ 'errors' => array(),
+ 'values' => array(),
+ 'menu' => 'users',
+ 'title' => t('New user')
+ )));
+ }
+
+ /**
+ * Validate and save a new user
+ *
+ * @access public
+ */
+ public function save()
+ {
+ $values = $this->request->getValues();
+ list($valid, $errors) = $this->user->validateCreation($values);
+
+ if ($valid) {
+
+ if ($this->user->create($values)) {
+ $this->session->flash(t('User created successfully.'));
+ $this->response->redirect('?controller=user');
+ }
+ else {
+ $this->session->flashError(t('Unable to create your user.'));
+ }
+ }
+
+ $this->response->html($this->template->layout('user_new', array(
+ 'projects' => $this->project->getList(),
+ 'errors' => $errors,
+ 'values' => $values,
+ 'menu' => 'users',
+ 'title' => t('New user')
+ )));
+ }
+
+ /**
+ * Display a form to edit a user
+ *
+ * @access public
+ */
+ public function edit()
+ {
+ $user = $this->user->getById($this->request->getIntegerParam('user_id'));
+
+ if (! $user) $this->notfound();
+
+ if ($this->acl->isRegularUser() && $this->acl->getUserId() != $user['id']) {
+ $this->forbidden();
+ }
+
+ unset($user['password']);
+
+ $this->response->html($this->template->layout('user_edit', array(
+ 'projects' => $this->project->filterListByAccess($this->project->getList(), $user['id']),
+ 'errors' => array(),
+ 'values' => $user,
+ 'menu' => 'users',
+ 'title' => t('Edit user')
+ )));
+ }
+
+ /**
+ * Validate and update a user
+ *
+ * @access public
+ */
+ public function update()
+ {
+ $values = $this->request->getValues();
+
+ if ($this->acl->isAdminUser()) {
+ $values += array('is_admin' => 0);
+ }
+ else {
+
+ if ($this->acl->getUserId() != $values['id']) {
+ $this->forbidden();
+ }
+
+ if (isset($values['is_admin'])) {
+ unset($values['is_admin']); // Regular users can't be admin
+ }
+ }
+
+ list($valid, $errors) = $this->user->validateModification($values);
+
+ if ($valid) {
+
+ if ($this->user->update($values)) {
+ $this->session->flash(t('User updated successfully.'));
+ $this->response->redirect('?controller=user');
+ }
+ else {
+ $this->session->flashError(t('Unable to update your user.'));
+ }
+ }
+
+ $this->response->html($this->template->layout('user_edit', array(
+ 'projects' => $this->project->filterListByAccess($this->project->getList(), $values['id']),
+ 'errors' => $errors,
+ 'values' => $values,
+ 'menu' => 'users',
+ 'title' => t('Edit user')
+ )));
+ }
+
+ /**
+ * Confirmation dialog before to remove a user
+ *
+ * @access public
+ */
+ public function confirm()
+ {
+ $user = $this->user->getById($this->request->getIntegerParam('user_id'));
+
+ if (! $user) $this->notfound();
+
+ $this->response->html($this->template->layout('user_remove', array(
+ 'user' => $user,
+ 'menu' => 'users',
+ 'title' => t('Remove user')
+ )));
+ }
+
+ /**
+ * Remove a user
+ *
+ * @access public
+ */
+ public function remove()
+ {
+ $user_id = $this->request->getIntegerParam('user_id');
+
+ if ($user_id && $this->user->remove($user_id)) {
+ $this->session->flash(t('User removed successfully.'));
+ } else {
+ $this->session->flashError(t('Unable to remove this user.'));
+ }
+
+ $this->response->redirect('?controller=user');
+ }
+
+ /**
+ * Google authentication
+ *
+ * @access public
+ */
+ public function google()
+ {
+ $code = $this->request->getStringParam('code');
+
+ if ($code) {
+
+ $profile = $this->google->getGoogleProfile($code);
+
+ if (is_array($profile)) {
+
+ // If the user is already logged, link the account otherwise authenticate
+ if ($this->acl->isLogged()) {
+
+ if ($this->google->updateUser($this->acl->getUserId(), $profile)) {
+ $this->session->flash(t('Your Google Account is linked to your profile successfully.'));
+ }
+ else {
+ $this->session->flashError(t('Unable to link your Google Account.'));
+ }
+
+ $this->response->redirect('?controller=user');
+ }
+ else if ($this->google->authenticate($profile['id'])) {
+ $this->response->redirect('?controller=app');
+ }
+ else {
+ $this->response->html($this->template->layout('user_login', array(
+ 'errors' => array('login' => t('Google authentication failed')),
+ 'values' => array(),
+ 'no_layout' => true,
+ 'title' => t('Login')
+ )));
+ }
+ }
+ }
+
+ $this->response->redirect($this->google->getAuthorizationUrl());
+ }
+
+ /**
+ * Unlink a Google account
+ *
+ * @access public
+ */
+ public function unlinkGoogle()
+ {
+ if ($this->google->unlink($this->acl->getUserId())) {
+ $this->session->flash(t('Your Google Account is not linked anymore to your profile.'));
+ }
+ else {
+ $this->session->flashError(t('Unable to unlink your Google Account.'));
+ }
+
+ $this->response->redirect('?controller=user');
+ }
+}
diff --git a/app/Core/Event.php b/app/Core/Event.php
new file mode 100644
index 00000000..2c029b49
--- /dev/null
+++ b/app/Core/Event.php
@@ -0,0 +1,135 @@
+<?php
+
+namespace Core;
+
+/**
+ * Event dispatcher class
+ *
+ * @package core
+ * @author Frederic Guillot
+ */
+class Event
+{
+ /**
+ * Contains all listeners
+ *
+ * @access private
+ * @var array
+ */
+ private $listeners = array();
+
+ /**
+ * The last listener executed
+ *
+ * @access private
+ * @var string
+ */
+ private $lastListener = '';
+
+ /**
+ * The last triggered event
+ *
+ * @access private
+ * @var string
+ */
+ private $lastEvent = '';
+
+ /**
+ * Triggered events list
+ *
+ * @access private
+ * @var array
+ */
+ private $events = array();
+
+ /**
+ * Attach a listener object to an event
+ *
+ * @access public
+ * @param string $eventName Event name
+ * @param Listener $listener Object that implements the Listener interface
+ */
+ public function attach($eventName, Listener $listener)
+ {
+ if (! isset($this->listeners[$eventName])) {
+ $this->listeners[$eventName] = array();
+ }
+
+ $this->listeners[$eventName][] = $listener;
+ }
+
+ /**
+ * Trigger an event
+ *
+ * @access public
+ * @param string $eventName Event name
+ * @param array $data Event data
+ */
+ public function trigger($eventName, array $data)
+ {
+ $this->lastEvent = $eventName;
+ $this->events[] = $eventName;
+
+ if (isset($this->listeners[$eventName])) {
+ foreach ($this->listeners[$eventName] as $listener) {
+ if ($listener->execute($data)) {
+ $this->lastListener = get_class($listener);
+ }
+ }
+ }
+ }
+
+ /**
+ * Get the last listener executed
+ *
+ * @access public
+ * @return string Event name
+ */
+ public function getLastListenerExecuted()
+ {
+ return $this->lastListener;
+ }
+
+ /**
+ * Get the last fired event
+ *
+ * @access public
+ * @return string Event name
+ */
+ public function getLastTriggeredEvent()
+ {
+ return $this->lastEvent;
+ }
+
+ /**
+ * Get a list of triggered events
+ *
+ * @access public
+ * @return array
+ */
+ public function getTriggeredEvents()
+ {
+ return $this->events;
+ }
+
+ /**
+ * Check if a listener bind to an event
+ *
+ * @access public
+ * @param string $eventName Event name
+ * @param mixed $instance Instance name or object itself
+ * @return bool Yes or no
+ */
+ public function hasListener($eventName, $instance)
+ {
+ if (isset($this->listeners[$eventName])) {
+ foreach ($this->listeners[$eventName] as $listener) {
+ if ($listener instanceof $instance) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/app/Core/Listener.php b/app/Core/Listener.php
new file mode 100644
index 00000000..b8bdd680
--- /dev/null
+++ b/app/Core/Listener.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace Core;
+
+/**
+ * Event listener interface
+ *
+ * @package core
+ * @author Frederic Guillot
+ */
+interface Listener {
+
+ /**
+ * @return boolean
+ */
+ public function execute(array $data);
+}
diff --git a/app/Core/Loader.php b/app/Core/Loader.php
new file mode 100644
index 00000000..7c437654
--- /dev/null
+++ b/app/Core/Loader.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Core;
+
+/**
+ * Loader class
+ *
+ * @package core
+ * @author Frederic Guillot
+ */
+class Loader
+{
+ /**
+ * Load the missing class
+ *
+ * @access public
+ * @param string $class Class name
+ */
+ public function load($class)
+ {
+ $filename = __DIR__.DIRECTORY_SEPARATOR.'..'.DIRECTORY_SEPARATOR.str_replace('\\', DIRECTORY_SEPARATOR, $class).'.php';
+
+ if (file_exists($filename)) {
+ require $filename;
+ }
+ }
+
+ /**
+ * Register the autoloader
+ *
+ * @access public
+ */
+ public function execute()
+ {
+ spl_autoload_register(array($this, 'load'));
+ }
+}
diff --git a/app/Core/Registry.php b/app/Core/Registry.php
new file mode 100644
index 00000000..f11d427c
--- /dev/null
+++ b/app/Core/Registry.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace Core;
+
+/**
+ * The registry class is a dependency injection container
+ *
+ * @package core
+ * @author Frederic Guillot
+ */
+class Registry
+{
+ /**
+ * Contains all dependencies
+ *
+ * @access private
+ * @var array
+ */
+ private $container = array();
+
+ /**
+ * Contains all instances
+ *
+ * @access private
+ * @var array
+ */
+ private $instances = array();
+
+ /**
+ * Set a dependency
+ *
+ * @access public
+ * @param string $name Unique identifier for the service/parameter
+ * @param mixed $value The value of the parameter or a closure to define an object
+ */
+ public function __set($name, $value)
+ {
+ $this->container[$name] = $value;
+ }
+
+ /**
+ * Get a dependency
+ *
+ * @access public
+ * @param string $name Unique identifier for the service/parameter
+ * @return mixed The value of the parameter or an object
+ * @throws RuntimeException If the identifier is not found
+ */
+ public function __get($name)
+ {
+ if (isset($this->container[$name])) {
+
+ if (is_callable($this->container[$name])) {
+ return $this->container[$name]();
+ }
+ else {
+ return $this->container[$name];
+ }
+ }
+
+ throw new \RuntimeException('Identifier not found in the registry: '.$name);
+ }
+
+ /**
+ * Return a shared instance of a dependency
+ *
+ * @access public
+ * @param string $name Unique identifier for the service/parameter
+ * @return mixed Same object instance of the dependency
+ */
+ public function shared($name)
+ {
+ if (! isset($this->instances[$name])) {
+ $this->instances[$name] = $this->$name;
+ }
+
+ return $this->instances[$name];
+ }
+}
diff --git a/app/Core/Request.php b/app/Core/Request.php
new file mode 100644
index 00000000..df8ea41a
--- /dev/null
+++ b/app/Core/Request.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace Core;
+
+class Request
+{
+ public function getStringParam($name, $default_value = '')
+ {
+ return isset($_GET[$name]) ? $_GET[$name] : $default_value;
+ }
+
+ public function getIntegerParam($name, $default_value = 0)
+ {
+ return isset($_GET[$name]) && ctype_digit($_GET[$name]) ? (int) $_GET[$name] : $default_value;
+ }
+
+ public function getValue($name)
+ {
+ $values = $this->getValues();
+ return isset($values[$name]) ? $values[$name] : null;
+ }
+
+ public function getValues()
+ {
+ if (! empty($_POST)) return $_POST;
+
+ $result = json_decode($this->getBody(), true);
+ if ($result) return $result;
+
+ return array();
+ }
+
+ public function getBody()
+ {
+ return file_get_contents('php://input');
+ }
+
+ public function getFileContent($name)
+ {
+ if (isset($_FILES[$name])) {
+ return file_get_contents($_FILES[$name]['tmp_name']);
+ }
+
+ return '';
+ }
+
+ public function isPost()
+ {
+ return isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] === 'POST';
+ }
+
+ public function isAjax()
+ {
+ return isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] === 'XMLHttpRequest';
+ }
+}
diff --git a/app/Core/Response.php b/app/Core/Response.php
new file mode 100644
index 00000000..a5f0e4dc
--- /dev/null
+++ b/app/Core/Response.php
@@ -0,0 +1,138 @@
+<?php
+
+namespace Core;
+
+class Response
+{
+ public function forceDownload($filename)
+ {
+ header('Content-Disposition: attachment; filename="'.$filename.'"');
+ }
+
+ /**
+ * @param integer $status_code
+ */
+ public function status($status_code)
+ {
+ header('Status: '.$status_code);
+ header($_SERVER['SERVER_PROTOCOL'].' '.$status_code);
+ }
+
+ public function redirect($url)
+ {
+ header('Location: '.$url);
+ exit;
+ }
+
+ public function json(array $data, $status_code = 200)
+ {
+ $this->status($status_code);
+
+ header('Content-Type: application/json');
+ echo json_encode($data);
+
+ exit;
+ }
+
+ public function text($data, $status_code = 200)
+ {
+ $this->status($status_code);
+
+ header('Content-Type: text/plain; charset=utf-8');
+ echo $data;
+
+ exit;
+ }
+
+ public function html($data, $status_code = 200)
+ {
+ $this->status($status_code);
+
+ header('Content-Type: text/html; charset=utf-8');
+ echo $data;
+
+ exit;
+ }
+
+ public function xml($data, $status_code = 200)
+ {
+ $this->status($status_code);
+
+ header('Content-Type: text/xml; charset=utf-8');
+ echo $data;
+
+ exit;
+ }
+
+ public function js($data, $status_code = 200)
+ {
+ $this->status($status_code);
+
+ header('Content-Type: text/javascript; charset=utf-8');
+ echo $data;
+
+ exit;
+ }
+
+ public function binary($data, $status_code = 200)
+ {
+ $this->status($status_code);
+
+ header('Content-Transfer-Encoding: binary');
+ header('Content-Type: application/octet-stream');
+ echo $data;
+
+ exit;
+ }
+
+ public function csp(array $policies = array())
+ {
+ $policies['default-src'] = "'self'";
+ $values = '';
+
+ foreach ($policies as $policy => $hosts) {
+
+ if (is_array($hosts)) {
+
+ $acl = '';
+
+ foreach ($hosts as &$host) {
+
+ if ($host === '*' || $host === 'self' || strpos($host, 'http') === 0) {
+ $acl .= $host.' ';
+ }
+ }
+ }
+ else {
+
+ $acl = $hosts;
+ }
+
+ $values .= $policy.' '.trim($acl).'; ';
+ }
+
+ header('Content-Security-Policy: '.$values);
+ }
+
+ public function nosniff()
+ {
+ header('X-Content-Type-Options: nosniff');
+ }
+
+ public function xss()
+ {
+ header('X-XSS-Protection: 1; mode=block');
+ }
+
+ public function hsts()
+ {
+ if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off') {
+ header('Strict-Transport-Security: max-age=31536000');
+ }
+ }
+
+ public function xframe($mode = 'DENY', array $urls = array())
+ {
+ header('X-Frame-Options: '.$mode.' '.implode(' ', $urls));
+ }
+}
diff --git a/app/Core/Router.php b/app/Core/Router.php
new file mode 100644
index 00000000..3a5df715
--- /dev/null
+++ b/app/Core/Router.php
@@ -0,0 +1,111 @@
+<?php
+
+namespace Core;
+
+/**
+ * Router class
+ *
+ * @package core
+ * @author Frederic Guillot
+ */
+class Router
+{
+ /**
+ * Controller name
+ *
+ * @access private
+ * @var string
+ */
+ private $controller = '';
+
+ /**
+ * Action name
+ *
+ * @access private
+ * @var string
+ */
+ private $action = '';
+
+ /**
+ * Registry instance
+ *
+ * @access private
+ * @var Core\Registry
+ */
+ private $registry;
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param Core\Registry $registry Registry instance
+ * @param string $controller Controller name
+ * @param string $action Action name
+ */
+ public function __construct(Registry $registry, $controller = '', $action = '')
+ {
+ $this->registry = $registry;
+ $this->controller = empty($_GET['controller']) ? $controller : $_GET['controller'];
+ $this->action = empty($_GET['action']) ? $controller : $_GET['action'];
+ }
+
+ /**
+ * Check controller and action parameter
+ *
+ * @access public
+ * @param string $value Controller or action name
+ * @param string $default_value Default value if validation fail
+ */
+ public function sanitize($value, $default_value)
+ {
+ return ! ctype_alpha($value) || empty($value) ? $default_value : strtolower($value);
+ }
+
+ /**
+ * Load a controller and execute the action
+ *
+ * @access public
+ * @param string $filename Controller filename
+ * @param string $class Class name
+ * @param string $method Method name
+ */
+ public function load($filename, $class, $method)
+ {
+ if (file_exists($filename)) {
+
+ require $filename;
+
+ if (! method_exists($class, $method)) {
+ return false;
+ }
+
+ $instance = new $class($this->registry);
+ $instance->request = new Request;
+ $instance->response = new Response;
+ $instance->session = new Session;
+ $instance->template = new Template;
+ $instance->beforeAction($this->controller, $this->action);
+ $instance->$method();
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Find a route
+ *
+ * @access public
+ */
+ public function execute()
+ {
+ $this->controller = $this->sanitize($this->controller, 'app');
+ $this->action = $this->sanitize($this->action, 'index');
+ $filename = __DIR__.'/../Controller/'.ucfirst($this->controller).'.php';
+
+ if (! $this->load($filename, '\Controller\\'.$this->controller, $this->action)) {
+ die('Page not found!');
+ }
+ }
+}
diff --git a/app/Core/Session.php b/app/Core/Session.php
new file mode 100644
index 00000000..0c3ec2d9
--- /dev/null
+++ b/app/Core/Session.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace Core;
+
+class Session
+{
+ const SESSION_LIFETIME = 86400; // 1 day
+
+ public function open($base_path = '/', $save_path = '')
+ {
+ if ($save_path !== '') session_save_path($save_path);
+
+ // HttpOnly and secure flags for session cookie
+ session_set_cookie_params(
+ self::SESSION_LIFETIME,
+ $base_path ?: '/',
+ null,
+ isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on',
+ true
+ );
+
+ // Avoid session id in the URL
+ ini_set('session.use_only_cookies', '1');
+
+ // Ensure session ID integrity
+ ini_set('session.entropy_file', '/dev/urandom');
+ ini_set('session.entropy_length', '32');
+ ini_set('session.hash_bits_per_character', 6);
+
+ // Custom session name
+ session_name('__S');
+
+ session_start();
+
+ // Regenerate the session id to avoid session fixation issue
+ if (empty($_SESSION['__validated'])) {
+ session_regenerate_id(true);
+ $_SESSION['__validated'] = 1;
+ }
+ }
+
+ public function close()
+ {
+ session_destroy();
+ }
+
+ public function flash($message)
+ {
+ $_SESSION['flash_message'] = $message;
+ }
+
+ public function flashError($message)
+ {
+ $_SESSION['flash_error_message'] = $message;
+ }
+}
diff --git a/app/Core/Template.php b/app/Core/Template.php
new file mode 100644
index 00000000..8740a685
--- /dev/null
+++ b/app/Core/Template.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace Core;
+
+/**
+ * Template class
+ *
+ * @package core
+ * @author Frederic Guillot
+ */
+class Template
+{
+ /**
+ * Template path
+ *
+ * @var string
+ */
+ const PATH = 'app/Templates/';
+
+ /**
+ * Load a template
+ *
+ * Example:
+ *
+ * $template->load('template_name', ['bla' => 'value']);
+ *
+ * @access public
+ * @return string
+ */
+ public function load()
+ {
+ if (func_num_args() < 1 || func_num_args() > 2) {
+ die('Invalid template arguments');
+ }
+
+ if (! file_exists(self::PATH.func_get_arg(0).'.php')) {
+ die('Unable to load the template: "'.func_get_arg(0).'"');
+ }
+
+ if (func_num_args() === 2) {
+
+ if (! is_array(func_get_arg(1))) {
+ die('Template variables must be an array');
+ }
+
+ extract(func_get_arg(1));
+ }
+
+ ob_start();
+
+ include self::PATH.func_get_arg(0).'.php';
+
+ return ob_get_clean();
+ }
+
+ /**
+ * Render a page layout
+ *
+ * @access public
+ * @param string $template_name Template name
+ * @param array $template_args Key/value map
+ * @param string $layout_name Layout name
+ * @return string
+ */
+ public function layout($template_name, array $template_args = array(), $layout_name = 'layout')
+ {
+ return $this->load(
+ $layout_name,
+ $template_args + array('content_for_layout' => $this->load($template_name, $template_args))
+ );
+ }
+}
diff --git a/app/Core/Translator.php b/app/Core/Translator.php
new file mode 100644
index 00000000..be0be66a
--- /dev/null
+++ b/app/Core/Translator.php
@@ -0,0 +1,155 @@
+<?php
+
+namespace Core;
+
+/**
+ * Translator class
+ *
+ * @package core
+ * @author Frederic Guillot
+ */
+class Translator
+{
+ /**
+ * Locales path
+ *
+ * @var string
+ */
+ const PATH = 'app/Locales/';
+
+ /**
+ * Locales
+ *
+ * @static
+ * @access private
+ * @var array
+ */
+ private static $locales = array();
+
+ /**
+ * Get a translation
+ *
+ * $translator->translate('I have %d kids', 5);
+ *
+ * @access public
+ * @return string
+ */
+ public function translate($identifier)
+ {
+ $args = func_get_args();
+
+ array_shift($args);
+ array_unshift($args, $this->get($identifier, $identifier));
+
+ foreach ($args as &$arg) {
+ $arg = htmlspecialchars($arg, ENT_QUOTES, 'UTF-8', false);
+ }
+
+ return call_user_func_array(
+ 'sprintf',
+ $args
+ );
+ }
+
+ /**
+ * Get a formatted number
+ *
+ * $translator->number(1234.56);
+ *
+ * @access public
+ * @param float $number Number to format
+ * @return string
+ */
+ public function number($number)
+ {
+ return number_format(
+ $number,
+ $this->get('number.decimals', 2),
+ $this->get('number.decimals_separator', '.'),
+ $this->get('number.thousands_separator', ',')
+ );
+ }
+
+ /**
+ * Get a formatted currency number
+ *
+ * $translator->currency(1234.56);
+ *
+ * @access public
+ * @param float $amount Number to format
+ * @return string
+ */
+ public function currency($amount)
+ {
+ $position = $this->get('currency.position', 'before');
+ $symbol = $this->get('currency.symbol', '$');
+ $str = '';
+
+ if ($position === 'before') {
+ $str .= $symbol;
+ }
+
+ $str .= $this->number($amount);
+
+ if ($position === 'after') {
+ $str .= ' '.$symbol;
+ }
+
+ return $str;
+ }
+
+ /**
+ * Get a formatted datetime
+ *
+ * $translator->datetime('%Y-%m-%d', time());
+ *
+ * @access public
+ * @param string $format Format defined by the strftime function
+ * @param integer $timestamp Unix timestamp
+ * @return string
+ */
+ public function datetime($format, $timestamp)
+ {
+ if (! $timestamp) {
+ return '';
+ }
+
+ return strftime($this->get($format, $format), (int) $timestamp);
+ }
+
+ /**
+ * Get an identifier from the translations or return the default
+ *
+ * @access public
+ * @param string $idendifier Locale identifier
+ * @param string $default Default value
+ * @return string
+ */
+ public function get($identifier, $default = '')
+ {
+ if (isset(self::$locales[$identifier])) {
+ return self::$locales[$identifier];
+ }
+ else {
+ return $default;
+ }
+ }
+
+ /**
+ * Load translations
+ *
+ * @static
+ * @access public
+ * @param string $language Locale code: fr_FR
+ */
+ public static function load($language)
+ {
+ setlocale(LC_TIME, $language.'.UTF-8', $language);
+
+ $filename = self::PATH.$language.DIRECTORY_SEPARATOR.'translations.php';
+
+ if (file_exists($filename)) {
+ self::$locales = require $filename;
+ }
+ }
+}
diff --git a/app/Event/TaskModification.php b/app/Event/TaskModification.php
new file mode 100644
index 00000000..b1d412c7
--- /dev/null
+++ b/app/Event/TaskModification.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Event;
+
+use Core\Listener;
+use Model\Project;
+
+/**
+ * Task modification listener
+ *
+ * @package events
+ * @author Frederic Guillot
+ */
+class TaskModification implements Listener
+{
+ /**
+ * Project model
+ *
+ * @accesss private
+ * @var \Model\Project
+ */
+ private $project;
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param \Model\Project $project Project model instance
+ */
+ public function __construct(Project $project)
+ {
+ $this->project = $project;
+ }
+
+ /**
+ * Execute the action
+ *
+ * @access public
+ * @param array $data Event data dictionary
+ * @return bool True if the action was executed or false when not executed
+ */
+ public function execute(array $data)
+ {
+ if (isset($data['project_id'])) {
+ $this->project->updateModificationDate($data['project_id']);
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/app/Locales/es_ES/translations.php b/app/Locales/es_ES/translations.php
new file mode 100644
index 00000000..ce797972
--- /dev/null
+++ b/app/Locales/es_ES/translations.php
@@ -0,0 +1,334 @@
+<?php
+
+return array(
+ 'English' => 'Inglés',
+ 'French' => 'Francés',
+ 'Polish' => 'Polaco',
+ 'Portuguese (Brazilian)' => 'Portugués (Brasil)',
+ 'Spanish' => 'Español',
+ 'None' => 'Ninguno',
+ 'edit' => 'modificar',
+ 'Edit' => 'Modificar',
+ 'remove' => 'suprimir',
+ 'Remove' => 'Suprimir',
+ 'Update' => 'Actualizar',
+ 'Yes' => 'Si',
+ 'No' => 'No',
+ 'cancel' => 'cancelar',
+ 'or' => 'o',
+ 'Yellow' => 'Amarillo',
+ 'Blue' => 'Azul',
+ 'Green' => 'Verde',
+ 'Purple' => 'Púrpura',
+ 'Red' => 'Rojo',
+ 'Orange' => 'Naranja',
+ 'Grey' => 'Gris',
+ 'Save' => 'Guardar',
+ 'Login' => 'Iniciar sesión',
+ 'Official website:' => 'Pagina web oficial :',
+ 'Unassigned' => 'No asignado',
+ 'View this task' => 'Ver esta tarea',
+ 'Remove user' => 'Eliminar un usuario',
+ 'Do you really want to remove this user: "%s"?' => '¿Realmente desea suprimir este usuario: « %s » ?',
+ 'New user' => 'Añadir un usuario',
+ 'All users' => 'Todos los usuarios',
+ 'Username' => 'Nombre de usuario',
+ 'Password' => 'Contraseña',
+ 'Default Project' => 'Proyecto por defecto',
+ 'Administrator' => 'Administrador',
+ 'Sign in' => 'Iniciar sesión',
+ 'Users' => 'Usuarios',
+ 'No user' => 'Ningún usuario',
+ 'Forbidden' => 'Acceso denegado',
+ 'Access Forbidden' => 'Acceso denegado',
+ 'Only administrators can access to this page.' => 'Solo los administradores pueden acceder a esta pagina.',
+ 'Edit user' => 'Editar un usuario',
+ 'Logout' => 'Salir',
+ 'Bad username or password' => 'Usuario o contraseña incorecto',
+ 'users' => 'usuarios',
+ 'projects' => 'proyectos',
+ 'Edit project' => 'Editar el proyecto',
+ 'Name' => 'Nombre',
+ 'Activated' => 'Activado',
+ 'Projects' => 'Proyectos',
+ 'No project' => 'Ningún proyecto',
+ 'Project' => 'Proyecto',
+ 'Status' => 'Estado',
+ 'Tasks' => 'Tareas',
+ 'Board' => 'Tablero',
+ 'Inactive' => 'Inactivo',
+ 'Active' => 'Activo',
+ 'Column %d' => 'Columna %d',
+ 'Add this column' => 'Añadir esta columna',
+ '%d tasks on the board' => '%d tareas en el tablero',
+ '%d tasks in total' => '%d tareas en total',
+ 'Unable to update this board.' => 'No se puede actualizar este tablero.',
+ 'Edit board' => 'Editar este tablero',
+ 'Disable' => 'Desactivar',
+ 'Enable' => 'Activar',
+ 'New project' => 'Nuevo proyecto',
+ 'Do you really want to remove this project: "%s"?' => '¿Realmente desea eliminar este proyecto: « %s » ?',
+ 'Remove project' => 'Suprimir el proyecto',
+ 'Boards' => 'Tableros',
+ 'Edit the board for "%s"' => 'Modificar el tablero por « %s »',
+ 'All projects' => 'Todos los proyectos',
+ 'Change columns' => 'Cambiar las columnas',
+ 'Add a new column' => 'Añadir una nueva columna',
+ 'Title' => 'Titulo',
+ 'Add Column' => 'Nueva columna',
+ 'Project "%s"' => 'Proyecto « %s »',
+ 'Nobody assigned' => 'Persona asignada',
+ 'Assigned to %s' => 'Asignada a %s',
+ 'Remove a column' => 'Suprimir esta columna',
+ 'Remove a column from a board' => 'Suprimir una columna de un tablero',
+ 'Unable to remove this column.' => 'No se puede suprimir esta columna.',
+ 'Do you really want to remove this column: "%s"?' => '¿Realmente desea eliminar esta columna : « %s » ?',
+ 'This action will REMOVE ALL TASKS associated to this column!' => '¡Esta acción suprimirá todas las tareas asociadas a esta columna!',
+ 'Settings' => 'Preferencias',
+ 'Application settings' => 'Parámetros de la aplicación',
+ 'Language' => 'Idioma',
+ 'Webhooks token:' => 'Identificador (token) para los webhooks :',
+ 'More information' => 'Más informaciones',
+ 'Database size:' => 'Tamaño de la base de datos:',
+ 'Download the database' => 'Descargar la base de datos',
+ 'Optimize the database' => 'Optimisar la base de datos',
+ '(VACUUM command)' => '(Comando VACUUM)',
+ '(Gzip compressed Sqlite file)' => '(Archivo Sqlite comprimido en Gzip)',
+ 'User settings' => 'Parámetros de usuario',
+ 'My default project:' => 'Mi proyecto por defecto : ',
+ 'Close a task' => 'Cerrar una tarea',
+ 'Do you really want to close this task: "%s"?' => '¿Realmente desea cerrar esta tarea: « %s » ?',
+ 'Edit a task' => 'Editar una tarea',
+ 'Column' => 'Columna',
+ 'Color' => 'Coulor',
+ 'Assignee' => 'Persona asignada',
+ 'Create another task' => 'Crear una nueva tarea',
+ 'New task' => 'Nueva tarea',
+ 'Open a task' => 'Abrir una tarea',
+ 'Do you really want to open this task: "%s"?' => '¿Realomente desea abrir esta tarea: « %s » ?',
+ 'Back to the board' => 'Volver al tablero',
+ 'Created on %B %e, %G at %k:%M %p' => 'Creado el %d/%m/%Y a las %H:%M',
+ 'There is nobody assigned' => 'No hay nadie asignado a esta tarea',
+ 'Column on the board:' => 'Columna en el tablero: ',
+ 'Status is open' => 'Estado abierto',
+ 'Status is closed' => 'Estado cerrado',
+ 'Close this task' => 'Cerrar esta tarea',
+ 'Open this task' => 'Abrir esta tarea',
+ 'There is no description.' => 'No hay descripción.',
+ 'Add a new task' => 'Añadir una nueva tarea',
+ 'The username is required' => 'El nombre de usuario es obligatorio',
+ 'The maximum length is %d characters' => 'La longitud máxima es de %d caractères',
+ 'The minimum length is %d characters' => 'La longitud mínima es de %d caractères',
+ 'The password is required' => 'La contraseña es obligatoria',
+ 'This value must be an integer' => 'Este valor debe ser un entero',
+ 'The username must be unique' => 'El nombre de usuario debe ser único',
+ 'The username must be alphanumeric' => 'El nombre de usuario debe ser alfanumérico',
+ 'The user id is required' => 'El identificador del usuario es obligatorio',
+ 'Passwords doesn\'t matches' => 'Las contraseñas no corresponden',
+ 'The confirmation is required' => 'La confirmación es obligatoria',
+ 'The column is required' => 'La columna es obligatoria',
+ 'The project is required' => 'El proyecto es obligatorio',
+ 'The color is required' => 'El color es obligatorio',
+ 'The id is required' => 'El identificador es obligatorio',
+ 'The project id is required' => 'El identificador del proyecto es obligatorio',
+ 'The project name is required' => 'El nombre del proyecto es obligatorio',
+ 'This project must be unique' => 'El nombre del proyecto debe ser único',
+ 'The title is required' => 'El titulo es obligatorio',
+ 'The language is required' => 'El idioma es obligatorio',
+ 'There is no active project, the first step is to create a new project.' => 'No hay proyectos activados, la primera etapa consiste en crear un nuevo proyecto.',
+ 'Settings saved successfully.' => 'Parámetros guardados.',
+ 'Unable to save your settings.' => 'No se puede guardar sus parámetros.',
+ 'Database optimization done.' => 'Optimización de la base de datos terminada.',
+ 'Your project have been created successfully.' => 'El proyecto ha sido creado.',
+ 'Unable to create your project.' => 'No se puede crear el proyecto.',
+ 'Project updated successfully.' => 'El proyecto ha sido actualizado.',
+ 'Unable to update this project.' => 'No se puede actualizar el proyecto.',
+ 'Unable to remove this project.' => 'No se puede suprimir este proyecto.',
+ 'Project removed successfully.' => 'El proyecto ha sido borrado.',
+ 'Project activated successfully.' => 'El proyecto ha sido activado.',
+ 'Unable to activate this project.' => 'No se puede activar el proyecto.',
+ 'Project disabled successfully.' => 'El proyecto ha sido desactivado.',
+ 'Unable to disable this project.' => 'No se puede desactivar el proyecto.',
+ 'Unable to open this task.' => 'No se puede abrir esta tarea.',
+ 'Task opened successfully.' => 'La tarea ha sido abierta.',
+ 'Unable to close this task.' => 'No se puede cerrar esta tarea.',
+ 'Task closed successfully.' => 'La tarea ha sido cerrada.',
+ 'Unable to update your task.' => 'No se puede modificar esta tarea.',
+ 'Task updated successfully.' => 'La tarea ha sido actualizada.',
+ 'Unable to create your task.' => 'No se puede crear esta tarea.',
+ 'Task created successfully.' => 'La tarea ha sido creada.',
+ 'User created successfully.' => 'El usuario ha sido creado.',
+ 'Unable to create your user.' => 'No se puede crear este usuario.',
+ 'User updated successfully.' => 'El usuario ha sido actualizado.',
+ 'Unable to update your user.' => 'No se puede actualizar este usuario.',
+ 'User removed successfully.' => 'El usuario ha sido creado.',
+ 'Unable to remove this user.' => 'No se puede crear este usuario.',
+ 'Board updated successfully.' => 'El tablero ha sido actualizado.',
+ 'Ready' => 'Listo',
+ 'Backlog' => 'En espera',
+ 'Work in progress' => 'En curso',
+ 'Done' => 'Terminado',
+ 'Application version:' => 'Versión de la aplicación:',
+ 'Completed on %B %e, %G at %k:%M %p' => 'Terminado el %d/%m/%Y a las %H:%M',
+ '%B %e, %G at %k:%M %p' => '%d/%m/%Y a las %H:%M',
+ 'Date created' => 'Fecha de creación',
+ 'Date completed' => 'Fecha terminada',
+ 'Id' => 'Identificador',
+ 'No task' => 'Ninguna tarea',
+ 'Completed tasks' => 'Tareas terminadas',
+ 'List of projects' => 'Lista de los proyectos',
+ 'Completed tasks for "%s"' => 'Tarea completada por « %s »',
+ '%d closed tasks' => '%d tareas completadas',
+ 'no task for this project' => 'ninguna tarea para este proyecto',
+ 'Public link' => 'Enlace publico',
+ 'There is no column in your project!' => '¡No hay ninguna columna para este proyecto!',
+ 'Change assignee' => 'Cambiar la persona asignada',
+ 'Change assignee for the task "%s"' => 'Cambiar la persona asignada por la tarea « %s »',
+ 'Timezone' => 'Zona horaria',
+ 'Sorry, I didn\'t found this information in my database!' => 'Lo siento no he encontrado información en la base de datos!',
+ 'Page not found' => 'Pagina no encontrada',
+ 'Story Points' => 'Complejidad',
+ 'limit' => 'limite',
+ 'Task limit' => 'Número máximo de tareas',
+ 'This value must be greater than %d' => 'Este valor no debe ser más grande que %d',
+ 'Edit project access list' => 'Editar los permisos del proyecto',
+ 'Edit users access' => 'Editar los permisos de usuario',
+ 'Allow this user' => 'Autorizar este usuario',
+ 'Project access list for "%s"' => 'Permisos del proyecto « %s »',
+ 'Only those users have access to this project:' => 'Solo estos usuarios tienen acceso a este proyecto:',
+ 'Don\'t forget that administrators have access to everything.' => 'No olvide que los administradores tienen acceso a todo',
+ 'revoke' => 'revocar',
+ 'List of authorized users' => 'Lista de los usuarios autorizados',
+ 'User' => 'Usuario',
+ 'Everybody have access to this project.' => 'Todo el mundo tiene acceso al proyecto.',
+ 'You are not allowed to access to this project.' => 'No esta autorizado a acceder a este proyecto.',
+ 'Comments' => 'Comentarios',
+ 'Post comment' => 'Commentar',
+ 'Write your text in Markdown' => 'Redacta el texto en Markdown',
+ 'Leave a comment' => 'Dejar un comentario',
+ 'Comment is required' => 'El comentario es obligatorio',
+ 'Leave a description' => 'Dejar una descripción',
+ 'Comment added successfully.' => 'El comentario ha sido añadido.',
+ 'Unable to create your comment.' => 'No se puede crear este comentario.',
+ 'The description is required' => 'La descripción es obligatoria',
+ 'Edit this task' => 'Editar esta tarea',
+ 'Due Date' => 'Fecha límite',
+ 'm/d/Y' => 'd/m/Y', // Date format parsed with php
+ 'month/day/year' => 'día/mes/año', // Help shown to the user
+ 'Invalid date' => 'Fecha no valida',
+ 'Must be done before %B %e, %G' => 'Debe estar hecho antes del %d/%m/%Y',
+ '%B %e, %G' => '%d/%m/%Y',
+ 'Automatic actions' => 'Acciones automatizadas',
+ 'Your automatic action have been created successfully.' => 'La acción automatizada ha sido creada',
+ 'Unable to create your automatic action.' => 'No se puede crear esta acción automatizada.',
+ 'Remove an action' => 'Suprimir una acción',
+ 'Unable to remove this action.' => 'No se puede suprimir esta accción',
+ 'Action removed successfully.' => 'La acción ha sido borrada.',
+ 'Automatic actions for the project "%s"' => 'Acciones automatizadas para este proyecto « %s »',
+ 'Defined actions' => 'Acciones definidas',
+ 'Event name' => 'Nombre del evento',
+ 'Action name' => 'Nombre de la acción',
+ 'Action parameters' => 'Parámetros de la acción',
+ 'Action' => 'Acción',
+ 'Event' => 'Evento',
+ 'When the selected event occurs execute the corresponding action.' => 'Cuando el evento seleccionado ocurre ejecutar la acción correspondiente.',
+ 'Next step' => 'Etapa siguiente',
+ 'Define action parameters' => 'Definición de los parametros de la acción',
+ 'Save this action' => 'Guardar esta acción',
+ 'Do you really want to remove this action: "%s"?' => '¿Realmente desea suprimir esta acción « %s » ?',
+ 'Remove an automatic action' => 'Suprimir una acción automatizada',
+ 'Close the task' => 'Cerrar esta tarea',
+ 'Assign the task to a specific user' => 'Asignar una tarea a un usuario especifico',
+ 'Assign the task to the person who does the action' => 'Asignar la tarea al usuario que hace la acción',
+ 'Duplicate the task to another project' => 'Duplicar la tarea a otro proyecto',
+ 'Move a task to another column' => 'Mover una tarea a otra columna',
+ 'Move a task to another position in the same column' => 'Mover una tarea a otra posición en la misma columna',
+ 'Task modification' => 'Modificación de una tarea',
+ 'Task creation' => 'Creación de una tarea',
+ 'Open a closed task' => 'Abrir una tarea cerrada',
+ 'Closing a task' => 'Cerrar una tarea',
+ 'Assign a color to a specific user' => 'Asignar un color a un usuario especifico',
+ 'Column title' => 'Titulo de la columna',
+ 'Position' => 'Posición',
+ 'Move Up' => 'Mover hacia arriba',
+ 'Move Down' => 'Mover hacia abajo',
+ 'Duplicate to another project' => 'Duplicar a otro proyecto',
+ 'Duplicate' => 'Duplicar',
+ 'link' => 'enlace',
+ 'Update this comment' => 'Actualizar este comentario',
+ 'Comment updated successfully.' => 'El comentario ha sido actualizado.',
+ 'Unable to update your comment.' => 'No se puede actualizar este comentario.',
+ 'Remove a comment' => 'Suprimir un comentario',
+ 'Comment removed successfully.' => 'El comentario ha sido suprimido.',
+ 'Unable to remove this comment.' => 'No se puede suprimir este comentario.',
+ 'Do you really want to remove this comment?' => '¿Desea suprimir este comentario?',
+ 'Only administrators or the creator of the comment can access to this page.' => 'Solo los administradores o el autor del comentario tienen acceso a esta pagina.',
+ 'Details' => 'Detalles',
+ 'Current password for the user "%s"' => 'Contraseña actual para el usuario: « %s »',
+ 'The current password is required' => 'La contraseña es obligatoria',
+ 'Wrong password' => 'contraseña incorrecta',
+ 'Reset all tokens' => 'Reiniciar los identificadores (tokens) de seguridad ',
+ 'All tokens have been regenerated.' => 'Todos los identificadores (tokens) han sido reiniciados.',
+ // 'Unknown' => '',
+ // 'Last logins' => '',
+ // 'Login date' => '',
+ // 'Authentication method' => '',
+ // 'IP address' => '',
+ // 'User agent' => '',
+ // 'Persistent connections' => '',
+ // 'No session' => '',
+ // 'Expiration date' => '',
+ // 'Remember Me' => '',
+ // 'Creation date' => '',
+ // 'Filter by user' => '',
+ // 'Filter by due date' => ',
+ // 'Everybody' => '',
+ // 'Open' => '',
+ // 'Closed' => '',
+ // 'Search' => '',
+ // 'Nothing found.' => '',
+ // 'Search in the project "%s"' => '',
+ // 'Due date' => '',
+ // 'Others formats accepted: %s and %s' => '',
+ // 'Description' => '',
+ // '%d comments' => '',
+ // '%d comment' => '',
+ // 'Email address invalid' => '',
+ // 'Your Google Account is not linked anymore to your profile.' => '',
+ // 'Unable to unlink your Google Account.' => '',
+ // 'Google authentication failed' => '',
+ // 'Unable to link your Google Account.' => '',
+ // 'Your Google Account is linked to your profile successfully.' => '',
+ // 'Email' => '',
+ // 'Link my Google Account' => '',
+ // 'Unlink my Google Account' => '',
+ // 'Login with my Google Account' => '',
+ // 'Project not found.' => '',
+ // 'Task #%d' => '',
+ // 'Task removed successfully.' => '',
+ // 'Unable to remove this task.' => '',
+ // 'Remove a task' => '',
+ // 'Do you really want to remove this task: "%s"?' => '',
+ // 'Assign a color to a specific category' => '',
+ // 'Task creation or modification' => '',
+ // 'Category' => '',
+ // 'Category:' => '',
+ // 'Categories' => '',
+ // 'Category not found.' => '',
+ // 'Your category have been created successfully.' => '',
+ // 'Unable to create your category.' => '',
+ // 'Your category have been updated successfully.' => '',
+ // 'Unable to update your category.' => '',
+ // 'Remove a category' => '',
+ // 'Category removed successfully.' => '',
+ // 'Unable to remove this category.' => '',
+ // 'Category modification for the project "%s"' => '',
+ // 'Category Name' => '',
+ // 'Categories for the project "%s"' => '',
+ // 'Add a new category' => '',
+ // 'Do you really want to remove this category: "%s"?' => '',
+ // 'Filter by category' => '',
+ // 'All categories' => '',
+ // 'No category' => '',
+ // 'The name is required' => '',
+);
diff --git a/app/Locales/fr_FR/translations.php b/app/Locales/fr_FR/translations.php
new file mode 100644
index 00000000..c93a83ae
--- /dev/null
+++ b/app/Locales/fr_FR/translations.php
@@ -0,0 +1,334 @@
+<?php
+
+return array(
+ 'English' => 'Anglais',
+ 'French' => 'Français',
+ 'Polish' => 'Polonais',
+ 'Portuguese (Brazilian)' => 'Portugais (Brésil)',
+ 'Spanish' => 'Espagnol',
+ 'None' => 'Aucun',
+ 'edit' => 'modifier',
+ 'Edit' => 'Modifier',
+ 'remove' => 'supprimer',
+ 'Remove' => 'Supprimer',
+ 'Update' => 'Mettre à jour',
+ 'Yes' => 'Oui',
+ 'No' => 'Non',
+ 'cancel' => 'annuler',
+ 'or' => 'ou',
+ 'Yellow' => 'Jaune',
+ 'Blue' => 'Bleu',
+ 'Green' => 'Vert',
+ 'Purple' => 'Violet',
+ 'Red' => 'Rouge',
+ 'Orange' => 'Orange',
+ 'Grey' => 'Gris',
+ 'Save' => 'Enregistrer',
+ 'Login' => 'Connexion',
+ 'Official website:' => 'Site web officiel :',
+ 'Unassigned' => 'Non assigné',
+ 'View this task' => 'Voir cette tâche',
+ 'Remove user' => 'Supprimer un utilisateur',
+ 'Do you really want to remove this user: "%s"?' => 'Voulez-vous vraiment supprimer cet utilisateur : « %s » ?',
+ 'New user' => 'Ajouter un utilisateur',
+ 'All users' => 'Tous les utilisateurs',
+ 'Username' => 'Identifiant',
+ 'Password' => 'Mot de passe',
+ 'Default Project' => 'Projet par défaut',
+ 'Administrator' => 'Administrateur',
+ 'Sign in' => 'Connexion',
+ 'Users' => 'Utilisateurs',
+ 'No user' => 'Aucun utilisateur',
+ 'Forbidden' => 'Accès interdit',
+ 'Access Forbidden' => 'Accès interdit',
+ 'Only administrators can access to this page.' => 'Uniquement les administrateurs peuvent accéder à cette page.',
+ 'Edit user' => 'Modifier un utilisateur',
+ 'Logout' => 'Déconnexion',
+ 'Bad username or password' => 'Identifiant ou mot de passe incorrect',
+ 'users' => 'utilisateurs',
+ 'projects' => 'projets',
+ 'Edit project' => 'Modifier le projet',
+ 'Name' => 'Nom',
+ 'Activated' => 'Actif',
+ 'Projects' => 'Projets',
+ 'No project' => 'Aucun projet',
+ 'Project' => 'Projet',
+ 'Status' => 'État',
+ 'Tasks' => 'Tâches',
+ 'Board' => 'Tableau',
+ 'Inactive' => 'Inactif',
+ 'Active' => 'Actif',
+ 'Column %d' => 'Colonne %d',
+ 'Add this column' => 'Ajouter cette colonne',
+ '%d tasks on the board' => '%d tâches sur le tableau',
+ '%d tasks in total' => '%d tâches au total',
+ 'Unable to update this board.' => 'Impossible de mettre à jour ce tableau.',
+ 'Edit board' => 'Modifier le tableau',
+ 'Disable' => 'Désactiver',
+ 'Enable' => 'Activer',
+ 'New project' => 'Nouveau projet',
+ 'Do you really want to remove this project: "%s"?' => 'Voulez-vous vraiment supprimer ce projet : « %s » ?',
+ 'Remove project' => 'Supprimer le projet',
+ 'Boards' => 'Tableaux',
+ 'Edit the board for "%s"' => 'Modifier le tableau pour « %s »',
+ 'All projects' => 'Tous les projets',
+ 'Change columns' => 'Changer les colonnes',
+ 'Add a new column' => 'Ajouter une nouvelle colonne',
+ 'Title' => 'Titre',
+ 'Add Column' => 'Nouvelle colonne',
+ 'Project "%s"' => 'Projet « %s »',
+ 'Nobody assigned' => 'Personne assigné',
+ 'Assigned to %s' => 'Assigné à %s',
+ 'Remove a column' => 'Supprimer une colonne',
+ 'Remove a column from a board' => 'Supprimer une colonne d\'un tableau',
+ 'Unable to remove this column.' => 'Impossible de supprimer cette colonne.',
+ 'Do you really want to remove this column: "%s"?' => 'Voulez vraiment supprimer cette colonne : « %s » ?',
+ 'This action will REMOVE ALL TASKS associated to this column!' => 'Cette action va supprimer toutes les tâches associées à cette colonne !',
+ 'Settings' => 'Préférences',
+ 'Application settings' => 'Paramètres de l\'application',
+ 'Language' => 'Langue',
+ 'Webhooks token:' => 'Jeton de securité pour les webhooks :',
+ 'More information' => 'Plus d\'informations',
+ 'Database size:' => 'Taille de la base de données :',
+ 'Download the database' => 'Télécharger la base de données',
+ 'Optimize the database' => 'Optimiser la base de données',
+ '(VACUUM command)' => '(Commande VACUUM)',
+ '(Gzip compressed Sqlite file)' => '(Fichier Sqlite compressé en Gzip)',
+ 'User settings' => 'Paramètres utilisateur',
+ 'My default project:' => 'Mon projet par défaut : ',
+ 'Close a task' => 'Fermer une tâche',
+ 'Do you really want to close this task: "%s"?' => 'Voulez-vous vraiment fermer cettre tâche : « %s » ?',
+ 'Edit a task' => 'Modifier une tâche',
+ 'Column' => 'Colonne',
+ 'Color' => 'Couleur',
+ 'Assignee' => 'Personne assignée',
+ 'Create another task' => 'Créer une autre tâche',
+ 'New task' => 'Nouvelle tâche',
+ 'Open a task' => 'Ouvrir une tâche',
+ 'Do you really want to open this task: "%s"?' => 'Voulez-vous vraiment ouvrir cette tâche : « %s » ?',
+ 'Back to the board' => 'Retour au tableau',
+ 'Created on %B %e, %G at %k:%M %p' => 'Créé le %d/%m/%Y à %H:%M',
+ 'There is nobody assigned' => 'Il n\'y a personne d\'assigné à cette tâche',
+ 'Column on the board:' => 'Colonne sur le tableau : ',
+ 'Status is open' => 'État ouvert',
+ 'Status is closed' => 'État fermé',
+ 'Close this task' => 'Fermer cette tâche',
+ 'Open this task' => 'Ouvrir cette tâche',
+ 'There is no description.' => 'Il n\'y a pas de description.',
+ 'Add a new task' => 'Ajouter une nouvelle tâche',
+ 'The username is required' => 'Le nom d\'utilisateur est obligatoire',
+ 'The maximum length is %d characters' => 'La longueur maximale est de %d caractères',
+ 'The minimum length is %d characters' => 'La longueur minimale est de %d caractères',
+ 'The password is required' => 'Le mot de passe est obligatoire',
+ 'This value must be an integer' => 'Cette valeur doit être un entier',
+ 'The username must be unique' => 'Le nom d\'utilisateur doit être unique',
+ 'The username must be alphanumeric' => 'Le nom d\'utilisateur doit être alpha-numérique',
+ 'The user id is required' => 'L\'id de l\'utilisateur est obligatoire',
+ 'Passwords doesn\'t matches' => 'Les mots de passe ne correspondent pas',
+ 'The confirmation is required' => 'Le confirmation est requise',
+ 'The column is required' => 'La colonne est obligatoire',
+ 'The project is required' => 'Le projet est obligatoire',
+ 'The color is required' => 'La couleur est obligatoire',
+ 'The id is required' => 'L\'identifiant est obligatoire',
+ 'The project id is required' => 'L\'identifiant du projet est obligatoire',
+ 'The project name is required' => 'Le nom du projet est obligatoire',
+ 'This project must be unique' => 'Le nom du projet doit être unique',
+ 'The title is required' => 'Le titre est obligatoire',
+ 'The language is required' => 'La langue est obligatoire',
+ 'There is no active project, the first step is to create a new project.' => 'Il n\'y a aucun projet actif, la première étape est de créer un nouveau projet.',
+ 'Settings saved successfully.' => 'Paramètres sauvegardés avec succès.',
+ 'Unable to save your settings.' => 'Impossible de sauvegarder vos réglages.',
+ 'Database optimization done.' => 'Optmisation de la base de données terminée.',
+ 'Your project have been created successfully.' => 'Votre projet a été créé avec succès.',
+ 'Unable to create your project.' => 'Impossible de créer un projet.',
+ 'Project updated successfully.' => 'Votre projet a été mis à jour avec succès.',
+ 'Unable to update this project.' => 'Impossible de mettre à jour ce projet.',
+ 'Unable to remove this project.' => 'Impossible de supprimer ce projet.',
+ 'Project removed successfully.' => 'Votre projet a été supprimé avec succès.',
+ 'Project activated successfully.' => 'Votre projet a été activé avec succès.',
+ 'Unable to activate this project.' => 'Impossible d\'activer ce projet.',
+ 'Project disabled successfully.' => 'Votre projet a été désactivé avec succès.',
+ 'Unable to disable this project.' => 'Impossible de désactiver ce projet.',
+ 'Unable to open this task.' => 'Impossible d\'ouvrir cette tâche.',
+ 'Task opened successfully.' => 'Tâche ouverte avec succès.',
+ 'Unable to close this task.' => 'Impossible de fermer cette tâche.',
+ 'Task closed successfully.' => 'Tâche fermé avec succès.',
+ 'Unable to update your task.' => 'Impossible de modifier cette tâche.',
+ 'Task updated successfully.' => 'Tâche mise à jour avec succès.',
+ 'Unable to create your task.' => 'Impossible de créer cette tâche.',
+ 'Task created successfully.' => 'Tâche créée avec succès.',
+ 'User created successfully.' => 'Utilisateur créé avec succès.',
+ 'Unable to create your user.' => 'Impossible de créer cet utilisateur.',
+ 'User updated successfully.' => 'Utilisateur mis à jour avec succès.',
+ 'Unable to update your user.' => 'Impossible de mettre à jour cet utilisateur.',
+ 'User removed successfully.' => 'Utilisateur supprimé avec succès.',
+ 'Unable to remove this user.' => 'Impossible de supprimer cet utilisateur.',
+ 'Board updated successfully.' => 'Tableau mis à jour avec succès.',
+ 'Ready' => 'Prêt',
+ 'Backlog' => 'En attente',
+ 'Work in progress' => 'En cours',
+ 'Done' => 'Terminé',
+ 'Application version:' => 'Version de l\'application :',
+ 'Completed on %B %e, %G at %k:%M %p' => 'Terminé le %d/%m/%Y à %H:%M',
+ '%B %e, %G at %k:%M %p' => '%d/%m/%Y à %H:%M',
+ 'Date created' => 'Date de création',
+ 'Date completed' => 'Date de clôture',
+ 'Id' => 'Identifiant',
+ 'No task' => 'Aucune tâche',
+ 'Completed tasks' => 'Tâches terminées',
+ 'List of projects' => 'Liste des projets',
+ 'Completed tasks for "%s"' => 'Tâches terminées pour « %s »',
+ '%d closed tasks' => '%d tâches terminées',
+ 'no task for this project' => 'aucune tâche pour ce projet',
+ 'Public link' => 'Accès public',
+ 'There is no column in your project!' => 'Il n\'y a aucune colonne dans votre projet !',
+ 'Change assignee' => 'Changer la personne assignée',
+ 'Change assignee for the task "%s"' => 'Changer la personne assignée pour la tâche « %s »',
+ 'Timezone' => 'Fuseau horaire',
+ 'Sorry, I didn\'t found this information in my database!' => 'Désolé, je n\'ai pas trouvé cette information dans ma base de données !',
+ 'Page not found' => 'Page introuvable',
+ 'Story Points' => 'Complexité',
+ 'limit' => 'limite',
+ 'Task limit' => 'Nombre maximum de tâches',
+ 'This value must be greater than %d' => 'Cette valeur doit être plus grande que %d',
+ 'Edit project access list' => 'Modifier l\'accès au projet',
+ 'Edit users access' => 'Modifier les utilisateurs autorisés',
+ 'Allow this user' => 'Autoriser cet utilisateur',
+ 'Project access list for "%s"' => 'Liste des accès au projet « %s »',
+ 'Only those users have access to this project:' => 'Seulement ces utilisateurs ont accès à ce projet :',
+ 'Don\'t forget that administrators have access to everything.' => 'N\'oubliez pas que les administrateurs ont accès à tout.',
+ 'revoke' => 'révoquer',
+ 'List of authorized users' => 'Liste des utilisateurs autorisés',
+ 'User' => 'Utilisateur',
+ 'Everybody have access to this project.' => 'Tout le monde a accès au projet.',
+ 'You are not allowed to access to this project.' => 'Vous n\'êtes pas autorisé à accéder à ce projet.',
+ 'Comments' => 'Commentaires',
+ 'Post comment' => 'Commenter',
+ 'Write your text in Markdown' => 'Écrivez votre texte en Markdown',
+ 'Leave a comment' => 'Laissez un commentaire',
+ 'Comment is required' => 'Le commentaire est obligatoire',
+ 'Leave a description' => 'Laissez une description',
+ 'Comment added successfully.' => 'Commentaire ajouté avec succès.',
+ 'Unable to create your comment.' => 'Impossible de sauvegarder votre commentaire.',
+ 'The description is required' => 'La description est obligatoire',
+ 'Edit this task' => 'Modifier cette tâche',
+ 'Due Date' => 'Date d\'échéance',
+ 'm/d/Y' => 'd/m/Y', // Date format parsed with php
+ 'month/day/year' => 'jour/mois/année', // Help shown to the user
+ 'Invalid date' => 'Date invalide',
+ 'Must be done before %B %e, %G' => 'Doit être fait avant le %d/%m/%Y',
+ '%B %e, %G' => '%d/%m/%Y',
+ 'Automatic actions' => 'Actions automatisées',
+ 'Your automatic action have been created successfully.' => 'Votre action automatisée a été ajouté avec succès.',
+ 'Unable to create your automatic action.' => 'Impossible de créer votre action automatisée.',
+ 'Remove an action' => 'Supprimer une action',
+ 'Unable to remove this action.' => 'Impossible de supprimer cette action',
+ 'Action removed successfully.' => 'Action supprimée avec succès.',
+ 'Automatic actions for the project "%s"' => 'Actions automatisées pour le projet « %s »',
+ 'Defined actions' => 'Actions définies',
+ 'Event name' => 'Nom de l\'événement',
+ 'Action name' => 'Nom de l\'action',
+ 'Action parameters' => 'Paramètres de l\'action',
+ 'Action' => 'Action',
+ 'Event' => 'Événement',
+ 'When the selected event occurs execute the corresponding action.' => 'Lorsque l\'événement sélectionné se déclenche, executer l\'action correspondante.',
+ 'Next step' => 'Étape suivante',
+ 'Define action parameters' => 'Définition des paramètres de l\'action',
+ 'Save this action' => 'Sauvegarder cette action',
+ 'Do you really want to remove this action: "%s"?' => 'Voulez-vous vraiment supprimer cette action « %s » ?',
+ 'Remove an automatic action' => 'Supprimer une action automatisée',
+ 'Close the task' => 'Fermer cette tâche',
+ 'Assign the task to a specific user' => 'Assigner la tâche à un utilisateur spécifique',
+ 'Assign the task to the person who does the action' => 'Assigner la tâche à la personne qui fait l\'action',
+ 'Duplicate the task to another project' => 'Dupliquer la tâche vers un autre projet',
+ 'Move a task to another column' => 'Déplacement d\'une tâche vers un autre colonne',
+ 'Move a task to another position in the same column' => 'Déplacement d\'une tâche à une autre position mais dans la même colonne',
+ 'Task modification' => 'Modification d\'une tâche',
+ 'Task creation' => 'Création d\'une tâche',
+ 'Open a closed task' => 'Ouverture d\'une tâche fermée',
+ 'Closing a task' => 'Fermeture d\'une tâche',
+ 'Assign a color to a specific user' => 'Assigner une couleur à un utilisateur',
+ 'Column title' => 'Titre de la colonne',
+ 'Position' => 'Position',
+ 'Move Up' => 'Déplacer vers le haut',
+ 'Move Down' => 'Déplacer vers le bas',
+ 'Duplicate to another project' => 'Dupliquer dans un autre projet',
+ 'Duplicate' => 'Dupliquer',
+ 'link' => 'lien',
+ 'Update this comment' => 'Mettre à jour ce commentaire',
+ 'Comment updated successfully.' => 'Commentaire mis à jour avec succès.',
+ 'Unable to update your comment.' => 'Impossible de supprimer votre commentaire.',
+ 'Remove a comment' => 'Supprimer un commentaire',
+ 'Comment removed successfully.' => 'Commentaire supprimé avec succès.',
+ 'Unable to remove this comment.' => 'Impossible de supprimer ce commentaire.',
+ 'Do you really want to remove this comment?' => 'Voulez-vous vraiment supprimer ce commentaire ?',
+ 'Only administrators or the creator of the comment can access to this page.' => 'Uniquement les administrateurs ou le créateur du commentaire peuvent accéder à cette page.',
+ 'Details' => 'Détails',
+ 'Current password for the user "%s"' => 'Mot de passe actuel pour l\'utilisateur « %s »',
+ 'The current password is required' => 'Le mot de passe actuel est obligatoire',
+ 'Wrong password' => 'Mauvais mot de passe',
+ 'Reset all tokens' => 'Réinitialiser tous les jetons de sécurité',
+ 'All tokens have been regenerated.' => 'Tous les jetons de sécurité ont été réinitialisés.',
+ 'Unknown' => 'Inconnu',
+ 'Last logins' => 'Dernières connexions',
+ 'Login date' => 'Date de connexion',
+ 'Authentication method' => 'Méthode d\'authentification',
+ 'IP address' => 'Adresse IP',
+ 'User agent' => 'Agent utilisateur',
+ 'Persistent connections' => 'Connexions persistantes',
+ 'No session' => 'Aucune session',
+ 'Expiration date' => 'Date d\'expiration',
+ 'Remember Me' => 'Connexion automatique',
+ 'Creation date' => 'Date de création',
+ 'Filter by user' => 'Filtrer par utilisateur',
+ 'Filter by due date' => 'Filtrer par date d\'échéance',
+ 'Everybody' => 'Tout le monde',
+ 'Open' => 'Ouvert',
+ 'Closed' => 'Fermé',
+ 'Search' => 'Rechercher',
+ 'Nothing found.' => 'Rien trouvé.',
+ 'Search in the project "%s"' => 'Rechercher dans le projet « %s »',
+ 'Due date' => 'Date d\'échéance',
+ 'Others formats accepted: %s and %s' => 'Autres formats acceptés : %s et %s',
+ 'Description' => 'Description',
+ '%d comments' => '%d commentaires',
+ '%d comment' => '%d commentaire',
+ 'Email address invalid' => 'Adresse email invalide',
+ 'Your Google Account is not linked anymore to your profile.' => 'Votre compte Google n\'est plus relié à votre profile.',
+ 'Unable to unlink your Google Account.' => 'Impossible de supprimer votre compte Google.',
+ 'Google authentication failed' => 'Authentification Google échouée',
+ 'Unable to link your Google Account.' => 'Impossible de lier votre compte Google.',
+ 'Your Google Account is linked to your profile successfully.' => 'Votre compte Google est désormais lié à votre profile.',
+ 'Email' => 'Email',
+ 'Link my Google Account' => 'Lier mon compte Google',
+ 'Unlink my Google Account' => 'Ne plus utiliser mon compte Google',
+ 'Login with my Google Account' => 'Se connecter avec mon compte Google',
+ 'Project not found.' => 'Projet introuvable.',
+ 'Task #%d' => 'Tâche n°%d',
+ 'Task removed successfully.' => 'Tâche supprimée avec succès.',
+ 'Unable to remove this task.' => 'Impossible de supprimer cette tâche.',
+ 'Remove a task' => 'Supprimer une tâche',
+ 'Do you really want to remove this task: "%s"?' => 'Voulez-vous vraiment supprimer cette tâche « %s » ?',
+ 'Assign a color to a specific category' => 'Assigner une couleur à une catégorie spécifique',
+ 'Task creation or modification' => 'Création ou modification d\'une tâche',
+ 'Category' => 'Catégorie',
+ 'Category:' => 'Catégorie :',
+ 'Categories' => 'Catégories',
+ 'Category not found.' => 'Catégorie introuvable',
+ 'Your category have been created successfully.' => 'Votre catégorie a été créé avec succès.',
+ 'Unable to create your category.' => 'Impossible de créer votre catégorie.',
+ 'Your category have been updated successfully.' => 'Votre catégorie a été mise à jour avec succès.',
+ 'Unable to update your category.' => 'Impossible de mettre à jour votre catégorie.',
+ 'Remove a category' => 'Supprimer une catégorie',
+ 'Category removed successfully.' => 'Catégorie supprimée avec succès.',
+ 'Unable to remove this category.' => 'Impossible de supprimer cette catégorie.',
+ 'Category modification for the project "%s"' => 'Modification d\'une catégorie pour le projet « %s »',
+ 'Category Name' => 'Nom de la catégorie',
+ 'Categories for the project "%s"' => 'Catégories du projet « %s »',
+ 'Add a new category' => 'Ajouter une nouvelle catégorie',
+ 'Do you really want to remove this category: "%s"?' => 'Voulez-vous vraiment supprimer cette catégorie « %s » ?',
+ 'Filter by category' => 'Filtrer par catégorie',
+ 'All categories' => 'Toutes les catégories',
+ 'No category' => 'Aucune catégorie',
+ 'The name is required' => 'Le nom est requis',
+);
diff --git a/app/Locales/pl_PL/translations.php b/app/Locales/pl_PL/translations.php
new file mode 100644
index 00000000..81ecaf01
--- /dev/null
+++ b/app/Locales/pl_PL/translations.php
@@ -0,0 +1,339 @@
+<?php
+
+return array(
+ 'English' => 'angielski',
+ 'French' => 'francuski',
+ 'Polish' => 'polski',
+ 'Portuguese (Brazilian)' => 'Portugalski (brazylijski)',
+ 'Spanish' => 'Hiszpański',
+ 'None' => 'Brak',
+ 'edit' => 'edytuj',
+ 'Edit' => 'Edytuj',
+ 'remove' => 'usuń',
+ 'Remove' => 'Usuń',
+ 'Update' => 'Aktualizuj',
+ 'Yes' => 'Tak',
+ 'No' => 'Nie',
+ 'cancel' => 'anuluj',
+ 'or' => 'lub',
+ 'Yellow' => 'Żółty',
+ 'Blue' => 'Niebieski',
+ 'Green' => 'Zielony',
+ 'Purple' => 'Fioletowy',
+ 'Red' => 'Czerwony',
+ 'Orange' => 'Pomarańczowy',
+ 'Grey' => 'Szary',
+ 'Save' => 'Zapisz',
+ 'Login' => 'Login',
+ 'Official website:' => 'Oficjalna strona:',
+ 'Unassigned' => 'Nieprzypisany',
+ 'View this task' => 'Zobacz zadanie',
+ 'Remove user' => 'Usuń użytkownika',
+ 'Do you really want to remove this user: "%s"?' => 'Na pewno chcesz usunąć użytkownika: "%s"?',
+ 'New user' => 'Nowy użytkownik',
+ 'All users' => 'Wszyscy użytkownicy',
+ 'Username' => 'Nazwa użytkownika',
+ 'Password' => 'Hasło',
+ 'Default Project' => 'Domyślny projekt',
+ 'Administrator' => 'Administrator',
+ 'Sign in' => 'Zaloguj',
+ 'Users' => 'Użytkownicy',
+ 'No user' => 'Brak użytkowników',
+ 'Forbidden' => 'Zabroniony',
+ 'Access Forbidden' => 'Dostęp zabroniony',
+ 'Only administrators can access to this page.' => 'Tylko administrator może wejść na tą stronę.',
+ 'Edit user' => 'Edytuj użytkownika',
+ 'Logout' => 'Wyloguj',
+ 'Bad username or password' => 'Zła nazwa uyżytkownika lub hasło',
+ 'users' => 'użytkownicy',
+ 'projects' => 'projekty',
+ 'Edit project' => 'Edytuj projekt',
+ 'Name' => 'Nazwa',
+ 'Activated' => 'Aktywny',
+ 'Projects' => 'Projekty',
+ 'No project' => 'Brak projektów',
+ 'Project' => 'Projekt',
+ 'Status' => 'Status',
+ 'Tasks' => 'Zadania',
+ 'Board' => 'Tablica',
+ 'Inactive' => 'Nieaktywny',
+ 'Active' => 'Aktywny',
+ 'Column %d' => 'Kolumna %d',
+ 'Add this column' => 'Dodaj kolumnÄ™',
+ '%d tasks on the board' => '%d zadań na tablicy',
+ '%d tasks in total' => '%d wszystkich zadań',
+ 'Unable to update this board.' => 'Nie można zaktualizować tablicy.',
+ 'Edit board' => 'Edytuj tablicÄ™',
+ 'Disable' => 'Wyłącz',
+ 'Enable' => 'WÅ‚Ä…cz',
+ 'New project' => 'Nowy projekt',
+ 'Do you really want to remove this project: "%s"?' => 'Na pewno chcesz usunąć projekt: "%s"?',
+ 'Remove project' => 'Usuń projekt',
+ 'Boards' => 'Tablice',
+ 'Edit the board for "%s"' => 'Edytuj tabliÄ™ dla "%s"',
+ 'All projects' => 'Wszystkie projekty',
+ 'Change columns' => 'Zmień kolumny',
+ 'Add a new column' => 'Dodaj nowÄ… kolumnÄ™',
+ 'Title' => 'Tytuł',
+ 'Add Column' => 'Dodaj kolumnÄ™',
+ 'Project "%s"' => 'Projekt "%s"',
+ 'Nobody assigned' => 'Nikt nie przypisany',
+ 'Assigned to %s' => 'Przypisane do %s',
+ 'Remove a column' => 'Usuń kolumnę',
+ 'Remove a column from a board' => 'Usuń kolumnę z tablicy',
+ 'Unable to remove this column.' => 'Nie udało się usunąć kolumny.',
+ 'Do you really want to remove this column: "%s"?' => 'Na pewno chcesz usunąć kolumnę: "%s"?',
+ 'This action will REMOVE ALL TASKS associated to this column!' => 'Wszystkie zadania w kolumnie zostaną usunięte!',
+ 'Settings' => 'Ustawienia',
+ 'Application settings' => 'Ustawienia aplikacji',
+ 'Language' => 'Język',
+ 'Webhooks token:' => 'Token :',
+ 'More information' => 'Więcej informacji',
+ 'Database size:' => 'Rozmiar bazy danych :',
+ 'Download the database' => 'Pobierz bazÄ™ danych',
+ 'Optimize the database' => 'Optymalizuj bazÄ™ danych',
+ '(VACUUM command)' => '(komenda VACUUM)',
+ '(Gzip compressed Sqlite file)' => '(baza danych spakowana Gzip)',
+ 'User settings' => 'Ustawienia użytkownika',
+ 'My default project:' => 'Mój domyślny projekt:',
+ 'Close a task' => 'Zakończ zadanie',
+ 'Do you really want to close this task: "%s"?' => 'Na pewno chcesz zakończyć to zadanie: "%s"?',
+ 'Edit a task' => 'Edytuj zadanie',
+ 'Column' => 'Kolumna',
+ 'Color' => 'Kolor',
+ 'Assignee' => 'Odpowiedzialny',
+ 'Create another task' => 'Dodaj kolejne zadanie',
+ 'New task' => 'Nowe zadanie',
+ 'Open a task' => 'Otwórz zadanie',
+ 'Do you really want to open this task: "%s"?' => 'Na pewno chcesz otworzyć zadanie: "%s"?',
+ 'Back to the board' => 'Powrót do tablicy',
+ 'Created on %B %e, %G at %k:%M %p' => 'Utworzono dnia %e %B %G o %k:%M',
+ 'There is nobody assigned' => 'Nikt nie jest przypisany',
+ 'Column on the board:' => 'Kolumna na tablicy:',
+ 'Status is open' => 'Status otwarty',
+ 'Status is closed' => 'Status zamknięty',
+ 'Close this task' => 'Zamknij zadanie',
+ 'Open this task' => 'Otwórz zadanie',
+ 'There is no description.' => 'Brak opisu.',
+ 'Add a new task' => 'Dodaj zadanie',
+ 'The username is required' => 'Nazwa użytkownika jest wymagana',
+ 'The maximum length is %d characters' => 'Maksymalna długość wynosi %d znaków',
+ 'The minimum length is %d characters' => 'Minimalna długość wynosi %d znaków',
+ 'The password is required' => 'Hasło jest wymagane',
+ 'This value must be an integer' => 'Wartość musi być liczbą całkowitą',
+ 'The username must be unique' => 'Nazwa użytkownika musi być unikalna',
+ 'The username must be alphanumeric' => 'Nazwa użytkownika musi być alfanumeryczna',
+ 'The user id is required' => 'ID użytkownika jest wymagane',
+ 'Passwords doesn\'t matches' => 'Hasła nie pasują do siebie',
+ 'The confirmation is required' => 'Wymagane jest potwierdzenie',
+ 'The column is required' => 'Kolumna jest wymagana',
+ 'The project is required' => 'Projekt jest wymagany',
+ 'The color is required' => 'Kolor jest wymagany',
+ 'The id is required' => 'ID jest wymagane',
+ 'The project id is required' => 'ID projektu jest wymagane',
+ 'The project name is required' => 'Nazwa projektu jest wymagana',
+ 'This project must be unique' => 'Projekt musi być unikalny',
+ 'The title is required' => 'Tutył jest wymagany',
+ 'The language is required' => 'Język jest wymagany',
+ 'There is no active project, the first step is to create a new project.' => 'Brak aktywnych projektów. Pierwszym krokiem jest utworzenie nowego projektu.',
+ 'Settings saved successfully.' => 'Ustawienia zapisane.',
+ 'Unable to save your settings.' => 'Nie udało się zapisać ustawień.',
+ 'Database optimization done.' => 'Optymalizacja bazy danych zakończona.',
+ 'Your project have been created successfully.' => 'Projekt został pomyślnie utworzony.',
+ 'Unable to create your project.' => 'Nie udało się stworzyć projektu.',
+ 'Project updated successfully.' => 'Projekt zaktualizowany.',
+ 'Unable to update this project.' => 'Nie można zaktualizować projektu.',
+ 'Unable to remove this project.' => 'Nie można usunąć projektu.',
+ 'Project removed successfully.' => 'Projekt usunięty.',
+ 'Project activated successfully.' => 'Projekt aktywowany.',
+ 'Unable to activate this project.' => 'Nie można aktywować projektu.',
+ 'Project disabled successfully.' => 'Projekt wyłączony.',
+ 'Unable to disable this project.' => 'Nie można wyłączyć projektu.',
+ 'Unable to open this task.' => 'Nie można otworzyć tego zadania.',
+ 'Task opened successfully.' => 'Zadanie otwarte.',
+ 'Unable to close this task.' => 'Nie można zamknąć tego zadania.',
+ 'Task closed successfully.' => 'Zadanie zamknięte.',
+ 'Unable to update your task.' => 'Nie można zaktualizować tego zadania.',
+ 'Task updated successfully.' => 'Zadanie zaktualizowane.',
+ 'Unable to create your task.' => 'Nie można dodać zadania.',
+ 'Task created successfully.' => 'Zadanie zostało utworzone.',
+ 'User created successfully.' => 'Użytkownik dodany',
+ 'Unable to create your user.' => 'Nie udało się dodać użytkownika.',
+ 'User updated successfully.' => 'Użytkownik zaktualizowany.',
+ 'Unable to update your user.' => 'Nie udało się zaktualizować użytkownika.',
+ 'User removed successfully.' => 'Użytkownik usunięty.',
+ 'Unable to remove this user.' => 'Nie udało się usunąć użytkownika.',
+ 'Board updated successfully.' => 'Tablica została zaktualizowana.',
+ 'Ready' => 'Gotowe',
+ 'Backlog' => 'Log',
+ 'Work in progress' => 'W trakcie',
+ 'Done' => 'Zakończone',
+ 'Application version:' => 'Wersja aplikacji:',
+ 'Completed on %B %e, %G at %k:%M %p' => 'Zakończono dnia %e %B %G o %k:%M',
+ '%B %e, %G at %k:%M %p' => '%e %B %G o %k:%M',
+ 'Date created' => 'Data utworzenia',
+ 'Date completed' => 'Data zakończenia',
+ 'Id' => 'Ident',
+ 'No task' => 'Brak zadań',
+ 'Completed tasks' => 'Ukończone zadania',
+ 'List of projects' => 'Lista projektów',
+ 'Completed tasks for "%s"' => 'Zadania zakończone dla "%s"',
+ '%d closed tasks' => '%d zamkniętych zadań',
+ 'no task for this project' => 'brak zadań dla tego projektu',
+ 'Public link' => 'Link publiczny',
+ 'There is no column in your project!' => 'Brak kolumny w Twoim projekcie',
+ 'Change assignee' => 'Zmień odpowiedzialną osobę',
+ 'Change assignee for the task "%s"' => 'Zmień odpowiedzialną osobę dla zadania "%s"',
+ 'Timezone' => 'Strefa czasowa',
+ 'Actions' => 'Akcje',
+ 'Confirmation' => 'Powtórzenie hasła',
+ 'Description' => 'Opis',
+ 'Details' => 'Informacje',
+ 'Sorry, I didn\'t found this information in my database!' => 'Niestety nie znaleziono tej informacji w bazie danych',
+ 'Page not found' => 'Strona nie istnieje',
+ 'Story Points' => 'Poziom trudności',
+ 'limit' => 'limit',
+ 'Task limit' => 'Limit zadań',
+ 'This value must be greater than %d' => 'Wartość musi być większa niż %d',
+ 'Edit project access list' => 'Edycja list dostępu dla projektu',
+ 'Edit users access' => 'Edytuj dostęp',
+ 'Allow this user' => 'Dodaj użytkownika',
+ 'Project access list for "%s"' => 'Lista uprawnionych dla projektu "%s"',
+ 'Only those users have access to this project:' => 'Użytkownicy mający dostęp:',
+ 'Don\'t forget that administrators have access to everything.' => 'Pamiętaj: Administratorzy mają zawsze dostęp do wszystkiego!',
+ 'revoke' => 'odbierz dostęp',
+ 'List of authorized users' => 'Lista użytkowników mających dostęp',
+ 'User' => 'Użytkownik',
+ 'Everybody have access to this project.' => 'Każdy ma dostęp do tego projektu.',
+ 'You are not allowed to access to this project.' => 'Nie masz dostępu do tego projektu.',
+ '%B %e, %G at %k:%M %p' => '%e %B %G o %k:%M',
+ 'Comments' => 'Komentarze',
+ 'Post comment' => 'Dodaj komentarz',
+ 'Write your text in Markdown' => 'Możesz użyć Markdown',
+ 'Leave a comment' => 'Zostaw komentarz',
+ 'Comment is required' => 'Komentarz jest wymagany',
+ 'Comment added successfully.' => 'Komentarz dodany',
+ 'Unable to create your comment.' => 'Nie udało się dodać komentarza',
+ 'The description is required' => 'Opis jest wymagany',
+ 'Edit this task' => 'Edytuj zadanie',
+ 'Due Date' => 'Termin',
+ 'm/d/Y' => 'd/m/Y', // Date format parsed with php
+ 'month/day/year' => 'dzień/miesiąc/rok', // Help shown to the user
+ 'Invalid date' => 'Błędna data',
+ 'Must be done before %B %e, %G' => 'Termin do %e %B %G',
+ '%B %e, %G' => '%e %B %G',
+ 'Automatic actions' => 'Akcje automatyczne',
+ 'Your automatic action have been created successfully.' => 'Twoja akcja została dodana',
+ 'Unable to create your automatic action.' => 'Nie udało się utworzyć akcji',
+ 'Remove an action' => 'Usuń akcję',
+ 'Unable to remove this action.' => 'Nie można usunąć akcji',
+ 'Action removed successfully.' => 'Akcja usunięta',
+ 'Automatic actions for the project "%s"' => 'Akcje automatyczne dla projektu "%s"',
+ 'Defined actions' => 'Zdefiniowane akcje',
+ 'Event name' => 'Nazwa zdarzenia',
+ 'Action name' => 'Nazwa akcji',
+ 'Action parameters' => 'Parametry akcji',
+ 'Action' => 'Akcja',
+ 'Event' => 'Zdarzenie',
+ 'When the selected event occurs execute the corresponding action.' => 'Gdy następuje wybrane zdarzenie, uruchom odpowiednią akcję',
+ 'Next step' => 'Następny krok',
+ 'Define action parameters' => 'Zdefiniuj parametry akcji',
+ 'Save this action' => 'Zapisz akcjÄ™',
+ 'Do you really want to remove this action: "%s"?' => 'Na pewno chcesz usunąć akcję "%s"?',
+ 'Remove an automatic action' => 'Usuń akcję automatyczną',
+ 'Close the task' => 'Zamknij zadanie',
+ 'Assign the task to a specific user' => 'Przypisz zadanie do wybranego użytkownika',
+ 'Assign the task to the person who does the action' => 'Przypisz zadanie to osoby wykonujÄ…cej akcjÄ™',
+ 'Duplicate the task to another project' => 'Kopiuj zadanie do innego projektu',
+ 'Move a task to another column' => 'Przeniesienie zadania do innej kolumny',
+ 'Move a task to another position in the same column' => 'Zmiania pozycji zadania w kolumnie',
+ 'Task modification' => 'Modyfikacja zadania',
+ 'Task creation' => 'Tworzenie zadania',
+ 'Open a closed task' => 'Otwarcie zamkniętego zadania',
+ 'Closing a task' => 'Zamknięcie zadania',
+ 'Assign a color to a specific user' => 'Przypisz kolor do wybranego użytkownika',
+ 'Add an action' => 'Nowa akcja',
+ 'Column title' => 'Tytuł kolumny',
+ 'Position' => 'Pozycja',
+ 'Move Up' => 'Przenieś wyżej',
+ 'Move Down' => 'Przenieś niżej',
+ 'Duplicate to another project' => 'Skopiuj do innego projektu',
+ 'Duplicate' => 'Utwórz kopię',
+ 'link' => 'link',
+ 'Update this comment' => 'Zapisz komentarz',
+ 'Comment updated successfully.' => 'Komentarz został zapisany.',
+ 'Unable to update your comment.' => 'Nie udało się zapisanie komentarza.',
+ 'Remove a comment' => 'Usuń komentarz',
+ 'Comment removed successfully.' => 'Komentarz został usunięty.',
+ 'Unable to remove this comment.' => 'Nie udało się usunąć komentarza.',
+ 'Do you really want to remove this comment?' => 'Czy na pewno usunąć ten komentarz?',
+ 'Only administrators or the creator of the comment can access to this page.' => 'Tylko administratorzy oraz autor komentarza ma dostęp do tej strony.',
+ 'Details' => 'Szczegóły',
+ 'Current password for the user "%s"' => 'Aktualne hasło dla użytkownika "%s"',
+ 'The current password is required' => 'Wymanage jest aktualne hasło',
+ 'Wrong password' => 'Błędne hasło',
+ 'Reset all tokens' => 'Zresetuj wszystkie tokeny',
+ 'All tokens have been regenerated.' => 'Wszystkie tokeny zostały zresetowane.',
+ 'Unknown' => 'Nieznany',
+ 'Last logins' => 'Ostatnie logowania',
+ 'Login date' => 'Data logowania',
+ 'Authentication method' => 'Sposób autentykacji',
+ 'IP address' => 'Adres IP',
+ 'User agent' => 'PrzeglÄ…darka',
+ 'Persistent connections' => 'Stałe połączenia',
+ 'No session' => 'Brak sesji',
+ 'Expiration date' => 'Data zakończenia',
+ 'Remember Me' => 'Pamiętaj mnie',
+ 'Creation date' => 'Data utworzenia',
+ // 'Filter by user' => '',
+ // 'Filter by due date' => ',
+ // 'Everybody' => '',
+ // 'Open' => '',
+ // 'Closed' => '',
+ // 'Search' => '',
+ // 'Nothing found.' => '',
+ // 'Search in the project "%s"' => '',
+ // 'Due date' => '',
+ // 'Others formats accepted: %s and %s' => '',
+ // 'Description' => '',
+ // '%d comments' => '',
+ // '%d comment' => '',
+ // 'Email address invalid' => '',
+ // 'Your Google Account is not linked anymore to your profile.' => '',
+ // 'Unable to unlink your Google Account.' => '',
+ // 'Google authentication failed' => '',
+ // 'Unable to link your Google Account.' => '',
+ // 'Your Google Account is linked to your profile successfully.' => '',
+ // 'Email' => '',
+ // 'Link my Google Account' => '',
+ // 'Unlink my Google Account' => '',
+ // 'Login with my Google Account' => '',
+ // 'Project not found.' => '',
+ // 'Task #%d' => '',
+ // 'Task removed successfully.' => '',
+ // 'Unable to remove this task.' => '',
+ // 'Remove a task' => '',
+ // 'Do you really want to remove this task: "%s"?' => '',
+ // 'Assign a color to a specific category' => '',
+ // 'Task creation or modification' => '',
+ // 'Category' => '',
+ // 'Category:' => '',
+ // 'Categories' => '',
+ // 'Category not found.' => '',
+ // 'Your category have been created successfully.' => '',
+ // 'Unable to create your category.' => '',
+ // 'Your category have been updated successfully.' => '',
+ // 'Unable to update your category.' => '',
+ // 'Remove a category' => '',
+ // 'Category removed successfully.' => '',
+ // 'Unable to remove this category.' => '',
+ // 'Category modification for the project "%s"' => '',
+ // 'Category Name' => '',
+ // 'Categories for the project "%s"' => '',
+ // 'Add a new category' => '',
+ // 'Do you really want to remove this category: "%s"?' => '',
+ // 'Filter by category' => '',
+ // 'All categories' => '',
+ // 'No category' => '',
+ // 'The name is required' => '',
+);
diff --git a/app/Locales/pt_BR/translations.php b/app/Locales/pt_BR/translations.php
new file mode 100644
index 00000000..7c9a6c17
--- /dev/null
+++ b/app/Locales/pt_BR/translations.php
@@ -0,0 +1,335 @@
+<?php
+
+return array(
+ 'English' => 'Inglês',
+ 'French' => 'Francês',
+ 'Polish' => 'Polonês',
+ 'Portuguese (Brazilian)' => 'Português (Brasil)',
+ 'Spanish' => 'Espanhol',
+ 'None' => 'Nenhum',
+ 'edit' => 'editar',
+ 'Edit' => 'Editar',
+ 'remove' => 'apagar',
+ 'Remove' => 'Apagar',
+ 'Update' => 'Atualizar',
+ 'Yes' => 'Sim',
+ 'No' => 'Não',
+ 'cancel' => 'cancelar',
+ 'or' => 'ou',
+ 'Yellow' => 'Amarelo',
+ 'Blue' => 'Azul',
+ 'Green' => 'Verde',
+ 'Purple' => 'Violeta',
+ 'Red' => 'Vermelho',
+ 'Orange' => 'Laranja',
+ 'Grey' => 'Cinza',
+ 'Save' => 'Salvar',
+ 'Login' => 'Login',
+ 'Official website:' => 'Site web oficial :',
+ 'Unassigned' => 'Não Atribuída',
+ 'View this task' => 'Ver esta tarefa',
+ 'Remove user' => 'Remover usuário',
+ 'Do you really want to remove this user: "%s"?' => 'Quer realmente remover este usuário: "%s"?',
+ 'New user' => 'Novo usuário',
+ 'All users' => 'Todos os usuários',
+ 'Username' => 'Nome do usuário',
+ 'Password' => 'Senha',
+ 'Default Project' => 'Projeto default',
+ 'Administrator' => 'Administrador',
+ 'Sign in' => 'Logar',
+ 'Users' => 'Usuários',
+ 'No user' => 'Sem usuário',
+ 'Forbidden' => 'Proibido',
+ 'Access Forbidden' => 'Acesso negado',
+ 'Only administrators can access to this page.' => 'Somente administradores têm acesso a esta página.',
+ 'Edit user' => 'Editar usuário',
+ 'Logout' => 'Logout',
+ 'Bad username or password' => 'Usuário ou senha inválidos',
+ 'users' => 'usuários',
+ 'projects' => 'projetos',
+ 'Edit project' => 'Editar projeto',
+ 'Name' => 'Nome',
+ 'Activated' => 'Ativo',
+ 'Projects' => 'Projetos',
+ 'No project' => 'Nenhum projeto',
+ 'Project' => 'Projeto',
+ 'Status' => 'Status',
+ 'Tasks' => 'Tarefas',
+ 'Board' => 'Quadro',
+ 'Inactive' => 'Inativo',
+ 'Active' => 'Ativo',
+ 'Column %d' => 'Coluna %d',
+ 'Add this column' => 'Adicionar esta coluna',
+ '%d tasks on the board' => '%d tarefas no quadro',
+ '%d tasks in total' => '%d tarefas no total',
+ 'Unable to update this board.' => 'Impossível atualizar este quadro.',
+ 'Edit board' => 'Modificar quadro',
+ 'Disable' => 'Desativar',
+ 'Enable' => 'Ativar',
+ 'New project' => 'Novo projeto',
+ 'Do you really want to remove this project: "%s"?' => 'Quer realmente remover este projeto: "%s" ?',
+ 'Remove project' => 'Remover projeto',
+ 'Boards' => 'Quadros',
+ 'Edit the board for "%s"' => 'Editar o quadro para "%s"',
+ 'All projects' => 'Todos os projetos',
+ 'Change columns' => 'Modificar colunas',
+ 'Add a new column' => 'Adicionar uma nova coluna',
+ 'Title' => 'Título',
+ 'Add Column' => 'Adicionar coluna',
+ 'Project "%s"' => 'Projeto "%s"',
+ 'Nobody assigned' => 'Ninguém designado',
+ 'Assigned to %s' => 'Designado para %s',
+ 'Remove a column' => 'Remover uma coluna',
+ 'Remove a column from a board' => 'Remover uma coluna do quadro',
+ 'Unable to remove this column.' => 'Impossível remover esta coluna.',
+ 'Do you really want to remove this column: "%s"?' => 'Quer realmente remover esta coluna: "%s"?',
+ 'This action will REMOVE ALL TASKS associated to this column!' => 'Esta ação vai REMOVER TODAS AS TAREFAS associadas a esta coluna!',
+ 'Settings' => 'Preferências',
+ 'Application settings' => 'Preferências da aplicação',
+ 'Language' => 'Idioma',
+ 'Webhooks token:' => 'Token de webhooks:',
+ 'More information' => 'Mais informação',
+ 'Database size:' => 'Tamanho do banco de dados:',
+ 'Download the database' => 'Download do banco de dados',
+ 'Optimize the database' => 'Otimizar o banco de dados',
+ '(VACUUM command)' => '(Comando VACUUM)',
+ '(Gzip compressed Sqlite file)' => '(Arquivo Sqlite comprimido com Gzip)',
+ 'User settings' => 'Configurações do usuário',
+ 'My default project:' => 'Meu projeto default:',
+ 'Close a task' => 'Encerrar uma tarefa',
+ 'Do you really want to close this task: "%s"?' => 'Quer realmente encerrar esta tarefa: "%s"?',
+ 'Edit a task' => 'Editar uma tarefa',
+ 'Column' => 'Coluna',
+ 'Color' => 'Cor',
+ 'Assignee' => 'Designação',
+ 'Create another task' => 'Criar uma outra tarefa (aproveitando os dados preenchidos)',
+ 'New task' => 'Nova tarefa',
+ 'Open a task' => 'Abrir uma tarefa',
+ 'Do you really want to open this task: "%s"?' => 'Quer realmente abrir esta tarefa: "%s"?',
+ 'Back to the board' => 'Voltar ao quadro',
+ 'Created on %B %e, %G at %k:%M %p' => 'Criado em %d %B %G às %H:%M',
+ 'There is nobody assigned' => 'Não há ninguém designado',
+ 'Column on the board:' => 'Coluna no quadro:',
+ 'Status is open' => 'Status está aberto',
+ 'Status is closed' => 'Status está fechado',
+ 'Close this task' => 'Fechar esta tarefa',
+ 'Open this task' => 'Abrir esta tarefa',
+ 'There is no description.' => 'Não há descrição.',
+ 'Add a new task' => 'Adicionar uma nova tarefa',
+ 'The username is required' => 'O nome de usuário é obrigatório',
+ 'The maximum length is %d characters' => 'O tamanho máximo são %d caracteres',
+ 'The minimum length is %d characters' => 'O tamanho mínimo são %d caracteres',
+ 'The password is required' => 'A senha é obrigatória',
+ 'This value must be an integer' => 'O valor deve ser um inteiro',
+ 'The username must be unique' => 'O nome de usuário deve ser único',
+ 'The username must be alphanumeric' => 'O nome de usuário deve ser alfanumérico, sem espaços ou _',
+ 'The user id is required' => 'O id de usuário é obrigatório',
+ 'Passwords doesn\'t matches' => 'As senhas não conferem',
+ 'The confirmation is required' => 'A confirmação é obrigatória',
+ 'The column is required' => 'A coluna é obrigatória',
+ 'The project is required' => 'O projeto é obrigatório',
+ 'The color is required' => 'A cor é obrigatória',
+ 'The id is required' => 'O id é obrigatório',
+ 'The project id is required' => 'O id do projeto é obrigatório',
+ 'The project name is required' => 'O nome do projeto é obrigatório',
+ 'This project must be unique' => 'Este projeto deve ser único',
+ 'The title is required' => 'O título é obrigatório',
+ 'The language is required' => 'O idioma é obrigatório',
+ 'There is no active project, the first step is to create a new project.' => 'Não há projeto ativo. O primeiro passo é criar um novo projeto.',
+ 'Settings saved successfully.' => 'Configurações salvas com sucesso.',
+ 'Unable to save your settings.' => 'Impossível salvar suas configurações.',
+ 'Database optimization done.' => 'Otimização do banco de dados terminada.',
+ 'Your project have been created successfully.' => 'Seu projeto foi criado com sucesso.',
+ 'Unable to create your project.' => 'Impossível criar seu projeto.',
+ 'Project updated successfully.' => 'Projeto atualizado com sucesso.',
+ 'Unable to update this project.' => 'Impossível atualizar este projeto.',
+ 'Unable to remove this project.' => 'Impossível remover este projeto.',
+ 'Project removed successfully.' => 'Projeto removido com sucesso.',
+ 'Project activated successfully.' => 'Projeto ativado com sucesso.',
+ 'Unable to activate this project.' => 'Impossível ativar este projeto.',
+ 'Project disabled successfully.' => 'Projeto desabilitado com sucesso.',
+ 'Unable to disable this project.' => 'Impossível desabilitar este projeto.',
+ 'Unable to open this task.' => 'Impossível abrir esta tarefa.',
+ 'Task opened successfully.' => 'Tarefa aberta com sucesso.',
+ 'Unable to close this task.' => 'Impossível encerrar esta tarefa.',
+ 'Task closed successfully.' => 'Tarefa encerrada com sucesso.',
+ 'Unable to update your task.' => 'Impossível atualizar sua tarefa.',
+ 'Task updated successfully.' => 'Tarefa atualizada com sucesso.',
+ 'Unable to create your task.' => 'Impossível criar sua tarefa.',
+ 'Task created successfully.' => 'Tarefa criada com sucesso.',
+ 'User created successfully.' => 'Usuário criado com sucesso.',
+ 'Unable to create your user.' => 'Impossível criar seu usuário.',
+ 'User updated successfully.' => 'Usuário atualizado com sucesso.',
+ 'Unable to update your user.' => 'Impossível atualizar seu usuário.',
+ 'User removed successfully.' => 'Usuário removido com sucesso.',
+ 'Unable to remove this user.' => 'Impossível remover este usuário.',
+ 'Board updated successfully.' => 'Quadro atualizado com sucesso.',
+ 'Ready' => 'Pronto',
+ 'Backlog' => 'Backlog',
+ 'Work in progress' => 'Em andamento',
+ 'Done' => 'Encerrado',
+ 'Application version:' => 'Versão da aplicação:',
+ 'Completed on %B %e, %G at %k:%M %p' => 'Encerrado em %d %B %G às %H:%M',
+ '%B %e, %G at %k:%M %p' => '%d %B %G às %H:%M',
+ 'Date created' => 'Data de criação',
+ 'Date completed' => 'Data de encerramento',
+ 'Id' => 'Id',
+ 'No task' => 'Nenhuma tarefa',
+ 'Completed tasks' => 'tarefas completadas',
+ 'List of projects' => 'Lista de projetos',
+ 'Completed tasks for "%s"' => 'Tarefas completadas por "%s"',
+ '%d closed tasks' => '%d tarefas encerradas',
+ 'no task for this project' => 'nenhuma tarefa para este projeto',
+ 'Public link' => 'Link público',
+ 'There is no column in your project!' => 'Não há colunas no seu projeto!',
+ 'Change assignee' => 'Mudar a designação',
+ 'Change assignee for the task "%s"' => 'Modificar designação para a tarefa "%s"',
+ 'Timezone' => 'Fuso horário',
+ 'Sorry, I didn\'t found this information in my database!' => 'Desculpe, não encontrei esta informação no meu banco de dados!',
+ 'Page not found' => 'Página não encontrada',
+ 'Story Points' => 'Complexidade',
+ 'limit' => 'limite',
+ 'Task limit' => 'Limite da tarefa',
+ 'This value must be greater than %d' => 'Este valor deve ser maior que %d',
+ 'Edit project access list' => 'Editar lista de acesso ao projeto', // new translations to brazilian portuguese starts here
+ 'Edit users access' => 'Editar acesso de usuários',
+ 'Allow this user' => 'Permitir esse usuário',
+ 'Project access list for "%s"' => 'Lista de acesso ao projeto para "%s"',
+ 'Only those users have access to this project:' => 'Somente estes usuários têm acesso a este projeto:',
+ 'Don\'t forget that administrators have access to everything.' => 'Não esqueça que administradores têm acesso a tudo.',
+ 'revoke' => 'revogar',
+ 'List of authorized users' => 'Lista de usuários autorizados',
+ 'User' => 'Usuário',
+ 'Everybody have access to this project.' => 'Todos têm acesso a este projeto.',
+ 'You are not allowed to access to this project.' => 'Você não está autorizado a acessar este projeto.',
+ '%B %e, %G at %k:%M %p' => '%d %B %G às %H:%M',
+ 'Comments' => 'Comentários',
+ 'Post comment' => 'Postar comentário',
+ 'Write your text in Markdown' => 'Escreva seu texto em Markdown',
+ 'Leave a comment' => 'Deixe um comentário',
+ 'Comment is required' => 'Comentário é obrigatório',
+ 'Leave a description' => 'Deixe uma descrição',
+ 'Comment added successfully.' => 'Cpmentário adicionado com sucesso.',
+ 'Unable to create your comment.' => 'Impossível criar seu comentário.',
+ 'The description is required' => 'A descrição é obrigatória',
+ 'Edit this task' => 'Editar esta tarefa',
+ 'Due Date' => 'Data de vencimento',
+ 'm/d/Y' => 'd/m/Y', // Date format parsed with php
+ 'month/day/year' => 'dia/mês/ano', // Help shown to the user
+ 'Invalid date' => 'Data inválida',
+ 'Must be done before %B %e, %G' => 'Deve ser feito antes de %d %B %G',
+ '%B %e, %G' => '%d %B %G',
+ 'Automatic actions' => 'Ações automáticas',
+ 'Your automatic action have been created successfully.' => 'Sua ação automética foi criada com sucesso.',
+ 'Unable to create your automatic action.' => 'Impossível criar sua ação automática.',
+ 'Remove an action' => 'Remover uma ação',
+ 'Unable to remove this action.' => 'Impossível remover esta ação',
+ 'Action removed successfully.' => 'Ação removida com sucesso.',
+ 'Automatic actions for the project "%s"' => 'Ações automáticas para o projeto "%s"',
+ 'Defined actions' => 'Ações definidas',
+ 'Event name' => 'Nome do evento',
+ 'Action name' => 'Nome da ação',
+ 'Action parameters' => 'Parâmetros da ação',
+ 'Action' => 'Ação',
+ 'Event' => 'Evento',
+ 'When the selected event occurs execute the corresponding action.' => 'Quando o evento selecionado ocorrer, execute a ação correspondente',
+ 'Next step' => 'Próximo passo',
+ 'Define action parameters' => 'Definir parêmetros da ação',
+ 'Save this action' => 'Salvar esta ação',
+ 'Do you really want to remove this action: "%s"?' => 'Você quer realmente remover esta ação: "%s"?',
+ 'Remove an automatic action' => 'Remove uma ação automática',
+ 'Close the task' => 'Fechar tarefa',
+ 'Assign the task to a specific user' => 'Designar a tarefa para um usuário específico',
+ 'Assign the task to the person who does the action' => 'Designar a tarefa para a pessoa que executa a ação',
+ 'Duplicate the task to another project' => 'Duplicar a tarefa para um outro projeto',
+ 'Move a task to another column' => 'Mover a tarefa para outra coluna',
+ 'Move a task to another position in the same column' => 'Mover a tarefa para outra posição, na mesma coluna',
+ 'Task modification' => 'Modificação de tarefa',
+ 'Task creation' => 'Criação de tarefa',
+ 'Open a closed task' => 'Reabrir uma tarefa fechada',
+ 'Closing a task' => 'Fechando uma tarefa',
+ 'Assign a color to a specific user' => 'Designar uma cor para um usuário específico',
+ // 'Column title' => '',
+ // 'Position' => '',
+ // 'Move Up' => '',
+ // 'Move Down' => '',
+ // 'Duplicate to another project' => '',
+ // 'Duplicate' => '',
+ // 'link' => '',
+ // 'Update this comment' => '',
+ // 'Comment updated successfully.' => '',
+ // 'Unable to update your comment.' => '',
+ // 'Remove a comment' => '',
+ // 'Comment removed successfully.' => '',
+ // 'Unable to remove this comment.' => '',
+ // 'Do you really want to remove this comment?' => '',
+ // 'Only administrators or the creator of the comment can access to this page.' => '',
+ // 'Details' => '',
+ // 'Current password for the user "%s"' => '',
+ // 'The current password is required' => '',
+ // 'Wrong password' => '',
+ // 'Reset all tokens' => '',
+ // 'All tokens have been regenerated.' => '',
+ // 'Unknown' => '',
+ // 'Last logins' => '',
+ // 'Login date' => '',
+ // 'Authentication method' => '',
+ // 'IP address' => '',
+ // 'User agent' => '',
+ // 'Persistent connections' => '',
+ // 'No session' => '',
+ // 'Expiration date' => '',
+ // 'Remember Me' => '',
+ // 'Creation date' => '',
+ // 'Filter by user' => '',
+ // 'Filter by due date' => ',
+ // 'Everybody' => '',
+ // 'Open' => '',
+ // 'Closed' => '',
+ // 'Search' => '',
+ // 'Nothing found.' => '',
+ // 'Search in the project "%s"' => '',
+ // 'Due date' => '',
+ // 'Others formats accepted: %s and %s' => '',
+ // 'Description' => '',
+ // '%d comments' => '',
+ // '%d comment' => '',
+ // 'Email address invalid' => '',
+ // 'Your Google Account is not linked anymore to your profile.' => '',
+ // 'Unable to unlink your Google Account.' => '',
+ // 'Google authentication failed' => '',
+ // 'Unable to link your Google Account.' => '',
+ // 'Your Google Account is linked to your profile successfully.' => '',
+ // 'Email' => '',
+ // 'Link my Google Account' => '',
+ // 'Unlink my Google Account' => '',
+ // 'Login with my Google Account' => '',
+ // 'Project not found.' => '',
+ // 'Task #%d' => '',
+ // 'Task removed successfully.' => '',
+ // 'Unable to remove this task.' => '',
+ // 'Remove a task' => '',
+ // 'Do you really want to remove this task: "%s"?' => '',
+ // 'Assign a color to a specific category' => '',
+ // 'Task creation or modification' => '',
+ // 'Category' => '',
+ // 'Category:' => '',
+ // 'Categories' => '',
+ // 'Category not found.' => '',
+ // 'Your category have been created successfully.' => '',
+ // 'Unable to create your category.' => '',
+ // 'Your category have been updated successfully.' => '',
+ // 'Unable to update your category.' => '',
+ // 'Remove a category' => '',
+ // 'Category removed successfully.' => '',
+ // 'Unable to remove this category.' => '',
+ // 'Category modification for the project "%s"' => '',
+ // 'Category Name' => '',
+ // 'Categories for the project "%s"' => '',
+ // 'Add a new category' => '',
+ // 'Do you really want to remove this category: "%s"?' => '',
+ // 'Filter by category' => '',
+ // 'All categories' => '',
+ // 'No category' => '',
+ // 'The name is required' => '',
+);
diff --git a/app/Model/Acl.php b/app/Model/Acl.php
new file mode 100644
index 00000000..ad2118f4
--- /dev/null
+++ b/app/Model/Acl.php
@@ -0,0 +1,159 @@
+<?php
+
+namespace Model;
+
+/**
+ * Acl model
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class Acl extends Base
+{
+ /**
+ * Controllers and actions allowed from outside
+ *
+ * @access private
+ * @var array
+ */
+ private $public_actions = array(
+ 'user' => array('login', 'check', 'google'),
+ 'task' => array('add'),
+ 'board' => array('readonly'),
+ );
+
+ /**
+ * Controllers and actions allowed for regular users
+ *
+ * @access private
+ * @var array
+ */
+ private $user_actions = array(
+ 'app' => array('index'),
+ 'board' => array('index', 'show', 'assign', 'assigntask', 'save', 'check'),
+ 'project' => array('tasks', 'index', 'forbidden', 'search'),
+ 'task' => array('show', 'create', 'save', 'edit', 'update', 'close', 'confirmclose', 'open', 'confirmopen', 'description', 'duplicate', 'remove', 'confirmremove'),
+ 'comment' => array('save', 'confirm', 'remove', 'update', 'edit'),
+ 'user' => array('index', 'edit', 'update', 'forbidden', 'logout', 'index', 'unlinkgoogle'),
+ 'config' => array('index', 'removeremembermetoken'),
+ );
+
+ /**
+ * Return true if the specified controller/action is allowed according to the given acl
+ *
+ * @access public
+ * @param array $acl Acl list
+ * @param string $controller Controller name
+ * @param string $action Action name
+ * @return bool
+ */
+ public function isAllowedAction(array $acl, $controller, $action)
+ {
+ if (isset($acl[$controller])) {
+ return in_array($action, $acl[$controller]);
+ }
+
+ return false;
+ }
+
+ /**
+ * Return true if the given action is public
+ *
+ * @access public
+ * @param string $controller Controller name
+ * @param string $action Action name
+ * @return bool
+ */
+ public function isPublicAction($controller, $action)
+ {
+ return $this->isAllowedAction($this->public_actions, $controller, $action);
+ }
+
+ /**
+ * Return true if the given action is allowed for a regular user
+ *
+ * @access public
+ * @param string $controller Controller name
+ * @param string $action Action name
+ * @return bool
+ */
+ public function isUserAction($controller, $action)
+ {
+ return $this->isAllowedAction($this->user_actions, $controller, $action);
+ }
+
+ /**
+ * Return true if the logged user is admin
+ *
+ * @access public
+ * @return bool
+ */
+ public function isAdminUser()
+ {
+ return isset($_SESSION['user']['is_admin']) && $_SESSION['user']['is_admin'] === true;
+ }
+
+ /**
+ * Return true if the logged user is not admin
+ *
+ * @access public
+ * @return bool
+ */
+ public function isRegularUser()
+ {
+ return isset($_SESSION['user']['is_admin']) && $_SESSION['user']['is_admin'] === false;
+ }
+
+ /**
+ * Get the connected user id
+ *
+ * @access public
+ * @return integer
+ */
+ public function getUserId()
+ {
+ return isset($_SESSION['user']['id']) ? (int) $_SESSION['user']['id'] : 0;
+ }
+
+ /**
+ * Check is the user is connected
+ *
+ * @access public
+ * @return bool
+ */
+ public function isLogged()
+ {
+ return ! empty($_SESSION['user']);
+ }
+
+ /**
+ * Check is the user was authenticated with the RememberMe or set the value
+ *
+ * @access public
+ * @param bool $value Set true if the user use the RememberMe
+ * @return bool
+ */
+ public function isRememberMe($value = null)
+ {
+ if ($value !== null) {
+ $_SESSION['is_remember_me'] = $value;
+ }
+
+ return empty($_SESSION['is_remember_me']) ? false : $_SESSION['is_remember_me'];
+ }
+
+ /**
+ * Check if an action is allowed for the logged user
+ *
+ * @access public
+ * @param string $controller Controller name
+ * @param string $action Action name
+ * @return bool
+ */
+ public function isPageAccessAllowed($controller, $action)
+ {
+ return $this->isPublicAction($controller, $action) ||
+ $this->isAdminUser() ||
+ ($this->isRegularUser() && $this->isUserAction($controller, $action));
+ }
+}
diff --git a/app/Model/Action.php b/app/Model/Action.php
new file mode 100644
index 00000000..d1b97ebc
--- /dev/null
+++ b/app/Model/Action.php
@@ -0,0 +1,267 @@
+<?php
+
+namespace Model;
+
+use LogicException;
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+
+/**
+ * Action model
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class Action extends Base
+{
+ /**
+ * SQL table name for actions
+ *
+ * @var string
+ */
+ const TABLE = 'actions';
+
+ /**
+ * SQL table name for action parameters
+ *
+ * @var string
+ */
+ const TABLE_PARAMS = 'action_has_params';
+
+ /**
+ * Return the name and description of available actions
+ *
+ * @access public
+ * @return array
+ */
+ public function getAvailableActions()
+ {
+ return array(
+ 'TaskClose' => t('Close the task'),
+ 'TaskAssignSpecificUser' => t('Assign the task to a specific user'),
+ 'TaskAssignCurrentUser' => t('Assign the task to the person who does the action'),
+ 'TaskDuplicateAnotherProject' => t('Duplicate the task to another project'),
+ 'TaskAssignColorUser' => t('Assign a color to a specific user'),
+ 'TaskAssignColorCategory' => t('Assign a color to a specific category'),
+ );
+ }
+
+ /**
+ * Return the name and description of available actions
+ *
+ * @access public
+ * @return array
+ */
+ public function getAvailableEvents()
+ {
+ return array(
+ Task::EVENT_MOVE_COLUMN => t('Move a task to another column'),
+ Task::EVENT_MOVE_POSITION => t('Move a task to another position in the same column'),
+ Task::EVENT_UPDATE => t('Task modification'),
+ Task::EVENT_CREATE => t('Task creation'),
+ Task::EVENT_OPEN => t('Open a closed task'),
+ Task::EVENT_CLOSE => t('Closing a task'),
+ Task::EVENT_CREATE_UPDATE => t('Task creation or modification'),
+ );
+ }
+
+ /**
+ * Return actions and parameters for a given project
+ *
+ * @access public
+ * @return array
+ */
+ public function getAllByProject($project_id)
+ {
+ $actions = $this->db->table(self::TABLE)->eq('project_id', $project_id)->findAll();
+
+ foreach ($actions as &$action) {
+ $action['params'] = $this->db->table(self::TABLE_PARAMS)->eq('action_id', $action['id'])->findAll();
+ }
+
+ return $actions;
+ }
+
+ /**
+ * Return all actions and parameters
+ *
+ * @access public
+ * @return array
+ */
+ public function getAll()
+ {
+ $actions = $this->db->table(self::TABLE)->findAll();
+
+ foreach ($actions as &$action) {
+ $action['params'] = $this->db->table(self::TABLE_PARAMS)->eq('action_id', $action['id'])->findAll();
+ }
+
+ return $actions;
+ }
+
+ /**
+ * Get all required action parameters for all registered actions
+ *
+ * @access public
+ * @return array All required parameters for all actions
+ */
+ public function getAllActionParameters()
+ {
+ $params = array();
+
+ foreach ($this->getAll() as $action) {
+
+ $action = $this->load($action['action_name'], $action['project_id']);
+ $params += $action->getActionRequiredParameters();
+ }
+
+ return $params;
+ }
+
+ /**
+ * Fetch an action
+ *
+ * @access public
+ * @param integer $action_id Action id
+ * @return array Action data
+ */
+ public function getById($action_id)
+ {
+ $action = $this->db->table(self::TABLE)->eq('id', $action_id)->findOne();
+ $action['params'] = $this->db->table(self::TABLE_PARAMS)->eq('action_id', $action_id)->findAll();
+
+ return $action;
+ }
+
+ /**
+ * Remove an action
+ *
+ * @access public
+ * @param integer $action_id Action id
+ * @return bool Success or not
+ */
+ public function remove($action_id)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $action_id)->remove();
+ }
+
+ /**
+ * Create an action
+ *
+ * @access public
+ * @param array $values Required parameters to save an action
+ * @return bool Success or not
+ */
+ public function create(array $values)
+ {
+ $this->db->startTransaction();
+
+ $action = array(
+ 'project_id' => $values['project_id'],
+ 'event_name' => $values['event_name'],
+ 'action_name' => $values['action_name'],
+ );
+
+ if (! $this->db->table(self::TABLE)->save($action)) {
+ $this->db->cancelTransaction();
+ return false;
+ }
+
+ $action_id = $this->db->getConnection()->getLastId();
+
+ foreach ($values['params'] as $param_name => $param_value) {
+
+ $action_param = array(
+ 'action_id' => $action_id,
+ 'name' => $param_name,
+ 'value' => $param_value,
+ );
+
+ if (! $this->db->table(self::TABLE_PARAMS)->save($action_param)) {
+ $this->db->cancelTransaction();
+ return false;
+ }
+ }
+
+ $this->db->closeTransaction();
+
+ return true;
+ }
+
+ /**
+ * Load all actions and attach events
+ *
+ * @access public
+ */
+ public function attachEvents()
+ {
+ foreach ($this->getAll() as $action) {
+
+ $listener = $this->load($action['action_name'], $action['project_id']);
+
+ foreach ($action['params'] as $param) {
+ $listener->setParam($param['name'], $param['value']);
+ }
+
+ $this->event->attach($action['event_name'], $listener);
+ }
+ }
+
+ /**
+ * Load an action
+ *
+ * @access public
+ * @param string $name Action class name
+ * @param integer $project_id Project id
+ * @return mixed Action Instance
+ * @throw LogicException
+ */
+ public function load($name, $project_id)
+ {
+ switch ($name) {
+ case 'TaskClose':
+ $className = '\Action\TaskClose';
+ return new $className($project_id, new Task($this->db, $this->event));
+ case 'TaskAssignCurrentUser':
+ $className = '\Action\TaskAssignCurrentUser';
+ return new $className($project_id, new Task($this->db, $this->event), new Acl($this->db, $this->event));
+ case 'TaskAssignSpecificUser':
+ $className = '\Action\TaskAssignSpecificUser';
+ return new $className($project_id, new Task($this->db, $this->event));
+ case 'TaskDuplicateAnotherProject':
+ $className = '\Action\TaskDuplicateAnotherProject';
+ return new $className($project_id, new Task($this->db, $this->event));
+ case 'TaskAssignColorUser':
+ $className = '\Action\TaskAssignColorUser';
+ return new $className($project_id, new Task($this->db, $this->event));
+ case 'TaskAssignColorCategory':
+ $className = '\Action\TaskAssignColorCategory';
+ return new $className($project_id, new Task($this->db, $this->event));
+ default:
+ throw new LogicException('Action not found: '.$name);
+ }
+ }
+
+ /**
+ * Validate action creation
+ *
+ * @access public
+ * @param array $values Required parameters to save an action
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateCreation(array $values)
+ {
+ $v = new Validator($values, array(
+ new Validators\Required('project_id', t('The project id is required')),
+ new Validators\Integer('project_id', t('This value must be an integer')),
+ new Validators\Required('event_name', t('This value is required')),
+ new Validators\Required('action_name', t('This value is required')),
+ new Validators\Required('params', t('This value is required')),
+ ));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+}
diff --git a/app/Model/Base.php b/app/Model/Base.php
new file mode 100644
index 00000000..fef2ddbb
--- /dev/null
+++ b/app/Model/Base.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Model;
+
+require __DIR__.'/../../vendor/SimpleValidator/Validator.php';
+require __DIR__.'/../../vendor/SimpleValidator/Base.php';
+require __DIR__.'/../../vendor/SimpleValidator/Validators/Required.php';
+require __DIR__.'/../../vendor/SimpleValidator/Validators/Unique.php';
+require __DIR__.'/../../vendor/SimpleValidator/Validators/MaxLength.php';
+require __DIR__.'/../../vendor/SimpleValidator/Validators/MinLength.php';
+require __DIR__.'/../../vendor/SimpleValidator/Validators/Integer.php';
+require __DIR__.'/../../vendor/SimpleValidator/Validators/Equals.php';
+require __DIR__.'/../../vendor/SimpleValidator/Validators/AlphaNumeric.php';
+require __DIR__.'/../../vendor/SimpleValidator/Validators/GreaterThan.php';
+require __DIR__.'/../../vendor/SimpleValidator/Validators/Date.php';
+require __DIR__.'/../../vendor/SimpleValidator/Validators/Email.php';
+
+use Core\Event;
+use PicoDb\Database;
+
+/**
+ * Base model class
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+abstract class Base
+{
+ /**
+ * Database instance
+ *
+ * @access protected
+ * @var PicoDb
+ */
+ protected $db;
+
+ /**
+ * Event dispatcher instance
+ *
+ * @access protected
+ * @var Core\Event
+ */
+ protected $event;
+
+ /**
+ * Constructor
+ *
+ * @access public
+ * @param \PicoDb\Database $db Database instance
+ * @param \Core\Event $event Event dispatcher instance
+ */
+ public function __construct(Database $db, Event $event)
+ {
+ $this->db = $db;
+ $this->event = $event;
+ }
+
+ /**
+ * Generate a random token with different methods: openssl or /dev/urandom or fallback to uniqid()
+ *
+ * @static
+ * @access public
+ * @return string Random token
+ */
+ public static function generateToken()
+ {
+ if (function_exists('openssl_random_pseudo_bytes')) {
+ return bin2hex(\openssl_random_pseudo_bytes(16));
+ }
+ else if (ini_get('open_basedir') === '' && strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN') {
+ return hash('sha256', file_get_contents('/dev/urandom', false, null, 0, 30));
+ }
+
+ return hash('sha256', uniqid(mt_rand(), true));
+ }
+}
diff --git a/app/Model/Board.php b/app/Model/Board.php
new file mode 100644
index 00000000..59a98923
--- /dev/null
+++ b/app/Model/Board.php
@@ -0,0 +1,340 @@
+<?php
+
+namespace Model;
+
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+
+/**
+ * Board model
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class Board extends Base
+{
+ /**
+ * SQL table name
+ *
+ * @var string
+ */
+ const TABLE = 'columns';
+
+ /**
+ * Save task positions for each column
+ *
+ * @access public
+ * @param array $values [['task_id' => X, 'column_id' => X, 'position' => X], ...]
+ * @return boolean
+ */
+ public function saveTasksPosition(array $values)
+ {
+ $taskModel = new Task($this->db, $this->event);
+
+ $this->db->startTransaction();
+
+ foreach ($values as $value) {
+ if (! $taskModel->move($value['task_id'], $value['column_id'], $value['position'])) {
+ $this->db->cancelTransaction();
+ return false;
+ }
+ }
+
+ $this->db->closeTransaction();
+
+ return true;
+ }
+
+ /**
+ * Create a board with default columns, must be executed inside a transaction
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param array $columns List of columns title ['column1', 'column2', ...]
+ * @return boolean
+ */
+ public function create($project_id, array $columns)
+ {
+ $position = 0;
+
+ foreach ($columns as $title) {
+
+ $values = array(
+ 'title' => $title,
+ 'position' => ++$position,
+ 'project_id' => $project_id,
+ );
+
+ if (! $this->db->table(self::TABLE)->save($values)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Add a new column to the board
+ *
+ * @access public
+ * @param array $values ['title' => X, 'project_id' => X]
+ * @return boolean
+ */
+ public function add(array $values)
+ {
+ $values['position'] = $this->getLastColumnPosition($values['project_id']) + 1;
+ return $this->db->table(self::TABLE)->save($values);
+ }
+
+ /**
+ * Update columns
+ *
+ * @access public
+ * @param array $values Form values
+ * @return boolean
+ */
+ public function update(array $values)
+ {
+ $this->db->startTransaction();
+
+ foreach (array('title', 'task_limit') as $field) {
+ foreach ($values[$field] as $column_id => $field_value) {
+ $this->db->table(self::TABLE)->eq('id', $column_id)->update(array($field => $field_value));
+ }
+ }
+
+ $this->db->closeTransaction();
+
+ return true;
+ }
+
+ /**
+ * Move a column down, increment the column position value
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param integer $column_id Column id
+ * @return boolean
+ */
+ public function moveDown($project_id, $column_id)
+ {
+ $columns = $this->db->table(self::TABLE)->eq('project_id', $project_id)->asc('position')->listing('id', 'position');
+ $positions = array_flip($columns);
+
+ if (isset($columns[$column_id]) && $columns[$column_id] < count($columns)) {
+
+ $position = ++$columns[$column_id];
+ $columns[$positions[$position]]--;
+
+ $this->db->startTransaction();
+ $this->db->table(self::TABLE)->eq('id', $column_id)->update(array('position' => $position));
+ $this->db->table(self::TABLE)->eq('id', $positions[$position])->update(array('position' => $columns[$positions[$position]]));
+ $this->db->closeTransaction();
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Move a column up, decrement the column position value
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param integer $column_id Column id
+ * @return boolean
+ */
+ public function moveUp($project_id, $column_id)
+ {
+ $columns = $this->db->table(self::TABLE)->eq('project_id', $project_id)->asc('position')->listing('id', 'position');
+ $positions = array_flip($columns);
+
+ if (isset($columns[$column_id]) && $columns[$column_id] > 1) {
+
+ $position = --$columns[$column_id];
+ $columns[$positions[$position]]++;
+
+ $this->db->startTransaction();
+ $this->db->table(self::TABLE)->eq('id', $column_id)->update(array('position' => $position));
+ $this->db->table(self::TABLE)->eq('id', $positions[$position])->update(array('position' => $columns[$positions[$position]]));
+ $this->db->closeTransaction();
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Get all columns and tasks for a given project
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @return array
+ */
+ public function get($project_id, array $filters = array())
+ {
+ $this->db->startTransaction();
+
+ $columns = $this->getColumns($project_id);
+
+ $filters[] = array('column' => 'project_id', 'operator' => 'eq', 'value' => $project_id);
+ $filters[] = array('column' => 'is_active', 'operator' => 'eq', 'value' => Task::STATUS_OPEN);
+
+ $taskModel = new Task($this->db, $this->event);
+ $tasks = $taskModel->find($filters);
+
+ foreach ($columns as &$column) {
+
+ $column['tasks'] = array();
+
+ foreach ($tasks as &$task) {
+ if ($task['column_id'] == $column['id']) {
+ $column['tasks'][] = $task;
+ }
+ }
+ }
+
+ $this->db->closeTransaction();
+
+ return $columns;
+ }
+
+ /**
+ * Get the first column id for a given project
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @return integer
+ */
+ public function getFirstColumn($project_id)
+ {
+ return $this->db->table(self::TABLE)->eq('project_id', $project_id)->asc('position')->findOneColumn('id');
+ }
+
+ /**
+ * Get the list of columns sorted by position [ column_id => title ]
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @return array
+ */
+ public function getColumnsList($project_id)
+ {
+ return $this->db->table(self::TABLE)->eq('project_id', $project_id)->asc('position')->listing('id', 'title');
+ }
+
+ /**
+ * Get all columns sorted by position for a given project
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @return array
+ */
+ public function getColumns($project_id)
+ {
+ return $this->db->table(self::TABLE)->eq('project_id', $project_id)->asc('position')->findAll();
+ }
+
+ /**
+ * Get the number of columns for a given project
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @return integer
+ */
+ public function countColumns($project_id)
+ {
+ return $this->db->table(self::TABLE)->eq('project_id', $project_id)->count();
+ }
+
+ /**
+ * Get a column by the id
+ *
+ * @access public
+ * @param integer $column_id Column id
+ * @return array
+ */
+ public function getColumn($column_id)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $column_id)->findOne();
+ }
+
+ /**
+ * Get the position of the last column for a given project
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @return integer
+ */
+ public function getLastColumnPosition($project_id)
+ {
+ return (int) $this->db
+ ->table(self::TABLE)
+ ->eq('project_id', $project_id)
+ ->desc('position')
+ ->findOneColumn('position');
+ }
+
+ /**
+ * Remove a column and all tasks associated to this column
+ *
+ * @access public
+ * @param integer $column_id Column id
+ * @return boolean
+ */
+ public function removeColumn($column_id)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $column_id)->remove();
+ }
+
+ /**
+ * Validate column modification
+ *
+ * @access public
+ * @param array $columns Original columns List
+ * @param array $values Required parameters to update a column
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateModification(array $columns, array $values)
+ {
+ $rules = array();
+
+ foreach ($columns as $column_id => $column_title) {
+ $rules[] = new Validators\Integer('task_limit['.$column_id.']', t('This value must be an integer'));
+ $rules[] = new Validators\GreaterThan('task_limit['.$column_id.']', t('This value must be greater than %d', 0), 0);
+ $rules[] = new Validators\Required('title['.$column_id.']', t('The title is required'));
+ $rules[] = new Validators\MaxLength('title['.$column_id.']', t('The maximum length is %d characters', 50), 50);
+ }
+
+ $v = new Validator($values, $rules);
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate column creation
+ *
+ * @access public
+ * @param array $values Required parameters to save an action
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateCreation(array $values)
+ {
+ $v = new Validator($values, array(
+ new Validators\Required('project_id', t('The project id is required')),
+ new Validators\Integer('project_id', t('This value must be an integer')),
+ new Validators\Required('title', t('The title is required')),
+ new Validators\MaxLength('title', t('The maximum length is %d characters', 50), 50),
+ ));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+}
diff --git a/app/Model/Category.php b/app/Model/Category.php
new file mode 100644
index 00000000..9be37f9d
--- /dev/null
+++ b/app/Model/Category.php
@@ -0,0 +1,150 @@
+<?php
+
+namespace Model;
+
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+
+/**
+ * Category model
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class Category extends Base
+{
+ /**
+ * SQL table name
+ *
+ * @var string
+ */
+ const TABLE = 'project_has_categories';
+
+ /**
+ * Get a category by the id
+ *
+ * @access public
+ * @param integer $category_id Category id
+ * @return array
+ */
+ public function getById($category_id)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $category_id)->findOne();
+ }
+
+ /**
+ * Return the list of all categories
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param bool $prepend_none If true, prepend to the list the value 'None'
+ * @param bool $prepend_all If true, prepend to the list the value 'All'
+ * @return array
+ */
+ public function getList($project_id, $prepend_none = true, $prepend_all = false)
+ {
+ $listing = $this->db->table(self::TABLE)
+ ->eq('project_id', $project_id)
+ ->asc('name')
+ ->listing('id', 'name');
+
+ $prepend = array();
+
+ if ($prepend_all) {
+ $prepend[-1] = t('All categories');
+ }
+
+ if ($prepend_none) {
+ $prepend[0] = t('No category');
+ }
+
+ return $prepend + $listing;
+ }
+
+ /**
+ * Create a category
+ *
+ * @access public
+ * @param array $values Form values
+ * @return bool
+ */
+ public function create(array $values)
+ {
+ return $this->db->table(self::TABLE)->save($values);
+ }
+
+ /**
+ * Update a category
+ *
+ * @access public
+ * @param array $values Form values
+ * @return bool
+ */
+ public function update(array $values)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $values['id'])->save($values);
+ }
+
+ /**
+ * Remove a category
+ *
+ * @access public
+ * @param integer $category_id Category id
+ * @return bool
+ */
+ public function remove($category_id)
+ {
+ $this->db->startTransaction();
+ $r1 = $this->db->table(Task::TABLE)->eq('category_id', $category_id)->update(array('category_id' => 0));
+ $r2 = $this->db->table(self::TABLE)->eq('id', $category_id)->remove();
+ $this->db->closeTransaction();
+
+ return $r1 && $r2;
+ }
+
+ /**
+ * Validate category creation
+ *
+ * @access public
+ * @param array $array Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateCreation(array $values)
+ {
+ $v = new Validator($values, array(
+ new Validators\Required('project_id', t('The project id is required')),
+ new Validators\Integer('project_id', t('The project id must be an integer')),
+ new Validators\Required('name', t('The name is required')),
+ new Validators\MaxLength('name', t('The maximum length is %d characters', 50), 50)
+ ));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate category modification
+ *
+ * @access public
+ * @param array $array Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateModification(array $values)
+ {
+ $v = new Validator($values, array(
+ new Validators\Required('id', t('The id is required')),
+ new Validators\Integer('id', t('The id must be an integer')),
+ new Validators\Required('project_id', t('The project id is required')),
+ new Validators\Integer('project_id', t('The project id must be an integer')),
+ new Validators\Required('name', t('The name is required')),
+ new Validators\MaxLength('name', t('The maximum length is %d characters', 50), 50)
+ ));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+}
diff --git a/app/Model/Comment.php b/app/Model/Comment.php
new file mode 100644
index 00000000..b5102070
--- /dev/null
+++ b/app/Model/Comment.php
@@ -0,0 +1,171 @@
+<?php
+
+namespace Model;
+
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+
+/**
+ * Comment model
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class Comment extends Base
+{
+ /**
+ * SQL table name
+ *
+ * @var string
+ */
+ const TABLE = 'comments';
+
+ /**
+ * Get all comments for a given task
+ *
+ * @access public
+ * @param integer $task_id Task id
+ * @return array
+ */
+ public function getAll($task_id)
+ {
+ return $this->db
+ ->table(self::TABLE)
+ ->columns(
+ self::TABLE.'.id',
+ self::TABLE.'.date',
+ self::TABLE.'.task_id',
+ self::TABLE.'.user_id',
+ self::TABLE.'.comment',
+ User::TABLE.'.username'
+ )
+ ->join(User::TABLE, 'id', 'user_id')
+ ->orderBy(self::TABLE.'.date', 'ASC')
+ ->eq(self::TABLE.'.task_id', $task_id)
+ ->findAll();
+ }
+
+ /**
+ * Get a comment
+ *
+ * @access public
+ * @param integer $comment_id Comment id
+ * @return array
+ */
+ public function getById($comment_id)
+ {
+ return $this->db
+ ->table(self::TABLE)
+ ->columns(
+ self::TABLE.'.id',
+ self::TABLE.'.task_id',
+ self::TABLE.'.user_id',
+ self::TABLE.'.date',
+ self::TABLE.'.comment',
+ User::TABLE.'.username'
+ )
+ ->join(User::TABLE, 'id', 'user_id')
+ ->eq(self::TABLE.'.id', $comment_id)
+ ->findOne();
+ }
+
+ /**
+ * Get the number of comments for a given task
+ *
+ * @access public
+ * @param integer $task_id Task id
+ * @return integer
+ */
+ public function count($task_id)
+ {
+ return $this->db
+ ->table(self::TABLE)
+ ->eq(self::TABLE.'.task_id', $task_id)
+ ->count();
+ }
+
+ /**
+ * Save a comment in the database
+ *
+ * @access public
+ * @param array $values Form values
+ * @return boolean
+ */
+ public function create(array $values)
+ {
+ $values['date'] = time();
+
+ return $this->db->table(self::TABLE)->save($values);
+ }
+
+ /**
+ * Update a comment in the database
+ *
+ * @access public
+ * @param array $values Form values
+ * @return boolean
+ */
+ public function update(array $values)
+ {
+ return $this->db
+ ->table(self::TABLE)
+ ->eq('id', $values['id'])
+ ->update(array('comment' => $values['comment']));
+ }
+
+ /**
+ * Remove a comment
+ *
+ * @access public
+ * @param integer $comment_id Comment id
+ * @return boolean
+ */
+ public function remove($comment_id)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $comment_id)->remove();
+ }
+
+ /**
+ * Validate comment creation
+ *
+ * @access public
+ * @param array $values Required parameters to save an action
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateCreation(array $values)
+ {
+ $v = new Validator($values, array(
+ new Validators\Required('task_id', t('This value is required')),
+ new Validators\Integer('task_id', t('This value must be an integer')),
+ new Validators\Required('user_id', t('This value is required')),
+ new Validators\Integer('user_id', t('This value must be an integer')),
+ new Validators\Required('comment', t('Comment is required'))
+ ));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate comment modification
+ *
+ * @access public
+ * @param array $values Required parameters to save an action
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateModification(array $values)
+ {
+ $v = new Validator($values, array(
+ new Validators\Required('id', t('This value is required')),
+ new Validators\Integer('id', t('This value must be an integer')),
+ new Validators\Required('comment', t('Comment is required'))
+ ));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+}
diff --git a/app/Model/Config.php b/app/Model/Config.php
new file mode 100644
index 00000000..994f0bc8
--- /dev/null
+++ b/app/Model/Config.php
@@ -0,0 +1,182 @@
+<?php
+
+namespace Model;
+
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+
+/**
+ * Config model
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class Config extends Base
+{
+ /**
+ * SQL table name
+ *
+ * @var string
+ */
+ const TABLE = 'config';
+
+ /**
+ * Get available timezones
+ *
+ * @access public
+ * @return array
+ */
+ public function getTimezones()
+ {
+ $timezones = \timezone_identifiers_list();
+ return array_combine(array_values($timezones), $timezones);
+ }
+
+ /**
+ * Get available languages
+ *
+ * @access public
+ * @return array
+ */
+ public function getLanguages()
+ {
+ $languages = array(
+ 'en_US' => t('English'),
+ 'es_ES' => t('Spanish'),
+ 'fr_FR' => t('French'),
+ 'pl_PL' => t('Polish'),
+ 'pt_BR' => t('Portuguese (Brazilian)'),
+ );
+
+ asort($languages);
+
+ return $languages;
+ }
+
+ /**
+ * Get a config variable from the session or the database
+ *
+ * @access public
+ * @param string $name Parameter name
+ * @param string $default_value Default value of the parameter
+ * @return mixed
+ */
+ public function get($name, $default_value = '')
+ {
+ if (! isset($_SESSION['config'][$name])) {
+ $_SESSION['config'] = $this->getAll();
+ }
+
+ if (isset($_SESSION['config'][$name])) {
+ return $_SESSION['config'][$name];
+ }
+
+ return $default_value;
+ }
+
+ /**
+ * Get all settings
+ *
+ * @access public
+ * @return array
+ */
+ public function getAll()
+ {
+ return $this->db->table(self::TABLE)->findOne();
+ }
+
+ /**
+ * Save settings in the database
+ *
+ * @access public
+ * @param $values array Settings values
+ * @return boolean
+ */
+ public function save(array $values)
+ {
+ $_SESSION['config'] = $values;
+ return $this->db->table(self::TABLE)->update($values);
+ }
+
+ /**
+ * Reload settings in the session and the translations
+ *
+ * @access public
+ */
+ public function reload()
+ {
+ $_SESSION['config'] = $this->getAll();
+
+ $language = $this->get('language', 'en_US');
+ if ($language !== 'en_US') \Translator\load($language);
+ }
+
+ /**
+ * Validate settings modification
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateModification(array $values)
+ {
+ $v = new Validator($values, array(
+ new Validators\Required('language', t('The language is required')),
+ new Validators\Required('timezone', t('The timezone is required')),
+ ));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Optimize the Sqlite database
+ *
+ * @access public
+ * @return boolean
+ */
+ public function optimizeDatabase()
+ {
+ return $this->db->getconnection()->exec("VACUUM");
+ }
+
+ /**
+ * Compress the Sqlite database
+ *
+ * @access public
+ * @return string
+ */
+ public function downloadDatabase()
+ {
+ return gzencode(file_get_contents(DB_FILENAME));
+ }
+
+ /**
+ * Get the Sqlite database size in bytes
+ *
+ * @access public
+ * @return integer
+ */
+ public function getDatabaseSize()
+ {
+ return DB_DRIVER === 'sqlite' ? filesize(DB_FILENAME) : 0;
+ }
+
+ /**
+ * Regenerate all tokens (projects and webhooks)
+ *
+ * @access public
+ */
+ public function regenerateTokens()
+ {
+ $this->db->table(self::TABLE)->update(array('webhooks_token' => $this->generateToken()));
+
+ $projects = $this->db->table(Project::TABLE)->findAllByColumn('id');
+
+ foreach ($projects as $project_id) {
+ $this->db->table(Project::TABLE)->eq('id', $project_id)->update(array('token' => $this->generateToken()));
+ }
+ }
+}
diff --git a/app/Model/Google.php b/app/Model/Google.php
new file mode 100644
index 00000000..f5beb8f8
--- /dev/null
+++ b/app/Model/Google.php
@@ -0,0 +1,152 @@
+<?php
+
+namespace Model;
+
+require __DIR__.'/../../vendor/OAuth/bootstrap.php';
+
+use OAuth\Common\Storage\Session;
+use OAuth\Common\Consumer\Credentials;
+use OAuth\Common\Http\Uri\UriFactory;
+use OAuth\ServiceFactory;
+use OAuth\Common\Http\Exception\TokenResponseException;
+
+/**
+ * Google model
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class Google extends Base
+{
+ /**
+ * Authenticate a Google user
+ *
+ * @access public
+ * @param string $google_id Google unique id
+ * @return boolean
+ */
+ public function authenticate($google_id)
+ {
+ $userModel = new User($this->db, $this->event);
+ $user = $userModel->getByGoogleId($google_id);
+
+ if ($user) {
+
+ // Create the user session
+ $userModel->updateSession($user);
+
+ // Update login history
+ $lastLogin = new LastLogin($this->db, $this->event);
+ $lastLogin->create(
+ LastLogin::AUTH_GOOGLE,
+ $user['id'],
+ $userModel->getIpAddress(),
+ $userModel->getUserAgent()
+ );
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Unlink a Google account for a given user
+ *
+ * @access public
+ * @param integer $user_id User id
+ * @return boolean
+ */
+ public function unlink($user_id)
+ {
+ $userModel = new User($this->db, $this->event);
+
+ return $userModel->update(array(
+ 'id' => $user_id,
+ 'google_id' => '',
+ ));
+ }
+
+ /**
+ * Update the user table based on the Google profile information
+ *
+ * @access public
+ * @param integer $user_id User id
+ * @param array $profile Google profile
+ * @return boolean
+ */
+ public function updateUser($user_id, array $profile)
+ {
+ $userModel = new User($this->db, $this->event);
+
+ return $userModel->update(array(
+ 'id' => $user_id,
+ 'google_id' => $profile['id'],
+ 'email' => $profile['email'],
+ 'name' => $profile['name'],
+ ));
+ }
+
+ /**
+ * Get the Google service instance
+ *
+ * @access public
+ * @return \OAuth\OAuth2\Service\Google
+ */
+ public function getService()
+ {
+ $uriFactory = new UriFactory();
+ $currentUri = $uriFactory->createFromSuperGlobalArray($_SERVER);
+ $currentUri->setQuery('controller=user&action=google');
+
+ $storage = new Session(false);
+
+ $credentials = new Credentials(
+ GOOGLE_CLIENT_ID,
+ GOOGLE_CLIENT_SECRET,
+ $currentUri->getAbsoluteUri()
+ );
+
+ $serviceFactory = new ServiceFactory();
+
+ return $serviceFactory->createService(
+ 'google',
+ $credentials,
+ $storage,
+ array('userinfo_email', 'userinfo_profile')
+ );
+ }
+
+ /**
+ * Get the authorization URL
+ *
+ * @access public
+ * @return \OAuth\Common\Http\Uri\Uri
+ */
+ public function getAuthorizationUrl()
+ {
+ return $this->getService()->getAuthorizationUri();
+ }
+
+ /**
+ * Get Google profile information from the API
+ *
+ * @access public
+ * @param string $code Google authorization code
+ * @return bool|array
+ */
+ public function getGoogleProfile($code)
+ {
+ try {
+
+ $googleService = $this->getService();
+ $googleService->requestAccessToken($code);
+ return json_decode($googleService->request('https://www.googleapis.com/oauth2/v1/userinfo'), true);
+ }
+ catch (TokenResponseException $e) {
+ return false;
+ }
+
+ return false;
+ }
+}
diff --git a/app/Model/LastLogin.php b/app/Model/LastLogin.php
new file mode 100644
index 00000000..56739b48
--- /dev/null
+++ b/app/Model/LastLogin.php
@@ -0,0 +1,91 @@
+<?php
+
+namespace Model;
+
+/**
+ * LastLogin model
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class LastLogin extends Base
+{
+ /**
+ * SQL table name
+ *
+ * @var string
+ */
+ const TABLE = 'last_logins';
+
+ /**
+ * Number of connections to keep for history
+ *
+ * @var integer
+ */
+ const NB_LOGINS = 10;
+
+ /**
+ * Authentication methods
+ *
+ * @var string
+ */
+ const AUTH_DATABASE = 'database';
+ const AUTH_REMEMBER_ME = 'remember_me';
+ const AUTH_LDAP = 'ldap';
+ const AUTH_GOOGLE = 'google';
+
+ /**
+ * Create a new record
+ *
+ * @access public
+ * @param string $auth_type Authentication method
+ * @param integer $user_id User id
+ * @param string $ip IP Address
+ * @param string $user_agent User Agent
+ * @return array
+ */
+ public function create($auth_type, $user_id, $ip, $user_agent)
+ {
+ // Cleanup old sessions if necessary
+ $connections = $this->db
+ ->table(self::TABLE)
+ ->eq('user_id', $user_id)
+ ->desc('date_creation')
+ ->findAllByColumn('id');
+
+ if (count($connections) >= self::NB_LOGINS) {
+
+ $this->db->table(self::TABLE)
+ ->eq('user_id', $user_id)
+ ->notin('id', array_slice($connections, 0, self::NB_LOGINS - 1))
+ ->remove();
+ }
+
+ return $this->db
+ ->table(self::TABLE)
+ ->insert(array(
+ 'auth_type' => $auth_type,
+ 'user_id' => $user_id,
+ 'ip' => $ip,
+ 'user_agent' => $user_agent,
+ 'date_creation' => time(),
+ ));
+ }
+
+ /**
+ * Get the last connections for a given user
+ *
+ * @access public
+ * @param integer $user_id User id
+ * @return array
+ */
+ public function getAll($user_id)
+ {
+ return $this->db
+ ->table(self::TABLE)
+ ->eq('user_id', $user_id)
+ ->desc('date_creation')
+ ->columns('id', 'auth_type', 'ip', 'user_agent', 'date_creation')
+ ->findAll();
+ }
+}
diff --git a/app/Model/Ldap.php b/app/Model/Ldap.php
new file mode 100644
index 00000000..3359318c
--- /dev/null
+++ b/app/Model/Ldap.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace Model;
+
+/**
+ * LDAP model
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class Ldap extends Base
+{
+ /**
+ * Authenticate a user
+ *
+ * @access public
+ * @param string $username Username
+ * @param string $password Password
+ * @return null|boolean
+ */
+ public function authenticate($username, $password)
+ {
+ if (! function_exists('ldap_connect')) {
+ die('The PHP LDAP extension is required');
+ }
+
+ $ldap = ldap_connect(LDAP_SERVER, LDAP_PORT);
+
+ if (! is_resource($ldap)) {
+ die('Unable to connect to the LDAP server: "'.LDAP_SERVER.'"');
+ }
+
+ ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3);
+ ldap_set_option($ldap, LDAP_OPT_REFERRALS, 0);
+
+ if (@ldap_bind($ldap, sprintf(LDAP_USER_DN, $username), $password)) {
+ return $this->create($username);
+ }
+
+ return false;
+ }
+
+ /**
+ * Create automatically a new local user after the LDAP authentication
+ *
+ * @access public
+ * @param string $username Username
+ * @return bool
+ */
+ public function create($username)
+ {
+ $userModel = new User($this->db, $this->event);
+ $user = $userModel->getByUsername($username);
+
+ // There is an existing user account
+ if ($user) {
+
+ if ($user['is_ldap_user'] == 1) {
+
+ // LDAP user already created
+ return true;
+ }
+ else {
+
+ // There is already a local user with that username
+ return false;
+ }
+ }
+
+ // Create a LDAP user
+ $values = array(
+ 'username' => $username,
+ 'is_admin' => 0,
+ 'is_ldap_user' => 1,
+ );
+
+ return $userModel->create($values);
+ }
+}
diff --git a/app/Model/Project.php b/app/Model/Project.php
new file mode 100644
index 00000000..85294830
--- /dev/null
+++ b/app/Model/Project.php
@@ -0,0 +1,558 @@
+<?php
+
+namespace Model;
+
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+use Event\TaskModification;
+
+/**
+ * Project model
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class Project extends Base
+{
+ /**
+ * SQL table name for projects
+ *
+ * @var string
+ */
+ const TABLE = 'projects';
+
+ /**
+ * SQL table name for users
+ *
+ * @var string
+ */
+ const TABLE_USERS = 'project_has_users';
+
+ /**
+ * Value for active project
+ *
+ * @var integer
+ */
+ const ACTIVE = 1;
+
+ /**
+ * Value for inactive project
+ *
+ * @var integer
+ */
+ const INACTIVE = 0;
+
+ /**
+ * Get a list of people that can be assigned for tasks
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param bool $prepend_unassigned Prepend the 'Unassigned' value
+ * @param bool $prepend_everybody Prepend the 'Everbody' value
+ * @return array
+ */
+ public function getUsersList($project_id, $prepend_unassigned = true, $prepend_everybody = false)
+ {
+ $allowed_users = $this->getAllowedUsers($project_id);
+ $userModel = new User($this->db, $this->event);
+
+ if (empty($allowed_users)) {
+ $allowed_users = $userModel->getList();
+ }
+
+ if ($prepend_unassigned) {
+ $allowed_users = array(t('Unassigned')) + $allowed_users;
+ }
+
+ if ($prepend_everybody) {
+ $allowed_users = array(User::EVERYBODY_ID => t('Everybody')) + $allowed_users;
+ }
+
+ return $allowed_users;
+ }
+
+ /**
+ * Get a list of allowed people for a project
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @return array
+ */
+ public function getAllowedUsers($project_id)
+ {
+ return $this->db
+ ->table(self::TABLE_USERS)
+ ->join(User::TABLE, 'id', 'user_id')
+ ->eq('project_id', $project_id)
+ ->asc('username')
+ ->listing('user_id', 'username');
+ }
+
+ /**
+ * Get allowed and not allowed users for a project
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @return array
+ */
+ public function getAllUsers($project_id)
+ {
+ $users = array(
+ 'allowed' => array(),
+ 'not_allowed' => array(),
+ );
+
+ $userModel = new User($this->db, $this->event);
+ $all_users = $userModel->getList();
+
+ $users['allowed'] = $this->getAllowedUsers($project_id);
+
+ foreach ($all_users as $user_id => $username) {
+
+ if (! isset($users['allowed'][$user_id])) {
+ $users['not_allowed'][$user_id] = $username;
+ }
+ }
+
+ return $users;
+ }
+
+ /**
+ * Allow a specific user for a given project
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param integer $user_id User id
+ * @return bool
+ */
+ public function allowUser($project_id, $user_id)
+ {
+ return $this->db
+ ->table(self::TABLE_USERS)
+ ->save(array('project_id' => $project_id, 'user_id' => $user_id));
+ }
+
+ /**
+ * Revoke a specific user for a given project
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param integer $user_id User id
+ * @return bool
+ */
+ public function revokeUser($project_id, $user_id)
+ {
+ return $this->db
+ ->table(self::TABLE_USERS)
+ ->eq('project_id', $project_id)
+ ->eq('user_id', $user_id)
+ ->remove();
+ }
+
+ /**
+ * Check if a specific user is allowed to access to a given project
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param integer $user_id User id
+ * @return bool
+ */
+ public function isUserAllowed($project_id, $user_id)
+ {
+ // If there is nobody specified, everybody have access to the project
+ $nb_users = $this->db
+ ->table(self::TABLE_USERS)
+ ->eq('project_id', $project_id)
+ ->count();
+
+ if ($nb_users < 1) return true;
+
+ // Check if user has admin rights
+ $nb_users = $this->db
+ ->table(User::TABLE)
+ ->eq('id', $user_id)
+ ->eq('is_admin', 1)
+ ->count();
+
+ if ($nb_users > 0) return true;
+
+ // Otherwise, allow only specific users
+ return (bool) $this->db
+ ->table(self::TABLE_USERS)
+ ->eq('project_id', $project_id)
+ ->eq('user_id', $user_id)
+ ->count();
+ }
+
+ /**
+ * Get a project by the id
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @return array
+ */
+ public function getById($project_id)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $project_id)->findOne();
+ }
+
+ /**
+ * Fetch project data by using the token
+ *
+ * @access public
+ * @param string $token Token
+ * @return array
+ */
+ public function getByToken($token)
+ {
+ return $this->db->table(self::TABLE)->eq('token', $token)->findOne();
+ }
+
+ /**
+ * Return the first project from the database (no sorting)
+ *
+ * @access public
+ * @return array
+ */
+ public function getFirst()
+ {
+ return $this->db->table(self::TABLE)->findOne();
+ }
+
+ /**
+ * Get all projects, optionaly fetch stats for each project and can check users permissions
+ *
+ * @access public
+ * @param bool $fetch_stats If true, return metrics about each projects
+ * @param bool $check_permissions If true, remove projects not allowed for the current user
+ * @return array
+ */
+ public function getAll($fetch_stats = false, $check_permissions = false)
+ {
+ if (! $fetch_stats) {
+ return $this->db->table(self::TABLE)->asc('name')->findAll();
+ }
+
+ $this->db->startTransaction();
+
+ $projects = $this->db
+ ->table(self::TABLE)
+ ->asc('name')
+ ->findAll();
+
+ $boardModel = new Board($this->db, $this->event);
+ $taskModel = new Task($this->db, $this->event);
+ $aclModel = new Acl($this->db, $this->event);
+
+ foreach ($projects as $pkey => &$project) {
+
+ if ($check_permissions && ! $this->isUserAllowed($project['id'], $aclModel->getUserId())) {
+ unset($projects[$pkey]);
+ }
+ else {
+
+ $columns = $boardModel->getcolumns($project['id']);
+ $project['nb_active_tasks'] = 0;
+
+ foreach ($columns as &$column) {
+ $column['nb_active_tasks'] = $taskModel->countByColumnId($project['id'], $column['id']);
+ $project['nb_active_tasks'] += $column['nb_active_tasks'];
+ }
+
+ $project['columns'] = $columns;
+ $project['nb_tasks'] = $taskModel->countByProjectId($project['id']);
+ $project['nb_inactive_tasks'] = $project['nb_tasks'] - $project['nb_active_tasks'];
+ }
+ }
+
+ $this->db->closeTransaction();
+
+ return $projects;
+ }
+
+ /**
+ * Return the list of all projects
+ *
+ * @access public
+ * @param bool $prepend If true, prepend to the list the value 'None'
+ * @return array
+ */
+ public function getList($prepend = true)
+ {
+ if ($prepend) {
+ return array(t('None')) + $this->db->table(self::TABLE)->asc('name')->listing('id', 'name');
+ }
+
+ return $this->db->table(self::TABLE)->asc('name')->listing('id', 'name');
+ }
+
+ /**
+ * Get all projects with all its data for a given status
+ *
+ * @access public
+ * @param integer $status Proejct status: self::ACTIVE or self:INACTIVE
+ * @return array
+ */
+ public function getAllByStatus($status)
+ {
+ return $this->db
+ ->table(self::TABLE)
+ ->asc('name')
+ ->eq('is_active', $status)
+ ->findAll();
+ }
+
+ /**
+ * Get a list of project by status
+ *
+ * @access public
+ * @param integer $status Proejct status: self::ACTIVE or self:INACTIVE
+ * @return array
+ */
+ public function getListByStatus($status)
+ {
+ return $this->db
+ ->table(self::TABLE)
+ ->asc('name')
+ ->eq('is_active', $status)
+ ->listing('id', 'name');
+ }
+
+ /**
+ * Return the number of projects by status
+ *
+ * @access public
+ * @param integer $status Status: self::ACTIVE or self:INACTIVE
+ * @return integer
+ */
+ public function countByStatus($status)
+ {
+ return $this->db
+ ->table(self::TABLE)
+ ->eq('is_active', $status)
+ ->count();
+ }
+
+ /**
+ * Return a list of projects for a given user
+ *
+ * @access public
+ * @param array $projects Project list: ['project_id' => 'project_name']
+ * @param integer $user_id User id
+ * @return array
+ */
+ public function filterListByAccess(array $projects, $user_id)
+ {
+ foreach ($projects as $project_id => $project_name) {
+ if (! $this->isUserAllowed($project_id, $user_id)) {
+ unset($projects[$project_id]);
+ }
+ }
+
+ return $projects;
+ }
+
+ /**
+ * Create a project
+ *
+ * @access public
+ * @param array $values Form values
+ * @return integer Project id
+ */
+ public function create(array $values)
+ {
+ $this->db->startTransaction();
+
+ $values['token'] = self::generateToken();
+
+ if (! $this->db->table(self::TABLE)->save($values)) {
+ $this->db->cancelTransaction();
+ return false;
+ }
+
+ $project_id = $this->db->getConnection()->getLastId();
+
+ $boardModel = new Board($this->db, $this->event);
+ $boardModel->create($project_id, array(
+ t('Backlog'),
+ t('Ready'),
+ t('Work in progress'),
+ t('Done'),
+ ));
+
+ $this->db->closeTransaction();
+
+ return (int) $project_id;
+ }
+
+ /**
+ * Check if the project have been modified
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param integer $timestamp Timestamp
+ * @return bool
+ */
+ public function isModifiedSince($project_id, $timestamp)
+ {
+ return (bool) $this->db->table(self::TABLE)
+ ->eq('id', $project_id)
+ ->gt('last_modified', $timestamp)
+ ->count();
+ }
+
+ /**
+ * Update modification date
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @return bool
+ */
+ public function updateModificationDate($project_id)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $project_id)->save(array(
+ 'last_modified' => time()
+ ));
+ }
+
+ /**
+ * Update a project
+ *
+ * @access public
+ * @param array $values Form values
+ * @return bool
+ */
+ public function update(array $values)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $values['id'])->save($values);
+ }
+
+ /**
+ * Remove a project
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @return bool
+ */
+ public function remove($project_id)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $project_id)->remove();
+ }
+
+ /**
+ * Enable a project
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @return bool
+ */
+ public function enable($project_id)
+ {
+ return $this->db
+ ->table(self::TABLE)
+ ->eq('id', $project_id)
+ ->save(array('is_active' => 1));
+ }
+
+ /**
+ * Disable a project
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @return bool
+ */
+ public function disable($project_id)
+ {
+ return $this->db
+ ->table(self::TABLE)
+ ->eq('id', $project_id)
+ ->save(array('is_active' => 0));
+ }
+
+ /**
+ * Validate project creation
+ *
+ * @access public
+ * @param array $array Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateCreation(array $values)
+ {
+ $v = new Validator($values, array(
+ new Validators\Required('name', t('The project name is required')),
+ new Validators\MaxLength('name', t('The maximum length is %d characters', 50), 50),
+ new Validators\Unique('name', t('This project must be unique'), $this->db->getConnection(), self::TABLE)
+ ));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate project modification
+ *
+ * @access public
+ * @param array $array Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateModification(array $values)
+ {
+ $v = new Validator($values, array(
+ new Validators\Required('id', t('The project id is required')),
+ new Validators\Integer('id', t('This value must be an integer')),
+ new Validators\Required('name', t('The project name is required')),
+ new Validators\MaxLength('name', t('The maximum length is %d characters', 50), 50),
+ new Validators\Unique('name', t('This project must be unique'), $this->db->getConnection(), self::TABLE)
+ ));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate allowed users
+ *
+ * @access public
+ * @param array $array Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateUserAccess(array $values)
+ {
+ $v = new Validator($values, array(
+ new Validators\Required('project_id', t('The project id is required')),
+ new Validators\Integer('project_id', t('This value must be an integer')),
+ new Validators\Required('user_id', t('The user id is required')),
+ new Validators\Integer('user_id', t('This value must be an integer')),
+ ));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Attach events
+ *
+ * @access public
+ */
+ public function attachEvents()
+ {
+ $events = array(
+ Task::EVENT_UPDATE,
+ Task::EVENT_CREATE,
+ Task::EVENT_CLOSE,
+ Task::EVENT_OPEN,
+ );
+
+ $listener = new TaskModification($this);
+
+ foreach ($events as $event_name) {
+ $this->event->attach($event_name, $listener);
+ }
+ }
+}
diff --git a/app/Model/RememberMe.php b/app/Model/RememberMe.php
new file mode 100644
index 00000000..1494b14a
--- /dev/null
+++ b/app/Model/RememberMe.php
@@ -0,0 +1,333 @@
+<?php
+
+namespace Model;
+
+/**
+ * RememberMe model
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class RememberMe extends Base
+{
+ /**
+ * SQL table name
+ *
+ * @var string
+ */
+ const TABLE = 'remember_me';
+
+ /**
+ * Cookie name
+ *
+ * @var string
+ */
+ const COOKIE_NAME = '__R';
+
+ /**
+ * Expiration (60 days)
+ *
+ * @var integer
+ */
+ const EXPIRATION = 5184000;
+
+ /**
+ * Get a remember me record
+ *
+ * @access public
+ * @return mixed
+ */
+ public function find($token, $sequence)
+ {
+ return $this->db
+ ->table(self::TABLE)
+ ->eq('token', $token)
+ ->eq('sequence', $sequence)
+ ->gt('expiration', time())
+ ->findOne();
+ }
+
+ /**
+ * Get all sessions for a given user
+ *
+ * @access public
+ * @param integer $user_id User id
+ * @return array
+ */
+ public function getAll($user_id)
+ {
+ return $this->db
+ ->table(self::TABLE)
+ ->eq('user_id', $user_id)
+ ->desc('date_creation')
+ ->columns('id', 'ip', 'user_agent', 'date_creation', 'expiration')
+ ->findAll();
+ }
+
+ /**
+ * Authenticate the user with the cookie
+ *
+ * @access public
+ * @return bool
+ */
+ public function authenticate()
+ {
+ $credentials = $this->readCookie();
+
+ if ($credentials !== false) {
+
+ $record = $this->find($credentials['token'], $credentials['sequence']);
+
+ if ($record) {
+
+ // Update the sequence
+ $this->writeCookie(
+ $record['token'],
+ $this->update($record['token'], $record['sequence']),
+ $record['expiration']
+ );
+
+ // Create the session
+ $user = new User($this->db, $this->event);
+ $acl = new Acl($this->db, $this->event);
+
+ $user->updateSession($user->getById($record['user_id']));
+ $acl->isRememberMe(true);
+
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Update the database and the cookie with a new sequence
+ *
+ * @access public
+ */
+ public function refresh()
+ {
+ $credentials = $this->readCookie();
+
+ if ($credentials !== false) {
+
+ $record = $this->find($credentials['token'], $credentials['sequence']);
+
+ if ($record) {
+
+ // Update the sequence
+ $this->writeCookie(
+ $record['token'],
+ $this->update($record['token'], $record['sequence']),
+ $record['expiration']
+ );
+ }
+ }
+ }
+
+ /**
+ * Remove a session record
+ *
+ * @access public
+ * @param integer $session_id Session id
+ * @return mixed
+ */
+ public function remove($session_id)
+ {
+ return $this->db
+ ->table(self::TABLE)
+ ->eq('id', $session_id)
+ ->remove();
+ }
+
+ /**
+ * Remove the current RememberMe session and the cookie
+ *
+ * @access public
+ * @param integer $user_id User id
+ */
+ public function destroy($user_id)
+ {
+ $credentials = $this->readCookie();
+
+ if ($credentials !== false) {
+
+ $this->deleteCookie();
+
+ $this->db
+ ->table(self::TABLE)
+ ->eq('user_id', $user_id)
+ ->eq('token', $credentials['token'])
+ ->remove();
+ }
+ }
+
+ /**
+ * Create a new RememberMe session
+ *
+ * @access public
+ * @param integer $user_id User id
+ * @param string $ip IP Address
+ * @param string $user_agent User Agent
+ * @return array
+ */
+ public function create($user_id, $ip, $user_agent)
+ {
+ $token = hash('sha256', $user_id.$user_agent.$ip.$this->generateToken());
+ $sequence = $this->generateToken();
+ $expiration = time() + self::EXPIRATION;
+
+ $this->cleanup($user_id);
+
+ $this->db
+ ->table(self::TABLE)
+ ->insert(array(
+ 'user_id' => $user_id,
+ 'ip' => $ip,
+ 'user_agent' => $user_agent,
+ 'token' => $token,
+ 'sequence' => $sequence,
+ 'expiration' => $expiration,
+ 'date_creation' => time(),
+ ));
+
+ return array(
+ 'token' => $token,
+ 'sequence' => $sequence,
+ 'expiration' => $expiration,
+ );
+ }
+
+ /**
+ * Remove old sessions for a given user
+ *
+ * @access public
+ * @param integer $user_id User id
+ * @return bool
+ */
+ public function cleanup($user_id)
+ {
+ return $this->db
+ ->table(self::TABLE)
+ ->eq('user_id', $user_id)
+ ->lt('expiration', time())
+ ->remove();
+ }
+
+ /**
+ * Return a new sequence token and update the database
+ *
+ * @access public
+ * @param string $token Session token
+ * @param string $sequence Sequence token
+ * @return string
+ */
+ public function update($token, $sequence)
+ {
+ $new_sequence = $this->generateToken();
+
+ $this->db
+ ->table(self::TABLE)
+ ->eq('token', $token)
+ ->eq('sequence', $sequence)
+ ->update(array('sequence' => $new_sequence));
+
+ return $new_sequence;
+ }
+
+ /**
+ * Encode the cookie
+ *
+ * @access public
+ * @param string $token Session token
+ * @param string $sequence Sequence token
+ * @return string
+ */
+ public function encodeCookie($token, $sequence)
+ {
+ return implode('|', array($token, $sequence));
+ }
+
+ /**
+ * Decode the value of a cookie
+ *
+ * @access public
+ * @param string $value Raw cookie data
+ * @return array
+ */
+ public function decodeCookie($value)
+ {
+ list($token, $sequence) = explode('|', $value);
+
+ return array(
+ 'token' => $token,
+ 'sequence' => $sequence,
+ );
+ }
+
+ /**
+ * Return true if the current user has a RememberMe cookie
+ *
+ * @access public
+ * @return bool
+ */
+ public function hasCookie()
+ {
+ return ! empty($_COOKIE[self::COOKIE_NAME]);
+ }
+
+ /**
+ * Write and encode the cookie
+ *
+ * @access public
+ * @param string $token Session token
+ * @param string $sequence Sequence token
+ * @param string $expiration Cookie expiration
+ */
+ public function writeCookie($token, $sequence, $expiration)
+ {
+ setcookie(
+ self::COOKIE_NAME,
+ $this->encodeCookie($token, $sequence),
+ $expiration,
+ BASE_URL_DIRECTORY,
+ null,
+ ! empty($_SERVER['HTTPS']),
+ true
+ );
+ }
+
+ /**
+ * Read and decode the cookie
+ *
+ * @access public
+ * @return mixed
+ */
+ public function readCookie()
+ {
+ if (empty($_COOKIE[self::COOKIE_NAME])) {
+ return false;
+ }
+
+ return $this->decodeCookie($_COOKIE[self::COOKIE_NAME]);
+ }
+
+ /**
+ * Remove the cookie
+ *
+ * @access public
+ */
+ public function deleteCookie()
+ {
+ setcookie(
+ self::COOKIE_NAME,
+ '',
+ time() - 3600,
+ BASE_URL_DIRECTORY,
+ null,
+ ! empty($_SERVER['HTTPS']),
+ true
+ );
+ }
+}
diff --git a/app/Model/Task.php b/app/Model/Task.php
new file mode 100644
index 00000000..bd67d272
--- /dev/null
+++ b/app/Model/Task.php
@@ -0,0 +1,627 @@
+<?php
+
+namespace Model;
+
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+use DateTime;
+
+/**
+ * Task model
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class Task extends Base
+{
+ /**
+ * SQL table name
+ *
+ * @var string
+ */
+ const TABLE = 'tasks';
+
+ /**
+ * Task status
+ *
+ * @var integer
+ */
+ const STATUS_OPEN = 1;
+ const STATUS_CLOSED = 0;
+
+ /**
+ * Events
+ *
+ * @var string
+ */
+ const EVENT_MOVE_COLUMN = 'task.move.column';
+ const EVENT_MOVE_POSITION = 'task.move.position';
+ const EVENT_UPDATE = 'task.update';
+ const EVENT_CREATE = 'task.create';
+ const EVENT_CLOSE = 'task.close';
+ const EVENT_OPEN = 'task.open';
+ const EVENT_CREATE_UPDATE = 'task.create_update';
+
+ /**
+ * Get available colors
+ *
+ * @access public
+ * @return array
+ */
+ public function getColors()
+ {
+ return array(
+ 'yellow' => t('Yellow'),
+ 'blue' => t('Blue'),
+ 'green' => t('Green'),
+ 'purple' => t('Purple'),
+ 'red' => t('Red'),
+ 'orange' => t('Orange'),
+ 'grey' => t('Grey'),
+ );
+ }
+
+ /**
+ * Fetch one task
+ *
+ * @access public
+ * @param integer $task_id Task id
+ * @param boolean $more If true, fetch all related information
+ * @return array
+ */
+ public function getById($task_id, $more = false)
+ {
+ if ($more) {
+
+ return $this->db
+ ->table(self::TABLE)
+ ->columns(
+ self::TABLE.'.id',
+ self::TABLE.'.title',
+ self::TABLE.'.description',
+ self::TABLE.'.date_creation',
+ self::TABLE.'.date_completed',
+ self::TABLE.'.date_due',
+ self::TABLE.'.color_id',
+ self::TABLE.'.project_id',
+ self::TABLE.'.column_id',
+ self::TABLE.'.owner_id',
+ self::TABLE.'.position',
+ self::TABLE.'.is_active',
+ self::TABLE.'.score',
+ self::TABLE.'.category_id',
+ Category::TABLE.'.name AS category_name',
+ Project::TABLE.'.name AS project_name',
+ Board::TABLE.'.title AS column_title',
+ User::TABLE.'.username'
+ )
+ ->join(Category::TABLE, 'id', 'category_id')
+ ->join(Project::TABLE, 'id', 'project_id')
+ ->join(Board::TABLE, 'id', 'column_id')
+ ->join(User::TABLE, 'id', 'owner_id')
+ ->eq(self::TABLE.'.id', $task_id)
+ ->findOne();
+ }
+ else {
+
+ return $this->db->table(self::TABLE)->eq('id', $task_id)->findOne();
+ }
+ }
+
+ /**
+ * Count all tasks for a given project and status
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param array $status List of status id
+ * @return integer
+ */
+ public function countByProjectId($project_id, array $status = array(self::STATUS_OPEN, self::STATUS_CLOSED))
+ {
+ return $this->db
+ ->table(self::TABLE)
+ ->eq('project_id', $project_id)
+ ->in('is_active', $status)
+ ->count();
+ }
+
+ /**
+ * Get tasks that match defined filters
+ *
+ * @access public
+ * @param array $filters Filters: [ ['column' => '...', 'operator' => '...', 'value' => '...'], ... ]
+ * @param array $sorting Sorting: [ 'column' => 'date_creation', 'direction' => 'asc']
+ * @return array
+ */
+ public function find(array $filters, array $sorting = array())
+ {
+ $table = $this->db
+ ->table(self::TABLE)
+ ->columns(
+ '(SELECT count(*) FROM comments WHERE task_id=tasks.id) AS nb_comments',
+ 'tasks.id',
+ 'tasks.title',
+ 'tasks.description',
+ 'tasks.date_creation',
+ 'tasks.date_completed',
+ 'tasks.date_due',
+ 'tasks.color_id',
+ 'tasks.project_id',
+ 'tasks.column_id',
+ 'tasks.owner_id',
+ 'tasks.position',
+ 'tasks.is_active',
+ 'tasks.score',
+ 'tasks.category_id',
+ 'users.username'
+ )
+ ->join('users', 'id', 'owner_id');
+
+ foreach ($filters as $key => $filter) {
+
+ if ($key === 'or') {
+
+ $table->beginOr();
+
+ foreach ($filter as $subfilter) {
+ $table->$subfilter['operator']($subfilter['column'], $subfilter['value']);
+ }
+
+ $table->closeOr();
+ }
+ else if (isset($filter['operator']) && isset($filter['column']) && isset($filter['value'])) {
+ $table->$filter['operator']($filter['column'], $filter['value']);
+ }
+ }
+
+ if (empty($sorting)) {
+ $table->orderBy('tasks.position', 'ASC');
+ }
+ else {
+ $table->orderBy($sorting['column'], $sorting['direction']);
+ }
+
+ return $table->findAll();
+ }
+
+ /**
+ * Count the number of tasks for a given column and status
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param integer $column_id Column id
+ * @param array $status List of status id
+ * @return integer
+ */
+ public function countByColumnId($project_id, $column_id, array $status = array(self::STATUS_OPEN))
+ {
+ return $this->db
+ ->table(self::TABLE)
+ ->eq('project_id', $project_id)
+ ->eq('column_id', $column_id)
+ ->in('is_active', $status)
+ ->count();
+ }
+
+ /**
+ * Duplicate a task
+ *
+ * @access public
+ * @param integer $task_id Task id
+ * @return boolean
+ */
+ public function duplicate($task_id)
+ {
+ $this->db->startTransaction();
+
+ $boardModel = new Board($this->db, $this->event);
+
+ // Get the original task
+ $task = $this->getById($task_id);
+
+ // Cleanup data
+ unset($task['id']);
+ unset($task['date_completed']);
+
+ // Assign new values
+ $task['date_creation'] = time();
+ $task['is_active'] = 1;
+ $task['position'] = $this->countByColumnId($task['project_id'], $task['column_id']);
+
+ // Save task
+ if (! $this->db->table(self::TABLE)->save($task)) {
+ $this->db->cancelTransaction();
+ return false;
+ }
+
+ $task_id = $this->db->getConnection()->getLastId();
+
+ $this->db->closeTransaction();
+
+ // Trigger events
+ $this->event->trigger(self::EVENT_CREATE_UPDATE, array('task_id' => $task_id) + $task);
+ $this->event->trigger(self::EVENT_CREATE, array('task_id' => $task_id) + $task);
+
+ return $task_id;
+ }
+
+ /**
+ * Duplicate a task to another project (always copy to the first column)
+ *
+ * @access public
+ * @param integer $task_id Task id
+ * @param integer $project_id Destination project id
+ * @return boolean
+ */
+ public function duplicateToAnotherProject($task_id, $project_id)
+ {
+ $this->db->startTransaction();
+
+ $boardModel = new Board($this->db, $this->event);
+
+ // Get the original task
+ $task = $this->getById($task_id);
+
+ // Cleanup data
+ unset($task['id']);
+ unset($task['date_completed']);
+
+ // Assign new values
+ $task['date_creation'] = time();
+ $task['owner_id'] = 0;
+ $task['category_id'] = 0;
+ $task['is_active'] = 1;
+ $task['column_id'] = $boardModel->getFirstColumn($project_id);
+ $task['project_id'] = $project_id;
+ $task['position'] = $this->countByColumnId($task['project_id'], $task['column_id']);
+
+ // Save task
+ if (! $this->db->table(self::TABLE)->save($task)) {
+ $this->db->cancelTransaction();
+ return false;
+ }
+
+ $task_id = $this->db->getConnection()->getLastId();
+
+ $this->db->closeTransaction();
+
+ // Trigger events
+ $this->event->trigger(self::EVENT_CREATE_UPDATE, array('task_id' => $task_id) + $task);
+ $this->event->trigger(self::EVENT_CREATE, array('task_id' => $task_id) + $task);
+
+ return $task_id;
+ }
+
+ /**
+ * Create a task
+ *
+ * @access public
+ * @param array $values Form values
+ * @return boolean
+ */
+ public function create(array $values)
+ {
+ $this->db->startTransaction();
+
+ // Prepare data
+ if (isset($values['another_task'])) {
+ unset($values['another_task']);
+ }
+
+ if (! empty($values['date_due']) && ! is_numeric($values['date_due'])) {
+ $values['date_due'] = $this->parseDate($values['date_due']);
+ }
+
+ $values['date_creation'] = time();
+ $values['position'] = $this->countByColumnId($values['project_id'], $values['column_id']);
+
+ // Save task
+ if (! $this->db->table(self::TABLE)->save($values)) {
+ $this->db->cancelTransaction();
+ return false;
+ }
+
+ $task_id = $this->db->getConnection()->getLastId();
+
+ $this->db->closeTransaction();
+
+ // Trigger events
+ $this->event->trigger(self::EVENT_CREATE_UPDATE, array('task_id' => $task_id) + $values);
+ $this->event->trigger(self::EVENT_CREATE, array('task_id' => $task_id) + $values);
+
+ return $task_id;
+ }
+
+ /**
+ * Update a task
+ *
+ * @access public
+ * @param array $values Form values
+ * @return boolean
+ */
+ public function update(array $values)
+ {
+ // Prepare data
+ if (! empty($values['date_due']) && ! is_numeric($values['date_due'])) {
+ $values['date_due'] = $this->parseDate($values['date_due']);
+ }
+
+ $original_task = $this->getById($values['id']);
+
+ if ($original_task === false) {
+ return false;
+ }
+
+ $updated_task = $values;
+ unset($updated_task['id']);
+
+ $result = $this->db->table(self::TABLE)->eq('id', $values['id'])->update($updated_task);
+
+ // Trigger events
+ if ($result) {
+
+ $events = array();
+
+ if (! in_array($this->event->getLastTriggeredEvent(), array(self::EVENT_CREATE_UPDATE))) {
+ $events[] = self::EVENT_CREATE_UPDATE;
+ $events[] = self::EVENT_UPDATE;
+ }
+
+ if (isset($values['column_id']) && $original_task['column_id'] != $values['column_id']) {
+ $events[] = self::EVENT_MOVE_COLUMN;
+ }
+ else if (isset($values['position']) && $original_task['position'] != $values['position']) {
+ $events[] = self::EVENT_MOVE_POSITION;
+ }
+
+ $event_data = array_merge($original_task, $values);
+ $event_data['task_id'] = $original_task['id'];
+
+ foreach ($events as $event) {
+ $this->event->trigger($event, $event_data);
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Mark a task closed
+ *
+ * @access public
+ * @param integer $task_id Task id
+ * @return boolean
+ */
+ public function close($task_id)
+ {
+ $result = $this->db
+ ->table(self::TABLE)
+ ->eq('id', $task_id)
+ ->update(array(
+ 'is_active' => 0,
+ 'date_completed' => time()
+ ));
+
+ if ($result) {
+ $this->event->trigger(self::EVENT_CLOSE, array('task_id' => $task_id) + $this->getById($task_id));
+ }
+
+ return $result;
+ }
+
+ /**
+ * Mark a task open
+ *
+ * @access public
+ * @param integer $task_id Task id
+ * @return boolean
+ */
+ public function open($task_id)
+ {
+ $result = $this->db
+ ->table(self::TABLE)
+ ->eq('id', $task_id)
+ ->update(array(
+ 'is_active' => 1,
+ 'date_completed' => ''
+ ));
+
+ if ($result) {
+ $this->event->trigger(self::EVENT_OPEN, array('task_id' => $task_id) + $this->getById($task_id));
+ }
+
+ return $result;
+ }
+
+ /**
+ * Remove a task
+ *
+ * @access public
+ * @param integer $task_id Task id
+ * @return boolean
+ */
+ public function remove($task_id)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $task_id)->remove();
+ }
+
+ /**
+ * Move a task to another column or to another position
+ *
+ * @access public
+ * @param integer $task_id Task id
+ * @param integer $column_id Column id
+ * @param integer $position Position (must be greater than 1)
+ * @return boolean
+ */
+ public function move($task_id, $column_id, $position)
+ {
+ return $this->update(array(
+ 'id' => $task_id,
+ 'column_id' => $column_id,
+ 'position' => $position,
+ ));
+ }
+
+ /**
+ * Validate task creation
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateCreation(array $values)
+ {
+ $v = new Validator($values, array(
+ new Validators\Required('color_id', t('The color is required')),
+ new Validators\Required('project_id', t('The project is required')),
+ new Validators\Integer('project_id', t('This value must be an integer')),
+ new Validators\Required('column_id', t('The column is required')),
+ new Validators\Integer('column_id', t('This value must be an integer')),
+ new Validators\Integer('owner_id', t('This value must be an integer')),
+ new Validators\Integer('score', t('This value must be an integer')),
+ new Validators\Required('title', t('The title is required')),
+ new Validators\MaxLength('title', t('The maximum length is %d characters', 200), 200),
+ new Validators\Date('date_due', t('Invalid date'), $this->getDateFormats()),
+ ));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate description creation
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateDescriptionCreation(array $values)
+ {
+ $v = new Validator($values, array(
+ new Validators\Required('id', t('The id is required')),
+ new Validators\Integer('id', t('This value must be an integer')),
+ new Validators\Required('description', t('The description is required')),
+ ));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate task modification
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateModification(array $values)
+ {
+ $v = new Validator($values, array(
+ new Validators\Required('id', t('The id is required')),
+ new Validators\Integer('id', t('This value must be an integer')),
+ new Validators\Required('color_id', t('The color is required')),
+ new Validators\Required('project_id', t('The project is required')),
+ new Validators\Integer('project_id', t('This value must be an integer')),
+ new Validators\Required('column_id', t('The column is required')),
+ new Validators\Integer('column_id', t('This value must be an integer')),
+ new Validators\Integer('owner_id', t('This value must be an integer')),
+ new Validators\Integer('score', t('This value must be an integer')),
+ new Validators\Required('title', t('The title is required')),
+ new Validators\MaxLength('title', t('The maximum length is %d characters', 200), 200),
+ new Validators\Date('date_due', t('Invalid date'), $this->getDateFormats()),
+ ));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate assignee change
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateAssigneeModification(array $values)
+ {
+ $v = new Validator($values, array(
+ new Validators\Required('id', t('The id is required')),
+ new Validators\Integer('id', t('This value must be an integer')),
+ new Validators\Required('project_id', t('The project is required')),
+ new Validators\Integer('project_id', t('This value must be an integer')),
+ new Validators\Required('owner_id', t('This value is required')),
+ new Validators\Integer('owner_id', t('This value must be an integer')),
+ ));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * 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 ad return a unix timestamp, try different date formats
+ *
+ * @access public
+ * @param string $value Date to parse
+ * @return integer
+ */
+ public function parseDate($value)
+ {
+ foreach ($this->getDateFormats() as $format) {
+
+ $timestamp = $this->getValidDate($value, $format);
+
+ if ($timestamp !== 0) {
+ return $timestamp;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Return the list of supported date formats
+ *
+ * @access public
+ * @return array
+ */
+ public function getDateFormats()
+ {
+ return array(
+ t('m/d/Y'),
+ 'Y-m-d',
+ 'Y_m_d',
+ );
+ }
+}
diff --git a/app/Model/User.php b/app/Model/User.php
new file mode 100644
index 00000000..bce717a7
--- /dev/null
+++ b/app/Model/User.php
@@ -0,0 +1,426 @@
+<?php
+
+namespace Model;
+
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+
+/**
+ * User model
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class User extends Base
+{
+ /**
+ * SQL table name
+ *
+ * @var string
+ */
+ const TABLE = 'users';
+
+ /**
+ * Id used for everbody (filtering)
+ *
+ * @var integer
+ */
+ const EVERYBODY_ID = -1;
+
+ /**
+ * Get a specific user by id
+ *
+ * @access public
+ * @param integer $user_id User id
+ * @return array
+ */
+ public function getById($user_id)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $user_id)->findOne();
+ }
+
+ /**
+ * Get a specific user by the Google id
+ *
+ * @access public
+ * @param string $google_id Google unique id
+ * @return array
+ */
+ public function getByGoogleId($google_id)
+ {
+ return $this->db->table(self::TABLE)->eq('google_id', $google_id)->findOne();
+ }
+
+ /**
+ * Get a specific user by the username
+ *
+ * @access public
+ * @param string $username Username
+ * @return array
+ */
+ public function getByUsername($username)
+ {
+ return $this->db->table(self::TABLE)->eq('username', $username)->findOne();
+ }
+
+ /**
+ * Get all users
+ *
+ * @access public
+ * @return array
+ */
+ public function getAll()
+ {
+ return $this->db
+ ->table(self::TABLE)
+ ->asc('username')
+ ->columns('id', 'username', 'name', 'email', 'is_admin', 'default_project_id', 'is_ldap_user')
+ ->findAll();
+ }
+
+ /**
+ * List all users (key-value pairs with id/username)
+ *
+ * @access public
+ * @return array
+ */
+ public function getList()
+ {
+ return $this->db->table(self::TABLE)->asc('username')->listing('id', 'username');
+ }
+
+ /**
+ * Add a new user in the database
+ *
+ * @access public
+ * @param array $values Form values
+ * @return boolean
+ */
+ public function create(array $values)
+ {
+ if (isset($values['confirmation'])) {
+ unset($values['confirmation']);
+ }
+
+ if (isset($values['password'])) {
+ $values['password'] = \password_hash($values['password'], PASSWORD_BCRYPT);
+ }
+
+ return $this->db->table(self::TABLE)->save($values);
+ }
+
+ /**
+ * Modify a new user
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array
+ */
+ public function update(array $values)
+ {
+ if (! empty($values['password'])) {
+ $values['password'] = \password_hash($values['password'], PASSWORD_BCRYPT);
+ }
+ else {
+ unset($values['password']);
+ }
+
+ if (isset($values['confirmation'])) {
+ unset($values['confirmation']);
+ }
+
+ if (isset($values['current_password'])) {
+ unset($values['current_password']);
+ }
+
+ $result = $this->db->table(self::TABLE)->eq('id', $values['id'])->update($values);
+
+ if ($_SESSION['user']['id'] == $values['id']) {
+ $this->updateSession();
+ }
+
+ return $result;
+ }
+
+ /**
+ * Remove a specific user
+ *
+ * @access public
+ * @param integer $user_id User id
+ * @return boolean
+ */
+ public function remove($user_id)
+ {
+ $this->db->startTransaction();
+
+ // All tasks assigned to this user will be unassigned
+ $this->db->table(Task::TABLE)->eq('owner_id', $user_id)->update(array('owner_id' => ''));
+ $this->db->table(self::TABLE)->eq('id', $user_id)->remove();
+
+ $this->db->closeTransaction();
+
+ return true;
+ }
+
+ /**
+ * Update user session information
+ *
+ * @access public
+ * @param array $user User data
+ */
+ public function updateSession(array $user = array())
+ {
+ if (empty($user)) {
+ $user = $this->getById($_SESSION['user']['id']);
+ }
+
+ if (isset($user['password'])) {
+ unset($user['password']);
+ }
+
+ $user['id'] = (int) $user['id'];
+ $user['default_project_id'] = (int) $user['default_project_id'];
+ $user['is_admin'] = (bool) $user['is_admin'];
+ $user['is_ldap_user'] = (bool) $user['is_ldap_user'];
+
+ $_SESSION['user'] = $user;
+ }
+
+ /**
+ * Validate user creation
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateCreation(array $values)
+ {
+ $v = new Validator($values, array(
+ new Validators\Required('username', t('The username is required')),
+ new Validators\MaxLength('username', t('The maximum length is %d characters', 50), 50),
+ new Validators\AlphaNumeric('username', t('The username must be alphanumeric')),
+ new Validators\Unique('username', t('The username must be unique'), $this->db->getConnection(), self::TABLE, 'id'),
+ new Validators\Required('password', t('The password is required')),
+ new Validators\MinLength('password', t('The minimum length is %d characters', 6), 6),
+ new Validators\Required('confirmation', t('The confirmation is required')),
+ new Validators\Equals('password', 'confirmation', t('Passwords doesn\'t matches')),
+ new Validators\Integer('default_project_id', t('This value must be an integer')),
+ new Validators\Integer('is_admin', t('This value must be an integer')),
+ new Validators\Email('email', t('Email address invalid')),
+ ));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate user modification
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateModification(array $values)
+ {
+ if (! empty($values['password'])) {
+ return $this->validatePasswordModification($values);
+ }
+
+ $v = new Validator($values, array(
+ new Validators\Required('id', t('The user id is required')),
+ new Validators\Required('username', t('The username is required')),
+ new Validators\MaxLength('username', t('The maximum length is %d characters', 50), 50),
+ new Validators\AlphaNumeric('username', t('The username must be alphanumeric')),
+ new Validators\Unique('username', t('The username must be unique'), $this->db->getConnection(), self::TABLE, 'id'),
+ new Validators\Integer('default_project_id', t('This value must be an integer')),
+ new Validators\Integer('is_admin', t('This value must be an integer')),
+ new Validators\Email('email', t('Email address invalid')),
+ ));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate password modification
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validatePasswordModification(array $values)
+ {
+ $v = new Validator($values, array(
+ new Validators\Required('id', t('The user id is required')),
+ new Validators\Required('username', t('The username is required')),
+ new Validators\MaxLength('username', t('The maximum length is %d characters', 50), 50),
+ new Validators\AlphaNumeric('username', t('The username must be alphanumeric')),
+ new Validators\Unique('username', t('The username must be unique'), $this->db->getConnection(), self::TABLE, 'id'),
+ new Validators\Required('current_password', t('The current password is required')),
+ new Validators\Required('password', t('The password is required')),
+ new Validators\MinLength('password', t('The minimum length is %d characters', 6), 6),
+ new Validators\Required('confirmation', t('The confirmation is required')),
+ new Validators\Equals('password', 'confirmation', t('Passwords doesn\'t matches')),
+ new Validators\Integer('default_project_id', t('This value must be an integer')),
+ new Validators\Integer('is_admin', t('This value must be an integer')),
+ new Validators\Email('email', t('Email address invalid')),
+ ));
+
+ if ($v->execute()) {
+
+ // Check password
+ list($authenticated,) = $this->authenticate($_SESSION['user']['username'], $values['current_password']);
+
+ if ($authenticated) {
+ return array(true, array());
+ }
+ else {
+ return array(false, array('current_password' => array(t('Wrong password'))));
+ }
+ }
+
+ return array(false, $v->getErrors());
+ }
+
+ /**
+ * Validate user login
+ *
+ * @access public
+ * @param array $values Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateLogin(array $values)
+ {
+ $v = new Validator($values, array(
+ new Validators\Required('username', t('The username is required')),
+ new Validators\MaxLength('username', t('The maximum length is %d characters', 50), 50),
+ new Validators\Required('password', t('The password is required')),
+ ));
+
+ $result = $v->execute();
+ $errors = $v->getErrors();
+
+ if ($result) {
+
+ list($authenticated, $method) = $this->authenticate($values['username'], $values['password']);
+
+ if ($authenticated === true) {
+
+ // Create the user session
+ $user = $this->getByUsername($values['username']);
+ $this->updateSession($user);
+
+ // Update login history
+ $lastLogin = new LastLogin($this->db, $this->event);
+ $lastLogin->create(
+ $method,
+ $user['id'],
+ $this->getIpAddress(),
+ $this->getUserAgent()
+ );
+
+ // Setup the remember me feature
+ if (! empty($values['remember_me'])) {
+ $rememberMe = new RememberMe($this->db, $this->event);
+ $credentials = $rememberMe->create($user['id'], $this->getIpAddress(), $this->getUserAgent());
+ $rememberMe->writeCookie($credentials['token'], $credentials['sequence'], $credentials['expiration']);
+ }
+ }
+ else {
+ $result = false;
+ $errors['login'] = t('Bad username or password');
+ }
+ }
+
+ return array(
+ $result,
+ $errors
+ );
+ }
+
+ /**
+ * Authenticate a user
+ *
+ * @access public
+ * @param string $username Username
+ * @param string $password Password
+ * @return array
+ */
+ public function authenticate($username, $password)
+ {
+ // Database authentication
+ $user = $this->db->table(self::TABLE)->eq('username', $username)->eq('is_ldap_user', 0)->findOne();
+ $authenticated = $user && \password_verify($password, $user['password']);
+ $method = LastLogin::AUTH_DATABASE;
+
+ // LDAP authentication
+ if (! $authenticated && LDAP_AUTH) {
+ require __DIR__.'/ldap.php';
+ $ldap = new Ldap($this->db, $this->event);
+ $authenticated = $ldap->authenticate($username, $password);
+ $method = LastLogin::AUTH_LDAP;
+ }
+
+ return array($authenticated, $method);
+ }
+
+ /**
+ * Get the user agent of the connected user
+ *
+ * @access public
+ * @return string
+ */
+ public function getUserAgent()
+ {
+ return empty($_SERVER['HTTP_USER_AGENT']) ? t('Unknown') : $_SERVER['HTTP_USER_AGENT'];
+ }
+
+ /**
+ * Get the real IP address of the connected user
+ *
+ * @access public
+ * @param bool $only_public Return only public IP address
+ * @return string
+ */
+ public function getIpAddress($only_public = false)
+ {
+ $keys = array(
+ 'HTTP_CLIENT_IP',
+ 'HTTP_X_FORWARDED_FOR',
+ 'HTTP_X_FORWARDED',
+ 'HTTP_X_CLUSTER_CLIENT_IP',
+ 'HTTP_FORWARDED_FOR',
+ 'HTTP_FORWARDED',
+ 'REMOTE_ADDR'
+ );
+
+ foreach ($keys as $key) {
+
+ if (isset($_SERVER[$key])) {
+
+ foreach (explode(',', $_SERVER[$key]) as $ip_address) {
+
+ $ip_address = trim($ip_address);
+
+ if ($only_public) {
+
+ // Return only public IP address
+ if (filter_var($ip_address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false) {
+ return $ip_address;
+ }
+ }
+ else {
+
+ return $ip_address;
+ }
+ }
+ }
+ }
+
+ return t('Unknown');
+ }
+}
diff --git a/app/Schema/Mysql.php b/app/Schema/Mysql.php
new file mode 100644
index 00000000..6764ad5d
--- /dev/null
+++ b/app/Schema/Mysql.php
@@ -0,0 +1,236 @@
+<?php
+
+namespace Schema;
+
+const VERSION = 16;
+
+function version_16($pdo)
+{
+ $pdo->exec("
+ CREATE TABLE project_has_categories (
+ id INT NOT NULL AUTO_INCREMENT,
+ name VARCHAR(255),
+ project_id INT,
+ PRIMARY KEY (id),
+ UNIQUE KEY `idx_project_category` (project_id, name),
+ FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
+ ) ENGINE=InnoDB CHARSET=utf8"
+ );
+
+ $pdo->exec("ALTER TABLE tasks ADD COLUMN category_id INT DEFAULT 0");
+}
+
+function version_15($pdo)
+{
+ $pdo->exec("ALTER TABLE projects ADD COLUMN last_modified INT DEFAULT 0");
+}
+
+function version_14($pdo)
+{
+ $pdo->exec("ALTER TABLE users ADD COLUMN name VARCHAR(255)");
+ $pdo->exec("ALTER TABLE users ADD COLUMN email VARCHAR(255)");
+ $pdo->exec("ALTER TABLE users ADD COLUMN google_id VARCHAR(30)");
+}
+
+function version_13($pdo)
+{
+ $pdo->exec("ALTER TABLE users ADD COLUMN is_ldap_user TINYINT(1) DEFAULT 0");
+}
+
+function version_12($pdo)
+{
+ $pdo->exec("
+ CREATE TABLE remember_me (
+ id INT NOT NULL AUTO_INCREMENT,
+ user_id INT,
+ ip VARCHAR(40),
+ user_agent VARCHAR(255),
+ token VARCHAR(255),
+ sequence VARCHAR(255),
+ expiration INT,
+ date_creation INT,
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
+ PRIMARY KEY (id)
+ ) ENGINE=InnoDB CHARSET=utf8"
+ );
+
+ $pdo->exec("
+ CREATE TABLE last_logins (
+ id INT NOT NULL AUTO_INCREMENT,
+ auth_type VARCHAR(25),
+ user_id INT,
+ ip VARCHAR(40),
+ user_agent VARCHAR(255),
+ date_creation INT,
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
+ PRIMARY KEY (id),
+ INDEX (user_id)
+ ) ENGINE=InnoDB CHARSET=utf8"
+ );
+}
+
+function version_11($pdo)
+{
+}
+
+function version_10($pdo)
+{
+}
+
+function version_9($pdo)
+{
+}
+
+function version_8($pdo)
+{
+}
+
+function version_7($pdo)
+{
+}
+
+function version_6($pdo)
+{
+}
+
+function version_5($pdo)
+{
+}
+
+function version_4($pdo)
+{
+}
+
+function version_3($pdo)
+{
+}
+
+function version_2($pdo)
+{
+}
+
+function version_1($pdo)
+{
+ $pdo->exec("
+ CREATE TABLE config (
+ language CHAR(5) DEFAULT 'en_US',
+ webhooks_token VARCHAR(255),
+ timezone VARCHAR(50) DEFAULT 'UTC'
+ ) ENGINE=InnoDB CHARSET=utf8
+ ");
+
+ $pdo->exec("
+ CREATE TABLE users (
+ id INT NOT NULL AUTO_INCREMENT,
+ username VARCHAR(50),
+ password VARCHAR(255),
+ is_admin TINYINT DEFAULT 0,
+ default_project_id INT DEFAULT 0,
+ PRIMARY KEY (id)
+ ) ENGINE=InnoDB CHARSET=utf8
+ ");
+
+ $pdo->exec("
+ CREATE TABLE projects (
+ id INT NOT NULL AUTO_INCREMENT,
+ name VARCHAR(50) UNIQUE,
+ is_active TINYINT DEFAULT 1,
+ token VARCHAR(255),
+ PRIMARY KEY (id)
+ ) ENGINE=InnoDB CHARSET=utf8
+ ");
+
+ $pdo->exec("
+ CREATE TABLE project_has_users (
+ id INT NOT NULL AUTO_INCREMENT,
+ project_id INT,
+ user_id INT,
+ PRIMARY KEY (id),
+ UNIQUE KEY `idx_project_user` (project_id, user_id),
+ FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE,
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
+ ) ENGINE=InnoDB CHARSET=utf8
+ ");
+
+ $pdo->exec("
+ CREATE TABLE columns (
+ id INT NOT NULL AUTO_INCREMENT,
+ title VARCHAR(255),
+ position INT NOT NULL,
+ project_id INT NOT NULL,
+ task_limit INT DEFAULT '0',
+ UNIQUE KEY `idx_title_project` (title, project_id),
+ PRIMARY KEY (id),
+ FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
+ ) ENGINE=InnoDB CHARSET=utf8
+ ");
+
+ $pdo->exec("
+ CREATE TABLE tasks (
+ id INT NOT NULL AUTO_INCREMENT,
+ title VARCHAR(255),
+ description TEXT,
+ date_creation INT,
+ date_completed INT,
+ date_due INT,
+ color_id VARCHAR(50),
+ project_id INT,
+ column_id INT,
+ owner_id INT DEFAULT '0',
+ position INT,
+ score INT,
+ is_active TINYINT DEFAULT 1,
+ PRIMARY KEY (id),
+ INDEX `idx_task_active` (is_active),
+ FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE,
+ FOREIGN KEY(column_id) REFERENCES columns(id) ON DELETE CASCADE
+ ) ENGINE=InnoDB CHARSET=utf8
+ ");
+
+ $pdo->exec("
+ CREATE TABLE comments (
+ id INT NOT NULL AUTO_INCREMENT,
+ task_id INT,
+ user_id INT,
+ date INT,
+ comment TEXT,
+ PRIMARY KEY (id),
+ FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE,
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
+ ) ENGINE=InnoDB CHARSET=utf8
+ ");
+
+ $pdo->exec("
+ CREATE TABLE actions (
+ id INT NOT NULL AUTO_INCREMENT,
+ project_id INT,
+ event_name VARCHAR(50),
+ action_name VARCHAR(50),
+ PRIMARY KEY (id),
+ FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
+ ) ENGINE=InnoDB CHARSET=utf8
+ ");
+
+ $pdo->exec("
+ CREATE TABLE action_has_params (
+ id INT NOT NULL AUTO_INCREMENT,
+ action_id INT,
+ name VARCHAR(50),
+ value VARCHAR(50),
+ PRIMARY KEY (id),
+ FOREIGN KEY(action_id) REFERENCES actions(id) ON DELETE CASCADE
+ ) ENGINE=InnoDB CHARSET=utf8
+ ");
+
+ $pdo->exec("
+ INSERT INTO users
+ (username, password, is_admin)
+ VALUES ('admin', '".\password_hash('admin', PASSWORD_BCRYPT)."', '1')
+ ");
+
+ $pdo->exec("
+ INSERT INTO config
+ (webhooks_token)
+ VALUES ('".\Model\Base::generateToken()."')
+ ");
+}
diff --git a/app/Schema/Sqlite.php b/app/Schema/Sqlite.php
new file mode 100644
index 00000000..0bb4de8d
--- /dev/null
+++ b/app/Schema/Sqlite.php
@@ -0,0 +1,259 @@
+<?php
+
+namespace Schema;
+
+const VERSION = 16;
+
+function version_16($pdo)
+{
+ $pdo->exec("
+ CREATE TABLE project_has_categories (
+ id INTEGER PRIMARY KEY,
+ name TEXT COLLATE NOCASE,
+ project_id INT,
+ UNIQUE (project_id, name),
+ FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
+ )"
+ );
+
+ $pdo->exec("ALTER TABLE tasks ADD COLUMN category_id INTEGER DEFAULT 0");
+}
+
+function version_15($pdo)
+{
+ $pdo->exec("ALTER TABLE projects ADD COLUMN last_modified INTEGER DEFAULT 0");
+}
+
+function version_14($pdo)
+{
+ $pdo->exec("ALTER TABLE users ADD COLUMN name TEXT");
+ $pdo->exec("ALTER TABLE users ADD COLUMN email TEXT");
+ $pdo->exec("ALTER TABLE users ADD COLUMN google_id TEXT");
+}
+
+function version_13($pdo)
+{
+ $pdo->exec("ALTER TABLE users ADD COLUMN is_ldap_user INTEGER DEFAULT 0");
+}
+
+function version_12($pdo)
+{
+ $pdo->exec(
+ 'CREATE TABLE remember_me (
+ id INTEGER PRIMARY KEY,
+ user_id INTEGER,
+ ip TEXT,
+ user_agent TEXT,
+ token TEXT,
+ sequence TEXT,
+ expiration INTEGER,
+ date_creation INTEGER,
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
+ )'
+ );
+
+ $pdo->exec(
+ 'CREATE TABLE last_logins (
+ id INTEGER PRIMARY KEY,
+ auth_type TEXT,
+ user_id INTEGER,
+ ip TEXT,
+ user_agent TEXT,
+ date_creation INTEGER,
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
+ )'
+ );
+
+ $pdo->exec('CREATE INDEX last_logins_user_idx ON last_logins(user_id)');
+}
+
+function version_11($pdo)
+{
+ $pdo->exec(
+ 'ALTER TABLE comments RENAME TO comments_bak'
+ );
+
+ $pdo->exec(
+ 'CREATE TABLE comments (
+ id INTEGER PRIMARY KEY,
+ task_id INTEGER,
+ user_id INTEGER,
+ date INTEGER,
+ comment TEXT,
+ FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE,
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
+ )'
+ );
+
+ $pdo->exec(
+ 'INSERT INTO comments SELECT * FROM comments_bak'
+ );
+
+ $pdo->exec(
+ 'DROP TABLE comments_bak'
+ );
+}
+
+function version_10($pdo)
+{
+ $pdo->exec(
+ 'CREATE TABLE actions (
+ id INTEGER PRIMARY KEY,
+ project_id INTEGER,
+ event_name TEXT,
+ action_name TEXT,
+ FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
+ )'
+ );
+
+ $pdo->exec(
+ 'CREATE TABLE action_has_params (
+ id INTEGER PRIMARY KEY,
+ action_id INTEGER,
+ name TEXT,
+ value TEXT,
+ FOREIGN KEY(action_id) REFERENCES actions(id) ON DELETE CASCADE
+ )'
+ );
+}
+
+function version_9($pdo)
+{
+ $pdo->exec("ALTER TABLE tasks ADD COLUMN date_due INTEGER");
+}
+
+function version_8($pdo)
+{
+ $pdo->exec(
+ 'CREATE TABLE comments (
+ id INTEGER PRIMARY KEY,
+ task_id INTEGER,
+ user_id INTEGER,
+ date INTEGER,
+ comment TEXT,
+ FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE,
+ FOREIGN KEY(user_id) REFERENCES tasks(id) ON DELETE CASCADE
+ )'
+ );
+}
+
+function version_7($pdo)
+{
+ $pdo->exec("
+ CREATE TABLE project_has_users (
+ id INTEGER PRIMARY KEY,
+ project_id INTEGER,
+ user_id INTEGER,
+ FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE,
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
+ UNIQUE(project_id, user_id)
+ )
+ ");
+}
+
+function version_6($pdo)
+{
+ $pdo->exec("ALTER TABLE columns ADD COLUMN task_limit INTEGER DEFAULT '0'");
+}
+
+function version_5($pdo)
+{
+ $pdo->exec("ALTER TABLE tasks ADD COLUMN score INTEGER");
+}
+
+function version_4($pdo)
+{
+ $pdo->exec("ALTER TABLE config ADD COLUMN timezone TEXT DEFAULT 'UTC'");
+}
+
+function version_3($pdo)
+{
+ $pdo->exec('ALTER TABLE projects ADD COLUMN token TEXT');
+
+ // For each existing project, assign a different token
+ $rq = $pdo->prepare("SELECT id FROM projects WHERE token IS NULL");
+ $rq->execute();
+ $results = $rq->fetchAll(\PDO::FETCH_ASSOC);
+
+ if ($results !== false) {
+
+ foreach ($results as &$result) {
+ $rq = $pdo->prepare('UPDATE projects SET token=? WHERE id=?');
+ $rq->execute(array(\Model\Base::generateToken(), $result['id']));
+ }
+ }
+}
+
+function version_2($pdo)
+{
+ $pdo->exec('ALTER TABLE tasks ADD COLUMN date_completed INTEGER');
+ $pdo->exec('UPDATE tasks SET date_completed=date_creation WHERE is_active=0');
+}
+
+function version_1($pdo)
+{
+ $pdo->exec("
+ CREATE TABLE config (
+ language TEXT,
+ webhooks_token TEXT
+ )
+ ");
+
+ $pdo->exec("
+ CREATE TABLE users (
+ id INTEGER PRIMARY KEY,
+ username TEXT,
+ password TEXT,
+ is_admin INTEGER DEFAULT 0,
+ default_project_id INTEGER DEFAULT 0
+ )
+ ");
+
+ $pdo->exec("
+ CREATE TABLE projects (
+ id INTEGER PRIMARY KEY,
+ name TEXT NOCASE UNIQUE,
+ is_active INTEGER DEFAULT 1
+ )
+ ");
+
+ $pdo->exec("
+ CREATE TABLE columns (
+ id INTEGER PRIMARY KEY,
+ title TEXT,
+ position INTEGER,
+ project_id INTEGER,
+ FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE,
+ UNIQUE (title, project_id)
+ )
+ ");
+
+ $pdo->exec("
+ CREATE TABLE tasks (
+ id INTEGER PRIMARY KEY,
+ title TEXT,
+ description TEXT,
+ date_creation INTEGER,
+ color_id TEXT,
+ project_id INTEGER,
+ column_id INTEGER,
+ owner_id INTEGER DEFAULT '0',
+ position INTEGER,
+ is_active INTEGER DEFAULT 1,
+ FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE,
+ FOREIGN KEY(column_id) REFERENCES columns(id) ON DELETE CASCADE
+ )
+ ");
+
+ $pdo->exec("
+ INSERT INTO users
+ (username, password, is_admin)
+ VALUES ('admin', '".\password_hash('admin', PASSWORD_BCRYPT)."', '1')
+ ");
+
+ $pdo->exec("
+ INSERT INTO config
+ (language, webhooks_token)
+ VALUES ('en_US', '".\Model\Base::generateToken()."')
+ ");
+}
diff --git a/app/Templates/action_index.php b/app/Templates/action_index.php
new file mode 100644
index 00000000..b515ccaa
--- /dev/null
+++ b/app/Templates/action_index.php
@@ -0,0 +1,77 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('Automatic actions for the project "%s"', $project['name']) ?></h2>
+ <ul>
+ <li><a href="?controller=project"><?= t('All projects') ?></a></li>
+ </ul>
+ </div>
+ <section>
+
+ <?php if (! empty($actions)): ?>
+
+ <h3><?= t('Defined actions') ?></h3>
+ <table>
+ <tr>
+ <th><?= t('Event name') ?></th>
+ <th><?= t('Action name') ?></th>
+ <th><?= t('Action parameters') ?></th>
+ <th><?= t('Action') ?></th>
+ </tr>
+
+ <?php foreach ($actions as $action): ?>
+ <tr>
+ <td><?= Helper\in_list($action['event_name'], $available_events) ?></td>
+ <td><?= Helper\in_list($action['action_name'], $available_actions) ?></td>
+ <td>
+ <ul>
+ <?php foreach ($action['params'] as $param): ?>
+ <li>
+ <?= Helper\in_list($param['name'], $available_params) ?> =
+ <strong>
+ <?php if (Helper\contains($param['name'], 'column_id')): ?>
+ <?= Helper\in_list($param['value'], $columns_list) ?>
+ <?php elseif (Helper\contains($param['name'], 'user_id')): ?>
+ <?= Helper\in_list($param['value'], $users_list) ?>
+ <?php elseif (Helper\contains($param['name'], 'project_id')): ?>
+ <?= Helper\in_list($param['value'], $projects_list) ?>
+ <?php elseif (Helper\contains($param['name'], 'color_id')): ?>
+ <?= Helper\in_list($param['value'], $colors_list) ?>
+ <?php elseif (Helper\contains($param['name'], 'category_id')): ?>
+ <?= Helper\in_list($param['value'], $categories_list) ?>
+ <?php endif ?>
+ </strong>
+ </li>
+ <?php endforeach ?>
+ </ul>
+ </td>
+ <td>
+ <a href="?controller=action&amp;action=confirm&amp;action_id=<?= $action['id'] ?>"><?= t('Remove') ?></a>
+ </td>
+ </tr>
+ <?php endforeach ?>
+
+ </table>
+
+ <?php endif ?>
+
+ <h3><?= t('Add an action') ?></h3>
+ <form method="post" action="?controller=action&amp;action=params&amp;project_id=<?= $project['id'] ?>" autocomplete="off">
+
+ <?= Helper\form_hidden('project_id', $values) ?>
+
+ <?= Helper\form_label(t('Event'), 'event_name') ?>
+ <?= Helper\form_select('event_name', $available_events, $values) ?><br/>
+
+ <?= Helper\form_label(t('Action'), 'action_name') ?>
+ <?= Helper\form_select('action_name', $available_actions, $values) ?><br/>
+
+ <div class="form-help">
+ <?= t('When the selected event occurs execute the corresponding action.') ?>
+ </div>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Next step') ?>" class="btn btn-blue"/>
+ </div>
+ </form>
+ </section>
+</section> \ No newline at end of file
diff --git a/app/Templates/action_params.php b/app/Templates/action_params.php
new file mode 100644
index 00000000..15a1d420
--- /dev/null
+++ b/app/Templates/action_params.php
@@ -0,0 +1,43 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('Automatic actions for the project "%s"', $project['name']) ?></h2>
+ <ul>
+ <li><a href="?controller=project"><?= t('All projects') ?></a></li>
+ </ul>
+ </div>
+ <section>
+
+ <h3><?= t('Define action parameters') ?></h3>
+ <form method="post" action="?controller=action&amp;action=create&amp;project_id=<?= $project['id'] ?>" autocomplete="off">
+
+ <?= Helper\form_hidden('project_id', $values) ?>
+ <?= Helper\form_hidden('event_name', $values) ?>
+ <?= Helper\form_hidden('action_name', $values) ?>
+
+ <?php foreach ($action_params as $param_name => $param_desc): ?>
+
+ <?php if (Helper\contains($param_name, 'column_id')): ?>
+ <?= Helper\form_label($param_desc, $param_name) ?>
+ <?= Helper\form_select('params['.$param_name.']', $columns_list, $values) ?><br/>
+ <?php elseif (Helper\contains($param_name, 'user_id')): ?>
+ <?= Helper\form_label($param_desc, $param_name) ?>
+ <?= Helper\form_select('params['.$param_name.']', $users_list, $values) ?><br/>
+ <?php elseif (Helper\contains($param_name, 'project_id')): ?>
+ <?= Helper\form_label($param_desc, $param_name) ?>
+ <?= Helper\form_select('params['.$param_name.']', $projects_list, $values) ?><br/>
+ <?php elseif (Helper\contains($param_name, 'color_id')): ?>
+ <?= Helper\form_label($param_desc, $param_name) ?>
+ <?= Helper\form_select('params['.$param_name.']', $colors_list, $values) ?><br/>
+ <?php elseif (Helper\contains($param_name, 'category_id')): ?>
+ <?= Helper\form_label($param_desc, $param_name) ?>
+ <?= Helper\form_select('params['.$param_name.']', $categories_list, $values) ?><br/>
+ <?php endif ?>
+ <?php endforeach ?>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save this action') ?>" class="btn btn-blue"/>
+ <?= t('or') ?> <a href="?controller=action&amp;action=index&amp;project_id=<?= $project['id'] ?>"><?= t('cancel') ?></a>
+ </div>
+ </form>
+ </section>
+</section> \ No newline at end of file
diff --git a/app/Templates/action_remove.php b/app/Templates/action_remove.php
new file mode 100644
index 00000000..b90136e8
--- /dev/null
+++ b/app/Templates/action_remove.php
@@ -0,0 +1,16 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('Remove an automatic action') ?></h2>
+ </div>
+
+ <div class="confirm">
+ <p class="alert alert-info">
+ <?= t('Do you really want to remove this action: "%s"?', Helper\in_list($action['event_name'], $available_events).'/'.Helper\in_list($action['action_name'], $available_actions)) ?>
+ </p>
+
+ <div class="form-actions">
+ <a href="?controller=action&amp;action=remove&amp;action_id=<?= $action['id'] ?>" class="btn btn-red"><?= t('Yes') ?></a>
+ <?= t('or') ?> <a href="?controller=action&amp;action=index&amp;project_id=<?= $action['project_id'] ?>"><?= t('cancel') ?></a>
+ </div>
+ </div>
+</section> \ No newline at end of file
diff --git a/app/Templates/app_notfound.php b/app/Templates/app_notfound.php
new file mode 100644
index 00000000..734d16a4
--- /dev/null
+++ b/app/Templates/app_notfound.php
@@ -0,0 +1,9 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('Page not found') ?></h2>
+ </div>
+
+ <p class="alert alert-error">
+ <?= t('Sorry, I didn\'t found this information in my database!') ?>
+ </p>
+</section> \ No newline at end of file
diff --git a/app/Templates/board_assign.php b/app/Templates/board_assign.php
new file mode 100644
index 00000000..74448a5c
--- /dev/null
+++ b/app/Templates/board_assign.php
@@ -0,0 +1,35 @@
+<section id="main">
+
+ <div class="page-header board">
+ <h2>
+ <?= t('Project "%s"', $current_project_name) ?>
+ </h2>
+ <ul>
+ <?php foreach ($projects as $project_id => $project_name): ?>
+ <?php if ($project_id != $current_project_id): ?>
+ <li>
+ <a href="?controller=board&amp;action=show&amp;project_id=<?= $project_id ?>"><?= Helper\escape($project_name) ?></a>
+ </li>
+ <?php endif ?>
+ <?php endforeach ?>
+ </ul>
+ </div>
+
+ <section>
+ <h3><?= t('Change assignee for the task "%s"', $values['title']) ?></h3>
+ <form method="post" action="?controller=board&amp;action=assignTask" autocomplete="off">
+
+ <?= Helper\form_hidden('id', $values) ?>
+ <?= Helper\form_hidden('project_id', $values) ?>
+
+ <?= Helper\form_label(t('Assignee'), 'owner_id') ?>
+ <?= Helper\form_select('owner_id', $users_list, $values, $errors) ?><br/>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ <?= t('or') ?> <a href="?controller=board&amp;action=show&amp;project_id=<?= $values['project_id'] ?>"><?= t('cancel') ?></a>
+ </div>
+ </form>
+ </section>
+
+</div> \ No newline at end of file
diff --git a/app/Templates/board_edit.php b/app/Templates/board_edit.php
new file mode 100644
index 00000000..575536a8
--- /dev/null
+++ b/app/Templates/board_edit.php
@@ -0,0 +1,66 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('Edit the board for "%s"', $project['name']) ?></h2>
+ <ul>
+ <li><a href="?controller=project"><?= t('All projects') ?></a></li>
+ </ul>
+ </div>
+ <section>
+
+ <h3><?= t('Change columns') ?></h3>
+ <form method="post" action="?controller=board&amp;action=update&amp;project_id=<?= $project['id'] ?>" autocomplete="off">
+
+ <?php $i = 0; ?>
+ <table>
+ <tr>
+ <th><?= t('Position') ?></th>
+ <th><?= t('Column title') ?></th>
+ <th><?= t('Task limit') ?></th>
+ <th><?= t('Actions') ?></th>
+ </tr>
+ <?php foreach ($columns as $column): ?>
+ <tr>
+ <td><?= Helper\form_label(t('Column %d', ++$i), 'title['.$column['id'].']', array('title="column_id='.$column['id'].'"')) ?></td>
+ <td><?= Helper\form_text('title['.$column['id'].']', $values, $errors, array('required')) ?></td>
+ <td><?= Helper\form_number('task_limit['.$column['id'].']', $values, $errors, array('placeholder="'.t('limit').'"')) ?></td>
+ <td>
+ <ul>
+ <?php if ($column['position'] != 1): ?>
+ <li>
+ <a href="?controller=board&amp;action=moveUp&amp;project_id=<?= $project['id'] ?>&amp;column_id=<?= $column['id'] ?>"><?= t('Move Up') ?></a>
+ </li>
+ <?php endif ?>
+ <?php if ($column['position'] != count($columns)): ?>
+ <li>
+ <a href="?controller=board&amp;action=moveDown&amp;project_id=<?= $project['id'] ?>&amp;column_id=<?= $column['id'] ?>"><?= t('Move Down') ?></a>
+ </li>
+ <?php endif ?>
+ <li>
+ <a href="?controller=board&amp;action=confirm&amp;project_id=<?= $project['id'] ?>&amp;column_id=<?= $column['id'] ?>"><?= t('Remove') ?></a>
+ </li>
+ </ul>
+ </td>
+ </tr>
+ <?php endforeach ?>
+ </table>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Update') ?>" class="btn btn-blue"/>
+ <?= t('or') ?> <a href="?controller=project"><?= t('cancel') ?></a>
+ </div>
+ </form>
+
+ <h3><?= t('Add a new column') ?></h3>
+ <form method="post" action="?controller=board&amp;action=add&amp;project_id=<?= $project['id'] ?>" autocomplete="off">
+
+ <?= Helper\form_hidden('project_id', $values) ?>
+ <?= Helper\form_label(t('Title'), 'title') ?>
+ <?= Helper\form_text('title', $values, $errors, array('required')) ?>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Add this column') ?>" class="btn btn-blue"/>
+ <?= t('or') ?> <a href="?controller=project"><?= t('cancel') ?></a>
+ </div>
+ </form>
+ </section>
+</section> \ No newline at end of file
diff --git a/app/Templates/board_index.php b/app/Templates/board_index.php
new file mode 100644
index 00000000..989c2e06
--- /dev/null
+++ b/app/Templates/board_index.php
@@ -0,0 +1,42 @@
+<section id="main">
+
+ <div class="page-header board">
+ <h2>
+ <?= t('Project "%s"', $current_project_name) ?>
+ </h2>
+ <ul>
+ <?php foreach ($projects as $project_id => $project_name): ?>
+ <?php if ($project_id != $current_project_id): ?>
+ <li>
+ <a href="?controller=board&amp;action=show&amp;project_id=<?= $project_id ?>"><?= Helper\escape($project_name) ?></a>
+ </li>
+ <?php endif ?>
+ <?php endforeach ?>
+ </ul>
+ </div>
+
+ <div class="project-menu">
+ <ul>
+ <li>
+ <?= t('Filter by user') ?>
+ <?= Helper\form_select('user_id', $users, $filters) ?>
+ </li>
+ <li>
+ <?= t('Filter by category') ?>
+ <?= Helper\form_select('category_id', $categories, $filters) ?>
+ </li>
+ <li><a href="#" id="filter-due-date"><?= t('Filter by due date') ?></a></li>
+ <li><a href="?controller=project&amp;action=search&amp;project_id=<?= $current_project_id ?>"><?= t('Search') ?></a></li>
+ <li><a href="?controller=project&amp;action=tasks&amp;project_id=<?= $current_project_id ?>"><?= t('Completed tasks') ?></a></li>
+ </ul>
+ </div>
+
+ <?php if (empty($board)): ?>
+ <p class="alert alert-error"><?= t('There is no column in your project!') ?></p>
+ <?php else: ?>
+ <?= Helper\template('board_show', array('current_project_id' => $current_project_id, 'board' => $board, 'categories' => $categories)) ?>
+ <?php endif ?>
+
+</section>
+
+<script type="text/javascript" src="assets/js/board.js"></script>
diff --git a/app/Templates/board_public.php b/app/Templates/board_public.php
new file mode 100644
index 00000000..0808079e
--- /dev/null
+++ b/app/Templates/board_public.php
@@ -0,0 +1,79 @@
+<section id="main" class="public-board">
+
+ <?php if (empty($columns)): ?>
+ <p class="alert alert-error"><?= t('There is no column in your project!') ?></p>
+ <?php else: ?>
+ <table id="board">
+ <tr>
+ <?php $column_with = round(100 / count($columns), 2); ?>
+ <?php foreach ($columns as $column): ?>
+ <th width="<?= $column_with ?>%">
+ <?= Helper\escape($column['title']) ?>
+ <?php if ($column['task_limit']): ?>
+ <span title="<?= t('Task limit') ?>" class="task-limit">(<?= Helper\escape(count($column['tasks']).'/'.$column['task_limit']) ?>)</span>
+ <?php endif ?>
+ </th>
+ <?php endforeach ?>
+ </tr>
+ <tr>
+ <?php foreach ($columns as $column): ?>
+ <td class="column <?= $column['task_limit'] && count($column['tasks']) > $column['task_limit'] ? 'task-limit-warning' : '' ?>">
+ <?php foreach ($column['tasks'] as $task): ?>
+ <div class="task task-<?= $task['color_id'] ?>">
+
+ #<?= $task['id'] ?> -
+
+ <span class="task-user">
+ <?php if (! empty($task['owner_id'])): ?>
+ <?= t('Assigned to %s', $task['username']) ?>
+ <?php else: ?>
+ <span class="task-nobody"><?= t('Nobody assigned') ?></span>
+ <?php endif ?>
+ </span>
+
+ <?php if ($task['score']): ?>
+ <span class="task-score"><?= Helper\escape($task['score']) ?></span>
+ <?php endif ?>
+
+ <div class="task-title">
+ <?= Helper\escape($task['title']) ?>
+ </div>
+
+ <?php if ($task['category_id']): ?>
+ <div class="task-category-container">
+ <span class="task-category">
+ <?= Helper\in_list($task['category_id'], $categories) ?>
+ </span>
+ </div>
+ <?php endif ?>
+
+ <?php if (! empty($task['date_due']) || ! empty($task['nb_comments']) || ! empty($task['description'])): ?>
+ <div class="task-footer">
+
+ <?php if (! empty($task['date_due'])): ?>
+ <div class="task-date">
+ <?= dt('%B %e, %G', $task['date_due']) ?>
+ </div>
+ <?php endif ?>
+
+ <div class="task-icons">
+ <?php if (! empty($task['nb_comments'])): ?>
+ <?= $task['nb_comments'] ?> <i class="fa fa-comment-o" title="<?= p($task['nb_comments'], t('%d comment', $task['nb_comments']), t('%d comments', $task['nb_comments'])) ?>"></i>
+ <?php endif ?>
+
+ <?php if (! empty($task['description'])): ?>
+ <i class="fa fa-file-text-o" title="<?= t('Description') ?>"></i>
+ <?php endif ?>
+ </div>
+ </div>
+ <?php endif ?>
+
+ </div>
+ <?php endforeach ?>
+ </td>
+ <?php endforeach ?>
+ </tr>
+ </table>
+ <?php endif ?>
+
+</section> \ No newline at end of file
diff --git a/app/Templates/board_remove.php b/app/Templates/board_remove.php
new file mode 100644
index 00000000..b406eb38
--- /dev/null
+++ b/app/Templates/board_remove.php
@@ -0,0 +1,17 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('Remove a column') ?></h2>
+ </div>
+
+ <div class="confirm">
+ <p class="alert alert-info">
+ <?= t('Do you really want to remove this column: "%s"?', $column['title']) ?>
+ <?= t('This action will REMOVE ALL TASKS associated to this column!') ?>
+ </p>
+
+ <div class="form-actions">
+ <a href="?controller=board&amp;action=remove&amp;column_id=<?= $column['id'] ?>" class="btn btn-red"><?= t('Yes') ?></a>
+ <?= t('or') ?> <a href="?controller=board&amp;action=edit&amp;project_id=<?= $column['project_id'] ?>"><?= t('cancel') ?></a>
+ </div>
+ </div>
+</section> \ No newline at end of file
diff --git a/app/Templates/board_show.php b/app/Templates/board_show.php
new file mode 100644
index 00000000..719e3bdd
--- /dev/null
+++ b/app/Templates/board_show.php
@@ -0,0 +1,88 @@
+<table id="board" data-project-id="<?= $current_project_id ?>" data-time="<?= time() ?>" data-check-interval="<?= BOARD_CHECK_INTERVAL ?>">
+<tr>
+ <?php $column_with = round(100 / count($board), 2); ?>
+ <?php foreach ($board as $column): ?>
+ <th width="<?= $column_with ?>%">
+ <a href="?controller=task&amp;action=create&amp;project_id=<?= $column['project_id'] ?>&amp;column_id=<?= $column['id'] ?>" title="<?= t('Add a new task') ?>">+</a>
+ <?= Helper\escape($column['title']) ?>
+ <?php if ($column['task_limit']): ?>
+ <span title="<?= t('Task limit') ?>" class="task-limit">
+ (
+ <span id="task-number-column-<?= $column['id'] ?>"><?= count($column['tasks']) ?></span>
+ /
+ <?= Helper\escape($column['task_limit']) ?>
+ )
+ </span>
+ <?php endif ?>
+ </th>
+ <?php endforeach ?>
+</tr>
+<tr>
+ <?php foreach ($board as $column): ?>
+ <td
+ id="column-<?= $column['id'] ?>"
+ class="column <?= $column['task_limit'] && count($column['tasks']) > $column['task_limit'] ? 'task-limit-warning' : '' ?>"
+ data-column-id="<?= $column['id'] ?>"
+ data-task-limit="<?= $column['task_limit'] ?>"
+ >
+ <?php foreach ($column['tasks'] as $task): ?>
+ <div class="task draggable-item task-<?= $task['color_id'] ?>"
+ data-task-id="<?= $task['id'] ?>"
+ data-owner-id="<?= $task['owner_id'] ?>"
+ data-category-id="<?= $task['category_id'] ?>"
+ data-due-date="<?= $task['date_due'] ?>"
+ title="<?= t('View this task') ?>">
+
+ <a href="?controller=task&amp;action=edit&amp;task_id=<?= $task['id'] ?>" title="<?= t('Edit this task') ?>">#<?= $task['id'] ?></a> -
+
+ <span class="task-user">
+ <?php if (! empty($task['owner_id'])): ?>
+ <a href="?controller=board&amp;action=assign&amp;task_id=<?= $task['id'] ?>" title="<?= t('Change assignee') ?>"><?= t('Assigned to %s', $task['username']) ?></a>
+ <?php else: ?>
+ <a href="?controller=board&amp;action=assign&amp;task_id=<?= $task['id'] ?>" title="<?= t('Change assignee') ?>" class="task-nobody"><?= t('Nobody assigned') ?></a>
+ <?php endif ?>
+ </span>
+
+ <?php if ($task['score']): ?>
+ <span class="task-score"><?= Helper\escape($task['score']) ?></span>
+ <?php endif ?>
+
+ <div class="task-title">
+ <a href="?controller=task&amp;action=show&amp;task_id=<?= $task['id'] ?>" title="<?= t('View this task') ?>"><?= Helper\escape($task['title']) ?></a>
+ </div>
+
+ <?php if ($task['category_id']): ?>
+ <div class="task-category-container">
+ <span class="task-category">
+ <?= Helper\in_list($task['category_id'], $categories) ?>
+ </span>
+ </div>
+ <?php endif ?>
+
+ <?php if (! empty($task['date_due']) || ! empty($task['nb_comments']) || ! empty($task['description'])): ?>
+ <div class="task-footer">
+
+ <?php if (! empty($task['date_due'])): ?>
+ <div class="task-date">
+ <?= dt('%B %e, %G', $task['date_due']) ?>
+ </div>
+ <?php endif ?>
+
+ <div class="task-icons">
+ <?php if (! empty($task['nb_comments'])): ?>
+ <?= $task['nb_comments'] ?> <i class="fa fa-comment-o" title="<?= p($task['nb_comments'], t('%d comment', $task['nb_comments']), t('%d comments', $task['nb_comments'])) ?>"></i>
+ <?php endif ?>
+
+ <?php if (! empty($task['description'])): ?>
+ <i class="fa fa-file-text-o" title="<?= t('Description') ?>"></i>
+ <?php endif ?>
+ </div>
+ </div>
+ <?php endif ?>
+
+ </div>
+ <?php endforeach ?>
+ </td>
+ <?php endforeach ?>
+</tr>
+</table>
diff --git a/app/Templates/category_edit.php b/app/Templates/category_edit.php
new file mode 100644
index 00000000..99ba0c7c
--- /dev/null
+++ b/app/Templates/category_edit.php
@@ -0,0 +1,24 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('Category modification for the project "%s"', $project['name']) ?></h2>
+ <ul>
+ <li><a href="?controller=project"><?= t('All projects') ?></a></li>
+ </ul>
+ </div>
+ <section>
+
+ <form method="post" action="?controller=category&amp;action=update&amp;project_id=<?= $project['id'] ?>" autocomplete="off">
+
+ <?= Helper\form_hidden('id', $values) ?>
+ <?= Helper\form_hidden('project_id', $values) ?>
+
+ <?= Helper\form_label(t('Category Name'), 'name') ?>
+ <?= Helper\form_text('name', $values, $errors, array('required')) ?>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ </div>
+ </form>
+
+ </section>
+</section> \ No newline at end of file
diff --git a/app/Templates/category_index.php b/app/Templates/category_index.php
new file mode 100644
index 00000000..db986143
--- /dev/null
+++ b/app/Templates/category_index.php
@@ -0,0 +1,48 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('Categories for the project "%s"', $project['name']) ?></h2>
+ <ul>
+ <li><a href="?controller=project"><?= t('All projects') ?></a></li>
+ </ul>
+ </div>
+ <section>
+
+ <?php if (! empty($categories)): ?>
+ <table>
+ <tr>
+ <th><?= t('Category Name') ?></th>
+ <th><?= t('Actions') ?></th>
+ </tr>
+ <?php foreach ($categories as $category_id => $category_name): ?>
+ <tr>
+ <td><?= Helper\escape($category_name) ?></td>
+ <td>
+ <ul>
+ <li>
+ <a href="?controller=category&amp;action=edit&amp;project_id=<?= $project['id'] ?>&amp;category_id=<?= $category_id ?>"><?= t('Edit') ?></a>
+ </li>
+ <li>
+ <a href="?controller=category&amp;action=confirm&amp;project_id=<?= $project['id'] ?>&amp;category_id=<?= $category_id ?>"><?= t('Remove') ?></a>
+ </li>
+ </ul>
+ </td>
+ </tr>
+ <?php endforeach ?>
+ </table>
+ <?php endif ?>
+
+ <h3><?= t('Add a new category') ?></h3>
+ <form method="post" action="?controller=category&amp;action=save&amp;project_id=<?= $project['id'] ?>" autocomplete="off">
+
+ <?= Helper\form_hidden('project_id', $values) ?>
+
+ <?= Helper\form_label(t('Category Name'), 'name') ?>
+ <?= Helper\form_text('name', $values, $errors, array('required')) ?>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ </div>
+ </form>
+
+ </section>
+</section> \ No newline at end of file
diff --git a/app/Templates/category_remove.php b/app/Templates/category_remove.php
new file mode 100644
index 00000000..cc2eb678
--- /dev/null
+++ b/app/Templates/category_remove.php
@@ -0,0 +1,16 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('Remove a category') ?></h2>
+ </div>
+
+ <div class="confirm">
+ <p class="alert alert-info">
+ <?= t('Do you really want to remove this category: "%s"?', $category['name']) ?>
+ </p>
+
+ <div class="form-actions">
+ <a href="?controller=category&amp;action=remove&amp;project_id=<?= $project['id'] ?>&amp;category_id=<?= $category['id'] ?>" class="btn btn-red"><?= t('Yes') ?></a>
+ <?= t('or') ?> <a href="?controller=category&amp;project_id=<?= $project['id'] ?>"><?= t('cancel') ?></a>
+ </div>
+ </div>
+</section> \ No newline at end of file
diff --git a/app/Templates/comment_forbidden.php b/app/Templates/comment_forbidden.php
new file mode 100644
index 00000000..eeea8404
--- /dev/null
+++ b/app/Templates/comment_forbidden.php
@@ -0,0 +1,9 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('Forbidden') ?></h2>
+ </div>
+
+ <p class="alert alert-error">
+ <?= t('Only administrators or the creator of the comment can access to this page.') ?>
+ </p>
+</section> \ No newline at end of file
diff --git a/app/Templates/comment_remove.php b/app/Templates/comment_remove.php
new file mode 100644
index 00000000..ad1b8e4a
--- /dev/null
+++ b/app/Templates/comment_remove.php
@@ -0,0 +1,18 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('Remove a comment') ?></h2>
+ </div>
+
+ <div class="confirm">
+ <p class="alert alert-info">
+ <?= t('Do you really want to remove this comment?') ?>
+ </p>
+
+ <?= Helper\template('comment_show', array('comment' => $comment)) ?>
+
+ <div class="form-actions">
+ <a href="?controller=comment&amp;action=remove&amp;project_id=<?= $project_id ?>&amp;comment_id=<?= $comment['id'] ?>" class="btn btn-red"><?= t('Yes') ?></a>
+ <?= t('or') ?> <a href="?controller=task&amp;action=show&amp;task_id=<?= $comment['task_id'] ?>#comment-<?= $comment['id'] ?>"><?= t('cancel') ?></a>
+ </div>
+ </div>
+</section> \ No newline at end of file
diff --git a/app/Templates/comment_show.php b/app/Templates/comment_show.php
new file mode 100644
index 00000000..24bf9070
--- /dev/null
+++ b/app/Templates/comment_show.php
@@ -0,0 +1,36 @@
+<div class="<?= isset($display_edit_form) && $display_edit_form === true ? 'comment-edit' : 'comment' ?>" id="comment-<?= $comment['id'] ?>">
+ <p class="comment-title">
+ <span class="comment-username"><?= Helper\escape($comment['username']) ?></span> @ <span class="comment-date"><?= dt('%B %e, %G at %k:%M %p', $comment['date']) ?></span>
+ </p>
+ <?php if (isset($task)): ?>
+ <ul class="comment-actions">
+ <li><a href="#comment-<?= $comment['id'] ?>"><?= t('link') ?></a></li>
+ <?php if (Helper\is_admin() || Helper\is_current_user($comment['user_id'])): ?>
+ <li>
+ <a href="?controller=comment&amp;action=confirm&amp;project_id=<?= $task['project_id'] ?>&amp;comment_id=<?= $comment['id'] ?>"><?= t('remove') ?></a>
+ </li>
+ <li>
+ <a href="?controller=comment&amp;action=edit&amp;task_id=<?= $task['id'] ?>&amp;comment_id=<?= $comment['id'] ?>#comment-<?= $comment['id'] ?>"><?= t('edit') ?></a>
+ </li>
+ <?php endif ?>
+ </ul>
+ <?php endif ?>
+
+ <?php if (isset($display_edit_form) && $display_edit_form === true): ?>
+ <form method="post" action="?controller=comment&amp;action=update&amp;task_id=<?= $task['id'] ?>&amp;comment_id=<?= $comment['id'] ?>" autocomplete="off">
+
+ <?= Helper\form_hidden('id', $values) ?>
+ <?= Helper\form_textarea('comment', $values, $errors, array('required', 'placeholder="'.t('Leave a comment').'"')) ?><br/>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Update this comment') ?>" class="btn btn-blue"/>
+ <?= t('or') ?>
+ <a href="?controller=task&amp;action=show&amp;task_id=<?= $task['id'] ?>"><?= t('cancel') ?></a>
+ </div>
+ </form>
+ <?php else: ?>
+ <div class="markdown">
+ <?= Helper\markdown($comment['comment']) ?>
+ </div>
+ <?php endif ?>
+</div> \ No newline at end of file
diff --git a/app/Templates/config_index.php b/app/Templates/config_index.php
new file mode 100644
index 00000000..6c610d2b
--- /dev/null
+++ b/app/Templates/config_index.php
@@ -0,0 +1,120 @@
+<section id="main">
+
+ <?php if ($user['is_admin']): ?>
+ <div class="page-header">
+ <h2><?= t('Application settings') ?></h2>
+ </div>
+ <section>
+ <form method="post" action="?controller=config&amp;action=save" autocomplete="off">
+
+ <?= Helper\form_label(t('Language'), 'language') ?>
+ <?= Helper\form_select('language', $languages, $values, $errors) ?><br/>
+
+ <?= Helper\form_label(t('Timezone'), 'timezone') ?>
+ <?= Helper\form_select('timezone', $timezones, $values, $errors) ?><br/>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ </div>
+ </form>
+ </section>
+ <?php endif ?>
+
+ <div class="page-header">
+ <h2><?= t('User settings') ?></h2>
+ </div>
+ <section class="settings">
+ <ul>
+ <li>
+ <strong><?= t('My default project:') ?> </strong>
+ <?= (isset($user['default_project_id']) && isset($projects[$user['default_project_id']])) ? Helper\escape($projects[$user['default_project_id']]) : t('None') ?>,
+ <a href="?controller=user&amp;action=edit&amp;user_id=<?= $user['id'] ?>"><?= t('edit') ?></a>
+ </li>
+ </ul>
+ </section>
+
+ <?php if ($user['is_admin']): ?>
+ <div class="page-header">
+ <h2><?= t('More information') ?></h2>
+ </div>
+ <section class="settings">
+ <ul>
+ <li><a href="?controller=config&amp;action=tokens"><?= t('Reset all tokens') ?></a></li>
+ <li>
+ <?= t('Webhooks token:') ?>
+ <strong><?= Helper\escape($values['webhooks_token']) ?></strong>
+ </li>
+ <?php if (DB_DRIVER === 'sqlite'): ?>
+ <li>
+ <?= t('Database size:') ?>
+ <strong><?= Helper\format_bytes($db_size) ?></strong>
+ </li>
+ <li>
+ <a href="?controller=config&amp;action=downloadDb"><?= t('Download the database') ?></a>
+ <?= t('(Gzip compressed Sqlite file)') ?>
+ </li>
+ <li>
+ <a href="?controller=config&amp;action=optimizeDb"><?= t('Optimize the database') ?></a>
+ <?= t('(VACUUM command)') ?>
+ </li>
+ <?php endif ?>
+ <li>
+ <?= t('Official website:') ?>
+ <a href="http://kanboard.net/" target="_blank" rel="noreferer">http://kanboard.net/</a>
+ </li>
+ <li>
+ <?= t('Application version:') ?>
+ <?= APP_VERSION ?>
+ </li>
+ </ul>
+ </section>
+ <?php endif ?>
+
+ <div class="page-header" id="last-logins">
+ <h2><?= t('Last logins') ?></h2>
+ </div>
+ <?php if (! empty($last_logins)): ?>
+ <table class="table-small table-hover">
+ <tr>
+ <th><?= t('Login date') ?></th>
+ <th><?= t('Authentication method') ?></th>
+ <th><?= t('IP address') ?></th>
+ <th><?= t('User agent') ?></th>
+ </tr>
+ <?php foreach($last_logins as $login): ?>
+ <tr>
+ <td><?= dt('%B %e, %G at %k:%M %p', $login['date_creation']) ?></td>
+ <td><?= Helper\escape($login['auth_type']) ?></td>
+ <td><?= Helper\escape($login['ip']) ?></td>
+ <td><?= Helper\escape($login['user_agent']) ?></td>
+ </tr>
+ <?php endforeach ?>
+ </table>
+ <?php endif ?>
+
+ <div class="page-header" id="remember-me">
+ <h2><?= t('Persistent connections') ?></h2>
+ </div>
+ <?php if (empty($remember_me_sessions)): ?>
+ <p class="alert alert-info"><?= t('No session') ?></p>
+ <?php else: ?>
+ <table class="table-small table-hover">
+ <tr>
+ <th><?= t('Creation date') ?></th>
+ <th><?= t('Expiration date') ?></th>
+ <th><?= t('IP address') ?></th>
+ <th><?= t('User agent') ?></th>
+ <th><?= t('Action') ?></th>
+ </tr>
+ <?php foreach($remember_me_sessions as $session): ?>
+ <tr>
+ <td><?= dt('%B %e, %G at %k:%M %p', $session['date_creation']) ?></td>
+ <td><?= dt('%B %e, %G at %k:%M %p', $session['expiration']) ?></td>
+ <td><?= Helper\escape($session['ip']) ?></td>
+ <td><?= Helper\escape($session['user_agent']) ?></td>
+ <td><a href="?controller=config&amp;action=removeRememberMeToken&amp;id=<?= $session['id'] ?>"><?= t('Remove') ?></a></td>
+ </tr>
+ <?php endforeach ?>
+ </table>
+ <?php endif ?>
+</section>
diff --git a/app/Templates/layout.php b/app/Templates/layout.php
new file mode 100644
index 00000000..0bb8446d
--- /dev/null
+++ b/app/Templates/layout.php
@@ -0,0 +1,61 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+
+ <meta name="viewport" content="width=device-width">
+ <meta name="mobile-web-app-capable" content="yes">
+
+ <script src="assets/js/jquery-1.11.1.min.js"></script>
+ <script src="assets/js/jquery-ui-1.10.4.custom.min.js"></script>
+ <script src="assets/js/jquery.ui.touch-punch.min.js"></script>
+
+ <link rel="stylesheet" href="assets/css/app.css" media="screen">
+ <link rel="stylesheet" href="assets/css/font-awesome.min.css" media="screen">
+
+ <link rel="icon" type="image/png" href="assets/img/favicon.png">
+ <link rel="apple-touch-icon" href="assets/img/touch-icon-iphone.png">
+ <link rel="apple-touch-icon" sizes="72x72" href="assets/img/touch-icon-ipad.png">
+ <link rel="apple-touch-icon" sizes="114x114" href="assets/img/touch-icon-iphone-retina.png">
+ <link rel="apple-touch-icon" sizes="144x144" href="assets/img/touch-icon-ipad-retina.png">
+
+ <title><?= isset($title) ? Helper\escape($title).' - Kanboard' : 'Kanboard' ?></title>
+ <?php if (isset($auto_refresh)): ?>
+ <meta http-equiv="refresh" content="<?= BOARD_PUBLIC_CHECK_INTERVAL ?>" >
+ <?php endif ?>
+ </head>
+ <body>
+ <?php if (isset($no_layout)): ?>
+ <?= $content_for_layout ?>
+ <?php else: ?>
+ <header>
+ <nav>
+ <a class="logo" href="?">kan<span>board</span></a>
+ <ul>
+ <li <?= isset($menu) && $menu === 'boards' ? 'class="active"' : '' ?>>
+ <a href="?controller=board"><?= t('Boards') ?></a>
+ </li>
+ <li <?= isset($menu) && $menu === 'projects' ? 'class="active"' : '' ?>>
+ <a href="?controller=project"><?= t('Projects') ?></a>
+ </li>
+ <li <?= isset($menu) && $menu === 'users' ? 'class="active"' : '' ?>>
+ <a href="?controller=user"><?= t('Users') ?></a>
+ </li>
+ <li <?= isset($menu) && $menu === 'config' ? 'class="active"' : '' ?>>
+ <a href="?controller=config"><?= t('Settings') ?></a>
+ </li>
+ <li>
+ <a href="?controller=user&amp;action=logout"><?= t('Logout') ?></a>
+ (<?= Helper\escape(Helper\get_username()) ?>)
+ </li>
+ </ul>
+ </nav>
+ </header>
+ <section class="page">
+ <?= Helper\flash('<div class="alert alert-success alert-fade-out">%s</div>') ?>
+ <?= Helper\flash_error('<div class="alert alert-error">%s</div>') ?>
+ <?= $content_for_layout ?>
+ </section>
+ <?php endif ?>
+ </body>
+</html> \ No newline at end of file
diff --git a/app/Templates/project_edit.php b/app/Templates/project_edit.php
new file mode 100644
index 00000000..557986bf
--- /dev/null
+++ b/app/Templates/project_edit.php
@@ -0,0 +1,24 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('Edit project') ?></h2>
+ <ul>
+ <li><a href="?controller=project"><?= t('All projects') ?></a></li>
+ </ul>
+ </div>
+ <section>
+ <form method="post" action="?controller=project&amp;action=update&amp;project_id=<?= $values['id'] ?>" autocomplete="off">
+
+ <?= Helper\form_hidden('id', $values) ?>
+
+ <?= Helper\form_label(t('Name'), 'name') ?>
+ <?= Helper\form_text('name', $values, $errors, array('required')) ?>
+
+ <?= Helper\form_checkbox('is_active', t('Activated'), 1, isset($values['is_active']) && $values['is_active'] == 1 ? true : false) ?><br/>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ <?= t('or') ?> <a href="?controller=project"><?= t('cancel') ?></a>
+ </div>
+ </form>
+ </section>
+</section> \ No newline at end of file
diff --git a/app/Templates/project_forbidden.php b/app/Templates/project_forbidden.php
new file mode 100644
index 00000000..1cba7b58
--- /dev/null
+++ b/app/Templates/project_forbidden.php
@@ -0,0 +1,9 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('Forbidden') ?></h2>
+ </div>
+
+ <p class="alert alert-error">
+ <?= t('You are not allowed to access to this project.') ?>
+ </p>
+</section> \ No newline at end of file
diff --git a/app/Templates/project_index.php b/app/Templates/project_index.php
new file mode 100644
index 00000000..df153fe7
--- /dev/null
+++ b/app/Templates/project_index.php
@@ -0,0 +1,98 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('Projects') ?><span id="page-counter"> (<?= $nb_projects ?>)</span></h2>
+ <?php if (Helper\is_admin()): ?>
+ <ul>
+ <li><a href="?controller=project&amp;action=create"><?= t('New project') ?></a></li>
+ </ul>
+ <?php endif ?>
+ </div>
+ <section>
+ <?php if (empty($projects)): ?>
+ <p class="alert"><?= t('No project') ?></p>
+ <?php else: ?>
+ <table>
+ <tr>
+ <th><?= t('Project') ?></th>
+ <th><?= t('Status') ?></th>
+ <th><?= t('Tasks') ?></th>
+ <th><?= t('Board') ?></th>
+
+ <?php if (Helper\is_admin()): ?>
+ <th><?= t('Actions') ?></th>
+ <?php endif ?>
+ </tr>
+ <?php foreach ($projects as $project): ?>
+ <tr>
+ <td>
+ <a href="?controller=board&amp;action=show&amp;project_id=<?= $project['id'] ?>" title="project_id=<?= $project['id'] ?>"><?= Helper\escape($project['name']) ?></a>
+ </td>
+ <td>
+ <?= $project['is_active'] ? t('Active') : t('Inactive') ?>
+ </td>
+ <td>
+ <?php if ($project['nb_tasks'] > 0): ?>
+
+ <?php if ($project['nb_active_tasks'] > 0): ?>
+ <a href="?controller=board&amp;action=show&amp;project_id=<?= $project['id'] ?>"><?= t('%d tasks on the board', $project['nb_active_tasks']) ?></a>,
+ <?php endif ?>
+
+ <?php if ($project['nb_inactive_tasks'] > 0): ?>
+ <a href="?controller=project&amp;action=tasks&amp;project_id=<?= $project['id'] ?>"><?= t('%d closed tasks', $project['nb_inactive_tasks']) ?></a>,
+ <?php endif ?>
+
+ <?= t('%d tasks in total', $project['nb_tasks']) ?>
+
+ <?php else: ?>
+ <?= t('no task for this project') ?>
+ <?php endif ?>
+ </td>
+ <td>
+ <ul>
+ <?php foreach ($project['columns'] as $column): ?>
+ <li>
+ <span title="column_id=<?= $column['id'] ?>"><?= Helper\escape($column['title']) ?></span> (<?= $column['nb_active_tasks'] ?>)
+ </li>
+ <?php endforeach ?>
+ </ul>
+ </td>
+ <?php if (Helper\is_admin()): ?>
+ <td>
+ <ul>
+ <li>
+ <a href="?controller=category&amp;action=index&amp;project_id=<?= $project['id'] ?>"><?= t('Categories') ?></a>
+ </li>
+ <li>
+ <a href="?controller=project&amp;action=edit&amp;project_id=<?= $project['id'] ?>"><?= t('Edit project') ?></a>
+ </li>
+ <li>
+ <a href="?controller=project&amp;action=users&amp;project_id=<?= $project['id'] ?>"><?= t('Edit users access') ?></a>
+ </li>
+ <li>
+ <a href="?controller=board&amp;action=edit&amp;project_id=<?= $project['id'] ?>"><?= t('Edit board') ?></a>
+ </li>
+ <li>
+ <a href="?controller=action&amp;action=index&amp;project_id=<?= $project['id'] ?>"><?= t('Automatic actions') ?></a>
+ </li>
+ <li>
+ <?php if ($project['is_active']): ?>
+ <a href="?controller=project&amp;action=disable&amp;project_id=<?= $project['id'] ?>"><?= t('Disable') ?></a>
+ <?php else: ?>
+ <a href="?controller=project&amp;action=enable&amp;project_id=<?= $project['id'] ?>"><?= t('Enable') ?></a>
+ <?php endif ?>
+ </li>
+ <li>
+ <a href="?controller=project&amp;action=confirm&amp;project_id=<?= $project['id'] ?>"><?= t('Remove') ?></a>
+ </li>
+ <li>
+ <a href="?controller=board&amp;action=readonly&amp;token=<?= $project['token'] ?>" target="_blank"><?= t('Public link') ?></a>
+ </li>
+ </ul>
+ </td>
+ <?php endif ?>
+ </tr>
+ <?php endforeach ?>
+ </table>
+ <?php endif ?>
+ </section>
+</section> \ No newline at end of file
diff --git a/app/Templates/project_new.php b/app/Templates/project_new.php
new file mode 100644
index 00000000..2026d461
--- /dev/null
+++ b/app/Templates/project_new.php
@@ -0,0 +1,20 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('New project') ?></h2>
+ <ul>
+ <li><a href="?controller=project"><?= t('All projects') ?></a></li>
+ </ul>
+ </div>
+ <section>
+ <form method="post" action="?controller=project&amp;action=save" autocomplete="off">
+
+ <?= Helper\form_label(t('Name'), 'name') ?>
+ <?= Helper\form_text('name', $values, $errors, array('autofocus', 'required')) ?>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ <?= t('or') ?> <a href="?controller=project"><?= t('cancel') ?></a>
+ </div>
+ </form>
+ </section>
+</section> \ No newline at end of file
diff --git a/app/Templates/project_remove.php b/app/Templates/project_remove.php
new file mode 100644
index 00000000..e9f213b5
--- /dev/null
+++ b/app/Templates/project_remove.php
@@ -0,0 +1,16 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('Remove project') ?></h2>
+ </div>
+
+ <div class="confirm">
+ <p class="alert alert-info">
+ <?= t('Do you really want to remove this project: "%s"?', $project['name']) ?>
+ </p>
+
+ <div class="form-actions">
+ <a href="?controller=project&amp;action=remove&amp;project_id=<?= $project['id'] ?>" class="btn btn-red"><?= t('Yes') ?></a>
+ <?= t('or') ?> <a href="?controller=project"><?= t('cancel') ?></a>
+ </div>
+ </div>
+</section> \ No newline at end of file
diff --git a/app/Templates/project_search.php b/app/Templates/project_search.php
new file mode 100644
index 00000000..3594fd09
--- /dev/null
+++ b/app/Templates/project_search.php
@@ -0,0 +1,93 @@
+<section id="main">
+ <div class="page-header">
+ <h2>
+ <?= t('Search in the project "%s"', $project['name']) ?>
+ <?php if (! empty($nb_tasks)): ?>
+ <span id="page-counter"> (<?= $nb_tasks ?>)</span>
+ <?php endif ?>
+ </h2>
+ <ul>
+ <li><a href="?controller=board&amp;action=show&amp;project_id=<?= $project['id'] ?>"><?= t('Back to the board') ?></a></li>
+ <li><a href="?controller=project&amp;action=tasks&amp;project_id=<?= $project['id'] ?>"><?= t('Completed tasks') ?></a></li>
+ <li><a href="?controller=project&amp;action=index"><?= t('List of projects') ?></a></li>
+ </ul>
+ </div>
+ <section>
+ <form method="get" action="?" autocomplete="off">
+ <?= Helper\form_hidden('controller', $values) ?>
+ <?= Helper\form_hidden('action', $values) ?>
+ <?= Helper\form_hidden('project_id', $values) ?>
+ <?= Helper\form_text('search', $values, array(), array('autofocus', 'required', 'placeholder="'.t('Search').'"')) ?>
+ <input type="submit" value="<?= t('Search') ?>" class="btn btn-blue"/>
+ </form>
+
+ <?php if (empty($tasks) && ! empty($values['search'])): ?>
+ <p class="alert"><?= t('Nothing found.') ?></p>
+ <?php elseif (! empty($tasks)): ?>
+ <table>
+ <tr>
+ <th><?= t('Id') ?></th>
+ <th><?= t('Column') ?></th>
+ <th><?= t('Category') ?></th>
+ <th><?= t('Title') ?></th>
+ <th><?= t('Assignee') ?></th>
+ <th><?= t('Due date') ?></th>
+ <th><?= t('Date created') ?></th>
+ <th><?= t('Date completed') ?></th>
+ <th><?= t('Status') ?></th>
+ </tr>
+ <?php foreach ($tasks as $task): ?>
+ <tr>
+ <td class="task task-<?= $task['color_id'] ?>">
+ <a href="?controller=task&amp;action=show&amp;task_id=<?= $task['id'] ?>" title="<?= t('View this task') ?>"><?= Helper\escape($task['id']) ?></a>
+ </td>
+ <td>
+ <?= Helper\in_list($task['column_id'], $columns) ?>
+ </td>
+ <td>
+ <?= Helper\in_list($task['category_id'], $categories, '') ?>
+ </td>
+ <td>
+ <a href="?controller=task&amp;action=show&amp;task_id=<?= $task['id'] ?>" title="<?= t('View this task') ?>"><?= Helper\escape($task['title']) ?></a>
+ <div class="task-table-icons">
+ <?php if (! empty($task['nb_comments'])): ?>
+ <?= $task['nb_comments'] ?> <i class="fa fa-comment-o" title="<?= p($task['nb_comments'], t('%d comment', $task['nb_comments']), t('%d comments', $task['nb_comments'])) ?>"></i>
+ <?php endif ?>
+
+ <?php if (! empty($task['description'])): ?>
+ <i class="fa fa-file-text-o" title="<?= t('Description') ?>"></i>
+ <?php endif ?>
+ </div>
+ </td>
+ <td>
+ <?php if ($task['username']): ?>
+ <?= Helper\escape($task['username']) ?>
+ <?php else: ?>
+ <?= t('Unassigned') ?>
+ <?php endif ?>
+ </td>
+ <td>
+ <?= dt('%B %e, %G', $task['date_due']) ?>
+ </td>
+ <td>
+ <?= dt('%B %e, %G at %k:%M %p', $task['date_creation']) ?>
+ </td>
+ <td>
+ <?php if ($task['date_completed']): ?>
+ <?= dt('%B %e, %G at %k:%M %p', $task['date_completed']) ?>
+ <?php endif ?>
+ </td>
+ <td>
+ <?php if ($task['is_active'] == \Model\Task::STATUS_OPEN): ?>
+ <?= t('Open') ?>
+ <?php else: ?>
+ <?= t('Closed') ?>
+ <?php endif ?>
+ </td>
+ </tr>
+ <?php endforeach ?>
+ </table>
+ <?php endif ?>
+
+ </section>
+</section> \ No newline at end of file
diff --git a/app/Templates/project_tasks.php b/app/Templates/project_tasks.php
new file mode 100644
index 00000000..9f4263b8
--- /dev/null
+++ b/app/Templates/project_tasks.php
@@ -0,0 +1,71 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('Completed tasks for "%s"', $project['name']) ?><span id="page-counter"> (<?= $nb_tasks ?>)</span></h2>
+ <ul>
+ <li><a href="?controller=board&amp;action=show&amp;project_id=<?= $project['id'] ?>"><?= t('Back to the board') ?></a></li>
+ <li><a href="?controller=project&amp;action=search&amp;project_id=<?= $project['id'] ?>"><?= t('Search') ?></a></li>
+ <li><a href="?controller=project&amp;action=index"><?= t('List of projects') ?></a></li>
+ </ul>
+ </div>
+ <section>
+ <?php if (empty($tasks)): ?>
+ <p class="alert"><?= t('No task') ?></p>
+ <?php else: ?>
+ <table>
+ <tr>
+ <th><?= t('Id') ?></th>
+ <th><?= t('Column') ?></th>
+ <th><?= t('Category') ?></th>
+ <th><?= t('Title') ?></th>
+ <th><?= t('Assignee') ?></th>
+ <th><?= t('Due date') ?></th>
+ <th><?= t('Date created') ?></th>
+ <th><?= t('Date completed') ?></th>
+ </tr>
+ <?php foreach ($tasks as $task): ?>
+ <tr>
+ <td class="task task-<?= $task['color_id'] ?>">
+ <a href="?controller=task&amp;action=show&amp;task_id=<?= $task['id'] ?>" title="<?= t('View this task') ?>"><?= Helper\escape($task['id']) ?></a>
+ </td>
+ <td>
+ <?= Helper\in_list($task['column_id'], $columns) ?>
+ </td>
+ <td>
+ <?= Helper\in_list($task['category_id'], $categories, '') ?>
+ </td>
+ <td>
+ <a href="?controller=task&amp;action=show&amp;task_id=<?= $task['id'] ?>" title="<?= t('View this task') ?>"><?= Helper\escape($task['title']) ?></a>
+ <div class="task-table-icons">
+ <?php if (! empty($task['nb_comments'])): ?>
+ <?= $task['nb_comments'] ?> <i class="fa fa-comment-o" title="<?= p($task['nb_comments'], t('%d comment', $task['nb_comments']), t('%d comments', $task['nb_comments'])) ?>"></i>
+ <?php endif ?>
+
+ <?php if (! empty($task['description'])): ?>
+ <i class="fa fa-file-text-o" title="<?= t('Description') ?>"></i>
+ <?php endif ?>
+ </div>
+ </td>
+ <td>
+ <?php if ($task['username']): ?>
+ <?= Helper\escape($task['username']) ?>
+ <?php else: ?>
+ <?= t('Unassigned') ?>
+ <?php endif ?>
+ </td>
+ <td>
+ <?= dt('%B %e, %G', $task['date_due']) ?>
+ </td>
+ <td>
+ <?= dt('%B %e, %G at %k:%M %p', $task['date_creation']) ?>
+ </td>
+ <td>
+ <?php if ($task['date_completed']): ?>
+ <?= dt('%B %e, %G at %k:%M %p', $task['date_completed']) ?>
+ <?php endif ?>
+ </td>
+ </tr>
+ <?php endforeach ?>
+ </table>
+ <?php endif ?>
+ </section>
+</section> \ No newline at end of file
diff --git a/app/Templates/project_users.php b/app/Templates/project_users.php
new file mode 100644
index 00000000..0448004f
--- /dev/null
+++ b/app/Templates/project_users.php
@@ -0,0 +1,44 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('Project access list for "%s"', $project['name']) ?></h2>
+ <ul>
+ <li><a href="?controller=project"><?= t('All projects') ?></a></li>
+ </ul>
+ </div>
+ <section>
+
+ <?php if (! empty($users['not_allowed'])): ?>
+ <form method="post" action="?controller=project&amp;action=allow&amp;project_id=<?= $project['id'] ?>" autocomplete="off">
+
+ <?= Helper\form_hidden('project_id', array('project_id' => $project['id'])) ?>
+
+ <?= Helper\form_label(t('User'), 'user_id') ?>
+ <?= Helper\form_select('user_id', $users['not_allowed']) ?><br/>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Allow this user') ?>" class="btn btn-blue"/>
+ <?= t('or') ?> <a href="?controller=project"><?= t('cancel') ?></a>
+ </div>
+ </form>
+ <?php endif ?>
+
+ <h3><?= t('List of authorized users') ?></h3>
+ <?php if (empty($users['allowed'])): ?>
+ <div class="alert alert-info"><?= t('Everybody have access to this project.') ?></div>
+ <?php else: ?>
+ <div class="listing">
+ <p><?= t('Only those users have access to this project:') ?></p>
+ <ul>
+ <?php foreach ($users['allowed'] as $user_id => $username): ?>
+ <li>
+ <strong><?= Helper\escape($username) ?></strong>
+ (<a href="?controller=project&amp;action=revoke&amp;project_id=<?= $project['id'] ?>&amp;user_id=<?= $user_id ?>"><?= t('revoke') ?></a>)
+ </li>
+ <?php endforeach ?>
+ </ul>
+ <p><?= t('Don\'t forget that administrators have access to everything.') ?></p>
+ </div>
+ <?php endif ?>
+
+ </section>
+</section> \ No newline at end of file
diff --git a/app/Templates/task_close.php b/app/Templates/task_close.php
new file mode 100644
index 00000000..3531b37d
--- /dev/null
+++ b/app/Templates/task_close.php
@@ -0,0 +1,10 @@
+<div class="confirm">
+ <p class="alert alert-info">
+ <?= t('Do you really want to close this task: "%s"?', Helper\escape($task['title'])) ?>
+ </p>
+
+ <div class="form-actions">
+ <a href="?controller=task&amp;action=close&amp;task_id=<?= $task['id'] ?>" class="btn btn-red"><?= t('Yes') ?></a>
+ <?= t('or') ?> <a href="?controller=task&amp;action=show&amp;task_id=<?= $task['id'] ?>"><?= t('cancel') ?></a>
+ </div>
+</div> \ No newline at end of file
diff --git a/app/Templates/task_edit.php b/app/Templates/task_edit.php
new file mode 100644
index 00000000..8c8bc107
--- /dev/null
+++ b/app/Templates/task_edit.php
@@ -0,0 +1,51 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('Edit a task') ?></h2>
+ </div>
+ <section>
+ <form method="post" action="?controller=task&amp;action=update" autocomplete="off">
+
+ <div class="form-column">
+
+ <?= Helper\form_label(t('Title'), 'title') ?>
+ <?= Helper\form_text('title', $values, $errors, array('required')) ?><br/>
+
+ <?= Helper\form_label(t('Description'), 'description') ?>
+ <?= Helper\form_textarea('description', $values, $errors) ?><br/>
+ <div class="form-help"><a href="http://kanboard.net/documentation/syntax-guide" target="_blank" rel="noreferrer"><?= t('Write your text in Markdown') ?></a></div>
+
+ </div>
+
+ <div class="form-column">
+
+ <?= Helper\form_hidden('id', $values) ?>
+ <?= Helper\form_hidden('project_id', $values) ?>
+
+ <?= Helper\form_label(t('Assignee'), 'owner_id') ?>
+ <?= Helper\form_select('owner_id', $users_list, $values, $errors) ?><br/>
+
+ <?= Helper\form_label(t('Category'), 'category_id') ?>
+ <?= Helper\form_select('category_id', $categories_list, $values, $errors) ?><br/>
+
+ <?= Helper\form_label(t('Column'), 'column_id') ?>
+ <?= Helper\form_select('column_id', $columns_list, $values, $errors) ?><br/>
+
+ <?= Helper\form_label(t('Color'), 'color_id') ?>
+ <?= Helper\form_select('color_id', $colors_list, $values, $errors) ?><br/>
+
+ <?= Helper\form_label(t('Story Points'), 'score') ?>
+ <?= Helper\form_number('score', $values, $errors) ?><br/>
+
+ <?= Helper\form_label(t('Due Date'), 'date_due') ?>
+ <?= Helper\form_text('date_due', $values, $errors, array('placeholder="'.t('month/day/year').'"'), 'form-date') ?><br/>
+ <div class="form-help"><?= t('Others formats accepted: %s and %s', date('Y-m-d'), date('Y_m_d')) ?></div>
+
+ </div>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ <?= t('or') ?> <a href="?controller=board&amp;action=show&amp;project_id=<?= $values['project_id'] ?>"><?= t('cancel') ?></a>
+ </div>
+ </form>
+ </section>
+</section> \ No newline at end of file
diff --git a/app/Templates/task_layout.php b/app/Templates/task_layout.php
new file mode 100644
index 00000000..9a6bbd00
--- /dev/null
+++ b/app/Templates/task_layout.php
@@ -0,0 +1,16 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= Helper\escape($task['project_name']) ?> &gt; <?= t('Task #%d', $task['id']) ?></h2>
+ <ul>
+ <li><a href="?controller=board&amp;action=show&amp;project_id=<?= $task['project_id'] ?>"><?= t('Back to the board') ?></a></li>
+ </ul>
+ </div>
+ <section class="task-show">
+
+ <?= Helper\template('task_sidebar', array('task' => $task)) ?>
+
+ <div class="task-show-main">
+ <?= $task_content_for_layout ?>
+ </div>
+ </section>
+</section> \ No newline at end of file
diff --git a/app/Templates/task_new.php b/app/Templates/task_new.php
new file mode 100644
index 00000000..d233efd2
--- /dev/null
+++ b/app/Templates/task_new.php
@@ -0,0 +1,51 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('New task') ?></h2>
+ </div>
+ <section>
+ <form method="post" action="?controller=task&amp;action=save" autocomplete="off">
+
+ <div class="form-column">
+ <?= Helper\form_label(t('Title'), 'title') ?>
+ <?= Helper\form_text('title', $values, $errors, array('autofocus', 'required')) ?><br/>
+
+ <?= Helper\form_label(t('Description'), 'description') ?>
+ <?= Helper\form_textarea('description', $values, $errors) ?><br/>
+ <div class="form-help"><a href="http://kanboard.net/documentation/syntax-guide" target="_blank" rel="noreferrer"><?= t('Write your text in Markdown') ?></a></div>
+
+ <?php if (! isset($duplicate)): ?>
+ <?= Helper\form_checkbox('another_task', t('Create another task'), 1, isset($values['another_task']) && $values['another_task'] == 1) ?>
+ <?php endif ?>
+ </div>
+
+ <div class="form-column">
+
+ <?= Helper\form_hidden('project_id', $values) ?>
+
+ <?= Helper\form_label(t('Assignee'), 'owner_id') ?>
+ <?= Helper\form_select('owner_id', $users_list, $values, $errors) ?><br/>
+
+ <?= Helper\form_label(t('Category'), 'category_id') ?>
+ <?= Helper\form_select('category_id', $categories_list, $values, $errors) ?><br/>
+
+ <?= Helper\form_label(t('Column'), 'column_id') ?>
+ <?= Helper\form_select('column_id', $columns_list, $values, $errors) ?><br/>
+
+ <?= Helper\form_label(t('Color'), 'color_id') ?>
+ <?= Helper\form_select('color_id', $colors_list, $values, $errors) ?><br/>
+
+ <?= Helper\form_label(t('Story Points'), 'score') ?>
+ <?= Helper\form_number('score', $values, $errors) ?><br/>
+
+ <?= Helper\form_label(t('Due Date'), 'date_due') ?>
+ <?= Helper\form_text('date_due', $values, $errors, array('placeholder="'.t('month/day/year').'"'), 'form-date') ?><br/>
+ <div class="form-help"><?= t('Others formats accepted: %s and %s', date('Y-m-d'), date('Y_m_d')) ?></div>
+ </div>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ <?= t('or') ?> <a href="?controller=board&amp;action=show&amp;project_id=<?= $values['project_id'] ?>"><?= t('cancel') ?></a>
+ </div>
+ </form>
+ </section>
+</section> \ No newline at end of file
diff --git a/app/Templates/task_open.php b/app/Templates/task_open.php
new file mode 100644
index 00000000..54cc11f0
--- /dev/null
+++ b/app/Templates/task_open.php
@@ -0,0 +1,16 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('Open a task') ?></h2>
+ </div>
+
+ <div class="confirm">
+ <p class="alert alert-info">
+ <?= t('Do you really want to open this task: "%s"?', Helper\escape($task['title'])) ?>
+ </p>
+
+ <div class="form-actions">
+ <a href="?controller=task&amp;action=open&amp;task_id=<?= $task['id'] ?>" class="btn btn-red"><?= t('Yes') ?></a>
+ <?= t('or') ?> <a href="?controller=task&amp;action=show&amp;task_id=<?= $task['id'] ?>"><?= t('cancel') ?></a>
+ </div>
+ </div>
+</section> \ No newline at end of file
diff --git a/app/Templates/task_remove.php b/app/Templates/task_remove.php
new file mode 100644
index 00000000..1aa9503b
--- /dev/null
+++ b/app/Templates/task_remove.php
@@ -0,0 +1,10 @@
+<div class="confirm">
+ <p class="alert alert-info">
+ <?= t('Do you really want to remove this task: "%s"?', Helper\escape($task['title'])) ?>
+ </p>
+
+ <div class="form-actions">
+ <a href="?controller=task&amp;action=remove&amp;task_id=<?= $task['id'] ?>" class="btn btn-red"><?= t('Yes') ?></a>
+ <?= t('or') ?> <a href="?controller=task&amp;action=show&amp;task_id=<?= $task['id'] ?>"><?= t('cancel') ?></a>
+ </div>
+</div> \ No newline at end of file
diff --git a/app/Templates/task_show.php b/app/Templates/task_show.php
new file mode 100644
index 00000000..a5b79359
--- /dev/null
+++ b/app/Templates/task_show.php
@@ -0,0 +1,94 @@
+<article class="task task-<?= $task['color_id'] ?> task-show-details">
+ <h2><?= Helper\escape($task['title']) ?></h2>
+<?php if ($task['score']): ?>
+ <span class="task-score"><?= Helper\escape($task['score']) ?></span>
+<?php endif ?>
+<ul>
+ <li>
+ <?= dt('Created on %B %e, %G at %k:%M %p', $task['date_creation']) ?>
+ </li>
+ <?php if ($task['date_completed']): ?>
+ <li>
+ <?= dt('Completed on %B %e, %G at %k:%M %p', $task['date_completed']) ?>
+ </li>
+ <?php endif ?>
+ <?php if ($task['date_due']): ?>
+ <li>
+ <strong><?= dt('Must be done before %B %e, %G', $task['date_due']) ?></strong>
+ </li>
+ <?php endif ?>
+ <li>
+ <strong>
+ <?php if ($task['username']): ?>
+ <?= t('Assigned to %s', $task['username']) ?>
+ <?php else: ?>
+ <?= t('There is nobody assigned') ?>
+ <?php endif ?>
+ </strong>
+ </li>
+ <li>
+ <?= t('Column on the board:') ?>
+ <strong><?= Helper\escape($task['column_title']) ?></strong>
+ (<?= Helper\escape($task['project_name']) ?>)
+ </li>
+ <?php if ($task['category_name']): ?>
+ <li>
+ <?= t('Category:') ?> <strong><?= Helper\escape($task['category_name']) ?></strong>
+ </li>
+ <?php endif ?>
+ <li>
+ <?php if ($task['is_active'] == 1): ?>
+ <?= t('Status is open') ?>
+ <?php else: ?>
+ <?= t('Status is closed') ?>
+ <?php endif ?>
+ </li>
+</ul>
+</article>
+
+<h2><?= t('Description') ?></h2>
+<?php if ($task['description']): ?>
+ <article class="markdown task-show-description">
+ <?= Helper\parse($task['description']) ?: t('There is no description.') ?>
+ </article>
+<?php else: ?>
+ <form method="post" action="?controller=task&amp;action=description&amp;task_id=<?= $task['id'] ?>" autocomplete="off">
+
+ <?= Helper\form_hidden('id', $description_form['values']) ?>
+ <?= Helper\form_textarea('description', $description_form['values'], $description_form['errors'], array('required', 'placeholder="'.t('Leave a description').'"')) ?><br/>
+ <div class="form-help"><a href="http://kanboard.net/documentation/syntax-guide" target="_blank" rel="noreferrer"><?= t('Write your text in Markdown') ?></a></div>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ </div>
+ </form>
+<?php endif ?>
+
+<h2><?= t('Comments') ?></h2>
+<?php if ($comments): ?>
+ <ul id="comments">
+ <?php foreach ($comments as $comment): ?>
+ <?= Helper\template('comment_show', array(
+ 'comment' => $comment,
+ 'task' => $task,
+ 'display_edit_form' => $comment['id'] == $comment_edit_form['values']['id'],
+ 'values' => $comment_edit_form['values'] + array('comment' => $comment['comment']),
+ 'errors' => $comment_edit_form['errors']
+ )) ?>
+ <?php endforeach ?>
+ </ul>
+<?php endif ?>
+
+<?php if (! isset($hide_comment_form) || $hide_comment_form === false): ?>
+<form method="post" action="?controller=comment&amp;action=save&amp;task_id=<?= $task['id'] ?>" autocomplete="off">
+
+ <?= Helper\form_hidden('task_id', $comment_form['values']) ?>
+ <?= Helper\form_hidden('user_id', $comment_form['values']) ?>
+ <?= Helper\form_textarea('comment', $comment_form['values'], $comment_form['errors'], array('required', 'placeholder="'.t('Leave a comment').'"'), 'comment-textarea') ?><br/>
+ <div class="form-help"><a href="http://kanboard.net/documentation/syntax-guide" target="_blank" rel="noreferrer"><?= t('Write your text in Markdown') ?></a></div>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Post comment') ?>" class="btn btn-blue"/>
+ </div>
+</form>
+<?php endif ?> \ No newline at end of file
diff --git a/app/Templates/task_sidebar.php b/app/Templates/task_sidebar.php
new file mode 100644
index 00000000..314d5214
--- /dev/null
+++ b/app/Templates/task_sidebar.php
@@ -0,0 +1,17 @@
+<div class="task-show-sidebar">
+ <h2><?= t('Actions') ?></h2>
+ <div class="task-show-actions">
+ <ul>
+ <li><a href="?controller=task&amp;action=duplicate&amp;project_id=<?= $task['project_id'] ?>&amp;task_id=<?= $task['id'] ?>"><?= t('Duplicate') ?></a></li>
+ <li><a href="?controller=task&amp;action=edit&amp;task_id=<?= $task['id'] ?>"><?= t('Edit') ?></a></li>
+ <li>
+ <?php if ($task['is_active'] == 1): ?>
+ <a href="?controller=task&amp;action=confirmClose&amp;task_id=<?= $task['id'] ?>"><?= t('Close this task') ?></a>
+ <?php else: ?>
+ <a href="?controller=task&amp;action=confirmOpen&amp;task_id=<?= $task['id'] ?>"><?= t('Open this task') ?></a>
+ <?php endif ?>
+ </li>
+ <li><a href="?controller=task&amp;action=confirmRemove&amp;task_id=<?= $task['id'] ?>"><?= t('Remove') ?></a></li>
+ </ul>
+ </div>
+</div> \ No newline at end of file
diff --git a/app/Templates/user_edit.php b/app/Templates/user_edit.php
new file mode 100644
index 00000000..c857fe1c
--- /dev/null
+++ b/app/Templates/user_edit.php
@@ -0,0 +1,64 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('Edit user') ?></h2>
+ <ul>
+ <li><a href="?controller=user"><?= t('All users') ?></a></li>
+ </ul>
+ </div>
+ <section>
+ <form method="post" action="?controller=user&amp;action=update" autocomplete="off">
+
+ <div class="form-column">
+
+ <?= Helper\form_hidden('id', $values) ?>
+ <?= Helper\form_hidden('is_ldap_user', $values) ?>
+
+ <?= Helper\form_label(t('Username'), 'username') ?>
+ <?= Helper\form_text('username', $values, $errors, array('required', $values['is_ldap_user'] == 1 ? 'readonly' : '')) ?><br/>
+
+ <?= Helper\form_label(t('Name'), 'name') ?>
+ <?= Helper\form_text('name', $values, $errors) ?><br/>
+
+ <?= Helper\form_label(t('Email'), 'email') ?>
+ <?= Helper\form_email('email', $values, $errors) ?><br/>
+
+ <?= Helper\form_label(t('Default Project'), 'default_project_id') ?>
+ <?= Helper\form_select('default_project_id', $projects, $values, $errors) ?><br/>
+
+ </div>
+
+ <div class="form-column">
+
+ <?php if ($values['is_ldap_user'] == 0): ?>
+
+ <?= Helper\form_label(t('Current password for the user "%s"', Helper\get_username()), 'current_password') ?>
+ <?= Helper\form_password('current_password', $values, $errors) ?><br/>
+
+ <?= Helper\form_label(t('Password'), 'password') ?>
+ <?= Helper\form_password('password', $values, $errors) ?><br/>
+
+ <?= Helper\form_label(t('Confirmation'), 'confirmation') ?>
+ <?= Helper\form_password('confirmation', $values, $errors) ?><br/>
+
+ <?php endif ?>
+
+ <?php if (Helper\is_admin()): ?>
+ <?= Helper\form_checkbox('is_admin', t('Administrator'), 1, isset($values['is_admin']) && $values['is_admin'] == 1 ? true : false) ?><br/>
+ <?php endif ?>
+
+ <?php if (GOOGLE_AUTH && Helper\is_current_user($values['id'])): ?>
+ <?php if (empty($values['google_id'])): ?>
+ <a href="?controller=user&amp;action=google"><?= t('Link my Google Account') ?></a>
+ <?php else: ?>
+ <a href="?controller=user&amp;action=unlinkGoogle"><?= t('Unlink my Google Account') ?></a>
+ <?php endif ?>
+ <?php endif ?>
+
+ </div>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/> <?= t('or') ?> <a href="?controller=user"><?= t('cancel') ?></a>
+ </div>
+ </form>
+ </section>
+</section> \ No newline at end of file
diff --git a/app/Templates/user_forbidden.php b/app/Templates/user_forbidden.php
new file mode 100644
index 00000000..853159ba
--- /dev/null
+++ b/app/Templates/user_forbidden.php
@@ -0,0 +1,9 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('Forbidden') ?></h2>
+ </div>
+
+ <p class="alert alert-error">
+ <?= t('Only administrators can access to this page.') ?>
+ </p>
+</section> \ No newline at end of file
diff --git a/app/Templates/user_index.php b/app/Templates/user_index.php
new file mode 100644
index 00000000..f6302a6b
--- /dev/null
+++ b/app/Templates/user_index.php
@@ -0,0 +1,56 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('Users') ?><span id="page-counter"> (<?= $nb_users ?>)</span></h2>
+ <?php if (Helper\is_admin()): ?>
+ <ul>
+ <li><a href="?controller=user&amp;action=create"><?= t('New user') ?></a></li>
+ </ul>
+ <?php endif ?>
+ </div>
+ <section>
+ <?php if (empty($users)): ?>
+ <p class="alert"><?= t('No user') ?></p>
+ <?php else: ?>
+ <table>
+ <tr>
+ <th><?= t('Username') ?></th>
+ <th><?= t('Name') ?></th>
+ <th><?= t('Email') ?></th>
+ <th><?= t('Administrator') ?></th>
+ <th><?= t('Default Project') ?></th>
+ <th><?= t('Actions') ?></th>
+ </tr>
+ <?php foreach ($users as $user): ?>
+ <tr>
+ <td>
+ <span title="user_id=<?= $user['id'] ?>"><?= Helper\escape($user['username']) ?></span>
+ </td>
+ <td>
+ <?= Helper\escape($user['name']) ?>
+ </td>
+ <td>
+ <?= Helper\escape($user['email']) ?>
+ </td>
+ <td>
+ <?= $user['is_admin'] ? t('Yes') : t('No') ?>
+ </td>
+ <td>
+ <?= (isset($user['default_project_id']) && isset($projects[$user['default_project_id']])) ? Helper\escape($projects[$user['default_project_id']]) : t('None'); ?>
+ </td>
+ <td>
+ <?php if (Helper\is_admin() || Helper\is_current_user($user['id'])): ?>
+ <a href="?controller=user&amp;action=edit&amp;user_id=<?= $user['id'] ?>"><?= t('edit') ?></a>
+ <?php endif ?>
+ <?php if (Helper\is_admin()): ?>
+ <?php if (count($users) > 1): ?>
+ <?= t('or') ?>
+ <a href="?controller=user&amp;action=confirm&amp;user_id=<?= $user['id'] ?>"><?= t('remove') ?></a>
+ <?php endif ?>
+ <?php endif ?>
+ </td>
+ </tr>
+ <?php endforeach ?>
+ </table>
+ <?php endif ?>
+ </section>
+</section>
diff --git a/app/Templates/user_login.php b/app/Templates/user_login.php
new file mode 100644
index 00000000..878170e3
--- /dev/null
+++ b/app/Templates/user_login.php
@@ -0,0 +1,28 @@
+<div class="page-header">
+ <h1><?= t('Sign in') ?></h1>
+</div>
+
+<?php if (isset($errors['login'])): ?>
+ <p class="alert alert-error"><?= Helper\escape($errors['login']) ?></p>
+<?php endif ?>
+
+<form method="post" action="?controller=user&amp;action=check" class="form-login">
+
+ <?= Helper\form_label(t('Username'), 'username') ?>
+ <?= Helper\form_text('username', $values, $errors, array('autofocus', 'required')) ?><br/>
+
+ <?= Helper\form_label(t('Password'), 'password') ?>
+ <?= Helper\form_password('password', $values, $errors, array('required')) ?>
+
+ <?= Helper\form_checkbox('remember_me', t('Remember Me'), 1) ?><br/>
+
+ <?php if (GOOGLE_AUTH): ?>
+ <p>
+ <a href="?controller=user&amp;action=google"><?= t('Login with my Google Account') ?></a>
+ </p>
+ <?php endif ?>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Sign in') ?>" class="btn btn-blue"/>
+ </div>
+</form> \ No newline at end of file
diff --git a/app/Templates/user_new.php b/app/Templates/user_new.php
new file mode 100644
index 00000000..6ad976f2
--- /dev/null
+++ b/app/Templates/user_new.php
@@ -0,0 +1,45 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('New user') ?></h2>
+ <ul>
+ <li><a href="?controller=user"><?= t('All users') ?></a></li>
+ </ul>
+ </div>
+ <section>
+ <form method="post" action="?controller=user&amp;action=save" autocomplete="off">
+
+ <div class="form-column">
+
+ <?= Helper\form_label(t('Username'), 'username') ?>
+ <?= Helper\form_text('username', $values, $errors, array('autofocus', 'required')) ?><br/>
+
+ <?= Helper\form_label(t('Name'), 'name') ?>
+ <?= Helper\form_text('name', $values, $errors) ?><br/>
+
+ <?= Helper\form_label(t('Email'), 'email') ?>
+ <?= Helper\form_email('email', $values, $errors) ?><br/>
+
+ <?= Helper\form_label(t('Default Project'), 'default_project_id') ?>
+ <?= Helper\form_select('default_project_id', $projects, $values, $errors) ?><br/>
+
+ </div>
+
+ <div class="form-column">
+
+ <?= Helper\form_label(t('Password'), 'password') ?>
+ <?= Helper\form_password('password', $values, $errors, array('required')) ?><br/>
+
+ <?= Helper\form_label(t('Confirmation'), 'confirmation') ?>
+ <?= Helper\form_password('confirmation', $values, $errors, array('required')) ?><br/>
+
+ <?= Helper\form_checkbox('is_admin', t('Administrator'), 1, isset($values['is_admin']) && $values['is_admin'] == 1 ? true : false) ?>
+
+ </div>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ <?= t('or') ?> <a href="?controller=user"><?= t('cancel') ?></a>
+ </div>
+ </form>
+ </section>
+</section> \ No newline at end of file
diff --git a/app/Templates/user_remove.php b/app/Templates/user_remove.php
new file mode 100644
index 00000000..a4db2e4a
--- /dev/null
+++ b/app/Templates/user_remove.php
@@ -0,0 +1,14 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('Remove user') ?></h2>
+ </div>
+
+ <div class="confirm">
+ <p class="alert alert-info"><?= t('Do you really want to remove this user: "%s"?', $user['username']) ?></p>
+
+ <div class="form-actions">
+ <a href="?controller=user&amp;action=remove&amp;user_id=<?= $user['id'] ?>" class="btn btn-red"><?= t('Yes') ?></a>
+ <?= t('or') ?> <a href="?controller=user"><?= t('cancel') ?></a>
+ </div>
+ </div>
+</section> \ No newline at end of file
diff --git a/app/check_setup.php b/app/check_setup.php
new file mode 100644
index 00000000..9ed16967
--- /dev/null
+++ b/app/check_setup.php
@@ -0,0 +1,40 @@
+<?php
+
+// PHP 5.3.3 minimum
+if (version_compare(PHP_VERSION, '5.3.3', '<')) {
+ die('This software require PHP 5.3.3 minimum');
+}
+
+// Checks for PHP < 5.4
+if (version_compare(PHP_VERSION, '5.4.0', '<')) {
+
+ // Short tags must be enabled for PHP < 5.4
+ if (! ini_get('short_open_tag')) {
+ die('This software require to have short tags enabled if you have PHP < 5.4 ("short_open_tag = On")');
+ }
+
+ // Magic quotes are deprecated since PHP 5.4
+ if (get_magic_quotes_gpc()) {
+ die('This software require to have "Magic quotes" disabled, it\'s deprecated since PHP 5.4 ("magic_quotes_gpc = Off")');
+ }
+}
+
+// Check extension: PDO
+if (! extension_loaded('pdo_sqlite') && ! extension_loaded('pdo_mysql')) {
+ die('PHP extension required: pdo_sqlite or pdo_mysql');
+}
+
+// Check extension: mbstring
+if (! extension_loaded('mbstring')) {
+ die('PHP extension required: mbstring');
+}
+
+// Check if /data is writeable
+if (! is_writable('data')) {
+ die('The directory "data" must be writeable by your web server user');
+}
+
+// Include password_compat for PHP < 5.5
+if (version_compare(PHP_VERSION, '5.5.0', '<')) {
+ require __DIR__.'/../vendor/password.php';
+}
diff --git a/app/common.php b/app/common.php
new file mode 100644
index 00000000..5a26860f
--- /dev/null
+++ b/app/common.php
@@ -0,0 +1,98 @@
+<?php
+
+require __DIR__.'/Core/Loader.php';
+require __DIR__.'/helpers.php';
+require __DIR__.'/translator.php';
+
+use Core\Event;
+use Core\Loader;
+use Core\Registry;
+
+// Include custom config file
+if (file_exists('config.php')) {
+ require 'config.php';
+}
+
+// Board refresh frequency in seconds for the public board view
+defined('BOARD_PUBLIC_CHECK_INTERVAL') or define('BOARD_PUBLIC_CHECK_INTERVAL', 60);
+
+// Board refresh frequency in seconds (the value 0 disable this feature)
+defined('BOARD_CHECK_INTERVAL') or define('BOARD_CHECK_INTERVAL', 10);
+
+// Custom session save path
+defined('SESSION_SAVE_PATH') or define('SESSION_SAVE_PATH', '');
+
+// Application version
+defined('APP_VERSION') or define('APP_VERSION', 'master');
+
+// Base directory
+define('BASE_URL_DIRECTORY', dirname($_SERVER['PHP_SELF']));
+
+// Database driver: sqlite or mysql
+defined('DB_DRIVER') or define('DB_DRIVER', 'sqlite');
+
+// Sqlite configuration
+defined('DB_FILENAME') or define('DB_FILENAME', 'data/db.sqlite');
+
+// Mysql configuration
+defined('DB_USERNAME') or define('DB_USERNAME', 'root');
+defined('DB_PASSWORD') or define('DB_PASSWORD', '');
+defined('DB_HOSTNAME') or define('DB_HOSTNAME', 'localhost');
+defined('DB_NAME') or define('DB_NAME', 'kanboard');
+
+// LDAP configuration
+defined('LDAP_AUTH') or define('LDAP_AUTH', false);
+defined('LDAP_SERVER') or define('LDAP_SERVER', '');
+defined('LDAP_PORT') or define('LDAP_PORT', 389);
+defined('LDAP_USER_DN') or define('LDAP_USER_DN', '%s');
+
+// Google authentication
+defined('GOOGLE_AUTH') or define('GOOGLE_AUTH', false);
+defined('GOOGLE_CLIENT_ID') or define('GOOGLE_CLIENT_ID', '');
+defined('GOOGLE_CLIENT_SECRET') or define('GOOGLE_CLIENT_SECRET', '');
+
+$loader = new Loader;
+$loader->execute();
+
+$registry = new Registry;
+
+$registry->db = function() use ($registry) {
+ require __DIR__.'/../vendor/PicoDb/Database.php';
+
+ if (DB_DRIVER === 'sqlite') {
+
+ require __DIR__.'/Schema/Sqlite.php';
+
+ $db = new \PicoDb\Database(array(
+ 'driver' => 'sqlite',
+ 'filename' => DB_FILENAME
+ ));
+ }
+ elseif (DB_DRIVER === 'mysql') {
+
+ require __DIR__.'/Schema/Mysql.php';
+
+ $db = new \PicoDb\Database(array(
+ 'driver' => 'mysql',
+ 'hostname' => DB_HOSTNAME,
+ 'username' => DB_USERNAME,
+ 'password' => DB_PASSWORD,
+ 'database' => DB_NAME,
+ 'charset' => 'utf8',
+ ));
+ }
+ else {
+ die('Database driver not supported');
+ }
+
+ if ($db->schema()->check(Schema\VERSION)) {
+ return $db;
+ }
+ else {
+ die('Unable to migrate database schema!');
+ }
+};
+
+$registry->event = function() use ($registry) {
+ return new Event;
+};
diff --git a/app/helpers.php b/app/helpers.php
new file mode 100644
index 00000000..8351328a
--- /dev/null
+++ b/app/helpers.php
@@ -0,0 +1,262 @@
+<?php
+
+namespace Helper;
+
+function template($name, array $args = array())
+{
+ $tpl = new \Core\Template;
+ return $tpl->load($name, $args);
+}
+
+function is_current_user($user_id)
+{
+ return $_SESSION['user']['id'] == $user_id;
+}
+
+function is_admin()
+{
+ return $_SESSION['user']['is_admin'] == 1;
+}
+
+function get_username()
+{
+ return $_SESSION['user']['username'];
+}
+
+function parse($text)
+{
+ $text = markdown($text);
+ $text = preg_replace('!#(\d+)!i', '<a href="?controller=task&action=show&task_id=$1">$0</a>', $text);
+ return $text;
+}
+
+function markdown($text)
+{
+ require_once __DIR__.'/../vendor/Michelf/MarkdownExtra.inc.php';
+
+ $parser = new \Michelf\MarkdownExtra;
+ $parser->no_markup = true;
+ $parser->no_entities = true;
+
+ return $parser->transform($text);
+}
+
+function get_current_base_url()
+{
+ $url = isset($_SERVER['HTTPS']) ? 'https://' : 'http://';
+ $url .= $_SERVER['SERVER_NAME'];
+ $url .= $_SERVER['SERVER_PORT'] == 80 || $_SERVER['SERVER_PORT'] == 443 ? '' : ':'.$_SERVER['SERVER_PORT'];
+ $url .= dirname($_SERVER['PHP_SELF']) !== '/' ? dirname($_SERVER['PHP_SELF']).'/' : '/';
+
+ return $url;
+}
+
+function escape($value)
+{
+ return htmlspecialchars($value, ENT_QUOTES, 'UTF-8', false);
+}
+
+function flash($html)
+{
+ $data = '';
+
+ if (isset($_SESSION['flash_message'])) {
+ $data = sprintf($html, escape($_SESSION['flash_message']));
+ unset($_SESSION['flash_message']);
+ }
+
+ return $data;
+}
+
+function flash_error($html)
+{
+ $data = '';
+
+ if (isset($_SESSION['flash_error_message'])) {
+ $data = sprintf($html, escape($_SESSION['flash_error_message']));
+ unset($_SESSION['flash_error_message']);
+ }
+
+ return $data;
+}
+
+function format_bytes($size, $precision = 2)
+{
+ $base = log($size) / log(1024);
+ $suffixes = array('', 'k', 'M', 'G', 'T');
+
+ return round(pow(1024, $base - floor($base)), $precision).$suffixes[floor($base)];
+}
+
+function get_host_from_url($url)
+{
+ return escape(parse_url($url, PHP_URL_HOST)) ?: $url;
+}
+
+function summary($value, $min_length = 5, $max_length = 120, $end = '[...]')
+{
+ $length = strlen($value);
+
+ if ($length > $max_length) {
+ return substr($value, 0, strpos($value, ' ', $max_length)).' '.$end;
+ }
+ else if ($length < $min_length) {
+ return '';
+ }
+
+ return $value;
+}
+
+function contains($haystack, $needle)
+{
+ return strpos($haystack, $needle) !== false;
+}
+
+function in_list($id, array $listing, $default_value = '?')
+{
+ if (isset($listing[$id])) {
+ return escape($listing[$id]);
+ }
+
+ return $default_value;
+}
+
+function error_class(array $errors, $name)
+{
+ return ! isset($errors[$name]) ? '' : ' form-error';
+}
+
+function error_list(array $errors, $name)
+{
+ $html = '';
+
+ if (isset($errors[$name])) {
+
+ $html .= '<ul class="form-errors">';
+
+ foreach ($errors[$name] as $error) {
+ $html .= '<li>'.escape($error).'</li>';
+ }
+
+ $html .= '</ul>';
+ }
+
+ return $html;
+}
+
+function form_value($values, $name)
+{
+ if (isset($values->$name)) {
+ return 'value="'.escape($values->$name).'"';
+ }
+
+ return isset($values[$name]) ? 'value="'.escape($values[$name]).'"' : '';
+}
+
+function form_hidden($name, $values = array())
+{
+ return '<input type="hidden" name="'.$name.'" id="form-'.$name.'" '.form_value($values, $name).'/>';
+}
+
+function form_default_select($name, array $options, $values = array(), array $errors = array(), $class = '')
+{
+ $options = array('' => '?') + $options;
+ return form_select($name, $options, $values, $errors, $class);
+}
+
+function form_select($name, array $options, $values = array(), array $errors = array(), $class = '')
+{
+ $html = '<select name="'.$name.'" id="form-'.$name.'" class="'.$class.'">';
+
+ foreach ($options as $id => $value) {
+
+ $html .= '<option value="'.escape($id).'"';
+
+ if (isset($values->$name) && $id == $values->$name) $html .= ' selected="selected"';
+ if (isset($values[$name]) && $id == $values[$name]) $html .= ' selected="selected"';
+
+ $html .= '>'.escape($value).'</option>';
+ }
+
+ $html .= '</select>';
+ $html .= error_list($errors, $name);
+
+ return $html;
+}
+
+function form_radios($name, array $options, array $values = array())
+{
+ $html = '';
+
+ foreach ($options as $value => $label) {
+ $html .= form_radio($name, $label, $value, isset($values[$name]) && $values[$name] == $value);
+ }
+
+ return $html;
+}
+
+function form_radio($name, $label, $value, $selected = false, $class = '')
+{
+ return '<label><input type="radio" name="'.$name.'" class="'.$class.'" value="'.escape($value).'" '.($selected ? 'selected="selected"' : '').'>'.escape($label).'</label>';
+}
+
+function form_checkbox($name, $label, $value, $checked = false, $class = '')
+{
+ return '<label><input type="checkbox" name="'.$name.'" class="'.$class.'" value="'.escape($value).'" '.($checked ? 'checked="checked"' : '').'>&nbsp;'.escape($label).'</label>';
+}
+
+function form_label($label, $name, array $attributes = array())
+{
+ return '<label for="form-'.$name.'" '.implode(' ', $attributes).'>'.escape($label).'</label>';
+}
+
+function form_textarea($name, $values = array(), array $errors = array(), array $attributes = array(), $class = '')
+{
+ $class .= error_class($errors, $name);
+
+ $html = '<textarea name="'.$name.'" id="form-'.$name.'" class="'.$class.'" ';
+ $html .= implode(' ', $attributes).'>';
+ $html .= isset($values->$name) ? escape($values->$name) : isset($values[$name]) ? $values[$name] : '';
+ $html .= '</textarea>';
+ if (in_array('required', $attributes)) $html .= '<span class="form-required">*</span>';
+ $html .= error_list($errors, $name);
+
+ return $html;
+}
+
+function form_input($type, $name, $values = array(), array $errors = array(), array $attributes = array(), $class = '')
+{
+ $class .= error_class($errors, $name);
+
+ $html = '<input type="'.$type.'" name="'.$name.'" id="form-'.$name.'" '.form_value($values, $name).' class="'.$class.'" ';
+ $html .= implode(' ', $attributes).'/>';
+ if (in_array('required', $attributes)) $html .= '<span class="form-required">*</span>';
+ $html .= error_list($errors, $name);
+
+ return $html;
+}
+
+function form_text($name, $values = array(), array $errors = array(), array $attributes = array(), $class = '')
+{
+ return form_input('text', $name, $values, $errors, $attributes, $class);
+}
+
+function form_password($name, $values = array(), array $errors = array(), array $attributes = array(), $class = '')
+{
+ return form_input('password', $name, $values, $errors, $attributes, $class);
+}
+
+function form_email($name, $values = array(), array $errors = array(), array $attributes = array(), $class = '')
+{
+ return form_input('email', $name, $values, $errors, $attributes, $class);
+}
+
+function form_date($name, $values = array(), array $errors = array(), array $attributes = array(), $class = '')
+{
+ return form_input('date', $name, $values, $errors, $attributes, $class);
+}
+
+function form_number($name, $values = array(), array $errors = array(), array $attributes = array(), $class = '')
+{
+ return form_input('number', $name, $values, $errors, $attributes, $class);
+}
diff --git a/app/translator.php b/app/translator.php
new file mode 100644
index 00000000..338821d3
--- /dev/null
+++ b/app/translator.php
@@ -0,0 +1,36 @@
+<?php
+
+use Core\Translator;
+
+// Get a translation
+function t()
+{
+ $t = new Translator;
+ return call_user_func_array(array($t, 'translate'), func_get_args());
+}
+
+// Get a locale currency
+function c($value)
+{
+ $t = new Translator;
+ return $t->currency($value);
+}
+
+// Get a formatted number
+function n($value)
+{
+ $t = new Translator;
+ return $t->number($value);
+}
+
+// Get a locale date
+function dt($format, $timestamp)
+{
+ $t = new Translator;
+ return $t->datetime($format, $timestamp);
+}
+
+// Plurals, return $t2 if $value > 1
+function p($value, $t1, $t2) {
+ return $value > 1 ? $t2 : $t1;
+}