diff --git a/.gitignore b/.gitignore
index b64b7bbb8..5f7073284 100644
--- a/.gitignore
+++ b/.gitignore
@@ -52,3 +52,4 @@ Thumbs.db
# App specific #
################
config.php
+data/files
\ No newline at end of file
diff --git a/app/Controller/Base.php b/app/Controller/Base.php
index bb9add4f0..183b93952 100644
--- a/app/Controller/Base.php
+++ b/app/Controller/Base.php
@@ -216,6 +216,7 @@ abstract class Base
'task' => $task,
'columns_list' => $this->board->getColumnsList($task['project_id']),
'colors_list' => $this->task->getColors(),
+ 'files' => $this->file->getAll($task['id']),
'menu' => 'tasks',
'title' => $task['title'],
)));
diff --git a/app/Controller/Task.php b/app/Controller/Task.php
index 2291ad439..1b67b6a03 100644
--- a/app/Controller/Task.php
+++ b/app/Controller/Task.php
@@ -3,6 +3,7 @@
namespace Controller;
use Model\Project;
+use Model\File;
/**
* Task controller
@@ -12,6 +13,19 @@ use Model\Project;
*/
class Task extends Base
{
+ private function getTask()
+ {
+ $task = $this->task->getById($this->request->getIntegerParam('task_id'), true);
+
+ if (! $task) {
+ $this->notfound();
+ }
+
+ $this->checkProjectPermissions($task['project_id']);
+
+ return $task;
+ }
+
/**
* Webhook to create a task (useful for external software)
*
@@ -57,12 +71,7 @@ class Task extends Base
*/
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);
+ $this->showTask($this->getTask());
}
/**
@@ -247,10 +256,7 @@ class Task extends Base
*/
public function close()
{
- $task = $this->task->getById($this->request->getIntegerParam('task_id'));
-
- if (! $task) $this->notfound();
- $this->checkProjectPermissions($task['project_id']);
+ $task = $this->getTask();
if ($this->task->close($task['id'])) {
$this->session->flash(t('Task closed successfully.'));
@@ -268,10 +274,7 @@ class Task extends Base
*/
public function confirmClose()
{
- $task = $this->task->getById($this->request->getIntegerParam('task_id'), true);
-
- if (! $task) $this->notfound();
- $this->checkProjectPermissions($task['project_id']);
+ $task = $this->getTask();
$this->response->html($this->taskLayout('task_close', array(
'task' => $task,
@@ -287,10 +290,7 @@ class Task extends Base
*/
public function open()
{
- $task = $this->task->getById($this->request->getIntegerParam('task_id'));
-
- if (! $task) $this->notfound();
- $this->checkProjectPermissions($task['project_id']);
+ $task = $this->getTask();
if ($this->task->open($task['id'])) {
$this->session->flash(t('Task opened successfully.'));
@@ -308,10 +308,7 @@ class Task extends Base
*/
public function confirmOpen()
{
- $task = $this->task->getById($this->request->getIntegerParam('task_id'), true);
-
- if (! $task) $this->notfound();
- $this->checkProjectPermissions($task['project_id']);
+ $task = $this->getTask();
$this->response->html($this->taskLayout('task_open', array(
'task' => $task,
@@ -327,10 +324,7 @@ class Task extends Base
*/
public function remove()
{
- $task = $this->task->getById($this->request->getIntegerParam('task_id'));
-
- if (! $task) $this->notfound();
- $this->checkProjectPermissions($task['project_id']);
+ $task = $this->getTask();
if ($this->task->remove($task['id'])) {
$this->session->flash(t('Task removed successfully.'));
@@ -348,10 +342,7 @@ class Task extends Base
*/
public function confirmRemove()
{
- $task = $this->task->getById($this->request->getIntegerParam('task_id'), true);
-
- if (! $task) $this->notfound();
- $this->checkProjectPermissions($task['project_id']);
+ $task = $this->getTask();
$this->response->html($this->taskLayout('task_remove', array(
'task' => $task,
@@ -367,10 +358,7 @@ class Task extends Base
*/
public function duplicate()
{
- $task = $this->task->getById($this->request->getIntegerParam('task_id'));
-
- if (! $task) $this->notfound();
- $this->checkProjectPermissions($task['project_id']);
+ $task = $this->getTask();
if (! empty($task['date_due'])) {
$task['date_due'] = date(t('m/d/Y'), $task['date_due']);
@@ -394,4 +382,126 @@ class Task extends Base
'title' => t('New task')
)));
}
+
+ /**
+ * File upload form
+ *
+ * @access public
+ */
+ public function file()
+ {
+ $task = $this->getTask();
+
+ $this->response->html($this->taskLayout('task_upload', array(
+ 'task' => $task,
+ 'menu' => 'tasks',
+ 'title' => t('Attach a document')
+ )));
+ }
+
+ /**
+ * File upload (save files)
+ *
+ * @access public
+ */
+ public function upload()
+ {
+ $task = $this->getTask();
+ $this->file->upload($task['project_id'], $task['id'], 'files');
+ $this->response->redirect('?controller=task&action=show&task_id='.$task['id'].'#attachments');
+ }
+
+ /**
+ * File download
+ *
+ * @access public
+ */
+ public function download()
+ {
+ $task = $this->getTask();
+ $file = $this->file->getById($this->request->getIntegerParam('file_id'));
+ $filename = File::BASE_PATH.$file['path'];
+
+ if ($file['task_id'] == $task['id'] && file_exists($filename)) {
+ $this->response->forceDownload($file['name']);
+ $this->response->binary(file_get_contents($filename));
+ }
+
+ $this->response->redirect('?controller=task&action=show&task_id='.$task['id']);
+ }
+
+ /**
+ * Open a file (show the content in a popover)
+ *
+ * @access public
+ */
+ public function openFile()
+ {
+ $task = $this->getTask();
+ $file = $this->file->getById($this->request->getIntegerParam('file_id'));
+
+ if ($file['task_id'] == $task['id']) {
+ $this->response->html($this->template->load('task_open_file', array(
+ 'file' => $file
+ )));
+ }
+ }
+
+ /**
+ * Return the file content (work only for images)
+ *
+ * @access public
+ */
+ public function image()
+ {
+ $task = $this->getTask();
+ $file = $this->file->getById($this->request->getIntegerParam('file_id'));
+ $filename = File::BASE_PATH.$file['path'];
+
+ if ($file['task_id'] == $task['id'] && file_exists($filename)) {
+ $metadata = getimagesize($filename);
+
+ if (isset($metadata['mime'])) {
+ $this->response->contentType($metadata['mime']);
+ readfile($filename);
+ }
+ }
+ }
+
+ /**
+ * Remove a file
+ *
+ * @access public
+ */
+ public function removeFile()
+ {
+ $task = $this->getTask();
+ $file = $this->file->getById($this->request->getIntegerParam('file_id'));
+
+ if ($file['task_id'] == $task['id'] && $this->file->remove($file['id'])) {
+ $this->session->flash(t('File removed successfully.'));
+ } else {
+ $this->session->flashError(t('Unable to remove this file.'));
+ }
+
+ $this->response->redirect('?controller=task&action=show&task_id='.$task['id']);
+ }
+
+ /**
+ * Confirmation dialog before removing a file
+ *
+ * @access public
+ */
+ public function confirmRemoveFile()
+ {
+ $task = $this->getTask();
+ $file = $this->file->getById($this->request->getIntegerParam('file_id'));
+
+ $this->response->html($this->taskLayout('task_remove_file', array(
+ 'task' => $task,
+ 'file' => $file,
+ 'menu' => 'tasks',
+ 'title' => t('Remove a file')
+ )));
+ }
}
diff --git a/app/Core/Response.php b/app/Core/Response.php
index a5f0e4dca..ee98c9ed2 100644
--- a/app/Core/Response.php
+++ b/app/Core/Response.php
@@ -4,6 +4,11 @@ namespace Core;
class Response
{
+ public function contentType($mimetype)
+ {
+ header('Content-Type: '.$mimetype);
+ }
+
public function forceDownload($filename)
{
header('Content-Disposition: attachment; filename="'.$filename.'"');
diff --git a/app/Locales/es_ES/translations.php b/app/Locales/es_ES/translations.php
index ce7979721..7374f6b66 100644
--- a/app/Locales/es_ES/translations.php
+++ b/app/Locales/es_ES/translations.php
@@ -331,4 +331,11 @@ return array(
// 'All categories' => '',
// 'No category' => '',
// 'The name is required' => '',
+ // 'Remove a file' => '',
+ // 'Unable to remove this file.' => '',
+ // 'File removed successfully.' => '',
+ // 'Attach a document' => '',
+ // 'Do you really want to remove this file: "%s"?' => '',
+ // 'open' => '',
+ // 'Attachments' => '',
);
diff --git a/app/Locales/fr_FR/translations.php b/app/Locales/fr_FR/translations.php
index c93a83aeb..26fee4684 100644
--- a/app/Locales/fr_FR/translations.php
+++ b/app/Locales/fr_FR/translations.php
@@ -331,4 +331,11 @@ return array(
'All categories' => 'Toutes les catégories',
'No category' => 'Aucune catégorie',
'The name is required' => 'Le nom est requis',
+ 'Remove a file' => 'Supprimer un fichier',
+ 'Unable to remove this file.' => 'Impossible de supprimer ce fichier.',
+ 'File removed successfully.' => 'Fichier supprimé avec succès.',
+ 'Attach a document' => 'Joindre un document',
+ 'Do you really want to remove this file: "%s"?' => 'Voulez-vous vraiment supprimer ce fichier « %s » ?',
+ 'open' => 'ouvrir',
+ 'Attachments' => 'Pièces-jointes',
);
diff --git a/app/Locales/pl_PL/translations.php b/app/Locales/pl_PL/translations.php
index 81ecaf011..43adb3301 100644
--- a/app/Locales/pl_PL/translations.php
+++ b/app/Locales/pl_PL/translations.php
@@ -336,4 +336,11 @@ return array(
// 'All categories' => '',
// 'No category' => '',
// 'The name is required' => '',
+ // 'Remove a file' => '',
+ // 'Unable to remove this file.' => '',
+ // 'File removed successfully.' => '',
+ // 'Attach a document' => '',
+ // 'Do you really want to remove this file: "%s"?' => '',
+ // 'open' => '',
+ // 'Attachments' => '',
);
diff --git a/app/Locales/pt_BR/translations.php b/app/Locales/pt_BR/translations.php
index 7c9a6c170..0b4765d19 100644
--- a/app/Locales/pt_BR/translations.php
+++ b/app/Locales/pt_BR/translations.php
@@ -332,4 +332,11 @@ return array(
// 'All categories' => '',
// 'No category' => '',
// 'The name is required' => '',
+ // 'Remove a file' => '',
+ // 'Unable to remove this file.' => '',
+ // 'File removed successfully.' => '',
+ // 'Attach a document' => '',
+ // 'Do you really want to remove this file: "%s"?' => '',
+ // 'open' => '',
+ // 'Attachments' => '',
);
diff --git a/app/Model/Acl.php b/app/Model/Acl.php
index ad2118f42..be32196ac 100644
--- a/app/Model/Acl.php
+++ b/app/Model/Acl.php
@@ -32,10 +32,31 @@ class Acl extends Base
'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'),
+ 'task' => array(
+ 'show',
+ 'create',
+ 'save',
+ 'edit',
+ 'update',
+ 'close',
+ 'confirmclose',
+ 'open',
+ 'confirmopen',
+ 'description',
+ 'duplicate',
+ 'remove',
+ 'confirmremove',
+ 'file',
+ 'upload',
+ 'download',
+ 'openfile',
+ 'image',
+ 'removefile',
+ 'confirmremovefile',
+ ),
);
/**
diff --git a/app/Model/File.php b/app/Model/File.php
new file mode 100644
index 000000000..1e2e1432a
--- /dev/null
+++ b/app/Model/File.php
@@ -0,0 +1,176 @@
+db->table(self::TABLE)->eq('id', $file_id)->findOne();
+ }
+
+ /**
+ * Remove a file
+ *
+ * @access public
+ * @param integer $file_id File id
+ * @return bool
+ */
+ public function remove($file_id)
+ {
+ $file = $this->getbyId($file_id);
+
+ if (! empty($file) && @unlink(self::BASE_PATH.$file['path'])) {
+ return $this->db->table(self::TABLE)->eq('id', $file_id)->remove();
+ }
+
+ return false;
+ }
+
+ /**
+ * Create a file entry in the database
+ *
+ * @access public
+ * @param integer $task_id Task id
+ * @param string $name Filename
+ * @param string $path Path on the disk
+ * @param bool $is_image Image or not
+ * @return bool
+ */
+ public function create($task_id, $name, $path, $is_image)
+ {
+ return $this->db->table(self::TABLE)->save(array(
+ 'task_id' => $task_id,
+ 'name' => $name,
+ 'path' => $path,
+ 'is_image' => $is_image ? '1' : '0',
+ ));
+ }
+
+ /**
+ * Get all files for a given task
+ *
+ * @access public
+ * @param integer $task_id Task id
+ * @return array
+ */
+ public function getAll($task_id)
+ {
+ return $listing = $this->db->table(self::TABLE)
+ ->eq('task_id', $task_id)
+ ->asc('name')
+ ->findAll();
+ }
+
+ /**
+ * Check if a filename is an image
+ *
+ * @access public
+ * @param string $filename Filename
+ * @return bool
+ */
+ public function isImage($filename)
+ {
+ return getimagesize($filename) !== false;
+ }
+
+ /**
+ * Generate the path for a new filename
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param integer $task_id Task id
+ * @param string $filename Filename
+ * @return bool
+ */
+ public function generatePath($project_id, $task_id, $filename)
+ {
+ return $project_id.DIRECTORY_SEPARATOR.$task_id.DIRECTORY_SEPARATOR.hash('sha1', $filename.time());
+ }
+
+ /**
+ * Check if the base directory is created correctly
+ *
+ * @access public
+ */
+ public function setup()
+ {
+ if (! is_dir(self::BASE_PATH)) {
+ if (! mkdir(self::BASE_PATH, 0755, true)) {
+ die('Unable to create the upload directory: "'.self::BASE_PATH.'"');
+ }
+ }
+
+ if (! is_writable(self::BASE_PATH)) {
+ die('The directory "'.self::BASE_PATH.'" must be writeable by your webserver user');
+ }
+ }
+
+ /**
+ * Handle file upload
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param integer $task_id Task id
+ * @param string $form_name File form name
+ */
+ public function upload($project_id, $task_id, $form_name)
+ {
+ $this->setup();
+
+ if (! empty($_FILES[$form_name])) {
+
+ foreach ($_FILES[$form_name]['error'] as $key => $error) {
+
+ if ($error == UPLOAD_ERR_OK && $_FILES[$form_name]['size'][$key] > 0) {
+
+ $original_filename = basename($_FILES[$form_name]['name'][$key]);
+ $uploaded_filename = $_FILES[$form_name]['tmp_name'][$key];
+ $destination_filename = $this->generatePath($project_id, $task_id, $original_filename);
+
+ @mkdir(self::BASE_PATH.dirname($destination_filename), 0755, true);
+
+ if (@move_uploaded_file($uploaded_filename, self::BASE_PATH.$destination_filename)) {
+
+ $this->create(
+ $task_id,
+ $original_filename,
+ $destination_filename,
+ $this->isImage(self::BASE_PATH.$destination_filename)
+ );
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/Model/Task.php b/app/Model/Task.php
index bd67d272c..f31594c9c 100644
--- a/app/Model/Task.php
+++ b/app/Model/Task.php
@@ -139,6 +139,7 @@ class Task extends Base
->table(self::TABLE)
->columns(
'(SELECT count(*) FROM comments WHERE task_id=tasks.id) AS nb_comments',
+ '(SELECT count(*) FROM task_has_files WHERE task_id=tasks.id) AS nb_files',
'tasks.id',
'tasks.title',
'tasks.description',
diff --git a/app/Schema/Mysql.php b/app/Schema/Mysql.php
index 6764ad5d8..d3b111f98 100644
--- a/app/Schema/Mysql.php
+++ b/app/Schema/Mysql.php
@@ -2,7 +2,22 @@
namespace Schema;
-const VERSION = 16;
+const VERSION = 17;
+
+function version_17($pdo)
+{
+ $pdo->exec("
+ CREATE TABLE task_has_files (
+ id INT NOT NULL AUTO_INCREMENT,
+ name VARCHAR(50),
+ path VARCHAR(255),
+ is_image TINYINT(1) DEFAULT 0,
+ task_id INT,
+ PRIMARY KEY (id),
+ FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE
+ ) ENGINE=InnoDB CHARSET=utf8"
+ );
+}
function version_16($pdo)
{
diff --git a/app/Schema/Sqlite.php b/app/Schema/Sqlite.php
index 0bb4de8df..94ef0316e 100644
--- a/app/Schema/Sqlite.php
+++ b/app/Schema/Sqlite.php
@@ -2,7 +2,21 @@
namespace Schema;
-const VERSION = 16;
+const VERSION = 17;
+
+function version_17($pdo)
+{
+ $pdo->exec("
+ CREATE TABLE task_has_files (
+ id INTEGER PRIMARY KEY,
+ name TEXT COLLATE NOCASE,
+ path TEXT,
+ is_image INTEGER DEFAULT 0,
+ task_id INTEGER,
+ FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE
+ )"
+ );
+}
function version_16($pdo)
{
diff --git a/app/Templates/board_show.php b/app/Templates/board_show.php
index 719e3bdd6..78f9dd50e 100644
--- a/app/Templates/board_show.php
+++ b/app/Templates/board_show.php
@@ -59,7 +59,7 @@
-
+
= t('Actions') ?>
-- = t('Duplicate') ?>
+ - = t('Description') ?>
- = t('Edit') ?>
+ - = t('Attach a document') ?>
+ - = t('Duplicate') ?>
-
= t('Close this task') ?>
diff --git a/app/Templates/task_upload.php b/app/Templates/task_upload.php
new file mode 100644
index 000000000..7100ab317
--- /dev/null
+++ b/app/Templates/task_upload.php
@@ -0,0 +1,10 @@
+
+
+
+
\ No newline at end of file
diff --git a/assets/css/app.css b/assets/css/app.css
index 45ec74447..51bdb878a 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -702,6 +702,43 @@ div.task .task-score {
padding: 10px;
}
+.task-show-files a {
+ font-weight: bold;
+ text-decoration: none;
+}
+
+.task-show-files li {
+ margin-left: 25px;
+ list-style-type: square;
+ line-height: 25px;
+}
+
+.task-show-file-actions {
+ font-size: 0.75em;
+}
+
+.task-show-file-actions:before {
+ content: " [";
+}
+
+.task-show-file-actions:after {
+ content: "]";
+}
+
+.task-show-file-actions a {
+ color: #333;
+}
+
+.task-file-viewer {
+ position: relative;
+}
+
+.task-file-viewer img {
+ max-width: 95%;
+ max-height: 85%;
+ margin-top: 10px;
+}
+
/* markdown content */
.markdown {
line-height: 1.4em;
diff --git a/assets/js/task.js b/assets/js/task.js
new file mode 100644
index 000000000..f95792c33
--- /dev/null
+++ b/assets/js/task.js
@@ -0,0 +1,27 @@
+(function () {
+
+ // Show popup
+ function popover_show(content)
+ {
+ $("body").append('' + content + ' ');
+
+ $("#popover-container").click(function() {
+ $(this).remove();
+ });
+
+ $("#popover-content").click(function(e) {
+ e.stopPropagation();
+ });
+ }
+
+ $(".popover").click(function(e) {
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ $.get($(this).attr("href"), function(data) {
+ popover_show(data);
+ });
+ });
+
+}());
= t('Attach a document') ?>
+