summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/Core/Base.php2
-rw-r--r--app/Model/TagModel.php139
-rw-r--r--app/Model/TaskTagModel.php125
-rw-r--r--app/Schema/Mysql.php25
-rw-r--r--app/Schema/Postgres.php24
-rw-r--r--app/Schema/Sqlite.php24
-rw-r--r--app/ServiceProvider/ClassProvider.php2
-rw-r--r--tests/units/Model/TagModelTest.php120
-rw-r--r--tests/units/Model/TaskTagModelTest.php67
9 files changed, 525 insertions, 3 deletions
diff --git a/app/Core/Base.php b/app/Core/Base.php
index 7b4462e2..6712cbce 100644
--- a/app/Core/Base.php
+++ b/app/Core/Base.php
@@ -86,6 +86,7 @@ use Pimple\Container;
* @property \Kanboard\Model\SubtaskModel $subtaskModel
* @property \Kanboard\Model\SubtaskTimeTrackingModel $subtaskTimeTrackingModel
* @property \Kanboard\Model\SwimlaneModel $swimlaneModel
+ * @property \Kanboard\Model\TagModel $tagModel
* @property \Kanboard\Model\TaskModel $taskModel
* @property \Kanboard\Model\TaskAnalyticModel $taskAnalyticModel
* @property \Kanboard\Model\TaskCreationModel $taskCreationModel
@@ -96,6 +97,7 @@ use Pimple\Container;
* @property \Kanboard\Model\TaskModificationModel $taskModificationModel
* @property \Kanboard\Model\TaskPositionModel $taskPositionModel
* @property \Kanboard\Model\TaskStatusModel $taskStatusModel
+ * @property \Kanboard\Model\TaskTagModel $taskTagModel
* @property \Kanboard\Model\TaskMetadataModel $taskMetadataModel
* @property \Kanboard\Model\TimezoneModel $timezoneModel
* @property \Kanboard\Model\TransitionModel $transitionModel
diff --git a/app/Model/TagModel.php b/app/Model/TagModel.php
new file mode 100644
index 00000000..1be05a66
--- /dev/null
+++ b/app/Model/TagModel.php
@@ -0,0 +1,139 @@
+<?php
+
+namespace Kanboard\Model;
+
+use Kanboard\Core\Base;
+
+/**
+ * Class TagModel
+ *
+ * @package Kanboard\Model
+ * @author Frederic Guillot
+ */
+class TagModel extends Base
+{
+ /**
+ * SQL table name
+ *
+ * @var string
+ */
+ const TABLE = 'tags';
+
+ /**
+ * Get all tags
+ *
+ * @access public
+ * @return array
+ */
+ public function getAll()
+ {
+ return $this->db->table(self::TABLE)->asc('name')->findAll();
+ }
+
+ /**
+ * Get all tags by project
+ *
+ * @access public
+ * @param integer $project_id
+ * @return array
+ */
+ public function getAllByProject($project_id)
+ {
+ return $this->db->table(self::TABLE)->eq('project_id', $project_id)->asc('name')->findAll();
+ }
+
+ /**
+ * Get one tag
+ *
+ * @access public
+ * @param integer $tag_id
+ * @return array|null
+ */
+ public function getById($tag_id)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $tag_id)->findOne();
+ }
+
+ /**
+ * Get tag id from tag name
+ *
+ * @access public
+ * @param int $project_id
+ * @param string $tag
+ * @return integer
+ */
+ public function getIdByName($project_id, $tag)
+ {
+ return $this->db
+ ->table(self::TABLE)
+ ->beginOr()
+ ->eq('project_id', 0)
+ ->eq('project_id', $project_id)
+ ->closeOr()
+ ->ilike('name', $tag)
+ ->asc('project_id')
+ ->findOneColumn('id');
+ }
+
+ /**
+ * Return tag id and create a new tag if necessary
+ *
+ * @access public
+ * @param int $project_id
+ * @param string $tag
+ * @return bool|int
+ */
+ public function findOrCreateTag($project_id, $tag)
+ {
+ $tag_id = $this->getIdByName($project_id, $tag);
+
+ if (empty($tag_id)) {
+ $tag_id = $this->create($project_id, $tag);
+ }
+
+ return $tag_id;
+ }
+
+ /**
+ * Add a new tag
+ *
+ * @access public
+ * @param int $project_id
+ * @param string $tag
+ * @return bool|int
+ */
+ public function create($project_id, $tag)
+ {
+ return $this->db->table(self::TABLE)->persist(array(
+ 'project_id' => $project_id,
+ 'name' => $tag,
+ ));
+ }
+
+ /**
+ * Update a tag
+ *
+ * @access public
+ * @param integer $tag_id
+ * @param string $tag
+ * @return bool
+ */
+ public function update($tag_id, $tag)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $tag_id)->update(array(
+ 'name' => $tag,
+ ));
+ }
+
+ /**
+ * Remove a tag
+ *
+ * @access public
+ * @param integer $tag_id
+ * @return bool
+ */
+ public function remove($tag_id)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $tag_id)->remove();
+ }
+}
diff --git a/app/Model/TaskTagModel.php b/app/Model/TaskTagModel.php
new file mode 100644
index 00000000..74d82539
--- /dev/null
+++ b/app/Model/TaskTagModel.php
@@ -0,0 +1,125 @@
+<?php
+
+namespace Kanboard\Model;
+
+use Kanboard\Core\Base;
+
+/**
+ * Class TaskTagModel
+ *
+ * @package Kanboard\Model
+ * @author Frederic Guillot
+ */
+class TaskTagModel extends Base
+{
+ /**
+ * SQL table name
+ *
+ * @var string
+ */
+ const TABLE = 'task_has_tags';
+
+ /**
+ * Get all tags associated to a task
+ *
+ * @access public
+ * @param integer $task_id
+ * @return array
+ */
+ public function getAll($task_id)
+ {
+ return $this->db->table(TagModel::TABLE)
+ ->columns(TagModel::TABLE.'.id', TagModel::TABLE.'.name')
+ ->eq(self::TABLE.'.task_id', $task_id)
+ ->join(self::TABLE, 'tag_id', 'id')
+ ->findAll();
+ }
+
+ /**
+ * Get dictionary of tags
+ *
+ * @access public
+ * @param integer $task_id
+ * @return array
+ */
+ public function getList($task_id)
+ {
+ $tags = $this->getAll($task_id);
+ return array_column($tags, 'name', 'id');
+ }
+
+ /**
+ * Add or update a list of tags to a task
+ *
+ * @access public
+ * @param integer $project_id
+ * @param integer $task_id
+ * @param string[] $tags
+ * @return boolean
+ */
+ public function save($project_id, $task_id, array $tags)
+ {
+ $task_tags = $this->getList($task_id);
+
+ return $this->addTags($project_id, $task_id, $task_tags, $tags) &&
+ $this->removeTags($task_id, $task_tags, $tags);
+ }
+
+ /**
+ * Associate a tag to a task
+ *
+ * @access public
+ * @param integer $task_id
+ * @param integer $tag_id
+ * @return boolean
+ */
+ public function associate($task_id, $tag_id)
+ {
+ return $this->db->table(self::TABLE)->insert(array(
+ 'task_id' => $task_id,
+ 'tag_id' => $tag_id,
+ ));
+ }
+
+ /**
+ * Dissociate a tag from a task
+ *
+ * @access public
+ * @param integer $task_id
+ * @param integer $tag_id
+ * @return boolean
+ */
+ public function dissociate($task_id, $tag_id)
+ {
+ return $this->db->table(self::TABLE)
+ ->eq('task_id', $task_id)
+ ->eq('tag_id', $tag_id)
+ ->remove();
+ }
+
+ private function addTags($project_id, $task_id, $task_tags, $tags)
+ {
+ foreach ($tags as $tag) {
+ $tag_id = $this->tagModel->findOrCreateTag($project_id, $tag);
+
+ if (! isset($task_tags[$tag_id]) && ! $this->associate($task_id, $tag_id)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private function removeTags($task_id, $task_tags, $tags)
+ {
+ foreach ($task_tags as $tag_id => $tag) {
+ if (! in_array($tag, $tags)) {
+ if (! $this->dissociate($task_id, $tag_id)) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/app/Schema/Mysql.php b/app/Schema/Mysql.php
index 934b063f..82ccb8c8 100644
--- a/app/Schema/Mysql.php
+++ b/app/Schema/Mysql.php
@@ -6,7 +6,30 @@ use PDO;
use Kanboard\Core\Security\Token;
use Kanboard\Core\Security\Role;
-const VERSION = 110;
+const VERSION = 111;
+
+function version_111(PDO $pdo)
+{
+ $pdo->exec("
+ CREATE TABLE tags (
+ id INT NOT NULL AUTO_INCREMENT,
+ name VARCHAR(255) NOT NULL,
+ project_id INT NOT NULL,
+ UNIQUE(project_id, name),
+ PRIMARY KEY(id)
+ ) ENGINE=InnoDB CHARSET=utf8
+ ");
+
+ $pdo->exec("
+ CREATE TABLE task_has_tags (
+ task_id INT NOT NULL,
+ tag_id INT NOT NULL,
+ FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE,
+ FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE,
+ UNIQUE(tag_id, task_id)
+ ) ENGINE=InnoDB CHARSET=utf8
+ ");
+}
function version_110(PDO $pdo)
{
diff --git a/app/Schema/Postgres.php b/app/Schema/Postgres.php
index 3ef49498..229cbd25 100644
--- a/app/Schema/Postgres.php
+++ b/app/Schema/Postgres.php
@@ -6,7 +6,29 @@ use PDO;
use Kanboard\Core\Security\Token;
use Kanboard\Core\Security\Role;
-const VERSION = 89;
+const VERSION = 90;
+
+function version_90(PDO $pdo)
+{
+ $pdo->exec("
+ CREATE TABLE tags (
+ id SERIAL PRIMARY KEY,
+ name VARCHAR(255) NOT NULL,
+ project_id INTEGER NOT NULL,
+ UNIQUE(project_id, name)
+ )
+ ");
+
+ $pdo->exec("
+ CREATE TABLE task_has_tags (
+ task_id INTEGER NOT NULL,
+ tag_id INTEGER NOT NULL,
+ FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE,
+ FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE,
+ UNIQUE(tag_id, task_id)
+ )
+ ");
+}
function version_89(PDO $pdo)
{
diff --git a/app/Schema/Sqlite.php b/app/Schema/Sqlite.php
index 9ded7ed9..dac348d4 100644
--- a/app/Schema/Sqlite.php
+++ b/app/Schema/Sqlite.php
@@ -6,7 +6,29 @@ use Kanboard\Core\Security\Token;
use Kanboard\Core\Security\Role;
use PDO;
-const VERSION = 101;
+const VERSION = 102;
+
+function version_102(PDO $pdo)
+{
+ $pdo->exec("
+ CREATE TABLE tags (
+ id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL,
+ project_id INTEGER NOT NULL,
+ UNIQUE(project_id, name)
+ )
+ ");
+
+ $pdo->exec("
+ CREATE TABLE task_has_tags (
+ task_id INTEGER NOT NULL,
+ tag_id INTEGER NOT NULL,
+ FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE,
+ FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE,
+ UNIQUE(tag_id, task_id)
+ )
+ ");
+}
function version_101(PDO $pdo)
{
diff --git a/app/ServiceProvider/ClassProvider.php b/app/ServiceProvider/ClassProvider.php
index 3e6efb02..778b4f9e 100644
--- a/app/ServiceProvider/ClassProvider.php
+++ b/app/ServiceProvider/ClassProvider.php
@@ -60,6 +60,7 @@ class ClassProvider implements ServiceProviderInterface
'SubtaskModel',
'SubtaskTimeTrackingModel',
'SwimlaneModel',
+ 'TagModel',
'TaskModel',
'TaskAnalyticModel',
'TaskCreationModel',
@@ -71,6 +72,7 @@ class ClassProvider implements ServiceProviderInterface
'TaskModificationModel',
'TaskPositionModel',
'TaskStatusModel',
+ 'TaskTagModel',
'TaskMetadataModel',
'TimezoneModel',
'TransitionModel',
diff --git a/tests/units/Model/TagModelTest.php b/tests/units/Model/TagModelTest.php
new file mode 100644
index 00000000..f090ab4a
--- /dev/null
+++ b/tests/units/Model/TagModelTest.php
@@ -0,0 +1,120 @@
+<?php
+
+use Kanboard\Model\ProjectModel;
+use Kanboard\Model\TagModel;
+
+require_once __DIR__.'/../Base.php';
+
+class TagModelTest extends Base
+{
+ public function testCreation()
+ {
+ $tagModel = new TagModel($this->container);
+ $projectModel = new ProjectModel($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
+ $this->assertEquals(1, $tagModel->create(0, 'Tag 1'));
+ $this->assertEquals(2, $tagModel->create(1, 'Tag 1'));
+ $this->assertEquals(3, $tagModel->create(1, 'Tag 2'));
+ $this->assertFalse($tagModel->create(0, 'Tag 1'));
+ $this->assertFalse($tagModel->create(1, 'Tag 2'));
+ }
+
+ public function testGetById()
+ {
+ $tagModel = new TagModel($this->container);
+ $this->assertEquals(1, $tagModel->create(0, 'Tag 1'));
+
+ $tag = $tagModel->getById(1);
+ $this->assertEquals(0, $tag['project_id']);
+ $this->assertEquals('Tag 1', $tag['name']);
+
+ $tag = $tagModel->getById(3);
+ $this->assertEmpty($tag);
+ }
+
+ public function testGetAll()
+ {
+ $tagModel = new TagModel($this->container);
+ $projectModel = new ProjectModel($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
+ $this->assertEquals(1, $tagModel->create(0, 'Tag 1'));
+ $this->assertEquals(2, $tagModel->create(1, 'Tag 2'));
+
+ $tags = $tagModel->getAll();
+ $this->assertCount(2, $tags);
+ $this->assertEquals(0, $tags[0]['project_id']);
+ $this->assertEquals('Tag 1', $tags[0]['name']);
+
+ $this->assertEquals(1, $tags[1]['project_id']);
+ $this->assertEquals('Tag 2', $tags[1]['name']);
+ }
+
+ public function testGetAllByProjectId()
+ {
+ $tagModel = new TagModel($this->container);
+ $projectModel = new ProjectModel($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
+ $this->assertEquals(1, $tagModel->create(0, 'Tag 1'));
+ $this->assertEquals(2, $tagModel->create(1, 'B'));
+ $this->assertEquals(3, $tagModel->create(1, 'A'));
+
+ $tags = $tagModel->getAllByProject(1);
+ $this->assertCount(2, $tags);
+ $this->assertEquals(1, $tags[0]['project_id']);
+ $this->assertEquals('A', $tags[0]['name']);
+
+ $this->assertEquals(1, $tags[1]['project_id']);
+ $this->assertEquals('B', $tags[1]['name']);
+ }
+
+ public function testGetIdByName()
+ {
+ $tagModel = new TagModel($this->container);
+ $projectModel = new ProjectModel($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
+ $this->assertEquals(1, $tagModel->create(0, 'Tag 1'));
+ $this->assertEquals(2, $tagModel->create(1, 'Tag 1'));
+ $this->assertEquals(3, $tagModel->create(1, 'Tag 3'));
+
+ $this->assertEquals(1, $tagModel->getIdByName(1, 'tag 1'));
+ $this->assertEquals(1, $tagModel->getIdByName(0, 'tag 1'));
+ $this->assertEquals(3, $tagModel->getIdByName(1, 'TaG 3'));
+ }
+
+ public function testFindOrCreateTag()
+ {
+ $tagModel = new TagModel($this->container);
+ $projectModel = new ProjectModel($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
+ $this->assertEquals(1, $tagModel->create(0, 'Tag 1'));
+
+ $this->assertEquals(2, $tagModel->findOrCreateTag(1, 'Tag 2'));
+ $this->assertEquals(2, $tagModel->findOrCreateTag(1, 'Tag 2'));
+ $this->assertEquals(1, $tagModel->findOrCreateTag(1, 'Tag 1'));
+ }
+
+ public function testRemove()
+ {
+ $tagModel = new TagModel($this->container);
+ $this->assertEquals(1, $tagModel->create(0, 'Tag 1'));
+
+ $this->assertTrue($tagModel->remove(1));
+ $this->assertFalse($tagModel->remove(1));
+ }
+
+ public function testUpdate()
+ {
+ $tagModel = new TagModel($this->container);
+ $this->assertEquals(1, $tagModel->create(0, 'Tag 1'));
+ $this->assertTrue($tagModel->update(1, 'Tag Updated'));
+
+ $tag = $tagModel->getById(1);
+ $this->assertEquals(0, $tag['project_id']);
+ $this->assertEquals('Tag Updated', $tag['name']);
+ }
+}
diff --git a/tests/units/Model/TaskTagModelTest.php b/tests/units/Model/TaskTagModelTest.php
new file mode 100644
index 00000000..c08b571f
--- /dev/null
+++ b/tests/units/Model/TaskTagModelTest.php
@@ -0,0 +1,67 @@
+<?php
+
+use Kanboard\Model\ProjectModel;
+use Kanboard\Model\TagModel;
+use Kanboard\Model\TaskCreationModel;
+use Kanboard\Model\TaskTagModel;
+
+require_once __DIR__.'/../Base.php';
+
+class TaskTagModelTest extends Base
+{
+ public function testAssociationAndDissociation()
+ {
+ $projectModel = new ProjectModel($this->container);
+ $taskCreationModel = new TaskCreationModel($this->container);
+ $taskTagModel = new TaskTagModel($this->container);
+ $tagModel = new TagModel($this->container);
+
+ $this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
+ $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test')));
+
+ $this->assertEquals(1, $tagModel->create(0, 'My tag 1'));
+ $this->assertEquals(2, $tagModel->create(0, 'My tag 2'));
+
+ $this->assertTrue($taskTagModel->save(1, 1, array('My tag 1', 'My tag 2', 'My tag 3')));
+
+ $tags = $taskTagModel->getAll(1);
+ $this->assertCount(3, $tags);
+
+ $this->assertEquals(1, $tags[0]['id']);
+ $this->assertEquals('My tag 1', $tags[0]['name']);
+
+ $this->assertEquals(2, $tags[1]['id']);
+ $this->assertEquals('My tag 2', $tags[1]['name']);
+
+ $this->assertEquals(3, $tags[2]['id']);
+ $this->assertEquals('My tag 3', $tags[2]['name']);
+
+ $this->assertTrue($taskTagModel->save(1, 1, array('My tag 3', 'My tag 1', 'My tag 4')));
+
+ $tags = $taskTagModel->getAll(1);
+ $this->assertCount(3, $tags);
+
+ $this->assertEquals(1, $tags[0]['id']);
+ $this->assertEquals('My tag 1', $tags[0]['name']);
+
+ $this->assertEquals(3, $tags[1]['id']);
+ $this->assertEquals('My tag 3', $tags[1]['name']);
+
+ $this->assertEquals(4, $tags[2]['id']);
+ $this->assertEquals('My tag 4', $tags[2]['name']);
+
+ $tags = $tagModel->getAll();
+ $this->assertCount(4, $tags);
+ $this->assertEquals('My tag 1', $tags[0]['name']);
+ $this->assertEquals(0, $tags[0]['project_id']);
+
+ $this->assertEquals('My tag 2', $tags[1]['name']);
+ $this->assertEquals(0, $tags[1]['project_id']);
+
+ $this->assertEquals('My tag 3', $tags[2]['name']);
+ $this->assertEquals(1, $tags[2]['project_id']);
+
+ $this->assertEquals('My tag 4', $tags[3]['name']);
+ $this->assertEquals(1, $tags[3]['project_id']);
+ }
+}