summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CONTRIBUTORS.md84
-rw-r--r--README.markdown239
-rw-r--r--app/Action/Base.php9
-rw-r--r--app/Action/TaskMoveColumnAssigned.php3
-rw-r--r--app/Action/TaskMoveColumnCategoryChange.php89
-rw-r--r--app/Action/TaskMoveColumnUnAssigned.php5
-rw-r--r--app/Api/File.php9
-rw-r--r--app/Api/Task.php11
-rw-r--r--app/Console/TaskOverdueNotification.php20
-rw-r--r--app/Controller/Base.php12
-rw-r--r--app/Controller/File.php2
-rw-r--r--app/Controller/Ical.php1
-rw-r--r--app/Controller/User.php6
-rw-r--r--app/Controller/Webhook.php43
-rw-r--r--app/Core/Base.php6
-rw-r--r--app/Core/EmailClient.php49
-rw-r--r--app/Core/HttpClient.php75
-rw-r--r--app/Helper/Task.php5
-rw-r--r--app/Helper/Text.php4
-rw-r--r--app/Integration/HipchatWebhook.php2
-rw-r--r--app/Integration/Mailgun.php (renamed from app/Integration/MailgunWebhook.php)38
-rw-r--r--app/Integration/Postmark.php (renamed from app/Integration/PostmarkWebhook.php)39
-rw-r--r--app/Integration/Sendgrid.php (renamed from app/Integration/SendgridWebhook.php)32
-rw-r--r--app/Integration/SlackWebhook.php2
-rw-r--r--app/Integration/Smtp.php71
-rw-r--r--app/Locale/es_ES/translations.php64
-rw-r--r--app/Locale/ru_RU/translations.php388
-rw-r--r--app/Locale/th_TH/translations.php630
-rw-r--r--app/Model/Action.php1
-rw-r--r--app/Model/Base.php19
-rw-r--r--app/Model/File.php134
-rw-r--r--app/Model/Notification.php377
-rw-r--r--app/Model/ProjectActivity.php5
-rw-r--r--app/Model/ProjectPermission.php4
-rw-r--r--app/Model/SubtaskTimeTracking.php3
-rw-r--r--app/Model/Task.php1
-rw-r--r--app/Model/TaskFinder.php4
-rw-r--r--app/Model/TaskModification.php25
-rw-r--r--app/Model/Webhook.php2
-rw-r--r--app/Schema/Mysql.php7
-rw-r--r--app/Schema/Postgres.php7
-rw-r--r--app/Schema/Sqlite.php7
-rw-r--r--app/ServiceProvider/ClassProvider.php8
-rw-r--r--app/ServiceProvider/MailerProvider.php33
-rw-r--r--app/Subscriber/NotificationSubscriber.php29
-rw-r--r--app/Subscriber/ProjectActivitySubscriber.php2
-rw-r--r--app/Template/action/index.php17
-rw-r--r--app/Template/board/filters.php6
-rw-r--r--app/Template/event/events.php2
-rw-r--r--app/Template/event/task_move_swimlane.php19
-rw-r--r--app/Template/event/task_update.php5
-rw-r--r--app/Template/layout.php5
-rw-r--r--app/Template/notification/comment_create.php (renamed from app/Template/notification/comment_creation.php)0
-rw-r--r--app/Template/notification/file_create.php (renamed from app/Template/notification/file_creation.php)0
-rw-r--r--app/Template/notification/footer.php3
-rw-r--r--app/Template/notification/subtask_create.php (renamed from app/Template/notification/subtask_creation.php)0
-rw-r--r--app/Template/notification/task_create.php (renamed from app/Template/notification/task_creation.php)6
-rw-r--r--app/Template/notification/task_move_swimlane.php19
-rw-r--r--app/Template/notification/task_overdue.php (renamed from app/Template/notification/task_due.php)4
-rw-r--r--app/Template/notification/task_update.php41
-rw-r--r--app/Template/task/changes.php78
-rw-r--r--app/Template/task/edit.php26
-rw-r--r--app/Template/task/new.php32
-rw-r--r--app/Template/user/notifications.php16
-rw-r--r--app/check_setup.php12
-rw-r--r--app/common.php2
-rw-r--r--app/constants.php5
-rw-r--r--assets/css/app.css17
-rw-r--r--assets/css/src/activity.css11
-rw-r--r--assets/css/src/header.css5
-rw-r--r--composer.lock43
-rw-r--r--config.default.php19
-rw-r--r--docs/api-json-rpc.markdown106
-rw-r--r--docs/email-configuration.markdown77
-rw-r--r--docs/index.markdown118
-rw-r--r--docs/webhooks.markdown9
-rw-r--r--index.php1
-rw-r--r--tests/functionals.sqlite.xml2
-rw-r--r--tests/functionals/ApiTest.php42
-rw-r--r--tests/units/ActionTaskMoveColumnCategoryChangeTest.php61
-rw-r--r--tests/units/ActionTaskUpdateStartDateTest.php2
-rw-r--r--tests/units/Base.php35
-rw-r--r--tests/units/FileTest.php163
-rw-r--r--tests/units/MailgunTest.php (renamed from tests/units/MailgunWebhookTest.php)36
-rw-r--r--tests/units/NotificationTest.php303
-rw-r--r--tests/units/PostmarkTest.php (renamed from tests/units/PostmarkWebhookTest.php)45
-rw-r--r--tests/units/SendgridTest.php (renamed from tests/units/SendgridWebhookTest.php)50
-rw-r--r--tests/units/TaskDuplicationTest.php4
-rw-r--r--tests/units/TextHelperTest.php2
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)
[![Build Status](https://travis-ci.org/fguillot/kanboard.svg)](https://travis-ci.org/fguillot/kanboard)
@@ -18,32 +20,26 @@ Official website: <http://kanboard.net>
[![Deploy](https://www.herokucdn.com/deploy/button.png)](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') === '&amp;') {
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
diff --git a/index.php b/index.php
index f146c6d1..4c49416f 100644
--- a/index.php
+++ b/index.php
@@ -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()