summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/Controller/App.php29
-rw-r--r--app/Controller/Base.php8
-rw-r--r--app/Controller/Board.php14
-rw-r--r--app/Controller/Calendar.php4
-rw-r--r--app/Controller/Link.php162
-rw-r--r--app/Controller/Task.php2
-rw-r--r--app/Controller/Tasklink.php116
-rw-r--r--app/Locale/da_DK/translations.php33
-rw-r--r--app/Locale/de_DE/translations.php33
-rw-r--r--app/Locale/es_ES/translations.php33
-rw-r--r--app/Locale/fi_FI/translations.php33
-rw-r--r--app/Locale/fr_FR/translations.php33
-rw-r--r--app/Locale/hu_HU/translations.php33
-rw-r--r--app/Locale/it_IT/translations.php33
-rw-r--r--app/Locale/ja_JP/translations.php33
-rw-r--r--app/Locale/pl_PL/translations.php33
-rw-r--r--app/Locale/pt_BR/translations.php33
-rw-r--r--app/Locale/ru_RU/translations.php33
-rw-r--r--app/Locale/sv_SE/translations.php33
-rw-r--r--app/Locale/th_TH/translations.php33
-rw-r--r--app/Locale/zh_CN/translations.php33
-rw-r--r--app/Model/Acl.php2
-rw-r--r--app/Model/Authentication.php7
-rw-r--r--app/Model/Base.php2
-rw-r--r--app/Model/Link.php234
-rw-r--r--app/Model/ProjectActivity.php8
-rw-r--r--app/Model/ProjectPermission.php19
-rw-r--r--app/Model/TaskFilter.php32
-rw-r--r--app/Model/TaskFinder.php1
-rw-r--r--app/Model/TaskLink.php142
-rw-r--r--app/Model/User.php12
-rw-r--r--app/Schema/Mysql.php47
-rw-r--r--app/Schema/Postgres.php43
-rw-r--r--app/Schema/Sqlite.php47
-rw-r--r--app/ServiceProvider/ClassProvider.php2
-rw-r--r--app/Subscriber/Base.php1
-rw-r--r--app/Template/board/task.php9
-rw-r--r--app/Template/board/tasklinks.php15
-rw-r--r--app/Template/config/sidebar.php3
-rw-r--r--app/Template/link/create.php18
-rw-r--r--app/Template/link/edit.php21
-rw-r--r--app/Template/link/index.php33
-rw-r--r--app/Template/link/remove.php15
-rw-r--r--app/Template/task/public.php7
-rw-r--r--app/Template/task/show.php3
-rw-r--r--app/Template/task/sidebar.php3
-rw-r--r--app/Template/tasklink/create.php27
-rw-r--r--app/Template/tasklink/remove.php15
-rw-r--r--app/Template/tasklink/show.php41
49 files changed, 1574 insertions, 32 deletions
diff --git a/app/Controller/App.php b/app/Controller/App.php
index ef0a08a9..46731e7c 100644
--- a/app/Controller/App.php
+++ b/app/Controller/App.php
@@ -2,7 +2,8 @@
namespace Controller;
-use Model\Subtask as SubTaskModel;
+use Model\Subtask as SubtaskModel;
+use Model\Task as TaskModel;
/**
* Application controller
@@ -39,7 +40,7 @@ class App extends Base
*/
public function index($user_id = 0, $action = 'index')
{
- $status = array(SubTaskModel::STATUS_TODO, SubTaskModel::STATUS_INPROGRESS);
+ $status = array(SubTaskModel::STATUS_TODO, SubtaskModel::STATUS_INPROGRESS);
$user_id = $user_id ?: $this->userSession->getId();
$projects = $this->projectPermission->getActiveMemberProjects($user_id);
$project_ids = array_keys($projects);
@@ -88,11 +89,8 @@ class App extends Base
if (empty($payload['text'])) {
$this->response->html('<p>'.t('Nothing to preview...').'</p>');
}
- else {
- $this->response->html(
- $this->template->markdown($payload['text'])
- );
- }
+
+ $this->response->html($this->template->markdown($payload['text']));
}
/**
@@ -104,4 +102,21 @@ class App extends Base
{
$this->response->css($this->color->getCss());
}
+
+ /**
+ * Task autocompletion (Ajax)
+ *
+ * @access public
+ */
+ public function autocomplete()
+ {
+ $this->response->json(
+ $this->taskFilter
+ ->create()
+ ->filterByProjects($this->projectPermission->getActiveMemberProjectIds($this->userSession->getId()))
+ ->excludeTasks(array($this->request->getIntegerParam('exclude_task_id')))
+ ->filterByTitle($this->request->getStringParam('term'))
+ ->toAutoCompletion()
+ );
+ }
}
diff --git a/app/Controller/Base.php b/app/Controller/Base.php
index 7f65e882..548fdb40 100644
--- a/app/Controller/Base.php
+++ b/app/Controller/Base.php
@@ -17,11 +17,13 @@ use Symfony\Component\EventDispatcher\Event;
* @package controller
* @author Frederic Guillot
*
+ * @property \Core\Helper $helper
* @property \Core\Session $session
* @property \Core\Template $template
* @property \Core\Paginator $paginator
* @property \Integration\GithubWebhook $githubWebhook
* @property \Integration\GitlabWebhook $gitlabWebhook
+ * @property \Integration\BitbucketWebhook $bitbucketWebhook
* @property \Model\Acl $acl
* @property \Model\Authentication $authentication
* @property \Model\Action $action
@@ -43,6 +45,7 @@ use Symfony\Component\EventDispatcher\Event;
* @property \Model\Subtask $subtask
* @property \Model\Swimlane $swimlane
* @property \Model\Task $task
+ * @property \Model\Link $link
* @property \Model\TaskCreation $taskCreation
* @property \Model\TaskModification $taskModification
* @property \Model\TaskDuplication $taskDuplication
@@ -54,6 +57,7 @@ use Symfony\Component\EventDispatcher\Event;
* @property \Model\TaskPermission $taskPermission
* @property \Model\TaskStatus $taskStatus
* @property \Model\TaskValidator $taskValidator
+ * @property \Model\TaskLink $taskLink
* @property \Model\CommentHistory $commentHistory
* @property \Model\SubtaskHistory $subtaskHistory
* @property \Model\SubtaskTimeTracking $subtaskTimeTracking
@@ -139,7 +143,7 @@ abstract class Base
private function sendHeaders($action)
{
// HTTP secure headers
- $this->response->csp(array('style-src' => "'self' 'unsafe-inline'"));
+ $this->response->csp(array('style-src' => "'self' 'unsafe-inline'", 'img-src' => '*'));
$this->response->nosniff();
$this->response->xss();
@@ -199,7 +203,7 @@ abstract class Base
{
$project_id = $this->request->getIntegerParam('project_id');
$task_id = $this->request->getIntegerParam('task_id');
-
+
// Allow urls without "project_id"
if ($task_id > 0 && $project_id === 0) {
$project_id = $this->taskFinder->getProjectId($task_id);
diff --git a/app/Controller/Board.php b/app/Controller/Board.php
index e859348e..90b7f357 100644
--- a/app/Controller/Board.php
+++ b/app/Controller/Board.php
@@ -402,6 +402,20 @@ class Board extends Base
}
/**
+ * Get links on mouseover
+ *
+ * @access public
+ */
+ public function tasklinks()
+ {
+ $task = $this->getTask();
+ $this->response->html($this->template->render('board/tasklinks', array(
+ 'links' => $this->taskLink->getLinks($task['id']),
+ 'task' => $task,
+ )));
+ }
+
+ /**
* Get subtasks on mouseover
*
* @access public
diff --git a/app/Controller/Calendar.php b/app/Controller/Calendar.php
index abbcab7f..0e749558 100644
--- a/app/Controller/Calendar.php
+++ b/app/Controller/Calendar.php
@@ -2,7 +2,7 @@
namespace Controller;
-use Model\Task;
+use Model\Task as TaskModel;
/**
* Project Calendar controller
@@ -74,7 +74,7 @@ class Calendar extends Base
$this->taskFilter
->create()
->filterByOwner($user_id)
- ->filterByStatus(Task::STATUS_OPEN)
+ ->filterByStatus(TaskModel::STATUS_OPEN)
->filterByDueDateRange(
$this->request->getStringParam('start'),
$this->request->getStringParam('end')
diff --git a/app/Controller/Link.php b/app/Controller/Link.php
new file mode 100644
index 00000000..ec9c6195
--- /dev/null
+++ b/app/Controller/Link.php
@@ -0,0 +1,162 @@
+<?php
+
+namespace Controller;
+
+/**
+ * Link controller
+ *
+ * @package controller
+ * @author Olivier Maridat
+ * @author Frederic Guillot
+ */
+class Link extends Base
+{
+ /**
+ * Common layout for config views
+ *
+ * @access private
+ * @param string $template Template name
+ * @param array $params Template parameters
+ * @return string
+ */
+ private function layout($template, array $params)
+ {
+ $params['board_selector'] = $this->projectPermission->getAllowedProjects($this->userSession->getId());
+ $params['config_content_for_layout'] = $this->template->render($template, $params);
+
+ return $this->template->layout('config/layout', $params);
+ }
+
+ /**
+ * Get the current link
+ *
+ * @access private
+ * @return array
+ */
+ private function getLink()
+ {
+ $link = $this->link->getById($this->request->getIntegerParam('link_id'));
+
+ if (! $link) {
+ $this->notfound();
+ }
+
+ return $link;
+ }
+
+ /**
+ * List of links
+ *
+ * @access public
+ */
+ public function index(array $values = array(), array $errors = array())
+ {
+ $this->response->html($this->layout('link/index', array(
+ 'links' => $this->link->getMergedList(),
+ 'values' => $values,
+ 'errors' => $errors,
+ 'title' => t('Settings').' &gt; '.t('Task\'s links'),
+ )));
+ }
+
+ /**
+ * Validate and save a new link
+ *
+ * @access public
+ */
+ public function save()
+ {
+ $values = $this->request->getValues();
+ list($valid, $errors) = $this->link->validateCreation($values);
+
+ if ($valid) {
+
+ if ($this->link->create($values['label'], $values['opposite_label'])) {
+ $this->session->flash(t('Link added successfully.'));
+ $this->response->redirect($this->helper->url('link', 'index'));
+ }
+ else {
+ $this->session->flashError(t('Unable to create your link.'));
+ }
+ }
+
+ $this->index($values, $errors);
+ }
+
+ /**
+ * Edit form
+ *
+ * @access public
+ */
+ public function edit(array $values = array(), array $errors = array())
+ {
+ $link = $this->getLink();
+ $link['label'] = t($link['label']);
+
+ $this->response->html($this->layout('link/edit', array(
+ 'values' => $values ?: $link,
+ 'errors' => $errors,
+ 'labels' => $this->link->getList($link['id']),
+ 'link' => $link,
+ 'title' => t('Link modification')
+ )));
+ }
+
+ /**
+ * Edit a link (validate the form and update the database)
+ *
+ * @access public
+ */
+ public function update()
+ {
+ $values = $this->request->getValues();
+ list($valid, $errors) = $this->link->validateModification($values);
+
+ if ($valid) {
+ if ($this->link->update($values)) {
+ $this->session->flash(t('Link updated successfully.'));
+ $this->response->redirect($this->helper->url('link', 'index'));
+ }
+ else {
+ $this->session->flashError(t('Unable to update your link.'));
+ }
+ }
+
+ $this->edit($values, $errors);
+ }
+
+ /**
+ * Confirmation dialog before removing a link
+ *
+ * @access public
+ */
+ public function confirm()
+ {
+ $link = $this->getLink();
+
+ $this->response->html($this->layout('link/remove', array(
+ 'link' => $link,
+ 'title' => t('Remove a link')
+ )));
+ }
+
+ /**
+ * Remove a link
+ *
+ * @access public
+ */
+ public function remove()
+ {
+ $this->checkCSRFParam();
+ $link = $this->getLink();
+
+ if ($this->link->remove($link['id'])) {
+ $this->session->flash(t('Link removed successfully.'));
+ }
+ else {
+ $this->session->flashError(t('Unable to remove this link.'));
+ }
+
+ $this->response->redirect($this->helper->url('link', 'index'));
+ }
+}
diff --git a/app/Controller/Task.php b/app/Controller/Task.php
index fdd20b5e..e561d5f7 100644
--- a/app/Controller/Task.php
+++ b/app/Controller/Task.php
@@ -36,6 +36,7 @@ class Task extends Base
'project' => $project,
'comments' => $this->comment->getAll($task['id']),
'subtasks' => $this->subtask->getAll($task['id']),
+ 'links' => $this->taskLink->getLinks($task['id']),
'task' => $task,
'columns_list' => $this->board->getColumnsList($task['project_id']),
'colors_list' => $this->color->getList(),
@@ -70,6 +71,7 @@ class Task extends Base
'files' => $this->file->getAll($task['id']),
'comments' => $this->comment->getAll($task['id']),
'subtasks' => $subtasks,
+ 'links' => $this->taskLink->getLinks($task['id']),
'task' => $task,
'values' => $values,
'columns_list' => $this->board->getColumnsList($task['project_id']),
diff --git a/app/Controller/Tasklink.php b/app/Controller/Tasklink.php
new file mode 100644
index 00000000..61b7fab8
--- /dev/null
+++ b/app/Controller/Tasklink.php
@@ -0,0 +1,116 @@
+<?php
+
+namespace Controller;
+
+/**
+ * TaskLink controller
+ *
+ * @package controller
+ * @author Olivier Maridat
+ * @author Frederic Guillot
+ */
+class Tasklink extends Base
+{
+ /**
+ * Get the current link
+ *
+ * @access private
+ * @return array
+ */
+ private function getTaskLink()
+ {
+ $link = $this->taskLink->getById($this->request->getIntegerParam('link_id'));
+
+ if (! $link) {
+ $this->notfound();
+ }
+
+ return $link;
+ }
+
+ /**
+ * Creation form
+ *
+ * @access public
+ */
+ public function create(array $values = array(), array $errors = array())
+ {
+ $task = $this->getTask();
+
+ if (empty($values)) {
+ $values = array(
+ 'task_id' => $task['id'],
+ );
+ }
+
+ $this->response->html($this->taskLayout('tasklink/create', array(
+ 'values' => $values,
+ 'errors' => $errors,
+ 'task' => $task,
+ 'labels' => $this->link->getList(0, false),
+ 'title' => t('Add a new link')
+ )));
+ }
+
+ /**
+ * Validation and creation
+ *
+ * @access public
+ */
+ public function save()
+ {
+ $task = $this->getTask();
+ $values = $this->request->getValues();
+
+ list($valid, $errors) = $this->taskLink->validateCreation($values);
+
+ if ($valid) {
+
+ if ($this->taskLink->create($values['task_id'], $values['opposite_task_id'], $values['link_id'])) {
+ $this->session->flash(t('Link added successfully.'));
+ $this->response->redirect($this->helper->url('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])).'#links');
+ }
+ else {
+ $this->session->flashError(t('Unable to create your link.'));
+ }
+ }
+
+ $this->create($values, $errors);
+ }
+
+ /**
+ * Confirmation dialog before removing a link
+ *
+ * @access public
+ */
+ public function confirm()
+ {
+ $task = $this->getTask();
+ $link = $this->getTaskLink();
+
+ $this->response->html($this->taskLayout('tasklink/remove', array(
+ 'link' => $link,
+ 'task' => $task,
+ )));
+ }
+
+ /**
+ * Remove a link
+ *
+ * @access public
+ */
+ public function remove()
+ {
+ $this->checkCSRFParam();
+ $task = $this->getTask();
+
+ if ($this->taskLink->remove($this->request->getIntegerParam('link_id'))) {
+ $this->session->flash(t('Link removed successfully.'));
+ }
+ else {
+ $this->session->flashError(t('Unable to remove this link.'));
+ }
+
+ $this->response->redirect($this->helper->url('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])));
+ }
+}
diff --git a/app/Locale/da_DK/translations.php b/app/Locale/da_DK/translations.php
index d0bebe0c..db91895f 100644
--- a/app/Locale/da_DK/translations.php
+++ b/app/Locale/da_DK/translations.php
@@ -686,4 +686,37 @@ return array(
// 'Task age in days' => '',
// 'Days in this column' => '',
// '%dd' => '',
+ // 'Add a link' => '',
+ // 'Add a new link' => '',
+ // 'Do you really want to remove this link: "%s"?' => '',
+ // 'Do you really want to remove this link with task #%d?' => '',
+ // 'Field required' => '',
+ // 'Link added successfully.' => '',
+ // 'Link updated successfully.' => '',
+ // 'Link removed successfully.' => '',
+ // 'Link labels' => '',
+ // 'Link modification' => '',
+ // 'Links' => '',
+ // 'Link settings' => '',
+ // 'Opposite label' => '',
+ // 'Remove a link' => '',
+ // 'Task\'s links' => '',
+ // 'The labels must be different' => '',
+ // 'There is no link.' => '',
+ // 'This label must be unique' => '',
+ // 'Unable to create your link.' => '',
+ // 'Unable to update your link.' => '',
+ // 'Unable to remove this link.' => '',
+ // 'relates to' => '',
+ // 'blocks' => '',
+ // 'is blocked by' => '',
+ // 'duplicates' => '',
+ // 'is duplicated by' => '',
+ // 'is a child of' => '',
+ // 'is a parent of' => '',
+ // 'targets milestone' => '',
+ // 'is a milestone of' => '',
+ // 'fixes' => '',
+ // 'is fixed by' => '',
+ // 'This task' => '',
);
diff --git a/app/Locale/de_DE/translations.php b/app/Locale/de_DE/translations.php
index 42db9bcd..967d5f62 100644
--- a/app/Locale/de_DE/translations.php
+++ b/app/Locale/de_DE/translations.php
@@ -686,4 +686,37 @@ return array(
// 'Task age in days' => '',
// 'Days in this column' => '',
// '%dd' => '',
+ // 'Add a link' => '',
+ // 'Add a new link' => '',
+ // 'Do you really want to remove this link: "%s"?' => '',
+ // 'Do you really want to remove this link with task #%d?' => '',
+ // 'Field required' => '',
+ // 'Link added successfully.' => '',
+ // 'Link updated successfully.' => '',
+ // 'Link removed successfully.' => '',
+ // 'Link labels' => '',
+ // 'Link modification' => '',
+ // 'Links' => '',
+ // 'Link settings' => '',
+ // 'Opposite label' => '',
+ // 'Remove a link' => '',
+ // 'Task\'s links' => '',
+ // 'The labels must be different' => '',
+ // 'There is no link.' => '',
+ // 'This label must be unique' => '',
+ // 'Unable to create your link.' => '',
+ // 'Unable to update your link.' => '',
+ // 'Unable to remove this link.' => '',
+ // 'relates to' => '',
+ // 'blocks' => '',
+ // 'is blocked by' => '',
+ // 'duplicates' => '',
+ // 'is duplicated by' => '',
+ // 'is a child of' => '',
+ // 'is a parent of' => '',
+ // 'targets milestone' => '',
+ // 'is a milestone of' => '',
+ // 'fixes' => '',
+ // 'is fixed by' => '',
+ // 'This task' => '',
);
diff --git a/app/Locale/es_ES/translations.php b/app/Locale/es_ES/translations.php
index e921c10f..c69d9084 100644
--- a/app/Locale/es_ES/translations.php
+++ b/app/Locale/es_ES/translations.php
@@ -686,4 +686,37 @@ return array(
// 'Task age in days' => '',
// 'Days in this column' => '',
// '%dd' => '',
+ // 'Add a link' => '',
+ // 'Add a new link' => '',
+ // 'Do you really want to remove this link: "%s"?' => '',
+ // 'Do you really want to remove this link with task #%d?' => '',
+ // 'Field required' => '',
+ // 'Link added successfully.' => '',
+ // 'Link updated successfully.' => '',
+ // 'Link removed successfully.' => '',
+ // 'Link labels' => '',
+ // 'Link modification' => '',
+ // 'Links' => '',
+ // 'Link settings' => '',
+ // 'Opposite label' => '',
+ // 'Remove a link' => '',
+ // 'Task\'s links' => '',
+ // 'The labels must be different' => '',
+ // 'There is no link.' => '',
+ // 'This label must be unique' => '',
+ // 'Unable to create your link.' => '',
+ // 'Unable to update your link.' => '',
+ // 'Unable to remove this link.' => '',
+ // 'relates to' => '',
+ // 'blocks' => '',
+ // 'is blocked by' => '',
+ // 'duplicates' => '',
+ // 'is duplicated by' => '',
+ // 'is a child of' => '',
+ // 'is a parent of' => '',
+ // 'targets milestone' => '',
+ // 'is a milestone of' => '',
+ // 'fixes' => '',
+ // 'is fixed by' => '',
+ // 'This task' => '',
);
diff --git a/app/Locale/fi_FI/translations.php b/app/Locale/fi_FI/translations.php
index 0ed1031b..0dd29c37 100644
--- a/app/Locale/fi_FI/translations.php
+++ b/app/Locale/fi_FI/translations.php
@@ -686,4 +686,37 @@ return array(
// 'Task age in days' => '',
// 'Days in this column' => '',
// '%dd' => '',
+ // 'Add a link' => '',
+ // 'Add a new link' => '',
+ // 'Do you really want to remove this link: "%s"?' => '',
+ // 'Do you really want to remove this link with task #%d?' => '',
+ // 'Field required' => '',
+ // 'Link added successfully.' => '',
+ // 'Link updated successfully.' => '',
+ // 'Link removed successfully.' => '',
+ // 'Link labels' => '',
+ // 'Link modification' => '',
+ // 'Links' => '',
+ // 'Link settings' => '',
+ // 'Opposite label' => '',
+ // 'Remove a link' => '',
+ // 'Task\'s links' => '',
+ // 'The labels must be different' => '',
+ // 'There is no link.' => '',
+ // 'This label must be unique' => '',
+ // 'Unable to create your link.' => '',
+ // 'Unable to update your link.' => '',
+ // 'Unable to remove this link.' => '',
+ // 'relates to' => '',
+ // 'blocks' => '',
+ // 'is blocked by' => '',
+ // 'duplicates' => '',
+ // 'is duplicated by' => '',
+ // 'is a child of' => '',
+ // 'is a parent of' => '',
+ // 'targets milestone' => '',
+ // 'is a milestone of' => '',
+ // 'fixes' => '',
+ // 'is fixed by' => '',
+ // 'This task' => '',
);
diff --git a/app/Locale/fr_FR/translations.php b/app/Locale/fr_FR/translations.php
index 7fc5e842..7872dec4 100644
--- a/app/Locale/fr_FR/translations.php
+++ b/app/Locale/fr_FR/translations.php
@@ -688,4 +688,37 @@ return array(
'Task age in days' => 'Age de la tâche en jours',
'Days in this column' => 'Jours dans cette colonne',
'%dd' => '%dj',
+ 'Add a link' => 'Ajouter un lien',
+ 'Add a new link' => 'Ajouter un nouveau lien',
+ 'Do you really want to remove this link: "%s"?' => 'Voulez-vous vraiment supprimer ce lien : « %s » ?',
+ 'Do you really want to remove this link with task #%d?' => 'Voulez-vous vraiment supprimer ce lien avec la tâche n°%s ?',
+ 'Field required' => 'Champ obligatoire',
+ 'Link added successfully.' => 'Lien créé avec succès.',
+ 'Link updated successfully.' => 'Lien mis à jour avec succès.',
+ 'Link removed successfully.' => 'Lien supprimé avec succès.',
+ 'Link labels' => 'Libellé des liens',
+ 'Link modification' => 'Modification d\'un lien',
+ 'Links' => 'Liens',
+ 'Link settings' => 'Paramètres des liens',
+ 'Opposite label' => 'Nom du libellé opposé',
+ 'Remove a link' => 'Supprimer un lien',
+ 'Task\'s links' => 'Liens des tâches',
+ 'The labels must be different' => 'Les libellés doivent être différents',
+ 'There is no link.' => 'Il n\'y a aucun lien.',
+ 'This label must be unique' => 'Ce libellé doit être unique',
+ 'Unable to create your link.' => 'Impossible d\'ajouter ce lien.',
+ 'Unable to update your link.' => 'Impossible de mettre à jour ce lien.',
+ 'Unable to remove this link.' => 'Impossible de supprimer ce lien.',
+ 'relates to' => 'est liée à',
+ 'blocks' => 'bloque',
+ 'is blocked by' => 'est bloquée par',
+ 'duplicates' => 'duplique',
+ 'is duplicated by' => 'est dupliquée par',
+ 'is a child of' => 'est un enfant de',
+ 'is a parent of' => 'est un parent de',
+ 'targets milestone' => 'vise l\'étape importante',
+ 'is a milestone of' => 'est une étape importante de',
+ 'fixes' => 'corrige',
+ 'is fixed by' => 'est corrigée par',
+ 'This task' => 'Cette tâche',
);
diff --git a/app/Locale/hu_HU/translations.php b/app/Locale/hu_HU/translations.php
index 3ca6cf28..1f8de0ea 100644
--- a/app/Locale/hu_HU/translations.php
+++ b/app/Locale/hu_HU/translations.php
@@ -686,4 +686,37 @@ return array(
// 'Task age in days' => '',
// 'Days in this column' => '',
// '%dd' => '',
+ // 'Add a link' => '',
+ // 'Add a new link' => '',
+ // 'Do you really want to remove this link: "%s"?' => '',
+ // 'Do you really want to remove this link with task #%d?' => '',
+ // 'Field required' => '',
+ // 'Link added successfully.' => '',
+ // 'Link updated successfully.' => '',
+ // 'Link removed successfully.' => '',
+ // 'Link labels' => '',
+ // 'Link modification' => '',
+ // 'Links' => '',
+ // 'Link settings' => '',
+ // 'Opposite label' => '',
+ // 'Remove a link' => '',
+ // 'Task\'s links' => '',
+ // 'The labels must be different' => '',
+ // 'There is no link.' => '',
+ // 'This label must be unique' => '',
+ // 'Unable to create your link.' => '',
+ // 'Unable to update your link.' => '',
+ // 'Unable to remove this link.' => '',
+ // 'relates to' => '',
+ // 'blocks' => '',
+ // 'is blocked by' => '',
+ // 'duplicates' => '',
+ // 'is duplicated by' => '',
+ // 'is a child of' => '',
+ // 'is a parent of' => '',
+ // 'targets milestone' => '',
+ // 'is a milestone of' => '',
+ // 'fixes' => '',
+ // 'is fixed by' => '',
+ // 'This task' => '',
);
diff --git a/app/Locale/it_IT/translations.php b/app/Locale/it_IT/translations.php
index 5c3ef47b..afd41d5c 100644
--- a/app/Locale/it_IT/translations.php
+++ b/app/Locale/it_IT/translations.php
@@ -686,4 +686,37 @@ return array(
// 'Task age in days' => '',
// 'Days in this column' => '',
// '%dd' => '',
+ // 'Add a link' => '',
+ // 'Add a new link' => '',
+ // 'Do you really want to remove this link: "%s"?' => '',
+ // 'Do you really want to remove this link with task #%d?' => '',
+ // 'Field required' => '',
+ // 'Link added successfully.' => '',
+ // 'Link updated successfully.' => '',
+ // 'Link removed successfully.' => '',
+ // 'Link labels' => '',
+ // 'Link modification' => '',
+ // 'Links' => '',
+ // 'Link settings' => '',
+ // 'Opposite label' => '',
+ // 'Remove a link' => '',
+ // 'Task\'s links' => '',
+ // 'The labels must be different' => '',
+ // 'There is no link.' => '',
+ // 'This label must be unique' => '',
+ // 'Unable to create your link.' => '',
+ // 'Unable to update your link.' => '',
+ // 'Unable to remove this link.' => '',
+ // 'relates to' => '',
+ // 'blocks' => '',
+ // 'is blocked by' => '',
+ // 'duplicates' => '',
+ // 'is duplicated by' => '',
+ // 'is a child of' => '',
+ // 'is a parent of' => '',
+ // 'targets milestone' => '',
+ // 'is a milestone of' => '',
+ // 'fixes' => '',
+ // 'is fixed by' => '',
+ // 'This task' => '',
);
diff --git a/app/Locale/ja_JP/translations.php b/app/Locale/ja_JP/translations.php
index 91030bee..9d06b83f 100644
--- a/app/Locale/ja_JP/translations.php
+++ b/app/Locale/ja_JP/translations.php
@@ -686,4 +686,37 @@ return array(
// 'Task age in days' => '',
// 'Days in this column' => '',
// '%dd' => '',
+ // 'Add a link' => '',
+ // 'Add a new link' => '',
+ // 'Do you really want to remove this link: "%s"?' => '',
+ // 'Do you really want to remove this link with task #%d?' => '',
+ // 'Field required' => '',
+ // 'Link added successfully.' => '',
+ // 'Link updated successfully.' => '',
+ // 'Link removed successfully.' => '',
+ // 'Link labels' => '',
+ // 'Link modification' => '',
+ // 'Links' => '',
+ // 'Link settings' => '',
+ // 'Opposite label' => '',
+ // 'Remove a link' => '',
+ // 'Task\'s links' => '',
+ // 'The labels must be different' => '',
+ // 'There is no link.' => '',
+ // 'This label must be unique' => '',
+ // 'Unable to create your link.' => '',
+ // 'Unable to update your link.' => '',
+ // 'Unable to remove this link.' => '',
+ // 'relates to' => '',
+ // 'blocks' => '',
+ // 'is blocked by' => '',
+ // 'duplicates' => '',
+ // 'is duplicated by' => '',
+ // 'is a child of' => '',
+ // 'is a parent of' => '',
+ // 'targets milestone' => '',
+ // 'is a milestone of' => '',
+ // 'fixes' => '',
+ // 'is fixed by' => '',
+ // 'This task' => '',
);
diff --git a/app/Locale/pl_PL/translations.php b/app/Locale/pl_PL/translations.php
index 7acb1497..2ebfc219 100644
--- a/app/Locale/pl_PL/translations.php
+++ b/app/Locale/pl_PL/translations.php
@@ -686,4 +686,37 @@ return array(
// 'Task age in days' => '',
// 'Days in this column' => '',
// '%dd' => '',
+ // 'Add a link' => '',
+ // 'Add a new link' => '',
+ // 'Do you really want to remove this link: "%s"?' => '',
+ // 'Do you really want to remove this link with task #%d?' => '',
+ // 'Field required' => '',
+ // 'Link added successfully.' => '',
+ // 'Link updated successfully.' => '',
+ // 'Link removed successfully.' => '',
+ // 'Link labels' => '',
+ // 'Link modification' => '',
+ // 'Links' => '',
+ // 'Link settings' => '',
+ // 'Opposite label' => '',
+ // 'Remove a link' => '',
+ // 'Task\'s links' => '',
+ // 'The labels must be different' => '',
+ // 'There is no link.' => '',
+ // 'This label must be unique' => '',
+ // 'Unable to create your link.' => '',
+ // 'Unable to update your link.' => '',
+ // 'Unable to remove this link.' => '',
+ // 'relates to' => '',
+ // 'blocks' => '',
+ // 'is blocked by' => '',
+ // 'duplicates' => '',
+ // 'is duplicated by' => '',
+ // 'is a child of' => '',
+ // 'is a parent of' => '',
+ // 'targets milestone' => '',
+ // 'is a milestone of' => '',
+ // 'fixes' => '',
+ // 'is fixed by' => '',
+ // 'This task' => '',
);
diff --git a/app/Locale/pt_BR/translations.php b/app/Locale/pt_BR/translations.php
index ac865046..ded6e0db 100644
--- a/app/Locale/pt_BR/translations.php
+++ b/app/Locale/pt_BR/translations.php
@@ -686,4 +686,37 @@ return array(
// 'Task age in days' => '',
// 'Days in this column' => '',
// '%dd' => '',
+ // 'Add a link' => '',
+ // 'Add a new link' => '',
+ // 'Do you really want to remove this link: "%s"?' => '',
+ // 'Do you really want to remove this link with task #%d?' => '',
+ // 'Field required' => '',
+ // 'Link added successfully.' => '',
+ // 'Link updated successfully.' => '',
+ // 'Link removed successfully.' => '',
+ // 'Link labels' => '',
+ // 'Link modification' => '',
+ // 'Links' => '',
+ // 'Link settings' => '',
+ // 'Opposite label' => '',
+ // 'Remove a link' => '',
+ // 'Task\'s links' => '',
+ // 'The labels must be different' => '',
+ // 'There is no link.' => '',
+ // 'This label must be unique' => '',
+ // 'Unable to create your link.' => '',
+ // 'Unable to update your link.' => '',
+ // 'Unable to remove this link.' => '',
+ // 'relates to' => '',
+ // 'blocks' => '',
+ // 'is blocked by' => '',
+ // 'duplicates' => '',
+ // 'is duplicated by' => '',
+ // 'is a child of' => '',
+ // 'is a parent of' => '',
+ // 'targets milestone' => '',
+ // 'is a milestone of' => '',
+ // 'fixes' => '',
+ // 'is fixed by' => '',
+ // 'This task' => '',
);
diff --git a/app/Locale/ru_RU/translations.php b/app/Locale/ru_RU/translations.php
index 624a17f1..de792516 100644
--- a/app/Locale/ru_RU/translations.php
+++ b/app/Locale/ru_RU/translations.php
@@ -686,4 +686,37 @@ return array(
// 'Task age in days' => '',
// 'Days in this column' => '',
// '%dd' => '',
+ // 'Add a link' => '',
+ // 'Add a new link' => '',
+ // 'Do you really want to remove this link: "%s"?' => '',
+ // 'Do you really want to remove this link with task #%d?' => '',
+ // 'Field required' => '',
+ // 'Link added successfully.' => '',
+ // 'Link updated successfully.' => '',
+ // 'Link removed successfully.' => '',
+ // 'Link labels' => '',
+ // 'Link modification' => '',
+ // 'Links' => '',
+ // 'Link settings' => '',
+ // 'Opposite label' => '',
+ // 'Remove a link' => '',
+ // 'Task\'s links' => '',
+ // 'The labels must be different' => '',
+ // 'There is no link.' => '',
+ // 'This label must be unique' => '',
+ // 'Unable to create your link.' => '',
+ // 'Unable to update your link.' => '',
+ // 'Unable to remove this link.' => '',
+ // 'relates to' => '',
+ // 'blocks' => '',
+ // 'is blocked by' => '',
+ // 'duplicates' => '',
+ // 'is duplicated by' => '',
+ // 'is a child of' => '',
+ // 'is a parent of' => '',
+ // 'targets milestone' => '',
+ // 'is a milestone of' => '',
+ // 'fixes' => '',
+ // 'is fixed by' => '',
+ // 'This task' => '',
);
diff --git a/app/Locale/sv_SE/translations.php b/app/Locale/sv_SE/translations.php
index b77a8a5a..838b426a 100644
--- a/app/Locale/sv_SE/translations.php
+++ b/app/Locale/sv_SE/translations.php
@@ -686,4 +686,37 @@ return array(
// 'Task age in days' => '',
// 'Days in this column' => '',
// '%dd' => '',
+ // 'Add a link' => '',
+ // 'Add a new link' => '',
+ // 'Do you really want to remove this link: "%s"?' => '',
+ // 'Do you really want to remove this link with task #%d?' => '',
+ // 'Field required' => '',
+ // 'Link added successfully.' => '',
+ // 'Link updated successfully.' => '',
+ // 'Link removed successfully.' => '',
+ // 'Link labels' => '',
+ // 'Link modification' => '',
+ // 'Links' => '',
+ // 'Link settings' => '',
+ // 'Opposite label' => '',
+ // 'Remove a link' => '',
+ // 'Task\'s links' => '',
+ // 'The labels must be different' => '',
+ // 'There is no link.' => '',
+ // 'This label must be unique' => '',
+ // 'Unable to create your link.' => '',
+ // 'Unable to update your link.' => '',
+ // 'Unable to remove this link.' => '',
+ // 'relates to' => '',
+ // 'blocks' => '',
+ // 'is blocked by' => '',
+ // 'duplicates' => '',
+ // 'is duplicated by' => '',
+ // 'is a child of' => '',
+ // 'is a parent of' => '',
+ // 'targets milestone' => '',
+ // 'is a milestone of' => '',
+ // 'fixes' => '',
+ // 'is fixed by' => '',
+ // 'This task' => '',
);
diff --git a/app/Locale/th_TH/translations.php b/app/Locale/th_TH/translations.php
index 64e0170e..66c7712b 100644
--- a/app/Locale/th_TH/translations.php
+++ b/app/Locale/th_TH/translations.php
@@ -686,4 +686,37 @@ return array(
// 'Task age in days' => '',
// 'Days in this column' => '',
// '%dd' => '',
+ // 'Add a link' => '',
+ // 'Add a new link' => '',
+ // 'Do you really want to remove this link: "%s"?' => '',
+ // 'Do you really want to remove this link with task #%d?' => '',
+ // 'Field required' => '',
+ // 'Link added successfully.' => '',
+ // 'Link updated successfully.' => '',
+ // 'Link removed successfully.' => '',
+ // 'Link labels' => '',
+ // 'Link modification' => '',
+ // 'Links' => '',
+ // 'Link settings' => '',
+ // 'Opposite label' => '',
+ // 'Remove a link' => '',
+ // 'Task\'s links' => '',
+ // 'The labels must be different' => '',
+ // 'There is no link.' => '',
+ // 'This label must be unique' => '',
+ // 'Unable to create your link.' => '',
+ // 'Unable to update your link.' => '',
+ // 'Unable to remove this link.' => '',
+ // 'relates to' => '',
+ // 'blocks' => '',
+ // 'is blocked by' => '',
+ // 'duplicates' => '',
+ // 'is duplicated by' => '',
+ // 'is a child of' => '',
+ // 'is a parent of' => '',
+ // 'targets milestone' => '',
+ // 'is a milestone of' => '',
+ // 'fixes' => '',
+ // 'is fixed by' => '',
+ // 'This task' => '',
);
diff --git a/app/Locale/zh_CN/translations.php b/app/Locale/zh_CN/translations.php
index addf7fa6..32278e21 100644
--- a/app/Locale/zh_CN/translations.php
+++ b/app/Locale/zh_CN/translations.php
@@ -686,4 +686,37 @@ return array(
// 'Task age in days' => '',
// 'Days in this column' => '',
// '%dd' => '',
+ // 'Add a link' => '',
+ // 'Add a new link' => '',
+ // 'Do you really want to remove this link: "%s"?' => '',
+ // 'Do you really want to remove this link with task #%d?' => '',
+ // 'Field required' => '',
+ // 'Link added successfully.' => '',
+ // 'Link updated successfully.' => '',
+ // 'Link removed successfully.' => '',
+ // 'Link labels' => '',
+ // 'Link modification' => '',
+ // 'Links' => '',
+ // 'Link settings' => '',
+ // 'Opposite label' => '',
+ // 'Remove a link' => '',
+ // 'Task\'s links' => '',
+ // 'The labels must be different' => '',
+ // 'There is no link.' => '',
+ // 'This label must be unique' => '',
+ // 'Unable to create your link.' => '',
+ // 'Unable to update your link.' => '',
+ // 'Unable to remove this link.' => '',
+ // 'relates to' => '',
+ // 'blocks' => '',
+ // 'is blocked by' => '',
+ // 'duplicates' => '',
+ // 'is duplicated by' => '',
+ // 'is a child of' => '',
+ // 'is a parent of' => '',
+ // 'targets milestone' => '',
+ // 'is a milestone of' => '',
+ // 'fixes' => '',
+ // 'is fixed by' => '',
+ // 'This task' => '',
);
diff --git a/app/Model/Acl.php b/app/Model/Acl.php
index b6c0e6b8..e713f2e1 100644
--- a/app/Model/Acl.php
+++ b/app/Model/Acl.php
@@ -38,6 +38,7 @@ class Acl extends Base
'project' => array('show', 'tasks', 'search', 'activity'),
'subtask' => '*',
'task' => '*',
+ 'tasklink' => '*',
'calendar' => array('show', 'events', 'save'),
);
@@ -67,6 +68,7 @@ class Acl extends Base
'app' => array('dashboard'),
'user' => array('index', 'create', 'save', 'remove'),
'config' => '*',
+ 'link' => '*',
'project' => array('remove'),
);
diff --git a/app/Model/Authentication.php b/app/Model/Authentication.php
index 92898cd5..86c1c43f 100644
--- a/app/Model/Authentication.php
+++ b/app/Model/Authentication.php
@@ -42,6 +42,13 @@ class Authentication extends Base
// If the user is already logged it's ok
if ($this->userSession->isLogged()) {
+ // Check if the user session match an existing user
+ if (! $this->user->exists($this->userSession->getId())) {
+ $this->backend('rememberMe')->destroy($this->userSession->getId());
+ $this->session->close();
+ return false;
+ }
+
// We update each time the RememberMe cookie tokens
if ($this->backend('rememberMe')->hasCookie()) {
$this->backend('rememberMe')->refresh();
diff --git a/app/Model/Base.php b/app/Model/Base.php
index 319e53fc..f836231c 100644
--- a/app/Model/Base.php
+++ b/app/Model/Base.php
@@ -25,6 +25,7 @@ use Pimple\Container;
* @property \Model\File $file
* @property \Model\Helper $helper
* @property \Model\LastLogin $lastLogin
+ * @property \Model\Link $link
* @property \Model\Notification $notification
* @property \Model\Project $project
* @property \Model\ProjectDuplication $projectDuplication
@@ -38,6 +39,7 @@ use Pimple\Container;
* @property \Model\TaskExport $taskExport
* @property \Model\TaskFinder $taskFinder
* @property \Model\TaskHistory $taskHistory
+ * @property \Model\TaskLink $taskLink
* @property \Model\TaskPosition $taskPosition
* @property \Model\TaskValidator $taskValidator
* @property \Model\TimeTracking $timeTracking
diff --git a/app/Model/Link.php b/app/Model/Link.php
new file mode 100644
index 00000000..87ba49c4
--- /dev/null
+++ b/app/Model/Link.php
@@ -0,0 +1,234 @@
+<?php
+
+namespace Model;
+
+use PDO;
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+
+/**
+ * Link model
+ *
+ * @package model
+ * @author Olivier Maridat
+ * @author Frederic Guillot
+ */
+class Link extends Base
+{
+ /**
+ * SQL table name
+ *
+ * @var string
+ */
+ const TABLE = 'links';
+
+ /**
+ * Get a link by id
+ *
+ * @access public
+ * @param integer $link_id Link id
+ * @return array
+ */
+ public function getById($link_id)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $link_id)->findOne();
+ }
+
+ /**
+ * Get a link by name
+ *
+ * @access public
+ * @param string $label
+ * @return array
+ */
+ public function getByLabel($label)
+ {
+ return $this->db->table(self::TABLE)->eq('label', $label)->findOne();
+ }
+
+ /**
+ * Get the opposite link id
+ *
+ * @access public
+ * @param integer $link_id Link id
+ * @return integer
+ */
+ public function getOppositeLinkId($link_id)
+ {
+ $link = $this->getById($link_id);
+ return $link['opposite_id'] ?: $link_id;
+ }
+
+ /**
+ * Get all links
+ *
+ * @access public
+ * @return array
+ */
+ public function getAll()
+ {
+ return $this->db->table(self::TABLE)->findAll();
+ }
+
+ /**
+ * Get merged links
+ *
+ * @access public
+ * @return array
+ */
+ public function getMergedList()
+ {
+ return $this->db
+ ->execute('
+ SELECT
+ links.id, links.label, opposite.label as opposite_label
+ FROM links
+ LEFT JOIN links AS opposite ON opposite.id=links.opposite_id
+ ')
+ ->fetchAll(PDO::FETCH_ASSOC);
+ }
+
+ /**
+ * Get label list
+ *
+ * @access public
+ * @param integer $exclude_id Exclude this link
+ * @param booelan $prepend Prepend default value
+ * @return array
+ */
+ public function getList($exclude_id = 0, $prepend = true)
+ {
+ $labels = $this->db->hashtable(self::TABLE)->neq('id', $exclude_id)->asc('id')->getAll('id', 'label');
+
+ foreach ($labels as &$value) {
+ $value = t($value);
+ }
+
+ return $prepend ? array('') + $labels : $labels;
+ }
+
+ /**
+ * Create a new link label
+ *
+ * @access public
+ * @param string $label
+ * @param string $opposite_label
+ * @return boolean
+ */
+ public function create($label, $opposite_label = '')
+ {
+ $this->db->startTransaction();
+
+ if (! $this->db->table(self::TABLE)->insert(array('label' => $label))) {
+ $this->db->cancelTransaction();
+ return false;
+ }
+
+ if ($opposite_label !== '') {
+ $this->createOpposite($opposite_label);
+ }
+
+ $this->db->closeTransaction();
+
+ return true;
+ }
+
+ /**
+ * Create the opposite label (executed inside create() method)
+ *
+ * @access private
+ * @param string $label
+ */
+ private function createOpposite($label)
+ {
+ $label_id = $this->db->getConnection()->getLastId();
+
+ $this->db
+ ->table(self::TABLE)
+ ->insert(array(
+ 'label' => $label,
+ 'opposite_id' => $label_id,
+ ));
+
+ $this->db
+ ->table(self::TABLE)
+ ->eq('id', $label_id)
+ ->update(array(
+ 'opposite_id' => $this->db->getConnection()->getLastId()
+ ));
+ }
+
+ /**
+ * Update a link
+ *
+ * @access public
+ * @param array $values
+ * @return boolean
+ */
+ public function update(array $values)
+ {
+ return $this->db
+ ->table(self::TABLE)
+ ->eq('id', $values['id'])
+ ->update(array(
+ 'label' => $values['label'],
+ 'opposite_id' => $values['opposite_id'],
+ ));
+ }
+
+ /**
+ * Remove a link a the relation to its opposite
+ *
+ * @access public
+ * @param integer $link_id
+ * @return boolean
+ */
+ public function remove($link_id)
+ {
+ $this->db->table(self::TABLE)->eq('opposite_id', $link_id)->update(array('opposite_id' => 0));
+ return $this->db->table(self::TABLE)->eq('id', $link_id)->remove();
+ }
+
+ /**
+ * Validate 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('label', t('Field required')),
+ new Validators\Unique('label', t('This label must be unique'), $this->db->getConnection(), self::TABLE),
+ new Validators\NotEquals('label', 'opposite_label', t('The labels must be different')),
+ ));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate 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('Field required')),
+ new Validators\Required('opposite_id', t('Field required')),
+ new Validators\Required('label', t('Field required')),
+ new Validators\Unique('label', t('This label must be unique'), $this->db->getConnection(), self::TABLE),
+ ));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+}
diff --git a/app/Model/ProjectActivity.php b/app/Model/ProjectActivity.php
index 7762d0fd..652cc842 100644
--- a/app/Model/ProjectActivity.php
+++ b/app/Model/ProjectActivity.php
@@ -62,7 +62,7 @@ class ProjectActivity extends Base
*/
public function getProject($project_id, $limit = 50, $start = null, $end = null)
{
- return $this->getProjects(array($project_id), $limit, $start = null, $end = null);
+ return $this->getProjects(array($project_id), $limit, $start, $end);
}
/**
@@ -91,15 +91,15 @@ class ProjectActivity extends Base
->join(User::TABLE, 'id', 'creator_id')
->desc(self::TABLE.'.id')
->limit($limit);
-
+
if(!is_null($start)){
$query->gte('date_creation', $start);
}
-
+
if(!is_null($end)){
$query->lte('date_creation', $end);
}
-
+
$events = $query->findAll();
foreach ($events as &$event) {
diff --git a/app/Model/ProjectPermission.php b/app/Model/ProjectPermission.php
index fb6316b6..12bd9309 100644
--- a/app/Model/ProjectPermission.php
+++ b/app/Model/ProjectPermission.php
@@ -326,7 +326,7 @@ class ProjectPermission extends Base
*
* @access public
* @param integer $user_id User id
- * @return []integer
+ * @return array
*/
public function getMemberProjectIds($user_id)
{
@@ -338,6 +338,23 @@ class ProjectPermission extends Base
}
/**
+ * Return a list of active project ids where the user is member
+ *
+ * @access public
+ * @param integer $user_id User id
+ * @return array
+ */
+ public function getActiveMemberProjectIds($user_id)
+ {
+ return $this->db
+ ->table(Project::TABLE)
+ ->eq('user_id', $user_id)
+ ->eq(Project::TABLE.'.is_active', Project::ACTIVE)
+ ->join(self::TABLE, 'project_id', 'id')
+ ->findAllByColumn('projects.id');
+ }
+
+ /**
* Return a list of active projects where the user is member
*
* @access public
diff --git a/app/Model/TaskFilter.php b/app/Model/TaskFilter.php
index b5a90154..31de2795 100644
--- a/app/Model/TaskFilter.php
+++ b/app/Model/TaskFilter.php
@@ -18,6 +18,24 @@ class TaskFilter extends Base
return $this;
}
+ public function excludeTasks(array $task_ids)
+ {
+ $this->query->notin('id', $task_ids);
+ return $this;
+ }
+
+ public function filterByTitle($title)
+ {
+ $this->query->ilike('title', '%'.$title.'%');
+ return $this;
+ }
+
+ public function filterByProjects(array $project_ids)
+ {
+ $this->query->in('project_id', $project_ids);
+ return $this;
+ }
+
public function filterByProject($project_id)
{
if ($project_id > 0) {
@@ -94,6 +112,20 @@ class TaskFilter extends Base
return $this->query->findAll();
}
+ public function toAutoCompletion()
+ {
+ return $this->query->columns('id', 'title')->filter(function(array $results) {
+
+ foreach ($results as &$result) {
+ $result['value'] = $result['title'];
+ $result['label'] = '#'.$result['id'].' - '.$result['title'];
+ }
+
+ return $results;
+
+ })->findAll();
+ }
+
public function toCalendarEvents()
{
$events = array();
diff --git a/app/Model/TaskFinder.php b/app/Model/TaskFinder.php
index 27fa8150..98ece4e1 100644
--- a/app/Model/TaskFinder.php
+++ b/app/Model/TaskFinder.php
@@ -84,6 +84,7 @@ class TaskFinder extends Base
'(SELECT count(*) FROM task_has_files WHERE task_id=tasks.id) AS nb_files',
'(SELECT count(*) FROM task_has_subtasks WHERE task_id=tasks.id) AS nb_subtasks',
'(SELECT count(*) FROM task_has_subtasks WHERE task_id=tasks.id AND status=2) AS nb_completed_subtasks',
+ '(SELECT count(*) FROM ' . TaskLink::TABLE . ' WHERE ' . TaskLink::TABLE . '.task_id = tasks.id) AS nb_links',
'tasks.id',
'tasks.reference',
'tasks.title',
diff --git a/app/Model/TaskLink.php b/app/Model/TaskLink.php
new file mode 100644
index 00000000..c712e5a8
--- /dev/null
+++ b/app/Model/TaskLink.php
@@ -0,0 +1,142 @@
+<?php
+
+namespace Model;
+
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+
+/**
+ * TaskLink model
+ *
+ * @package model
+ * @author Olivier Maridat
+ * @author Frederic Guillot
+ */
+class TaskLink extends Base
+{
+ /**
+ * SQL table name
+ *
+ * @var string
+ */
+ const TABLE = 'task_has_links';
+
+ /**
+ * Get a task link
+ *
+ * @access public
+ * @param integer $task_link_id Task link id
+ * @return array
+ */
+ public function getById($task_link_id)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $task_link_id)->findOne();
+ }
+
+ /**
+ * Get all links attached to a task
+ *
+ * @access public
+ * @param integer $task_id Task id
+ * @return array
+ */
+ public function getLinks($task_id)
+ {
+ return $this->db
+ ->table(self::TABLE)
+ ->columns(
+ self::TABLE.'.id',
+ self::TABLE.'.opposite_task_id AS task_id',
+ Link::TABLE.'.label',
+ Task::TABLE.'.title',
+ Task::TABLE.'.is_active',
+ Task::TABLE.'.project_id'
+ )
+ ->eq(self::TABLE.'.task_id', $task_id)
+ ->join(Link::TABLE, 'id', 'link_id')
+ ->join(Task::TABLE, 'id', 'opposite_task_id')
+ ->findAll();
+ }
+
+ /**
+ * Create a new link
+ *
+ * @access public
+ * @param integer $task_id Task id
+ * @param integer $opposite_task_id Opposite task id
+ * @param integer $link_id Link id
+ * @return boolean
+ */
+ public function create($task_id, $opposite_task_id, $link_id)
+ {
+ $this->db->startTransaction();
+
+ // Create the original link
+ $this->db->table(self::TABLE)->insert(array(
+ 'task_id' => $task_id,
+ 'opposite_task_id' => $opposite_task_id,
+ 'link_id' => $link_id,
+ ));
+
+ $link_id = $this->link->getOppositeLinkId($link_id);
+
+ // Create the opposite link
+ $this->db->table(self::TABLE)->insert(array(
+ 'task_id' => $opposite_task_id,
+ 'opposite_task_id' => $task_id,
+ 'link_id' => $link_id,
+ ));
+
+ $this->db->closeTransaction();
+
+ return true;
+ }
+
+ /**
+ * Remove a link between two tasks
+ *
+ * @access public
+ * @param integer $task_link_id
+ * @return boolean
+ */
+ public function remove($task_link_id)
+ {
+ $this->db->startTransaction();
+
+ $link = $this->getById($task_link_id);
+ $link_id = $this->link->getOppositeLinkId($link['link_id']);
+
+ $this->db->table(self::TABLE)->eq('id', $task_link_id)->remove();
+
+ $this->db
+ ->table(self::TABLE)
+ ->eq('opposite_task_id', $link['task_id'])
+ ->eq('task_id', $link['opposite_task_id'])
+ ->eq('link_id', $link_id)->remove();
+
+ $this->db->closeTransaction();
+
+ return true;
+ }
+
+ /**
+ * Validate 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('task_id', t('Field required')),
+ new Validators\Required('link_id', t('Field required')),
+ new Validators\Required('title', t('Field required')),
+ ));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+}
diff --git a/app/Model/User.php b/app/Model/User.php
index 01be8597..7586f3c4 100644
--- a/app/Model/User.php
+++ b/app/Model/User.php
@@ -29,6 +29,18 @@ class User extends Base
const EVERYBODY_ID = -1;
/**
+ * Return true if the user exists
+ *
+ * @access public
+ * @param integer $user_id User id
+ * @return boolean
+ */
+ public function exists($user_id)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $user_id)->count() === 1;
+ }
+
+ /**
* Get query to fetch all users
*
* @access public
diff --git a/app/Schema/Mysql.php b/app/Schema/Mysql.php
index 24bc2baf..947a62b3 100644
--- a/app/Schema/Mysql.php
+++ b/app/Schema/Mysql.php
@@ -4,13 +4,52 @@ namespace Schema;
use PDO;
use Core\Security;
-
-const VERSION = 45;
+use Model\Link;
+
+const VERSION = 46;
+
+function version_46($pdo)
+{
+ $pdo->exec("CREATE TABLE links (
+ id INT NOT NULL AUTO_INCREMENT,
+ label VARCHAR(255) NOT NULL,
+ opposite_id INT DEFAULT 0,
+ PRIMARY KEY(id),
+ UNIQUE(label)
+ ) ENGINE=InnoDB CHARSET=utf8");
+
+ $pdo->exec("CREATE TABLE task_has_links (
+ id INT NOT NULL AUTO_INCREMENT,
+ link_id INT NOT NULL,
+ task_id INT NOT NULL,
+ opposite_task_id INT NOT NULL,
+ FOREIGN KEY(link_id) REFERENCES links(id) ON DELETE CASCADE,
+ FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE,
+ FOREIGN KEY(opposite_task_id) REFERENCES tasks(id) ON DELETE CASCADE,
+ PRIMARY KEY(id)
+ ) ENGINE=InnoDB CHARSET=utf8");
+
+ $pdo->exec("CREATE INDEX task_has_links_task_index ON task_has_links(task_id)");
+ $pdo->exec("CREATE UNIQUE INDEX task_has_links_unique ON task_has_links(link_id, task_id, opposite_task_id)");
+
+ $rq = $pdo->prepare('INSERT INTO links (label, opposite_id) VALUES (?, ?)');
+ $rq->execute(array('relates to', 0));
+ $rq->execute(array('blocks', 3));
+ $rq->execute(array('is blocked by', 2));
+ $rq->execute(array('duplicates', 5));
+ $rq->execute(array('is duplicated by', 4));
+ $rq->execute(array('is a child of', 7));
+ $rq->execute(array('is a parent of', 6));
+ $rq->execute(array('targets milestone', 9));
+ $rq->execute(array('is a milestone of', 8));
+ $rq->execute(array('fixes', 11));
+ $rq->execute(array('is fixed by', 10));
+}
function version_45($pdo)
{
$pdo->exec('ALTER TABLE tasks ADD COLUMN date_moved INT DEFAULT 0');
-
+
/* Update tasks.date_moved from project_activities table if tasks.date_moved = null or 0.
* We take max project_activities.date_creation where event_name in task.create','task.move.column
* since creation date is always less than task moves
@@ -122,7 +161,7 @@ function version_38($pdo)
");
$pdo->exec('ALTER TABLE tasks ADD COLUMN swimlane_id INT DEFAULT 0');
- $pdo->exec("ALTER TABLE projects ADD COLUMN default_swimlane VARCHAR(200) DEFAULT '".t('Default swimlane')."'");
+ $pdo->exec("ALTER TABLE projects ADD COLUMN default_swimlane VARCHAR(200) DEFAULT 'Default swimlane'");
$pdo->exec("ALTER TABLE projects ADD COLUMN show_default_swimlane INT DEFAULT 1");
}
diff --git a/app/Schema/Postgres.php b/app/Schema/Postgres.php
index d3fb9fc4..027401ff 100644
--- a/app/Schema/Postgres.php
+++ b/app/Schema/Postgres.php
@@ -4,8 +4,45 @@ namespace Schema;
use PDO;
use Core\Security;
+use Model\Link;
-const VERSION = 26;
+const VERSION = 27;
+
+function version_27($pdo)
+{
+ $pdo->exec('CREATE TABLE links (
+ "id" SERIAL PRIMARY KEY,
+ "label" VARCHAR(255) NOT NULL,
+ "opposite_id" INTEGER DEFAULT 0,
+ UNIQUE("label")
+ )');
+
+ $pdo->exec("CREATE TABLE task_has_links (
+ id SERIAL PRIMARY KEY,
+ link_id INTEGER NOT NULL,
+ task_id INTEGER NOT NULL,
+ opposite_task_id INTEGER NOT NULL,
+ FOREIGN KEY(link_id) REFERENCES links(id) ON DELETE CASCADE,
+ FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE,
+ FOREIGN KEY(opposite_task_id) REFERENCES tasks(id) ON DELETE CASCADE
+ )");
+
+ $pdo->exec("CREATE INDEX task_has_links_task_index ON task_has_links(task_id)");
+ $pdo->exec("CREATE UNIQUE INDEX task_has_links_unique ON task_has_links(link_id, task_id, opposite_task_id)");
+
+ $rq = $pdo->prepare('INSERT INTO links (label, opposite_id) VALUES (?, ?)');
+ $rq->execute(array('relates to', 0));
+ $rq->execute(array('blocks', 3));
+ $rq->execute(array('is blocked by', 2));
+ $rq->execute(array('duplicates', 5));
+ $rq->execute(array('is duplicated by', 4));
+ $rq->execute(array('is a child of', 7));
+ $rq->execute(array('is a parent of', 6));
+ $rq->execute(array('targets milestone', 9));
+ $rq->execute(array('is a milestone of', 8));
+ $rq->execute(array('fixes', 11));
+ $rq->execute(array('is fixed by', 10));
+}
function version_26($pdo)
{
@@ -66,7 +103,7 @@ function version_24($pdo)
function version_23($pdo)
{
- $pdo->exec('ALTER TABLE columns ADD COLUMN description TEXT');
+ $pdo->exec('ALTER TABLE columns ADD COLUMN description TEXT');
}
function version_22($pdo)
@@ -120,7 +157,7 @@ function version_19($pdo)
");
$pdo->exec('ALTER TABLE tasks ADD COLUMN swimlane_id INTEGER DEFAULT 0');
- $pdo->exec("ALTER TABLE projects ADD COLUMN default_swimlane VARCHAR(200) DEFAULT '".t('Default swimlane')."'");
+ $pdo->exec("ALTER TABLE projects ADD COLUMN default_swimlane VARCHAR(200) DEFAULT 'Default swimlane'");
$pdo->exec("ALTER TABLE projects ADD COLUMN show_default_swimlane BOOLEAN DEFAULT '1'");
}
diff --git a/app/Schema/Sqlite.php b/app/Schema/Sqlite.php
index f027cf91..c6dec33f 100644
--- a/app/Schema/Sqlite.php
+++ b/app/Schema/Sqlite.php
@@ -4,13 +4,50 @@ namespace Schema;
use Core\Security;
use PDO;
-
-const VERSION = 44;
+use Model\Link;
+
+const VERSION = 45;
+
+function version_45($pdo)
+{
+ $pdo->exec("CREATE TABLE links (
+ id INTEGER PRIMARY KEY,
+ label TEXT NOT NULL,
+ opposite_id INTEGER DEFAULT 0,
+ UNIQUE(label)
+ )");
+
+ $pdo->exec("CREATE TABLE task_has_links (
+ id INTEGER PRIMARY KEY,
+ link_id INTEGER NOT NULL,
+ task_id INTEGER NOT NULL,
+ opposite_task_id INTEGER NOT NULL,
+ FOREIGN KEY(link_id) REFERENCES links(id) ON DELETE CASCADE,
+ FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE,
+ FOREIGN KEY(opposite_task_id) REFERENCES tasks(id) ON DELETE CASCADE
+ )");
+
+ $pdo->exec("CREATE INDEX task_has_links_task_index ON task_has_links(task_id)");
+ $pdo->exec("CREATE UNIQUE INDEX task_has_links_unique ON task_has_links(link_id, task_id, opposite_task_id)");
+
+ $rq = $pdo->prepare('INSERT INTO links (label, opposite_id) VALUES (?, ?)');
+ $rq->execute(array('relates to', 0));
+ $rq->execute(array('blocks', 3));
+ $rq->execute(array('is blocked by', 2));
+ $rq->execute(array('duplicates', 5));
+ $rq->execute(array('is duplicated by', 4));
+ $rq->execute(array('is a child of', 7));
+ $rq->execute(array('is a parent of', 6));
+ $rq->execute(array('targets milestone', 9));
+ $rq->execute(array('is a milestone of', 8));
+ $rq->execute(array('fixes', 11));
+ $rq->execute(array('is fixed by', 10));
+}
function version_44($pdo)
{
$pdo->exec('ALTER TABLE tasks ADD COLUMN date_moved INTEGER DEFAULT 0');
-
+
/* Update tasks.date_moved from project_activities table if tasks.date_moved = null or 0.
* We take max project_activities.date_creation where event_name in task.create','task.move.column
* since creation date is always less than task moves
@@ -66,7 +103,7 @@ function version_42($pdo)
function version_41($pdo)
{
- $pdo->exec('ALTER TABLE columns ADD COLUMN description TEXT');
+ $pdo->exec('ALTER TABLE columns ADD COLUMN description TEXT');
}
function version_40($pdo)
@@ -120,7 +157,7 @@ function version_37($pdo)
");
$pdo->exec('ALTER TABLE tasks ADD COLUMN swimlane_id INTEGER DEFAULT 0');
- $pdo->exec("ALTER TABLE projects ADD COLUMN default_swimlane TEXT DEFAULT '".t('Default swimlane')."'");
+ $pdo->exec("ALTER TABLE projects ADD COLUMN default_swimlane TEXT DEFAULT 'Default swimlane'");
$pdo->exec("ALTER TABLE projects ADD COLUMN show_default_swimlane INTEGER DEFAULT 1");
}
diff --git a/app/ServiceProvider/ClassProvider.php b/app/ServiceProvider/ClassProvider.php
index bee03184..213972ed 100644
--- a/app/ServiceProvider/ClassProvider.php
+++ b/app/ServiceProvider/ClassProvider.php
@@ -24,6 +24,7 @@ class ClassProvider implements ServiceProviderInterface
'DateParser',
'File',
'LastLogin',
+ 'Link',
'Notification',
'Project',
'ProjectActivity',
@@ -41,6 +42,7 @@ class ClassProvider implements ServiceProviderInterface
'TaskExport',
'TaskFinder',
'TaskFilter',
+ 'TaskLink',
'TaskModification',
'TaskPermission',
'TaskPosition',
diff --git a/app/Subscriber/Base.php b/app/Subscriber/Base.php
index 489d49b0..f90d9604 100644
--- a/app/Subscriber/Base.php
+++ b/app/Subscriber/Base.php
@@ -10,6 +10,7 @@ use Pimple\Container;
* @package subscriber
* @author Frederic Guillot
*
+ * @property \Model\Board $board
* @property \Model\Config $config
* @property \Model\Comment $comment
* @property \Model\LastLogin $lastLogin
diff --git a/app/Template/board/task.php b/app/Template/board/task.php
index 1608e337..075eab92 100644
--- a/app/Template/board/task.php
+++ b/app/Template/board/task.php
@@ -100,7 +100,7 @@
<?php endif ?>
-<?php if (! empty($task['date_due']) || ! empty($task['nb_files']) || ! empty($task['nb_comments']) || ! empty($task['description']) || ! empty($task['nb_subtasks'])): ?>
+<?php if (! empty($task['date_due']) || ! empty($task['nb_files']) || ! empty($task['nb_comments']) || ! empty($task['description']) || ! empty($task['nb_subtasks']) || ! empty($task['nb_links'])): ?>
<div class="task-board-footer">
<?php if (! empty($task['date_due'])): ?>
@@ -110,7 +110,10 @@
<?php endif ?>
<div class="task-board-icons">
-
+ <?php if (! empty($task['nb_links'])): ?>
+ <span title="<?= t('Links') ?>" class="task-board-tooltip" data-href="<?= $this->u('board', 'tasklinks', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>"><?= $task['nb_links'] ?> <i class="fa fa-code-fork"></i></span>
+ <?php endif ?>
+
<?php if (! empty($task['nb_subtasks'])): ?>
<span title="<?= t('Sub-Tasks') ?>" class="task-board-tooltip" data-href="<?= $this->u('board', 'subtasks', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>"><?= round($task['nb_completed_subtasks']/$task['nb_subtasks']*100, 0).'%' ?> <i class="fa fa-bars"></i></span>
<?php endif ?>
@@ -132,4 +135,4 @@
</div>
<?php endif ?>
-</div> \ No newline at end of file
+</div>
diff --git a/app/Template/board/tasklinks.php b/app/Template/board/tasklinks.php
new file mode 100644
index 00000000..9c4f52ca
--- /dev/null
+++ b/app/Template/board/tasklinks.php
@@ -0,0 +1,15 @@
+<div class="tooltip-tasklinks">
+ <ul>
+ <?php foreach($links as $link): ?>
+ <li>
+ <strong><?= t($link['label']) ?></strong>
+ <?= $this->a(
+ $this->e('#'.$link['task_id'].' - '.$link['title']),
+ 'task', 'show', array('task_id' => $link['task_id'], 'project_id' => $link['project_id']),
+ false,
+ $link['is_active'] ? '' : 'task-link-closed'
+ ) ?>
+ </li>
+ <?php endforeach ?>
+ </ul>
+</div> \ No newline at end of file
diff --git a/app/Template/config/sidebar.php b/app/Template/config/sidebar.php
index 8e6fa379..89f2c203 100644
--- a/app/Template/config/sidebar.php
+++ b/app/Template/config/sidebar.php
@@ -11,6 +11,9 @@
<?= $this->a(t('Board settings'), 'config', 'board') ?>
</li>
<li>
+ <?= $this->a(t('Link settings'), 'link', 'index') ?>
+ </li>
+ <li>
<?= $this->a(t('Webhooks'), 'config', 'webhook') ?>
</li>
<li>
diff --git a/app/Template/link/create.php b/app/Template/link/create.php
new file mode 100644
index 00000000..12589574
--- /dev/null
+++ b/app/Template/link/create.php
@@ -0,0 +1,18 @@
+<div class="page-header">
+ <h2><?= t('Add a new link') ?></h2>
+</div>
+
+<form action="<?= $this->u('link', 'save') ?>" method="post" autocomplete="off">
+
+ <?= $this->formCsrf() ?>
+
+ <?= $this->formLabel(t('Label'), 'label') ?>
+ <?= $this->formText('label', $values, $errors, array('required')) ?>
+
+ <?= $this->formLabel(t('Opposite label'), 'opposite_label') ?>
+ <?= $this->formText('opposite_label', $values, $errors) ?>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ </div>
+</form> \ No newline at end of file
diff --git a/app/Template/link/edit.php b/app/Template/link/edit.php
new file mode 100644
index 00000000..d9ce280c
--- /dev/null
+++ b/app/Template/link/edit.php
@@ -0,0 +1,21 @@
+<div class="page-header">
+ <h2><?= t('Link modification') ?></h2>
+</div>
+
+<form action="<?= $this->u('link', 'update', array('link_id' => $link['id'])) ?>" method="post" autocomplete="off">
+
+ <?= $this->formCsrf() ?>
+ <?= $this->formHidden('id', $values) ?>
+
+ <?= $this->formLabel(t('Label'), 'label') ?>
+ <?= $this->formText('label', $values, $errors, array('required')) ?>
+
+ <?= $this->formLabel(t('Opposite label'), 'opposite_id') ?>
+ <?= $this->formSelect('opposite_id', $labels, $values, $errors) ?>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ <?= t('or') ?>
+ <?= $this->a(t('cancel'), 'link', 'index') ?>
+ </div>
+</form> \ No newline at end of file
diff --git a/app/Template/link/index.php b/app/Template/link/index.php
new file mode 100644
index 00000000..90d1c357
--- /dev/null
+++ b/app/Template/link/index.php
@@ -0,0 +1,33 @@
+<div class="page-header">
+ <h2><?= t('Link labels') ?></h2>
+</div>
+<?php if (! empty($links)): ?>
+<table>
+ <tr>
+ <th class="column-70"><?= t('Link labels') ?></th>
+ <th><?= t('Actions') ?></th>
+ </tr>
+ <?php foreach ($links as $link): ?>
+ <tr>
+ <td>
+ <strong><?= t($link['label']) ?></strong>
+
+ <?php if (! empty($link['opposite_label'])): ?>
+ | <?= t($link['opposite_label']) ?>
+ <?php endif ?>
+ </td>
+ <td>
+ <ul>
+ <?= $this->a(t('Edit'), 'link', 'edit', array('link_id' => $link['id'])) ?>
+ <?= t('or') ?>
+ <?= $this->a(t('Remove'), 'link', 'confirm', array('link_id' => $link['id'])) ?>
+ </ul>
+ </td>
+ </tr>
+ <?php endforeach ?>
+</table>
+<?php else: ?>
+ <?= t('There is no link.') ?>
+<?php endif ?>
+
+<?= $this->render('link/create', array('values' => $values, 'errors' => $errors)) ?> \ No newline at end of file
diff --git a/app/Template/link/remove.php b/app/Template/link/remove.php
new file mode 100644
index 00000000..a802feb0
--- /dev/null
+++ b/app/Template/link/remove.php
@@ -0,0 +1,15 @@
+<div class="page-header">
+ <h2><?= t('Remove a link') ?></h2>
+</div>
+
+<div class="confirm">
+ <p class="alert alert-info">
+ <?= t('Do you really want to remove this link: "%s"?', $link['label']) ?>
+ </p>
+
+ <div class="form-actions">
+ <?= $this->a(t('Yes'), 'link', 'remove', array('link_id' => $link['id']), true, 'btn btn-red') ?>
+ <?= t('or') ?>
+ <?= $this->a(t('cancel'), 'link', 'index') ?>
+ </div>
+</div> \ No newline at end of file
diff --git a/app/Template/task/public.php b/app/Template/task/public.php
index 2d95e6db..d7acef9f 100644
--- a/app/Template/task/public.php
+++ b/app/Template/task/public.php
@@ -10,6 +10,13 @@
'is_public' => true
)) ?>
+ <?= $this->render('tasklink/show', array(
+ 'task' => $task,
+ 'links' => $links,
+ 'project' => $project,
+ 'not_editable' => true
+ )) ?>
+
<?= $this->render('subtask/show', array(
'task' => $task,
'subtasks' => $subtasks,
diff --git a/app/Template/task/show.php b/app/Template/task/show.php
index b8243cc6..1ff2ef43 100644
--- a/app/Template/task/show.php
+++ b/app/Template/task/show.php
@@ -1,7 +1,8 @@
<?= $this->render('task/details', array('task' => $task, 'project' => $project)) ?>
<?= $this->render('task/time', array('task' => $task, 'values' => $values, 'date_format' => $date_format, 'date_formats' => $date_formats)) ?>
<?= $this->render('task/show_description', array('task' => $task)) ?>
+<?= $this->render('tasklink/show', array('task' => $task, 'links' => $links)) ?>
<?= $this->render('subtask/show', array('task' => $task, 'subtasks' => $subtasks)) ?>
<?= $this->render('task/timesheet', array('task' => $task)) ?>
<?= $this->render('file/show', array('task' => $task, 'files' => $files)) ?>
-<?= $this->render('task/comments', array('task' => $task, 'comments' => $comments, 'project' => $project)) ?> \ No newline at end of file
+<?= $this->render('task/comments', array('task' => $task, 'comments' => $comments, 'project' => $project)) ?>
diff --git a/app/Template/task/sidebar.php b/app/Template/task/sidebar.php
index e85a1671..f41be14d 100644
--- a/app/Template/task/sidebar.php
+++ b/app/Template/task/sidebar.php
@@ -19,6 +19,9 @@
<?= $this->a(t('Add a sub-task'), 'subtask', 'create', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
</li>
<li>
+ <?= $this->a(t('Add a link'), 'tasklink', 'create', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
+ </li>
+ <li>
<?= $this->a(t('Add a comment'), 'comment', 'create', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
</li>
<li>
diff --git a/app/Template/tasklink/create.php b/app/Template/tasklink/create.php
new file mode 100644
index 00000000..fb438cd8
--- /dev/null
+++ b/app/Template/tasklink/create.php
@@ -0,0 +1,27 @@
+<div class="page-header">
+ <h2><?= t('Add a new link') ?></h2>
+</div>
+
+<form action="<?= $this->u('tasklink', 'save', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>" method="post" autocomplete="off">
+
+ <?= $this->formCsrf() ?>
+ <?= $this->formHidden('task_id', $values) ?>
+ <?= $this->formHidden('opposite_task_id', $values) ?>
+
+ <?= $this->formLabel(t('Label'), 'link_id') ?>
+ <?= $this->formSelect('link_id', $labels, $values, $errors) ?>
+
+ <?= $this->formLabel(t('Task'), 'title') ?>
+ <?= $this->formText(
+ 'title',
+ $values,
+ $errors,
+ array('required', 'data-dst-field="opposite_task_id"', 'data-search-url="'.$this->u('app', 'autocomplete', array('exclude_task_id' => $task['id'])).'"'),
+ 'task-autocomplete') ?>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ <?= t('or') ?>
+ <?= $this->a(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
+ </div>
+</form> \ No newline at end of file
diff --git a/app/Template/tasklink/remove.php b/app/Template/tasklink/remove.php
new file mode 100644
index 00000000..9322ec24
--- /dev/null
+++ b/app/Template/tasklink/remove.php
@@ -0,0 +1,15 @@
+<div class="page-header">
+ <h2><?= t('Remove a link') ?></h2>
+</div>
+
+<div class="confirm">
+ <p class="alert alert-info">
+ <?= t('Do you really want to remove this link with task #%d?', $link['opposite_task_id']) ?>
+ </p>
+
+ <div class="form-actions">
+ <?= $this->a(t('Yes'), 'tasklink', 'remove', array('link_id' => $link['id'], 'task_id' => $task['id'], 'project_id' => $task['project_id']), true, 'btn btn-red') ?>
+ <?= t('or') ?>
+ <?= $this->a(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
+ </div>
+</div> \ No newline at end of file
diff --git a/app/Template/tasklink/show.php b/app/Template/tasklink/show.php
new file mode 100644
index 00000000..ca4e4383
--- /dev/null
+++ b/app/Template/tasklink/show.php
@@ -0,0 +1,41 @@
+<?php if (! empty($links)): ?>
+<div class="page-header">
+ <h2><?= t('Links') ?></h2>
+</div>
+<table class="table-fixed" id="links">
+ <tr>
+ <th class="column-30"><?= t('Label') ?></th>
+ <th class="column-60"><?= t('Task') ?></th>
+ <?php if (! isset($not_editable)): ?>
+ <th><?= t('Action') ?></th>
+ <?php endif ?>
+ </tr>
+ <?php foreach ($links as $link): ?>
+ <tr>
+ <td><?= t('This task') ?> <strong><?= t($link['label']) ?></strong></td>
+ <?php if (! isset($not_editable)): ?>
+ <td>
+ <?= $this->a(
+ $this->e('#'.$link['task_id'].' - '.$link['title']),
+ 'task', 'show', array('task_id' => $link['task_id'], 'project_id' => $link['project_id']),
+ false,
+ $link['is_active'] ? '' : 'task-link-closed'
+ ) ?>
+ </td>
+ <td>
+ <?= $this->a(t('Remove'), 'tasklink', 'confirm', array('link_id' => $link['id'], 'task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
+ </td>
+ <?php else: ?>
+ <td>
+ <?= $this->a(
+ $this->e('#'.$link['task_id'].' - '.$link['title']),
+ 'task', 'readonly', array('task_id' => $link['task_id'], 'token' => $project['token']),
+ false,
+ $link['is_active'] ? '' : 'task-link-closed'
+ ) ?>
+ </td>
+ <?php endif ?>
+ </tr>
+ <?php endforeach ?>
+</table>
+<?php endif ?> \ No newline at end of file