diff options
89 files changed, 2674 insertions, 1381 deletions
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 00000000..860e9370 --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,84 @@ +Contributors +============ + +Original author and main developer: Frédéric Guillot (fguillot) + +Contributors: + +- Alex Butum +- [Aleix Pol](https://github.com/aleixpol) +- [Ashbike](https://github.com/ashbike) +- [Ashish Kulkarni](https://github.com/ashkulz) +- [Christian González](https://github.com/nerdoc) +- [Chorgroup](https://github.com/chorgroup) +- Claudio Lobo +- [Cluxter](https://github.com/cluxter) +- [Cmer](https://github.com/chncsu) +- [Colin Williams](https://github.com/crwilliams) +- [Crash5](https://github.com/crash5) +- [Creador30](https://github.com/creador30) +- [Cynthia Pereira](https://github.com/cynthiapereira) +- [David-Norris](https://github.com/David-Norris) +- [Draza (bdpsoft)](https://github.com/bdpsoft) +- [Esteban Monge](https://github.com/EstebanMonge) +- [Federico Lazcano](https://github.com/lazki) +- [Fengchao](https://github.com/fengchao) +- [Floaltvater](https://github.com/floaltvater) +- [Gavlepeter](https://github.com/gavlepeter) +- [Hendrik Stocker](https://github.com/hendrik-stoker) +- [Iterate From 0](https://github.com/freebsd-kanboard) +- [Jan Dittrich](https://github.com/jdittrich) +- [Janne Mäntyharju](https://github.com/JanneMantyharju) +- [Jean-François Magnier](https://github.com/lefakir) +- [Jesusaplsoft](https://github.com/jesusaplsoft) +- [Karol J](https://github.com/dzudek) +- [Kiswa](https://github.com/kiswa) +- [Kralo](https://github.com/kralo) +- [Lars Christian Schou](https://github.com/NegoZiatoR) +- [Levlaz](https://github.com/levlaz) +- [Lim Yuen Hoe](https://github.com/jasonmoofang) +- [Manish Lad](https://github.com/manishlad) +- [Mathgl67](https://github.com/mathgl67) +- [Matthieu Keller](https://github.com/maggick) +- [Mauro Mariño](https://github.com/moromarino) +- [Maxime](https://github.com/EpocDotFr) +- [mfoucrier](https://github.com/mfoucrier) +- [Mgro](https://github.com/mgro) +- [Michael Lüpkes](https://github.com/mluepkes) +- [Mihailov Vasilievic Filho](https://github.com/mihailov-vf) +- [Moraxy](https://github.com/moraxy) +- [Nala Ginrut](https://github.com/NalaGinrut) +- [Nekohayo](https://github.com/nekohayo) +- [Nicolas Lœuillet](https://github.com/nicosomb) +- [Norcnorc](https://github.com/norcnorc) +- [Nramel](https://github.com/nramel) +- [Null-Kelvin](https://github.com/Null-Kelvin) +- [Oliver Bertuch](https://github.com/poikilotherm) +- [Olivier Maridat](https://github.com/oliviermaridat) +- [Oren Ben-Kiki](https://github.com/orenbenkiki) +- Paolo Mainieri +- [Peller Zoltan](https://github.com/PierP) +- [Petja Touru](https://github.com/Petja) +- [Piotr Zęgota](https://github.com/ZegalPL) +- [Rafaelrossa](https://github.com/rafaelrossa) +- [Raphaël Doursenaud](https://github.com/rdoursenaud) +- [René Stoltenberg](https://github.com/rstoltenberg) +- [Rzeka](https://github.com/rzeka) +- [Sebastien Pacilly](https://github.com/spacilly) +- [Sebastian Reese](https://github.com/ReeseSebastian) +- [Semyon Novikov](https://github.com/semka) +- [Sylvain Veyrié](https://github.com/turb) +- [Timo](https://github.com/BlueTeck) +- [Timotheus Pokorra](https://github.com/tpokorra) +- [Tomáš Votruba](https://github.com/TomasVotruba) +- [Toomyem](https://github.com/Toomyem) +- [Tony G. Bolaño](https://github.com/tonybolanyo) +- [Torsten](https://github.com/misterfu) +- [Troloo](https://github.com/troloo) +- [Typz](https://github.com/Typz) +- [Vedovator](https://github.com/vedovator) +- [Vladimir Babin](https://github.com/Chiliec) +- [Ybarc](https://github.com/ybarc) +- [Yuichi Murata](https://github.com/yuichi1004) + +There is also many people who have reported bugs or proposed awesome ideas.
\ No newline at end of file diff --git a/README.markdown b/README.markdown index 2a477c4c..352cfe5b 100644 --- a/README.markdown +++ b/README.markdown @@ -11,6 +11,8 @@ Official website: <http://kanboard.net> - Minimalist software, focus only on essential features (Less is more) - Open source and self-hosted - Super simple installation +- Translated in 18 languages +- [List of features are available on the website](http://kanboard.net/features) [](https://travis-ci.org/fguillot/kanboard) @@ -18,32 +20,26 @@ Official website: <http://kanboard.net> [](https://heroku.com/deploy) -Features --------- - -- Multiple boards/projects -- Boards customization, rename/add/remove columns -- Tasks with different colors, categories, sub-tasks, attachments, comments and Markdown support for the description -- Automatic actions based on events -- User management with a basic privileges separation (administrator or regular user) -- Email notifications -- External authentication: Google, GitHub, LDAP/ActiveDirectory and Reverse-Proxy -- Webhooks to create tasks from an external software -- A basic command line interface -- Host anywhere (shared hosting, VPS, Raspberry Pi or localhost) -- No external dependencies -- **Super easy setup**, copy and paste files and you are done! -- Translated in 18 languages (Brazilian, Chinese, Danish, Dutch, English, Finnish, French, German, Hungarian, Italian, Japanese, Polish, Russian, Serbian, Spanish, Swedish, Thai, Turkish) - Known bugs and feature requests ------------------------------- -See Issues: <https://github.com/fguillot/kanboard/issues> +- Bug tracker: <https://github.com/fguillot/kanboard/issues> License ------- -GNU Affero General Public License version 3: <http://www.gnu.org/licenses/agpl-3.0.txt> +- GNU Affero General Public License version 3: <http://www.gnu.org/licenses/agpl-3.0.txt> + +Authors +------- + +- Main developer: Frédéric Guillot (fguillot) +- [List of contributors](CONTRIBUTORS.md) + +Documentation +------------- + +- [Read the documentation](docs/index.markdown) Related projects ---------------- @@ -54,208 +50,3 @@ Related projects - [Trello import script by @matueranet](https://github.com/matueranet/kanboard-import-trello) - [Chrome extension by Timo](https://chrome.google.com/webstore/detail/kanboard-quickmenu/akjbeplnnihghabpgcfmfhfmifjljneh?utm_source=chrome-ntp-icon), [Source code](https://github.com/BlueTeck/kanboard_chrome_extension) - [Wunderlist To Kanboard script by EpocDotFr](https://github.com/EpocDotFr/WunderlistToKanboard) - -Documentation -------------- - -### Using Kanboard - -#### Introduction - -- [What is Kanban?](docs/what-is-kanban.markdown) -- [Kanban vs Todo Lists and Scrum](docs/kanban-vs-todo-and-scrum.markdown) -- [Usage examples](docs/usage-examples.markdown) - -#### Working with projects - -- [Creating projects](docs/creating-projects.markdown) -- [Editing projects](docs/editing-projects.markdown) -- [Sharing boards and tasks](docs/sharing-projects.markdown) -- [Automatic actions](docs/automatic-actions.markdown) -- [Project permissions](docs/project-permissions.markdown) -- [Swimlanes](docs/swimlanes.markdown) -- [Calendar](docs/calendar.markdown) -- [Budget](docs/budget.markdown) -- [Analytics](docs/analytics.markdown) - -#### Working with tasks - -- [Creating tasks](docs/creating-tasks.markdown) -- [Adding screenshots](docs/screenshots.markdown) -- [Task links](docs/task-links.markdown) -- [Transitions](docs/transitions.markdown) -- [Time tracking](docs/time-tracking.markdown) -- [Recurring tasks](docs/recurring-tasks.markdown) -- [Create tasks by email](docs/create-tasks-by-email.markdown) - -#### Working with users - -- [User management](docs/user-management.markdown) -- [Hourly rate](docs/hourly-rate.markdown) -- [Timetable](docs/timetable.markdown) -- [Two factor authentication](docs/2fa.markdown) - -#### Settings - -- [Keyboard shortcuts](docs/keyboard-shortcuts.markdown) -- [Application settings](docs/application-configuration.markdown) -- [Project settings](docs/project-configuration.markdown) -- [Board settings](docs/board-configuration.markdown) -- [Calendar settings](docs/calendar-configuration.markdown) -- [Link settings](docs/link-labels.markdown) -- [Currency rate](docs/currency-rate.markdown) -- [Config file](docs/config.markdown) - -### Integrations - -- [Bitbucket webhooks](docs/bitbucket-webhooks.markdown) -- [Github webhooks](docs/github-webhooks.markdown) -- [Gitlab webhooks](docs/gitlab-webhooks.markdown) -- [Hipchat](docs/hipchat.markdown) -- [Jabber](docs/jabber.markdown) -- [Mailgun](docs/mailgun.markdown) -- [Sendgrid](docs/sendgrid.markdown) -- [Slack](docs/slack.markdown) -- [Postmark](docs/postmark.markdown) -- [iCalendar subscriptions](docs/ical.markdown) - -#### More - -- [Syntax guide](docs/syntax-guide.markdown) -- [Frequently asked questions](docs/faq.markdown) - -### Technical details - -#### Installation - -- [Installation instructions](docs/installation.markdown) -- [Upgrade Kanboard to a new version](docs/update.markdown) -- [Installation on Ubuntu](docs/ubuntu-installation.markdown) -- [Installation on Debian](docs/debian-installation.markdown) -- [Installation on Centos](docs/centos-installation.markdown) -- [Installation on FreeBSD](docs/freebsd-installation.markdown) -- [Installation on Windows Server with IIS](docs/windows-iis-installation.markdown) -- [Installation on Windows Server with Apache](docs/windows-apache-installation.markdown) -- [Installation on Heroku](docs/heroku.markdown) -- [Example with Nginx + HTTPS + SPDY + PHP-FPM](docs/nginx-ssl-php-fpm.markdown) - -#### Database - -- [Sqlite database management](docs/sqlite-database.markdown) -- [How to use Mysql](docs/mysql-configuration.markdown) -- [How to use Postgresql](docs/postgresql-configuration.markdown) - -#### Authentication - -- [LDAP authentication](docs/ldap-authentication.markdown) -- [Google authentication](docs/google-authentication.markdown) -- [GitHub authentication](docs/github-authentication.markdown) -- [Reverse proxy authentication](docs/reverse-proxy-authentication.markdown) - -#### Developers and sysadmins - -- [Email configuration](docs/email-configuration.markdown) -- [Command line interface](docs/cli.markdown) -- [Json-RPC API](docs/api-json-rpc.markdown) -- [Webhooks](docs/webhooks.markdown) -- [Run Kanboard with Vagrant](docs/vagrant.markdown) -- [Run Kanboard with Docker](docs/docker.markdown) - -### Contributors - -- [Contributor guide](docs/contributing.markdown) -- [Translations](docs/translations.markdown) -- [Coding standards](docs/coding-standards.markdown) -- [Running tests](docs/tests.markdown) -- [Build assets](docs/assets.markdown) - -The documentation is written in [Markdown](http://en.wikipedia.org/wiki/Markdown). -If you want to improve the documentation, just send a pull-request. - -FAQ ---- - -Go to the official website: <http://kanboard.net/faq> - -Authors -------- - -Original author: [Frédéric Guillot](http://fredericguillot.com/) - -Contributors: - -- Alex Butum -- [Aleix Pol](https://github.com/aleixpol) -- [Ashbike](https://github.com/ashbike) -- [Ashish Kulkarni](https://github.com/ashkulz) -- [Christian González](https://github.com/nerdoc) -- [Chorgroup](https://github.com/chorgroup) -- Claudio Lobo -- [Cluxter](https://github.com/cluxter) -- [Cmer](https://github.com/chncsu) -- [Colin Williams](https://github.com/crwilliams) -- [Crash5](https://github.com/crash5) -- [Creador30](https://github.com/creador30) -- [Cynthia Pereira](https://github.com/cynthiapereira) -- [David-Norris](https://github.com/David-Norris) -- [Draza (bdpsoft)](https://github.com/bdpsoft) -- [Esteban Monge](https://github.com/EstebanMonge) -- [Fengchao](https://github.com/fengchao) -- [Floaltvater](https://github.com/floaltvater) -- [Gavlepeter](https://github.com/gavlepeter) -- [Hendrik Stocker](https://github.com/hendrik-stoker) -- [Iterate From 0](https://github.com/freebsd-kanboard) -- [Jan Dittrich](https://github.com/jdittrich) -- [Janne Mäntyharju](https://github.com/JanneMantyharju) -- [Jean-François Magnier](https://github.com/lefakir) -- [Jesusaplsoft](https://github.com/jesusaplsoft) -- [Karol J](https://github.com/dzudek) -- [Kiswa](https://github.com/kiswa) -- [Kralo](https://github.com/kralo) -- [Lars Christian Schou](https://github.com/NegoZiatoR) -- [Levlaz](https://github.com/levlaz) -- [Lim Yuen Hoe](https://github.com/jasonmoofang) -- [Manish Lad](https://github.com/manishlad) -- [Mathgl67](https://github.com/mathgl67) -- [Matthieu Keller](https://github.com/maggick) -- [Mauro Mariño](https://github.com/moromarino) -- [Maxime](https://github.com/EpocDotFr) -- [mfoucrier](https://github.com/mfoucrier) -- [Mgro](https://github.com/mgro) -- [Michael Lüpkes](https://github.com/mluepkes) -- [Mihailov Vasilievic Filho](https://github.com/mihailov-vf) -- [Moraxy](https://github.com/moraxy) -- [Nala Ginrut](https://github.com/NalaGinrut) -- [Nekohayo](https://github.com/nekohayo) -- [Nicolas Lœuillet](https://github.com/nicosomb) -- [Norcnorc](https://github.com/norcnorc) -- [Nramel](https://github.com/nramel) -- [Null-Kelvin](https://github.com/Null-Kelvin) -- [Oliver Bertuch](https://github.com/poikilotherm) -- [Olivier Maridat](https://github.com/oliviermaridat) -- [Oren Ben-Kiki](https://github.com/orenbenkiki) -- Paolo Mainieri -- [Peller Zoltan](https://github.com/PierP) -- [Petja Touru](https://github.com/Petja) -- [Piotr Zęgota](https://github.com/ZegalPL) -- [Rafaelrossa](https://github.com/rafaelrossa) -- [Raphaël Doursenaud](https://github.com/rdoursenaud) -- [René Stoltenberg](https://github.com/rstoltenberg) -- [Rzeka](https://github.com/rzeka) -- [Sebastien Pacilly](https://github.com/spacilly) -- [Sebastian Reese](https://github.com/ReeseSebastian) -- [Semyon Novikov](https://github.com/semka) -- [Sylvain Veyrié](https://github.com/turb) -- [Timo](https://github.com/BlueTeck) -- [Tomáš Votruba](https://github.com/TomasVotruba) -- [Toomyem](https://github.com/Toomyem) -- [Tony G. Bolaño](https://github.com/tonybolanyo) -- [Torsten](https://github.com/misterfu) -- [Troloo](https://github.com/troloo) -- [Typz](https://github.com/Typz) -- [Vedovator](https://github.com/vedovator) -- [Vladimir Babin](https://github.com/Chiliec) -- [Ybarc](https://github.com/ybarc) -- [Yuichi Murata](https://github.com/yuichi1004) - -There is also many people who have reported bugs or proposed awesome ideas. diff --git a/app/Action/Base.php b/app/Action/Base.php index f29e9323..d0c81d89 100644 --- a/app/Action/Base.php +++ b/app/Action/Base.php @@ -222,12 +222,17 @@ abstract class Base extends \Core\Base } $data = $event->getAll(); + $result = false; if ($this->isExecutable($data)) { $this->called = true; - return $this->doAction($data); + $result = $this->doAction($data); } - return false; + if (DEBUG) { + $this->container['logger']->debug(get_called_class().' => '.($result ? 'true' : 'false')); + } + + return $result; } } diff --git a/app/Action/TaskMoveColumnAssigned.php b/app/Action/TaskMoveColumnAssigned.php index decf4b01..70ad7699 100644 --- a/app/Action/TaskMoveColumnAssigned.php +++ b/app/Action/TaskMoveColumnAssigned.php @@ -22,6 +22,7 @@ class TaskMoveColumnAssigned extends Base { return array( Task::EVENT_ASSIGNEE_CHANGE, + Task::EVENT_UPDATE, ); } @@ -85,6 +86,6 @@ class TaskMoveColumnAssigned extends Base */ public function hasRequiredCondition(array $data) { - return $data['column_id'] == $this->getParam('src_column_id') && $data['owner_id']; + return $data['column_id'] == $this->getParam('src_column_id') && $data['owner_id'] > 0; } } diff --git a/app/Action/TaskMoveColumnCategoryChange.php b/app/Action/TaskMoveColumnCategoryChange.php new file mode 100644 index 00000000..a0f41ba4 --- /dev/null +++ b/app/Action/TaskMoveColumnCategoryChange.php @@ -0,0 +1,89 @@ +<?php + +namespace Action; + +use Model\Task; + +/** + * Move a task to another column when the category is changed + * + * @package action + * @author Francois Ferrand + */ +class TaskMoveColumnCategoryChange extends Base +{ + /** + * Get the list of compatible events + * + * @access public + * @return array + */ + public function getCompatibleEvents() + { + return array( + Task::EVENT_UPDATE, + ); + } + + /** + * Get the required parameter for the action (defined by the user) + * + * @access public + * @return array + */ + public function getActionRequiredParameters() + { + return array( + 'dest_column_id' => t('Destination column'), + 'category_id' => t('Category'), + ); + } + + /** + * Get the required parameter for the event + * + * @access public + * @return string[] + */ + public function getEventRequiredParameters() + { + return array( + 'task_id', + 'column_id', + 'project_id', + 'category_id', + ); + } + + /** + * Execute the action (move the task to another column) + * + * @access public + * @param array $data Event data dictionary + * @return bool True if the action was executed or false when not executed + */ + public function doAction(array $data) + { + $original_task = $this->taskFinder->getById($data['task_id']); + + return $this->taskPosition->movePosition( + $data['project_id'], + $data['task_id'], + $this->getParam('dest_column_id'), + $original_task['position'], + $original_task['swimlane_id'] + ); + } + + /** + * Check if the event data meet the action condition + * + * @access public + * @param array $data Event data dictionary + * @return bool + */ + public function hasRequiredCondition(array $data) + { + return $data['column_id'] != $this->getParam('dest_column_id') && $data['category_id'] == $this->getParam('category_id'); + } +} diff --git a/app/Action/TaskMoveColumnUnAssigned.php b/app/Action/TaskMoveColumnUnAssigned.php index b773252d..2216c8d0 100644 --- a/app/Action/TaskMoveColumnUnAssigned.php +++ b/app/Action/TaskMoveColumnUnAssigned.php @@ -21,7 +21,8 @@ class TaskMoveColumnUnAssigned extends Base public function getCompatibleEvents() { return array( - Task::EVENT_ASSIGNEE_CHANGE + Task::EVENT_ASSIGNEE_CHANGE, + Task::EVENT_UPDATE, ); } @@ -85,6 +86,6 @@ class TaskMoveColumnUnAssigned extends Base */ public function hasRequiredCondition(array $data) { - return $data['column_id'] == $this->getParam('src_column_id') && ! $data['owner_id']; + return $data['column_id'] == $this->getParam('src_column_id') && $data['owner_id'] == 0; } } diff --git a/app/Api/File.php b/app/Api/File.php index 11c48404..5b82179c 100644 --- a/app/Api/File.php +++ b/app/Api/File.php @@ -36,13 +36,18 @@ class File extends Base return ''; } - public function createFile($project_id, $task_id, $filename, $is_image, &$blob) + public function createFile($project_id, $task_id, $filename, $blob) { - return $this->file->uploadContent($project_id, $task_id, $filename, $is_image, $blob); + return $this->file->uploadContent($project_id, $task_id, $filename, $blob); } public function removeFile($file_id) { return $this->file->remove($file_id); } + + public function removeAllFiles($task_id) + { + return $this->file->removeAll($task_id); + } } diff --git a/app/Api/Task.php b/app/Api/Task.php index c98b24a6..d7a9e91b 100644 --- a/app/Api/Task.php +++ b/app/Api/Task.php @@ -17,6 +17,11 @@ class Task extends Base return $this->taskFinder->getById($task_id); } + public function getTaskByReference($project_id, $reference) + { + return $this->taskFinder->getByReference($project_id, $reference); + } + public function getAllTasks($project_id, $status_id = TaskModel::STATUS_OPEN) { return $this->taskFinder->getAll($project_id, $status_id); @@ -50,7 +55,7 @@ class Task extends Base public function createTask($title, $project_id, $color_id = '', $column_id = 0, $owner_id = 0, $creator_id = 0, $date_due = '', $description = '', $category_id = 0, $score = 0, $swimlane_id = 0, $recurrence_status = 0, $recurrence_trigger = 0, $recurrence_factor = 0, $recurrence_timeframe = 0, - $recurrence_basedate = 0) + $recurrence_basedate = 0, $reference = '') { $values = array( 'title' => $title, @@ -69,6 +74,7 @@ class Task extends Base 'recurrence_factor' => $recurrence_factor, 'recurrence_timeframe' => $recurrence_timeframe, 'recurrence_basedate' => $recurrence_basedate, + 'reference' => $reference, ); list($valid,) = $this->taskValidator->validateCreation($values); @@ -79,7 +85,7 @@ class Task extends Base public function updateTask($id, $title = null, $project_id = null, $color_id = null, $column_id = null, $owner_id = null, $creator_id = null, $date_due = null, $description = null, $category_id = null, $score = null, $swimlane_id = null, $recurrence_status = null, $recurrence_trigger = null, $recurrence_factor = null, - $recurrence_timeframe = null, $recurrence_basedate = null) + $recurrence_timeframe = null, $recurrence_basedate = null, $reference = null) { $values = array( 'id' => $id, @@ -99,6 +105,7 @@ class Task extends Base 'recurrence_factor' => $recurrence_factor, 'recurrence_timeframe' => $recurrence_timeframe, 'recurrence_basedate' => $recurrence_basedate, + 'reference' => $reference, ); foreach ($values as $key => $value) { diff --git a/app/Console/TaskOverdueNotification.php b/app/Console/TaskOverdueNotification.php index 86a7d1b9..3d254ae4 100644 --- a/app/Console/TaskOverdueNotification.php +++ b/app/Console/TaskOverdueNotification.php @@ -19,25 +19,7 @@ class TaskOverdueNotification extends Base protected function execute(InputInterface $input, OutputInterface $output) { - $projects = array(); - $tasks = $this->taskFinder->getOverdueTasks(); - - // Group tasks by project - foreach ($tasks as $task) { - $projects[$task['project_id']][] = $task; - } - - // Send notifications for each project - foreach ($projects as $project_id => $project_tasks) { - - $users = $this->notification->getUsersList($project_id); - - $this->notification->sendEmails( - 'task_due', - $users, - array('tasks' => $project_tasks, 'project' => $project_tasks[0]['project_name']) - ); - } + $tasks = $this->notification->sendOverdueTaskNotifications(); if ($input->getOption('show')) { $this->showTable($output, $tasks); diff --git a/app/Controller/Base.php b/app/Controller/Base.php index fcd07b99..19bb9ac9 100644 --- a/app/Controller/Base.php +++ b/app/Controller/Base.php @@ -211,6 +211,18 @@ abstract class Base extends \Core\Base } /** + * Check webhook token + * + * @access protected + */ + protected function checkWebhookToken() + { + if ($this->config->get('webhook_token') !== $this->request->getStringParam('token')) { + $this->response->text('Not Authorized', 401); + } + } + + /** * Redirection when there is no project in the database * * @access protected diff --git a/app/Controller/File.php b/app/Controller/File.php index f0367537..f73a9de9 100644 --- a/app/Controller/File.php +++ b/app/Controller/File.php @@ -19,7 +19,7 @@ class File extends Base { $task = $this->getTask(); - if ($this->request->isPost() && $this->file->uploadScreenshot($task['project_id'], $task['id'], $this->request->getValue('screenshot'))) { + if ($this->request->isPost() && $this->file->uploadScreenshot($task['project_id'], $task['id'], $this->request->getValue('screenshot')) !== false) { $this->session->flash(t('Screenshot uploaded successfully.')); diff --git a/app/Controller/Ical.php b/app/Controller/Ical.php index 52e10fa1..8a7ed8b5 100644 --- a/app/Controller/Ical.php +++ b/app/Controller/Ical.php @@ -3,7 +3,6 @@ namespace Controller; use Model\TaskFilter; -use Model\Task as TaskModel; use Eluceo\iCal\Component\Calendar as iCalendar; /** diff --git a/app/Controller/User.php b/app/Controller/User.php index b049c926..4cea06b1 100644 --- a/app/Controller/User.php +++ b/app/Controller/User.php @@ -105,9 +105,11 @@ class User extends Base if ($valid) { - if ($this->user->create($values)) { + $user_id = $this->user->create($values); + + if ($user_id !== false) { $this->session->flash(t('User created successfully.')); - $this->response->redirect('?controller=user'); + $this->response->redirect($this->helper->url->to('user', 'show', array('user_id' => $user_id))); } else { $this->session->flashError(t('Unable to create your user.')); diff --git a/app/Controller/Webhook.php b/app/Controller/Webhook.php index c79b4ed6..d04f83b3 100644 --- a/app/Controller/Webhook.php +++ b/app/Controller/Webhook.php @@ -17,9 +17,7 @@ class Webhook extends Base */ public function task() { - if ($this->config->get('webhook_token') !== $this->request->getStringParam('token')) { - $this->response->text('Not Authorized', 401); - } + $this->checkWebhookToken(); $defaultProject = $this->project->getFirst(); @@ -49,9 +47,7 @@ class Webhook extends Base */ public function github() { - if ($this->config->get('webhook_token') !== $this->request->getStringParam('token')) { - $this->response->text('Not Authorized', 401); - } + $this->checkWebhookToken(); $this->githubWebhook->setProjectId($this->request->getIntegerParam('project_id')); @@ -70,15 +66,10 @@ class Webhook extends Base */ public function gitlab() { - if ($this->config->get('webhook_token') !== $this->request->getStringParam('token')) { - $this->response->text('Not Authorized', 401); - } + $this->checkWebhookToken(); $this->gitlabWebhook->setProjectId($this->request->getIntegerParam('project_id')); - - $result = $this->gitlabWebhook->parsePayload( - $this->request->getJson() ?: array() - ); + $result = $this->gitlabWebhook->parsePayload($this->request->getJson() ?: array()); echo $result ? 'PARSED' : 'IGNORED'; } @@ -90,12 +81,9 @@ class Webhook extends Base */ public function bitbucket() { - if ($this->config->get('webhook_token') !== $this->request->getStringParam('token')) { - $this->response->text('Not Authorized', 401); - } + $this->checkWebhookToken(); $this->bitbucketWebhook->setProjectId($this->request->getIntegerParam('project_id')); - $result = $this->bitbucketWebhook->parsePayload(json_decode(@$_POST['payload'], true) ?: array()); echo $result ? 'PARSED' : 'IGNORED'; @@ -108,11 +96,8 @@ class Webhook extends Base */ public function postmark() { - if ($this->config->get('webhook_token') !== $this->request->getStringParam('token')) { - $this->response->text('Not Authorized', 401); - } - - echo $this->postmarkWebhook->parsePayload($this->request->getJson() ?: array()) ? 'PARSED' : 'IGNORED'; + $this->checkWebhookToken(); + echo $this->postmark->receiveEmail($this->request->getJson() ?: array()) ? 'PARSED' : 'IGNORED'; } /** @@ -122,11 +107,8 @@ class Webhook extends Base */ public function mailgun() { - if ($this->config->get('webhook_token') !== $this->request->getStringParam('token')) { - $this->response->text('Not Authorized', 401); - } - - echo $this->mailgunWebhook->parsePayload($_POST) ? 'PARSED' : 'IGNORED'; + $this->checkWebhookToken(); + echo $this->mailgun->receiveEmail($_POST) ? 'PARSED' : 'IGNORED'; } /** @@ -136,10 +118,7 @@ class Webhook extends Base */ public function sendgrid() { - if ($this->config->get('webhook_token') !== $this->request->getStringParam('token')) { - $this->response->text('Not Authorized', 401); - } - - echo $this->sendgridWebhook->parsePayload($_POST) ? 'PARSED' : 'IGNORED'; + $this->checkWebhookToken(); + echo $this->sendgrid->receiveEmail($_POST) ? 'PARSED' : 'IGNORED'; } } diff --git a/app/Core/Base.php b/app/Core/Base.php index cb8e4487..6cb87cbc 100644 --- a/app/Core/Base.php +++ b/app/Core/Base.php @@ -11,6 +11,7 @@ use Pimple\Container; * @author Frederic Guillot * * @property \Core\Helper $helper + * @property \Core\EmailClient $emailClient * @property \Core\HttpClient $httpClient * @property \Core\Paginator $paginator * @property \Core\Request $request @@ -21,10 +22,11 @@ use Pimple\Container; * @property \Integration\GitlabWebhook $gitlabWebhook * @property \Integration\HipchatWebhook $hipchatWebhook * @property \Integration\Jabber $jabber - * @property \Integration\MailgunWebhook $mailgunWebhook - * @property \Integration\PostmarkWebhook $postmarkWebhook + * @property \Integration\Mailgun $mailgun + * @property \Integration\Postmark $postmark * @property \Integration\SendgridWebhook $sendgridWebhook * @property \Integration\SlackWebhook $slackWebhook + * @property \Integration\Smtp $smtp * @property \Model\Acl $acl * @property \Model\Action $action * @property \Model\Authentication $authentication diff --git a/app/Core/EmailClient.php b/app/Core/EmailClient.php new file mode 100644 index 00000000..b1986502 --- /dev/null +++ b/app/Core/EmailClient.php @@ -0,0 +1,49 @@ +<?php + +namespace Core; + +/** + * Mail client + * + * @package core + * @author Frederic Guillot + */ +class EmailClient extends Base +{ + /** + * Send a HTML email + * + * @access public + * @param string $email + * @param string $name + * @param string $subject + * @param string $html + */ + public function send($email, $name, $subject, $html) + { + $this->container['logger']->debug('Sending email to '.$email.' ('.MAIL_TRANSPORT.')'); + + $start_time = microtime(true); + $author = 'Kanboard'; + + if (Session::isOpen() && $this->userSession->isLogged()) { + $author = e('%s via Kanboard', $this->user->getFullname($this->session['user'])); + } + + switch (MAIL_TRANSPORT) { + case 'sendgrid': + $this->sendgrid->sendEmail($email, $name, $subject, $html, $author); + break; + case 'mailgun': + $this->mailgun->sendEmail($email, $name, $subject, $html, $author); + break; + case 'postmark': + $this->postmark->sendEmail($email, $name, $subject, $html, $author); + break; + default: + $this->smtp->sendEmail($email, $name, $subject, $html, $author); + } + + $this->container['logger']->debug('Email sent in '.round(microtime(true) - $start_time, 6).' seconds'); + } +} diff --git a/app/Core/HttpClient.php b/app/Core/HttpClient.php index 0803ec7a..2f280a1e 100644 --- a/app/Core/HttpClient.php +++ b/app/Core/HttpClient.php @@ -2,22 +2,20 @@ namespace Core; -use Pimple\Container; - /** * HTTP client * * @package core * @author Frederic Guillot */ -class HttpClient +class HttpClient extends Base { /** * HTTP connection timeout in seconds * * @var integer */ - const HTTP_TIMEOUT = 2; + const HTTP_TIMEOUT = 5; /** * Number of maximum redirections for the HTTP client @@ -31,46 +29,60 @@ class HttpClient * * @var string */ - const HTTP_USER_AGENT = 'Kanboard Webhook'; + const HTTP_USER_AGENT = 'Kanboard'; /** - * Container instance + * Send a POST HTTP request encoded in JSON * - * @access protected - * @var \Pimple\Container + * @access public + * @param string $url + * @param array $data + * @param array $headers + * @return string */ - protected $container; + public function postJson($url, array $data, array $headers = array()) + { + return $this->doRequest( + $url, + json_encode($data), + array_merge(array('Content-type: application/json'), $headers) + ); + } /** - * Constructor + * Send a POST HTTP request encoded in www-form-urlencoded * * @access public - * @param \Pimple\Container $container + * @param string $url + * @param array $data + * @param array $headers + * @return string */ - public function __construct(Container $container) + public function postForm($url, array $data, array $headers = array()) { - $this->container = $container; + return $this->doRequest( + $url, + http_build_query($data), + array_merge(array('Content-type: application/x-www-form-urlencoded'), $headers) + ); } /** - * Send a POST HTTP request + * Make the HTTP request * - * @access public + * @access private * @param string $url - * @param array $data + * @param array $content + * @param array $headers * @return string */ - public function post($url, array $data) + private function doRequest($url, $content, array $headers) { if (empty($url)) { return ''; } - $headers = array( - 'User-Agent: '.self::HTTP_USER_AGENT, - 'Content-Type: application/json', - 'Connection: close', - ); + $headers = array_merge(array('User-Agent: '.self::HTTP_USER_AGENT, 'Connection: close'), $headers); $context = stream_context_create(array( 'http' => array( @@ -79,16 +91,25 @@ class HttpClient 'timeout' => self::HTTP_TIMEOUT, 'max_redirects' => self::HTTP_MAX_REDIRECTS, 'header' => implode("\r\n", $headers), - 'content' => json_encode($data) + 'content' => $content ) )); - $response = @file_get_contents(trim($url), false, $context); + $stream = @fopen(trim($url), 'r', false, $context); + $response = ''; + + if (is_resource($stream)) { + $response = stream_get_contents($stream); + } + else { + $this->container['logger']->error('HttpClient: request failed'); + } if (DEBUG) { - $this->container['logger']->debug($url); - $this->container['logger']->debug(var_export($data, true)); - $this->container['logger']->debug($response); + $this->container['logger']->debug('HttpClient: url='.$url); + $this->container['logger']->debug('HttpClient: payload='.$content); + $this->container['logger']->debug('HttpClient: metadata='.var_export(@stream_get_meta_data($stream), true)); + $this->container['logger']->debug('HttpClient: response='.$response); } return $response; diff --git a/app/Helper/Task.php b/app/Helper/Task.php index b3931cdb..13bdb07a 100644 --- a/app/Helper/Task.php +++ b/app/Helper/Task.php @@ -37,6 +37,11 @@ class Task extends \Core\Base return t('%dd', ($now - $timestamp) / 86400); } + public function getColors() + { + return $this->color->getList(); + } + public function recurrenceTriggers() { return $this->task->getRecurrenceTriggerList(); diff --git a/app/Helper/Text.php b/app/Helper/Text.php index cfb557b1..790fc411 100644 --- a/app/Helper/Text.php +++ b/app/Helper/Text.php @@ -51,10 +51,10 @@ class Text extends \Core\Base */ public function truncate($value, $max_length = 85, $end = '[...]') { - $length = strlen($value); + $length = mb_strlen($value); if ($length > $max_length) { - return substr($value, 0, $max_length).' '.$end; + return mb_substr($value, 0, $max_length).' '.$end; } return $value; diff --git a/app/Integration/HipchatWebhook.php b/app/Integration/HipchatWebhook.php index 5cd01fb0..f1be0f34 100644 --- a/app/Integration/HipchatWebhook.php +++ b/app/Integration/HipchatWebhook.php @@ -89,7 +89,7 @@ class HipchatWebhook extends \Core\Base $params['room_token'] ); - $this->httpClient->post($url, $payload); + $this->httpClient->postJson($url, $payload); } } } diff --git a/app/Integration/MailgunWebhook.php b/app/Integration/Mailgun.php index 50d96a4a..1451b211 100644 --- a/app/Integration/MailgunWebhook.php +++ b/app/Integration/Mailgun.php @@ -6,21 +6,47 @@ use HTML_To_Markdown; use Core\Tool; /** - * Mailgun Webhook + * Mailgun Integration * * @package integration * @author Frederic Guillot */ -class MailgunWebhook extends \Core\Base +class Mailgun extends \Core\Base { /** + * Send a HTML email + * + * @access public + * @param string $email + * @param string $name + * @param string $subject + * @param string $html + * @param string $author + */ + public function sendEmail($email, $name, $subject, $html, $author) + { + $headers = array( + 'Authorization: Basic '.base64_encode('api:'.MAILGUN_API_TOKEN) + ); + + $payload = array( + 'from' => sprintf('%s <%s>', $author, MAIL_FROM), + 'to' => sprintf('%s <%s>', $name, $email), + 'subject' => $subject, + 'html' => $html, + ); + + $this->httpClient->postForm('https://api.mailgun.net/v3/'.MAILGUN_DOMAIN.'/messages', $payload, $headers); + } + + /** * Parse incoming email * * @access public * @param array $payload Incoming email * @return boolean */ - public function parsePayload(array $payload) + public function receiveEmail(array $payload) { if (empty($payload['sender']) || empty($payload['subject']) || empty($payload['recipient'])) { return false; @@ -30,7 +56,7 @@ class MailgunWebhook extends \Core\Base $user = $this->user->getByEmail($payload['sender']); if (empty($user)) { - $this->container['logger']->debug('MailgunWebhook: ignored => user not found'); + $this->container['logger']->debug('Mailgun: ignored => user not found'); return false; } @@ -38,13 +64,13 @@ class MailgunWebhook extends \Core\Base $project = $this->project->getByIdentifier(Tool::getMailboxHash($payload['recipient'])); if (empty($project)) { - $this->container['logger']->debug('MailgunWebhook: ignored => project not found'); + $this->container['logger']->debug('Mailgun: ignored => project not found'); return false; } // The user must be member of the project if (! $this->projectPermission->isMember($project['id'], $user['id'])) { - $this->container['logger']->debug('MailgunWebhook: ignored => user is not member of the project'); + $this->container['logger']->debug('Mailgun: ignored => user is not member of the project'); return false; } diff --git a/app/Integration/PostmarkWebhook.php b/app/Integration/Postmark.php index 9051e5f7..dbb70aee 100644 --- a/app/Integration/PostmarkWebhook.php +++ b/app/Integration/Postmark.php @@ -5,21 +5,48 @@ namespace Integration; use HTML_To_Markdown; /** - * Postmark Webhook + * Postmark integration * * @package integration * @author Frederic Guillot */ -class PostmarkWebhook extends \Core\Base +class Postmark extends \Core\Base { /** + * Send a HTML email + * + * @access public + * @param string $email + * @param string $name + * @param string $subject + * @param string $html + * @param string $author + */ + public function sendEmail($email, $name, $subject, $html, $author) + { + $headers = array( + 'Accept: application/json', + 'X-Postmark-Server-Token: '.POSTMARK_API_TOKEN, + ); + + $payload = array( + 'From' => sprintf('%s <%s>', $author, MAIL_FROM), + 'To' => sprintf('%s <%s>', $name, $email), + 'Subject' => $subject, + 'HtmlBody' => $html, + ); + + $this->httpClient->postJson('https://api.postmarkapp.com/email', $payload, $headers); + } + + /** * Parse incoming email * * @access public * @param array $payload Incoming email * @return boolean */ - public function parsePayload(array $payload) + public function receiveEmail(array $payload) { if (empty($payload['From']) || empty($payload['Subject']) || empty($payload['MailboxHash'])) { return false; @@ -29,7 +56,7 @@ class PostmarkWebhook extends \Core\Base $user = $this->user->getByEmail($payload['From']); if (empty($user)) { - $this->container['logger']->debug('PostmarkWebhook: ignored => user not found'); + $this->container['logger']->debug('Postmark: ignored => user not found'); return false; } @@ -37,13 +64,13 @@ class PostmarkWebhook extends \Core\Base $project = $this->project->getByIdentifier($payload['MailboxHash']); if (empty($project)) { - $this->container['logger']->debug('PostmarkWebhook: ignored => project not found'); + $this->container['logger']->debug('Postmark: ignored => project not found'); return false; } // The user must be member of the project if (! $this->projectPermission->isMember($project['id'], $user['id'])) { - $this->container['logger']->debug('PostmarkWebhook: ignored => user is not member of the project'); + $this->container['logger']->debug('Postmark: ignored => user is not member of the project'); return false; } diff --git a/app/Integration/SendgridWebhook.php b/app/Integration/Sendgrid.php index 9125f00b..902749f6 100644 --- a/app/Integration/SendgridWebhook.php +++ b/app/Integration/Sendgrid.php @@ -6,21 +6,47 @@ use HTML_To_Markdown; use Core\Tool; /** - * Sendgrid Webhook + * Sendgrid Integration * * @package integration * @author Frederic Guillot */ -class SendgridWebhook extends \Core\Base +class Sendgrid extends \Core\Base { /** + * Send a HTML email + * + * @access public + * @param string $email + * @param string $name + * @param string $subject + * @param string $html + * @param string $author + */ + public function sendEmail($email, $name, $subject, $html, $author) + { + $payload = array( + 'api_user' => SENDGRID_API_USER, + 'api_key' => SENDGRID_API_KEY, + 'to' => $email, + 'toname' => $name, + 'from' => MAIL_FROM, + 'fromname' => $author, + 'html' => $html, + 'subject' => $subject, + ); + + $this->httpClient->postForm('https://api.sendgrid.com/api/mail.send.json', $payload); + } + + /** * Parse incoming email * * @access public * @param array $payload Incoming email * @return boolean */ - public function parsePayload(array $payload) + public function receiveEmail(array $payload) { if (empty($payload['envelope']) || empty($payload['subject'])) { return false; diff --git a/app/Integration/SlackWebhook.php b/app/Integration/SlackWebhook.php index 8be33496..975ea21f 100644 --- a/app/Integration/SlackWebhook.php +++ b/app/Integration/SlackWebhook.php @@ -69,7 +69,7 @@ class SlackWebhook extends \Core\Base $payload['text'] .= '|'.t('view the task on Kanboard').'>'; } - $this->httpClient->post($this->getWebhookUrl($project_id), $payload); + $this->httpClient->postJson($this->getWebhookUrl($project_id), $payload); } } } diff --git a/app/Integration/Smtp.php b/app/Integration/Smtp.php new file mode 100644 index 00000000..ad2f30f8 --- /dev/null +++ b/app/Integration/Smtp.php @@ -0,0 +1,71 @@ +<?php + +namespace Integration; + +use Swift_Message; +use Swift_Mailer; +use Swift_MailTransport; +use Swift_SendmailTransport; +use Swift_SmtpTransport; +use Swift_TransportException; + +/** + * Smtp + * + * @package integration + * @author Frederic Guillot + */ +class Smtp extends \Core\Base +{ + /** + * Send a HTML email + * + * @access public + * @param string $email + * @param string $name + * @param string $subject + * @param string $html + * @param string $author + */ + public function sendEmail($email, $name, $subject, $html, $author) + { + try { + + $message = Swift_Message::newInstance() + ->setSubject($subject) + ->setFrom(array(MAIL_FROM => $author)) + ->setBody($html, 'text/html') + ->setTo(array($email => $name)); + + Swift_Mailer::newInstance($this->getTransport())->send($message); + } + catch (Swift_TransportException $e) { + $this->container['logger']->error($e->getMessage()); + } + } + + /** + * Get SwiftMailer transport + * + * @access private + * @return \Swift_Transport + */ + private function getTransport() + { + switch (MAIL_TRANSPORT) { + case 'smtp': + $transport = Swift_SmtpTransport::newInstance(MAIL_SMTP_HOSTNAME, MAIL_SMTP_PORT); + $transport->setUsername(MAIL_SMTP_USERNAME); + $transport->setPassword(MAIL_SMTP_PASSWORD); + $transport->setEncryption(MAIL_SMTP_ENCRYPTION); + break; + case 'sendmail': + $transport = Swift_SendmailTransport::newInstance(MAIL_SENDMAIL_COMMAND); + break; + default: + $transport = Swift_MailTransport::newInstance(); + } + + return $transport; + } +} diff --git a/app/Locale/es_ES/translations.php b/app/Locale/es_ES/translations.php index 2c215390..9c679c93 100644 --- a/app/Locale/es_ES/translations.php +++ b/app/Locale/es_ES/translations.php @@ -760,7 +760,7 @@ return array( 'Timetable' => 'Horario', 'Work timetable' => 'Horario de trabajo', 'Week timetable' => 'Horario semanal', - 'Day timetable' => 'Horario de día', + 'Day timetable' => 'Horario diario', 'From' => 'Desde', 'To' => 'Hasta', 'Time slot created successfully.' => 'Tiempo asignado creado con éxito.', @@ -784,7 +784,7 @@ return array( 'CHF - Swiss Francs' => 'CHF - Francos suizos', 'Cost' => 'Costo', 'Cost breakdown' => 'Desglose de costes', - 'Custom Stylesheet' => 'Hoja de Estilo habitual', + 'Custom Stylesheet' => 'Hoja de estilo Personalizada', 'download' => 'descargar', 'Do you really want to remove this budget line?' => '¿Realmente quieres quitar esta línea de presupuesto?', 'EUR - Euro' => 'EUR - Euro', @@ -866,7 +866,7 @@ return array( 'Help on Sendgrid integration' => 'Ayuda sobre la integración con Sendgrid', 'Disable two factor authentication' => 'Desactivar la autenticación de dos factores', 'Do you really want to disable the two factor authentication for this user: "%s"?' => '¿Realmentes quieres desactuvar la autenticación de dos factores para este usuario: "%s?"', - // 'Edit link' => '', + 'Edit link' => 'Editar enlace', // 'Start to type task title...' => '', // 'A task cannot be linked to itself' => '', // 'The exact same link already exists' => '', @@ -876,7 +876,7 @@ return array( // 'The identifier must be unique' => '', // 'This linked task id doesn\'t exists' => '', // 'This value must be alphanumeric' => '', - // 'Edit recurrence' => '', + 'Edit recurrence' => 'Editar recurrencia', // 'Generate recurrent task' => '', // 'Trigger to generate recurrent task' => '', // 'Factor to calculate new due date' => '', @@ -885,41 +885,41 @@ return array( // 'Action date' => '', // 'Base date to calculate new due date: ' => '', // 'This task has created this child task: ' => '', - // 'Day(s)' => '', + 'Day(s)' => 'Día(s)', // 'Existing due date' => '', // 'Factor to calculate new due date: ' => '', - // 'Month(s)' => '', - // 'Recurrence' => '', - // 'This task has been created by: ' => '', + 'Month(s)' => 'Mes(es)', + 'Recurrence' => 'Recurrencia', + 'This task has been created by: ' => 'Esta tarea ha sido creada por: ', // 'Recurrent task has been generated:' => '', // 'Timeframe to calculate new due date: ' => '', // 'Trigger to generate recurrent task: ' => '', - // 'When task is closed' => '', - // 'When task is moved from first column' => '', - // 'When task is moved to last column' => '', - // 'Year(s)' => '', - // 'Jabber (XMPP)' => '', - // 'Send notifications to Jabber' => '', - // 'XMPP server address' => '', - // 'Jabber domain' => '', - // 'Jabber nickname' => '', - // 'Multi-user chat room' => '', + 'When task is closed' => 'Cuando la tarea es cerrada', + 'When task is moved from first column' => 'Cuando la tarea es movida a la primer columna', + 'When task is moved to last column' => 'Cuando la tarea es movida a la última columna', + 'Year(s)' => 'Año(s)', + 'Jabber (XMPP)' => 'Jabber (XMPP)', + 'Send notifications to Jabber' => 'Enviar notificaciones a Jabber', + 'XMPP server address' => 'Dirección del servidor XMPP', + 'Jabber domain' => 'Dominio Jabber', + 'Jabber nickname' => 'Apodo Jabber', + 'Multi-user chat room' => 'Sala de chat multiusuario', // 'Help on Jabber integration' => '', // 'The server address must use this format: "tcp://hostname:5222"' => '', - // 'Calendar settings' => '', - // 'Project calendar view' => '', - // 'Project settings' => '', - // 'Show subtasks based on the time tracking' => '', - // 'Show tasks based on the creation date' => '', - // 'Show tasks based on the start date' => '', - // 'Subtasks time tracking' => '', - // 'User calendar view' => '', - // 'Automatically update the start date' => '', - // 'iCal feed' => '', - // 'Preferences' => '', - // 'Security' => '', + 'Calendar settings' => 'Parámetros del Calendario', + 'Project calendar view' => 'Vista de Calendario para el Proyecto', + 'Project settings' => 'Parámetros del Proyecto', + 'Show subtasks based on the time tracking' => 'Mostrar subtareas en base al seguimiento de tiempo', + 'Show tasks based on the creation date' => 'Mostrar tareas en base a la fecha de creación', + 'Show tasks based on the start date' => 'Mostrar tareas en base a la fecha de comienzo', + 'Subtasks time tracking' => 'Seguimiento de tiempo en subtareas', + 'User calendar view' => 'Vista de Calendario para el Usuario', + 'Automatically update the start date' => 'Actualizar automáticamente la fecha de comienzo', + 'iCal feed' => 'Fuente iCal', + 'Preferences' => 'Preferencias', + 'Security' => 'Seguridad', // 'Two factor authentication disabled' => '', // 'Two factor authentication enabled' => '', - // 'Unable to update this user.' => '', - // 'There is no user management for private projects.' => '', + 'Unable to update this user.' => 'Imposible actualizar este usuario.', + 'There is no user management for private projects.' => 'No hay gestión de usuarios para proyectos privados.', ); diff --git a/app/Locale/ru_RU/translations.php b/app/Locale/ru_RU/translations.php index 2ea22e5a..ee71ea95 100644 --- a/app/Locale/ru_RU/translations.php +++ b/app/Locale/ru_RU/translations.php @@ -554,8 +554,8 @@ return array( 'Webhooks' => 'Webhooks', 'API' => 'API', 'Integration' => 'Интеграция', - // 'Github webhooks' => '', - // 'Help on Github webhooks' => '', + 'Github webhooks' => 'GitHub webhooks', + 'Help on Github webhooks' => 'Помощь по GitHub webhooks', 'Create a comment from an external provider' => 'Создать комментарий из внешнего источника', 'Github issue comment created' => 'Github issue комментарий создан', 'Configure' => 'Настройки', @@ -572,13 +572,13 @@ return array( 'Analytics' => 'Аналитика', 'Subtask' => 'Подзадача', 'My subtasks' => 'Мои подзадачи', - // 'User repartition' => '', - // 'User repartition for "%s"' => '', + 'User repartition' => 'Перераспределение пользователей', + 'User repartition for "%s"' => 'Перераспределение пользователей для "%s"', 'Clone this project' => 'Клонировать проект', 'Column removed successfully.' => 'Колонка успешно удалена.', 'Edit Project' => 'Редактировать Проект', // 'Github Issue' => '', - // 'Not enough data to show the graph.' => '', + 'Not enough data to show the graph.' => 'Недостаточно данных, чтобы показать график.', 'Previous' => 'Предыдущий', 'The id must be an integer' => 'Этот id должен быть целочисленным', 'The project id must be an integer' => 'Id проекта должен быть целочисленным', @@ -591,58 +591,58 @@ return array( 'This value is required' => 'Это значение обязательно', 'This value must be numeric' => 'Это значение должно быть цифровым', 'Unable to create this task.' => 'Невозможно создать задачу.', - // 'Cumulative flow diagram' => '', - // 'Cumulative flow diagram for "%s"' => '', + 'Cumulative flow diagram' => 'Накопительная диаграма', + 'Cumulative flow diagram for "%s"' => 'Накопительная диаграма для "%s"', 'Daily project summary' => 'Ежедневное состояние проекта', - // 'Daily project summary export' => '', - // 'Daily project summary export for "%s"' => '', + 'Daily project summary export' => 'Экспорт ежедневного резюме проекта', + 'Daily project summary export for "%s"' => 'Экспорт ежедневного резюме проекта "%s"', 'Exports' => 'Экспорт', - // 'This export contains the number of tasks per column grouped per day.' => '', + 'This export contains the number of tasks per column grouped per day.' => 'Этот экспорт содержит ряд задач в колонках, сгруппированные по дням.', 'Nothing to preview...' => 'Нет данных для предпросмотра...', 'Preview' => 'Предпросмотр', 'Write' => 'Написание', 'Active swimlanes' => 'Активные ', - 'Add a new swimlane' => 'Добавить новый swimlane', - 'Change default swimlane' => 'Сменить стандартный swimlane', - 'Default swimlane' => 'Стандартный swimlane', - 'Do you really want to remove this swimlane: "%s"?' => 'Вы действительно хотите удалить swimlane "%s"?', - 'Inactive swimlanes' => 'Неактивные swimlane\'ы', + 'Add a new swimlane' => 'Добавить новую дорожку', + 'Change default swimlane' => 'Сменить стандартную дорожку', + 'Default swimlane' => 'Стандартная дорожка', + 'Do you really want to remove this swimlane: "%s"?' => 'Вы действительно хотите удалить дорожку "%s"?', + 'Inactive swimlanes' => 'Неактивные дорожки', 'Set project manager' => 'Установить менеджера проекта', 'Set project member' => 'Установить участника проекта', - 'Remove a swimlane' => 'Удалить swimlane', + 'Remove a swimlane' => 'Удалить дорожку', 'Rename' => 'Переименовать', - 'Show default swimlane' => 'Показать стандартный swimlane', - 'Swimlane modification for the project "%s"' => 'Редактирование swimlane\'а для проекта "%s"', - 'Swimlane not found.' => 'Swimlane не найден.', - 'Swimlane removed successfully.' => 'Swimlane успешно удален', - 'Swimlanes' => 'Swimlane\'ы', - 'Swimlane updated successfully.' => 'Swimlane успешно обновлен.', - 'The default swimlane have been updated successfully.' => 'Стандартный swimlane был успешно обновлен.', - 'Unable to create your swimlane.' => 'Невозможно создать swimlane.', - 'Unable to remove this swimlane.' => 'Невозможно удалить swimlane.', - 'Unable to update this swimlane.' => 'Невозможно обновить swimlane.', - 'Your swimlane have been created successfully.' => 'Ваш swimlane был успешно создан.', + 'Show default swimlane' => 'Показать стандартную дорожку', + 'Swimlane modification for the project "%s"' => 'Редактирование дорожки для проекта "%s"', + 'Swimlane not found.' => 'Дорожка не найдена.', + 'Swimlane removed successfully.' => 'Дорожка успешно удалена', + 'Swimlanes' => 'Дорожки', + 'Swimlane updated successfully.' => 'Дорожка успешно обновлена.', + 'The default swimlane have been updated successfully.' => 'Стандартная swimlane был успешно обновлен.', + 'Unable to create your swimlane.' => 'Невозможно создать дорожку.', + 'Unable to remove this swimlane.' => 'Невозможно удалить дорожку.', + 'Unable to update this swimlane.' => 'Невозможно обновить дорожку.', + 'Your swimlane have been created successfully.' => 'Ваша дорожка была успешно создан.', 'Example: "Bug, Feature Request, Improvement"' => 'Например: "Баг, Фича, Улучшение"', 'Default categories for new projects (Comma-separated)' => 'Стандартные категории для нового проекта (разделяются запятыми)', // 'Gitlab commit received' => '', - // 'Gitlab issue opened' => '', - // 'Gitlab issue closed' => '', - // 'Gitlab webhooks' => '', - // 'Help on Gitlab webhooks' => '', + 'Gitlab issue opened' => 'Gitlab вопрос открыт', + 'Gitlab issue closed' => 'Gitlab вопрос закрыт', + 'Gitlab webhooks' => 'Gitlab webhooks', + 'Help on Gitlab webhooks' => 'Помощь по Gitlab webhooks', 'Integrations' => 'Интеграции', 'Integration with third-party services' => 'Интеграция со сторонними сервисами', 'Role for this project' => 'Роли для этого проекта', 'Project manager' => 'Менеджер проекта', 'Project member' => 'Участник проекта', 'A project manager can change the settings of the project and have more privileges than a standard user.' => 'Менеджер проекта может изменять настройки проекта и имеет больше привелегий чем стандартный пользователь.', - // 'Gitlab Issue' => '', + 'Gitlab Issue' => 'Gitlab вопросы', 'Subtask Id' => 'Id подзадачи', 'Subtasks' => 'Подзадачи', 'Subtasks Export' => 'Экспортировать подзадачи', 'Subtasks exportation for "%s"' => 'Экспорт подзадач для "%s"', 'Task Title' => 'Загловок задачи', - 'Untitled' => 'Неозаглавленный', - // 'Application default' => '', + 'Untitled' => 'Заголовок отсутствует', + 'Application default' => 'Приложение по умолчанию', 'Language:' => 'Язык:', 'Timezone:' => 'Временная зона:', 'All columns' => 'Все колонки', @@ -653,14 +653,14 @@ return array( 'Next' => 'Следующий', // '#%d' => '', 'Filter by color' => 'Фильтрация по цвету', - 'Filter by swimlane' => 'Фильтрация по swimlane', - 'All swimlanes' => 'Все swimlanes', + 'Filter by swimlane' => 'Фильтрация по дорожкам', + 'All swimlanes' => 'Все дорожки', 'All colors' => 'Все цвета', 'All status' => 'Все статусы', 'Add a comment logging moving the task between columns' => 'Добавлять комментарий при движении задач между колонками', 'Moved to column %s' => 'Перемещена в колонку %s', 'Change description' => 'Изменить описание', - 'User dashboard' => 'Пользователь дашборда', + 'User dashboard' => 'Пользователь панели мониторинга', 'Allow only one subtask in progress at the same time for a user' => 'Разрешена только одна подзадача в разработке одновременно для одного пользователя', 'Edit column "%s"' => 'Редактировать колонку "%s"', 'Enable time tracking for subtasks' => 'Включить учет времени для подзадач', @@ -670,7 +670,7 @@ return array( 'Time Tracking' => 'Учет времени', 'You already have one subtask in progress' => 'У вас уже есть одна задача в разработке', 'Which parts of the project do you want to duplicate?' => 'Какие части проекта должны быть дублированы?', - 'Change dashboard view' => 'Изменить отображение дашборда', + 'Change dashboard view' => 'Изменить отображение панели мониторинга', 'Show/hide activities' => 'Показать/скрыть активность', 'Show/hide projects' => 'Показать/скрыть проекты', 'Show/hide subtasks' => 'Показать/скрыть подзадачи', @@ -679,8 +679,8 @@ return array( 'Show/hide calendar' => 'Показать/скрыть календарь', 'User calendar' => 'Пользовательский календарь', // 'Bitbucket commit received' => '', - // 'Bitbucket webhooks' => '', - // 'Help on Bitbucket webhooks' => '', + 'Bitbucket webhooks' => 'BitBucket webhooks', + 'Help on Bitbucket webhooks' => 'Помощь по BitBucket webhooks', 'Start' => 'Начало', 'End' => 'Конец', 'Task age in days' => 'Возраст задачи в днях', @@ -698,15 +698,15 @@ return array( 'Link modification' => 'Обновление ссылки', 'Links' => 'Ссылки', 'Link settings' => 'Настройки ссылки', - // 'Opposite label' => '', + '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.' => '', + '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' => 'заблокирована в', @@ -719,7 +719,7 @@ return array( 'fixes' => 'исправляет', 'is fixed by' => 'исправлено в', 'This task' => 'Эта задача', - // '<1h' => '', + '<1h' => '<1ч', // '%dh' => '', // '%b %e' => '', 'Expand tasks' => 'Развернуть задачи', @@ -738,98 +738,98 @@ return array( 'Horizontal scrolling' => 'Широкий вид', 'Compact/wide view' => 'Компактный/широкий вид', 'No results match:' => 'Отсутствуют результаты:', - // 'Remove hourly rate' => '', - // 'Do you really want to remove this hourly rate?' => '', - // 'Hourly rates' => '', - // 'Hourly rate' => '', - // 'Currency' => '', - // 'Effective date' => '', - // 'Add new rate' => '', - // 'Rate removed successfully.' => '', - // 'Unable to remove this rate.' => '', - // 'Unable to save the hourly rate.' => '', - // 'Hourly rate created successfully.' => '', + 'Remove hourly rate' => 'Удалить почасовую ставку', + 'Do you really want to remove this hourly rate?' => 'Вы действительно хотите удалить эту почасовую ставку?', + 'Hourly rates' => 'Почасовые ставки', + 'Hourly rate' => 'Почасовая ставка', + 'Currency' => 'Валюта', + 'Effective date' => 'Дата вступления в силу', + 'Add new rate' => 'Добавить новый показатель', + 'Rate removed successfully.' => 'Показатель успешно удален.', + 'Unable to remove this rate.' => 'Не удается удалить этот показатель.', + 'Unable to save the hourly rate.' => 'Не удается сохранить почасовую ставку.', + 'Hourly rate created successfully.' => 'Почасовая ставка успешно создана.', 'Start time' => 'Время начала', 'End time' => 'Время завершения', 'Comment' => 'Комментарий', 'All day' => 'Весь день', 'Day' => 'День', - // 'Manage timetable' => '', - // 'Overtime timetable' => '', - // 'Time off timetable' => '', - // 'Timetable' => '', - // 'Work timetable' => '', - // 'Week timetable' => '', - // 'Day timetable' => '', + 'Manage timetable' => 'Управление графиками', + 'Overtime timetable' => 'График сверхурочных', + 'Time off timetable' => 'Время в графике', + 'Timetable' => 'График', + 'Work timetable' => 'Work timetable', + 'Week timetable' => 'График на неделю', + 'Day timetable' => 'График на день', 'From' => 'От кого', 'To' => 'Кому', - // 'Time slot created successfully.' => '', - // 'Unable to save this time slot.' => '', - // 'Time slot removed successfully.' => '', - // 'Unable to remove this time slot.' => '', - // 'Do you really want to remove this time slot?' => '', - // 'Remove time slot' => '', - // 'Add new time slot' => '', + 'Time slot created successfully.' => 'Временной интервал успешно создан.', + 'Unable to save this time slot.' => 'Невозможно сохранить этот временной интервал.', + 'Time slot removed successfully.' => 'Временной интервал успешно удален.', + 'Unable to remove this time slot.' => 'Не удается удалить этот временной интервал.', + 'Do you really want to remove this time slot?' => 'Вы действительно хотите удалить этот период времени?', + 'Remove time slot' => 'Удалить новый интервал времени', + 'Add new time slot' => 'Добавить новый интервал времени', // 'This timetable is used when the checkbox "all day" is checked for scheduled time off and overtime.' => '', 'Files' => 'Файлы', 'Images' => 'Изображения', 'Private project' => 'Приватный проект', 'Amount' => 'Количество', - // 'AUD - Australian Dollar' => '', + 'AUD - Australian Dollar' => 'AUD - Австралийский доллар', 'Budget' => 'Бюджет', - // 'Budget line' => '', - // 'Budget line removed successfully.' => '', - // 'Budget lines' => '', - // 'CAD - Canadian Dollar' => '', - // 'CHF - Swiss Francs' => '', + 'Budget line' => 'Статья бюджета', + 'Budget line removed successfully.' => 'Бюджетная статья успешно удалена.', + 'Budget lines' => 'Статьи бюджета', + 'CAD - Canadian Dollar' => 'CAD - Канадский доллар', + 'CHF - Swiss Francs' => 'CHF - Швейцарский Франк', 'Cost' => 'Стоимость', - // 'Cost breakdown' => '', - // 'Custom Stylesheet' => '', + 'Cost breakdown' => 'Детализация затрат', + 'Custom Stylesheet' => 'Пользовательский стиль', 'download' => 'загрузить', - // 'Do you really want to remove this budget line?' => '', - // 'EUR - Euro' => '', + 'Do you really want to remove this budget line?' => 'Вы действительно хотите удалить эту статью бюджета?', + 'EUR - Euro' => 'EUR - Евро', 'Expenses' => 'Расходы', - // 'GBP - British Pound' => '', - // 'INR - Indian Rupee' => '', - // 'JPY - Japanese Yen' => '', - // 'New budget line' => '', - // 'NZD - New Zealand Dollar' => '', - // 'Remove a budget line' => '', - // 'Remove budget line' => '', - // 'RSD - Serbian dinar' => '', - // 'The budget line have been created successfully.' => '', - // 'Unable to create the budget line.' => '', - // 'Unable to remove this budget line.' => '', - // 'USD - US Dollar' => '', + 'GBP - British Pound' => 'GBP - Британский фунт', + 'INR - Indian Rupee' => 'INR - Индийский рупий', + 'JPY - Japanese Yen' => 'JPY - Японскай йена', + 'New budget line' => 'Новая статья бюджета', + 'NZD - New Zealand Dollar' => 'NZD - Новозеландский доллар', + 'Remove a budget line' => 'Удалить строку в бюджете', + 'Remove budget line' => 'Удалить статью бюджета', + 'RSD - Serbian dinar' => 'RSD - Сербский динар', + 'The budget line have been created successfully.' => 'Статья бюджета успешно создана.', + 'Unable to create the budget line.' => 'Не удается создать эту статью бюджета.', + 'Unable to remove this budget line.' => 'Не удается удалить эту статью бюджета.', + 'USD - US Dollar' => 'USD - доллар США', 'Remaining' => 'Прочее', - // 'Destination column' => '', - // 'Move the task to another column when assigned to a user' => '', - // 'Move the task to another column when assignee is cleared' => '', - // 'Source column' => '', - // 'Show subtask estimates (forecast of future work)' => '', + 'Destination column' => 'Колонка назначения', + 'Move the task to another column when assigned to a user' => 'Переместить задачу в другую колонку, когда она назначена пользователю', + 'Move the task to another column when assignee is cleared' => 'Переместить задачу в другую колонку, когда назначение снято ', + 'Source column' => 'Исходная колонка', + 'Show subtask estimates (forecast of future work)' => 'Показать оценку подзадач (прогноз будущей работы)', 'Transitions' => 'Перемещения', 'Executer' => 'Исполнитель', 'Time spent in the column' => 'Время проведенное в колонке', 'Task transitions' => 'Перемещения задач', - // 'Task transitions export' => '', - // 'This report contains all column moves for each task with the date, the user and the time spent for each transition.' => '', - // 'Currency rates' => '', - // 'Rate' => '', - // 'Change reference currency' => '', - // 'Add a new currency rate' => '', - // 'Currency rates are used to calculate project budget.' => '', - // 'Reference currency' => '', - // 'The currency rate have been added successfully.' => '', - // 'Unable to add this currency rate.' => '', - // 'Send notifications to a Slack channel' => '', - // 'Webhook URL' => '', - // 'Help on Slack integration' => '', - // '%s remove the assignee of the task %s' => '', - // 'Send notifications to Hipchat' => '', - // 'API URL' => '', - // 'Room API ID or name' => '', - // 'Room notification token' => '', - // 'Help on Hipchat integration' => '', + 'Task transitions export' => 'Экспорт перемещений задач', + 'This report contains all column moves for each task with the date, the user and the time spent for each transition.' => 'Этот отчет содержит все перемещения задач в колонках с датой, пользователем и времени, затраченным для каждого перемещения.', + 'Currency rates' => 'Курсы валют', + 'Rate' => 'Курс', + 'Change reference currency' => 'Изменить справочник валют', + 'Add a new currency rate' => 'Add a new currency rate', + 'Currency rates are used to calculate project budget.' => 'Курсы валют используются для расчета бюджета проекта.', + 'Reference currency' => 'Справочник валют', + 'The currency rate have been added successfully.' => 'Курс валюты был успешно добавлен.', + 'Unable to add this currency rate.' => 'Невозможно добавить этот курс валюты.', + 'Send notifications to a Slack channel' => 'Отправлять уведомления в канал Slack', + 'Webhook URL' => 'Webhook URL', + 'Help on Slack integration' => 'Помощь по интеграции Slack', + '%s remove the assignee of the task %s' => '%s удалить назначенную задачу %s', + 'Send notifications to Hipchat' => 'Отправлять уведомления в Hipchat', + 'API URL' => 'API URL', + 'Room API ID or name' => 'API ID комнаты или имя', + 'Room notification token' => 'Ключь комнаты для уведомлений', + 'Help on Hipchat integration' => 'Помощь по интеграции Hipchat', 'Enable Gravatar images' => 'Включить Gravatar изображения', 'Information' => 'Информация', 'Check two factor authentication code' => 'Проверка кода двухфакторной авторизации', @@ -838,88 +838,88 @@ return array( 'Code' => 'Код', 'Two factor authentication' => 'Двухфакторная авторизация', 'Enable/disable two factor authentication' => 'Включить/выключить двухфакторную авторизацию', - // 'This QR code contains the key URI: ' => '', - // 'Save the secret key in your TOTP software (by example Google Authenticator or FreeOTP).' => '', - // 'Check my code' => '', - // 'Secret key: ' => '', - // 'Test your device' => '', - // 'Assign a color when the task is moved to a specific column' => '', + 'This QR code contains the key URI: ' => 'Это QR-код содержит ключевую URI:', + 'Save the secret key in your TOTP software (by example Google Authenticator or FreeOTP).' => 'Сохраните Ваш секретный ключ в TOTP программе (например Google Autentificator или FreeOTP).', + 'Check my code' => 'Проверить мой код', + 'Secret key: ' => 'Секретный ключ: ', + 'Test your device' => 'Проверьте свое устройство', + 'Assign a color when the task is moved to a specific column' => 'Назначить цвет, когда задача перемещается в определенную колонку', '%s via Kanboard' => '%s через Канборд', // 'uploaded by: %s' => '', // 'uploaded on: %s' => '', 'size: %s' => 'размер: %s', - // 'Burndown chart for "%s"' => '', - // 'Burndown chart' => '', - // 'This chart show the task complexity over the time (Work Remaining).' => '', - // 'Screenshot taken %s' => '', + 'Burndown chart for "%s"' => 'Диаграмма сгорания для', + 'Burndown chart' => 'Диаграмма сгорания', + 'This chart show the task complexity over the time (Work Remaining).' => 'Эта диаграмма показывают сложность задачи по времени (оставшейся работы).', + 'Screenshot taken %s' => 'Принято скриншотов', 'Add a screenshot' => 'Прикрепить картинку', - // 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '', - // 'Screenshot uploaded successfully.' => '', - // 'SEK - Swedish Krona' => '', + 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => 'Сделайте скриншот и нажмите CTRL+V или ⌘+V для вложения', + 'Screenshot uploaded successfully.' => 'Скриншет успешно загружен', + 'SEK - Swedish Krona' => 'SEK - Шведская крона', 'The project identifier is an optional alphanumeric code used to identify your project.' => 'Идентификатор проекта - это опциональный буквенно-цифровой код использующийся для идентификации проекта', 'Identifier' => 'Идентификатор', - // 'Postmark (incoming emails)' => '', - // 'Help on Postmark integration' => '', - // 'Mailgun (incoming emails)' => '', - // 'Help on Mailgun integration' => '', - // 'Sendgrid (incoming emails)' => '', - // 'Help on Sendgrid integration' => '', + 'Postmark (incoming emails)' => 'Postmark (входящие сообщения)', + 'Help on Postmark integration' => 'Справка о Postmark интеграции', + 'Mailgun (incoming emails)' => 'Mailgun (входящие сообщения)', + 'Help on Mailgun integration' => 'Справка о Mailgun интеграции', + 'Sendgrid (incoming emails)' => 'Sendgrid (входящие сообщения)', + 'Help on Sendgrid integration' => 'Справка о Sendgrid интеграции', 'Disable two factor authentication' => 'Выключить двухфакторную авторизацию', 'Do you really want to disable the two factor authentication for this user: "%s"?' => 'Вы действительно хотите выключить двухфакторную авторизацию для пользователя "%s"?', - // 'Edit link' => '', - // 'Start to type task title...' => '', - // 'A task cannot be linked to itself' => '', - // 'The exact same link already exists' => '', - // 'Recurrent task is scheduled to be generated' => '', - // 'Recurring information' => '', - // 'Score' => '', - // 'The identifier must be unique' => '', - // 'This linked task id doesn\'t exists' => '', - // 'This value must be alphanumeric' => '', - // 'Edit recurrence' => '', - // 'Generate recurrent task' => '', - // 'Trigger to generate recurrent task' => '', - // 'Factor to calculate new due date' => '', - // 'Timeframe to calculate new due date' => '', - // 'Base date to calculate new due date' => '', - // 'Action date' => '', - // 'Base date to calculate new due date: ' => '', - // 'This task has created this child task: ' => '', - // 'Day(s)' => '', - // 'Existing due date' => '', - // 'Factor to calculate new due date: ' => '', - // 'Month(s)' => '', - // 'Recurrence' => '', - // 'This task has been created by: ' => '', - // 'Recurrent task has been generated:' => '', - // 'Timeframe to calculate new due date: ' => '', - // 'Trigger to generate recurrent task: ' => '', - // 'When task is closed' => '', - // 'When task is moved from first column' => '', - // 'When task is moved to last column' => '', - // 'Year(s)' => '', - // 'Jabber (XMPP)' => '', - // 'Send notifications to Jabber' => '', - // 'XMPP server address' => '', - // 'Jabber domain' => '', - // 'Jabber nickname' => '', - // 'Multi-user chat room' => '', - // 'Help on Jabber integration' => '', - // 'The server address must use this format: "tcp://hostname:5222"' => '', - // 'Calendar settings' => '', - // 'Project calendar view' => '', - // 'Project settings' => '', - // 'Show subtasks based on the time tracking' => '', - // 'Show tasks based on the creation date' => '', - // 'Show tasks based on the start date' => '', - // 'Subtasks time tracking' => '', - // 'User calendar view' => '', - // 'Automatically update the start date' => '', - // 'iCal feed' => '', - // 'Preferences' => '', - // 'Security' => '', - // 'Two factor authentication disabled' => '', - // 'Two factor authentication enabled' => '', - // 'Unable to update this user.' => '', - // 'There is no user management for private projects.' => '', + 'Edit link' => 'Редактировать ссылку', + 'Start to type task title...' => 'Начните вводить название задачи...', + 'A task cannot be linked to itself' => 'Задача не может быть связана с собой же', + 'The exact same link already exists' => 'Такая ссылка уже существует', + 'Recurrent task is scheduled to be generated' => 'Периодическая задача запланирована к созданию', + 'Recurring information' => 'Информация о периодичности', + 'Score' => 'Оценка', + 'The identifier must be unique' => 'Идентификатор должен быть уникальным', + 'This linked task id doesn\'t exists' => 'Этот ID звязанной задачи не существует', + 'This value must be alphanumeric' => 'Это значение должно быть буквенно-цифровым', + 'Edit recurrence' => 'Завершить повторение', + 'Generate recurrent task' => 'Создать повторяющуюся задачу', + 'Trigger to generate recurrent task' => 'Триггер для генерации периодической задачи', + 'Factor to calculate new due date' => 'Коэффициент для рассчета новой даты', + 'Timeframe to calculate new due date' => 'Вычисление для рассчета новой даты', + 'Base date to calculate new due date' => 'Базовая дата вычисления новой даты', + 'Action date' => 'Дата действия', + 'Base date to calculate new due date: ' => 'Базовая дата вычисления новой даты: ', + 'This task has created this child task: ' => 'Эта задача создала эту дочернюю задачу:', + 'Day(s)' => 'День(й)', + 'Existing due date' => 'Существующий срок', + 'Factor to calculate new due date: ' => 'Коэффициент для рассчета новой даты: ', + 'Month(s)' => 'Месяц(а)', + 'Recurrence' => 'Повторение', + 'This task has been created by: ' => 'Эта задача была создана: ', + 'Recurrent task has been generated:' => 'Периодическая задача была сформирована:', + 'Timeframe to calculate new due date: ' => 'Вычисление для рассчета новой даты: ', + 'Trigger to generate recurrent task: ' => 'Триггер для генерации периодической задачи: ', + 'When task is closed' => 'Когда задача закрывается', + 'When task is moved from first column' => 'Когда задача перемещается из первой колонки', + 'When task is moved to last column' => 'Когда задача перемещается в последнюю колонку', + 'Year(s)' => 'Год(а)', + 'Jabber (XMPP)' => 'Jabber (XMPP)', + 'Send notifications to Jabber' => 'Отправлять уведомления в Jabber', + 'XMPP server address' => 'Адрес Jabber сервера', + 'Jabber domain' => 'Домен Jabber', + 'Jabber nickname' => 'Имя пользователя Jabber', + 'Multi-user chat room' => 'Многопользовательский чат', + 'Help on Jabber integration' => 'Помощь по интеграции Jabber', + 'The server address must use this format: "tcp://hostname:5222"' => 'Адрес сервера должен быть в формате: tcp://hostname:5222', + 'Calendar settings' => 'Настройки календаря', + 'Project calendar view' => 'Вид календаря проекта', + 'Project settings' => 'Настройки проекта', + 'Show subtasks based on the time tracking' => 'Показать подзадачи, основанные на отслеживании времени', + 'Show tasks based on the creation date' => 'Показать задачи в зависимости от даты создания', + 'Show tasks based on the start date' => 'Показать задачи в зависимости от даты начала', + 'Subtasks time tracking' => 'Отслеживание времени подзадач', + 'User calendar view' => 'Просмотреть календарь пользователя', + 'Automatically update the start date' => 'Автоматическое обновление даты начала', + 'iCal feed' => 'iCal данные', + 'Preferences' => 'Предпочтения', + 'Security' => 'Безопастность', + 'Two factor authentication disabled' => 'Двухфакторная аутентификация отключена', + 'Two factor authentication enabled' => 'Включена двухфакторная аутентификация', + 'Unable to update this user.' => 'Не удается обновить этого пользователя.', + 'There is no user management for private projects.' => 'Там нет управления пользователя для частных проектов', ); diff --git a/app/Locale/th_TH/translations.php b/app/Locale/th_TH/translations.php index 0c5a06dd..8e4781d1 100644 --- a/app/Locale/th_TH/translations.php +++ b/app/Locale/th_TH/translations.php @@ -189,7 +189,7 @@ return array( 'Complexity' => 'ความซับซ้อน', 'limit' => 'จำกัด', 'Task limit' => 'จำกัดงาน', - // 'Task count' => '', + 'Task count' => 'นับงาน', 'This value must be greater than %d' => 'ค่าต้องมากกว่า %d', 'Edit project access list' => 'แก้ไขการเข้าถึงรายชื่อโปรเจค', 'Edit users access' => 'แก้ไขการเข้าถึงผู้ใช้', @@ -355,7 +355,7 @@ return array( 'Add a sub-task' => 'เพิ่มงานย่อย', // 'Original estimate' => '', 'Create another sub-task' => 'สร้างงานย่อยอื่น', - // 'Time spent' => '', + 'Time spent' => 'ใช้เวลา', 'Edit a sub-task' => 'แก้ไขงานย่อย', 'Remove a sub-task' => 'ลบงานย่อย', 'The time must be a numeric value' => 'เวลาที่ต้องเป็นตัวเลข', @@ -390,7 +390,7 @@ return array( 'Modification date' => 'วันที่แก้ไข', 'Completion date' => 'วันที่เสร็จสิ้น', 'Clone' => 'เลียนแบบ', - // 'Clone Project' => '', + 'Clone Project' => 'เลียนแบบโปรเจค', 'Project cloned successfully.' => 'เลียนแบบโปรเจคเรียบร้อยแล้ว', 'Unable to clone this project.' => 'ไม่สามารถเลียบแบบโปรเจคได้', 'Email notifications' => 'อีเมลแจ้งเตือน', @@ -408,20 +408,20 @@ return array( 'Comment updated' => 'ปรับปรุงความคิดเห็น', 'New comment posted by %s' => 'ความคิดเห็นใหม่จาก %s', 'List of due tasks for the project "%s"' => 'รายการงานสำหรับโปรเจค "%s"', - // 'New attachment' => '', - // 'New comment' => '', - // 'New subtask' => '', - // 'Subtask updated' => '', - // 'Task updated' => '', - // 'Task closed' => '', - // 'Task opened' => '', + 'New attachment' => 'การแนบใหม่', + 'New comment' => 'ความคิดเห็นใหม่', + 'New subtask' => 'งานย่อยใหม่', + 'Subtask updated' => 'ปรับปรุงงานย่อยแล้ว', + 'Task updated' => 'ปรับปรุงงานแล้ว', + 'Task closed' => 'ปิดงาน', + 'Task opened' => 'เปิดงาน', '[%s][Due tasks]' => '[%s][งานปัจจุบัน]', '[Kanboard] Notification' => '[Kanboard] แจ้งเตือน', 'I want to receive notifications only for those projects:' => 'ฉันต้องการรับการแจ้งเตือนสำหรับโปรเจค:', 'view the task on Kanboard' => 'แสดงงานบน Kanboard', 'Public access' => 'การเข้าถึงสาธารณะ', - // 'Category management' => '', - // 'User management' => '', + 'Category management' => 'การจัดการกลุ่ม', + 'User management' => 'การจัดการผู้ใช้', 'Active tasks' => 'งานที่กำลังใช้งาน', 'Disable public access' => 'ปิดการเข้าถึงสาธารณะ', 'Enable public access' => 'เปิดการเข้าถึงสาธารณะ', @@ -498,11 +498,11 @@ return array( 'Task assignee change' => 'เปลี่ยนการกำหนดบุคคลของงาน', // '%s change the assignee of the task #%d to %s' => '', // '%s changed the assignee of the task %s to %s' => '', - // 'Column Change' => '', - // 'Position Change' => '', - // 'Assignee Change' => '', + 'Column Change' => 'เปลี่ยนคอลัมน์', + 'Position Change' => 'เปลี่ยนตำแหน่ง', + 'Assignee Change' => 'เปลิ่ยนการกำหนด', 'New password for the user "%s"' => 'รหัสผ่านใหม่สำหรับผู้ใช้ "%s"', - // 'Choose an event' => '', + 'Choose an event' => 'เลือกเหตุการณ์', // 'Github commit received' => '', // 'Github issue opened' => '', // 'Github issue closed' => '', @@ -514,314 +514,314 @@ return array( // 'Change the category based on an external label' => '', // 'Reference' => '', // 'Reference: %s' => '', - // 'Label' => '', - // 'Database' => '', - // 'About' => '', - // 'Database driver:' => '', - // 'Board settings' => '', + 'Label' => 'ป้ายชื่อ', + 'Database' => 'ฐานข้อมูล', + 'About' => 'เกี่ยวกับ', + 'Database driver:' => 'เครื่องมือฐานขข้อมูล', + 'Board settings' => 'ตั้งค่าบอร์ด', // 'URL and token' => '', // 'Webhook settings' => '', // 'URL for task creation:' => '', // 'Reset token' => '', // 'API endpoint:' => '', - // 'Refresh interval for private board' => '', - // 'Refresh interval for public board' => '', - // 'Task highlight period' => '', - // 'Period (in second) to consider a task was modified recently (0 to disable, 2 days by default)' => '', - // 'Frequency in second (60 seconds by default)' => '', - // 'Frequency in second (0 to disable this feature, 10 seconds by default)' => '', + 'Refresh interval for private board' => 'ระยะรีเฟรชบอร์ดส่วนตัว', + 'Refresh interval for public board' => 'ระยะรีเฟรชบอร์ดสาธารณะ', + 'Task highlight period' => 'ช่วงเวลาไฮไลต์งาน', + 'Period (in second) to consider a task was modified recently (0 to disable, 2 days by default)' => 'ช่วงเวลา (เป็นวินาที) ใช้ในการตัดสินใจว่าเป็นการแก้ไขเร็วๆ นี้ (0 ไม่ใช้งาน, ค่าเริ่มต้น 2 วัน)', + 'Frequency in second (60 seconds by default)' => 'ความถี่ (ค่าเริ่มต้นทุก 60 วินาที) ', + 'Frequency in second (0 to disable this feature, 10 seconds by default)' => 'ความถี่ (0 ไม่ใช้คุณลักษณะนี้, ค่าเริ่มต้นทุก 10 วินาที)', // 'Application URL' => '', - // 'Example: http://example.kanboard.net/ (used by email notifications)' => '', + 'Example: http://example.kanboard.net/ (used by email notifications)' => 'ตัวอย่าง: http://example.kanboard.net/ (ถูกใช้ในการแจ้งเตือนทางอีเมล์)', // 'Token regenerated.' => '', - // 'Date format' => '', + 'Date format' => 'รูปแบบวันที่', // 'ISO format is always accepted, example: "%s" and "%s"' => '', - // 'New private project' => '', - // 'This project is private' => '', + 'New private project' => 'เพิ่มโปรเจคส่วนตัวใหม่', + 'This project is private' => 'โปรเจคนี้เป็นโปรเจคส่วนตัว', // 'Type here to create a new sub-task' => '', - // 'Add' => '', - // 'Estimated time: %s hours' => '', - // 'Time spent: %s hours' => '', - // 'Started on %B %e, %Y' => '', - // 'Start date' => '', - // 'Time estimated' => '', - // 'There is nothing assigned to you.' => '', - // 'My tasks' => '', - // 'Activity stream' => '', - // 'Dashboard' => '', + 'Add' => 'เพิ่ม', + 'Estimated time: %s hours' => 'เวลาเฉลี่ย: %s ชั่วโมง', + 'Time spent: %s hours' => 'ใช้เวลาไป %s ชม.', + 'Started on %B %e, %Y' => 'เริ่ม %B %e, %Y', + 'Start date' => 'เริ่มวันที่', + 'Time estimated' => 'เวลาโดยประมาณ', + 'There is nothing assigned to you.' => 'ไม่มีอะไรกำหนดให้คุณ', + 'My tasks' => 'งานของฉัน', + 'Activity stream' => 'กิจกรรมที่เกิดขึ้น', + 'Dashboard' => 'แดชบอร์ด', 'Confirmation' => 'ยืนยันรหัสผ่าน', - // 'Allow everybody to access to this project' => '', + 'Allow everybody to access to this project' => 'อนุญาตให้ทุกคนเข้าถึงโปรเจคนี้', 'Everybody have access to this project.' => 'ทุกคนสามารถเข้าถึงโปรเจคนี้', // 'Webhooks' => '', // 'API' => '', - // 'Integration' => '', + 'Integration' => 'การใช้งานร่วมกัน', // 'Github webhooks' => '', // 'Help on Github webhooks' => '', // 'Create a comment from an external provider' => '', // 'Github issue comment created' => '', - // 'Configure' => '', - // 'Project management' => '', - // 'My projects' => '', - // 'Columns' => '', - // 'Task' => '', - // 'Your are not member of any project.' => '', - // 'Percentage' => '', - // 'Number of tasks' => '', - // 'Task distribution' => '', - // 'Reportings' => '', + 'Configure' => 'การตั้งค่า', + 'Project management' => 'การจัดการโปรเจค', + 'My projects' => 'โปรเจคของฉัน', + 'Columns' => 'คอลัมน์', + 'Task' => 'งาน', + 'Your are not member of any project.' => 'คุณไม่ได้เป็นสมาชิกของโปรเจค', + 'Percentage' => 'เปอร์เซ็นต์', + 'Number of tasks' => 'จำนวนงาน', + 'Task distribution' => 'การกระจายงาน', + 'Reportings' => 'รายงาน', // 'Task repartition for "%s"' => '', - // 'Analytics' => '', - // 'Subtask' => '', - // 'My subtasks' => '', - // 'User repartition' => '', - // 'User repartition for "%s"' => '', - // 'Clone this project' => '', - // 'Column removed successfully.' => '', - // 'Edit Project' => '', + 'Analytics' => 'การวิเคราะห์', + 'Subtask' => 'งานย่อย', + 'My subtasks' => 'งานย่อยของฉัน', + 'User repartition' => 'การแบ่งงานของผู้ใช้', + 'User repartition for "%s"' => 'การแบ่งงานของผู้ใช้ "%s"', + 'Clone this project' => 'เลียนแบบโปรเจคนี้', + 'Column removed successfully.' => 'ลบคอลัมน์สำเร็จ', + 'Edit Project' => 'แก้ไขโปรเจค', // 'Github Issue' => '', - // 'Not enough data to show the graph.' => '', - // 'Previous' => '', - // 'The id must be an integer' => '', - // 'The project id must be an integer' => '', - // 'The status must be an integer' => '', - // 'The subtask id is required' => '', - // 'The subtask id must be an integer' => '', - // 'The task id is required' => '', - // 'The task id must be an integer' => '', - // 'The user id must be an integer' => '', - // 'This value is required' => '', - // 'This value must be numeric' => '', - // 'Unable to create this task.' => '', - // 'Cumulative flow diagram' => '', - // 'Cumulative flow diagram for "%s"' => '', - // 'Daily project summary' => '', - // 'Daily project summary export' => '', - // 'Daily project summary export for "%s"' => '', - // 'Exports' => '', - // 'This export contains the number of tasks per column grouped per day.' => '', - // 'Nothing to preview...' => '', - // 'Preview' => '', - // 'Write' => '', - // 'Active swimlanes' => '', - // 'Add a new swimlane' => '', - // 'Change default swimlane' => '', - // 'Default swimlane' => '', - // 'Do you really want to remove this swimlane: "%s"?' => '', - // 'Inactive swimlanes' => '', - // 'Set project manager' => '', - // 'Set project member' => '', - // 'Remove a swimlane' => '', - // 'Rename' => '', - // 'Show default swimlane' => '', - // 'Swimlane modification for the project "%s"' => '', - // 'Swimlane not found.' => '', - // 'Swimlane removed successfully.' => '', - // 'Swimlanes' => '', - // 'Swimlane updated successfully.' => '', - // 'The default swimlane have been updated successfully.' => '', - // 'Unable to create your swimlane.' => '', - // 'Unable to remove this swimlane.' => '', - // 'Unable to update this swimlane.' => '', - // 'Your swimlane have been created successfully.' => '', - // 'Example: "Bug, Feature Request, Improvement"' => '', - // 'Default categories for new projects (Comma-separated)' => '', + 'Not enough data to show the graph.' => 'ไม่มีข้อมูลแสดงเป็นกราฟ', + 'Previous' => 'ก่อนหน้า', + 'The id must be an integer' => 'ไอดีต้องเป็นตัวเลขจำนวนเต็ม', + 'The project id must be an integer' => 'ไอดีโปรเจคต้องเป็นตัวเลข', + 'The status must be an integer' => 'สถานะต้องเป็นตัวเลข', + 'The subtask id is required' => 'ต้องการงานย่อย', + 'The subtask id must be an integer' => 'ไอดีงานย่อยต้องเป็นตัวเลข', + 'The task id is required' => 'ต้องการไอดีงาน', + 'The task id must be an integer' => 'ไอดีงานต้องเป็นตัวเลข', + 'The user id must be an integer' => 'ไอดีผู้ใช้ต้องเป็นตัวเลข', + 'This value is required' => 'ต้องการค่านี้', + 'This value must be numeric' => 'ค่านี้ต้องเป็นตัวเลข', + 'Unable to create this task.' => 'ไม่สามารถสร้างงานนี้', + 'Cumulative flow diagram' => 'แผนภาพงานสะสม', + 'Cumulative flow diagram for "%s"' => 'แผนภาพงานสะสม "%s"', + 'Daily project summary' => 'สรุปโปรเจครายวัน', + 'Daily project summary export' => 'ส่งออกสรุปโปรเจครายวัน', + 'Daily project summary export for "%s"' => 'ส่งออกสรุปโปรเจครายวันสำหรับ "%s"', + 'Exports' => 'ส่งออก', + 'This export contains the number of tasks per column grouped per day.' => 'การส่งออกนี้เป็นการนับจำนวนงานในแต่ละคอลัมน์ในแต่ละวัน', + 'Nothing to preview...' => 'ไม่มีพรีวิว...', + 'Preview' => 'พรีวิว', + 'Write' => 'เขียน', + 'Active swimlanes' => 'สวิมเลนพร้อมใช้งาน', + 'Add a new swimlane' => 'เพิ่มสวิมเลนใหม่', + 'Change default swimlane' => 'เปลี่ยนสวิมเลนเริ่มต้น', + 'Default swimlane' => 'สวิมเลนเริ่มต้น', + 'Do you really want to remove this swimlane: "%s"?' => 'คุณต้องการลบสวิมเลนนี้ : "%s"?', + 'Inactive swimlanes' => 'สวิมเลนไม่ทำงาน', + 'Set project manager' => 'กำหนดผู้จัดการโปรเจค', + 'Set project member' => 'กำหนดสมาชิกโปรเจค', + 'Remove a swimlane' => 'ลบสวิมเลน', + 'Rename' => 'เปลี่ยนชื่อ', + 'Show default swimlane' => 'แสดงสวิมเลนเริ่มต้น', + 'Swimlane modification for the project "%s"' => 'แก้ไขสวิมเลนสำหรับโปรเจค "%s"', + 'Swimlane not found.' => 'หาสวิมเลนไม่พบ', + 'Swimlane removed successfully.' => 'ลบสวิมเลนเรียบร้อยแล้ว', + 'Swimlanes' => 'สวิมเลน', + 'Swimlane updated successfully.' => 'ปรับปรุงสวิมเลนเรียบร้อยแล้ว', + 'The default swimlane have been updated successfully.' => 'สวิมเลนเริ่มต้นปรับปรุงเรียบร้อยแล้ว', + 'Unable to create your swimlane.' => 'ไม่สามารถสร้างสวิมเลนของคุณได้', + 'Unable to remove this swimlane.' => 'ไม่สามารถลบสวิมเลนนี้', + 'Unable to update this swimlane.' => 'ไม่สามารถปรับปรุงสวิมเลนนี้', + 'Your swimlane have been created successfully.' => 'สวิมเลนของคุณถูกสร้างเรียบร้อยแล้ว', + 'Example: "Bug, Feature Request, Improvement"' => 'ตัวอย่าง: "Bug, Feature Request, Improvement"', + 'Default categories for new projects (Comma-separated)' => 'ค่าเริ่มต้นกลุ่มสำหรับโปรเจคใหม่ (Comma-separated)', // 'Gitlab commit received' => '', // 'Gitlab issue opened' => '', // 'Gitlab issue closed' => '', // 'Gitlab webhooks' => '', // 'Help on Gitlab webhooks' => '', - // 'Integrations' => '', - // 'Integration with third-party services' => '', + 'Integrations' => 'การใช้ร่วมกัน', + 'Integration with third-party services' => 'การใช้งานร่วมกับบริการ third-party', // 'Role for this project' => '', - // 'Project manager' => '', - // 'Project member' => '', - // 'A project manager can change the settings of the project and have more privileges than a standard user.' => '', + 'Project manager' => 'ผู้จัดการโปรเจค', + 'Project member' => 'สมาชิกโปรเจค', + 'A project manager can change the settings of the project and have more privileges than a standard user.' => 'ผู้จัดการโปรเจคสามารถตั้งค่าของโปรเจคและมีสิทธิ์มากกว่าผู้ใช้ทั่วไป', // 'Gitlab Issue' => '', - // 'Subtask Id' => '', - // 'Subtasks' => '', - // 'Subtasks Export' => '', - // 'Subtasks exportation for "%s"' => '', - // 'Task Title' => '', - // 'Untitled' => '', - // 'Application default' => '', - // 'Language:' => '', - // 'Timezone:' => '', - // 'All columns' => '', - // 'Calendar for "%s"' => '', - // 'Filter by column' => '', - // 'Filter by status' => '', - // 'Calendar' => '', - // 'Next' => '', + 'Subtask Id' => 'รหัสงานย่อย', + 'Subtasks' => 'งานย่อย', + 'Subtasks Export' => 'ส่งออก งานย่อย', + 'Subtasks exportation for "%s"' => 'ส่งออกงานย่อยสำหรับ "%s"', + 'Task Title' => 'ชื่องาน', + 'Untitled' => 'ไม่มีชื่อ', + 'Application default' => 'แอพพลิเคชันเริ่มต้น', + 'Language:' => 'ภาษา:', + 'Timezone:' => 'เขตเวลา:', + 'All columns' => 'คอลัมน์ทั้งหมด', + 'Calendar for "%s"' => 'ปฏิทินสำหรับ "%s"', + 'Filter by column' => 'กรองโดยคอลัมน์', + 'Filter by status' => 'กรองโดยสถานะ', + 'Calendar' => 'ปฏิทิน', + 'Next' => 'ต่อไป', // '#%d' => '', - // 'Filter by color' => '', - // 'Filter by swimlane' => '', - // 'All swimlanes' => '', - // 'All colors' => '', - // 'All status' => '', - // 'Add a comment logging moving the task between columns' => '', - // 'Moved to column %s' => '', - // 'Change description' => '', - // 'User dashboard' => '', - // 'Allow only one subtask in progress at the same time for a user' => '', - // 'Edit column "%s"' => '', - // 'Enable time tracking for subtasks' => '', - // 'Select the new status of the subtask: "%s"' => '', - // 'Subtask timesheet' => '', - // 'There is nothing to show.' => '', - // 'Time Tracking' => '', - // 'You already have one subtask in progress' => '', + 'Filter by color' => 'กรองโดยสี', + 'Filter by swimlane' => 'กรองโดยสวิมเลน', + 'All swimlanes' => 'สวิมเลนทั้งหมด', + 'All colors' => 'สีทั้งหมด', + 'All status' => 'สถานะทั้งหมด', + 'Add a comment logging moving the task between columns' => 'เพิ่มความคิดเห็นที่เป็น log เมื่อเปลี่ยนคอลัมน์', + 'Moved to column %s' => 'เคลื่อนไปคอลัมน์ %s', + 'Change description' => 'เปลี่ยนคำอธิบาย', + 'User dashboard' => 'ผู้ใช้แดชบอร์ด', + 'Allow only one subtask in progress at the same time for a user' => 'อนุญาตให้ทำงานย่อยได้เพียงงานเดียวต่อหนึ่งคนในเวลาเดียวกัน', + 'Edit column "%s"' => 'แก้ไขคอลัมน์ "%s"', + 'Enable time tracking for subtasks' => 'สามารถติดตามเวลาของงานย่อย', + 'Select the new status of the subtask: "%s"' => 'เลือกสถานะใหม่ของงานย่อย', + 'Subtask timesheet' => 'เวลางานย่อย', + 'There is nothing to show.' => 'ไม่มีที่ต้องแสดง', + 'Time Tracking' => 'ติดตามเวลา', + 'You already have one subtask in progress' => 'คุณมีหนึ่งงานย่อยที่กำลังทำงาน', // 'Which parts of the project do you want to duplicate?' => '', - // 'Change dashboard view' => '', - // 'Show/hide activities' => '', - // 'Show/hide projects' => '', - // 'Show/hide subtasks' => '', - // 'Show/hide tasks' => '', + 'Change dashboard view' => 'เปลี่ยนมุมมองแดชบอร์ด', + 'Show/hide activities' => 'แสดง/ซ่อน กิจกรรม', + 'Show/hide projects' => 'แสดง/ซ่อน โปรเจค', + 'Show/hide subtasks' => 'แสดง/ซ่อน งานย่อย', + 'Show/hide tasks' => 'แสดง/ซ่อน งาน', // 'Disable login form' => '', - // 'Show/hide calendar' => '', - // 'User calendar' => '', + 'Show/hide calendar' => 'แสดง/ซ่อน ปฎิทิน', + 'User calendar' => 'ปฏิทินผู้ใช้', // 'Bitbucket commit received' => '', // 'Bitbucket webhooks' => '', // 'Help on Bitbucket webhooks' => '', - // 'Start' => '', - // 'End' => '', - // 'Task age in days' => '', - // 'Days in this column' => '', + 'Start' => 'เริ่ม', + 'End' => 'จบ', + '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"?' => '', + 'Add a link' => 'เพิ่มลิงค์', + 'Add a new link' => 'เพิ่มลิงค์ใหม่', + 'Do you really want to remove this link: "%s"?' => 'คุณต้องการลบลิงค์นี้: "%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' => '', + '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' => 'งานนี้', // '<1h' => '', // '%dh' => '', // '%b %e' => '', - // 'Expand tasks' => '', - // 'Collapse tasks' => '', - // 'Expand/collapse tasks' => '', - // 'Close dialog box' => '', - // 'Submit a form' => '', - // 'Board view' => '', - // 'Keyboard shortcuts' => '', - // 'Open board switcher' => '', - // 'Application' => '', - // 'Filter recently updated' => '', - // 'since %B %e, %Y at %k:%M %p' => '', - // 'More filters' => '', - // 'Compact view' => '', - // 'Horizontal scrolling' => '', - // 'Compact/wide view' => '', - // 'No results match:' => '', - // 'Remove hourly rate' => '', - // 'Do you really want to remove this hourly rate?' => '', - // 'Hourly rates' => '', - // 'Hourly rate' => '', - // 'Currency' => '', - // 'Effective date' => '', - // 'Add new rate' => '', - // 'Rate removed successfully.' => '', - // 'Unable to remove this rate.' => '', - // 'Unable to save the hourly rate.' => '', - // 'Hourly rate created successfully.' => '', - // 'Start time' => '', - // 'End time' => '', - // 'Comment' => '', - // 'All day' => '', - // 'Day' => '', - // 'Manage timetable' => '', - // 'Overtime timetable' => '', - // 'Time off timetable' => '', - // 'Timetable' => '', - // 'Work timetable' => '', - // 'Week timetable' => '', - // 'Day timetable' => '', - // 'From' => '', - // 'To' => '', - // 'Time slot created successfully.' => '', - // 'Unable to save this time slot.' => '', - // 'Time slot removed successfully.' => '', - // 'Unable to remove this time slot.' => '', - // 'Do you really want to remove this time slot?' => '', - // 'Remove time slot' => '', - // 'Add new time slot' => '', + 'Expand tasks' => 'ขยายงาน', + 'Collapse tasks' => 'ย่องาน', + 'Expand/collapse tasks' => 'ขยาย/ย่อ งาน', + 'Close dialog box' => 'ปิดกล่องข้อความ', + 'Submit a form' => 'ยอมรับฟอร์ม', + 'Board view' => 'มุมมองบอร์ด', + 'Keyboard shortcuts' => 'คีย์ลัด', + 'Open board switcher' => 'เปิดการสลับบอร์ด', + 'Application' => 'แอพพลิเคชัน', + 'Filter recently updated' => 'ตัวกรองที่ปรับปรุงเร็วๆ นี้', + 'since %B %e, %Y at %k:%M %p' => 'เริ่ม %B %e, %Y เวลา %k:%M %p', + 'More filters' => 'ตัวกรองเพิ่มเติม', + 'Compact view' => 'มุมมองพอดี', + 'Horizontal scrolling' => 'เลื่อนตามแนวนอน', + 'Compact/wide view' => 'พอดี/กว้าง มุมมอง', + 'No results match:' => 'ไม่มีผลลัพท์ที่ตรง', + 'Remove hourly rate' => 'ลบอัตรารายชั่วโมง', + 'Do you really want to remove this hourly rate?' => 'คุณต้องการลบอัตรารายชั่วโมง?', + 'Hourly rates' => 'อัตรารายชั่วโมง', + 'Hourly rate' => 'อัตรารายชั่วโมง', + 'Currency' => 'สกุลเงิน', + 'Effective date' => 'วันที่จ่าย', + 'Add new rate' => 'เพิ่มอัตราใหม่', + 'Rate removed successfully.' => 'ลบอัตราเรียบร้อยแล้ว', + 'Unable to remove this rate.' => 'ไม่สามารถลบอัตรานี้ได้', + 'Unable to save the hourly rate.' => 'ไม่สามารถบันทึกอัตรารายชั่วโมง', + 'Hourly rate created successfully.' => 'อัตรารายชั่วโมงสร้างเรียบร้อยแล้ว', + 'Start time' => 'เวลาเริ่มต้น', + 'End time' => 'เวลาจบ', + 'Comment' => 'ความคิดเห็น', + 'All day' => 'ทั้งวัน', + 'Day' => 'วัน', + 'Manage timetable' => 'จัดการตารางเวลา', + 'Overtime timetable' => 'ตารางเวลาโอที', + 'Time off timetable' => 'ตารางเวลาวันหยุด', + 'Timetable' => 'ตารางเวลา', + 'Work timetable' => 'ตารางเวลางาน', + 'Week timetable' => 'ตารางเวลาสัปดาห์', + 'Day timetable' => 'ตารางเวลาวัน', + 'From' => 'จาก', + 'To' => 'ถึง', + 'Time slot created successfully.' => 'สร้างช่วงเวลาเรียบร้อยแล้ว', + 'Unable to save this time slot.' => 'ไม่สามารถบันทึกช่วงเวลานี้', + 'Time slot removed successfully.' => 'ลบช่วงเวลาเรียบร้อยแล้ว', + 'Unable to remove this time slot.' => 'ไม่สามารถลบช่วงเวลาได้', + 'Do you really want to remove this time slot?' => 'คุณต้องการลบช่วงเวลานี้?', + 'Remove time slot' => 'ลบช่วงเวลา', + 'Add new time slot' => 'เพิ่มช่วงเวลาใหม่', // 'This timetable is used when the checkbox "all day" is checked for scheduled time off and overtime.' => '', - // 'Files' => '', - // 'Images' => '', - // 'Private project' => '', - // 'Amount' => '', + 'Files' => 'ไฟล์', + 'Images' => 'รูปภาพ', + 'Private project' => 'โปรเจคส่วนตัว', + 'Amount' => 'จำนวนเงิน', // 'AUD - Australian Dollar' => '', - // 'Budget' => '', - // 'Budget line' => '', - // 'Budget line removed successfully.' => '', - // 'Budget lines' => '', + 'Budget' => 'งบประมาณ', + 'Budget line' => 'วงเงินงบประมาณ', + 'Budget line removed successfully.' => 'ลบวงเงินประมาณเรียบร้อยแล้ว', + 'Budget lines' => 'วงเงินงบประมาณ', // 'CAD - Canadian Dollar' => '', // 'CHF - Swiss Francs' => '', - // 'Cost' => '', - // 'Cost breakdown' => '', + 'Cost' => 'มูลค่า', + 'Cost breakdown' => 'รายละเอียดค่าใช้จ่าย', // 'Custom Stylesheet' => '', - // 'download' => '', - // 'Do you really want to remove this budget line?' => '', + 'download' => 'ดาวน์โหลด', + 'Do you really want to remove this budget line?' => 'คุณต้องการลบวงเงินงบประมาณนี้?', // 'EUR - Euro' => '', - // 'Expenses' => '', + 'Expenses' => 'รายจ่าย', // 'GBP - British Pound' => '', // 'INR - Indian Rupee' => '', // 'JPY - Japanese Yen' => '', - // 'New budget line' => '', + 'New budget line' => 'วงเงินงบประมาณใหม่', // 'NZD - New Zealand Dollar' => '', - // 'Remove a budget line' => '', - // 'Remove budget line' => '', + 'Remove a budget line' => 'ลบวงเงินประมาณ', + 'Remove budget line' => 'ลบวงเงินประมาณ', // 'RSD - Serbian dinar' => '', - // 'The budget line have been created successfully.' => '', - // 'Unable to create the budget line.' => '', - // 'Unable to remove this budget line.' => '', + 'The budget line have been created successfully.' => 'สร้างวงเงินงบประมาณเรียบร้อยแล้ว', + 'Unable to create the budget line.' => 'ไม่สามารถสร้างวงเงินงบประมาณได้', + 'Unable to remove this budget line.' => 'ไม่สามารถลบวงเงินงบประมาณนี้', // 'USD - US Dollar' => '', - // 'Remaining' => '', - // 'Destination column' => '', - // 'Move the task to another column when assigned to a user' => '', - // 'Move the task to another column when assignee is cleared' => '', - // 'Source column' => '', + 'Remaining' => 'เหลืออยู่', + 'Destination column' => 'คอลัมน์เป้าหมาย', + 'Move the task to another column when assigned to a user' => 'ย้ายงานไปคอลัมน์อื่นเมื่อกำหนดบุคคลรับผิดชอบ', + 'Move the task to another column when assignee is cleared' => 'ย้ายงานไปคอลัมน์อื่นเมื่อไม่กำหนดบุคคลรับผิดชอบ', + 'Source column' => 'คอลัมน์ต้นทาง', // 'Show subtask estimates (forecast of future work)' => '', - // 'Transitions' => '', - // 'Executer' => '', - // 'Time spent in the column' => '', - // 'Task transitions' => '', - // 'Task transitions export' => '', + 'Transitions' => 'การเปลี่ยนคอลัมน์', + 'Executer' => 'ผู้ประมวลผล', + 'Time spent in the column' => 'เวลาที่ใช้ในคอลัมน์', + 'Task transitions' => 'การเปลี่ยนคอลัมน์งาน', + 'Task transitions export' => 'ส่งออกการเปลี่ยนคอลัมน์งาน', // 'This report contains all column moves for each task with the date, the user and the time spent for each transition.' => '', - // 'Currency rates' => '', - // 'Rate' => '', + 'Currency rates' => 'อัตราแลกเปลี่ยน', + 'Rate' => 'อัตรา', // 'Change reference currency' => '', - // 'Add a new currency rate' => '', - // 'Currency rates are used to calculate project budget.' => '', + 'Add a new currency rate' => 'เพิ่มอัตราแลกเปลี่ยนเงินตราใหม่', + 'Currency rates are used to calculate project budget.' => 'อัตราแลกเปลี่ยนเงินตราถูกใช้ในการคำนวณงบประมาณของโปรเจค', // 'Reference currency' => '', // 'The currency rate have been added successfully.' => '', // 'Unable to add this currency rate.' => '', - // 'Send notifications to a Slack channel' => '', + 'Send notifications to a Slack channel' => 'ส่งการแจ้งเตือนไปทาง Slack channel', // 'Webhook URL' => '', // 'Help on Slack integration' => '', // '%s remove the assignee of the task %s' => '', @@ -830,31 +830,31 @@ return array( // 'Room API ID or name' => '', // 'Room notification token' => '', // 'Help on Hipchat integration' => '', - // 'Enable Gravatar images' => '', - // 'Information' => '', + 'Enable Gravatar images' => 'สามารถใช้งานภาพ Gravatar', + 'Information' => 'ข้อมูลสารสนเทศ', // 'Check two factor authentication code' => '', // 'The two factor authentication code is not valid.' => '', // 'The two factor authentication code is valid.' => '', - // 'Code' => '', + 'Code' => 'รหัส', // 'Two factor authentication' => '', - // 'Enable/disable two factor authentication' => '', + 'Enable/disable two factor authentication' => 'เปิด/ปิด การยืนยันตัวตนสองชั้น', // 'This QR code contains the key URI: ' => '', // 'Save the secret key in your TOTP software (by example Google Authenticator or FreeOTP).' => '', - // 'Check my code' => '', - // 'Secret key: ' => '', - // 'Test your device' => '', - // 'Assign a color when the task is moved to a specific column' => '', + 'Check my code' => 'ตรวจสอบรหัสของฉัน', + 'Secret key: ' => 'กุญแจลับ', + 'Test your device' => 'ทดสอบอุปกรณ์ของคุณ', + 'Assign a color when the task is moved to a specific column' => 'กำหนดสีเมื่องานถูกย้ายไปคอลัมน์ที่กำหนดไว้', // '%s via Kanboard' => '', // 'uploaded by: %s' => '', // 'uploaded on: %s' => '', - // 'size: %s' => '', - // 'Burndown chart for "%s"' => '', - // 'Burndown chart' => '', + 'size: %s' => 'ขนาด: %s', + 'Burndown chart for "%s"' => 'แผนภูมิงานกับเวลา "%s"', + 'Burndown chart' => 'แผนภูมิงานกับเวลา', // 'This chart show the task complexity over the time (Work Remaining).' => '', // 'Screenshot taken %s' => '', - // 'Add a screenshot' => '', + 'Add a screenshot' => 'เพิ่ม screenshot', // 'Take a screenshot and press CTRL+V or ⌘+V to paste here.' => '', - // 'Screenshot uploaded successfully.' => '', + 'Screenshot uploaded successfully.' => 'อัพโหลด screenshot เรียบร้อยแล้ว', // 'SEK - Swedish Krona' => '', // 'The project identifier is an optional alphanumeric code used to identify your project.' => '', // 'Identifier' => '', @@ -866,38 +866,38 @@ return array( // 'Help on Sendgrid integration' => '', // 'Disable two factor authentication' => '', // 'Do you really want to disable the two factor authentication for this user: "%s"?' => '', - // 'Edit link' => '', - // 'Start to type task title...' => '', - // 'A task cannot be linked to itself' => '', + 'Edit link' => 'แก้ไขลิงค์', + 'Start to type task title...' => 'พิมพ์ชื่องาน', + 'A task cannot be linked to itself' => 'งานไม่สามารถลิงค์ตัวเอง', // 'The exact same link already exists' => '', - // 'Recurrent task is scheduled to be generated' => '', - // 'Recurring information' => '', - // 'Score' => '', + 'Recurrent task is scheduled to be generated' => 'งานแบบวนลูปถูกสร้างตามที่กำหนดไว้', + 'Recurring information' => 'รายละเอียดการวนลูป', + 'Score' => 'คะแนน', // 'The identifier must be unique' => '', // 'This linked task id doesn\'t exists' => '', - // 'This value must be alphanumeric' => '', - // 'Edit recurrence' => '', - // 'Generate recurrent task' => '', - // 'Trigger to generate recurrent task' => '', + 'This value must be alphanumeric' => 'ค่านี้ต้องเป็นตัวอักษร', + 'Edit recurrence' => 'แก้ไขการวนลูป', + 'Generate recurrent task' => 'สร้างงานที่เป็นวนลูป', + 'Trigger to generate recurrent task' => 'จะสร้างงานแบบวนลูป', // 'Factor to calculate new due date' => '', // 'Timeframe to calculate new due date' => '', // 'Base date to calculate new due date' => '', // 'Action date' => '', // 'Base date to calculate new due date: ' => '', - // 'This task has created this child task: ' => '', - // 'Day(s)' => '', + 'This task has created this child task: ' => 'งานนี้สร้างงานลูกคือ', + 'Day(s)' => 'วัน', // 'Existing due date' => '', // 'Factor to calculate new due date: ' => '', - // 'Month(s)' => '', - // 'Recurrence' => '', - // 'This task has been created by: ' => '', - // 'Recurrent task has been generated:' => '', + 'Month(s)' => 'เดือน', + 'Recurrence' => 'วนลูป', + 'This task has been created by: ' => 'งานนี้ถูกสร้างโดย', + 'Recurrent task has been generated:' => 'งานแบบวนลูปถูกสร้าง', // 'Timeframe to calculate new due date: ' => '', - // 'Trigger to generate recurrent task: ' => '', - // 'When task is closed' => '', - // 'When task is moved from first column' => '', - // 'When task is moved to last column' => '', - // 'Year(s)' => '', + 'Trigger to generate recurrent task: ' => 'จะสร้างงานแบบวนลูป', + 'When task is closed' => 'เมื่อปิดงาน', + 'When task is moved from first column' => 'เมื่องานถูกย้ายจากคอลัมน์แรก', + 'When task is moved to last column' => 'เมื่องานถูกย้ายไปคอลัมน์สุดท้าย', + 'Year(s)' => 'ปี', // 'Jabber (XMPP)' => '', // 'Send notifications to Jabber' => '', // 'XMPP server address' => '', @@ -906,20 +906,20 @@ return array( // 'Multi-user chat room' => '', // 'Help on Jabber integration' => '', // 'The server address must use this format: "tcp://hostname:5222"' => '', - // 'Calendar settings' => '', - // 'Project calendar view' => '', - // 'Project settings' => '', - // 'Show subtasks based on the time tracking' => '', - // 'Show tasks based on the creation date' => '', - // 'Show tasks based on the start date' => '', - // 'Subtasks time tracking' => '', - // 'User calendar view' => '', - // 'Automatically update the start date' => '', + 'Calendar settings' => 'ตั้งค่าปฏิทิน', + 'Project calendar view' => 'มุมมองปฏิทินของโปรเจค', + 'Project settings' => 'ตั้งค่าโปรเจค', + 'Show subtasks based on the time tracking' => 'แสดงงานย่อยในการติดตามเวลา', + 'Show tasks based on the creation date' => 'แสดงงานจากวันที่สร้าง', + 'Show tasks based on the start date' => 'แสดงงานจากวันที่เริ่ม', + 'Subtasks time tracking' => 'การติดตามเวลางานย่อย', + 'User calendar view' => 'มุมมองปฏิทินของผู้ใช้', + 'Automatically update the start date' => 'ปรับปรุงวันที่เริ่มอัตโนมมัติ', // 'iCal feed' => '', // 'Preferences' => '', - // 'Security' => '', + 'Security' => 'ความปลอดภัย', // 'Two factor authentication disabled' => '', // 'Two factor authentication enabled' => '', - // 'Unable to update this user.' => '', - // 'There is no user management for private projects.' => '', + 'Unable to update this user.' => 'ไม่สามารถปรับปรุงผู้ใช้นี้', + 'There is no user management for private projects.' => 'ไม่มีการจัดการผู้ใช้สำหรับโปรเจคส่วนตัว', ); diff --git a/app/Model/Action.php b/app/Model/Action.php index 3e8aa091..c3bfe017 100644 --- a/app/Model/Action.php +++ b/app/Model/Action.php @@ -57,6 +57,7 @@ class Action extends Base 'TaskAssignUser' => t('Change the assignee based on an external username'), 'TaskAssignCategoryLabel' => t('Change the category based on an external label'), 'TaskUpdateStartDate' => t('Automatically update the start date'), + 'TaskMoveColumnCategoryChange' => t('Move the task to another column when the category is changed'), ); asort($values); diff --git a/app/Model/Base.php b/app/Model/Base.php index 784545fe..51ae782d 100644 --- a/app/Model/Base.php +++ b/app/Model/Base.php @@ -143,4 +143,23 @@ abstract class Base extends \Core\Base 'url' => $this->helper->url->to('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])), ); } + + /** + * Group a collection of records by a column + * + * @access public + * @param array $collection + * @param string $column + * @return array + */ + public function groupByColumn(array $collection, $column) + { + $result = array(); + + foreach ($collection as $item) { + $result[$item[$column]][] = $item; + } + + return $result; + } } diff --git a/app/Model/File.php b/app/Model/File.php index 1f62a55e..38b34cd3 100644 --- a/app/Model/File.php +++ b/app/Model/File.php @@ -49,7 +49,8 @@ class File extends Base { $file = $this->getbyId($file_id); - if (! empty($file) && @unlink(FILES_DIR.$file['path'])) { + if (! empty($file)) { + @unlink(FILES_DIR.$file['path']); return $this->db->table(self::TABLE)->eq('id', $file_id)->remove(); } @@ -66,10 +67,13 @@ class File extends Base public function removeAll($task_id) { $files = $this->getAll($task_id); + $results = array(); foreach ($files as $file) { - $this->remove($file['id']); + $results[] = $this->remove($file['id']); } + + return ! in_array(false, $results, true); } /** @@ -79,36 +83,41 @@ class File extends Base * @param integer $task_id Task id * @param string $name Filename * @param string $path Path on the disk - * @param bool $is_image Image or not * @param integer $size File size - * @return bool + * @return bool|integer */ - public function create($task_id, $name, $path, $is_image, $size) + public function create($task_id, $name, $path, $size) { - $this->container['dispatcher']->dispatch( - self::EVENT_CREATE, - new FileEvent(array('task_id' => $task_id, 'name' => $name)) - ); - - return $this->db->table(self::TABLE)->save(array( + $result = $this->db->table(self::TABLE)->save(array( 'task_id' => $task_id, 'name' => substr($name, 0, 255), 'path' => $path, - 'is_image' => $is_image ? '1' : '0', + 'is_image' => $this->isImage($name) ? 1 : 0, 'size' => $size, 'user_id' => $this->userSession->getId() ?: 0, 'date' => time(), )); + + if ($result) { + + $this->container['dispatcher']->dispatch( + self::EVENT_CREATE, + new FileEvent(array('task_id' => $task_id, 'name' => $name)) + ); + + return (int) $this->db->getConnection()->getLastId(); + } + + return false; } /** - * Get all files for a given task + * Get PicoDb query to get all files * * @access public - * @param integer $task_id Task id - * @return array + * @return \PicoDb\Table */ - public function getAll($task_id) + public function getQuery() { return $this->db ->table(self::TABLE) @@ -125,9 +134,19 @@ class File extends Base User::TABLE.'.name as user_name' ) ->join(User::TABLE, 'id', 'user_id') - ->eq('task_id', $task_id) - ->asc(self::TABLE.'.name') - ->findAll(); + ->asc(self::TABLE.'.name'); + } + + /** + * Get all files for a given task + * + * @access public + * @param integer $task_id Task id + * @return array + */ + public function getAll($task_id) + { + return $this->getQuery()->eq('task_id', $task_id)->findAll(); } /** @@ -139,25 +158,7 @@ class File extends Base */ public function getAllImages($task_id) { - return $this->db - ->table(self::TABLE) - ->columns( - self::TABLE.'.id', - self::TABLE.'.name', - self::TABLE.'.path', - self::TABLE.'.is_image', - self::TABLE.'.task_id', - self::TABLE.'.date', - self::TABLE.'.user_id', - self::TABLE.'.size', - User::TABLE.'.username', - User::TABLE.'.name as user_name' - ) - ->join(User::TABLE, 'id', 'user_id') - ->eq('task_id', $task_id) - ->eq('is_image', 1) - ->asc(self::TABLE.'.name') - ->findAll(); + return $this->getQuery()->eq('task_id', $task_id)->eq('is_image', 1)->findAll(); } /** @@ -169,29 +170,11 @@ class File extends Base */ public function getAllDocuments($task_id) { - return $this->db - ->table(self::TABLE) - ->columns( - self::TABLE.'.id', - self::TABLE.'.name', - self::TABLE.'.path', - self::TABLE.'.is_image', - self::TABLE.'.task_id', - self::TABLE.'.date', - self::TABLE.'.user_id', - self::TABLE.'.size', - User::TABLE.'.username', - User::TABLE.'.name as user_name' - ) - ->join(User::TABLE, 'id', 'user_id') - ->eq('task_id', $task_id) - ->eq('is_image', 0) - ->asc(self::TABLE.'.name') - ->findAll(); + return $this->getQuery()->eq('task_id', $task_id)->eq('is_image', 0)->findAll(); } /** - * Check if a filename is an image + * Check if a filename is an image (file types that can be shown as thumbnail) * * @access public * @param string $filename Filename @@ -227,24 +210,6 @@ class File extends Base } /** - * Check if the base directory is created correctly - * - * @access public - */ - public function setup() - { - if (! is_dir(FILES_DIR)) { - if (! mkdir(FILES_DIR, 0755, true)) { - die('Unable to create the upload directory: "'.FILES_DIR.'"'); - } - } - - if (! is_writable(FILES_DIR)) { - die('The directory "'.FILES_DIR.'" must be writeable by your webserver user'); - } - } - - /** * Handle file upload * * @access public @@ -255,8 +220,7 @@ class File extends Base */ public function upload($project_id, $task_id, $form_name) { - $this->setup(); - $result = array(); + $results = array(); if (! empty($_FILES[$form_name])) { @@ -272,11 +236,10 @@ class File extends Base if (@move_uploaded_file($uploaded_filename, FILES_DIR.$destination_filename)) { - $result[] = $this->create( + $results[] = $this->create( $task_id, $original_filename, $destination_filename, - $this->isImage($original_filename), $_FILES[$form_name]['size'][$key] ); } @@ -284,7 +247,7 @@ class File extends Base } } - return count(array_unique($result)) === 1; + return ! in_array(false, $results, true); } /** @@ -294,7 +257,7 @@ class File extends Base * @param integer $project_id Project id * @param integer $task_id Task id * @param string $blob Base64 encoded image - * @return bool + * @return bool|integer */ public function uploadScreenshot($project_id, $task_id, $blob) { @@ -314,7 +277,6 @@ class File extends Base $task_id, $original_filename, $destination_filename, - true, strlen($data) ); } @@ -326,11 +288,10 @@ class File extends Base * @param integer $project_id Project id * @param integer $task_id Task id * @param string $filename Filename - * @param bool $is_image Is image file? * @param string $blob Base64 encoded image - * @return bool + * @return bool|integer */ - public function uploadContent($project_id, $task_id, $filename, $is_image, &$blob) + public function uploadContent($project_id, $task_id, $filename, $blob) { $data = base64_decode($blob); @@ -347,7 +308,6 @@ class File extends Base $task_id, $filename, $destination_filename, - $is_image, strlen($data) ); } diff --git a/app/Model/Notification.php b/app/Model/Notification.php index 048b6a39..ec349681 100644 --- a/app/Model/Notification.php +++ b/app/Model/Notification.php @@ -2,11 +2,7 @@ namespace Model; -use Core\Session; use Core\Translator; -use Swift_Message; -use Swift_Mailer; -use Swift_TransportException; /** * Notification model @@ -24,207 +20,329 @@ class Notification extends Base const TABLE = 'user_has_notifications'; /** - * Get a list of people with notifications enabled + * User filters + * + * @var integer + */ + const FILTER_NONE = 1; + const FILTER_ASSIGNEE = 2; + const FILTER_CREATOR = 3; + const FILTER_BOTH = 4; + + /** + * Send overdue tasks * * @access public - * @param integer $project_id Project id - * @param array $exclude_users List of user_id to exclude - * @return array */ - public function getUsersWithNotification($project_id, array $exclude_users = array()) + public function sendOverdueTaskNotifications() { - if ($this->projectPermission->isEverybodyAllowed($project_id)) { + $tasks = $this->taskFinder->getOverdueTasks(); + $projects = array(); - return $this->db - ->table(User::TABLE) - ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name', User::TABLE.'.email', User::TABLE.'.language') - ->eq('notifications_enabled', '1') - ->neq('email', '') - ->notin(User::TABLE.'.id', $exclude_users) - ->findAll(); + foreach ($this->groupByColumn($tasks, 'project_id') as $project_id => $project_tasks) { + + // Get the list of users that should receive notifications for each projects + $users = $this->notification->getUsersWithNotificationEnabled($project_id); + + foreach ($users as $user) { + $this->sendUserOverdueTaskNotifications($user, $project_tasks); + } } - return $this->db - ->table(ProjectPermission::TABLE) - ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name', User::TABLE.'.email', User::TABLE.'.language') - ->join(User::TABLE, 'id', 'user_id') - ->eq('project_id', $project_id) - ->eq('notifications_enabled', '1') - ->neq('email', '') - ->notin(User::TABLE.'.id', $exclude_users) - ->findAll(); + return $tasks; } /** - * Get the list of users to send the notification for a given project + * Send overdue tasks for a given user * * @access public - * @param integer $project_id Project id - * @param array $exclude_users List of user_id to exclude - * @return array + * @param array $user + * @param array $tasks */ - public function getUsersList($project_id, array $exclude_users = array()) + public function sendUserOverdueTaskNotifications(array $user, array $tasks) { - // Exclude the connected user - if (Session::isOpen() && $this->userSession->isLogged()) { - $exclude_users[] = $this->userSession->getId(); - } - - $users = $this->getUsersWithNotification($project_id, $exclude_users); + $user_tasks = array(); - foreach ($users as $index => $user) { + foreach ($tasks as $task) { + if ($this->notification->shouldReceiveNotification($user, array('task' => $task))) { + $user_tasks[] = $task; + } + } - $projects = $this->db->table(self::TABLE) - ->eq('user_id', $user['id']) - ->findAllByColumn('project_id'); + if (! empty($user_tasks)) { + $this->sendEmailNotification( + $user, + Task::EVENT_OVERDUE, + array('tasks' => $user_tasks, 'project_name' => $tasks[0]['project_name']) + ); + } + } - // The user have selected only some projects - if (! empty($projects)) { + /** + * Send notifications to people + * + * @access public + * @param string $event_name + * @param array $event_data + */ + public function sendNotifications($event_name, array $event_data) + { + $logged_user_id = $this->userSession->isLogged() ? $this->userSession->getId() : 0; + $users = $this->notification->getUsersWithNotificationEnabled($event_data['task']['project_id'], $logged_user_id); - // If the user didn't select this project we remove that guy from the list - if (! in_array($project_id, $projects)) { - unset($users[$index]); - } + foreach ($users as $user) { + if ($this->shouldReceiveNotification($user, $event_data)) { + $this->sendEmailNotification($user, $event_name, $event_data); } } - return $users; + // Restore locales + $this->config->setupTranslations(); } /** - * Send the email notifications + * Send email notification to someone * * @access public - * @param string $template Template name - * @param array $users List of users - * @param array $data Template data + * @param array $user User + * @param string $event_name + * @param array $event_data */ - public function sendEmails($template, array $users, array $data) + public function sendEmailNotification(array $user, $event_name, array $event_data) { - try { + // Use the user language otherwise use the application language (do not use the session language) + if (! empty($user['language'])) { + Translator::load($user['language']); + } + else { + Translator::load($this->config->get('application_language', 'en_US')); + } - $author = ''; + $this->emailClient->send( + $user['email'], + $user['name'] ?: $user['username'], + $this->getMailSubject($event_name, $event_data), + $this->getMailContent($event_name, $event_data) + ); + } + + /** + * Return true if the user should receive notification + * + * @access public + * @param array $user + * @param array $event_data + * @return boolean + */ + public function shouldReceiveNotification(array $user, array $event_data) + { + $filters = array( + 'filterNone', + 'filterAssignee', + 'filterCreator', + 'filterBoth', + ); - if (Session::isOpen() && $this->userSession->isLogged()) { - $author = e('%s via Kanboard', $this->user->getFullname($this->session['user'])); + foreach ($filters as $filter) { + if ($this->$filter($user, $event_data)) { + return $this->filterProject($user, $event_data); } + } - $mailer = Swift_Mailer::newInstance($this->container['mailer']); + return false; + } - foreach ($users as $user) { + /** + * Return true if the user will receive all notifications + * + * @access public + * @param array $user + * @param array $event_data + * @return boolean + */ + public function filterNone(array $user, array $event_data) + { + return $user['notifications_filter'] == self::FILTER_NONE; + } - $this->container['logger']->debug('Send email notification to '.$user['username'].' lang='.$user['language']); - $start_time = microtime(true); + /** + * Return true if the user is the assignee and selected the filter "assignee" + * + * @access public + * @param array $user + * @param array $event_data + * @return boolean + */ + public function filterAssignee(array $user, array $event_data) + { + return $user['notifications_filter'] == self::FILTER_ASSIGNEE && $event_data['task']['owner_id'] == $user['id']; + } - // Use the user language otherwise use the application language (do not use the session language) - if (! empty($user['language'])) { - Translator::load($user['language']); - } - else { - Translator::load($this->config->get('application_language', 'en_US')); - } + /** + * Return true if the user is the creator and enabled the filter "creator" + * + * @access public + * @param array $user + * @param array $event_data + * @return boolean + */ + public function filterCreator(array $user, array $event_data) + { + return $user['notifications_filter'] == self::FILTER_CREATOR && $event_data['task']['creator_id'] == $user['id']; + } - // Send the message - $message = Swift_Message::newInstance() - ->setSubject($this->getMailSubject($template, $data)) - ->setFrom(array(MAIL_FROM => $author ?: 'Kanboard')) - ->setBody($this->getMailContent($template, $data), 'text/html') - ->setTo(array($user['email'] => $user['name'] ?: $user['username'])); + /** + * Return true if the user is the assignee or the creator and selected the filter "both" + * + * @access public + * @param array $user + * @param array $event_data + * @return boolean + */ + public function filterBoth(array $user, array $event_data) + { + return $user['notifications_filter'] == self::FILTER_BOTH && + ($event_data['task']['creator_id'] == $user['id'] || $event_data['task']['owner_id'] == $user['id']); + } - $mailer->send($message); + /** + * Return true if the user want to receive notification for the selected project + * + * @access public + * @param array $user + * @param array $event_data + * @return boolean + */ + public function filterProject(array $user, array $event_data) + { + $projects = $this->db->table(self::TABLE)->eq('user_id', $user['id'])->findAllByColumn('project_id'); - $this->container['logger']->debug('Email sent in '.round(microtime(true) - $start_time, 6).' seconds'); - } + if (! empty($projects)) { + return in_array($event_data['task']['project_id'], $projects); } - catch (Swift_TransportException $e) { - $this->container['logger']->error($e->getMessage()); + + return true; + } + + /** + * Get a list of people with notifications enabled + * + * @access public + * @param integer $project_id Project id + * @param array $exclude_user_id User id to exclude + * @return array + */ + public function getUsersWithNotificationEnabled($project_id, $exclude_user_id = 0) + { + if ($this->projectPermission->isEverybodyAllowed($project_id)) { + + return $this->db + ->table(User::TABLE) + ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name', User::TABLE.'.email', User::TABLE.'.language', User::TABLE.'.notifications_filter') + ->eq('notifications_enabled', '1') + ->neq('email', '') + ->neq(User::TABLE.'.id', $exclude_user_id) + ->findAll(); } - // Restore locales - $this->config->setupTranslations(); + return $this->db + ->table(ProjectPermission::TABLE) + ->columns(User::TABLE.'.id', User::TABLE.'.username', User::TABLE.'.name', User::TABLE.'.email', User::TABLE.'.language', User::TABLE.'.notifications_filter') + ->join(User::TABLE, 'id', 'user_id') + ->eq('project_id', $project_id) + ->eq('notifications_enabled', '1') + ->neq('email', '') + ->neq(User::TABLE.'.id', $exclude_user_id) + ->findAll(); } /** - * Get the mail subject for a given label + * Get the mail content for a given template name * - * @access private - * @param string $label Label - * @param array $data Template data + * @access public + * @param string $event_name Event name + * @param array $event_data Event data + * @return string */ - private function getStandardMailSubject($label, array $data) + public function getMailContent($event_name, array $event_data) { - return sprintf('[%s][%s] %s (#%d)', $data['task']['project_name'], $label, $data['task']['title'], $data['task']['id']); + return $this->template->render( + 'notification/'.str_replace('.', '_', $event_name), + $event_data + array('application_url' => $this->config->get('application_url')) + ); } /** * Get the mail subject for a given template name * * @access public - * @param string $template Template name - * @param array $data Template data + * @param string $event_name Event name + * @param array $event_data Event data + * @return string */ - public function getMailSubject($template, array $data) + public function getMailSubject($event_name, array $event_data) { - switch ($template) { - case 'file_creation': - $subject = $this->getStandardMailSubject(t('New attachment'), $data); + switch ($event_name) { + case File::EVENT_CREATE: + $subject = $this->getStandardMailSubject(e('New attachment'), $event_data); + break; + case Comment::EVENT_CREATE: + $subject = $this->getStandardMailSubject(e('New comment'), $event_data); break; - case 'comment_creation': - $subject = $this->getStandardMailSubject(t('New comment'), $data); + case Comment::EVENT_UPDATE: + $subject = $this->getStandardMailSubject(e('Comment updated'), $event_data); break; - case 'comment_update': - $subject = $this->getStandardMailSubject(t('Comment updated'), $data); + case Subtask::EVENT_CREATE: + $subject = $this->getStandardMailSubject(e('New subtask'), $event_data); break; - case 'subtask_creation': - $subject = $this->getStandardMailSubject(t('New subtask'), $data); + case Subtask::EVENT_UPDATE: + $subject = $this->getStandardMailSubject(e('Subtask updated'), $event_data); break; - case 'subtask_update': - $subject = $this->getStandardMailSubject(t('Subtask updated'), $data); + case Task::EVENT_CREATE: + $subject = $this->getStandardMailSubject(e('New task'), $event_data); break; - case 'task_creation': - $subject = $this->getStandardMailSubject(t('New task'), $data); + case Task::EVENT_UPDATE: + $subject = $this->getStandardMailSubject(e('Task updated'), $event_data); break; - case 'task_update': - $subject = $this->getStandardMailSubject(t('Task updated'), $data); + case Task::EVENT_CLOSE: + $subject = $this->getStandardMailSubject(e('Task closed'), $event_data); break; - case 'task_close': - $subject = $this->getStandardMailSubject(t('Task closed'), $data); + case Task::EVENT_OPEN: + $subject = $this->getStandardMailSubject(e('Task opened'), $event_data); break; - case 'task_open': - $subject = $this->getStandardMailSubject(t('Task opened'), $data); + case Task::EVENT_MOVE_COLUMN: + $subject = $this->getStandardMailSubject(e('Column change'), $event_data); break; - case 'task_move_column': - $subject = $this->getStandardMailSubject(t('Column Change'), $data); + case Task::EVENT_MOVE_POSITION: + $subject = $this->getStandardMailSubject(e('Position change'), $event_data); break; - case 'task_move_position': - $subject = $this->getStandardMailSubject(t('Position Change'), $data); + case Task::EVENT_MOVE_SWIMLANE: + $subject = $this->getStandardMailSubject(e('Swimlane change'), $event_data); break; - case 'task_assignee_change': - $subject = $this->getStandardMailSubject(t('Assignee Change'), $data); + case Task::EVENT_ASSIGNEE_CHANGE: + $subject = $this->getStandardMailSubject(e('Assignee change'), $event_data); break; - case 'task_due': - $subject = e('[%s][Due tasks]', $data['project']); + case Task::EVENT_OVERDUE: + $subject = e('[%s] Overdue tasks', $event_data['project_name']); break; default: - $subject = e('[Kanboard] Notification'); + $subject = e('Notification'); } return $subject; } /** - * Get the mail content for a given template name + * Get the mail subject for a given label * - * @access public - * @param string $template Template name + * @access private + * @param string $label Label * @param array $data Template data + * @return string */ - public function getMailContent($template, array $data) + private function getStandardMailSubject($label, array $data) { - return $this->template->render( - 'notification/'.$template, - $data + array('application_url' => $this->config->get('application_url')) - ); + return sprintf('[%s][%s] %s (#%d)', $data['task']['project_name'], $label, $data['task']['title'], $data['task']['id']); } /** @@ -243,7 +361,8 @@ class Notification extends Base // Activate notifications $this->db->table(User::TABLE)->eq('id', $user_id)->update(array( - 'notifications_enabled' => '1' + 'notifications_enabled' => '1', + 'notifications_filter' => empty($values['notifications_filter']) ? self::FILTER_BOTH : $values['notifications_filter'], )); // Save selected projects @@ -275,9 +394,7 @@ class Notification extends Base */ public function readSettings($user_id) { - $values = array(); - $values['notifications_enabled'] = $this->db->table(User::TABLE)->eq('id', $user_id)->findOneColumn('notifications_enabled'); - + $values = $this->db->table(User::TABLE)->eq('id', $user_id)->columns('notifications_enabled', 'notifications_filter')->findOne(); $projects = $this->db->table(self::TABLE)->eq('user_id', $user_id)->findAllByColumn('project_id'); foreach ($projects as $project_id) { diff --git a/app/Model/ProjectActivity.php b/app/Model/ProjectActivity.php index 27f1cfcd..a9222fcc 100644 --- a/app/Model/ProjectActivity.php +++ b/app/Model/ProjectActivity.php @@ -227,6 +227,11 @@ class ProjectActivity extends Base return t('%s moved the task #%d to the column "%s"', $event['author'], $event['task']['id'], $event['task']['column_title']); case Task::EVENT_MOVE_POSITION: return t('%s moved the task #%d to the position %d in the column "%s"', $event['author'], $event['task']['id'], $event['task']['position'], $event['task']['column_title']); + case Task::EVENT_MOVE_SWIMLANE: + if ($event['task']['swimlane_id'] == 0) { + return t('%s moved the task #%d to the first swimlane', $event['author'], $event['task']['id']); + } + return t('%s moved the task #%d to the swimlane "%s"', $event['author'], $event['task']['id'], $event['task']['swimlane_name']); case Subtask::EVENT_UPDATE: return t('%s updated a subtask for the task #%d', $event['author'], $event['task']['id']); case Subtask::EVENT_CREATE: diff --git a/app/Model/ProjectPermission.php b/app/Model/ProjectPermission.php index d4f44f66..b0a09df4 100644 --- a/app/Model/ProjectPermission.php +++ b/app/Model/ProjectPermission.php @@ -290,7 +290,7 @@ class ProjectPermission extends Base } /** - * Return a list of allowed projects for a given user + * Return a list of allowed active projects for a given user * * @access public * @param integer $user_id User id @@ -302,7 +302,7 @@ class ProjectPermission extends Base return $this->project->getListByStatus(Project::ACTIVE); } - return $this->getMemberProjects($user_id); + return $this->getActiveMemberProjects($user_id); } /** diff --git a/app/Model/SubtaskTimeTracking.php b/app/Model/SubtaskTimeTracking.php index d4edf660..93a698b6 100644 --- a/app/Model/SubtaskTimeTracking.php +++ b/app/Model/SubtaskTimeTracking.php @@ -105,7 +105,8 @@ class SubtaskTimeTracking extends Base ->join(Subtask::TABLE, 'id', 'subtask_id') ->join(Task::TABLE, 'id', 'task_id', Subtask::TABLE) ->join(User::TABLE, 'id', 'user_id', self::TABLE) - ->eq(Task::TABLE.'.project_id', $project_id); + ->eq(Task::TABLE.'.project_id', $project_id) + ->asc(self::TABLE.'.id'); } /** diff --git a/app/Model/Task.php b/app/Model/Task.php index abd787ad..71d973a4 100644 --- a/app/Model/Task.php +++ b/app/Model/Task.php @@ -40,6 +40,7 @@ class Task extends Base const EVENT_OPEN = 'task.open'; const EVENT_CREATE_UPDATE = 'task.create_update'; const EVENT_ASSIGNEE_CHANGE = 'task.assignee_change'; + const EVENT_OVERDUE = 'task.overdue'; /** * Recurrence: status diff --git a/app/Model/TaskFinder.php b/app/Model/TaskFinder.php index 6f53249a..327b480f 100644 --- a/app/Model/TaskFinder.php +++ b/app/Model/TaskFinder.php @@ -168,6 +168,8 @@ class TaskFinder extends Base Task::TABLE.'.title', Task::TABLE.'.date_due', Task::TABLE.'.project_id', + Task::TABLE.'.creator_id', + Task::TABLE.'.owner_id', Project::TABLE.'.name AS project_name', User::TABLE.'.username AS assignee_username', User::TABLE.'.name AS assignee_name' @@ -261,6 +263,7 @@ class TaskFinder extends Base tasks.recurrence_parent, tasks.recurrence_child, project_has_categories.name AS category_name, + swimlanes.name AS swimlane_name, projects.name AS project_name, columns.title AS column_title, users.username AS assignee_username, @@ -273,6 +276,7 @@ class TaskFinder extends Base LEFT JOIN project_has_categories ON project_has_categories.id = tasks.category_id LEFT JOIN projects ON projects.id = tasks.project_id LEFT JOIN columns ON columns.id = tasks.column_id + LEFT JOIN swimlanes ON swimlanes.id = tasks.swimlane_id WHERE tasks.id = ? '; diff --git a/app/Model/TaskModification.php b/app/Model/TaskModification.php index 677fcd60..4691ce81 100644 --- a/app/Model/TaskModification.php +++ b/app/Model/TaskModification.php @@ -42,13 +42,19 @@ class TaskModification extends Base */ public function fireEvents(array $task, array $new_values) { + $events = array(); $event_data = array_merge($task, $new_values, array('task_id' => $task['id'])); - if (isset($new_values['owner_id']) && $task['owner_id'] != $new_values['owner_id']) { - $events = array(Task::EVENT_ASSIGNEE_CHANGE); + // Values changed + $event_data['changes'] = array_diff_assoc($new_values, $task); + unset($event_data['changes']['date_modification']); + + if ($this->isFieldModified('owner_id', $event_data['changes'])) { + $events[] = Task::EVENT_ASSIGNEE_CHANGE; } else { - $events = array(Task::EVENT_CREATE_UPDATE, Task::EVENT_UPDATE); + $events[] = Task::EVENT_CREATE_UPDATE; + $events[] = Task::EVENT_UPDATE; } foreach ($events as $event) { @@ -57,6 +63,19 @@ class TaskModification extends Base } /** + * Return true if the field is the only modified value + * + * @access public + * @param string $field + * @param array $changes + * @return boolean + */ + public function isFieldModified($field, array $changes) + { + return isset($changes[$field]) && count($changes) === 1; + } + + /** * Prepare data before task modification * * @access public diff --git a/app/Model/Webhook.php b/app/Model/Webhook.php index 8c270fb6..e3af37f7 100644 --- a/app/Model/Webhook.php +++ b/app/Model/Webhook.php @@ -30,7 +30,7 @@ class Webhook extends Base $url .= '?token='.$token; } - return $this->httpClient->post($url, $values); + return $this->httpClient->postJson($url, $values); } } } diff --git a/app/Schema/Mysql.php b/app/Schema/Mysql.php index a65525c8..bcb365bd 100644 --- a/app/Schema/Mysql.php +++ b/app/Schema/Mysql.php @@ -6,7 +6,12 @@ use PDO; use Core\Security; use Model\Link; -const VERSION = 72; +const VERSION = 73; + +function version_73($pdo) +{ + $pdo->exec("ALTER TABLE users ADD COLUMN notifications_filter INT DEFAULT 4"); +} function version_72($pdo) { diff --git a/app/Schema/Postgres.php b/app/Schema/Postgres.php index 0afcd26a..65a9c9bf 100644 --- a/app/Schema/Postgres.php +++ b/app/Schema/Postgres.php @@ -6,7 +6,12 @@ use PDO; use Core\Security; use Model\Link; -const VERSION = 52; +const VERSION = 53; + +function version_53($pdo) +{ + $pdo->exec("ALTER TABLE users ADD COLUMN notifications_filter INTEGER DEFAULT 4"); +} function version_52($pdo) { diff --git a/app/Schema/Sqlite.php b/app/Schema/Sqlite.php index 43fb136e..ceb3028c 100644 --- a/app/Schema/Sqlite.php +++ b/app/Schema/Sqlite.php @@ -6,7 +6,12 @@ use Core\Security; use PDO; use Model\Link; -const VERSION = 70; +const VERSION = 71; + +function version_71($pdo) +{ + $pdo->exec("ALTER TABLE users ADD COLUMN notifications_filter INTEGER DEFAULT 4"); +} function version_70($pdo) { diff --git a/app/ServiceProvider/ClassProvider.php b/app/ServiceProvider/ClassProvider.php index ced7c7c6..4ecd357b 100644 --- a/app/ServiceProvider/ClassProvider.php +++ b/app/ServiceProvider/ClassProvider.php @@ -64,6 +64,7 @@ class ClassProvider implements ServiceProviderInterface 'Webhook', ), 'Core' => array( + 'EmailClient', 'Helper', 'HttpClient', 'MemoryCache', @@ -77,10 +78,11 @@ class ClassProvider implements ServiceProviderInterface 'GitlabWebhook', 'HipchatWebhook', 'Jabber', - 'MailgunWebhook', - 'PostmarkWebhook', - 'SendgridWebhook', + 'Mailgun', + 'Postmark', + 'Sendgrid', 'SlackWebhook', + 'Smtp', ) ); diff --git a/app/ServiceProvider/MailerProvider.php b/app/ServiceProvider/MailerProvider.php deleted file mode 100644 index 6469a737..00000000 --- a/app/ServiceProvider/MailerProvider.php +++ /dev/null @@ -1,33 +0,0 @@ -<?php - -namespace ServiceProvider; - -use Pimple\Container; -use Pimple\ServiceProviderInterface; -use Swift_SmtpTransport; -use Swift_SendmailTransport; -use Swift_MailTransport; - -class MailerProvider implements ServiceProviderInterface -{ - public function register(Container $container) - { - $container['mailer'] = function () { - switch (MAIL_TRANSPORT) { - case 'smtp': - $transport = Swift_SmtpTransport::newInstance(MAIL_SMTP_HOSTNAME, MAIL_SMTP_PORT); - $transport->setUsername(MAIL_SMTP_USERNAME); - $transport->setPassword(MAIL_SMTP_PASSWORD); - $transport->setEncryption(MAIL_SMTP_ENCRYPTION); - break; - case 'sendmail': - $transport = Swift_SendmailTransport::newInstance(MAIL_SENDMAIL_COMMAND); - break; - default: - $transport = Swift_MailTransport::newInstance(); - } - - return $transport; - }; - } -} diff --git a/app/Subscriber/NotificationSubscriber.php b/app/Subscriber/NotificationSubscriber.php index 92d46754..b99c2945 100644 --- a/app/Subscriber/NotificationSubscriber.php +++ b/app/Subscriber/NotificationSubscriber.php @@ -11,21 +11,6 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface; class NotificationSubscriber extends \Core\Base implements EventSubscriberInterface { - private $templates = array( - Task::EVENT_CREATE => 'task_creation', - Task::EVENT_UPDATE => 'task_update', - Task::EVENT_CLOSE => 'task_close', - Task::EVENT_OPEN => 'task_open', - Task::EVENT_MOVE_COLUMN => 'task_move_column', - Task::EVENT_MOVE_POSITION => 'task_move_position', - Task::EVENT_ASSIGNEE_CHANGE => 'task_assignee_change', - Subtask::EVENT_CREATE => 'subtask_creation', - Subtask::EVENT_UPDATE => 'subtask_update', - Comment::EVENT_CREATE => 'comment_creation', - Comment::EVENT_UPDATE => 'comment_update', - File::EVENT_CREATE => 'file_creation', - ); - public static function getSubscribedEvents() { return array( @@ -35,6 +20,7 @@ class NotificationSubscriber extends \Core\Base implements EventSubscriberInterf Task::EVENT_OPEN => array('execute', 0), Task::EVENT_MOVE_COLUMN => array('execute', 0), Task::EVENT_MOVE_POSITION => array('execute', 0), + Task::EVENT_MOVE_SWIMLANE => array('execute', 0), Task::EVENT_ASSIGNEE_CHANGE => array('execute', 0), Subtask::EVENT_CREATE => array('execute', 0), Subtask::EVENT_UPDATE => array('execute', 0), @@ -46,24 +32,17 @@ class NotificationSubscriber extends \Core\Base implements EventSubscriberInterf public function execute(GenericEvent $event, $event_name) { - $values = $this->getTemplateData($event); - - if (isset($values['task']['project_id'])) { - $users = $this->notification->getUsersList($values['task']['project_id']); - - if (! empty($users)) { - $this->notification->sendEmails($this->templates[$event_name], $users, $values); - } - } + $this->notification->sendNotifications($event_name, $this->getEventData($event)); } - public function getTemplateData(GenericEvent $event) + public function getEventData(GenericEvent $event) { $values = array(); switch (get_class($event)) { case 'Event\TaskEvent': $values['task'] = $this->taskFinder->getDetails($event['task_id']); + $values['changes'] = isset($event['changes']) ? $event['changes'] : array(); break; case 'Event\SubtaskEvent': $values['subtask'] = $this->subtask->getById($event['id'], true); diff --git a/app/Subscriber/ProjectActivitySubscriber.php b/app/Subscriber/ProjectActivitySubscriber.php index 31f771f8..82b13d8e 100644 --- a/app/Subscriber/ProjectActivitySubscriber.php +++ b/app/Subscriber/ProjectActivitySubscriber.php @@ -20,6 +20,7 @@ class ProjectActivitySubscriber extends \Core\Base implements EventSubscriberInt Task::EVENT_OPEN => array('execute', 0), Task::EVENT_MOVE_COLUMN => array('execute', 0), Task::EVENT_MOVE_POSITION => array('execute', 0), + Task::EVENT_MOVE_SWIMLANE => array('execute', 0), Comment::EVENT_UPDATE => array('execute', 0), Comment::EVENT_CREATE => array('execute', 0), Subtask::EVENT_UPDATE => array('execute', 0), @@ -58,6 +59,7 @@ class ProjectActivitySubscriber extends \Core\Base implements EventSubscriberInt { $values = array(); $values['task'] = $this->taskFinder->getDetails($event['task_id']); + $values['changes'] = isset($event['changes']) ? $event['changes'] : array(); switch (get_class($event)) { case 'Event\SubtaskEvent': diff --git a/app/Template/action/index.php b/app/Template/action/index.php index 9e98554c..ca9c6543 100644 --- a/app/Template/action/index.php +++ b/app/Template/action/index.php @@ -7,16 +7,25 @@ <h3><?= t('Defined actions') ?></h3> <table> <tr> - <th><?= t('Event name') ?></th> - <th><?= t('Action name') ?></th> + <th><?= t('Automatic actions') ?></th> <th><?= t('Action parameters') ?></th> <th><?= t('Action') ?></th> </tr> <?php foreach ($actions as $action): ?> <tr> - <td><?= $this->text->in($action['event_name'], $available_events) ?></td> - <td><?= $this->text->in($action['action_name'], $available_actions) ?></td> + <td> + <ul> + <li> + <?= t('Event name') ?> = + <strong><?= $this->text->in($action['event_name'], $available_events) ?></strong> + </li> + <li> + <?= t('Action name') ?> = + <strong><?= $this->text->in($action['action_name'], $available_actions) ?></strong> + </li> + <ul> + </td> <td> <ul> <?php foreach ($action['params'] as $param): ?> diff --git a/app/Template/board/filters.php b/app/Template/board/filters.php index bf2adfac..b80234a0 100644 --- a/app/Template/board/filters.php +++ b/app/Template/board/filters.php @@ -27,13 +27,13 @@ </span> </li> <li> - <?= $this->form->select('user_id', $users, array(), array(), array('data-placeholder="'.t('Filter by user').'"', 'data-notfound="'.t('No results match:').'"'), 'apply-filters chosen-select') ?> + <?= $this->form->select('user_id', $users, array(), array(), array('data-placeholder="'.t('Filter by user').'"', 'data-notfound="'.t('No results match:').'"', 'tabindex=="-1"'), 'apply-filters chosen-select') ?> </li> <li> - <?= $this->form->select('category_id', $categories, array(), array(), array('data-placeholder="'.t('Filter by category').'"', 'data-notfound="'.t('No results match:').'"'), 'apply-filters chosen-select') ?> + <?= $this->form->select('category_id', $categories, array(), array(), array('data-placeholder="'.t('Filter by category').'"', 'data-notfound="'.t('No results match:').'"', 'tabindex=="-1"'), 'apply-filters chosen-select') ?> </li> <li> - <select id="more-filters" multiple data-placeholder="<?= t('More filters') ?>" data-notfound="<?= t('No results match:') ?>" class="apply-filters hide-mobile"> + <select id="more-filters" multiple data-placeholder="<?= t('More filters') ?>" data-notfound="<?= t('No results match:') ?>" class="apply-filters hide-mobile" tabindex="-1"> <option value=""></option> <option value="filter-due-date"><?= t('Filter by due date') ?></option> <option value="filter-recent"><?= t('Filter recently updated') ?></option> diff --git a/app/Template/event/events.php b/app/Template/event/events.php index 2dc79871..971f6587 100644 --- a/app/Template/event/events.php +++ b/app/Template/event/events.php @@ -7,6 +7,8 @@ <p class="activity-datetime"> <?php if ($this->text->contains($event['event_name'], 'subtask')): ?> <i class="fa fa-tasks"></i> + <?php elseif ($this->text->contains($event['event_name'], 'task.move')): ?> + <i class="fa fa-arrows-alt"></i> <?php elseif ($this->text->contains($event['event_name'], 'task')): ?> <i class="fa fa-newspaper-o"></i> <?php elseif ($this->text->contains($event['event_name'], 'comment')): ?> diff --git a/app/Template/event/task_move_swimlane.php b/app/Template/event/task_move_swimlane.php new file mode 100644 index 00000000..ca440dbf --- /dev/null +++ b/app/Template/event/task_move_swimlane.php @@ -0,0 +1,19 @@ +<?= $this->user->avatar($email, $author) ?> + +<p class="activity-title"> + <?php if ($task['swimlane_id'] == 0): ?> + <?= e('%s moved the task %s to the first swimlane', + $this->e($author), + $this->url->link(t('#%d', $task['id']), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) + ) ?> + <?php else: ?> + <?= e('%s moved the task %s to the swimlane "%s"', + $this->e($author), + $this->url->link(t('#%d', $task['id']), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])), + $this->e($task['swimlane_name']) + ) ?> + <?php endif ?> +</p> +<p class="activity-description"> + <em><?= $this->e($task['title']) ?></em> +</p>
\ No newline at end of file diff --git a/app/Template/event/task_update.php b/app/Template/event/task_update.php index 7d036d43..e8254bcb 100644 --- a/app/Template/event/task_update.php +++ b/app/Template/event/task_update.php @@ -8,4 +8,9 @@ </p> <p class="activity-description"> <em><?= $this->e($task['title']) ?></em> + <?php if (isset($changes)): ?> + <div class="activity-changes"> + <?= $this->render('task/changes', array('changes' => $changes, 'task' => $task)) ?> + </div> + <?php endif ?> </p>
\ No newline at end of file diff --git a/app/Template/layout.php b/app/Template/layout.php index cf74c8ab..0d9326f4 100644 --- a/app/Template/layout.php +++ b/app/Template/layout.php @@ -5,6 +5,7 @@ <meta name="viewport" content="width=device-width"> <meta name="mobile-web-app-capable" content="yes"> <meta name="robots" content="noindex,nofollow"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> <?php if (isset($board_public_refresh_interval)): ?> <meta http-equiv="refresh" content="<?= $board_public_refresh_interval ?>"> @@ -37,7 +38,7 @@ <?php else: ?> <header> <nav> - <h1><?= $this->url->link('K<span>B</span>', 'app', 'index', array(), false, 'logo', t('Dashboard')).' '.$this->text->truncate($this->e($title)) ?> + <h1><?= $this->url->link('K<span>B</span>', 'app', 'index', array(), false, 'logo', t('Dashboard')).' '.$this->e($title) ?> <?php if (! empty($description)): ?> <span class="column-tooltip" title='<?= $this->e($this->text->markdown($description)) ?>'> <i class="fa fa-info-circle"></i> @@ -47,7 +48,7 @@ <ul> <?php if (isset($board_selector) && ! empty($board_selector)): ?> <li> - <select id="board-selector" data-notfound="<?= t('No results match:') ?>" data-placeholder="<?= t('Display another project') ?>" data-board-url="<?= $this->url->href('board', 'show', array('project_id' => 'PROJECT_ID')) ?>"> + <select id="board-selector" tabindex=="-1" data-notfound="<?= t('No results match:') ?>" data-placeholder="<?= t('Display another project') ?>" data-board-url="<?= $this->url->href('board', 'show', array('project_id' => 'PROJECT_ID')) ?>"> <option value=""></option> <?php foreach($board_selector as $board_id => $board_name): ?> <option value="<?= $board_id ?>"><?= $this->e($board_name) ?></option> diff --git a/app/Template/notification/comment_creation.php b/app/Template/notification/comment_create.php index 747c4f43..747c4f43 100644 --- a/app/Template/notification/comment_creation.php +++ b/app/Template/notification/comment_create.php diff --git a/app/Template/notification/file_creation.php b/app/Template/notification/file_create.php index 63f7d1b8..63f7d1b8 100644 --- a/app/Template/notification/file_creation.php +++ b/app/Template/notification/file_create.php diff --git a/app/Template/notification/footer.php b/app/Template/notification/footer.php index 7041c43b..69d2cf82 100644 --- a/app/Template/notification/footer.php +++ b/app/Template/notification/footer.php @@ -2,5 +2,6 @@ Kanboard <?php if (isset($application_url) && ! empty($application_url)): ?> - - <a href="<?= $application_url.$this->url->href('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>"><?= t('view the task on Kanboard') ?></a>. + - <a href="<?= $application_url.$this->url->href('task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>"><?= t('view the task on Kanboard') ?></a> + - <a href="<?= $application_url.$this->url->href('board', 'show', array('project_id' => $task['project_id'])) ?>"><?= t('view the board on Kanboard') ?></a> <?php endif ?> diff --git a/app/Template/notification/subtask_creation.php b/app/Template/notification/subtask_create.php index e1c62b73..e1c62b73 100644 --- a/app/Template/notification/subtask_creation.php +++ b/app/Template/notification/subtask_create.php diff --git a/app/Template/notification/task_creation.php b/app/Template/notification/task_create.php index 0905d3f5..1d834d44 100644 --- a/app/Template/notification/task_creation.php +++ b/app/Template/notification/task_create.php @@ -9,14 +9,14 @@ <strong><?= dt('Must be done before %B %e, %Y', $task['date_due']) ?></strong> </li> <?php endif ?> - <?php if ($task['creator_username']): ?> + <?php if (! empty($task['creator_username'])): ?> <li> <?= t('Created by %s', $task['creator_name'] ?: $task['creator_username']) ?> </li> <?php endif ?> <li> <strong> - <?php if ($task['assignee_username']): ?> + <?php if (! empty($task['assignee_username'])): ?> <?= t('Assigned to %s', $task['assignee_name'] ?: $task['assignee_username']) ?> <?php else: ?> <?= t('There is nobody assigned') ?> @@ -28,7 +28,7 @@ <strong><?= $this->e($task['column_title']) ?></strong> </li> <li><?= t('Task position:').' '.$this->e($task['position']) ?></li> - <?php if ($task['category_name']): ?> + <?php if (! empty($task['category_name'])): ?> <li> <?= t('Category:') ?> <strong><?= $this->e($task['category_name']) ?></strong> </li> diff --git a/app/Template/notification/task_move_swimlane.php b/app/Template/notification/task_move_swimlane.php new file mode 100644 index 00000000..04de7cc6 --- /dev/null +++ b/app/Template/notification/task_move_swimlane.php @@ -0,0 +1,19 @@ +<h2><?= $this->e($task['title']) ?> (#<?= $task['id'] ?>)</h2> + +<ul> + <li> + <?php if ($task['swimlane_id'] == 0): ?> + <?= t('The task have been moved to the first swimlane') ?> + <?php else: ?> + <?= t('The task have been moved to another swimlane:') ?> + <strong><?= $this->e($task['swimlane_name']) ?></strong> + <?php endif ?> + </li> + <li> + <?= t('Column on the board:') ?> + <strong><?= $this->e($task['column_title']) ?></strong> + </li> + <li><?= t('Task position:').' '.$this->e($task['position']) ?></li> +</ul> + +<?= $this->render('notification/footer', array('task' => $task, 'application_url' => $application_url)) ?>
\ No newline at end of file diff --git a/app/Template/notification/task_due.php b/app/Template/notification/task_overdue.php index 7482424a..dc2659dc 100644 --- a/app/Template/notification/task_due.php +++ b/app/Template/notification/task_overdue.php @@ -1,4 +1,4 @@ -<h2><?= t('List of due tasks for the project "%s"', $project) ?></h2> +<h2><?= t('Overdue tasks for the project "%s"', $project_name) ?></h2> <ul> <?php foreach ($tasks as $task): ?> @@ -16,5 +16,3 @@ </li> <?php endforeach ?> </ul> - -<?= $this->render('notification/footer', array('task' => $task)) ?> diff --git a/app/Template/notification/task_update.php b/app/Template/notification/task_update.php index ffea49cd..54f5c6d1 100644 --- a/app/Template/notification/task_update.php +++ b/app/Template/notification/task_update.php @@ -1,43 +1,4 @@ <h2><?= $this->e($task['title']) ?> (#<?= $task['id'] ?>)</h2> -<ul> - <li> - <?= dt('Created on %B %e, %Y at %k:%M %p', $task['date_creation']) ?> - </li> - <?php if ($task['date_due']): ?> - <li> - <strong><?= dt('Must be done before %B %e, %Y', $task['date_due']) ?></strong> - </li> - <?php endif ?> - <?php if ($task['creator_username']): ?> - <li> - <?= t('Created by %s', $task['creator_name'] ?: $task['creator_username']) ?> - </li> - <?php endif ?> - <li> - <strong> - <?php if ($task['assignee_username']): ?> - <?= t('Assigned to %s', $task['assignee_name'] ?: $task['assignee_username']) ?> - <?php else: ?> - <?= t('There is nobody assigned') ?> - <?php endif ?> - </strong> - </li> - <li> - <?= t('Column on the board:') ?> - <strong><?= $this->e($task['column_title']) ?></strong> - </li> - <li><?= t('Task position:').' '.$this->e($task['position']) ?></li> - <?php if ($task['category_name']): ?> - <li> - <?= t('Category:') ?> <strong><?= $this->e($task['category_name']) ?></strong> - </li> - <?php endif ?> -</ul> - -<?php if (! empty($task['description'])): ?> - <h2><?= t('Description') ?></h2> - <?= $this->text->markdown($task['description']) ?: t('There is no description.') ?> -<?php endif ?> - +<?= $this->render('task/changes', array('changes' => $changes, 'task' => $task)) ?> <?= $this->render('notification/footer', array('task' => $task, 'application_url' => $application_url)) ?>
\ No newline at end of file diff --git a/app/Template/task/changes.php b/app/Template/task/changes.php new file mode 100644 index 00000000..c7fc0d52 --- /dev/null +++ b/app/Template/task/changes.php @@ -0,0 +1,78 @@ +<?php if (! empty($changes)): ?> + <ul> + <?php + + foreach ($changes as $field => $value) { + + switch ($field) { + case 'title': + echo '<li>'.t('New title: %s', $task['title']).'</li>'; + break; + case 'owner_id': + if (empty($task['owner_id'])) { + echo '<li>'.t('The task is not assigned anymore').'</li>'; + } + else { + echo '<li>'.t('New assignee: %s', $task['assignee_name'] ?: $task['assignee_username']).'</li>'; + } + break; + case 'category_id': + if (empty($task['category_id'])) { + echo '<li>'.t('There is no category now').'</li>'; + } + else { + echo '<li>'.t('New category: %s', $task['category_name']).'</li>'; + } + break; + case 'color_id': + echo '<li>'.t('New color: %s', $this->text->in($task['color_id'], $this->task->getColors())).'</li>'; + break; + case 'score': + echo '<li>'.t('New complexity: %d', $task['score']).'</li>'; + break; + case 'date_due': + if (empty($task['date_due'])) { + echo '<li>'.t('The due date have been removed').'</li>'; + } + else { + echo '<li>'.dt('New due date: %B %e, %Y', $task['date_due']).'</li>'; + } + break; + case 'description': + if (empty($task['description'])) { + echo '<li>'.t('There is no description anymore').'</li>'; + } + break; + case 'recurrence_status': + case 'recurrence_trigger': + case 'recurrence_factor': + case 'recurrence_timeframe': + case 'recurrence_basedate': + case 'recurrence_parent': + case 'recurrence_child': + echo '<li>'.t('Recurrence settings have been modified').'</li>'; + break; + case 'time_spent': + echo '<li>'.t('Time spent changed: %sh', $task['time_spent']).'</li>'; + break; + case 'time_estimated': + echo '<li>'.t('Time estimated changed: %sh', $task['time_estimated']).'</li>'; + break; + case 'date_started': + if ($value != 0) { + echo '<li>'.dt('Start date changed: %B %e, %Y', $task['date_started']).'</li>'; + } + break; + default: + echo '<li>'.t('The field "%s" have been updated', $field).'</li>'; + } + } + + ?> + </ul> + + <?php if (! empty($changes['description'])): ?> + <p><?= t('The description have been modified') ?></p> + <div class="markdown"><?= $this->text->markdown($task['description']) ?></div> + <?php endif ?> +<?php endif ?>
\ No newline at end of file diff --git a/app/Template/task/edit.php b/app/Template/task/edit.php index 2900b739..359df779 100644 --- a/app/Template/task/edit.php +++ b/app/Template/task/edit.php @@ -9,11 +9,17 @@ <div class="form-column"> <?= $this->form->label(t('Title'), 'title') ?> - <?= $this->form->text('title', $values, $errors, array('required', 'maxlength="200"')) ?><br/> + <?= $this->form->text('title', $values, $errors, array('autofocus', 'required', 'maxlength="200"', 'tabindex="1"')) ?><br/> <?= $this->form->label(t('Description'), 'description') ?> <div class="form-tabs"> + <div class="write-area"> + <?= $this->form->textarea('description', $values, $errors, array('placeholder="'.t('Leave a description').'"', 'tabindex="2"')) ?> + </div> + <div class="preview-area"> + <div class="markdown"></div> + </div> <ul class="form-tabs-nav"> <li class="form-tab form-tab-selected"> <i class="fa fa-pencil-square-o fa-fw"></i><a id="markdown-write" href="#"><?= t('Write') ?></a> @@ -22,12 +28,6 @@ <a id="markdown-preview" href="#"><i class="fa fa-eye fa-fw"></i><?= t('Preview') ?></a> </li> </ul> - <div class="write-area"> - <?= $this->form->textarea('description', $values, $errors, array('placeholder="'.t('Leave a description').'"')) ?> - </div> - <div class="preview-area"> - <div class="markdown"></div> - </div> </div> <div class="form-help"><a href="http://kanboard.net/documentation/syntax-guide" target="_blank" rel="noreferrer"><?= t('Write your text in Markdown') ?></a></div> @@ -39,24 +39,24 @@ <?= $this->form->hidden('project_id', $values) ?> <?= $this->form->label(t('Assignee'), 'owner_id') ?> - <?= $this->form->select('owner_id', $users_list, $values, $errors) ?><br/> + <?= $this->form->select('owner_id', $users_list, $values, $errors, array('tabindex="3"')) ?><br/> <?= $this->form->label(t('Category'), 'category_id') ?> - <?= $this->form->select('category_id', $categories_list, $values, $errors) ?><br/> + <?= $this->form->select('category_id', $categories_list, $values, $errors, array('tabindex="4"')) ?><br/> <?= $this->form->label(t('Color'), 'color_id') ?> - <?= $this->form->select('color_id', $colors_list, $values, $errors) ?><br/> + <?= $this->form->select('color_id', $colors_list, $values, $errors, array('tabindex="5"')) ?><br/> <?= $this->form->label(t('Complexity'), 'score') ?> - <?= $this->form->number('score', $values, $errors) ?><br/> + <?= $this->form->number('score', $values, $errors, array('tabindex="6"')) ?><br/> <?= $this->form->label(t('Due Date'), 'date_due') ?> - <?= $this->form->text('date_due', $values, $errors, array('placeholder="'.$this->text->in($date_format, $date_formats).'"'), 'form-date') ?><br/> + <?= $this->form->text('date_due', $values, $errors, array('placeholder="'.$this->text->in($date_format, $date_formats).'"', 'tabindex="7"'), 'form-date') ?><br/> <div class="form-help"><?= t('Others formats accepted: %s and %s', date('Y-m-d'), date('Y_m_d')) ?></div> </div> <div class="form-actions"> - <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/> + <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue" tabindex="10"> <?= t('or') ?> <?php if ($ajax): ?> <?= $this->url->link(t('cancel'), 'board', 'show', array('project_id' => $task['project_id']), false, 'close-popover') ?> diff --git a/app/Template/task/new.php b/app/Template/task/new.php index bd00d347..181b82bf 100644 --- a/app/Template/task/new.php +++ b/app/Template/task/new.php @@ -17,11 +17,17 @@ <div class="form-column"> <?= $this->form->label(t('Title'), 'title') ?> - <?= $this->form->text('title', $values, $errors, array('autofocus', 'required', 'maxlength="200"'), 'form-input-large') ?><br/> + <?= $this->form->text('title', $values, $errors, array('autofocus', 'required', 'maxlength="200"', 'tabindex="1"'), 'form-input-large') ?><br/> <?= $this->form->label(t('Description'), 'description') ?> <div class="form-tabs"> + <div class="write-area"> + <?= $this->form->textarea('description', $values, $errors, array('placeholder="'.t('Leave a description').'"', 'tabindex="2"')) ?> + </div> + <div class="preview-area"> + <div class="markdown"></div> + </div> <ul class="form-tabs-nav"> <li class="form-tab form-tab-selected"> <i class="fa fa-pencil-square-o fa-fw"></i><a id="markdown-write" href="#"><?= t('Write') ?></a> @@ -30,12 +36,6 @@ <a id="markdown-preview" href="#"><i class="fa fa-eye fa-fw"></i><?= t('Preview') ?></a> </li> </ul> - <div class="write-area"> - <?= $this->form->textarea('description', $values, $errors, array('placeholder="'.t('Leave a description').'"')) ?> - </div> - <div class="preview-area"> - <div class="markdown"></div> - </div> </div> <div class="form-help"><a href="http://kanboard.net/documentation/syntax-guide" target="_blank" rel="noreferrer"><?= t('Write your text in Markdown') ?></a></div> @@ -49,35 +49,35 @@ <?= $this->form->hidden('project_id', $values) ?> <?= $this->form->label(t('Assignee'), 'owner_id') ?> - <?= $this->form->select('owner_id', $users_list, $values, $errors) ?><br/> + <?= $this->form->select('owner_id', $users_list, $values, $errors, array('tabindex="3"')) ?><br/> <?= $this->form->label(t('Category'), 'category_id') ?> - <?= $this->form->select('category_id', $categories_list, $values, $errors) ?><br/> + <?= $this->form->select('category_id', $categories_list, $values, $errors, array('tabindex="4"')) ?><br/> <?php if (! (count($swimlanes_list) === 1 && key($swimlanes_list) === 0)): ?> <?= $this->form->label(t('Swimlane'), 'swimlane_id') ?> - <?= $this->form->select('swimlane_id', $swimlanes_list, $values, $errors) ?><br/> + <?= $this->form->select('swimlane_id', $swimlanes_list, $values, $errors, array('tabindex="5"')) ?><br/> <?php endif ?> <?= $this->form->label(t('Column'), 'column_id') ?> - <?= $this->form->select('column_id', $columns_list, $values, $errors) ?><br/> + <?= $this->form->select('column_id', $columns_list, $values, $errors, array('tabindex="6"')) ?><br/> <?= $this->form->label(t('Color'), 'color_id') ?> - <?= $this->form->select('color_id', $colors_list, $values, $errors) ?><br/> + <?= $this->form->select('color_id', $colors_list, $values, $errors, array('tabindex="7"')) ?><br/> <?= $this->form->label(t('Complexity'), 'score') ?> - <?= $this->form->number('score', $values, $errors) ?><br/> + <?= $this->form->number('score', $values, $errors, array('tabindex="8"')) ?><br/> <?= $this->form->label(t('Original estimate'), 'time_estimated') ?> - <?= $this->form->numeric('time_estimated', $values, $errors) ?> <?= t('hours') ?><br/> + <?= $this->form->numeric('time_estimated', $values, $errors, array('tabindex="9"')) ?> <?= t('hours') ?><br/> <?= $this->form->label(t('Due Date'), 'date_due') ?> - <?= $this->form->text('date_due', $values, $errors, array('placeholder="'.$this->text->in($date_format, $date_formats).'"'), 'form-date') ?><br/> + <?= $this->form->text('date_due', $values, $errors, array('placeholder="'.$this->text->in($date_format, $date_formats).'"', 'tabindex="10"'), 'form-date') ?><br/> <div class="form-help"><?= t('Others formats accepted: %s and %s', date('Y-m-d'), date('Y_m_d')) ?></div> </div> <div class="form-actions"> - <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/> + <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue" tabindex="11"/> <?= t('or') ?> <?= $this->url->link(t('cancel'), 'board', 'show', array('project_id' => $values['project_id']), false, 'close-popover') ?> </div> </form> diff --git a/app/Template/user/notifications.php b/app/Template/user/notifications.php index df5cbb9b..a425705d 100644 --- a/app/Template/user/notifications.php +++ b/app/Template/user/notifications.php @@ -5,15 +5,27 @@ <form method="post" action="<?= $this->url->href('user', 'notifications', array('user_id' => $user['id'])) ?>" autocomplete="off"> <?= $this->form->csrf() ?> + <?= $this->form->checkbox('notifications_enabled', t('Enable email notifications'), '1', $notifications['notifications_enabled'] == 1) ?><br> - <?= $this->form->checkbox('notifications_enabled', t('Enable email notifications'), '1', $notifications['notifications_enabled'] == 1) ?><br/> + <hr> + + <?= t('I want to receive notifications for:') ?> + + <?= $this->form->radios('notifications_filter', array( + \Model\Notification::FILTER_NONE => t('All tasks'), + \Model\Notification::FILTER_ASSIGNEE => t('Only for tasks assigned to me'), + \Model\Notification::FILTER_CREATOR => t('Only for tasks created by me'), + \Model\Notification::FILTER_BOTH => t('Only for tasks created by me and assigned to me'), + ), $notifications) ?><br> + + <hr> <?php if (! empty($projects)): ?> <p><?= t('I want to receive notifications only for those projects:') ?><br/><br/></p> <div class="form-checkbox-group"> <?php foreach ($projects as $project_id => $project_name): ?> - <?= $this->form->checkbox('projects['.$project_id.']', $project_name, '1', isset($notifications['project_'.$project_id])) ?><br/> + <?= $this->form->checkbox('projects['.$project_id.']', $project_name, '1', isset($notifications['project_'.$project_id])) ?><br> <?php endforeach ?> </div> <?php endif ?> diff --git a/app/check_setup.php b/app/check_setup.php index 065b8e10..624b6b34 100644 --- a/app/check_setup.php +++ b/app/check_setup.php @@ -38,3 +38,15 @@ if (! is_writable('data')) { if (ini_get('arg_separator.output') === '&') { ini_set('arg_separator.output', '&'); } + +// Prepare folder for uploaded files +if (! is_dir(FILES_DIR)) { + if (! mkdir(FILES_DIR, 0755, true)) { + die('Unable to create the upload directory: "'.FILES_DIR.'"'); + } +} + +// Check permissions for files folder +if (! is_writable(FILES_DIR)) { + die('The directory "'.FILES_DIR.'" must be writeable by your webserver user'); +} diff --git a/app/common.php b/app/common.php index 01c3077b..d1659018 100644 --- a/app/common.php +++ b/app/common.php @@ -21,10 +21,10 @@ if (file_exists('config.php')) { } require __DIR__.'/constants.php'; +require __DIR__.'/check_setup.php'; $container = new Pimple\Container; $container->register(new ServiceProvider\LoggingProvider); $container->register(new ServiceProvider\DatabaseProvider); $container->register(new ServiceProvider\ClassProvider); $container->register(new ServiceProvider\EventDispatcherProvider); -$container->register(new ServiceProvider\MailerProvider); diff --git a/app/constants.php b/app/constants.php index df57fe30..9b66b746 100644 --- a/app/constants.php +++ b/app/constants.php @@ -64,6 +64,11 @@ defined('MAIL_SMTP_USERNAME') or define('MAIL_SMTP_USERNAME', ''); defined('MAIL_SMTP_PASSWORD') or define('MAIL_SMTP_PASSWORD', ''); defined('MAIL_SMTP_ENCRYPTION') or define('MAIL_SMTP_ENCRYPTION', null); defined('MAIL_SENDMAIL_COMMAND') or define('MAIL_SENDMAIL_COMMAND', '/usr/sbin/sendmail -bs'); +defined('POSTMARK_API_TOKEN') or define('POSTMARK_API_TOKEN', ''); +defined('MAILGUN_API_TOKEN') or define('MAILGUN_API_TOKEN', ''); +defined('MAILGUN_DOMAIN') or define('MAILGUN_DOMAIN', ''); +defined('SENDGRID_API_USER') or define('SENDGRID_API_USER', ''); +defined('SENDGRID_API_KEY') or define('SENDGRID_API_KEY', ''); // Enable or disable "Strict-Transport-Security" HTTP header defined('ENABLE_HSTS') or define('ENABLE_HSTS', true); diff --git a/assets/css/app.css b/assets/css/app.css index d006d562..f71c96b3 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -653,13 +653,16 @@ div.ui-tooltip { header { margin-top: 10px; padding-bottom: 15px; - clear: both; border-bottom: 1px solid #dedede; } header h1 { margin: 0; padding: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 75%; float: left; } @@ -1370,7 +1373,17 @@ span.task-board-date-overdue { .activity-description .markdown { margin-top: 10px; color: #555; -}/* dashboard */ +} + +.activity-changes { + margin-top: 10px; + font-size: 0.85em; +} + +.activity-changes ul { + margin-left: 25px; +} +/* dashboard */ @media only screen and (min-width: 1280px) { diff --git a/assets/css/src/activity.css b/assets/css/src/activity.css index 2d44aa5d..3dd18a9d 100644 --- a/assets/css/src/activity.css +++ b/assets/css/src/activity.css @@ -39,4 +39,13 @@ .activity-description .markdown { margin-top: 10px; color: #555; -}
\ No newline at end of file +} + +.activity-changes { + margin-top: 10px; + font-size: 0.85em; +} + +.activity-changes ul { + margin-left: 25px; +} diff --git a/assets/css/src/header.css b/assets/css/src/header.css index 2490b125..bcb9dcd7 100644 --- a/assets/css/src/header.css +++ b/assets/css/src/header.css @@ -2,13 +2,16 @@ header { margin-top: 10px; padding-bottom: 15px; - clear: both; border-bottom: 1px solid #dedede; } header h1 { margin: 0; padding: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 75%; float: left; } diff --git a/composer.lock b/composer.lock index cbcacef9..35709f5b 100644 --- a/composer.lock +++ b/composer.lock @@ -599,23 +599,23 @@ }, { "name": "swiftmailer/swiftmailer", - "version": "v5.4.0", + "version": "v5.4.1", "source": { "type": "git", "url": "https://github.com/swiftmailer/swiftmailer.git", - "reference": "31454f258f10329ae7c48763eb898a75c39e0a9f" + "reference": "0697e6aa65c83edf97bb0f23d8763f94e3f11421" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/swiftmailer/swiftmailer/zipball/31454f258f10329ae7c48763eb898a75c39e0a9f", - "reference": "31454f258f10329ae7c48763eb898a75c39e0a9f", + "url": "https://api.github.com/repos/swiftmailer/swiftmailer/zipball/0697e6aa65c83edf97bb0f23d8763f94e3f11421", + "reference": "0697e6aa65c83edf97bb0f23d8763f94e3f11421", "shasum": "" }, "require": { "php": ">=5.3.3" }, "require-dev": { - "mockery/mockery": "~0.9.1" + "mockery/mockery": "~0.9.1,<0.9.4" }, "type": "library", "extra": { @@ -644,23 +644,24 @@ "description": "Swiftmailer, free feature-rich PHP mailer", "homepage": "http://swiftmailer.org", "keywords": [ + "email", "mail", "mailer" ], - "time": "2015-03-14 06:06:39" + "time": "2015-06-06 14:19:39" }, { "name": "symfony/console", - "version": "v2.7.0", + "version": "v2.7.1", "source": { "type": "git", "url": "https://github.com/symfony/Console.git", - "reference": "7f0bec04961c61c961df0cb8c2ae88dbfd83f399" + "reference": "564398bc1f33faf92fc2ec86859983d30eb81806" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/Console/zipball/7f0bec04961c61c961df0cb8c2ae88dbfd83f399", - "reference": "7f0bec04961c61c961df0cb8c2ae88dbfd83f399", + "url": "https://api.github.com/repos/symfony/Console/zipball/564398bc1f33faf92fc2ec86859983d30eb81806", + "reference": "564398bc1f33faf92fc2ec86859983d30eb81806", "shasum": "" }, "require": { @@ -704,20 +705,20 @@ ], "description": "Symfony Console Component", "homepage": "https://symfony.com", - "time": "2015-05-29 16:22:24" + "time": "2015-06-10 15:30:22" }, { "name": "symfony/event-dispatcher", - "version": "v2.7.0", + "version": "v2.7.1", "source": { "type": "git", "url": "https://github.com/symfony/EventDispatcher.git", - "reference": "687039686d0e923429ba6e958d0baa920cd5d458" + "reference": "be3c5ff8d503c46768aeb78ce6333051aa6f26d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/EventDispatcher/zipball/687039686d0e923429ba6e958d0baa920cd5d458", - "reference": "687039686d0e923429ba6e958d0baa920cd5d458", + "url": "https://api.github.com/repos/symfony/EventDispatcher/zipball/be3c5ff8d503c46768aeb78ce6333051aa6f26d9", + "reference": "be3c5ff8d503c46768aeb78ce6333051aa6f26d9", "shasum": "" }, "require": { @@ -762,22 +763,22 @@ ], "description": "Symfony EventDispatcher Component", "homepage": "https://symfony.com", - "time": "2015-05-02 15:21:08" + "time": "2015-06-08 09:37:21" } ], "packages-dev": [ { "name": "symfony/stopwatch", - "version": "v2.7.0", + "version": "v2.7.1", "source": { "type": "git", "url": "https://github.com/symfony/Stopwatch.git", - "reference": "7702945bceddc0e1f744519abb8a2baeb94bd5ce" + "reference": "c653f1985f6c2b7dbffd04d48b9c0a96aaef814b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/Stopwatch/zipball/7702945bceddc0e1f744519abb8a2baeb94bd5ce", - "reference": "7702945bceddc0e1f744519abb8a2baeb94bd5ce", + "url": "https://api.github.com/repos/symfony/Stopwatch/zipball/c653f1985f6c2b7dbffd04d48b9c0a96aaef814b", + "reference": "c653f1985f6c2b7dbffd04d48b9c0a96aaef814b", "shasum": "" }, "require": { @@ -813,7 +814,7 @@ ], "description": "Symfony Stopwatch Component", "homepage": "https://symfony.com", - "time": "2015-05-02 15:21:08" + "time": "2015-06-04 20:11:48" } ], "aliases": [], diff --git a/config.default.php b/config.default.php index 7f379f5c..7c6955e8 100644 --- a/config.default.php +++ b/config.default.php @@ -1,6 +1,8 @@ <?php -// Rename this file to config.php if you want to change the values +/*******************************************************************/ +/* Rename this file to config.php if you want to change the values */ +/*******************************************************************/ // Enable/Disable debug define('DEBUG', false); @@ -14,7 +16,7 @@ define('FILES_DIR', 'data/files/'); // E-mail address for the "From" header (notifications) define('MAIL_FROM', 'notifications@kanboard.local'); -// Mail transport to use: "smtp", "sendmail" or "mail" (PHP mail function) +// Mail transport available: "smtp", "sendmail", "mail" (PHP mail function), "postmark", "mailgun", "sendgrid" define('MAIL_TRANSPORT', 'mail'); // SMTP configuration to use when the "smtp" transport is chosen @@ -27,6 +29,19 @@ define('MAIL_SMTP_ENCRYPTION', null); // Valid values are "null", "ssl" or "tls" // Sendmail command to use when the transport is "sendmail" define('MAIL_SENDMAIL_COMMAND', '/usr/sbin/sendmail -bs'); +// Postmark API token (used to send emails through their API) +define('POSTMARK_API_TOKEN', ''); + +// Mailgun API key (used to send emails through their API) +define('MAILGUN_API_TOKEN', ''); + +// Mailgun domain name +define('MAILGUN_DOMAIN', ''); + +// Sendgrid API configuration +define('SENDGRID_API_USER', ''); +define('SENDGRID_API_KEY', ''); + // Database driver: sqlite, mysql or postgres (sqlite by default) define('DB_DRIVER', 'sqlite'); diff --git a/docs/api-json-rpc.markdown b/docs/api-json-rpc.markdown index 929d63fd..3e5d76a6 100644 --- a/docs/api-json-rpc.markdown +++ b/docs/api-json-rpc.markdown @@ -1916,7 +1916,7 @@ Response example: ### getTask -- Purpose: **Get task information** +- Purpose: **Get task by the unique id** - Parameters: - **task_id** (integer, required) - Result on success: **task properties** @@ -1975,6 +1975,69 @@ Response example: } ``` +### getTaskByReference + +- Purpose: **Get task by the external reference** +- Parameters: + - **project_id** (integer, required) + - **reference** (string, required) +- Result on success: **task properties** +- Result on failure: **null** + +Request example: + +```json +{ + "jsonrpc": "2.0", + "method": "getTaskByReference", + "id": 1992081213, + "params": { + "project_id": 1, + "reference": "TICKET-1234" + } +} +``` + +Response example: + +```json +{ + "jsonrpc": "2.0", + "id": 1992081213, + "result": { + "id": "5", + "title": "Task with external ticket number", + "description": "[Link to my ticket](http:\/\/my-ticketing-system\/1234)", + "date_creation": "1434227446", + "color_id": "yellow", + "project_id": "1", + "column_id": "1", + "owner_id": "0", + "position": "4", + "is_active": "1", + "date_completed": null, + "score": "0", + "date_due": "0", + "category_id": "0", + "creator_id": "0", + "date_modification": "1434227446", + "reference": "TICKET-1234", + "date_started": null, + "time_spent": "0", + "time_estimated": "0", + "swimlane_id": "0", + "date_moved": "1434227446", + "recurrence_status": "0", + "recurrence_trigger": "0", + "recurrence_factor": "0", + "recurrence_timeframe": "0", + "recurrence_basedate": "0", + "recurrence_parent": null, + "recurrence_child": null + } +} +``` + ### getAllTasks - Purpose: **Get all available tasks** @@ -3542,9 +3605,8 @@ Response example: - **project_id** (integer, required) - **task_id** (integer, required) - **filename** (integer, required) - - **is_image** (boolean, required) - **blob** File content encoded in base64 (string, required) -- Result on success: **true** +- Result on success: **file_id** - Result on failure: **false** - Note: **The maximum file size depends of your PHP configuration, this method should not be used to upload large files** @@ -3554,12 +3616,11 @@ Request example: { "jsonrpc": "2.0", "method": "createFile", - "id": 1035045925, + "id": 94500810, "params": [ 1, 1, "My file", - false, "cGxhaW4gdGV4dCBmaWxl" ] } @@ -3570,8 +3631,8 @@ Response example: ```json { "jsonrpc": "2.0", - "id": 1035045925, - "result": true + "id": 94500810, + "result": 1 } ``` @@ -3720,3 +3781,34 @@ Response example: "result": true } ``` + +### removeAllFiles + +- Purpose: **Remove all files associated to a task** +- Parameters: + - **task_id** (integer, required) +- Result on success: **true** +- Result on failure: **false** + +Request example: + +```json +{ + "jsonrpc": "2.0", + "method": "removeAllFiles", + "id": 593312993, + "params": { + "task_id": 1 + } +} +``` + +Response example: + +```json +{ + "jsonrpc": "2.0", + "id": 593312993, + "result": true +} +``` diff --git a/docs/email-configuration.markdown b/docs/email-configuration.markdown index d5559a8f..c66996c6 100644 --- a/docs/email-configuration.markdown +++ b/docs/email-configuration.markdown @@ -12,6 +12,18 @@ To receive email notifications, users of Kanboard must have: Note: The logged user who performs the action doesn't receive any notifications, only other project members. +Email transports +---------------- + +There are several email transports available: + +- SMTP +- Sendmail +- PHP native mail function +- Mailgun +- Postmark +- Sendgrid + Server settings --------------- @@ -57,6 +69,71 @@ define('MAIL_TRANSPORT', 'sendmail'); define('MAIL_SENDMAIL_COMMAND', '/usr/sbin/sendmail -bs'); ``` +### PHP native mail function + +This is the default configuration: + +```php +define('MAIL_TRANSPORT', 'mail'); +``` + +### Mailgun HTTP API + +You can use the HTTP API of Mailgun to send emails. + +Configuration: + +```php +// We choose "mailgun" as mail transport +define('MAIL_TRANSPORT', 'mailgun'); + +// Mailgun API key +define('MAILGUN_API_TOKEN', 'YOUR_API_KEY'); + +// Mailgun domain name +define('MAILGUN_DOMAIN', 'YOUR_DOMAIN_CONFIGURED_IN_MAILGUN'); + +// Be sure to use the sender email address configured in Mailgun +define('MAIL_FROM', 'sender-address-configured-in-mailgun@example.org'); +``` + +### Postmark HTTP API + +Postmark is a third-party email service. +If you already use the Postmark integration to receive emails in Kanboard you can use the same provider to send email too. + +This system use their HTTP API instead of the SMTP protocol. + +Here are the required settings for this configuration: + +```php +// We choose "postmark" as mail transport +define('MAIL_TRANSPORT', 'postmark'); + +// Copy and paste your Postmark API token +define('POSTMARK_API_TOKEN', 'COPY HERE YOUR POSTMARK API TOKEN'); + +// Be sure to use the Postmark configured sender email address +define('MAIL_FROM', 'sender-address-configured-in-postmark@example.org'); +``` + +### Sendgrid HTTP API + +You can use the HTTP API of Sendgrid to send emails. + +Configuration: + +```php +// We choose "sendgrid" as mail transport +define('MAIL_TRANSPORT', 'sendgrid'); + +// Sendgrid username +define('SENDGRID_API_USER', 'YOUR_SENDGRID_USERNAME'); + +// Sendgrid password +define('SENDGRID_API_KEY', 'YOUR_SENDGRID_PASSWORD'); +``` + ### The sender email address By default, emails will use the sender address `notifications@kanboard.local`. diff --git a/docs/index.markdown b/docs/index.markdown new file mode 100644 index 00000000..75df4694 --- /dev/null +++ b/docs/index.markdown @@ -0,0 +1,118 @@ +Documentation +============= + +Using Kanboard +-------------- + +### Introduction + +- [What is Kanban?](what-is-kanban.markdown) +- [Kanban vs Todo Lists and Scrum](kanban-vs-todo-and-scrum.markdown) +- [Usage examples](usage-examples.markdown) + +### Working with projects + +- [Creating projects](creating-projects.markdown) +- [Editing projects](editing-projects.markdown) +- [Sharing boards and tasks](sharing-projects.markdown) +- [Automatic actions](automatic-actions.markdown) +- [Project permissions](project-permissions.markdown) +- [Swimlanes](swimlanes.markdown) +- [Calendar](calendar.markdown) +- [Budget](budget.markdown) +- [Analytics](analytics.markdown) + +### Working with tasks + +- [Creating tasks](creating-tasks.markdown) +- [Adding screenshots](screenshots.markdown) +- [Task links](task-links.markdown) +- [Transitions](transitions.markdown) +- [Time tracking](time-tracking.markdown) +- [Recurring tasks](recurring-tasks.markdown) +- [Create tasks by email](create-tasks-by-email.markdown) + +### Working with users + +- [User management](user-management.markdown) +- [Hourly rate](hourly-rate.markdown) +- [Timetable](timetable.markdown) +- [Two factor authentication](2fa.markdown) + +### Settings + +- [Keyboard shortcuts](keyboard-shortcuts.markdown) +- [Application settings](application-configuration.markdown) +- [Project settings](project-configuration.markdown) +- [Board settings](board-configuration.markdown) +- [Calendar settings](calendar-configuration.markdown) +- [Link settings](link-labels.markdown) +- [Currency rate](currency-rate.markdown) +- [Config file](config.markdown) + +### Integrations + +- [Bitbucket webhooks](bitbucket-webhooks.markdown) +- [Github webhooks](github-webhooks.markdown) +- [Gitlab webhooks](gitlab-webhooks.markdown) +- [Hipchat](hipchat.markdown) +- [Jabber](jabber.markdown) +- [Mailgun](mailgun.markdown) +- [Sendgrid](sendgrid.markdown) +- [Slack](slack.markdown) +- [Postmark](postmark.markdown) +- [iCalendar subscriptions](ical.markdown) +- [Json-RPC API](api-json-rpc.markdown) +- [Webhooks](webhooks.markdown) + +### More + +- [Command line interface](cli.markdown) +- [Syntax guide](syntax-guide.markdown) +- [Frequently asked questions](faq.markdown) + +Technical details +----------------- + +### Installation + +- [Installation instructions](installation.markdown) +- [Upgrade Kanboard to a new version](update.markdown) +- [Installation on Ubuntu](ubuntu-installation.markdown) +- [Installation on Debian](debian-installation.markdown) +- [Installation on Centos](centos-installation.markdown) +- [Installation on FreeBSD](freebsd-installation.markdown) +- [Installation on Windows Server with IIS](windows-iis-installation.markdown) +- [Installation on Windows Server with Apache](windows-apache-installation.markdown) +- [Installation on Heroku](heroku.markdown) +- [Example with Nginx + HTTPS + SPDY + PHP-FPM](nginx-ssl-php-fpm.markdown) +- [Run Kanboard with Docker](docker.markdown) + +### Configuration + +- [Email configuration](email-configuration.markdown) + +### Database + +- [Sqlite database management](sqlite-database.markdown) +- [How to use Mysql](mysql-configuration.markdown) +- [How to use Postgresql](postgresql-configuration.markdown) + +### Authentication + +- [LDAP authentication](ldap-authentication.markdown) +- [Google authentication](google-authentication.markdown) +- [GitHub authentication](github-authentication.markdown) +- [Reverse proxy authentication](reverse-proxy-authentication.markdown) + +### Contributors + +- [Contributor guide](contributing.markdown) +- [Translations](translations.markdown) +- [Coding standards](coding-standards.markdown) +- [Running tests](tests.markdown) +- [Build assets](assets.markdown) +- [Run Kanboard with Vagrant](vagrant.markdown) + +The documentation is written in [Markdown](http://en.wikipedia.org/wiki/Markdown). +If you want to improve the documentation, just send a pull-request. diff --git a/docs/webhooks.markdown b/docs/webhooks.markdown index fa3e11d8..4ace116b 100644 --- a/docs/webhooks.markdown +++ b/docs/webhooks.markdown @@ -122,7 +122,7 @@ Task modification: "date_completed": null, "score": "0", "date_due": "0", - "category_id": "0", + "category_id": "2", "creator_id": "1", "date_modification": 1431991603, "reference": "", @@ -138,11 +138,16 @@ Task modification: "recurrence_basedate": "0", "recurrence_parent": null, "recurrence_child": null, - "task_id": "1" + "task_id": "1", + "changes": { + "category_id": "2" + } } } ``` +Task update events have a field called `changes` that contains updated values. + Move a task to another column: ```json @@ -1,6 +1,5 @@ <?php -require __DIR__.'/app/check_setup.php'; require __DIR__.'/app/common.php'; use Core\Router; diff --git a/tests/functionals.sqlite.xml b/tests/functionals.sqlite.xml index bf5d4117..0d011ac3 100644 --- a/tests/functionals.sqlite.xml +++ b/tests/functionals.sqlite.xml @@ -5,7 +5,7 @@ </testsuite> </testsuites> <php> - <const name="API_URL" value="http://localhost:8000/jsonrpc.php" /> + <const name="API_URL" value="http://127.0.0.1:8000/jsonrpc.php" /> <const name="API_KEY" value="19ffd9709d03ce50675c3a43d1c49c1ac207f4bc45f06c5b2701fbdf8929" /> <const name="DB_DRIVER" value="sqlite" /> <const name="DB_FILENAME" value="data/db.sqlite" /> diff --git a/tests/functionals/ApiTest.php b/tests/functionals/ApiTest.php index b5039759..92c752bb 100644 --- a/tests/functionals/ApiTest.php +++ b/tests/functionals/ApiTest.php @@ -937,7 +937,7 @@ class Api extends PHPUnit_Framework_TestCase public function testCreateFile() { - $this->assertTrue($this->client->createFile(1, 1, 'My file', false, base64_encode('plain text file'))); + $this->assertEquals(1, $this->client->createFile(1, 1, 'My file', base64_encode('plain text file'))); } public function testGetAllFiles() @@ -962,4 +962,44 @@ class Api extends PHPUnit_Framework_TestCase $this->assertTrue($this->client->removeFile($file['id'])); $this->assertEmpty($this->client->getAllFiles(1)); } + + public function testRemoveAllFiles() + { + $this->assertEquals(1, $this->client->createFile(1, 1, 'My file 1', base64_encode('plain text file'))); + $this->assertEquals(2, $this->client->createFile(1, 1, 'My file 2', base64_encode('plain text file'))); + + $files = $this->client->getAllFiles(array('task_id' => 1)); + $this->assertNotEmpty($files); + $this->assertCount(2, $files); + + $this->assertTrue($this->client->removeAllFiles(array('task_id' => 1))); + + $files = $this->client->getAllFiles(array('task_id' => 1)); + $this->assertEmpty($files); + } + + public function testCreateTaskWithReference() + { + $task = array( + 'title' => 'Task with external ticket number', + 'reference' => 'TICKET-1234', + 'project_id' => 1, + 'description' => '[Link to my ticket](http://my-ticketing-system/1234)', + ); + + $task_id = $this->client->createTask($task); + + $this->assertNotFalse($task_id); + $this->assertInternalType('int', $task_id); + $this->assertTrue($task_id > 0); + } + + public function testGetTaskByReference() + { + $task = $this->client->getTaskByReference(array('project_id' => 1, 'reference' => 'TICKET-1234')); + + $this->assertNotEmpty($task); + $this->assertEquals('Task with external ticket number', $task['title']); + $this->assertEquals('TICKET-1234', $task['reference']); + } }
\ No newline at end of file diff --git a/tests/units/ActionTaskMoveColumnCategoryChangeTest.php b/tests/units/ActionTaskMoveColumnCategoryChangeTest.php new file mode 100644 index 00000000..0ddee786 --- /dev/null +++ b/tests/units/ActionTaskMoveColumnCategoryChangeTest.php @@ -0,0 +1,61 @@ +<?php + +require_once __DIR__.'/Base.php'; + +use Event\GenericEvent; +use Model\Task; +use Model\TaskCreation; +use Model\TaskFinder; +use Model\Project; +use Model\Category; +use Integration\GithubWebhook; + +class ActionTaskMoveColumnCategoryChangeTest extends Base +{ + public function testExecute() + { + $action = new Action\TaskMoveColumnCategoryChange($this->container, 1, Task::EVENT_UPDATE); + $action->setParam('dest_column_id', 3); + $action->setParam('category_id', 1); + + $this->assertEquals(3, $action->getParam('dest_column_id')); + $this->assertEquals(1, $action->getParam('category_id')); + + // We create a task in the first column + $tc = new TaskCreation($this->container); + $tf = new TaskFinder($this->container); + $p = new Project($this->container); + $c = new Category($this->container); + + $this->assertEquals(1, $p->create(array('name' => 'test'))); + $this->assertEquals(1, $c->create(array('name' => 'bug', 'project_id' => 1))); + $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 1))); + + // No category should be assigned + column_id=1 + $task = $tf->getById(1); + $this->assertNotEmpty($task); + $this->assertEmpty($task['category_id']); + $this->assertEquals(1, $task['column_id']); + + // We create an event to move the task to the 2nd column + $event = array( + 'task_id' => 1, + 'column_id' => 1, + 'project_id' => 1, + 'category_id' => 1, + ); + + // Our event should be executed + $this->assertTrue($action->hasCompatibleEvent()); + $this->assertTrue($action->hasRequiredProject($event)); + $this->assertTrue($action->hasRequiredParameters($event)); + $this->assertTrue($action->hasRequiredCondition($event)); + $this->assertTrue($action->isExecutable($event)); + $this->assertTrue($action->execute(new GenericEvent($event))); + + // Our task should be moved to the other column + $task = $tf->getById(1); + $this->assertNotEmpty($task); + $this->assertEquals(3, $task['column_id']); + } +} diff --git a/tests/units/ActionTaskUpdateStartDateTest.php b/tests/units/ActionTaskUpdateStartDateTest.php index da36551d..4bf87452 100644 --- a/tests/units/ActionTaskUpdateStartDateTest.php +++ b/tests/units/ActionTaskUpdateStartDateTest.php @@ -38,7 +38,7 @@ class ActionTaskUpdateStartDateTest extends Base // Our event should be executed $this->assertTrue($action->execute(new GenericEvent($event))); - // Our task should be closed + // Our task should be updated $task = $tf->getById(1); $this->assertNotEmpty($task); $this->assertEquals(time(), $task['date_started'], '', 2); diff --git a/tests/units/Base.php b/tests/units/Base.php index 20f4a8cc..bc6518fb 100644 --- a/tests/units/Base.php +++ b/tests/units/Base.php @@ -11,10 +11,27 @@ use SimpleLogger\File; date_default_timezone_set('UTC'); +class FakeEmailClient +{ + public $email; + public $name; + public $subject; + public $html; + + public function send($email, $name, $subject, $html) + { + $this->email = $email; + $this->name = $name; + $this->subject = $subject; + $this->html = $html; + } +} + class FakeHttpClient { private $url = ''; private $data = array(); + private $headers = array(); public function getUrl() { @@ -26,16 +43,29 @@ class FakeHttpClient return $this->data; } + public function getHeaders() + { + return $this->headers; + } + public function toPrettyJson() { return json_encode($this->data, JSON_PRETTY_PRINT); } - public function post($url, array $data) + public function postJson($url, array $data, array $headers = array()) + { + $this->url = $url; + $this->data = $data; + $this->headers = $headers; + return true; + } + + public function postForm($url, array $data, array $headers = array()) { $this->url = $url; $this->data = $data; - //echo $this->toPrettyJson(); + $this->headers = $headers; return true; } } @@ -73,6 +103,7 @@ abstract class Base extends PHPUnit_Framework_TestCase $this->container['logger'] = new Logger; $this->container['logger']->setLogger(new File('/dev/null')); $this->container['httpClient'] = new FakeHttpClient; + $this->container['emailClient'] = new FakeEmailClient; } public function tearDown() diff --git a/tests/units/FileTest.php b/tests/units/FileTest.php index 5e882fdb..4ea7f386 100644 --- a/tests/units/FileTest.php +++ b/tests/units/FileTest.php @@ -9,6 +9,36 @@ use Model\Project; class FileTest extends Base { + public function testCreation() + { + $p = new Project($this->container); + $f = new File($this->container); + $tc = new TaskCreation($this->container); + + $this->assertEquals(1, $p->create(array('name' => 'test'))); + $this->assertEquals(1, $tc->create(array('project_id' => 1, 'title' => 'test'))); + + $this->assertEquals(1, $f->create(1, 'test', '/tmp/foo', 10)); + + $file = $f->getById(1); + $this->assertNotEmpty($file); + $this->assertEquals('test', $file['name']); + $this->assertEquals('/tmp/foo', $file['path']); + $this->assertEquals(0, $file['is_image']); + $this->assertEquals(1, $file['task_id']); + $this->assertEquals(time(), $file['date'], '', 2); + $this->assertEquals(0, $file['user_id']); + $this->assertEquals(10, $file['size']); + + $this->assertEquals(2, $f->create(1, 'test2.png', '/tmp/foobar', 10)); + + $file = $f->getById(2); + $this->assertNotEmpty($file); + $this->assertEquals('test2.png', $file['name']); + $this->assertEquals('/tmp/foobar', $file['path']); + $this->assertEquals(1, $file['is_image']); + } + public function testCreationFileNameTooLong() { $p = new Project($this->container); @@ -18,8 +48,8 @@ class FileTest extends Base $this->assertEquals(1, $p->create(array('name' => 'test'))); $this->assertEquals(1, $tc->create(array('project_id' => 1, 'title' => 'test'))); - $this->assertTrue($f->create(1, 'test', '/tmp/foo', false, 10)); - $this->assertTrue($f->create(1, str_repeat('a', 1000), '/tmp/foo', false, 10)); + $this->assertNotFalse($f->create(1, 'test', '/tmp/foo', 10)); + $this->assertNotFalse($f->create(1, str_repeat('a', 1000), '/tmp/foo', 10)); $files = $f->getAll(1); $this->assertNotEmpty($files); @@ -28,4 +58,133 @@ class FileTest extends Base $this->assertEquals(str_repeat('a', 255), $files[0]['name']); $this->assertEquals('test', $files[1]['name']); } + + public function testIsImage() + { + $f = new File($this->container); + + $this->assertTrue($f->isImage('test.png')); + $this->assertTrue($f->isImage('test.jpeg')); + $this->assertTrue($f->isImage('test.gif')); + $this->assertTrue($f->isImage('test.jpg')); + $this->assertTrue($f->isImage('test.JPG')); + + $this->assertFalse($f->isImage('test.bmp')); + $this->assertFalse($f->isImage('test')); + $this->assertFalse($f->isImage('test.pdf')); + } + + public function testGeneratePath() + { + $f = new File($this->container); + + $this->assertStringStartsWith('12/34/', $f->generatePath(12, 34, 'test.png')); + $this->assertNotEquals($f->generatePath(12, 34, 'test1.png'), $f->generatePath(12, 34, 'test2.png')); + } + + public function testUploadScreenshot() + { + $p = new Project($this->container); + $f = new File($this->container); + $tc = new TaskCreation($this->container); + + $this->assertEquals(1, $p->create(array('name' => 'test'))); + $this->assertEquals(1, $tc->create(array('project_id' => 1, 'title' => 'test'))); + + $this->assertEquals(1, $f->uploadScreenshot(1, 1, base64_encode('image data'))); + + $file = $f->getById(1); + $this->assertNotEmpty($file); + $this->assertStringStartsWith('Screenshot taken ', $file['name']); + $this->assertStringStartsWith('1/1/', $file['path']); + $this->assertEquals(1, $file['is_image']); + $this->assertEquals(1, $file['task_id']); + $this->assertEquals(time(), $file['date'], '', 2); + $this->assertEquals(0, $file['user_id']); + $this->assertEquals(10, $file['size']); + } + + public function testUploadFileContent() + { + $p = new Project($this->container); + $f = new File($this->container); + $tc = new TaskCreation($this->container); + + $this->assertEquals(1, $p->create(array('name' => 'test'))); + $this->assertEquals(1, $tc->create(array('project_id' => 1, 'title' => 'test'))); + + $this->assertEquals(1, $f->uploadContent(1, 1, 'my file.pdf', base64_encode('file data'))); + + $file = $f->getById(1); + $this->assertNotEmpty($file); + $this->assertEquals('my file.pdf', $file['name']); + $this->assertStringStartsWith('1/1/', $file['path']); + $this->assertEquals(0, $file['is_image']); + $this->assertEquals(1, $file['task_id']); + $this->assertEquals(time(), $file['date'], '', 2); + $this->assertEquals(0, $file['user_id']); + $this->assertEquals(9, $file['size']); + } + + public function testGetAll() + { + $p = new Project($this->container); + $f = new File($this->container); + $tc = new TaskCreation($this->container); + + $this->assertEquals(1, $p->create(array('name' => 'test'))); + $this->assertEquals(1, $tc->create(array('project_id' => 1, 'title' => 'test'))); + + $this->assertEquals(1, $f->create(1, 'B.pdf', '/tmp/foo', 10)); + $this->assertEquals(2, $f->create(1, 'A.png', '/tmp/foo', 10)); + $this->assertEquals(3, $f->create(1, 'D.doc', '/tmp/foo', 10)); + $this->assertEquals(4, $f->create(1, 'C.JPG', '/tmp/foo', 10)); + + $files = $f->getAll(1); + $this->assertNotEmpty($files); + $this->assertCount(4, $files); + $this->assertEquals('A.png', $files[0]['name']); + $this->assertEquals('B.pdf', $files[1]['name']); + $this->assertEquals('C.JPG', $files[2]['name']); + $this->assertEquals('D.doc', $files[3]['name']); + + $files = $f->getAllImages(1); + $this->assertNotEmpty($files); + $this->assertCount(2, $files); + $this->assertEquals('A.png', $files[0]['name']); + $this->assertEquals('C.JPG', $files[1]['name']); + + $files = $f->getAllDocuments(1); + $this->assertNotEmpty($files); + $this->assertCount(2, $files); + $this->assertEquals('B.pdf', $files[0]['name']); + $this->assertEquals('D.doc', $files[1]['name']); + } + + public function testRemove() + { + $p = new Project($this->container); + $f = new File($this->container); + $tc = new TaskCreation($this->container); + + $this->assertEquals(1, $p->create(array('name' => 'test'))); + $this->assertEquals(1, $tc->create(array('project_id' => 1, 'title' => 'test'))); + + $this->assertEquals(1, $f->create(1, 'B.pdf', '/tmp/foo', 10)); + $this->assertEquals(2, $f->create(1, 'A.png', '/tmp/foo', 10)); + $this->assertEquals(3, $f->create(1, 'D.doc', '/tmp/foo', 10)); + + $this->assertTrue($f->remove(2)); + + $files = $f->getAll(1); + $this->assertNotEmpty($files); + $this->assertCount(2, $files); + $this->assertEquals('B.pdf', $files[0]['name']); + $this->assertEquals('D.doc', $files[1]['name']); + + $this->assertTrue($f->removeAll(1)); + + $files = $f->getAll(1); + $this->assertEmpty($files); + } } diff --git a/tests/units/MailgunWebhookTest.php b/tests/units/MailgunTest.php index c2745180..b33a66fe 100644 --- a/tests/units/MailgunWebhookTest.php +++ b/tests/units/MailgunTest.php @@ -2,18 +2,38 @@ require_once __DIR__.'/Base.php'; -use Integration\MailgunWebhook; +use Integration\Mailgun; use Model\TaskCreation; use Model\TaskFinder; use Model\Project; use Model\ProjectPermission; use Model\User; -class MailgunWebhookTest extends Base +class MailgunTest extends Base { + public function testSendEmail() + { + $pm = new Mailgun($this->container); + $pm->sendEmail('test@localhost', 'Me', 'Test', 'Content', 'Bob'); + + $this->assertStringStartsWith('https://api.mailgun.net/v3/', $this->container['httpClient']->getUrl()); + + $data = $this->container['httpClient']->getData(); + + $this->assertArrayHasKey('from', $data); + $this->assertArrayHasKey('to', $data); + $this->assertArrayHasKey('subject', $data); + $this->assertArrayHasKey('html', $data); + + $this->assertEquals('Me <test@localhost>', $data['to']); + $this->assertEquals('Bob <notifications@kanboard.local>', $data['from']); + $this->assertEquals('Test', $data['subject']); + $this->assertEquals('Content', $data['html']); + } + public function testHandlePayload() { - $w = new MailgunWebhook($this->container); + $w = new Mailgun($this->container); $p = new Project($this->container); $pp = new ProjectPermission($this->container); $u = new User($this->container); @@ -26,20 +46,20 @@ class MailgunWebhookTest extends Base $this->assertEquals(2, $p->create(array('name' => 'test2', 'identifier' => 'TEST1'))); // Empty payload - $this->assertFalse($w->parsePayload(array())); + $this->assertFalse($w->receiveEmail(array())); // Unknown user - $this->assertFalse($w->parsePayload(array('sender' => 'a@b.c', 'subject' => 'Email task', 'recipient' => 'foobar', 'stripped-text' => 'boo'))); + $this->assertFalse($w->receiveEmail(array('sender' => 'a@b.c', 'subject' => 'Email task', 'recipient' => 'foobar', 'stripped-text' => 'boo'))); // Project not found - $this->assertFalse($w->parsePayload(array('sender' => 'me@localhost', 'subject' => 'Email task', 'recipient' => 'foo+test@localhost', 'stripped-text' => 'boo'))); + $this->assertFalse($w->receiveEmail(array('sender' => 'me@localhost', 'subject' => 'Email task', 'recipient' => 'foo+test@localhost', 'stripped-text' => 'boo'))); // User is not member - $this->assertFalse($w->parsePayload(array('sender' => 'me@localhost', 'subject' => 'Email task', 'recipient' => 'foo+test1@localhost', 'stripped-text' => 'boo'))); + $this->assertFalse($w->receiveEmail(array('sender' => 'me@localhost', 'subject' => 'Email task', 'recipient' => 'foo+test1@localhost', 'stripped-text' => 'boo'))); $this->assertTrue($pp->addMember(2, 2)); // The task must be created - $this->assertTrue($w->parsePayload(array('sender' => 'me@localhost', 'subject' => 'Email task', 'recipient' => 'foo+test1@localhost', 'stripped-text' => 'boo'))); + $this->assertTrue($w->receiveEmail(array('sender' => 'me@localhost', 'subject' => 'Email task', 'recipient' => 'foo+test1@localhost', 'stripped-text' => 'boo'))); $task = $tf->getById(1); $this->assertNotEmpty($task); diff --git a/tests/units/NotificationTest.php b/tests/units/NotificationTest.php index 37285212..92b839c4 100644 --- a/tests/units/NotificationTest.php +++ b/tests/units/NotificationTest.php @@ -2,14 +2,168 @@ require_once __DIR__.'/Base.php'; +use Model\TaskFinder; +use Model\TaskCreation; +use Model\Subtask; +use Model\Comment; use Model\User; +use Model\File; use Model\Project; use Model\ProjectPermission; use Model\Notification; +use Subscriber\NotificationSubscriber; class NotificationTest extends Base { - public function testGetUsersWithNotification() + public function testFilterNone() + { + $u = new User($this->container); + $n = new Notification($this->container); + + $this->assertEquals(2, $u->create(array('username' => 'user1', 'notifications_filter' => Notification::FILTER_NONE))); + $this->assertTrue($n->filterNone($u->getById(2), array())); + + $this->assertEquals(3, $u->create(array('username' => 'user2', 'notifications_filter' => Notification::FILTER_BOTH))); + $this->assertFalse($n->filterNone($u->getById(3), array())); + } + + public function testFilterCreator() + { + $u = new User($this->container); + $n = new Notification($this->container); + + $this->assertEquals(2, $u->create(array('username' => 'user1', 'notifications_filter' => Notification::FILTER_CREATOR))); + $this->assertTrue($n->filterCreator($u->getById(2), array('task' => array('creator_id' => 2)))); + + $this->assertEquals(3, $u->create(array('username' => 'user2', 'notifications_filter' => Notification::FILTER_CREATOR))); + $this->assertFalse($n->filterCreator($u->getById(3), array('task' => array('creator_id' => 1)))); + + $this->assertEquals(4, $u->create(array('username' => 'user3', 'notifications_filter' => Notification::FILTER_NONE))); + $this->assertFalse($n->filterCreator($u->getById(4), array('task' => array('creator_id' => 2)))); + } + + public function testFilterAssignee() + { + $u = new User($this->container); + $n = new Notification($this->container); + + $this->assertEquals(2, $u->create(array('username' => 'user1', 'notifications_filter' => Notification::FILTER_ASSIGNEE))); + $this->assertTrue($n->filterAssignee($u->getById(2), array('task' => array('owner_id' => 2)))); + + $this->assertEquals(3, $u->create(array('username' => 'user2', 'notifications_filter' => Notification::FILTER_ASSIGNEE))); + $this->assertFalse($n->filterAssignee($u->getById(3), array('task' => array('owner_id' => 1)))); + + $this->assertEquals(4, $u->create(array('username' => 'user3', 'notifications_filter' => Notification::FILTER_NONE))); + $this->assertFalse($n->filterAssignee($u->getById(4), array('task' => array('owner_id' => 2)))); + } + + public function testFilterBoth() + { + $u = new User($this->container); + $n = new Notification($this->container); + + $this->assertEquals(2, $u->create(array('username' => 'user1', 'notifications_filter' => Notification::FILTER_BOTH))); + $this->assertTrue($n->filterBoth($u->getById(2), array('task' => array('owner_id' => 2, 'creator_id' => 1)))); + $this->assertTrue($n->filterBoth($u->getById(2), array('task' => array('owner_id' => 0, 'creator_id' => 2)))); + + $this->assertEquals(3, $u->create(array('username' => 'user2', 'notifications_filter' => Notification::FILTER_BOTH))); + $this->assertFalse($n->filterBoth($u->getById(3), array('task' => array('owner_id' => 1, 'creator_id' => 1)))); + $this->assertFalse($n->filterBoth($u->getById(3), array('task' => array('owner_id' => 2, 'creator_id' => 1)))); + + $this->assertEquals(4, $u->create(array('username' => 'user3', 'notifications_filter' => Notification::FILTER_NONE))); + $this->assertFalse($n->filterBoth($u->getById(4), array('task' => array('owner_id' => 2, 'creator_id' => 1)))); + } + + public function testFilterProject() + { + $u = new User($this->container); + $n = new Notification($this->container); + $p = new Project($this->container); + + $this->assertEquals(1, $p->create(array('name' => 'UnitTest1'))); + $this->assertEquals(2, $p->create(array('name' => 'UnitTest2'))); + + // No project selected + $this->assertTrue($n->filterProject($u->getById(1), array())); + + // User that select only some projects + $this->assertEquals(2, $u->create(array('username' => 'user2', 'notifications_filter' => Notification::FILTER_NONE))); + $n->saveSettings(2, array('notifications_enabled' => 1, 'projects' => array(2 => true))); + + $this->assertFalse($n->filterProject($u->getById(2), array('task' => array('project_id' => 1)))); + $this->assertTrue($n->filterProject($u->getById(2), array('task' => array('project_id' => 2)))); + } + + public function testFilterUserWithNoFilter() + { + $u = new User($this->container); + $n = new Notification($this->container); + $p = new Project($this->container); + + $this->assertEquals(2, $u->create(array('username' => 'user2', 'notifications_filter' => Notification::FILTER_NONE))); + + $this->assertTrue($n->shouldReceiveNotification($u->getById(2), array('task' => array('project_id' => 1)))); + } + + public function testFilterUserWithAssigneeFilter() + { + $u = new User($this->container); + $n = new Notification($this->container); + $p = new Project($this->container); + + $this->assertEquals(2, $u->create(array('username' => 'user2', 'notifications_filter' => Notification::FILTER_ASSIGNEE))); + + $this->assertTrue($n->shouldReceiveNotification($u->getById(2), array('task' => array('project_id' => 1, 'owner_id' => 2)))); + $this->assertFalse($n->shouldReceiveNotification($u->getById(2), array('task' => array('project_id' => 1, 'owner_id' => 1)))); + } + + public function testFilterUserWithCreatorFilter() + { + $u = new User($this->container); + $n = new Notification($this->container); + $p = new Project($this->container); + + $this->assertEquals(2, $u->create(array('username' => 'user2', 'notifications_filter' => Notification::FILTER_CREATOR))); + + $this->assertTrue($n->shouldReceiveNotification($u->getById(2), array('task' => array('project_id' => 1, 'creator_id' => 2)))); + $this->assertFalse($n->shouldReceiveNotification($u->getById(2), array('task' => array('project_id' => 1, 'creator_id' => 1)))); + } + + public function testFilterUserWithBothFilter() + { + $u = new User($this->container); + $n = new Notification($this->container); + $p = new Project($this->container); + + $this->assertEquals(2, $u->create(array('username' => 'user2', 'notifications_filter' => Notification::FILTER_BOTH))); + + $this->assertTrue($n->shouldReceiveNotification($u->getById(2), array('task' => array('project_id' => 1, 'creator_id' => 2, 'owner_id' => 3)))); + $this->assertTrue($n->shouldReceiveNotification($u->getById(2), array('task' => array('project_id' => 1, 'creator_id' => 0, 'owner_id' => 2)))); + $this->assertFalse($n->shouldReceiveNotification($u->getById(2), array('task' => array('project_id' => 1, 'creator_id' => 4, 'owner_id' => 1)))); + $this->assertFalse($n->shouldReceiveNotification($u->getById(2), array('task' => array('project_id' => 1, 'creator_id' => 5, 'owner_id' => 0)))); + } + + public function testFilterUserWithBothFilterAndProjectSelected() + { + $u = new User($this->container); + $n = new Notification($this->container); + $p = new Project($this->container); + + $this->assertEquals(1, $p->create(array('name' => 'UnitTest1'))); + $this->assertEquals(2, $p->create(array('name' => 'UnitTest2'))); + + $this->assertEquals(2, $u->create(array('username' => 'user2', 'notifications_filter' => Notification::FILTER_BOTH))); + + $n->saveSettings(2, array('notifications_enabled' => 1, 'projects' => array(2 => true))); + + $this->assertFalse($n->shouldReceiveNotification($u->getById(2), array('task' => array('project_id' => 1, 'creator_id' => 2, 'owner_id' => 3)))); + $this->assertFalse($n->shouldReceiveNotification($u->getById(2), array('task' => array('project_id' => 1, 'creator_id' => 0, 'owner_id' => 2)))); + + $this->assertTrue($n->shouldReceiveNotification($u->getById(2), array('task' => array('project_id' => 2, 'creator_id' => 2, 'owner_id' => 3)))); + $this->assertTrue($n->shouldReceiveNotification($u->getById(2), array('task' => array('project_id' => 2, 'creator_id' => 0, 'owner_id' => 2)))); + } + + public function testGetProjectMembersWithNotifications() { $u = new User($this->container); $p = new Project($this->container); @@ -32,7 +186,7 @@ class NotificationTest extends Base // Nobody is member of any projects $this->assertEmpty($pp->getMembers(1)); - $this->assertEmpty($n->getUsersWithNotification(1)); + $this->assertEmpty($n->getUsersWithNotificationEnabled(1)); // We allow all users to be member of our projects $this->assertTrue($pp->addMember(1, 1)); @@ -41,7 +195,7 @@ class NotificationTest extends Base $this->assertTrue($pp->addMember(1, 4)); $this->assertNotEmpty($pp->getMembers(1)); - $users = $n->getUsersWithNotification(1); + $users = $n->getUsersWithNotificationEnabled(1); $this->assertNotEmpty($users); $this->assertEquals(2, count($users)); @@ -49,16 +203,15 @@ class NotificationTest extends Base $this->assertEquals('user3@here', $users[1]['email']); } - public function testGetUserList() + public function testGetUsersWithNotificationsWhenEverybodyAllowed() { $u = new User($this->container); $p = new Project($this->container); - $pp = new ProjectPermission($this->container); $n = new Notification($this->container); + $pp = new ProjectPermission($this->container); - $this->assertEquals(1, $p->create(array('name' => 'UnitTest1'))); - $this->assertEquals(2, $p->create(array('name' => 'UnitTest2'))); - $this->assertEquals(3, $p->create(array('name' => 'UnitTest3', 'is_everybody_allowed' => 1))); + $this->assertEquals(1, $p->create(array('name' => 'UnitTest1', 'is_everybody_allowed' => 1))); + $this->assertTrue($pp->isEverybodyAllowed(1)); // Email + Notifications enabled $this->assertNotFalse($u->create(array('username' => 'user1', 'email' => 'user1@here', 'notifications_enabled' => 1))); @@ -72,62 +225,108 @@ class NotificationTest extends Base // No email + notifications disabled $this->assertNotFalse($u->create(array('username' => 'user4'))); - // We allow all users to be member of our projects - $this->assertTrue($pp->addMember(1, 1)); - $this->assertTrue($pp->addMember(1, 2)); - $this->assertTrue($pp->addMember(1, 3)); - $this->assertTrue($pp->addMember(1, 4)); - - $this->assertTrue($pp->addMember(2, 1)); - $this->assertTrue($pp->addMember(2, 2)); - $this->assertTrue($pp->addMember(2, 3)); - $this->assertTrue($pp->addMember(2, 4)); + $users = $n->getUsersWithNotificationEnabled(1); - $users = $n->getUsersList(1); $this->assertNotEmpty($users); $this->assertEquals(2, count($users)); $this->assertEquals('user1@here', $users[0]['email']); $this->assertEquals('user3@here', $users[1]['email']); + } - $users = $n->getUsersList(2); - $this->assertNotEmpty($users); - $this->assertEquals(2, count($users)); - $this->assertEquals('user1@here', $users[0]['email']); - $this->assertEquals('user3@here', $users[1]['email']); + public function testGetMailContent() + { + $n = new Notification($this->container); + $p = new Project($this->container); + $tf = new TaskFinder($this->container); + $tc = new TaskCreation($this->container); + $s = new Subtask($this->container); + $c = new Comment($this->container); + $f = new File($this->container); - // User 3 choose to receive notification only for project 2 - $n->saveSettings(4, array('notifications_enabled' => 1, 'projects' => array(2 => true))); + $this->assertEquals(1, $p->create(array('name' => 'test'))); + $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1))); + $this->assertEquals(1, $s->create(array('title' => 'test', 'task_id' => 1))); + $this->assertEquals(1, $c->create(array('comment' => 'test', 'task_id' => 1, 'user_id' => 1))); + $this->assertEquals(1, $f->create(1, 'test', 'blah', 123)); - $users = $n->getUsersList(1); - $this->assertNotEmpty($users); - $this->assertEquals(1, count($users)); - $this->assertEquals('user1@here', $users[0]['email']); + $task = $tf->getDetails(1); + $subtask = $s->getById(1, true); + $comment = $c->getById(1); + $file = $c->getById(1); - $users = $n->getUsersList(2); - $this->assertNotEmpty($users); - $this->assertEquals(2, count($users)); - $this->assertEquals('user1@here', $users[0]['email']); - $this->assertEquals('user3@here', $users[1]['email']); + $this->assertNotEmpty($task); + $this->assertNotEmpty($subtask); + $this->assertNotEmpty($comment); + $this->assertNotEmpty($file); - // User 1 excluded - $users = $n->getUsersList(1, array(2)); - $this->assertEmpty($users); + foreach (Subscriber\NotificationSubscriber::getSubscribedEvents() as $event => $values) { + $this->assertNotEmpty($n->getMailContent($event, array('task' => $task, 'comment' => $comment, 'subtask' => $subtask, 'file' => $file, 'changes' => array()))); + } + } - $users = $n->getUsersList(2, array(2)); - $this->assertNotEmpty($users); - $this->assertEquals(1, count($users)); - $this->assertEquals('user3@here', $users[0]['email']); + public function testGetEmailSubject() + { + $n = new Notification($this->container); - // Project #3 allow everybody - $users = $n->getUsersList(3); - $this->assertNotEmpty($users); - $this->assertEquals(1, count($users)); - $this->assertEquals('user1@here', $users[0]['email']); + $this->assertEquals( + '[test][Task opened] blah (#2)', + $n->getMailSubject('task.open', array('task' => array('id' => 2, 'title' => 'blah', 'project_name' => 'test'))) + ); + } - $users = $n->getUsersWithNotification(3); - $this->assertNotEmpty($users); - $this->assertEquals(2, count($users)); - $this->assertEquals('user1@here', $users[0]['email']); - $this->assertEquals('user3@here', $users[1]['email']); + public function testSendNotificationsToCreator() + { + $u = new User($this->container); + $p = new Project($this->container); + $n = new Notification($this->container); + $pp = new ProjectPermission($this->container); + + $this->assertEquals(1, $p->create(array('name' => 'UnitTest1'))); + $this->assertEquals(2, $u->create(array('username' => 'user1', 'email' => 'user1@here', 'notifications_enabled' => 1))); + $this->assertTrue($pp->addMember(1, 2)); + + $n->sendNotifications('task.open', array('task' => array( + 'id' => 2, 'title' => 'blah', 'project_name' => 'test', 'project_id' => 1, 'owner_id' => 0, 'creator_id' => 2 + ))); + + $this->assertEquals('user1@here', $this->container['emailClient']->email); + $this->assertEquals('user1', $this->container['emailClient']->name); + $this->assertEquals('[test][Task opened] blah (#2)', $this->container['emailClient']->subject); + $this->assertNotEmpty($this->container['emailClient']->html); + } + + public function testSendNotificationsToAnotherAssignee() + { + $u = new User($this->container); + $p = new Project($this->container); + $n = new Notification($this->container); + $pp = new ProjectPermission($this->container); + + $this->assertEquals(1, $p->create(array('name' => 'UnitTest1'))); + $this->assertEquals(2, $u->create(array('username' => 'user1', 'email' => 'user1@here', 'notifications_enabled' => 1))); + $this->assertTrue($pp->addMember(1, 2)); + + $n->sendNotifications('task.open', array('task' => array( + 'id' => 2, 'title' => 'blah', 'project_name' => 'test', 'project_id' => 1, 'owner_id' => 1, 'creator_id' => 1 + ))); + + $this->assertEmpty($this->container['emailClient']->email); + } + + public function testSendNotificationsToNotMember() + { + $u = new User($this->container); + $p = new Project($this->container); + $n = new Notification($this->container); + $pp = new ProjectPermission($this->container); + + $this->assertEquals(1, $p->create(array('name' => 'UnitTest1'))); + $this->assertEquals(2, $u->create(array('username' => 'user1', 'email' => 'user1@here', 'notifications_enabled' => 1))); + + $n->sendNotifications('task.open', array('task' => array( + 'id' => 2, 'title' => 'blah', 'project_name' => 'test', 'project_id' => 1, 'owner_id' => 0, 'creator_id' => 2 + ))); + + $this->assertEmpty($this->container['emailClient']->email); } } diff --git a/tests/units/PostmarkWebhookTest.php b/tests/units/PostmarkTest.php index 34be8515..b708217d 100644 --- a/tests/units/PostmarkWebhookTest.php +++ b/tests/units/PostmarkTest.php @@ -2,18 +2,41 @@ require_once __DIR__.'/Base.php'; -use Integration\PostmarkWebhook; +use Integration\Postmark; use Model\TaskCreation; use Model\TaskFinder; use Model\Project; use Model\ProjectPermission; use Model\User; -class PostmarkWebhookTest extends Base +class PostmarkTest extends Base { + public function testSendEmail() + { + $pm = new Postmark($this->container); + $pm->sendEmail('test@localhost', 'Me', 'Test', 'Content', 'Bob'); + + $this->assertEquals('https://api.postmarkapp.com/email', $this->container['httpClient']->getUrl()); + + $data = $this->container['httpClient']->getData(); + + $this->assertArrayHasKey('From', $data); + $this->assertArrayHasKey('To', $data); + $this->assertArrayHasKey('Subject', $data); + $this->assertArrayHasKey('HtmlBody', $data); + + $this->assertEquals('Me <test@localhost>', $data['To']); + $this->assertEquals('Bob <notifications@kanboard.local>', $data['From']); + $this->assertEquals('Test', $data['Subject']); + $this->assertEquals('Content', $data['HtmlBody']); + + $this->assertContains('Accept: application/json', $this->container['httpClient']->getHeaders()); + $this->assertContains('X-Postmark-Server-Token: ', $this->container['httpClient']->getHeaders()); + } + public function testHandlePayload() { - $w = new PostmarkWebhook($this->container); + $w = new Postmark($this->container); $p = new Project($this->container); $pp = new ProjectPermission($this->container); $u = new User($this->container); @@ -26,20 +49,20 @@ class PostmarkWebhookTest extends Base $this->assertEquals(2, $p->create(array('name' => 'test2', 'identifier' => 'TEST1'))); // Empty payload - $this->assertFalse($w->parsePayload(array())); + $this->assertFalse($w->receiveEmail(array())); // Unknown user - $this->assertFalse($w->parsePayload(array('From' => 'a@b.c', 'Subject' => 'Email task', 'MailboxHash' => 'foobar', 'TextBody' => 'boo'))); + $this->assertFalse($w->receiveEmail(array('From' => 'a@b.c', 'Subject' => 'Email task', 'MailboxHash' => 'foobar', 'TextBody' => 'boo'))); // Project not found - $this->assertFalse($w->parsePayload(array('From' => 'me@localhost', 'Subject' => 'Email task', 'MailboxHash' => 'test', 'TextBody' => 'boo'))); + $this->assertFalse($w->receiveEmail(array('From' => 'me@localhost', 'Subject' => 'Email task', 'MailboxHash' => 'test', 'TextBody' => 'boo'))); // User is not member - $this->assertFalse($w->parsePayload(array('From' => 'me@localhost', 'Subject' => 'Email task', 'MailboxHash' => 'test1', 'TextBody' => 'boo'))); + $this->assertFalse($w->receiveEmail(array('From' => 'me@localhost', 'Subject' => 'Email task', 'MailboxHash' => 'test1', 'TextBody' => 'boo'))); $this->assertTrue($pp->addMember(2, 2)); // The task must be created - $this->assertTrue($w->parsePayload(array('From' => 'me@localhost', 'Subject' => 'Email task', 'MailboxHash' => 'test1', 'TextBody' => 'boo'))); + $this->assertTrue($w->receiveEmail(array('From' => 'me@localhost', 'Subject' => 'Email task', 'MailboxHash' => 'test1', 'TextBody' => 'boo'))); $task = $tf->getById(1); $this->assertNotEmpty($task); @@ -51,7 +74,7 @@ class PostmarkWebhookTest extends Base public function testHtml2Markdown() { - $w = new PostmarkWebhook($this->container); + $w = new Postmark($this->container); $p = new Project($this->container); $pp = new ProjectPermission($this->container); $u = new User($this->container); @@ -62,7 +85,7 @@ class PostmarkWebhookTest extends Base $this->assertEquals(1, $p->create(array('name' => 'test2', 'identifier' => 'TEST1'))); $this->assertTrue($pp->addMember(1, 2)); - $this->assertTrue($w->parsePayload(array('From' => 'me@localhost', 'Subject' => 'Email task', 'MailboxHash' => 'test1', 'TextBody' => 'boo', 'HtmlBody' => '<p><strong>boo</strong></p>'))); + $this->assertTrue($w->receiveEmail(array('From' => 'me@localhost', 'Subject' => 'Email task', 'MailboxHash' => 'test1', 'TextBody' => 'boo', 'HtmlBody' => '<p><strong>boo</strong></p>'))); $task = $tf->getById(1); $this->assertNotEmpty($task); @@ -71,7 +94,7 @@ class PostmarkWebhookTest extends Base $this->assertEquals('**boo**', $task['description']); $this->assertEquals(2, $task['creator_id']); - $this->assertTrue($w->parsePayload(array('From' => 'me@localhost', 'Subject' => 'Email task', 'MailboxHash' => 'test1', 'TextBody' => '**boo**', 'HtmlBody' => ''))); + $this->assertTrue($w->receiveEmail(array('From' => 'me@localhost', 'Subject' => 'Email task', 'MailboxHash' => 'test1', 'TextBody' => '**boo**', 'HtmlBody' => ''))); $task = $tf->getById(2); $this->assertNotEmpty($task); diff --git a/tests/units/SendgridWebhookTest.php b/tests/units/SendgridTest.php index 3b30d212..1814c761 100644 --- a/tests/units/SendgridWebhookTest.php +++ b/tests/units/SendgridTest.php @@ -2,18 +2,46 @@ require_once __DIR__.'/Base.php'; -use Integration\SendgridWebhook; +use Integration\Sendgrid; use Model\TaskCreation; use Model\TaskFinder; use Model\Project; use Model\ProjectPermission; use Model\User; -class SendgridWebhookTest extends Base +class SendgridTest extends Base { + public function testSendEmail() + { + $pm = new Sendgrid($this->container); + $pm->sendEmail('test@localhost', 'Me', 'Test', 'Content', 'Bob'); + + $this->assertEquals('https://api.sendgrid.com/api/mail.send.json', $this->container['httpClient']->getUrl()); + + $data = $this->container['httpClient']->getData(); + + $this->assertArrayHasKey('api_user', $data); + $this->assertArrayHasKey('api_key', $data); + $this->assertArrayHasKey('from', $data); + $this->assertArrayHasKey('fromname', $data); + $this->assertArrayHasKey('to', $data); + $this->assertArrayHasKey('toname', $data); + $this->assertArrayHasKey('subject', $data); + $this->assertArrayHasKey('html', $data); + + $this->assertEquals('test@localhost', $data['to']); + $this->assertEquals('Me', $data['toname']); + $this->assertEquals('notifications@kanboard.local', $data['from']); + $this->assertEquals('Bob', $data['fromname']); + $this->assertEquals('Test', $data['subject']); + $this->assertEquals('Content', $data['html']); + $this->assertEquals('', $data['api_key']); + $this->assertEquals('', $data['api_user']); + } + public function testHandlePayload() { - $w = new SendgridWebhook($this->container); + $w = new Sendgrid($this->container); $p = new Project($this->container); $pp = new ProjectPermission($this->container); $u = new User($this->container); @@ -26,22 +54,22 @@ class SendgridWebhookTest extends Base $this->assertEquals(2, $p->create(array('name' => 'test2', 'identifier' => 'TEST1'))); // Empty payload - $this->assertFalse($w->parsePayload(array())); + $this->assertFalse($w->receiveEmail(array())); // Unknown user - $this->assertFalse($w->parsePayload(array( + $this->assertFalse($w->receiveEmail(array( 'envelope' => '{"to":["a@b.c"],"from":"a.b.c"}', 'subject' => 'Email task' ))); // Project not found - $this->assertFalse($w->parsePayload(array( + $this->assertFalse($w->receiveEmail(array( 'envelope' => '{"to":["a@b.c"],"from":"me@localhost"}', 'subject' => 'Email task' ))); // User is not member - $this->assertFalse($w->parsePayload(array( + $this->assertFalse($w->receiveEmail(array( 'envelope' => '{"to":["something+test1@localhost"],"from":"me@localhost"}', 'subject' => 'Email task' ))); @@ -49,7 +77,7 @@ class SendgridWebhookTest extends Base $this->assertTrue($pp->addMember(2, 2)); // The task must be created - $this->assertTrue($w->parsePayload(array( + $this->assertTrue($w->receiveEmail(array( 'envelope' => '{"to":["something+test1@localhost"],"from":"me@localhost"}', 'subject' => 'Email task' ))); @@ -62,7 +90,7 @@ class SendgridWebhookTest extends Base $this->assertEquals(2, $task['creator_id']); // Html content - $this->assertTrue($w->parsePayload(array( + $this->assertTrue($w->receiveEmail(array( 'envelope' => '{"to":["something+test1@localhost"],"from":"me@localhost"}', 'subject' => 'Email task', 'html' => '<strong>bold</strong> text', @@ -76,7 +104,7 @@ class SendgridWebhookTest extends Base $this->assertEquals(2, $task['creator_id']); // Text content - $this->assertTrue($w->parsePayload(array( + $this->assertTrue($w->receiveEmail(array( 'envelope' => '{"to":["something+test1@localhost"],"from":"me@localhost"}', 'subject' => 'Email task', 'text' => '**bold** text', @@ -90,7 +118,7 @@ class SendgridWebhookTest extends Base $this->assertEquals(2, $task['creator_id']); // Text + html content - $this->assertTrue($w->parsePayload(array( + $this->assertTrue($w->receiveEmail(array( 'envelope' => '{"to":["something+test1@localhost"],"from":"me@localhost"}', 'subject' => 'Email task', 'html' => '<strong>bold</strong> html', diff --git a/tests/units/TaskDuplicationTest.php b/tests/units/TaskDuplicationTest.php index f991efd6..cd791312 100644 --- a/tests/units/TaskDuplicationTest.php +++ b/tests/units/TaskDuplicationTest.php @@ -548,7 +548,7 @@ class TaskDuplicationTest extends Base $this->assertNotEmpty($task); $this->assertEquals(Task::RECURRING_STATUS_PROCESSED, $task['recurrence_status']); $this->assertEquals(2, $task['recurrence_child']); - $this->assertEquals(1436561776, $task['date_due']); + $this->assertEquals(1436561776, $task['date_due'], '', 2); $task = $tf->getById(2); $this->assertNotEmpty($task); @@ -558,6 +558,6 @@ class TaskDuplicationTest extends Base $this->assertEquals(Task::RECURRING_BASEDATE_TRIGGERDATE, $task['recurrence_basedate']); $this->assertEquals(1, $task['recurrence_parent']); $this->assertEquals(2, $task['recurrence_factor']); - $this->assertEquals(strtotime('+2 days'), $task['date_due']); + $this->assertEquals(strtotime('+2 days'), $task['date_due'], '', 2); } } diff --git a/tests/units/TextHelperTest.php b/tests/units/TextHelperTest.php index 20b89fa8..01652d5c 100644 --- a/tests/units/TextHelperTest.php +++ b/tests/units/TextHelperTest.php @@ -45,6 +45,8 @@ class TextHelperTest extends Base $this->assertEquals('abc', $h->truncate('abc')); $this->assertEquals(str_repeat('a', 85).' [...]', $h->truncate(str_repeat('a', 200))); + + $this->assertEquals('Настольная рекл [...]', $h->truncate('Настольная реклама в фудкорте ГЧ', 15)); } public function testContains() |